Compare commits

..

47 Commits

Author SHA1 Message Date
portainer-bot[bot] aae5e533c6 chore(version): Bump 2.39.1 to 2.39.2 (#2585)
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
2026-05-07 02:51:53 +00:00
Oscar Zhou d54ccd5502 fix(ecr): prevent deadlock on ECR token refresh during stack deployment [BE-12842] (#2544) 2026-05-06 15:55:57 +12:00
Devon Steenberg 7e526c4df7 fix(libstack): pull images sequentially and respect COMPOSE_PARALLEL_LIMIT [BE-12930] (#2555) 2026-05-06 15:16:14 +12:00
Devon Steenberg bf56a6c913 fix(kubectl-shell): kubectl-shell-image flag [BE-12929] (#2545) 2026-05-06 08:13:12 +12:00
andres-portainer 6b1b6ff998 fix(docker): add missing restrictions for Swarm BE-12772 (#2557) 2026-05-05 11:35:37 -03:00
andres-portainer 9183be7a8c fix(docker): add more bind mount restriction checks BE-12771 (#2551) 2026-05-05 09:25:21 -03:00
Steven Kang 7f83d15812 fix(security): bump CVE-affected dependencies and Alpine base images - release 2.39.2 [R8S-1002] (#2563) 2026-05-05 16:20:17 +12:00
andres-portainer f926b61978 fix(datastore): change EnforceEdgeID default to true BE-12925 (#2548) 2026-05-04 20:52:37 -03:00
andres-portainer cc5f790f98 fix(docker): enforce bind mount restrictions for Mounts field BE-12770 (#2528) 2026-05-04 13:06:02 -03:00
andres-portainer 40b210a708 fix(environments): fix the TLS certificate uploading BE-12719 (#2101) (#2534) 2026-05-04 12:59:15 -03:00
andres-portainer 8be327f087 fix(git): forbid the usage of symlinks BE-12768 (#2532) 2026-05-04 12:57:29 -03:00
LP B f498d76c4f fix(app/container): handle no healthcheck logs output (#2388) 2026-04-21 18:40:03 -03:00
andres-portainer a1fa77cbe4 fix(kubernetes): enforce admin permissions in /system BE-12862 (#2397) 2026-04-21 17:04:01 -03:00
RHCowan e11c2e9611 chore(ci): update CI changes from develop (#2379)
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Devon Steenberg <devon.steenberg@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2026-04-21 14:36:10 +12:00
andres-portainer 6e03a801e6 fix(endpoints): enforce admin permissions when updating endpoint relations BE-12861 (#2395) 2026-04-20 14:29:22 -03:00
LP B 6024a97892 fix(api): deny plugin related changes to regular users (#2297) 2026-04-20 12:29:03 -03:00
andres-portainer 1549b36103 fix(websocket): remove the JWT token query string parameter BE-12833 (#2334) 2026-04-16 14:10:37 -03:00
Chaim Lev-Ari 7bb3e0f7a6 chore(deps): upgrade ts to v6 [BE-12820] (#2272) 2026-04-15 03:55:42 +03:00
Chaim Lev-Ari 49cc901dc3 fix(gitops): save git credentials [BE-12773] (#2240) 2026-04-09 09:25:20 +03:00
andres-portainer 31dd62fbcc fix(containers): avoid using the request context BE-12870 (#2217) 2026-04-08 12:39:41 -03:00
Chaim Lev-Ari a7d2d134d0 fix(stacks): stack.env can be null [BE-12736] (#2241) 2026-04-06 16:56:49 +03:00
Phil Calder 14f25f1e88 fix(kubernetes): filter CronJob executions by namespace [DEV-19] (#2154)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:29:39 +13:00
Oscar Zhou 69b8b8373f fix(edge/agent): deleting k8s edge agent disconnect environment [BE-12723] (#2125) 2026-03-24 11:36:25 +13:00
Josiah Clumont 0b075e6e10 fix(grpc): upgrade to v1.79.3 to mitigate CVE-2026-33186 [C9S-55] (#2089) 2026-03-20 07:17:49 +13:00
Chaim Lev-Ari b468160606 fix(stacks): disabled edit button while submit [BE-12681] (#2093) 2026-03-19 16:09:16 +02:00
Josiah Clumont a42e96b650 chore: bump 2.39.0 to 2.39.1 (#2085) 2026-03-19 08:21:14 +13:00
Chaim Lev-Ari 5a19f66a37 fix(stacks): validate stacks with env vars [BE-12689] (#2051) 2026-03-17 20:05:16 -03:00
andres-portainer b271026188 fix(otel): upgrade to v1.42.0 BE-12724 (#2071) 2026-03-17 13:02:50 -03:00
LP B d168e3c912 fix(api/uac): panic on external stacks UAC eval (#2074) 2026-03-17 16:00:39 +01:00
andres-portainer 0b6ebd70e0 fix(go): upgrade Go to v1.25.8 to mitigate CVEs BE-12721 (#2066) 2026-03-17 09:36:48 -03:00
andres-portainer 127e03552a fix(docker): upgrade Docker binary to v29.3.0 to mitigate CVE-2025-68121 BE-12720 (#2063) 2026-03-16 17:52:30 -03:00
Chaim Lev-Ari f2bdfc6eff fix(kube/app): enable edit button for regular apps [BE-12690] (#2040) 2026-03-15 11:48:38 +02:00
Cara Ryan 5db67faa00 chore(kubernetes): Upgrade k8s deps to 0.35 [C9S-32] (#2042) 2026-03-13 15:44:38 +13:00
andres-portainer f9dcfcb435 fix(GO-2026-4550): upgrade circl to v1.6.3 BE-12694 (#2012) 2026-03-06 14:29:28 -03:00
LP B 1d1bb526d0 fix(app/container): query env registries instead of system registries (#1997) 2026-03-06 14:18:09 -03:00
LP B c8fe8ba4fd fix(app): paginate nested tables (#1999) 2026-03-06 14:17:34 -03:00
andres-portainer d3692a5a5f fix(GO-2026-4473): upgrade go-git to v5.17.0 BE-12693 (#2009) 2026-03-06 11:24:08 -03:00
LP B 3407811c28 fix(app/stack): virtual grouping in EnvSelector for non admins (#2002) 2026-03-06 15:00:28 +01:00
andres-portainer b71db0d1f1 fix(GO-2026-4394): upgrade opentelemetry to v1.41.0 BE-12692 (#2004) 2026-03-06 08:31:22 -03:00
LP B 5e5e85ff3a fix(api/custom_template): validate UAC when retrieving custom template file (#1981) 2026-03-04 13:22:09 +01:00
RHCowan 65d82e12ee fix(policy) avoid URL length limit when adding environments to large groups [R8S-893] (#1970) (#1972) 2026-02-27 12:00:54 +13:00
Steven Kang d9e730e0a5 fix(kubernetes): local exec to fall back to SPDY - release 2.39 [R8S-873] (#1947) 2026-02-25 15:46:16 +13:00
Ali 21eb20b35e fix(environment-groups): allow bulk selecting environments on create and edit [r8s-872] (#1956)
Merging because the failed system tests are related to helm and not environment groups
2026-02-24 17:53:06 +13:00
Steven Kang f85a7ea24c fix(environment): collapsing More options breaking the style for podman - release 2.39 [R8S-874] (#1943) 2026-02-24 10:11:50 +13:00
Oscar Zhou 6aacb61c87 fix(stack): avoid removing running service if stack deployment fails [BE-12542] (#1941) 2026-02-24 08:41:53 +13:00
andres-portainer bb2c75ba93 fix(policies): fixes for async edge R8S-661 (#1934) 2026-02-20 17:45:38 -03:00
Steven Kang 16536c8a71 feat(environment): reorder options - release 2.39 [R8S-524] (#1924) 2026-02-20 14:58:05 +13:00
757 changed files with 8483 additions and 12164 deletions
+3 -1
View File
@@ -114,6 +114,9 @@ overrides:
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error'
'@typescript-eslint/switch-exhaustiveness-check': 'error'
'consistent-return': 'off'
'default-case': off
'jsx-a11y/label-has-associated-control':
- error
- assert: either
@@ -151,7 +154,6 @@ overrides:
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'@vitest/no-conditional-expect': warn
'max-classes-per-file': off
- files:
- app/**/*.stories.*
rules:
+3 -2
View File
@@ -94,8 +94,6 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.39.1'
- '2.39.0'
- '2.38.1'
- '2.38.0'
- '2.37.0'
@@ -143,6 +141,9 @@ body:
- '2.21.5'
- '2.21.4'
- '2.21.3'
- '2.21.2'
- '2.21.1'
- '2.21.0'
validations:
required: true
-20
View File
@@ -54,28 +54,8 @@ linters:
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
- pkg: github.com/cosi-project/runtime
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
- pkg: gopkg.in/yaml.v2
desc: use go.yaml.in/yaml/v3 instead
- pkg: gopkg.in/yaml.v3
desc: use go.yaml.in/yaml/v3 instead
- pkg: github.com/golang-jwt/jwt/v4
desc: use github.com/golang-jwt/jwt/v5 instead
- pkg: github.com/mitchellh/mapstructure
desc: use github.com/go-viper/mapstructure/v2 instead
- pkg: gopkg.in/alecthomas/kingpin.v2
desc: use github.com/alecthomas/kingpin/v2 instead
- pkg: github.com/jcmturner/gokrb5$
desc: use github.com/jcmturner/gokrb5/v8 instead
- pkg: github.com/gofrs/uuid
desc: use github.com/google/uuid
- pkg: github.com/Masterminds/semver$
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/blang/semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/coreos/go-semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/hashicorp/go-version
desc: use github.com/Masterminds/semver/v3
forbidigo:
forbid:
- pattern: ^tls\.Config$
+9 -7
View File
@@ -1,6 +1,7 @@
import path from 'path';
import { StorybookConfig } from '@storybook/react-webpack5';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack';
import postcss from 'postcss';
@@ -85,12 +86,7 @@ const config: StorybookConfig = {
...config,
resolve: {
...config.resolve,
plugins: [
...(config.resolve?.plugins || []),
new TsconfigPathsPlugin({
extensions: config.resolve?.extensions,
}),
],
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
},
module: {
...config.module,
@@ -101,11 +97,17 @@ const config: StorybookConfig = {
staticDirs: ['./public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
compilerOptions: {
outDir: path.resolve(__dirname, '..', 'dist/public'),
},
},
},
framework: {
name: '@storybook/react-webpack5',
options: {},
},
docs: {},
};
export default config;
+8 -4
View File
@@ -11,7 +11,7 @@ see also:
## Package Manager
- **PNPM** 10+ (for frontend)
- **Go** 1.26.1 (for backend)
- **Go** 1.25.8 (for backend)
## Build Commands
@@ -27,9 +27,13 @@ make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server
pnpm run dev # Webpack dev server
pnpm run build # Build frontend with webpack
pnpm run test # Run frontend tests
# Frontend
pnpm dev # Webpack dev server
pnpm build # Build frontend with webpack
pnpm typecheck # Run typecheck for frontend (with tsc)
pnpm lint # lint frontend (with eslint)
pnpm test # test frontend (with vitest)
pnpm format # format frontend (with prettier)
# Testing
make test # All tests (backend + frontend)
+5 -2
View File
@@ -4,7 +4,8 @@ WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM=go run gotest.tools/gotestsum@latest
GOTESTSUM_VERSION?=v1.13.0
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
# Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help
@@ -57,8 +58,10 @@ test: test-server test-client ## Run all tests
test-client: ## Run client tests
pnpm run test $(ARGS) --coverage
TEST_PACKAGES?=./...
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
##@ Dev
.PHONY: dev dev-client dev-server
+10 -13
View File
@@ -4,13 +4,13 @@
Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
| Version Type | Support Status |
| ------------------------ | ------------------------------------------- |
| LTS (Long-Term Support) | Supported for critical security fixes |
| Version Type | Support Status |
| --- | --- |
| LTS (Long-Term Support) | Supported for critical security fixes |
| STS (Short-Term Support) | Supported until the next STS or LTS release |
| Legacy / EOL | Not supported |
| Legacy / EOL | Not supported |
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
## Reporting a Vulnerability
@@ -21,19 +21,15 @@ The Portainer team takes the security of our products seriously. If you believe
### Disclosure Process
1. **Report**: You can report in one of two ways:
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
- **Email**: Send your findings to security@portainer.io.
1. **Report**: Email your findings to security@portainer.io.
2. **Details**: To help us verify the issue, please include:
- A description of the vulnerability and its potential impact.
- A description of the vulnerability and its potential impact.
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
- The version of the software and the environment in which it was found.
- The version of the software and the environment in which it was found.
3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment.
@@ -51,6 +47,7 @@ If you follow the responsible disclosure process, we will:
- Give credit for the discovery (if desired) once the fix is public.
We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed.
Thank you for helping keep Portainer and our community secure.
+4 -4
View File
@@ -8,8 +8,8 @@ import (
"time"
)
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
if err != nil {
return
}
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
return
}
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
if err != nil {
return
}
+10 -3
View File
@@ -32,7 +32,7 @@ func CLIFlags() *portainer.CLIFlags {
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
flags.KubectlShellImage = kingpin.Flag(
var hasKubectlShellImageFlag bool
kubectlShellImageFlag := kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
).Envar(portainer.KubectlShellImageEnvVar).
Default(portainer.DefaultKubectlShellImage).
IsSetByUser(&hasKubectlShellImageFlag)
flags.KubectlShellImage = kubectlShellImageFlag.String()
kingpin.Parse()
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable()
if err != nil {
+54
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@@ -26,6 +27,59 @@ func TestOptionParser(t *testing.T) {
require.True(t, *opts.EnableEdgeComputeFeatures)
}
func TestParseKubectlShellImageFlag(t *testing.T) {
tests := []struct {
name string
args []string
envVars map[string]string
expectedKubectlShellImageSet bool
expectedKubectlShellFlag string
}{
{
name: "no flag, no env var",
expectedKubectlShellImageSet: false,
expectedKubectlShellFlag: portainer.DefaultKubectlShellImage,
},
{
name: "explicit flag",
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v2",
},
{
name: "env var",
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v3",
},
{
name: "both env var and flag set",
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v2",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.args == nil {
tc.args = []string{"portainer"}
}
setOsArgs(t, tc.args)
for k, v := range tc.envVars {
t.Setenv(k, v)
}
flags, err := Service{}.ParseFlags("test-version")
require.NoError(t, err)
require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet)
require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage)
})
}
}
func TestParseTLSFlags(t *testing.T) {
testCases := []struct {
name string
+6 -2
View File
@@ -55,7 +55,7 @@ import (
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/portainer/portainer/pkg/validate"
"github.com/google/uuid"
"github.com/gofrs/uuid"
"github.com/rs/zerolog/log"
)
@@ -119,7 +119,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
}
if isNew {
instanceId, err := uuid.NewRandom()
instanceId, err := uuid.NewV4()
if err != nil {
log.Fatal().Err(err).Msg("failed generating instance id")
}
@@ -248,6 +248,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if flags.KubectlShellImageSet {
settings.KubectlShellImage = *flags.KubectlShellImage
}
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
}
+63 -1
View File
@@ -5,6 +5,9 @@ import (
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,7 +15,7 @@ import (
const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string {
err := os.WriteFile(secretPath, []byte(password), 0600)
err := os.WriteFile(secretPath, []byte(password), 0o600)
require.NoError(t, err)
return secretPath
}
@@ -38,6 +41,65 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
require.Len(t, encryptionKey, 32)
}
func TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) {
const existingImage = "existing-image:v1"
const newImage = "new-image:v2"
emptyString := ""
falseBool := false
var emptyLabels []portainer.Pair
tests := []struct {
name string
imageSet bool
flagImage string
expectedKubectlShellImage string
}{
{
name: "flag not set — DB image unchanged",
imageSet: false,
flagImage: portainer.DefaultKubectlShellImage,
expectedKubectlShellImage: existingImage,
},
{
name: "flag set — DB image updated",
imageSet: true,
flagImage: newImage,
expectedKubectlShellImage: newImage,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := testhelpers.NewDatastore(
testhelpers.WithSettingsService(&portainer.Settings{
KubectlShellImage: existingImage,
}),
testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}),
)
flags := &portainer.CLIFlags{
SnapshotInterval: &emptyString,
Logo: &emptyString,
EnableEdgeComputeFeatures: &falseBool,
Templates: &emptyString,
Labels: &emptyLabels,
HTTPDisabled: &falseBool,
HTTPEnabled: &falseBool,
}
flags.KubectlShellImage = &tc.flagImage
flags.KubectlShellImageSet = tc.imageSet
err := updateSettingsFromFlags(store, flags)
require.NoError(t, err)
settings, err := store.Settings().Settings()
require.NoError(t, err)
require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage)
})
}
}
func TestDBSecretPath(t *testing.T) {
tests := []struct {
keyFilenameFlag string
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"io"
"testing"
"github.com/google/uuid"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -29,7 +29,7 @@ func secretToEncryptionKey(passphrase string) []byte {
func Test_MarshalObjectUnencrypted(t *testing.T) {
is := assert.New(t)
uuid := uuid.New()
uuid := uuid.Must(uuid.NewV4())
tests := []struct {
object any
-13
View File
@@ -119,19 +119,6 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
return endpoints, nil
}
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
var endpoints []portainer.Endpoint
var err error
err = service.connection.ViewTx(func(tx portainer.Transaction) error {
endpoints, err = service.Tx(tx).ReadAll(predicates...)
return err
})
return endpoints, err
}
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
service.mu.RLock()
-5
View File
@@ -89,11 +89,6 @@ func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
)
}
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...)
}
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
@@ -28,9 +28,6 @@ func (service *Service) BucketName() string {
func (service *Service) RegisterUpdateStackFunction(
updateFuncTx func(portainer.Transaction, portainer.EdgeStackID, func(*portainer.EdgeStack)) error,
) {
service.mu.Lock()
defer service.mu.Unlock()
service.updateStackFnTx = updateFuncTx
}
-3
View File
@@ -102,9 +102,6 @@ type (
// EndpointService represents a service for managing environment(endpoint) data
EndpointService interface {
// partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID]
ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error)
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
+2 -2
View File
@@ -8,13 +8,13 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/google/uuid"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newGuidString(t *testing.T) string {
uuid, err := uuid.NewRandom()
uuid, err := uuid.NewV4()
require.NoError(t, err)
return uuid.String()
+1
View File
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)
+2 -2
View File
@@ -9,15 +9,15 @@ import (
"path/filepath"
"testing"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/stretchr/testify/require"
"github.com/Masterminds/semver/v3"
"github.com/google/go-cmp/cmp"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
func TestMigrateData(t *testing.T) {
-205
View File
@@ -1,205 +0,0 @@
package migrator
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/pendingactions"
"github.com/portainer/portainer/api/dataservices/registry"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMigrateRegistryAccessSASecrets_2_40_0(t *testing.T) {
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
registryService, err := registry.NewService(conn)
require.NoError(t, err)
endpointService, err := endpoint.NewService(conn)
require.NoError(t, err)
pendingActionsService, err := pendingactions.NewService(conn)
require.NoError(t, err)
t.Run("sets MigrateRegistrySASecrets flag for k8s endpoints with registry access", func(t *testing.T) {
k8sEndpoint := &portainer.Endpoint{
ID: 1,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
dockerEndpoint := &portainer.Endpoint{
ID: 2,
Name: "docker-standalone",
Type: portainer.DockerEnvironment,
}
err := conn.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
err = conn.CreateObjectWithId(endpoint.BucketName, int(dockerEndpoint.ID), dockerEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 1,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default", "production"},
},
dockerEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"ignored"},
},
},
}
err = conn.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService,
EndpointService: endpointService,
PendingActionsService: pendingActionsService,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
updatedK8sEndpoint, err := endpointService.Endpoint(k8sEndpoint.ID)
require.NoError(t, err)
assert.True(t, updatedK8sEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should have set MigrateRegistrySASecrets flag for k8s endpoint")
updatedDockerEndpoint, err := endpointService.Endpoint(dockerEndpoint.ID)
require.NoError(t, err)
assert.False(t, updatedDockerEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should not have set MigrateRegistrySASecrets flag for docker endpoint")
})
t.Run("skips endpoints with empty namespaces", func(t *testing.T) {
conn2 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn2.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn2)
registryService2, _ := registry.NewService(conn2)
endpointService2, _ := endpoint.NewService(conn2)
pendingActionsService2, _ := pendingactions.NewService(conn2)
k8sEndpoint := &portainer.Endpoint{
ID: 10,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn2.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 10,
Name: "empty-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{},
},
},
}
err = conn2.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService2,
EndpointService: endpointService2,
PendingActionsService: pendingActionsService2,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService2.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for empty namespaces")
})
t.Run("skips non-existent endpoints", func(t *testing.T) {
conn3 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn3.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn3)
registryService3, _ := registry.NewService(conn3)
endpointService3, _ := endpoint.NewService(conn3)
pendingActionsService3, _ := pendingactions.NewService(conn3)
reg := &portainer.Registry{
ID: 20,
Name: "orphan-registry",
RegistryAccesses: portainer.RegistryAccesses{
999: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn3.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService3,
EndpointService: endpointService3,
PendingActionsService: pendingActionsService3,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService3.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for non-existent endpoints")
})
t.Run("idempotent - running twice creates duplicate actions but doesn't error", func(t *testing.T) {
conn4 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn4.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn4)
registryService4, _ := registry.NewService(conn4)
endpointService4, _ := endpoint.NewService(conn4)
pendingActionsService4, _ := pendingactions.NewService(conn4)
k8sEndpoint := &portainer.Endpoint{
ID: 30,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn4.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 30,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn4.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService4,
EndpointService: endpointService4,
PendingActionsService: pendingActionsService4,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
})
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/semver"
"github.com/rs/zerolog/log"
)
@@ -1,58 +0,0 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/rs/zerolog/log"
)
// migrateRegistryAccessSASecrets_2_40_0 marks Kubernetes endpoints that have
// registry access configured so that imagePullSecrets can be added to their
// default ServiceAccounts during the post-init migration phase (when cluster
// access is available).
func (m *Migrator) migrateRegistryAccessSASecrets_2_40_0() error {
log.Info().Msg("migrating registry access service account secrets")
registries, err := m.registryService.ReadAll()
if err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
// Collect the IDs of endpoints that have at least one registry with
// non-empty namespace access - these need the SA imagePullSecrets migration.
needsMigration := make(map[portainer.EndpointID]bool)
for _, registry := range registries {
for endpointID, access := range registry.RegistryAccesses {
if len(access.Namespaces) > 0 {
needsMigration[endpointID] = true
}
}
}
for i := range endpoints {
endpoint := &endpoints[i]
if !endpointutils.IsKubernetesEndpoint(endpoint) {
continue
}
if !needsMigration[endpoint.ID] {
continue
}
endpoint.PostInitMigrations.MigrateRegistrySASecrets = true
if err := m.endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Warn().
Err(err).
Int("endpointID", int(endpoint.ID)).
Msg("failed to set registry SA secret migration flag for endpoint")
}
}
return nil
}
+1 -3
View File
@@ -29,7 +29,7 @@ import (
"github.com/portainer/portainer/api/dataservices/version"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/semver"
"github.com/rs/zerolog/log"
)
@@ -258,8 +258,6 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.33.1", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
m.addMigrations("2.40.0", m.migrateRegistryAccessSASecrets_2_40_0)
// WARNING: do not change migrations that have already been released!
// Add new migrations above...
+52 -172
View File
@@ -1,10 +1,8 @@
package postinit
import (
"cmp"
"context"
"fmt"
"slices"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
@@ -12,7 +10,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
dockerClient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/pendingactions/actions"
@@ -47,65 +44,40 @@ func NewPostInitMigrator(
// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments
func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
var environments []portainer.Endpoint
environments, err := postInitMigrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Error().Err(err).Msg("Error getting environments")
return err
}
if err := postInitMigrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
if environments, err = tx.Endpoint().ReadAll(func(endpoint portainer.Endpoint) bool {
return endpoints.HasDirectConnectivity(&endpoint)
}); err != nil {
return fmt.Errorf("failed to retrieve environments: %w", err)
}
var pendingActions []portainer.PendingAction
if pendingActions, err = tx.PendingActions().ReadAll(func(action portainer.PendingAction) bool {
return action.Action == actions.PostInitMigrateEnvironment
}); err != nil {
return fmt.Errorf("failed to retrieve pending actions: %w", err)
}
// Sort for the binary search in createPostInitMigrationPendingAction()
slices.SortFunc(pendingActions, func(a, b portainer.PendingAction) int {
return cmp.Compare(a.EndpointID, b.EndpointID)
})
for _, environment := range environments {
if !endpoints.IsEdgeEndpoint(&environment) {
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
if endpoints.IsEdgeEndpoint(&environment) {
// Skip edge environments that do not have direct connectivity
if !endpoints.HasDirectConnectivity(&environment) {
continue
}
// Edge environments will run after the server starts, in pending actions
log.Info().
Int("endpoint_id", int(environment.ID)).
Msg("adding pending action 'PostInitMigrateEnvironment' for environment")
if err := postInitMigrator.createPostInitMigrationPendingAction(tx, environment.ID, pendingActions); err != nil {
if err := postInitMigrator.createPostInitMigrationPendingAction(environment.ID); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating pending action for environment")
}
} else {
// Non-edge environments will run before the server starts.
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error running post-init migrations for non-edge environment")
}
}
return err
}); err != nil {
log.Error().Err(err).Msg("error running post-init migrations")
return err
}
for _, environment := range environments {
if endpoints.IsEdgeEndpoint(&environment) {
continue
}
// Non-edge environments will run before the server starts.
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error running post-init migrations for non-edge environment")
}
}
return nil
@@ -113,79 +85,59 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
// try to create a post init migration pending action. If it already exists, do nothing
// this function exists for readability, not reusability
// pending actions must be passed in ascending order by endpoint ID
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(tx dataservices.DataStoreTx, environmentID portainer.EndpointID, pendingActions []portainer.PendingAction) error {
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
action := portainer.PendingAction{
EndpointID: environmentID,
Action: actions.PostInitMigrateEnvironment,
}
if _, found := slices.BinarySearchFunc(pendingActions, environmentID, func(e portainer.PendingAction, id portainer.EndpointID) int {
return cmp.Compare(e.EndpointID, id)
}); found {
log.Debug().
Str("action", action.Action).
Int("endpoint_id", int(action.EndpointID)).
Msg("pending action already exists for environment, skipping...")
return nil
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
if err != nil {
return fmt.Errorf("failed to retrieve pending actions: %w", err)
}
return tx.PendingActions().Create(&action)
for _, dba := range pendingActions {
if dba.EndpointID == action.EndpointID && dba.Action == action.Action {
log.Debug().
Str("action", action.Action).
Int("endpoint_id", int(action.EndpointID)).
Msg("pending action already exists for environment, skipping...")
return nil
}
}
return postInitMigrator.dataStore.PendingActions().Create(&action)
}
// MigrateEnvironment runs migrations on a single environment
func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error {
log.Info().
Int("endpoint_id", int(environment.ID)).
Msg("executing post init migration for environment")
log.Info().Msgf("Executing post init migration for environment %d", environment.ID)
switch {
case endpointutils.IsKubernetesEndpoint(environment):
// get the kubeclient for the environment, and skip all kube migrations if there's an error
kubeclient, err := migrator.kubeFactory.GetPrivilegedKubeClient(environment)
if err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating kubeclient for environment")
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
return err
}
// If one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
var latestErr error
kubernetesMigrations := []func() error{
func() error { return migrator.MigrateIngresses(*environment, kubeclient) },
func() error { return migrator.MigrateRegistrySASecrets(*environment, kubeclient) },
// if one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
if err := migrator.MigrateIngresses(*environment, kubeclient); err != nil {
return err
}
for _, migration := range kubernetesMigrations {
if err := migration(); err != nil {
latestErr = err
}
}
return latestErr
return nil
case endpointutils.IsDockerEndpoint(environment):
// get the docker client for the environment, and skip all docker migrations if there's an error
dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil)
if err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating docker client for environment")
log.Error().Err(err).Msgf("Error creating docker client for environment: %d", environment.ID)
return err
}
defer logs.CloseAndLogErr(dockerClient)
if err := migrator.MigrateGPUs(*environment, dockerClient); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error migrating GPUs for environment")
return err
}
}
@@ -193,73 +145,18 @@ func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endp
return nil
}
func (migrator *PostInitMigrator) MigrateRegistrySASecrets(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
if !environment.PostInitMigrations.MigrateRegistrySASecrets {
return nil
}
log.Debug().
Int("endpoint_id", int(environment.ID)).
Msg("migrating registry SA secrets for environment")
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
env, err := tx.Endpoint().Endpoint(environment.ID)
if err != nil {
return err
}
if !env.PostInitMigrations.MigrateRegistrySASecrets {
return nil
}
registries, err := tx.Registry().ReadAll()
if err != nil {
return err
}
for _, registry := range registries {
access, ok := registry.RegistryAccesses[env.ID]
if !ok || len(access.Namespaces) == 0 {
continue
}
secretName := registryutils.RegistrySecretName(registry.ID)
for _, namespace := range access.Namespaces {
if err := kubeclient.AddImagePullSecretToServiceAccount(namespace, "default", secretName); err != nil {
log.Warn().
Err(err).
Int("endpoint_id", int(env.ID)).
Str("namespace", namespace).
Str("secret", secretName).
Msg("failed to add imagePullSecret to service account during registry SA secret migration")
}
}
}
env.PostInitMigrations.MigrateRegistrySASecrets = false
return tx.Endpoint().UpdateEndpoint(env.ID, env)
})
}
func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateIngresses {
return nil
}
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
log.Debug().
Int("endpoint_id", int(environment.ID)).
Msg("migrating ingresses for environment")
if err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error migrating ingresses for environment")
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
if err != nil {
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
return err
}
return nil
}
@@ -269,42 +166,29 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
environment, err := tx.Endpoint().Endpoint(e.ID)
if err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(e.ID)).
Msg("error getting environment")
log.Error().Err(err).Msgf("Error getting environment %d", e.ID)
return err
}
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateGPUs {
return nil
}
log.Debug().Msgf("Migrating GPUs for environment %d", e.ID)
log.Debug().
Int("endpoint_id", int(e.ID)).
Msg("migrating GPUs for environment")
// Get all containers
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("failed to list containers for environment")
log.Error().Err(err).Msgf("failed to list containers for environment %d", environment.ID)
return err
}
// Check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Error().Err(err).Msg("failed to inspect container")
continue
}
@@ -318,14 +202,10 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
}
}
// Set the MigrateGPUs flag to false so we don't run this again
// set the MigrateGPUs flag to false so we don't run this again
environment.PostInitMigrations.MigrateGPUs = false
if err := tx.Endpoint().UpdateEndpoint(environment.ID, environment); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error updating EnableGPUManagement flag for environment")
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
return err
}
@@ -80,8 +80,7 @@
"Name": "local",
"PostInitMigrations": {
"MigrateGPUs": true,
"MigrateIngresses": true,
"MigrateRegistrySASecrets": false
"MigrateIngresses": true
},
"PublicURL": "",
"SecuritySettings": {
@@ -90,7 +89,6 @@
"allowDeviceMappingForRegularUsers": true,
"allowHostNamespaceForRegularUsers": true,
"allowPrivilegedModeForRegularUsers": true,
"allowSecurityOptForRegularUsers": false,
"allowStackManagementForRegularUsers": true,
"allowSysctlSettingForRegularUsers": false,
"allowVolumeBrowserForRegularUsers": false,
@@ -615,7 +613,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.40.0",
"KubectlShellImage": "portainer/kubectl-shell:2.39.2",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -944,7 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.40.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.39.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/logs"
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/semver"
"github.com/docker/docker/api/types"
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
-3
View File
@@ -54,9 +54,6 @@ type (
// Used only for EE
AlwaysCloneGitRepoForRelativePath bool
// Whether the edge stack supports per device configs
SupportPerDeviceConfigs bool
// Mount point for relative path
FilesystemPath string
// Used only for EE
+11 -16
View File
@@ -66,11 +66,10 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
RemoveOrphans: options.Prune,
})
return errors.Wrap(err, "failed to deploy a stack")
}
@@ -98,7 +97,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
Remove: options.Remove,
Args: options.Args,
@@ -147,7 +146,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
}
@@ -230,7 +229,12 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
// portainerRegistriesToAuthConfigs converts registries to Docker auth configs.
// Callers must ensure ECR tokens are valid before calling this function (e.g. via
// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function
// intentionally performs no DB writes to avoid write-lock contention when called inside
// an active BoltDB write transaction.
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
@@ -243,7 +247,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
if err != nil {
continue
}
@@ -255,16 +259,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
+72
View File
@@ -6,6 +6,7 @@ import (
"path"
"path/filepath"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
@@ -94,3 +95,74 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
}
func Test_portainerRegistriesToAuthConfigs(t *testing.T) {
t.Parallel()
t.Run("returns empty slice for empty input", func(t *testing.T) {
t.Parallel()
result := portainerRegistriesToAuthConfigs([]portainer.Registry{})
require.Nil(t, result)
})
t.Run("uses registry URL, username and password for non-authenticated registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: false},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "registry.example.com", result[0].ServerAddress)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("uses username and password for authenticated non-ECR registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: true, Type: portainer.CustomRegistry},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("parses ECR access token for authenticated ECR registry with valid token", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Username: "AKIAIOSFODNN7EXAMPLE",
Password: "secretkey",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "AWS:ecr-password",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "AWS", result[0].Username)
require.Equal(t, "ecr-password", result[0].Password)
})
t.Run("includes valid registries and skips ones with credential errors", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "valid.example.com", Username: "user", Password: "pass", Authentication: false},
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "no-colon-token",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "valid.example.com", result[0].ServerAddress)
})
}
+1 -1
View File
@@ -61,7 +61,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
for _, registry := range registries {
if registry.Authentication {
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
username, password, err := getEffectiveRegUsernamePassword(&registry)
if err != nil {
continue
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/logs"
"github.com/google/uuid"
"github.com/gofrs/uuid"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -812,7 +812,7 @@ func (service *Service) getEdgeJobTaskLogPath(edgeJobID string, taskID string) s
// GetTemporaryPath returns a temp folder
func (service *Service) GetTemporaryPath() (string, error) {
uid, err := uuid.NewRandom()
uid, err := uuid.NewV4()
if err != nil {
return "", err
}
@@ -223,15 +223,3 @@ func TestIsInConfigDir(t *testing.T) {
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
}
func TestShouldIncludeDir(t *testing.T) {
f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) {
t.Helper()
actual := shouldIncludeDir(dirEntry, deviceName, configPath)
assert.Equal(t, expect, actual)
}
f(DirEntry{Name: "app/blue-app", IsFile: false}, "blue-app", "app", true)
f(DirEntry{Name: "app/blue-app/values.yaml", IsFile: true}, "blue-app", "app", true)
}
+34 -90
View File
@@ -16,9 +16,7 @@ import (
"github.com/portainer/portainer/api/logs"
"github.com/rs/zerolog/log"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/filemode"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
)
@@ -28,7 +26,7 @@ const (
visualStudioHostSuffix = ".visualstudio.com"
)
func IsAzureUrl(s string) bool {
func isAzureUrl(s string) bool {
return strings.Contains(s, azureDevOpsHost) ||
strings.Contains(s, visualStudioHostSuffix)
}
@@ -75,11 +73,7 @@ func newHttpClientForAzure(insecureSkipVerify bool) *http.Client {
return httpsCli
}
func (a *azureClient) Download(ctx context.Context, destination string, opt *git.CloneOptions) error {
if opt == nil {
return errors.New("options cannot be nil")
}
func (a *azureClient) download(ctx context.Context, destination string, opt cloneOption) error {
zipFilepath, err := a.downloadZipFromAzureDevOps(ctx, opt)
if err != nil {
return errors.Wrap(err, "failed to download a zip file from Azure DevOps")
@@ -97,13 +91,13 @@ func (a *azureClient) Download(ctx context.Context, destination string, opt *git
return nil
}
func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt *git.CloneOptions) (string, error) {
config, err := parseUrl(opt.URL)
func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneOption) (string, error) {
config, err := parseUrl(opt.repositoryUrl)
if err != nil {
return "", errors.WithMessage(err, "failed to parse url")
}
downloadUrl, err := a.buildDownloadUrl(config, string(opt.ReferenceName))
downloadUrl, err := a.buildDownloadUrl(config, opt.referenceName)
if err != nil {
return "", errors.WithMessage(err, "failed to build download url")
}
@@ -115,18 +109,9 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt *git.C
defer logs.CloseAndLogErr(zipFile)
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return "", errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadUrl, nil)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -135,7 +120,7 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt *git.C
return "", errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.InsecureSkipTLS)
client := newHttpClientForAzure(opt.tlsSkipVerify)
defer client.CloseIdleConnections()
res, err := client.Do(req)
@@ -160,12 +145,8 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt *git.C
return zipFile.Name(), nil
}
func (a *azureClient) LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error) {
if opt == nil {
return "", errors.New("options cannot be nil")
}
rootItem, err := a.getRootItem(ctx, repositoryUrl, referenceName, opt)
func (a *azureClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
rootItem, err := a.getRootItem(ctx, opt)
if err != nil {
return "", err
}
@@ -173,29 +154,20 @@ func (a *azureClient) LatestCommitID(ctx context.Context, repositoryUrl, referen
return rootItem.CommitId, nil
}
func (a *azureClient) getRootItem(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (*azureItem, error) {
config, err := parseUrl(repositoryUrl)
func (a *azureClient) getRootItem(ctx context.Context, opt fetchOption) (*azureItem, error) {
config, err := parseUrl(opt.repositoryUrl)
if err != nil {
return nil, errors.WithMessage(err, "failed to parse url")
}
rootItemUrl, err := a.buildRootItemUrl(config, referenceName)
rootItemUrl, err := a.buildRootItemUrl(config, opt.referenceName)
if err != nil {
return nil, errors.WithMessage(err, "failed to build azure root item url")
}
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return nil, errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", rootItemUrl, nil)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -204,7 +176,7 @@ func (a *azureClient) getRootItem(ctx context.Context, repositoryUrl, referenceN
return nil, errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.InsecureSkipTLS)
client := newHttpClientForAzure(opt.tlsSkipVerify)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
@@ -267,10 +239,8 @@ func parseSshUrl(rawUrl string) (*azureOptions, error) {
}, nil
}
const (
expectedAzureDevOpsHttpUrl = "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository"
expectedVisualStudioHttpUrl = "https://organisation.visualstudio.com/project/_git/repository"
)
const expectedAzureDevOpsHttpUrl = "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository"
const expectedVisualStudioHttpUrl = "https://organisation.visualstudio.com/project/_git/repository"
func parseHttpUrl(rawUrl string) (*azureOptions, error) {
u, err := url.Parse(rawUrl)
@@ -313,6 +283,7 @@ func (a *azureClient) buildDownloadUrl(config *azureOptions, referenceName strin
url.PathEscape(config.project),
url.PathEscape(config.repository))
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse download url path %s", rawUrl)
}
@@ -339,6 +310,7 @@ func (a *azureClient) buildRootItemUrl(config *azureOptions, referenceName strin
url.PathEscape(config.project),
url.PathEscape(config.repository))
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse root item url path %s", rawUrl)
}
@@ -363,6 +335,7 @@ func (a *azureClient) buildRefsUrl(config *azureOptions) (string, error) {
url.PathEscape(config.project),
url.PathEscape(config.repository))
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse list refs url path %s", rawUrl)
}
@@ -384,6 +357,7 @@ func (a *azureClient) buildTreeUrl(config *azureOptions, rootObjectHash string)
url.PathEscape(rootObjectHash),
)
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse list tree url path %s", rawUrl)
}
@@ -426,12 +400,8 @@ func getVersionType(name string) string {
return "commit"
}
func (a *azureClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error) {
if opt == nil {
return nil, errors.New("options cannot be nil")
}
config, err := parseUrl(repositoryUrl)
func (a *azureClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) {
config, err := parseUrl(opt.repositoryUrl)
if err != nil {
return nil, errors.WithMessage(err, "failed to parse url")
}
@@ -441,18 +411,9 @@ func (a *azureClient) ListRefs(ctx context.Context, repositoryUrl string, opt *g
return nil, errors.WithMessage(err, "failed to build list refs url")
}
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return nil, errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", listRefsUrl, nil)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -461,7 +422,7 @@ func (a *azureClient) ListRefs(ctx context.Context, repositoryUrl string, opt *g
return nil, errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.InsecureSkipTLS)
client := newHttpClientForAzure(opt.tlsSkipVerify)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
@@ -498,21 +459,13 @@ func (a *azureClient) ListRefs(ctx context.Context, repositoryUrl string, opt *g
}
// listFiles list all filenames under the specific repository
func (a *azureClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error) {
if opt == nil {
return nil, errors.New("options cannot be nil")
}
listOptions := &git.ListOptions{
Auth: opt.Auth,
InsecureSkipTLS: opt.InsecureSkipTLS,
}
rootItem, err := a.getRootItem(ctx, opt.URL, string(opt.ReferenceName), listOptions)
func (a *azureClient) listFiles(ctx context.Context, opt fetchOption) ([]string, error) {
rootItem, err := a.getRootItem(ctx, opt)
if err != nil {
return nil, err
}
config, err := parseUrl(opt.URL)
config, err := parseUrl(opt.repositoryUrl)
if err != nil {
return nil, errors.WithMessage(err, "failed to parse url")
}
@@ -522,18 +475,9 @@ func (a *azureClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.Clon
return nil, errors.WithMessage(err, "failed to build list tree url")
}
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return nil, errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", listTreeUrl, nil)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -542,7 +486,7 @@ func (a *azureClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.Clon
return nil, errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.InsecureSkipTLS)
client := newHttpClientForAzure(opt.tlsSkipVerify)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
@@ -574,7 +518,7 @@ func (a *azureClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.Clon
for _, treeEntry := range tree.TreeEntries {
mode, _ := filemode.New(treeEntry.Mode)
isDir := filemode.Dir == mode
if dirOnly == isDir {
if opt.dirOnly == isDir {
allPaths = append(allPaths, treeEntry.RelativePath)
}
}
+63 -49
View File
@@ -65,6 +65,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
tt.args.referenceName,
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -87,6 +88,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
"refs/heads/main",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -104,6 +106,7 @@ func TestService_LatestCommitID_Azure(t *testing.T) {
"refs/heads/main",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -121,6 +124,7 @@ func TestService_ListRefs_Azure(t *testing.T) {
privateAzureRepoURL,
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
)
@@ -136,10 +140,10 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go func() {
_, _ = service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
_, _ = service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
}()
_, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
_, err := service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
@@ -148,14 +152,6 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
func TestService_ListFiles_Azure(t *testing.T) {
ensureIntegrationTest(t)
type args struct {
repositoryUrl string
referenceName string
username string
password string
extensions []string
}
type expectResult struct {
shouldFail bool
err error
@@ -167,19 +163,22 @@ func TestService_ListFiles_Azure(t *testing.T) {
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
tests := []struct {
name string
args args
expect expectResult
name string
args fetchOption
extensions []string
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "test-username",
password: "test-token",
},
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -187,13 +186,15 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "",
password: "",
},
referenceName: "refs/heads/main",
username: "",
password: "",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -201,13 +202,15 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
err: nil,
matchedCount: 19,
@@ -215,13 +218,15 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref and existing file extension",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"yml"},
},
extensions: []string{"yml"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -229,13 +234,15 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref and non-existing file extension",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"hcl"},
},
extensions: []string{"hcl"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -243,26 +250,30 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
},
},
{
name: "list tree with fake repository ",
args: args{
repositoryUrl: privateAzureRepoURL + "fake",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL + "fake",
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrIncorrectRepositoryURL,
@@ -277,9 +288,10 @@ func TestService_ListFiles_Azure(t *testing.T) {
tt.args.referenceName,
tt.args.username,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.args.extensions,
tt.extensions,
false,
)
@@ -311,6 +323,7 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -323,6 +336,7 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
+75 -93
View File
@@ -7,9 +7,6 @@ import (
"net/url"
"testing"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
@@ -237,7 +234,7 @@ func Test_isAzureUrl(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsAzureUrl(tt.args.s))
assert.Equal(t, tt.want, isAzureUrl(tt.args.s))
})
}
}
@@ -246,9 +243,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
fips.InitFIPS(false)
type args struct {
repositoryUrl string
username string
password string
options baseOption
}
type basicAuth struct {
username, password string
@@ -261,7 +256,9 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
{
name: "username, password embedded",
args: args{
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
options: baseOption{
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
},
},
want: &basicAuth{
username: "username",
@@ -271,9 +268,11 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
{
name: "username, password embedded, clone options take precedence",
args: args{
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
username: "u",
password: "p",
options: baseOption{
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
username: "u",
password: "p",
},
},
want: &basicAuth{
username: "u",
@@ -283,7 +282,9 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
{
name: "no credentials",
args: args{
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
options: baseOption{
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
},
},
},
}
@@ -302,14 +303,10 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
baseUrl: server.URL,
}
option := &git.CloneOptions{
URL: tt.args.repositoryUrl,
}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
option := cloneOption{
fetchOption: fetchOption{
baseOption: tt.args.options,
},
}
_, err := a.downloadZipFromAzureDevOps(context.Background(), option)
require.Error(t, err)
@@ -343,21 +340,18 @@ func Test_azureDownloader_latestCommitID(t *testing.T) {
a := &azureClient{baseUrl: server.URL}
type args struct {
repositoryUrl string
referenceName string
}
tests := []struct {
name string
args args
args fetchOption
want string
wantErr bool
}{
{
name: "should be able to parse response",
args: args{
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
},
referenceName: "",
},
want: "27104ad7549d9e66685e115a497533f18024be9c",
@@ -367,7 +361,7 @@ func Test_azureDownloader_latestCommitID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := a.LatestCommitID(context.Background(), tt.args.repositoryUrl, tt.args.referenceName, &git.ListOptions{})
id, err := a.latestCommitID(context.Background(), tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -381,23 +375,22 @@ type testRepoManager struct {
called bool
}
func (t *testRepoManager) Download(_ context.Context, _ string, _ *git.CloneOptions) error {
func (t *testRepoManager) download(_ context.Context, _ string, _ cloneOption) error {
t.called = true
return nil
}
func (t *testRepoManager) LatestCommitID(_ context.Context, _, _ string, _ *git.ListOptions) (string, error) {
func (t *testRepoManager) latestCommitID(_ context.Context, _ fetchOption) (string, error) {
return "", nil
}
func (t *testRepoManager) ListRefs(_ context.Context, _ string, _ *git.ListOptions) ([]string, error) {
func (t *testRepoManager) listRefs(_ context.Context, _ baseOption) ([]string, error) {
return nil, nil
}
func (t *testRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptions) ([]string, error) {
func (t *testRepoManager) listFiles(_ context.Context, _ fetchOption) ([]string, error) {
return nil, nil
}
func Test_cloneRepository_azure(t *testing.T) {
tests := []struct {
name string
@@ -427,7 +420,15 @@ func Test_cloneRepository_azure(t *testing.T) {
git := &testRepoManager{}
s := &Service{azure: azure, git: git}
err := s.CloneRepository("", tt.url, "", "", "", false)
err := s.cloneRepository("", cloneOption{
fetchOption: fetchOption{
baseOption: baseOption{
repositoryUrl: tt.url,
},
},
depth: 1,
})
require.NoError(t, err)
// if azure API is called, git isn't and vice versa
@@ -442,12 +443,6 @@ func Test_listRefs_azure(t *testing.T) {
client := NewAzureClient()
type args struct {
repositoryUrl string
username string
password string
}
type expectResult struct {
err error
refsCount int
@@ -458,12 +453,12 @@ func Test_listRefs_azure(t *testing.T) {
tests := []struct {
name string
args args
args baseOption
expect expectResult
}{
{
name: "list refs of a real repository",
args: args{
args: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
@@ -475,7 +470,7 @@ func Test_listRefs_azure(t *testing.T) {
},
{
name: "list refs of a real repository with incorrect credential",
args: args{
args: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "test-username",
password: "test-token",
@@ -486,7 +481,7 @@ func Test_listRefs_azure(t *testing.T) {
},
{
name: "list refs of a real repository without providing credential",
args: args{
args: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "",
password: "",
@@ -497,7 +492,7 @@ func Test_listRefs_azure(t *testing.T) {
},
{
name: "list refs of a fake repository",
args: args{
args: baseOption{
repositoryUrl: privateAzureRepoURL + "fake",
username: username,
password: accessToken,
@@ -510,14 +505,7 @@ func Test_listRefs_azure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := &git.ListOptions{}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
refs, err := client.ListRefs(context.TODO(), tt.args.repositoryUrl, option)
refs, err := client.listRefs(context.TODO(), tt.args)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -529,6 +517,7 @@ func Test_listRefs_azure(t *testing.T) {
}
})
}
}
func Test_listFiles_azure(t *testing.T) {
@@ -536,13 +525,6 @@ func Test_listFiles_azure(t *testing.T) {
client := NewAzureClient()
type args struct {
repositoryUrl string
referenceName string
username string
password string
}
type expectResult struct {
shouldFail bool
err error
@@ -553,16 +535,18 @@ func Test_listFiles_azure(t *testing.T) {
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
tests := []struct {
name string
args args
args fetchOption
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "test-username",
password: "test-token",
},
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
},
expect: expectResult{
shouldFail: true,
@@ -571,11 +555,13 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "",
password: "",
},
referenceName: "refs/heads/main",
username: "",
password: "",
},
expect: expectResult{
shouldFail: true,
@@ -584,11 +570,13 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
},
expect: expectResult{
err: nil,
@@ -597,11 +585,13 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: args{
repositoryUrl: privateAzureRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -609,11 +599,13 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with fake repository ",
args: args{
repositoryUrl: privateAzureRepoURL + "fake",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL + "fake",
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -624,17 +616,7 @@ func Test_listFiles_azure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := &git.CloneOptions{
URL: tt.args.repositoryUrl,
ReferenceName: plumbing.ReferenceName(tt.args.referenceName),
}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
paths, err := client.ListFiles(context.TODO(), false, option)
paths, err := client.listFiles(context.TODO(), tt.args)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
+2
View File
@@ -19,6 +19,7 @@ type CloneOptions struct {
ReferenceName string
Username string
Password string
AuthType gittypes.GitCredentialAuthType
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
}
@@ -48,6 +49,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
options.ReferenceName,
options.Username,
options.Password,
options.AuthType,
options.TLSSkipVerify,
); err != nil {
cleanUp = false
+126 -18
View File
@@ -3,20 +3,42 @@ package git
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
// symlink traversal attacks from untrusted git repositories
type noSymlinkFS struct {
billy.Filesystem
}
func (fs noSymlinkFS) Symlink(_, _ string) error {
return gittypes.ErrSymlinkDetected
}
// NewNoSymlinkFS wraps fs and rejects any symlink creation
func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem {
return noSymlinkFS{fs}
}
type gitClient struct {
preserveGitDirectory bool
}
@@ -28,38 +50,67 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
_, err := git.PlainCloneContext(ctx, dst, false, opt)
wt := NewNoSymlinkFS(osfs.New(dst))
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
_, err := git.CloneContext(ctx, storer, wt, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if !c.preserveGitDirectory {
err := os.RemoveAll(filepath.Join(dst, ".git"))
if err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
if c.preserveGitDirectory {
return nil
}
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
return nil
}
func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error) {
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
gitOptions := &git.CloneOptions{
URL: opt.repositoryUrl,
Depth: opt.depth,
InsecureSkipTLS: opt.tlsSkipVerify,
Auth: getAuth(opt.authType, opt.username, opt.password),
Tags: git.NoTags,
}
if opt.referenceName != "" {
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
}
return c.Download(ctx, dst, gitOptions)
}
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin",
URLs: []string{repositoryUrl},
URLs: []string{opt.repositoryUrl},
})
refs, err := remote.List(opt)
listOptions := &git.ListOptions{
Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
}
refs, err := remote.List(listOptions)
if err != nil {
if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure
}
return "", errors.Wrap(err, "failed to list repository refs")
}
referenceName := opt.referenceName
if referenceName == "" {
for _, ref := range refs {
if strings.EqualFold(ref.Name().String(), "HEAD") {
@@ -74,16 +125,60 @@ func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, reference
}
}
return "", errors.Errorf("could not find ref %q in the repository", referenceName)
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
}
func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error) {
func getAuth(authType gittypes.GitCredentialAuthType, username, password string) transport.AuthMethod {
if password == "" {
return nil
}
switch authType {
case gittypes.GitCredentialAuthType_Basic:
return getBasicAuth(username, password)
case gittypes.GitCredentialAuthType_Token:
return getTokenAuth(password)
default:
log.Warn().Msg("unknown git credentials authorization type, defaulting to None")
return nil
}
}
func getBasicAuth(username, password string) *githttp.BasicAuth {
if password != "" {
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
}
return nil
}
func getTokenAuth(token string) *githttp.TokenAuth {
if token != "" {
return &githttp.TokenAuth{
Token: token,
}
}
return nil
}
func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) {
rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin",
URLs: []string{repositoryUrl},
URLs: []string{opt.repositoryUrl},
})
refs, err := rem.List(opt)
listOptions := &git.ListOptions{
Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
}
refs, err := rem.List(listOptions)
if err != nil {
return nil, checkGitError(err)
}
@@ -93,6 +188,7 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
if ref.Name().String() == "HEAD" {
continue
}
ret = append(ret, ref.Name().String())
}
@@ -100,8 +196,19 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
}
// listFiles list all filenames under the specific repository
func (c *gitClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error) {
repo, err := git.Clone(memory.NewStorage(), nil, opt)
func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, error) {
cloneOption := &git.CloneOptions{
URL: opt.repositoryUrl,
NoCheckout: true,
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.ReferenceName(opt.referenceName),
Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
Tags: git.NoTags,
}
repo, err := git.Clone(memory.NewStorage(), nil, cloneOption)
if err != nil {
return nil, checkGitError(err)
}
@@ -133,7 +240,7 @@ func (c *gitClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneO
}
isDir := entry.Mode == filemode.Dir
if dirOnly == isDir {
if opt.dirOnly == isDir {
allPaths = append(allPaths, name)
}
}
@@ -148,5 +255,6 @@ func checkGitError(err error) error {
} else if errMsg == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return err
}
+76 -57
View File
@@ -34,6 +34,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -53,6 +54,7 @@ func TestService_LatestCommitID_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -67,7 +69,7 @@ func TestService_ListRefs_GitHub(t *testing.T) {
service := newService(context.TODO(), 0, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
}
@@ -81,10 +83,10 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) {
repositoryUrl := privateGitRepoURL
go func() {
_, _ = service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, _ = service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
}()
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
@@ -93,14 +95,6 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) {
func TestService_ListFiles_GitHub(t *testing.T) {
ensureIntegrationTest(t)
type args struct {
repositoryUrl string
referenceName string
username string
password string
extensions []string
}
type expectResult struct {
shouldFail bool
err error
@@ -111,19 +105,22 @@ func TestService_ListFiles_GitHub(t *testing.T) {
username := getRequiredValue(t, "GITHUB_USERNAME")
tests := []struct {
name string
args args
expect expectResult
name string
args fetchOption
extensions []string
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: "test-username",
password: "test-token",
},
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -131,13 +128,15 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: args{
repositoryUrl: privateGitRepoURL + "fake",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: "",
password: "",
},
referenceName: "refs/heads/main",
username: "",
password: "",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -145,13 +144,15 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
err: nil,
matchedCount: 15,
@@ -159,13 +160,15 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref and existing file extension",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"yml"},
},
extensions: []string{"yml"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -173,13 +176,15 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref and non-existing file extension",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"hcl"},
},
extensions: []string{"hcl"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -187,26 +192,30 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
},
},
{
name: "list tree with fake repository ",
args: args{
repositoryUrl: privateGitRepoURL + "fake",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrIncorrectRepositoryURL,
@@ -221,9 +230,10 @@ func TestService_ListFiles_GitHub(t *testing.T) {
tt.args.referenceName,
tt.args.username,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.args.extensions,
tt.extensions,
false,
)
if tt.expect.shouldFail {
@@ -255,6 +265,7 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -267,6 +278,7 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -285,7 +297,7 @@ func TestService_purgeCache_Github(t *testing.T) {
username := getRequiredValue(t, "GITHUB_USERNAME")
service := NewService(context.TODO())
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
_, err = service.ListFiles(
@@ -293,6 +305,7 @@ func TestService_purgeCache_Github(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -318,13 +331,14 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
// 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result
service := newService(context.TODO(), 2, 40*timeout)
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
_, err = service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -361,12 +375,12 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
service := newService(context.TODO(), 2, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
require.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
}
@@ -379,7 +393,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
service := newService(context.TODO(), 2, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len())
@@ -389,6 +403,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -403,6 +418,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
"refs/heads/test",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -412,11 +428,11 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
assert.GreaterOrEqual(t, len(files), 1)
assert.Equal(t, 2, service.repoFileCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
require.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", true, false)
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, true, false)
require.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
// The relevant file caches should be removed too
@@ -435,6 +451,7 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -449,6 +466,7 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
"refs/heads/main",
username,
"fake-token",
gittypes.GitCredentialAuthType_Basic,
false,
true,
[]string{},
@@ -477,6 +495,7 @@ func TestService_CloneRepository_TokenAuth(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Token,
false,
)
+179 -67
View File
@@ -3,16 +3,18 @@ package git
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -20,9 +22,9 @@ import (
func setup(t *testing.T) string {
dir := t.TempDir()
bareRepoDir := filepath.Join(dir, "test-clone.git")
bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git")
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0o755)
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
if err != nil {
t.Fatal(errors.Wrap(err, "failed to open an archive"))
}
@@ -41,7 +43,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) {
dir := t.TempDir()
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
require.NoError(t, err)
assert.Equal(t, 1, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
}
@@ -53,9 +55,32 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
dir := t.TempDir()
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
require.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git"))
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_cloneRepository(t *testing.T) {
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
repositoryURL := setup(t)
referenceName := "refs/heads/main"
dir := t.TempDir()
t.Logf("Cloning into %s", dir)
err := service.cloneRepository(dir, cloneOption{
fetchOption: fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
},
referenceName: referenceName,
},
depth: 10,
})
require.NoError(t, err)
assert.Equal(t, 4, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
}
func Test_latestCommitID(t *testing.T) {
@@ -64,7 +89,7 @@ func Test_latestCommitID(t *testing.T) {
repositoryURL := setup(t)
referenceName := "refs/heads/main"
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false)
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
require.NoError(t, err)
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
@@ -75,7 +100,7 @@ func Test_ListRefs(t *testing.T) {
repositoryURL := setup(t)
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
fs, err := service.ListRefs(repositoryURL, "", "", gittypes.GitCredentialAuthType_Basic, false, false)
require.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main"}, fs)
@@ -92,6 +117,7 @@ func Test_ListFiles(t *testing.T) {
referenceName,
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{".yml"},
@@ -124,6 +150,112 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
return count
}
func Test_noSymlinkFS_Symlink(t *testing.T) {
fs := NewNoSymlinkFS(osfs.New(t.TempDir()))
err := fs.Symlink("../../../etc/passwd", "evil-link")
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_noSymlinkFS_OtherOperations(t *testing.T) {
dir := t.TempDir()
fs := NewNoSymlinkFS(osfs.New(dir))
f, err := fs.Create("test.txt")
require.NoError(t, err)
_, err = f.Write([]byte("hello"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
info, err := fs.Stat("test.txt")
require.NoError(t, err)
require.Equal(t, "test.txt", info.Name())
}
func createBareRepoWithSymlink(t *testing.T) string {
t.Helper()
bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git")
repo, err := git.PlainInit(bareDir, true)
require.NoError(t, err)
storer := repo.Storer
fileBlob := &plumbing.MemoryObject{}
fileBlob.SetType(plumbing.BlobObject)
_, err = fileBlob.Write([]byte("hello world\n"))
require.NoError(t, err)
fileHash, err := storer.SetEncodedObject(fileBlob)
require.NoError(t, err)
symlinkBlob := &plumbing.MemoryObject{}
symlinkBlob.SetType(plumbing.BlobObject)
_, err = symlinkBlob.Write([]byte("../../../etc/passwd"))
require.NoError(t, err)
symlinkHash, err := storer.SetEncodedObject(symlinkBlob)
require.NoError(t, err)
tree := &object.Tree{
Entries: []object.TreeEntry{
{Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash},
{Name: "file.txt", Mode: filemode.Regular, Hash: fileHash},
},
}
treeObj := &plumbing.MemoryObject{}
err = tree.Encode(treeObj)
require.NoError(t, err)
treeHash, err := storer.SetEncodedObject(treeObj)
require.NoError(t, err)
sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}
commit := &object.Commit{
Message: "add symlink",
Author: sig,
Committer: sig,
TreeHash: treeHash,
}
commitObj := &plumbing.MemoryObject{}
err = commit.Encode(commitObj)
require.NoError(t, err)
commitHash, err := storer.SetEncodedObject(commitObj)
require.NoError(t, err)
err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash))
require.NoError(t, err)
err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main"))
require.NoError(t, err)
return bareDir
}
func Test_Download_RejectsSymlink(t *testing.T) {
client := NewGitClient(false)
repoURL := createBareRepoWithSymlink(t)
err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{
URL: repoURL,
Depth: 1,
SingleBranch: true,
Tags: git.NoTags,
})
require.Error(t, err)
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_listRefsPrivateRepository(t *testing.T) {
ensureIntegrationTest(t)
@@ -132,12 +264,6 @@ func Test_listRefsPrivateRepository(t *testing.T) {
client := NewGitClient(false)
type args struct {
repositoryUrl string
username string
password string
}
type expectResult struct {
err error
refsCount int
@@ -145,12 +271,12 @@ func Test_listRefsPrivateRepository(t *testing.T) {
tests := []struct {
name string
args args
args baseOption
expect expectResult
}{
{
name: "list refs of a real private repository",
args: args{
args: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
@@ -162,7 +288,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
},
{
name: "list refs of a real private repository with incorrect credential",
args: args{
args: baseOption{
repositoryUrl: privateGitRepoURL,
username: "test-username",
password: "test-token",
@@ -173,7 +299,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
},
{
name: "list refs of a fake repository without providing credential",
args: args{
args: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: "",
password: "",
@@ -184,7 +310,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
},
{
name: "list refs of a fake repository",
args: args{
args: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: username,
password: accessToken,
@@ -197,14 +323,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := &git.ListOptions{}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
refs, err := client.ListRefs(context.TODO(), tt.args.repositoryUrl, option)
refs, err := client.listRefs(context.TODO(), tt.args)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -223,13 +342,6 @@ func Test_listFilesPrivateRepository(t *testing.T) {
client := NewGitClient(false)
type args struct {
repositoryUrl string
referenceName string
username string
password string
}
type expectResult struct {
shouldFail bool
err error
@@ -241,16 +353,18 @@ func Test_listFilesPrivateRepository(t *testing.T) {
tests := []struct {
name string
args args
args fetchOption
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: "test-username",
password: "test-token",
},
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
},
expect: expectResult{
shouldFail: true,
@@ -259,11 +373,13 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: "",
password: "",
},
referenceName: "refs/heads/main",
username: "",
password: "",
},
expect: expectResult{
shouldFail: true,
@@ -272,11 +388,13 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/heads/main",
username: username,
password: accessToken,
},
expect: expectResult{
err: nil,
@@ -285,11 +403,13 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: args{
repositoryUrl: privateGitRepoURL,
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -297,11 +417,13 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with fake repository ",
args: args{
repositoryUrl: privateGitRepoURL + "fake",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: username,
password: accessToken,
},
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -312,17 +434,7 @@ func Test_listFilesPrivateRepository(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := &git.CloneOptions{
URL: tt.args.repositoryUrl,
ReferenceName: plumbing.ReferenceName(tt.args.referenceName),
}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
paths, err := client.ListFiles(context.TODO(), false, option)
paths, err := client.listFiles(context.TODO(), tt.args)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
+85 -55
View File
@@ -7,10 +7,8 @@ import (
"sync"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
lru "github.com/hashicorp/golang-lru"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
@@ -20,18 +18,40 @@ const (
repositoryCacheTTL = 5 * time.Minute
)
type RepoManager interface {
Download(ctx context.Context, dst string, opt *git.CloneOptions) error
LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error)
ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error)
ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error)
// baseOption provides a minimum group of information to operate a git repository, like git-remote
type baseOption struct {
repositoryUrl string
username string
password string
authType gittypes.GitCredentialAuthType
tlsSkipVerify bool
}
// fetchOption allows to specify the reference name of the target repository
type fetchOption struct {
baseOption
referenceName string
dirOnly bool
}
// cloneOption allows to add a history truncated to the specified number of commits
type cloneOption struct {
fetchOption
depth int
}
type repoManager interface {
download(ctx context.Context, dst string, opt cloneOption) error
latestCommitID(ctx context.Context, opt fetchOption) (string, error)
listRefs(ctx context.Context, opt baseOption) ([]string, error)
listFiles(ctx context.Context, opt fetchOption) ([]string, error)
}
// Service represents a service for managing Git.
type Service struct {
shutdownCtx context.Context
azure RepoManager
git RepoManager
azure repoManager
git repoManager
timerStopped bool
mut sync.Mutex
@@ -111,47 +131,61 @@ func (service *Service) CloneRepository(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
gitOptions := &git.CloneOptions{
URL: repositoryURL,
Depth: 1,
InsecureSkipTLS: tlsSkipVerify,
Auth: GetBasicAuth(username, password),
Tags: git.NoTags,
options := cloneOption{
fetchOption: fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
},
referenceName: referenceName,
},
depth: 1,
}
if referenceName != "" {
gitOptions.ReferenceName = plumbing.ReferenceName(referenceName)
}
return service.repoManager(repositoryURL).Download(context.TODO(), destination, gitOptions)
return service.cloneRepository(destination, options)
}
func (service *Service) repoManager(repositoryURL string) RepoManager {
func (service *Service) repoManager(options baseOption) repoManager {
repoManager := service.git
if IsAzureUrl(repositoryURL) {
if isAzureUrl(options.repositoryUrl) {
repoManager = service.azure
}
return repoManager
}
func (service *Service) cloneRepository(destination string, options cloneOption) error {
return service.repoManager(options.baseOption).download(context.TODO(), destination, options)
}
// LatestCommitID returns SHA1 of the latest commit of the specified reference
func (service *Service) LatestCommitID(
repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
listOptions := &git.ListOptions{
Auth: GetBasicAuth(username, password),
InsecureSkipTLS: tlsSkipVerify,
options := fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
},
referenceName: referenceName,
}
return service.repoManager(repositoryURL).LatestCommitID(context.TODO(), repositoryURL, referenceName, listOptions)
return service.repoManager(options.baseOption).latestCommitID(context.TODO(), options)
}
// ListRefs will list target repository's references without cloning the repository
@@ -159,6 +193,7 @@ func (service *Service) ListRefs(
repositoryURL,
username,
password string,
authType gittypes.GitCredentialAuthType,
hardRefresh bool,
tlsSkipVerify bool,
) ([]string, error) {
@@ -183,12 +218,15 @@ func (service *Service) ListRefs(
}
}
options := &git.ListOptions{
Auth: GetBasicAuth(username, password),
InsecureSkipTLS: tlsSkipVerify,
options := baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
}
refs, err := service.repoManager(repositoryURL).ListRefs(context.TODO(), repositoryURL, options)
refs, err := service.repoManager(options).listRefs(context.TODO(), options)
if err != nil {
return nil, err
}
@@ -209,6 +247,7 @@ func (service *Service) ListFiles(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
includedExts []string,
@@ -220,6 +259,7 @@ func (service *Service) ListFiles(
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
strconv.FormatBool(dirOnly),
)
@@ -229,6 +269,7 @@ func (service *Service) ListFiles(
referenceName,
username,
password,
authType,
dirOnly,
hardRefresh,
tlsSkipVerify,
@@ -243,6 +284,7 @@ func (service *Service) listFiles(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
tlsSkipVerify bool,
@@ -253,6 +295,7 @@ func (service *Service) listFiles(
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
strconv.FormatBool(dirOnly),
)
@@ -270,18 +313,19 @@ func (service *Service) listFiles(
}
}
cloneOption := &git.CloneOptions{
URL: repositoryURL,
NoCheckout: true,
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.ReferenceName(referenceName),
Auth: GetBasicAuth(username, password),
InsecureSkipTLS: tlsSkipVerify,
Tags: git.NoTags,
options := fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
},
referenceName: referenceName,
dirOnly: dirOnly,
}
files, err := service.repoManager(repositoryURL).ListFiles(context.TODO(), dirOnly, cloneOption)
files, err := service.repoManager(options.baseOption).listFiles(context.TODO(), options)
if err != nil {
return nil, err
}
@@ -336,17 +380,3 @@ func filterFiles(paths []string, includedExts []string) []string {
return includedFiles
}
func GetBasicAuth(username, password string) *githttp.BasicAuth {
if password != "" {
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
}
return nil
}
+12 -3
View File
@@ -7,6 +7,14 @@ import (
var (
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
)
type GitCredentialAuthType int
const (
GitCredentialAuthType_Basic GitCredentialAuthType = iota
GitCredentialAuthType_Token
)
// RepoConfig represents a configuration for a repo
@@ -26,10 +34,11 @@ type RepoConfig struct {
}
type GitAuthentication struct {
Username string
Password string
Username string
Password string
AuthorizationType GitCredentialAuthType
// Git credentials identifier when the value is not 0
// When the value is 0, Username and Password are set without using saved credential
// When the value is 0, Username, Password, and Authtype are set without using saved credential
// This is introduced since 2.15.0
GitCredentialID int `example:"0"`
}
+5
View File
@@ -34,6 +34,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
gitConfig.ReferenceName,
username,
password,
gittypes.GitCredentialAuthType_Basic,
gitConfig.TLSSkipVerify,
)
if err != nil {
@@ -68,6 +69,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
cloneParams.auth = &gitAuth{
username: username,
password: password,
authType: gitConfig.Authentication.AuthorizationType,
}
}
@@ -95,6 +97,7 @@ type cloneRepositoryParameters struct {
}
type gitAuth struct {
authType gittypes.GitCredentialAuthType
username string
password string
}
@@ -107,6 +110,7 @@ func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepos
cloneParams.ref,
cloneParams.auth.username,
cloneParams.auth.password,
cloneParams.auth.authType,
cloneParams.tlsSkipVerify,
)
}
@@ -117,6 +121,7 @@ func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepos
cloneParams.ref,
"",
"",
gittypes.GitCredentialAuthType_Basic,
cloneParams.tlsSkipVerify,
)
}
+23
View File
@@ -0,0 +1,23 @@
package git
import (
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/validate"
)
func ValidateRepoConfig(repoConfig *gittypes.RepoConfig) error {
if len(repoConfig.URL) == 0 || !validate.IsURL(repoConfig.URL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
return ValidateRepoAuthentication(repoConfig.Authentication)
}
func ValidateRepoAuthentication(auth *gittypes.GitAuthentication) error {
if auth != nil && len(auth.Password) == 0 && auth.GitCredentialID == 0 {
return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password or GitCredentialID must be specified when authentication is enabled")
}
return nil
}
@@ -46,6 +46,7 @@ func (g *TestGitService) CloneRepository(
referenceName string,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
time.Sleep(100 * time.Millisecond)
@@ -58,6 +59,7 @@ func (g *TestGitService) LatestCommitID(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
return "", nil
@@ -82,6 +84,7 @@ func (g *InvalidTestGitService) CloneRepository(
refName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
return errors.New("simulate network error")
@@ -92,6 +95,7 @@ func (g *InvalidTestGitService) LatestCommitID(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
return "", nil
@@ -45,6 +45,8 @@ type customTemplateUpdatePayload struct {
// Password used in basic authentication or token used in token authentication.
// Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0
RepositoryPassword string `example:"myGitPassword"`
// RepositoryAuthorizationType is the authorization type to use
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
// GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
// is true and RepositoryUsername/RepositoryPassword are not provided
RepositoryGitCredentialID int `example:"0"`
@@ -182,12 +184,15 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
repositoryUsername := ""
repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if payload.RepositoryAuthentication {
repositoryUsername = payload.RepositoryUsername
repositoryPassword = payload.RepositoryPassword
repositoryAuthType = payload.RepositoryAuthorizationType
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
AuthorizationType: payload.RepositoryAuthorizationType,
}
}
@@ -197,6 +202,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
ReferenceName: gitConfig.ReferenceName,
Username: repositoryUsername,
Password: repositoryPassword,
AuthType: repositoryAuthType,
TLSSkipVerify: gitConfig.TLSSkipVerify,
})
if err != nil {
@@ -210,6 +216,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
gitConfig.ReferenceName,
repositoryUsername,
repositoryPassword,
repositoryAuthType,
gitConfig.TLSSkipVerify,
)
if err != nil {
@@ -1,6 +1,7 @@
package containers
import (
"context"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -46,7 +47,7 @@ func (handler *Handler) recreate(w http.ResponseWriter, r *http.Request) *httper
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
newContainer, err := handler.containerService.Recreate(r.Context(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
newContainer, err := handler.containerService.Recreate(context.TODO(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
if err != nil {
return httperror.InternalServerError("Error recreating container", err)
}
@@ -34,6 +34,8 @@ type edgeStackFromGitRepositoryPayload struct {
RepositoryUsername string `example:"myGitUsername"`
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// RepositoryAuthorizationType is the authorization type to use
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
// Path to the Stack file inside the Git repository
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// List of identifiers of EdgeGroups
@@ -126,8 +128,9 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
if payload.RepositoryAuthentication {
repoConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
AuthorizationType: payload.RepositoryAuthorizationType,
}
}
@@ -149,9 +152,11 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
repositoryUsername := ""
repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" {
repositoryUsername = repositoryConfig.Authentication.Username
repositoryPassword = repositoryConfig.Authentication.Password
repositoryAuthType = repositoryConfig.Authentication.AuthorizationType
}
if err := handler.GitService.CloneRepository(
@@ -160,6 +165,7 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
repositoryConfig.ReferenceName,
repositoryUsername,
repositoryPassword,
repositoryAuthType,
repositoryConfig.TLSSkipVerify,
); err != nil {
return "", "", "", err
@@ -18,7 +18,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/google/uuid"
"github.com/gofrs/uuid"
)
type endpointCreatePayload struct {
@@ -405,7 +405,7 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay
}
if settings.EnforceEdgeID {
edgeID, err := uuid.NewRandom()
edgeID, err := uuid.NewV4()
if err != nil {
return nil, httperror.InternalServerError("Cannot generate the Edge ID", err)
}
@@ -139,14 +139,14 @@ func Test_endpointList_edgeFilter(t *testing.T) {
"should show only trusted edge async agents and regular endpoints",
[]portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID},
},
edgeAsync: new(true),
edgeAsync: BoolAddr(true),
},
{
endpointListTest: endpointListTest{
"should show only untrusted edge devices and regular endpoints",
[]portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID},
},
edgeAsync: new(true),
edgeAsync: BoolAddr(true),
edgeDeviceUntrusted: true,
},
{
@@ -154,7 +154,7 @@ func Test_endpointList_edgeFilter(t *testing.T) {
"should show no edge devices",
[]portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID},
},
edgeAsync: new(false),
edgeAsync: BoolAddr(false),
},
}
@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -120,41 +119,27 @@ func (handler *Handler) updateRegistryAccess(tx dataservices.DataStoreTx, r *htt
}
func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error {
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return err
}
return applyKubeRegistryAccess(cli, registry, oldNamespaces, newNamespaces)
}
func applyKubeRegistryAccess(cli portainer.KubeClient, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error {
oldNamespacesSet := toSet(oldNamespaces)
newNamespacesSet := toSet(newNamespaces)
namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet)
namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet)
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return err
}
for namespace := range namespacesToRemove {
secretName := registryutils.RegistrySecretName(registry.ID)
if err := cli.RemoveImagePullSecretFromServiceAccount(namespace, "default", secretName); err != nil {
return err
}
if err := cli.DeleteRegistrySecret(registry.ID, namespace); err != nil {
err := cli.DeleteRegistrySecret(registry.ID, namespace)
if err != nil {
return err
}
}
for namespace := range namespacesToAdd {
secretName := registryutils.RegistrySecretName(registry.ID)
if err := cli.CreateRegistrySecret(registry, namespace); err != nil {
return err
}
if err := cli.AddImagePullSecretToServiceAccount(namespace, "default", secretName); err != nil {
err := cli.CreateRegistrySecret(registry, namespace)
if err != nil {
return err
}
}
@@ -1,166 +0,0 @@
package endpoints
import (
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// spyKubeClient implements portainer.KubeClient for testing applyKubeRegistryAccess.
// It embeds the interface so unimplemented methods panic, and overrides only the
// four methods exercised by applyKubeRegistryAccess.
type spyKubeClient struct {
portainer.KubeClient
createSecretErrors map[string]error
deleteSecretErrors map[string]error
addPullSecretErrors map[string]error
removePullSecretErrors map[string]error
createdSecrets []string
deletedSecrets []string
addedPullSecrets []string
removedPullSecrets []string
}
func newSpyKubeClient() *spyKubeClient {
return &spyKubeClient{
createSecretErrors: make(map[string]error),
deleteSecretErrors: make(map[string]error),
addPullSecretErrors: make(map[string]error),
removePullSecretErrors: make(map[string]error),
}
}
func (s *spyKubeClient) CreateRegistrySecret(_ *portainer.Registry, namespace string) error {
s.createdSecrets = append(s.createdSecrets, namespace)
return s.createSecretErrors[namespace]
}
func (s *spyKubeClient) DeleteRegistrySecret(_ portainer.RegistryID, namespace string) error {
s.deletedSecrets = append(s.deletedSecrets, namespace)
return s.deleteSecretErrors[namespace]
}
func (s *spyKubeClient) AddImagePullSecretToServiceAccount(namespace, _, _ string) error {
s.addedPullSecrets = append(s.addedPullSecrets, namespace)
return s.addPullSecretErrors[namespace]
}
func (s *spyKubeClient) RemoveImagePullSecretFromServiceAccount(namespace, _, _ string) error {
s.removedPullSecrets = append(s.removedPullSecrets, namespace)
return s.removePullSecretErrors[namespace]
}
var testRegistry = &portainer.Registry{ID: 3, URL: "registry.example.com"}
func TestApplyKubeRegistryAccess_Grant(t *testing.T) {
t.Run("single namespace granted creates secret then patches SA", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"})
require.NoError(t, err)
assert.Equal(t, []string{"ns-a"}, spy.createdSecrets)
assert.Equal(t, []string{"ns-a"}, spy.addedPullSecrets)
assert.Empty(t, spy.deletedSecrets)
assert.Empty(t, spy.removedPullSecrets)
})
t.Run("multiple namespaces granted applies to all", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a", "ns-b"})
require.NoError(t, err)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.createdSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.addedPullSecrets)
})
t.Run("CreateRegistrySecret fails - AddImagePullSecret not called", func(t *testing.T) {
spy := newSpyKubeClient()
spy.createSecretErrors["ns-a"] = errors.New("secret create failed")
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"})
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.createdSecrets)
assert.Empty(t, spy.addedPullSecrets)
})
t.Run("AddImagePullSecret fails after secret created - returns error", func(t *testing.T) {
spy := newSpyKubeClient()
spy.addPullSecretErrors["ns-a"] = errors.New("sa patch failed")
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"})
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.createdSecrets)
assert.Equal(t, []string{"ns-a"}, spy.addedPullSecrets)
})
}
func TestApplyKubeRegistryAccess_Revoke(t *testing.T) {
t.Run("single namespace revoked removes from SA then deletes secret", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil)
require.NoError(t, err)
assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-a"}, spy.deletedSecrets)
assert.Empty(t, spy.createdSecrets)
assert.Empty(t, spy.addedPullSecrets)
})
t.Run("multiple namespaces revoked applies to all", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a", "ns-b"}, nil)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets)
})
t.Run("RemoveImagePullSecret fails - DeleteRegistrySecret not called", func(t *testing.T) {
spy := newSpyKubeClient()
spy.removePullSecretErrors["ns-a"] = errors.New("sa remove failed")
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil)
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets)
assert.Empty(t, spy.deletedSecrets)
})
t.Run("DeleteRegistrySecret fails after SA patched - returns error", func(t *testing.T) {
spy := newSpyKubeClient()
spy.deleteSecretErrors["ns-a"] = errors.New("secret delete failed")
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil)
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-a"}, spy.deletedSecrets)
})
}
func TestApplyKubeRegistryAccess_Mixed(t *testing.T) {
t.Run("one namespace added and one removed in same call", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-old"}, []string{"ns-new"})
require.NoError(t, err)
assert.Equal(t, []string{"ns-old"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-old"}, spy.deletedSecrets)
assert.Equal(t, []string{"ns-new"}, spy.createdSecrets)
assert.Equal(t, []string{"ns-new"}, spy.addedPullSecrets)
})
t.Run("empty old and new namespaces - no operations performed", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, nil, nil)
require.NoError(t, err)
assert.Empty(t, spy.createdSecrets)
assert.Empty(t, spy.deletedSecrets)
assert.Empty(t, spy.addedPullSecrets)
assert.Empty(t, spy.removedPullSecrets)
})
t.Run("namespace present in both old and new - no operations performed for it", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-keep"}, []string{"ns-keep"})
require.NoError(t, err)
assert.Empty(t, spy.createdSecrets)
assert.Empty(t, spy.deletedSecrets)
assert.Empty(t, spy.addedPullSecrets)
assert.Empty(t, spy.removedPullSecrets)
})
}
@@ -26,8 +26,6 @@ type endpointSettingsUpdatePayload struct {
AllowContainerCapabilitiesForRegularUsers *bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
// Whether non-administrator should be able to use sysctl settings
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
// Whether non-administrator should be able to use security-opt settings
AllowSecurityOptForRegularUsers *bool `json:"allowSecurityOptForRegularUsers" example:"true"`
// Whether host management features are enabled
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
@@ -113,12 +111,6 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
}
if payload.AllowSecurityOptForRegularUsers != nil {
securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers
}
endpoint.SecuritySettings = securitySettings
if payload.EnableGPUManagement != nil {
endpoint.EnableGPUManagement = *payload.EnableGPUManagement
}
@@ -127,6 +119,8 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
endpoint.Gpus = payload.Gpus
}
endpoint.SecuritySettings = securitySettings
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
if err != nil {
return httperror.InternalServerError("Failed persisting environment in database", err)
+1 -1
View File
@@ -103,7 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
var edgeAsync *bool
edgeAsyncParam, _ := request.RetrieveQueryParameter(r, "edgeAsync", true)
if edgeAsyncParam != "" {
edgeAsync = new(edgeAsyncParam == "true")
edgeAsync = BoolAddr(edgeAsyncParam == "true")
}
edgeDeviceUntrusted, _ := request.RetrieveBooleanQueryParameter(r, "edgeDeviceUntrusted", true)
+3 -3
View File
@@ -106,14 +106,14 @@ func Test_Filter_edgeFilter(t *testing.T) {
"should show only trusted edge devices and other regular endpoints",
[]portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeAsync: new(true),
edgeAsync: BoolAddr(true),
},
},
{
"should show only untrusted edge devices and other regular endpoints",
[]portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeAsync: new(true),
edgeAsync: BoolAddr(true),
edgeDeviceUntrusted: true,
},
},
@@ -121,7 +121,7 @@ func Test_Filter_edgeFilter(t *testing.T) {
"should show no edge devices",
[]portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID},
EnvironmentsQuery{
edgeAsync: new(false),
edgeAsync: BoolAddr(false),
},
},
}
+1 -1
View File
@@ -61,7 +61,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
h.Handle("/endpoints/agent_versions",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
h.Handle("/endpoints/relations", bouncer.RestrictedAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
h.Handle("/endpoints/relations", bouncer.AdminAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
+7
View File
@@ -0,0 +1,7 @@
package endpoints
func ptr[T any](i T) *T { return &i }
func BoolAddr(b bool) *bool {
return ptr(b)
}
@@ -18,10 +18,11 @@ type fileResponse struct {
}
type repositoryFilePreviewPayload struct {
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
Reference string `json:"reference" example:"refs/heads/master"`
Username string `json:"username" example:"myGitUsername"`
Password string `json:"password" example:"myGitPassword"`
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
Reference string `json:"reference" example:"refs/heads/master"`
Username string `json:"username" example:"myGitUsername"`
Password string `json:"password" example:"myGitPassword"`
AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"`
// Path to file whose content will be read
TargetFile string `json:"targetFile" example:"docker-compose.yml"`
// TLSSkipVerify skips SSL verification when cloning the Git repository
@@ -75,6 +76,7 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
payload.Reference,
payload.Username,
payload.Password,
payload.AuthorizationType,
payload.TLSSkipVerify,
)
if err != nil {
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.40.0
// @version 2.39.2
// @description.markdown api-description.md
// @termsOfService
+103 -20
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/validation"
@@ -19,6 +19,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/pkg/errors"
"golang.org/x/sync/errgroup"
)
type installChartPayload struct {
@@ -94,7 +95,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
return fmt.Errorf("required field(s) missing: %s", strings.Join(required, ", "))
}
if err := validation.IsDNS1123Subdomain(p.Name); err != nil {
if errs := validation.IsDNS1123Subdomain(p.Name); len(errs) > 0 {
return errChartNameInvalid
}
@@ -107,23 +108,6 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
return nil, httperr.Err
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
}
var username string
if err := handler.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
user, err := tx.User().Read(tokenData.ID)
if err != nil {
return errors.Wrap(err, "unable to load user information from the database")
}
username = user.Username
return nil
}); err != nil {
return nil, err
}
installOpts := options.InstallOptions{
Name: p.Name,
Chart: p.Chart,
@@ -133,7 +117,6 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
Atomic: p.Atomic,
DryRun: dryRun,
KubernetesClusterAccess: clusterAccess,
HelmAppLabels: kubernetes.GetHelmAppLabels(p.Name, username),
}
if p.Values != "" {
@@ -164,5 +147,105 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
return nil, err
}
if !installOpts.DryRun {
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
if err != nil {
return nil, err
}
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
return nil, err
}
}
return release, nil
}
// applyPortainerLabelsToHelmAppManifest will patch all the resources deployed in the helm release manifest
// with portainer specific labels. This is to mark the resources as managed by portainer - hence the helm apps
// wont appear external in the portainer UI.
func (handler *Handler) applyPortainerLabelsToHelmAppManifest(r *http.Request, installOpts options.InstallOptions, manifest string) ([]byte, error) {
// Patch helm release by adding with portainer labels to all deployed resources
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
}
user, err := handler.dataStore.User().Read(tokenData.ID)
if err != nil {
return nil, errors.Wrap(err, "unable to load user information from the database")
}
appLabels := kubernetes.GetHelmAppLabels(installOpts.Name, user.Username)
labeledManifest, err := kubernetes.AddAppLabels([]byte(manifest), appLabels)
if err != nil {
return nil, errors.Wrap(err, "failed to label helm release manifest")
}
return labeledManifest, nil
}
// updateHelmAppManifest will update the resources of helm release manifest with portainer labels using kubectl.
// The resources of the manifest will be updated in parallel and individuallly since resources of a chart
// can be deployed to different namespaces.
// NOTE: These updates will need to be re-applied when upgrading the helm release
func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte, namespace string) error {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return errors.Wrap(err, "unable to find an endpoint on request context")
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return errors.Wrap(err, "unable to retrieve user details from authentication token")
}
// Extract list of YAML resources from Helm manifest
yamlResources, err := kubernetes.ExtractDocuments(manifest, nil)
if err != nil {
return errors.Wrap(err, "unable to extract documents from helm release manifest")
}
// Deploy individual resources in parallel
g := new(errgroup.Group)
for _, resource := range yamlResources {
g.Go(func() error {
tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
if err != nil {
return errors.Wrap(err, "failed to create a tmp helm manifest file")
}
defer func() {
if err := tmpfile.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close tmp helm manifest file")
}
if err := os.Remove(tmpfile.Name()); err != nil {
log.Warn().Err(err).Msg("failed to remove tmp helm manifest file")
}
}()
if _, err := tmpfile.Write(resource); err != nil {
return errors.Wrap(err, "failed to write a tmp helm manifest file")
}
// get resource namespace, fallback to provided namespace if not explicit on resource
resourceNamespace, err := kubernetes.GetNamespace(resource)
if err != nil {
return err
}
if resourceNamespace == "" {
resourceNamespace = namespace
}
_, err = handler.kubernetesDeployer.Deploy(tokenData.ID, endpoint, []string{tmpfile.Name()}, resourceNamespace)
return err
})
}
if err := g.Wait(); err != nil {
return errors.Wrap(err, "unable to patch helm release using kubectl")
}
return nil
}
+1 -2
View File
@@ -113,7 +113,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/system", bouncer.AdminAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet)
@@ -124,7 +124,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/service_accounts/{name}", httperror.LoggerHandler(h.getKubernetesServiceAccount)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
@@ -41,47 +41,6 @@ func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r
return response.JSON(w, serviceAccounts)
}
// @id GetKubernetesServiceAccount
// @summary Get a kubernetes service account
// @description Get a kubernetes service account in the given namespace.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace"
// @param name path string true "Service account name"
// @success 200 {object} models.K8sServiceAccount "Success"
// @failure 400 "Invalid request"
// @failure 401 "Unauthorized"
// @failure 403 "Permission denied"
// @failure 404 "Service account not found"
// @failure 500 "Server error"
// @router /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name} [get]
func (handler *Handler) getKubernetesServiceAccount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return httperror.BadRequest("Invalid namespace", err)
}
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
return httperror.BadRequest("Invalid name", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
return httperror.InternalServerError("Unable to prepare kube client", httpErr)
}
sa, err := cli.GetServiceAccount(namespace, name)
if err != nil {
return httperror.InternalServerError("Unable to retrieve service account", err)
}
return response.JSON(w, sa)
}
// @id DeleteServiceAccounts
// @summary Delete service accounts
// @description Delete the provided list of service accounts.
@@ -1,140 +0,0 @@
package kubernetes
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeclient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newServiceAccountTestHandler(t *testing.T) (*Handler, *portainer.User, string) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.AgentOnKubernetesEnvironment,
})
require.NoError(t, err, "error creating environment")
u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole}
err = store.User().Create(u)
require.NoError(t, err, "error creating a user")
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err, "error initiating jwt service")
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
require.NoError(t, err)
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
srvURL, err := url.Parse(srv.URL)
require.NoError(t, err)
cli := testhelpers.NewKubernetesClient()
factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "")
require.NoError(t, err)
authorizationService := authorization.NewService(store)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli)
return handler, u, tk
}
func newServiceAccountRequest(t *testing.T, method, path string, body []byte, u *portainer.User, tk string) *http.Request {
t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
req = req.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
return req
}
func TestDeleteKubernetesServiceAccounts_ValidPayload(t *testing.T) {
handler, u, tk := newServiceAccountTestHandler(t)
payload := models.K8sServiceAccountDeleteRequests{
"default": {"sa-1", "sa-2"},
}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not return bad request for valid payload")
}
func TestDeleteKubernetesServiceAccounts_InvalidPayload(t *testing.T) {
handler, u, tk := newServiceAccountTestHandler(t)
payload := models.K8sServiceAccountDeleteRequests{}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for invalid payload")
bodyData, err := io.ReadAll(rr.Result().Body)
require.NoError(t, err)
assert.NotEmpty(t, string(bodyData), "should have error response body")
}
func TestDeleteKubernetesServiceAccounts_EmptyNamespace(t *testing.T) {
handler, u, tk := newServiceAccountTestHandler(t)
payload := models.K8sServiceAccountDeleteRequests{
"": {"sa-1"},
}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for empty namespace")
bodyData, err := io.ReadAll(rr.Result().Body)
require.NoError(t, err)
assert.NotEmpty(t, string(bodyData), "should have error response body")
}
+8 -25
View File
@@ -17,30 +17,6 @@ import (
"github.com/rs/zerolog/log"
)
// cleanupRegistryFromNamespaces removes the registry imagePullSecret from the
// default service account and deletes the registry secret in each namespace.
// It returns the list of namespaces that failed either operation so the caller
// can schedule a pending action for retry.
func cleanupRegistryFromNamespaces(cli portainer.KubeClient, registryID portainer.RegistryID, namespaces []string, endpointID portainer.EndpointID) []string {
secretName := registryutils.RegistrySecretName(registryID)
failed := make([]string, 0)
for _, ns := range namespaces {
if err := cli.RemoveImagePullSecretFromServiceAccount(ns, "default", secretName); err != nil {
failed = append(failed, ns)
log.Warn().Err(err).Msgf("Unable to remove registry secret from default service account in namespace %q for environment %d. Retrying offline", ns, endpointID)
continue
}
if err := cli.DeleteRegistrySecret(registryID, ns); err != nil {
failed = append(failed, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", secretName, ns, endpointID)
}
}
return failed
}
// @id RegistryDelete
// @summary Remove a registry
// @description Remove a registry
@@ -104,7 +80,14 @@ func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, reg
continue
}
failedNamespaces := cleanupRegistryFromNamespaces(cli, registry.ID, access.Namespaces, endpointId)
failedNamespaces := make([]string, 0)
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
}
}
if len(failedNamespaces) == 0 {
continue
@@ -1,220 +0,0 @@
package registries
import (
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// spyKubeClient for registry delete tests - same pattern as endpoint_registry_access_test.go
type deleteSpyKubeClient struct {
portainer.KubeClient
deleteSecretErrors map[string]error
removePullSecretErrors map[string]error
deletedSecrets []string
removedPullSecrets []string
}
func newDeleteSpy() *deleteSpyKubeClient {
return &deleteSpyKubeClient{
deleteSecretErrors: make(map[string]error),
removePullSecretErrors: make(map[string]error),
}
}
func (s *deleteSpyKubeClient) DeleteRegistrySecret(_ portainer.RegistryID, namespace string) error {
s.deletedSecrets = append(s.deletedSecrets, namespace)
return s.deleteSecretErrors[namespace]
}
func (s *deleteSpyKubeClient) RemoveImagePullSecretFromServiceAccount(namespace, _, _ string) error {
s.removedPullSecrets = append(s.removedPullSecrets, namespace)
return s.removePullSecretErrors[namespace]
}
// --- cleanupRegistryFromNamespaces unit tests ---
func TestCleanupRegistryFromNamespaces(t *testing.T) {
const registryID portainer.RegistryID = 3
const endpointID portainer.EndpointID = 1
t.Run("all namespaces succeed - returns empty failed list", func(t *testing.T) {
spy := newDeleteSpy()
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.Empty(t, failed)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets)
})
t.Run("SA removal fails - namespace in failed list and secret not deleted", func(t *testing.T) {
spy := newDeleteSpy()
spy.removePullSecretErrors["ns-a"] = errors.New("sa error")
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.Equal(t, []string{"ns-a"}, failed)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-b"}, spy.deletedSecrets, "ns-a secret must not be deleted when SA removal fails")
})
t.Run("secret deletion fails - namespace in failed list", func(t *testing.T) {
spy := newDeleteSpy()
spy.deleteSecretErrors["ns-a"] = errors.New("delete error")
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.Equal(t, []string{"ns-a"}, failed)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets)
})
t.Run("both operations fail for all namespaces - all in failed list", func(t *testing.T) {
spy := newDeleteSpy()
spy.removePullSecretErrors["ns-a"] = errors.New("err")
spy.removePullSecretErrors["ns-b"] = errors.New("err")
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, failed)
assert.Empty(t, spy.deletedSecrets)
})
t.Run("empty namespace list - returns empty failed list", func(t *testing.T) {
spy := newDeleteSpy()
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{}, endpointID)
assert.Empty(t, failed)
assert.Empty(t, spy.removedPullSecrets)
assert.Empty(t, spy.deletedSecrets)
})
}
// --- deleteKubernetesSecrets integration tests ---
func TestDeleteKubernetesSecrets(t *testing.T) {
const registryID portainer.RegistryID = 3
const endpointID portainer.EndpointID = 1
newHandlerWithFakeK8s := func(t *testing.T, endpoint *portainer.Endpoint, registry *portainer.Registry) (*Handler, *datastore.Store) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
require.NoError(t, store.Endpoint().Create(endpoint))
require.NoError(t, store.Registry().Create(registry))
defaultSA := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"},
}
fakeK8s := kfake.NewSimpleClientset(defaultSA)
factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s))
pas := pendingactions.NewService(store, nil)
h := &Handler{
DataStore: store,
K8sClientFactory: factory,
PendingActionsService: pas,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
return h, store
}
t.Run("GetPrivilegedKubeClient fails - no pending action created", func(t *testing.T) {
// KubernetesLocalEnvironment calls rest.InClusterConfig() which fails outside
// a real cluster, causing GetPrivilegedKubeClient to return an error gracefully.
endpoint := &portainer.Endpoint{
ID: endpointID,
Name: "test-env",
Type: portainer.KubernetesLocalEnvironment,
}
registry := &portainer.Registry{
ID: registryID,
RegistryAccesses: portainer.RegistryAccesses{
endpointID: portainer.RegistryAccessPolicies{Namespaces: []string{"ns-a"}},
},
}
_, store := datastore.MustNewTestStore(t, true, false)
require.NoError(t, store.Endpoint().Create(endpoint))
require.NoError(t, store.Registry().Create(registry))
// Empty factory: endpoint not in cache, CreateConfig will fail → returns error, not panic
emptyFactory, err := kubecli.NewClientFactory(nil, nil, nil, "test", "", "")
require.NoError(t, err)
pas := pendingactions.NewService(store, nil)
h := &Handler{
DataStore: store,
K8sClientFactory: emptyFactory,
PendingActionsService: pas,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
h.deleteKubernetesSecrets(store, registry)
actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true })
require.NoError(t, err)
assert.Empty(t, actions, "no pending action should be created when kube client cannot be obtained")
})
t.Run("all namespaces succeed - no pending action created", func(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: endpointID,
Name: "test-env",
Type: portainer.AgentOnKubernetesEnvironment,
}
registry := &portainer.Registry{
ID: registryID,
RegistryAccesses: portainer.RegistryAccesses{
endpointID: portainer.RegistryAccessPolicies{Namespaces: []string{"ns-a"}},
},
}
_, store := datastore.MustNewTestStore(t, true, false)
require.NoError(t, store.Endpoint().Create(endpoint))
require.NoError(t, store.Registry().Create(registry))
defaultSA := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"},
}
fakeK8s := kfake.NewSimpleClientset(defaultSA)
factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s))
pas := pendingactions.NewService(store, nil)
h := &Handler{
DataStore: store,
K8sClientFactory: factory,
PendingActionsService: pas,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
h.deleteKubernetesSecrets(store, registry)
actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true })
require.NoError(t, err)
assert.Empty(t, actions)
})
t.Run("registry with no Kubernetes namespaces - no operations attempted", func(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: endpointID,
Name: "test-env",
Type: portainer.AgentOnKubernetesEnvironment,
}
registry := &portainer.Registry{
ID: registryID,
RegistryAccesses: portainer.RegistryAccesses{
endpointID: portainer.RegistryAccessPolicies{Namespaces: nil},
},
}
h, store := newHandlerWithFakeK8s(t, endpoint, registry)
h.deleteKubernetesSecrets(store, registry)
actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true })
require.NoError(t, err)
assert.Empty(t, actions)
})
}
@@ -16,6 +16,8 @@ import (
"github.com/stretchr/testify/require"
)
func ptr[T any](i T) *T { return &i }
func TestHandler_registryUpdate(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
@@ -25,12 +27,12 @@ func TestHandler_registryUpdate(t *testing.T) {
require.NoError(t, err)
payload := registryUpdatePayload{
Name: new("Updated test registry"),
URL: new("http://example.org/feed"),
BaseURL: new("http://example.org"),
Authentication: new(true),
Username: new("username"),
Password: new("password"),
Name: ptr("Updated test registry"),
URL: ptr("http://example.org/feed"),
BaseURL: ptr("http://example.org"),
Authentication: ptr(true),
Username: ptr("username"),
Password: ptr("password"),
}
payloadBytes, err := json.Marshal(payload)
+7 -7
View File
@@ -2,7 +2,6 @@ package stacks
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
@@ -17,6 +16,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
@@ -215,7 +215,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
}
}
return fmt.Errorf("failed to remove kubernetes resources: %q: %w", out, err)
return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out)
}
return fmt.Errorf("unsupported stack type: %v", stack.Type)
@@ -315,7 +315,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
log.Debug().Msgf("Trying to delete Kubernetes stacks `%v` for endpoint `%d`", stacksToDelete, endpointID)
var errs error
errors := make([]error, 0)
// Delete all the stacks one by one
for _, stack := range stacksToDelete {
log.Debug().Msgf("Trying to delete Kubernetes stack id `%d`", stack.ID)
@@ -328,27 +328,27 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
err = handler.deleteStack(securityContext.UserID, &stack, endpoint)
if err != nil {
log.Err(err).Msgf("Unable to delete Kubernetes stack `%d`", stack.ID)
errs = errors.Join(errs, err)
errors = append(errors, err)
continue
}
if err := handler.DataStore.Stack().Delete(stack.ID); err != nil {
errs = errors.Join(errs, err)
errors = append(errors, err)
log.Err(err).Msgf("Unable to remove the stack `%d` from the database", stack.ID)
continue
}
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
errs = errors.Join(errs, err)
errors = append(errors, err)
log.Warn().Err(err).Msg("Unable to remove stack files from disk")
}
log.Debug().Msgf("Kubernetes stack `%d` deleted", stack.ID)
}
if errs != nil {
if len(errors) > 0 {
return httperror.InternalServerError("Unable to delete some Kubernetes stack(s). Check Portainer logs for more details", nil)
}
-1
View File
@@ -199,7 +199,6 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
handler.DataStore,
handler.FileService,
handler.StackDeployer,
true,
false,
false)
if err != nil {
+1 -12
View File
@@ -26,8 +26,6 @@ type updateComposeStackPayload struct {
Env []portainer.Pair
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
// Prune services that are no longer referenced
Prune bool `example:"true"`
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
@@ -47,7 +45,7 @@ type updateSwarmStackPayload struct {
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"`
// A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair
// Prune services that are no longer referenced
// Prune services that are no longer referenced (only available for Swarm stacks)
Prune bool `example:"true"`
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
@@ -244,7 +242,6 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
endpoint,
handler.FileService,
handler.StackDeployer,
payload.Prune,
payload.RepullImageAndRedeploy,
payload.RepullImageAndRedeploy)
if err != nil {
@@ -255,14 +252,6 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
return httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
stack.Option.Prune = payload.Prune
} else {
stack.Option = &portainer.StackOption{
Prune: payload.Prune,
}
}
// Deploy the stack
if err := composeDeploymentConfig.Deploy(); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
+14 -39
View File
@@ -1,12 +1,10 @@
package stacks
import (
"cmp"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
httperrors "github.com/portainer/portainer/api/http/errors"
@@ -21,17 +19,15 @@ import (
)
type stackGitUpdatePayload struct {
AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair
Prune bool
RepositoryURL string
ConfigFilePath string
AdditionalFiles []string
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
TLSSkipVerify bool
AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair
Prune bool
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
RepositoryAuthorizationType gittypes.GitCredentialAuthType
TLSSkipVerify bool
}
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
@@ -142,30 +138,9 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
}
if stack.CurrentDeploymentInfo == nil && stack.GitConfig != nil {
stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{
RepositoryURL: stack.GitConfig.URL,
ConfigFilePath: stack.GitConfig.ConfigFilePath,
AdditionalFiles: stack.AdditionalFiles,
ConfigHash: stack.GitConfig.ConfigHash,
}
}
//update retrieved stack data based on the payload
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.GitConfig.TLSSkipVerify = payload.TLSSkipVerify
if payload.RepositoryURL != "" {
stack.GitConfig.URL = payload.RepositoryURL
}
if payload.ConfigFilePath != "" {
stack.GitConfig.ConfigFilePath = payload.ConfigFilePath
}
if payload.AdditionalFiles != nil {
stack.AdditionalFiles = payload.AdditionalFiles
}
stack.EntryPoint = cmp.Or(payload.ConfigFilePath, stack.EntryPoint)
stack.AutoUpdate = payload.AutoUpdate
stack.Env = payload.Env
stack.UpdatedBy = user.Username
@@ -185,8 +160,9 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
}
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: password,
Username: payload.RepositoryUsername,
Password: password,
AuthorizationType: payload.RepositoryAuthorizationType,
}
if _, err := handler.GitService.LatestCommitID(
@@ -194,6 +170,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
stack.GitConfig.ReferenceName,
stack.GitConfig.Authentication.Username,
stack.GitConfig.Authentication.Password,
stack.GitConfig.Authentication.AuthorizationType,
stack.GitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err)
@@ -211,9 +188,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
}
// Save the updated stack to DB
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Stack().Update(stack.ID, stack)
}); err != nil {
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
@@ -5,8 +5,8 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes"
@@ -20,12 +20,13 @@ import (
)
type stackGitRedployPayload struct {
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
Env []portainer.Pair
Prune bool
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
RepositoryAuthorizationType gittypes.GitCredentialAuthType
Env []portainer.Pair
Prune bool
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
@@ -129,11 +130,8 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.Env = payload.Env
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if stack.Option == nil {
stack.Option = &portainer.StackOption{}
}
stack.Option.Prune = payload.Prune
if stack.Type == portainer.DockerSwarmStack {
stack.Option = &portainer.StackOption{Prune: payload.Prune}
}
if stack.Type == portainer.KubernetesStack {
@@ -142,13 +140,16 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
repositoryUsername := ""
repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if payload.RepositoryAuthentication {
repositoryPassword = payload.RepositoryPassword
repositoryAuthType = payload.RepositoryAuthorizationType
// When the existing stack is using the custom username/password and the password is not updated,
// the stack should keep using the saved username/password
if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
repositoryPassword = stack.GitConfig.Authentication.Password
repositoryAuthType = stack.GitConfig.Authentication.AuthorizationType
}
repositoryUsername = payload.RepositoryUsername
}
@@ -159,6 +160,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
ReferenceName: stack.GitConfig.ReferenceName,
Username: repositoryUsername,
Password: repositoryPassword,
AuthType: repositoryAuthType,
TLSSkipVerify: stack.GitConfig.TLSSkipVerify,
}
@@ -173,7 +175,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return err
}
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, stack.GitConfig.TLSSkipVerify)
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryAuthType, stack.GitConfig.TLSSkipVerify)
if err != nil {
return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID))
}
@@ -183,20 +185,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if err != nil {
return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
}
stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{
RepositoryURL: stack.GitConfig.URL,
ConfigFilePath: stack.GitConfig.ConfigFilePath,
AdditionalFiles: stack.AdditionalFiles,
ConfigHash: stack.GitConfig.ConfigHash,
}
stack.UpdatedBy = user.Username
stack.UpdateDate = time.Now().Unix()
stack.Status = portainer.StackStatusActive
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Stack().Update(stack.ID, stack)
}); err != nil {
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", errors.Wrap(err, "failed to update the stack"))
}
@@ -233,9 +226,7 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pul
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
prune := stack.Option != nil && stack.Option.Prune
deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, prune, pullImage, true)
deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, pullImage, true)
if err != nil {
return httperror.InternalServerError(err.Error(), err)
}
@@ -13,13 +13,13 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/google/uuid"
"github.com/gofrs/uuid"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
webhook, err := uuid.NewRandom()
webhook, err := uuid.NewV4()
require.NoError(t, err)
_, store := datastore.MustNewTestStore(t, false, false)
+20 -74
View File
@@ -16,6 +16,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -247,7 +248,7 @@ func TestStackUpdate(t *testing.T) {
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testhelpers.NewTestStackDeployer()
handler.StackDeployer = testStackDeployer{}
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
handler.SwarmStackManager = swarmStackManager{}
@@ -317,11 +318,7 @@ type updateStackInTxTestSetup struct {
req *http.Request
}
type testUpdateStackPayload interface {
*updateComposeStackPayload | *updateSwarmStackPayload
}
func setupUpdateStackInTxTest[T testUpdateStackPayload](t *testing.T, stack *portainer.Stack, payload T) *updateStackInTxTestSetup {
func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *updateComposeStackPayload) *updateStackInTxTestSetup {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, true)
@@ -367,7 +364,7 @@ func setupUpdateStackInTxTest[T testUpdateStackPayload](t *testing.T, stack *por
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testhelpers.NewTestStackDeployer()
handler.StackDeployer = testStackDeployer{}
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
// Create mock request with security context
@@ -401,73 +398,22 @@ func (manager swarmStackManager) NormalizeStackName(name string) string {
return name
}
func Test_updateSwarmStack_Prune(t *testing.T) {
fips.InitFIPS(false)
payload := &updateSwarmStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Prune: true,
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-prune",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerSwarmStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
setup.handler.SwarmStackManager = swarmStackManager{}
deployer := testhelpers.NewTestStackDeployer()
setup.handler.StackDeployer = deployer
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
return nil
})
require.NoError(t, err, "handler should accept Prune=true and succeed")
stored, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err)
require.NotNil(t, stored.Option, "stack.Option should not be nil")
assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true")
assert.Equal(t, 1, deployer.DeploySwarmCallCount, "DeploySwarmStack should be called exactly once")
assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true")
type testStackDeployer struct {
deployments.StackDeployer
}
func Test_updateComposeStack_Prune(t *testing.T) {
fips.InitFIPS(false)
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Prune: true,
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-prune",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
deployer := testhelpers.NewTestStackDeployer()
setup.handler.StackDeployer = deployer
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
return nil
})
require.NoError(t, err, "handler should accept Prune=true and succeed")
stored, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err)
require.NotNil(t, stored.Option, "stack.Option should not be nil")
assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true")
assert.Equal(t, 1, deployer.DeployComposeCallCount, "DeployComposeStack should be called exactly once")
assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true")
func (testStackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
func (testStackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
func (testStackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
func (testStackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
@@ -27,12 +27,13 @@ type kubernetesFileStackUpdatePayload struct {
}
type kubernetesGitStackUpdatePayload struct {
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
AutoUpdate *portainer.AutoUpdateSettings
TLSSkipVerify bool
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
RepositoryAuthorizationType gittypes.GitCredentialAuthType
AutoUpdate *portainer.AutoUpdateSettings
TLSSkipVerify bool
}
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
@@ -76,8 +77,9 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
}
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: password,
Username: payload.RepositoryUsername,
Password: password,
AuthorizationType: payload.RepositoryAuthorizationType,
}
if _, err := handler.GitService.LatestCommitID(
@@ -85,6 +87,7 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
stack.GitConfig.ReferenceName,
stack.GitConfig.Authentication.Username,
stack.GitConfig.Authentication.Password,
stack.GitConfig.Authentication.AuthorizationType,
stack.GitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err)
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/google/uuid"
"github.com/gofrs/uuid"
)
// @id WebhookInvoke
@@ -56,7 +56,7 @@ func retrieveUUIDRouteVariableValue(r *http.Request, name string) (uuid.UUID, er
return uuid.Nil, err
}
uid, err := uuid.Parse(webhookID)
uid, err := uuid.FromString(webhookID)
if err != nil {
return uuid.Nil, err
}
@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/google/uuid"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -52,7 +52,7 @@ func TestHandler_webhookInvoke(t *testing.T) {
}
func newGuidString(t *testing.T) string {
uuid, err := uuid.NewRandom()
uuid, err := uuid.NewV4()
require.NoError(t, err)
return uuid.String()
+2 -2
View File
@@ -11,7 +11,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/Masterminds/semver/v3"
"github.com/coreos/go-semver/semver"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -109,5 +109,5 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
return false
}
return currentVersionSemver.LessThan(latestVersionSemver)
return currentVersionSemver.LessThan(*latestVersionSemver)
}
@@ -5,6 +5,7 @@ import (
"slices"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -77,6 +78,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
"",
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
); err != nil {
return httperror.InternalServerError("Unable to clone git repository", err)
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/google/uuid"
"github.com/gofrs/uuid"
)
type webhookCreatePayload struct {
@@ -86,7 +86,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
}
}
token, err := uuid.NewRandom()
token, err := uuid.NewV4()
if err != nil {
return httperror.InternalServerError("Error creating unique token", err)
}
-1
View File
@@ -28,7 +28,6 @@ import (
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
-1
View File
@@ -32,7 +32,6 @@ type execStartOperationPayload struct {
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 409
-1
View File
@@ -31,7 +31,6 @@ import (
// @param podName query string true "name of the pod containing the container"
// @param containerName query string true "name of the container"
// @param command query string true "command to execute in the container"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
+35 -9
View File
@@ -1,6 +1,7 @@
package websocket
import (
"context"
"net"
"net/http"
"net/url"
@@ -92,17 +93,42 @@ func (handler *Handler) doProxyWebsocketRequest(
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}
proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) {
netDialer := &net.Dialer{}
logoutCtx := logoutcontext.GetContext(tokenData.Token)
conn, err := netDialer.DialContext(logoutCtx, network, addr)
return conn, err
}
abortProxyOnLogout(r.Context(), proxy, tokenData.Token)
proxy.ServeHTTP(w, r)
return nil
}
func abortProxyOnLogout(ctx context.Context, proxy *websocketproxy.WebsocketProxy, token string) {
var wsConn net.Conn
proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) {
netDialer := &net.Dialer{}
conn, err := netDialer.DialContext(context.Background(), network, addr)
wsConn = conn
return conn, err
}
logoutCtx := logoutcontext.GetContext(token)
go func() {
log.Debug().Msg("logout watcher for websocket proxy started")
select {
case <-logoutCtx.Done():
log.Debug().Msg("logout watcher for websocket proxy stopped as user logged out")
if wsConn != nil {
if err := wsConn.Close(); err != nil {
log.Warn().
Err(err).
Msg("failed to close websocket connection on logout")
}
}
case <-ctx.Done():
log.Debug().Msg("logout watcher for websocket proxy stopped as the ws connection closed")
}
}()
}
-1
View File
@@ -18,7 +18,6 @@ import (
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
+5 -10
View File
@@ -5,21 +5,16 @@ import (
"net/http"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
)
type (
K8sServiceAccount struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
AutomountServiceAccountToken *bool `json:"automountServiceAccountToken,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
// K8sServiceAcountDeleteRequests is a mapping of namespace names to a slice of service account names.
@@ -1,122 +0,0 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestK8sServiceAccountDeleteRequests_Validate(t *testing.T) {
tests := []struct {
name string
payload K8sServiceAccountDeleteRequests
wantErr bool
errMsg string
}{
{
name: "empty payload returns error",
payload: K8sServiceAccountDeleteRequests{},
wantErr: true,
errMsg: "missing deletion request list in payload",
},
{
name: "valid single namespace with service accounts",
payload: K8sServiceAccountDeleteRequests{
"default": {"sa-1", "sa-2"},
},
wantErr: false,
},
{
name: "valid multiple namespaces",
payload: K8sServiceAccountDeleteRequests{
"default": {"sa-1"},
"kube-system": {"sa-2"},
"custom-ns": {"sa-3"},
},
wantErr: false,
},
{
name: "empty namespace key returns error",
payload: K8sServiceAccountDeleteRequests{
"": {"sa-1"},
},
wantErr: true,
errMsg: "deletion given with empty namespace",
},
{
name: "valid with empty service account list",
payload: K8sServiceAccountDeleteRequests{
"default": {},
},
wantErr: false,
},
{
name: "multiple namespaces with one empty returns error",
payload: K8sServiceAccountDeleteRequests{
"default": {"sa-1"},
"": {"sa-2"},
},
wantErr: true,
errMsg: "deletion given with empty namespace",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
err := tt.payload.Validate(req)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
} else {
require.NoError(t, err)
}
})
}
}
func TestK8sServiceAccount_Structure(t *testing.T) {
sa := K8sServiceAccount{
Name: "test-sa",
Namespace: "default",
IsSystem: false,
}
assert.Equal(t, "test-sa", sa.Name)
assert.Equal(t, "default", sa.Namespace)
assert.False(t, sa.IsSystem)
assert.Nil(t, sa.AutomountServiceAccountToken)
assert.Empty(t, sa.Labels)
assert.Empty(t, sa.Annotations)
}
func TestK8sServiceAccount_WithAllFields(t *testing.T) {
automountToken := true
sa := K8sServiceAccount{
Name: "full-sa",
Namespace: "production",
IsSystem: true,
Labels: map[string]string{
"app": "web",
"env": "prod",
},
Annotations: map[string]string{
"description": "service account for web",
},
AutomountServiceAccountToken: &automountToken,
}
assert.Equal(t, "full-sa", sa.Name)
assert.Equal(t, "production", sa.Namespace)
assert.True(t, sa.IsSystem)
assert.NotNil(t, sa.AutomountServiceAccountToken)
assert.True(t, *sa.AutomountServiceAccountToken)
assert.Len(t, sa.Labels, 2)
assert.Equal(t, "web", sa.Labels["app"])
assert.Len(t, sa.Annotations, 1)
assert.Equal(t, "service account for web", sa.Annotations["description"])
}
+56 -8
View File
@@ -25,7 +25,6 @@ var (
ErrPIDHostNamespaceForbidden = errors.New("forbidden to use pid host namespace")
ErrDeviceMappingForbidden = errors.New("forbidden to use device mapping")
ErrSysCtlSettingsForbidden = errors.New("forbidden to use sysctl settings")
ErrSecurityOptSettingsForbidden = errors.New("forbidden to use security-opt settings")
ErrContainerCapabilitiesForbidden = errors.New("forbidden to use container capabilities")
ErrBindMountsForbidden = errors.New("forbidden to use bind mounts")
)
@@ -91,7 +90,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec
// containerInspectOperation extracts the response as a JSON object, verify that the user
// has access to the container based on resource control and either rewrite an access denied response or a decorated container.
func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error {
// ContainerInspect response is a JSON object
//ContainerInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
@@ -117,7 +116,6 @@ func selectorContainerLabelsFromContainerInspectOperation(responseObject map[str
containerConfigObject := utils.GetJSONObject(responseObject, "Config")
if containerConfigObject != nil {
containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels")
return containerLabelsObject
}
@@ -180,11 +178,15 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Mounts []struct {
Type string `json:"Type"`
} `json:"Mounts"`
} `json:"HostConfig"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
Body: http.NoBody,
}
tokenData, err := security.RetrieveTokenData(request)
@@ -229,15 +231,11 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return forbiddenResponse, ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowSecurityOptForRegularUsers && len(partialContainer.HostConfig.SecurityOpt) > 0 {
return forbiddenResponse, ErrSecurityOptSettingsForbidden
}
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) {
return nil, ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) {
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 {
for _, bind := range partialContainer.HostConfig.Binds {
if strings.HasPrefix(bind, "/") {
return forbiddenResponse, ErrBindMountsForbidden
@@ -245,6 +243,14 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
}
}
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 {
for _, mount := range partialContainer.HostConfig.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, ErrBindMountsForbidden
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
}
@@ -259,3 +265,45 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return response, err
}
func (transport *Transport) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) {
type PartialContainerUpdate struct {
Devices []any `json:"Devices"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialUpdate := &PartialContainerUpdate{}
if err := json.Unmarshal(body, partialUpdate); err != nil {
return nil, err
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 {
return forbiddenResponse, ErrDeviceMappingForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
@@ -0,0 +1,123 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&admin)
require.NoError(t, err)
err = tx.User().Create(&regularUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Name: "test",
SecuritySettings: portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role}
makeRequest := func(token portainer.TokenData, body any) *http.Request {
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes))
req = req.WithContext(security.StoreTokenData(req, &token))
return req
}
// Admin bypasses security checks
req := makeRequest(adminToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Binds with an absolute path is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Binds": []string{"/:/host:ro"},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with type bind is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with a non-bind type is allowed for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
}
+96 -26
View File
@@ -3,13 +3,13 @@ package docker
import (
"bytes"
"context"
"errors"
"io"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
@@ -18,6 +18,70 @@ import (
const serviceObjectIdentifier = "ID"
type partialServiceSpec struct {
TaskTemplate struct {
ContainerSpec struct {
CapabilityAdd []string `json:"CapabilityAdd"`
CapabilityDrop []string `json:"CapabilityDrop"`
Sysctls map[string]any `json:"Sysctls"`
Privileges *struct {
Seccomp *struct{ Mode string } `json:"Seccomp"`
AppArmor *struct{ Mode string } `json:"AppArmor"`
} `json:"Privileges"`
Mounts []struct {
Type string `json:"Type"`
VolumeOptions *struct {
DriverConfig *struct {
Options map[string]string `json:"Options"`
} `json:"DriverConfig"`
} `json:"VolumeOptions"`
} `json:"Mounts"`
} `json:"ContainerSpec"`
} `json:"TaskTemplate"`
}
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
spec := &partialServiceSpec{}
if err := json.Unmarshal(body, spec); err != nil {
return err
}
containerSpec := spec.TaskTemplate.ContainerSpec
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) {
return ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 {
return ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers {
for _, mount := range containerSpec.Mounts {
if mount.Type == "bind" {
return ErrBindMountsForbidden
}
if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil {
if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" {
return ErrBindMountsForbidden
}
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
if err != nil {
@@ -90,20 +154,6 @@ func selectorServiceLabels(responseObject map[string]any) map[string]any {
}
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
type PartialService struct {
TaskTemplate struct {
ContainerSpec struct {
Mounts []struct {
Type string
}
}
}
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
@@ -118,25 +168,45 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
return nil, err
}
body, err := io.ReadAll(request.Body)
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")),
}, err
}
return transport.replaceRegistryAuthenticationHeader(request)
}
func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) {
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
partialService := &PartialService{}
if err := json.Unmarshal(body, partialService); err != nil {
if isAdminOrEndpointAdmin {
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.executeDockerRequest(request)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) {
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, errors.New("forbidden to use bind mounts")
}
}
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")),
}, err
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.replaceRegistryAuthenticationHeader(request)
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
}
@@ -0,0 +1,522 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/swarm"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const serviceCreationAPIVersion = "1.51"
type serviceCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
t.Helper()
const serviceID = "some-service-id"
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", serviceCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"ID": serviceID})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &serviceCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *serviceCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(spec)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
var (
restrictiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: false,
AllowSysctlSettingForRegularUsers: false,
AllowBindMountsForRegularUsers: false,
}
permissiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
}
)
func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityDrop: []string{"MKNOD"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
var spec swarm.ServiceSpec
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: false,
})
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeVolume}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
CapabilityDrop: []string{"MKNOD"},
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
Sysctls: map[string]string{"net.core.somaxconn": "128"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "tmpfs"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id")
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
@@ -17,10 +17,12 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker/client"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/slicesx"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
@@ -108,6 +110,28 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
"volumes": (*Transport).proxyVolumeRequest,
}
type route struct {
method string
pattern *regexp.Regexp
}
var adminOnlyRoutes = []route{
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/create$`)},
{http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)},
}
func isAdminOnlyRoute(method string, path string) bool {
return slicesx.Some(adminOnlyRoutes, func(r route) bool {
return method == r.method && r.pattern.MatchString(path)
})
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
@@ -136,6 +160,10 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
return proxyFunc(transport, request, unversionedPath)
}
if isAdminOnlyRoute(request.Method, unversionedPath) {
return transport.administratorOperation(request)
}
return transport.executeDockerRequest(request)
}
@@ -260,6 +288,11 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
if action == "json" {
return transport.rewriteOperation(request, transport.containerInspectOperation)
}
if action == "update" {
return transport.decorateContainerUpdateOperation(request, containerID)
}
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests
@@ -291,6 +324,11 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath))
action := path.Base(requestPath)
if action == "update" {
return transport.decorateServiceUpdateOperation(request, serviceID)
}
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
@@ -448,6 +486,7 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error
"",
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
)
if err != nil {
@@ -108,6 +108,141 @@ func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Serve
return srv, version
}
func TestTransport_adminProxy(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
// allowed routes
{http.MethodGet, "/plugins"}: nil,
{http.MethodGet, "/plugins/xxx/json"}: nil,
{http.MethodGet, "/plugins/privileges"}: nil,
// admin routes ; see `adminOnlyRoutes`
{http.MethodDelete, "/plugins/xxx"}: nil,
{http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs"
{http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs"
{http.MethodPost, "/plugins/xxx/disable"}: nil,
{http.MethodPost, "/plugins/pull"}: nil,
{http.MethodPost, "/plugins/xxx/push"}: nil,
{http.MethodPost, "/plugins/xxx/upgrade"}: nil,
{http.MethodPost, "/plugins/xxx/set"}: nil,
{http.MethodPost, "/plugins/create"}: nil,
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.ProxyDockerRequest(req)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
{
r, err := test(http.MethodGet, "/plugins", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}
func TestTransport_getRealResourceID(t *testing.T) {
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
+49
View File
@@ -1,9 +1,11 @@
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
@@ -15,6 +17,7 @@ import (
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const volumeObjectIdentifier = "ResourceID"
@@ -122,12 +125,58 @@ func selectorVolumeLabels(responseObject map[string]any) map[string]any {
return utils.GetJSONObject(responseObject, "Labels")
}
func CheckVolumeBodyRestrictions(request *http.Request) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
var volumeCreateBody struct {
DriverOpts map[string]string `json:"DriverOpts"`
}
if err := json.Unmarshal(body, &volumeCreateBody); err != nil {
return err
}
if volumeCreateBody.DriverOpts["type"] == "bind" {
return ErrBindMountsForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if !isAdminOrEndpointAdmin {
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers {
if err := CheckVolumeBodyRestrictions(request); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")),
}, err
}
}
}
volumeID := request.Header.Get("X-Portainer-VolumeName")
if volumeID != "" {
@@ -0,0 +1,226 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/volume"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const volumeCreationAPIVersion = "1.51"
type volumeCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures {
t.Helper()
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", volumeCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"Name": "test-volume"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &volumeCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *volumeCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "evil-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
"o": "bind",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "admin-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: true,
})
body := volume.CreateOptions{
Name: "allowed-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/data",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "normal-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "tmpfs",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
+2 -14
View File
@@ -447,26 +447,14 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
return tokenData, nil
}
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
// extractBearerToken extracts the Bearer token from the Authorization header and returns the token.
func extractBearerToken(r *http.Request) (string, bool) {
// Token might be set via the "token" query parameter.
// For example, in websocket requests
// For these cases, hide the token from the query
query := r.URL.Query()
token := query.Get("token")
if token != "" {
query.Del("token")
r.URL.RawQuery = query.Encode()
return token, true
}
tokens, ok := r.Header[jwtTokenHeader]
if !ok || len(tokens) == 0 {
return "", false
}
token = tokens[0]
token := tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
return token, true
+22
View File
@@ -0,0 +1,22 @@
package errorlist
import (
"errors"
"strings"
)
// Combine a slice of errors into a single error
// to use this, generate errors by appending to errorList in a loop, then return combine(errorList)
func Combine(errorList []error) error {
if len(errorList) == 0 {
return nil
}
var errorMsg strings.Builder
_, _ = errorMsg.WriteString("Multiple errors occurred:")
for _, err := range errorList {
_, _ = errorMsg.WriteString("\n" + err.Error())
}
return errors.New(errorMsg.String())
}
+41 -6
View File
@@ -1,6 +1,8 @@
package registryutils
import (
"context"
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
@@ -14,9 +16,12 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
}
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
func fetchRegToken(registry *portainer.Registry) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken(ctx)
if err != nil {
return err
}
@@ -24,12 +29,34 @@ func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) er
registry.AccessToken = *accessToken
registry.AccessTokenExpiry = expiryAt.Unix()
return nil
}
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if err := fetchRegToken(registry); err != nil {
return err
}
return tx.Registry().Update(registry.ID, registry)
}
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
// ValidateRegistriesECRTokens refreshes and persists ECR tokens for all registries that need it.
// Must be called with a real DataStoreTx (not a top-level DataStore) to avoid write-lock contention.
func ValidateRegistriesECRTokens(tx dataservices.DataStoreTx, registries []portainer.Registry) error {
for i := range registries {
reg := &registries[i]
if reg.Type != portainer.EcrRegistry {
continue
}
if isRegTokenValid(reg) {
continue
}
if err := doGetRegToken(tx, reg); err != nil {
return fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", reg.Name, err)
}
}
return nil
}
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
@@ -57,7 +84,15 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
password = registry.Password
if registry.Type == portainer.EcrRegistry {
username, password, err = parseRegToken(registry)
// Fallback token refresh in case the upstream caller did not pre-validate the token.
if !isRegTokenValid(registry) {
if err := fetchRegToken(registry); err != nil {
return "", "", fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", registry.Name, err)
}
}
username, password, err = ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
}
return
@@ -0,0 +1,131 @@
package registryutils_test
import (
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/stretchr/testify/require"
)
func newECRRegistry(id portainer.RegistryID, accessToken string, expiry int64) portainer.Registry {
return portainer.Registry{
ID: id,
Type: portainer.EcrRegistry,
Name: "test-ecr",
Username: "AKIAIOSFODNN7EXAMPLE",
Password: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: accessToken,
AccessTokenExpiry: expiry,
}
}
func TestValidateRegistriesECRTokens(t *testing.T) {
t.Parallel()
t.Run("skips non-ECR registries without error", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
registries := []portainer.Registry{
{ID: 1, Type: portainer.DockerHubRegistry, Name: "dockerhub"},
{ID: 2, Type: portainer.CustomRegistry, Name: "custom"},
}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, registries)
}))
})
t.Run("skips ECR registries with valid tokens", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
reg := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
}))
})
t.Run("returns nil for empty registry list", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{})
}))
})
t.Run("returns error for ECR registry with missing token", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
reg := newECRRegistry(1, "", 0)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Registry().Create(&reg)
}))
var validateErr error
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
return nil
})
require.Error(t, validateErr)
require.Contains(t, validateErr.Error(), "test-ecr")
})
t.Run("stops on first invalid ECR registry and includes its name in error", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
validECR := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
invalidECR := newECRRegistry(2, "", 0)
invalidECR.Name = "invalid-ecr"
nonECR := portainer.Registry{ID: 3, Type: portainer.DockerHubRegistry}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Registry().Create(&invalidECR)
}))
var validateErr error
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{validECR, invalidECR, nonECR})
return nil
})
require.Error(t, validateErr)
require.Contains(t, validateErr.Error(), "invalid-ecr")
})
}
func TestGetRegEffectiveCredential(t *testing.T) {
t.Parallel()
t.Run("returns username and password directly for non-ECR registry", func(t *testing.T) {
t.Parallel()
reg := &portainer.Registry{
Type: portainer.DockerHubRegistry,
Username: "user",
Password: "pass",
}
username, password, err := registryutils.GetRegEffectiveCredential(reg)
require.NoError(t, err)
require.Equal(t, "user", username)
require.Equal(t, "pass", password)
})
t.Run("parses ECR access token when token is valid", func(t *testing.T) {
t.Parallel()
reg := newECRRegistry(1, "AWS:ecr-password", time.Now().Add(time.Hour).Unix())
username, password, err := registryutils.GetRegEffectiveCredential(&reg)
require.NoError(t, err)
require.Equal(t, "AWS", username)
require.Equal(t, "ecr-password", password)
})
t.Run("returns error for ECR registry with missing token and invalid credentials", func(t *testing.T) {
t.Parallel()
reg := newECRRegistry(1, "", 0)
_, _, err := registryutils.GetRegEffectiveCredential(&reg)
require.Error(t, err)
require.Contains(t, err.Error(), "test-ecr")
})
}
+21 -2
View File
@@ -147,6 +147,27 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
}
}
type stubSSLSettingsService struct {
settings *portainer.SSLSettings
}
func (s *stubSSLSettingsService) BucketName() string { return "ssl" }
func (s *stubSSLSettingsService) Settings() (*portainer.SSLSettings, error) {
return s.settings, nil
}
func (s *stubSSLSettingsService) UpdateSettings(settings *portainer.SSLSettings) error {
s.settings = settings
return nil
}
func WithSSLSettingsService(settings *portainer.SSLSettings) datastoreOption {
return func(d *testDatastore) {
d.sslSettings = &stubSSLSettingsService{settings: settings}
}
}
type stubUserService struct {
dataservices.UserService
@@ -262,8 +283,6 @@ func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOpti
}
type stubEndpointService struct {
dataservices.EndpointService
endpoints []portainer.Endpoint
}
+5
View File
@@ -2,6 +2,7 @@ package testhelpers
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
type gitService struct {
@@ -23,6 +24,7 @@ func (g *gitService) CloneRepository(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
return g.cloneErr
@@ -33,6 +35,7 @@ func (g *gitService) LatestCommitID(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
return g.id, nil
@@ -42,6 +45,7 @@ func (g *gitService) ListRefs(
repositoryURL,
username,
password string,
authType gittypes.GitCredentialAuthType,
hardRefresh bool,
tlsSkipVerify bool,
) ([]string, error) {
@@ -53,6 +57,7 @@ func (g *gitService) ListFiles(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
includedExts []string,

Some files were not shown because too many files have changed in this diff Show More