Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aae5e533c6 | |||
| d54ccd5502 | |||
| 7e526c4df7 | |||
| bf56a6c913 | |||
| 6b1b6ff998 | |||
| 9183be7a8c | |||
| 7f83d15812 | |||
| f926b61978 | |||
| cc5f790f98 | |||
| 40b210a708 | |||
| 8be327f087 | |||
| f498d76c4f | |||
| a1fa77cbe4 | |||
| e11c2e9611 | |||
| 6e03a801e6 | |||
| 6024a97892 | |||
| 1549b36103 | |||
| 7bb3e0f7a6 | |||
| 49cc901dc3 | |||
| 31dd62fbcc | |||
| a7d2d134d0 | |||
| 14f25f1e88 | |||
| 69b8b8373f | |||
| 0b075e6e10 | |||
| b468160606 | |||
| a42e96b650 | |||
| 5a19f66a37 | |||
| b271026188 | |||
| d168e3c912 | |||
| 0b6ebd70e0 | |||
| 127e03552a | |||
| f2bdfc6eff | |||
| 5db67faa00 | |||
| f9dcfcb435 | |||
| 1d1bb526d0 | |||
| c8fe8ba4fd | |||
| d3692a5a5f | |||
| 3407811c28 | |||
| b71db0d1f1 | |||
| 5e5e85ff3a | |||
| 65d82e12ee | |||
| d9e730e0a5 | |||
| 21eb20b35e | |||
| f85a7ea24c | |||
| 6aacb61c87 | |||
| bb2c75ba93 | |||
| 16536c8a71 |
+3
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
||||
KubectlShellImage: *store.flags.KubectlShellImage,
|
||||
|
||||
IsDockerDesktopExtension: isDDExtention,
|
||||
EnforceEdgeID: true,
|
||||
}
|
||||
|
||||
return store.SettingsService.UpdateSettings(defaultSettings)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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().
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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, ®istry)
|
||||
username, password, err := getEffectiveRegUsernamePassword(®istry)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.40.0
|
||||
// @version 2.39.2
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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,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"])
|
||||
}
|
||||
@@ -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(®ularUser)
|
||||
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)
|
||||
}
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 := ®istries[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(®)
|
||||
}))
|
||||
|
||||
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(®)
|
||||
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(®)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "test-ecr")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user