Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 245f905507 | |||
| 2029799c73 | |||
| d40462b756 | |||
| d0018ce373 | |||
| b1d0f4708b | |||
| 8a46572174 | |||
| 6ea3e22cc3 | |||
| a33cc4886a | |||
| 7ae0affdca | |||
| 980485212d | |||
| 26f5a94546 | |||
| b410694307 | |||
| 27c04caa27 | |||
| 97c8553e20 | |||
| 379f4c3147 | |||
| 21937dfe60 | |||
| 90946ceca5 | |||
| 9cc3243166 | |||
| 1f20add37f | |||
| 60733427e6 | |||
| 3f451830cb | |||
| 9f0facc0f3 | |||
| a622122486 | |||
| 12fdc45ee5 | |||
| abf3d1450d | |||
| 1ae795d508 | |||
| 6f9ddd47de | |||
| 9507cf9d8b | |||
| 76e4054215 | |||
| 0a3e13915c | |||
| dbd6e49e5f | |||
| abad58a370 | |||
| 4eb1c7b11f | |||
| 3afedce570 | |||
| a7b6db72a5 | |||
| 9c79d6dc7d | |||
| 11f612a501 | |||
| cb8d8fcfd6 | |||
| 22bb1e604d | |||
| 970b135261 | |||
| a69470ec08 | |||
| ea6f1c97f5 | |||
| 6d058987f3 | |||
| 6998f05855 | |||
| 94d01c58fc | |||
| d98eb77067 | |||
| 941e86563a | |||
| f72d6b97d3 | |||
| 32926aa8bf | |||
| 1849c61c38 | |||
| fd6d74602c | |||
| 74b1dd04d1 | |||
| 7450501b7a | |||
| dcfe2d9809 | |||
| c21c91632f | |||
| 732337615e | |||
| 6ea16c0060 | |||
| 4e7d4b60a5 | |||
| 19e1cc2fbd | |||
| 68b9fef3f0 | |||
| 1e47df6611 | |||
| 405ce8f671 | |||
| e9d31b3b7b | |||
| f97adc94ad | |||
| 11d6341765 | |||
| c3cf46b0e0 | |||
| ff746beba1 | |||
| da1672fc17 | |||
| 7a9376cbaf | |||
| c0f6410d80 | |||
| 4b9ab98fd2 | |||
| 3354ee4e4b | |||
| af3c45bea0 | |||
| 816a6f9bef | |||
| e86ea22900 | |||
| 12b2acbc00 | |||
| 4a8b42928e | |||
| 2e828b39da | |||
| 49c6521c23 | |||
| debf1a742b | |||
| 5d3708ec3e | |||
| 9320fd4c50 | |||
| 974682bd98 | |||
| 631f1deb2e | |||
| 4169b045fb | |||
| 0a2a786aa3 | |||
| 808f87206e | |||
| ed6fa82904 | |||
| 9fc301110b | |||
| 69101ac89a | |||
| 69d33dd432 | |||
| 389cbf748c | |||
| d01b31f707 |
+7
-1
@@ -114,7 +114,13 @@ overrides:
|
||||
'@typescript-eslint/explicit-module-boundary-types': off
|
||||
'@typescript-eslint/no-unused-vars': 'error'
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
|
||||
'jsx-a11y/label-has-associated-control':
|
||||
- error
|
||||
- assert: either
|
||||
controlComponents:
|
||||
- Input
|
||||
- Checkbox
|
||||
'jsx-a11y/control-has-associated-label': off
|
||||
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
||||
'react/jsx-no-bind': off
|
||||
'no-await-in-loop': 'off'
|
||||
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
|
||||
Thanks for suggesting an idea for Portainer!
|
||||
|
||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion cagetory](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||
|
||||
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
||||
|
||||
|
||||
@@ -94,15 +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.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
- '2.33.3'
|
||||
- '2.33.2'
|
||||
- '2.33.1'
|
||||
- '2.33.0'
|
||||
- '2.32.0'
|
||||
- '2.31.3'
|
||||
- '2.31.2'
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- forbidigo
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
@@ -13,12 +13,6 @@ linters:
|
||||
- perfsprint
|
||||
- staticcheck
|
||||
- unused
|
||||
- mirror
|
||||
- durationcheck
|
||||
- errorlint
|
||||
- govet
|
||||
- zerologlint
|
||||
- testifylint
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
|
||||
@@ -38,20 +32,12 @@ linters:
|
||||
desc: use github.com/portainer/portainer/pkg/libcrypto
|
||||
- pkg: github.com/portainer/libhttp
|
||||
desc: use github.com/portainer/portainer/pkg/libhttp
|
||||
- pkg: golang.org/x/crypto
|
||||
desc: golang.org/x/crypto is not allowed because of FIPS mode
|
||||
- pkg: github.com/ProtonMail/go-crypto/openpgp
|
||||
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^tls\.Config$
|
||||
msg: Use crypto.CreateTLSConfiguration() instead
|
||||
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
|
||||
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
|
||||
- pattern: ^object\.(Commit|Tag)\.Verify$
|
||||
msg: "Not allowed because of FIPS mode"
|
||||
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
|
||||
msg: "Not allowed because of FIPS mode"
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd $(dirname -- "$0") && yarn lint-staged
|
||||
cd $(dirname -- "$0") && pnpm lint-staged
|
||||
+1
-1
@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
|
||||
|
||||
## Build and run Portainer locally
|
||||
|
||||
Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions.
|
||||
Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
|
||||
# For a list of valid GOOS and GOARCH values
|
||||
# Note: these can be overriden on the command line e.g. `make PLATFORM=<platform> ARCH=<arch>`
|
||||
PLATFORM=$(shell go env GOOS)
|
||||
ARCH=$(shell go env GOARCH)
|
||||
|
||||
# build target, can be one of "production", "testing", "development"
|
||||
ENV=development
|
||||
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
||||
@@ -20,7 +26,7 @@ all: tidy deps build-server build-client ## Build the client, server and downloa
|
||||
build-all: all ## Alias for the 'all' target (used by CI)
|
||||
|
||||
build-client: init-dist ## Build the client
|
||||
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
|
||||
export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG)
|
||||
|
||||
build-server: init-dist ## Build the server binary
|
||||
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
|
||||
@@ -29,7 +35,11 @@ build-image: build-all ## Build the Portainer image locally
|
||||
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
|
||||
|
||||
build-storybook: ## Build and serve the storybook files
|
||||
yarn storybook:build
|
||||
pnpm run storybook:build
|
||||
|
||||
devops: clean deps build-client ## Build the everything target specifically for CI
|
||||
echo "Building the devops binary..."
|
||||
@./build/build_binary_azuredevops.sh "$(PLATFORM)" "$(ARCH)"
|
||||
|
||||
##@ Build dependencies
|
||||
.PHONY: deps server-deps client-deps tidy
|
||||
@@ -39,23 +49,25 @@ server-deps: init-dist ## Download dependant server binaries
|
||||
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
|
||||
|
||||
client-deps: ## Install client dependencies
|
||||
yarn
|
||||
pnpm install
|
||||
|
||||
tidy: ## Tidy up the go.mod file
|
||||
@go mod tidy
|
||||
|
||||
|
||||
##@ Cleanup
|
||||
.PHONY: clean
|
||||
clean: ## Remove all build and download artifacts
|
||||
@echo "Clearing the dist directory..."
|
||||
@rm -rf dist/*
|
||||
|
||||
|
||||
##@ Testing
|
||||
.PHONY: test test-client test-server
|
||||
test: test-server test-client ## Run all tests
|
||||
|
||||
test-client: ## Run client tests
|
||||
yarn test $(ARGS) --coverage
|
||||
pnpm run test $(ARGS) --coverage
|
||||
|
||||
test-server: ## Run server tests
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
|
||||
@@ -67,7 +79,7 @@ dev: ## Run both the client and server in development mode
|
||||
make dev-client
|
||||
|
||||
dev-client: ## Run the client in development mode
|
||||
yarn dev
|
||||
pnpm run dev
|
||||
|
||||
dev-server: build-server ## Run the server in development mode
|
||||
@./dev/run_container.sh
|
||||
@@ -81,7 +93,7 @@ dev-server-podman: build-server ## Run the server in development mode
|
||||
format: format-client format-server ## Format all code
|
||||
|
||||
format-client: ## Format client code
|
||||
yarn format
|
||||
pnpm run format
|
||||
|
||||
format-server: ## Format server code
|
||||
go fmt ./...
|
||||
@@ -91,25 +103,27 @@ format-server: ## Format server code
|
||||
lint: lint-client lint-server ## Lint all code
|
||||
|
||||
lint-client: ## Lint client code
|
||||
yarn lint
|
||||
pnpm run lint
|
||||
|
||||
lint-server: tidy ## Lint server code
|
||||
golangci-lint run --timeout=10m -c .golangci.yaml
|
||||
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
|
||||
|
||||
|
||||
##@ Extension
|
||||
.PHONY: dev-extension
|
||||
dev-extension: build-server build-client ## Run the extension in development mode
|
||||
make local -f build/docker-extension/Makefile
|
||||
|
||||
|
||||
##@ Docs
|
||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||
docs-build: init-dist ## Build docs
|
||||
go mod download -x
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||
|
||||
docs-validate: docs-build ## Validate docs
|
||||
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
yarn swagger-cli validate dist/docs/openapi.yaml
|
||||
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
pnpm swagger-cli validate dist/docs/openapi.yaml
|
||||
|
||||
##@ Helpers
|
||||
.PHONY: help
|
||||
|
||||
+12
-12
@@ -10,31 +10,31 @@ func Test_generateRandomKey(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantLength int
|
||||
name string
|
||||
wantLenth int
|
||||
}{
|
||||
{
|
||||
name: "Generate a random key of length 16",
|
||||
wantLength: 16,
|
||||
name: "Generate a random key of length 16",
|
||||
wantLenth: 16,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 32",
|
||||
wantLength: 32,
|
||||
name: "Generate a random key of length 32",
|
||||
wantLenth: 32,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 64",
|
||||
wantLength: 64,
|
||||
name: "Generate a random key of length 64",
|
||||
wantLenth: 64,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 128",
|
||||
wantLength: 128,
|
||||
name: "Generate a random key of length 128",
|
||||
wantLenth: 128,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateRandomKey(tt.wantLength)
|
||||
is.Len(got, tt.wantLength)
|
||||
got := GenerateRandomKey(tt.wantLenth)
|
||||
is.Equal(tt.wantLenth, len(got))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+30
-31
@@ -10,10 +10,9 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
||||
@@ -31,7 +30,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Run("Successfully generates API key", func(t *testing.T) {
|
||||
desc := "test-1"
|
||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.NotEmpty(rawKey)
|
||||
is.NotEmpty(apiKey)
|
||||
is.Equal(desc, apiKey.Description)
|
||||
@@ -39,7 +38,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
|
||||
t.Run("Api key prefix is 7 chars", func(t *testing.T) {
|
||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
is.Equal(rawKey[:7], apiKey.Prefix)
|
||||
is.Len(apiKey.Prefix, 7)
|
||||
@@ -47,7 +46,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
|
||||
t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) {
|
||||
rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
is.Equal(portainerAPIKeyPrefix, "ptr_")
|
||||
is.True(strings.HasPrefix(rawKey, "ptr_"))
|
||||
@@ -56,7 +55,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Run("Successfully caches API key", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-3")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -66,7 +65,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
|
||||
t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) {
|
||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
generatedDigest := sha256.Sum256([]byte(rawKey))
|
||||
|
||||
@@ -84,10 +83,10 @@ func Test_GetAPIKey(t *testing.T) {
|
||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
is.Equal(apiKey, apiKeyGot)
|
||||
})
|
||||
@@ -103,12 +102,12 @@ func Test_GetAPIKeys(t *testing.T) {
|
||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, _, err := service.GenerateApiKey(user, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
_, _, err = service.GenerateApiKey(user, "test-2")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
keys, err := service.GetAPIKeys(user.ID)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Len(keys, 2)
|
||||
})
|
||||
}
|
||||
@@ -123,10 +122,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(user, userGot)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
})
|
||||
@@ -134,10 +133,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(user, userGot)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
|
||||
@@ -159,14 +158,14 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
store.User().Create(&user)
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-x")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
apiKey.LastUsed = time.Now().UTC().Unix()
|
||||
err = service.UpdateAPIKey(apiKey)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("")
|
||||
|
||||
@@ -175,7 +174,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
|
||||
t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) {
|
||||
_, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -185,7 +184,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
is.NotEqual(*apiKey, apiKeyFromCache)
|
||||
|
||||
err = service.UpdateAPIKey(apiKey)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -203,30 +202,30 @@ func Test_DeleteAPIKey(t *testing.T) {
|
||||
t.Run("Successfully updates the api-key", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
|
||||
err = service.DeleteAPIKey(apiKey.ID)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, _, err = service.GetDigestUserAndKey(apiKey.Digest)
|
||||
require.Error(t, err)
|
||||
is.Error(err)
|
||||
})
|
||||
|
||||
t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
is.Equal(*apiKey, apiKeyFromCache)
|
||||
|
||||
err = service.DeleteAPIKey(apiKey.ID)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, _, ok = service.cache.Get(apiKey.Digest)
|
||||
is.False(ok)
|
||||
@@ -244,10 +243,10 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
// generate api keys
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
// verify api keys are present in cache
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||
@@ -274,11 +273,11 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
// generate keys for 2 users
|
||||
user1 := portainer.User{ID: 1}
|
||||
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
user2 := portainer.User{ID: 2}
|
||||
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
// verify keys in cache
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
// TarGzDir creates a tar.gz archive and returns it's path.
|
||||
@@ -105,7 +107,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
|
||||
+66
-30
@@ -1,26 +1,26 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func listFiles(dir string) []string {
|
||||
items := make([]string, 0)
|
||||
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
|
||||
items = append(items, path)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -30,21 +30,13 @@ func listFiles(dir string) []string {
|
||||
func Test_shouldCreateArchive(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
|
||||
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
@@ -57,8 +49,7 @@ func Test_shouldCreateArchive(t *testing.T) {
|
||||
wasExtracted := func(p string) {
|
||||
fullpath := path.Join(extractionDir, p)
|
||||
assert.Contains(t, extractedFiles, fullpath)
|
||||
copyContent, err := os.ReadFile(fullpath)
|
||||
require.NoError(t, err)
|
||||
copyContent, _ := os.ReadFile(fullpath)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
@@ -70,21 +61,13 @@ func Test_shouldCreateArchive(t *testing.T) {
|
||||
func Test_shouldCreateArchive2(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
|
||||
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
@@ -105,3 +88,56 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
wasExtracted("dir/inner")
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create an evil file with a path traversal attempt
|
||||
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
||||
|
||||
evilFile, err := os.Create(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzWriter := gzip.NewWriter(evilFile)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
content := []byte("evil content")
|
||||
|
||||
header := &tar.Header{
|
||||
Name: "../evil.txt",
|
||||
Mode: 0600,
|
||||
Size: int64(len(content)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tarWriter.Write(content)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tarWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = gzWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = evilFile.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to extract the evil file
|
||||
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
||||
err = os.Mkdir(extractionDir, 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
tarFile, err := os.Open(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the file didn't escape
|
||||
err = ExtractTarGz(tarFile, extractionDir)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
||||
|
||||
err = tarFile.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnzipFile(t *testing.T) {
|
||||
@@ -21,7 +20,7 @@ func TestUnzipFile(t *testing.T) {
|
||||
|
||||
err := UnzipFile("./testdata/sample_archive.zip", dir)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
archiveDir := dir + "/sample_archive"
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
||||
|
||||
@@ -630,7 +630,7 @@ func main() {
|
||||
Str("build_number", build.BuildNumber).
|
||||
Str("image_tag", build.ImageTag).
|
||||
Str("nodejs_version", build.NodejsVersion).
|
||||
Str("yarn_version", build.YarnVersion).
|
||||
Str("pnpm_version", build.PnpmVersion).
|
||||
Str("webpack_version", build.WebpackVersion).
|
||||
Str("go_version", build.GoVersion).
|
||||
Msg("starting Portainer")
|
||||
|
||||
@@ -53,5 +53,4 @@ type Connection interface {
|
||||
|
||||
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
|
||||
ConvertToKey(v int) []byte
|
||||
ConvertStringToKey(v string) []byte
|
||||
}
|
||||
|
||||
+2
-3
@@ -15,9 +15,8 @@ import (
|
||||
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
// Not allowed in FIPS mode
|
||||
"golang.org/x/crypto/argon2" //nolint:depguard
|
||||
"golang.org/x/crypto/scrypt" //nolint:depguard
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+28
-42
@@ -55,19 +55,17 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
require.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
require.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||
@@ -157,11 +155,11 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
@@ -171,7 +169,7 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
@@ -207,29 +205,25 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
@@ -253,40 +247,32 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
)
|
||||
|
||||
content := randBytes(1024 * 50)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
|
||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte(""))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
|
||||
err := encrypt(originFile, encryptedFileWriter, []byte(""))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
@@ -319,9 +305,9 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
defer encryptedFileWriter.Close()
|
||||
|
||||
err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
@@ -331,7 +317,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
_, err = decrypt(encryptedFileReader, []byte("garbage"))
|
||||
require.Error(t, err, "Should not allow decrypt with wrong passphrase")
|
||||
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
|
||||
}
|
||||
|
||||
t.Run("fips", func(t *testing.T) {
|
||||
|
||||
@@ -11,12 +11,12 @@ func TestCreateSignature(t *testing.T) {
|
||||
|
||||
privKey, pubKey, err := s.GenerateKeyPair()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, privKey)
|
||||
require.NotEmpty(t, pubKey)
|
||||
require.Greater(t, len(privKey), 0)
|
||||
require.Greater(t, len(pubKey), 0)
|
||||
|
||||
m := "test message"
|
||||
r, err := s.CreateSignature(m)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, r, m)
|
||||
require.NotEmpty(t, r)
|
||||
require.Greater(t, len(r), 0)
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
// Not allowed in FIPS mode
|
||||
"golang.org/x/crypto/bcrypt" //nolint:depguard
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Service represents a service for encrypting/hashing data.
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestCreateTLSConfigurationFIPS(t *testing.T) {
|
||||
func TestCreateTLSConfigurationFromBytes(t *testing.T) {
|
||||
// No TLS
|
||||
config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, config)
|
||||
|
||||
// Skip TLS client/server verifications
|
||||
@@ -61,7 +61,7 @@ func TestCreateTLSConfigurationFromBytes(t *testing.T) {
|
||||
func TestCreateTLSConfigurationFromDisk(t *testing.T) {
|
||||
// No TLS
|
||||
config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, config)
|
||||
|
||||
// Skip TLS verifications
|
||||
|
||||
@@ -233,10 +233,6 @@ func (connection *DbConnection) ConvertToKey(v int) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func (connection *DbConnection) ConvertStringToKey(v string) []byte {
|
||||
return []byte(v)
|
||||
}
|
||||
|
||||
// keyToString Converts a key to a string value suitable for logging
|
||||
func keyToString(b []byte) string {
|
||||
if len(b) != 8 {
|
||||
|
||||
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
}
|
||||
}
|
||||
|
||||
if e := json.Unmarshal(data, object); e != nil {
|
||||
if err := json.Unmarshal(data, object); err != nil {
|
||||
// Special case for the VERSION bucket. Here we're not using json
|
||||
// So we need to return it as a string
|
||||
s, ok := object.(*string)
|
||||
if !ok {
|
||||
return errors.Wrap(err, e.Error())
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
|
||||
@@ -94,7 +94,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
data, err := conn.MarshalObject(test.object)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(test.expected, string(data))
|
||||
})
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
var object string
|
||||
err := conn.UnmarshalObject(test.object, &object)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(test.expected, object)
|
||||
})
|
||||
}
|
||||
@@ -172,12 +172,12 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
|
||||
data, err := conn.MarshalObject(test.object)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
var object []byte
|
||||
err = conn.UnmarshalObject(data, &object)
|
||||
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(test.object, object)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewDatabase(t *testing.T) {
|
||||
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
|
||||
connection, err := NewDatabase("boltdb", dbPath, nil, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, connection)
|
||||
|
||||
_, ok := connection.(*boltdb.DbConnection)
|
||||
require.True(t, ok)
|
||||
|
||||
connection, err = NewDatabase("unknown", dbPath, nil, false)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, connection)
|
||||
}
|
||||
@@ -50,9 +50,6 @@ func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
|
||||
func (m mockConnection) ConvertToKey(v int) []byte {
|
||||
return []byte(strconv.Itoa(v))
|
||||
}
|
||||
func (c mockConnection) ConvertStringToKey(v string) []byte {
|
||||
return []byte(v)
|
||||
}
|
||||
|
||||
func TestReadAll(t *testing.T) {
|
||||
service := BaseDataService[testObject, int]{
|
||||
|
||||
@@ -136,5 +136,5 @@ func TestEndpointRelations(t *testing.T) {
|
||||
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1}))
|
||||
rels, err := service.EndpointRelations()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rels, 1)
|
||||
require.Equal(t, 1, len(rels))
|
||||
}
|
||||
|
||||
@@ -4,18 +4,17 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
uuid, err := uuid.NewV4()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return uuid.String()
|
||||
}
|
||||
@@ -42,7 +41,7 @@ func TestService_StackByWebhookID(t *testing.T) {
|
||||
|
||||
// can find a stack by webhook ID
|
||||
got, err := store.StackService.StackByWebhookID(webhookID)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, stack, *got)
|
||||
|
||||
// returns nil and object not found error if there's no stack associated with the webhook
|
||||
@@ -95,10 +94,10 @@ func Test_RefreshableStacks(t *testing.T) {
|
||||
|
||||
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
|
||||
err := store.Stack().Create(stack)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
stacks, err := store.Stack().RefreshableStacks()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_teamByName(t *testing.T) {
|
||||
@@ -15,7 +13,7 @@ func Test_teamByName(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
_, err := store.Team().TeamByName("name")
|
||||
require.ErrorIs(t, err, errors.ErrObjectNotFound)
|
||||
assert.ErrorIs(t, err, errors.ErrObjectNotFound)
|
||||
|
||||
})
|
||||
|
||||
@@ -31,7 +29,7 @@ func Test_teamByName(t *testing.T) {
|
||||
teamBuilder.createNew("name1")
|
||||
|
||||
_, err := store.Team().TeamByName("name")
|
||||
require.ErrorIs(t, err, errors.ErrObjectNotFound)
|
||||
assert.ErrorIs(t, err, errors.ErrObjectNotFound)
|
||||
})
|
||||
|
||||
t.Run("When there is an object with the same name should return the object", func(t *testing.T) {
|
||||
@@ -46,7 +44,7 @@ func Test_teamByName(t *testing.T) {
|
||||
expectedTeam := teamBuilder.createNew("name1")
|
||||
|
||||
team, err := store.Team().TeamByName("name1")
|
||||
require.NoError(t, err, "TeamByName should succeed")
|
||||
assert.NoError(t, err, "TeamByName should succeed")
|
||||
assert.Equal(t, expectedTeam, team)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
dataservices.BaseDataServiceTx[models.Version, int] // ID is not used
|
||||
}
|
||||
|
||||
func (tx ServiceTx) InstanceID() (string, error) {
|
||||
v, err := tx.Version()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.InstanceID, nil
|
||||
}
|
||||
|
||||
func (tx ServiceTx) UpdateInstanceID(ID string) error {
|
||||
v, err := tx.Version()
|
||||
if err != nil {
|
||||
if !dataservices.IsErrObjectNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
v = &models.Version{}
|
||||
}
|
||||
|
||||
v.InstanceID = ID
|
||||
|
||||
return tx.UpdateVersion(v)
|
||||
}
|
||||
|
||||
func (tx ServiceTx) Edition() (portainer.SoftwareEdition, error) {
|
||||
v, err := tx.Version()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return portainer.SoftwareEdition(v.Edition), nil
|
||||
}
|
||||
|
||||
func (tx ServiceTx) Version() (*models.Version, error) {
|
||||
var v models.Version
|
||||
|
||||
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (tx ServiceTx) UpdateVersion(version *models.Version) error {
|
||||
return tx.Tx.UpdateObject(BucketName, []byte(versionKey), version)
|
||||
}
|
||||
|
||||
func (tx ServiceTx) SchemaVersion() (string, error) {
|
||||
var v models.Version
|
||||
|
||||
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.SchemaVersion, nil
|
||||
}
|
||||
@@ -33,16 +33,6 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[models.Version, int]{
|
||||
Bucket: BucketName,
|
||||
Connection: service.connection,
|
||||
Tx: tx,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) SchemaVersion() (string, error) {
|
||||
v, err := service.Version()
|
||||
if err != nil {
|
||||
|
||||
@@ -5,14 +5,15 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func TestStoreCreation(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
require.NotNil(t, store)
|
||||
if store == nil {
|
||||
t.Fatal("Expect to create a store")
|
||||
}
|
||||
|
||||
v, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
|
||||
@@ -6,14 +6,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dchest/uniuri"
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
|
||||
"github.com/dchest/uniuri"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,21 +30,45 @@ func TestStoreFull(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
|
||||
testCases := map[string]func(t *testing.T){
|
||||
"User Accounts": store.testUserAccounts,
|
||||
"Environments": store.testEnvironments,
|
||||
"Settings": store.testSettings,
|
||||
"SSL Settings": store.testSSLSettings,
|
||||
"Tunnel Server": store.testTunnelServer,
|
||||
"Custom Templates": store.testCustomTemplates,
|
||||
"Registries": store.testRegistries,
|
||||
"Resource Control": store.testResourceControl,
|
||||
"Schedules": store.testSchedules,
|
||||
"Tags": store.testTags,
|
||||
"User Accounts": func(t *testing.T) {
|
||||
store.testUserAccounts(t)
|
||||
},
|
||||
"Environments": func(t *testing.T) {
|
||||
store.testEnvironments(t)
|
||||
},
|
||||
"Settings": func(t *testing.T) {
|
||||
store.testSettings(t)
|
||||
},
|
||||
"SSL Settings": func(t *testing.T) {
|
||||
store.testSSLSettings(t)
|
||||
},
|
||||
"Tunnel Server": func(t *testing.T) {
|
||||
store.testTunnelServer(t)
|
||||
},
|
||||
"Custom Templates": func(t *testing.T) {
|
||||
store.testCustomTemplates(t)
|
||||
},
|
||||
"Registries": func(t *testing.T) {
|
||||
store.testRegistries(t)
|
||||
},
|
||||
"Resource Control": func(t *testing.T) {
|
||||
store.testResourceControl(t)
|
||||
},
|
||||
"Schedules": func(t *testing.T) {
|
||||
store.testSchedules(t)
|
||||
},
|
||||
"Tags": func(t *testing.T) {
|
||||
store.testTags(t)
|
||||
},
|
||||
|
||||
// "Test Title": func(t *testing.T) {
|
||||
// },
|
||||
}
|
||||
|
||||
for name, test := range testCases {
|
||||
t.Run(name, test)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (store *Store) testEnvironments(t *testing.T) {
|
||||
@@ -145,7 +167,7 @@ func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType porta
|
||||
store.Endpoint().Create(expectedEndpoint)
|
||||
|
||||
endpoint, err := store.Endpoint().Endpoint(id)
|
||||
require.NoError(t, err, "Endpoint() should not return an error")
|
||||
is.NoError(err, "Endpoint() should not return an error")
|
||||
is.Equal(expectedEndpoint, endpoint, "endpoint should be the same")
|
||||
|
||||
return endpoint.ID
|
||||
@@ -172,7 +194,7 @@ func (store *Store) testSSLSettings(t *testing.T) {
|
||||
store.SSLSettings().UpdateSettings(ssl)
|
||||
|
||||
settings, err := store.SSLSettings().Settings()
|
||||
require.NoError(t, err, "Get sslsettings should succeed")
|
||||
is.NoError(err, "Get sslsettings should succeed")
|
||||
is.Equal(ssl, settings, "Stored SSLSettings should be the same as what is read out")
|
||||
}
|
||||
|
||||
@@ -181,27 +203,27 @@ func (store *Store) testTunnelServer(t *testing.T) {
|
||||
expectPrivateKeySeed := uniuri.NewLen(16)
|
||||
|
||||
err := store.TunnelServer().UpdateInfo(&portainer.TunnelServerInfo{PrivateKeySeed: expectPrivateKeySeed})
|
||||
require.NoError(t, err, "UpdateInfo should have succeeded")
|
||||
is.NoError(err, "UpdateInfo should have succeeded")
|
||||
|
||||
serverInfo, err := store.TunnelServer().Info()
|
||||
require.NoError(t, err, "Info should have succeeded")
|
||||
is.NoError(err, "Info should have succeeded")
|
||||
|
||||
is.Equal(expectPrivateKeySeed, serverInfo.PrivateKeySeed, "hashed passwords should not differ")
|
||||
}
|
||||
|
||||
// add users, read them back and check the details are unchanged
|
||||
func (store *Store) testUserAccounts(t *testing.T) {
|
||||
err := store.createAccount(adminUsername, adminPassword, portainer.AdministratorRole)
|
||||
require.NoError(t, err, "CreateAccount should succeed")
|
||||
is := assert.New(t)
|
||||
|
||||
err = store.checkAccount(adminUsername, adminPassword, portainer.AdministratorRole)
|
||||
require.NoError(t, err, "Account failure")
|
||||
err := store.createAccount(adminUsername, adminPassword, portainer.AdministratorRole)
|
||||
is.NoError(err, "CreateAccount should succeed")
|
||||
store.checkAccount(adminUsername, adminPassword, portainer.AdministratorRole)
|
||||
is.NoError(err, "Account failure")
|
||||
|
||||
err = store.createAccount(standardUsername, standardPassword, portainer.StandardUserRole)
|
||||
require.NoError(t, err, "CreateAccount should succeed")
|
||||
|
||||
err = store.checkAccount(standardUsername, standardPassword, portainer.StandardUserRole)
|
||||
require.NoError(t, err, "Account failure")
|
||||
is.NoError(err, "CreateAccount should succeed")
|
||||
store.checkAccount(standardUsername, standardPassword, portainer.StandardUserRole)
|
||||
is.NoError(err, "Account failure")
|
||||
}
|
||||
|
||||
// create an account with the provided details
|
||||
@@ -216,7 +238,12 @@ func (store *Store) createAccount(username, password string, role portainer.User
|
||||
return err
|
||||
}
|
||||
|
||||
return store.User().Create(user)
|
||||
err = store.User().Create(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) checkAccount(username, expectPassword string, expectRole portainer.UserRole) error {
|
||||
@@ -233,7 +260,12 @@ func (store *Store) checkAccount(username, expectPassword string, expectRole por
|
||||
|
||||
// Check the password
|
||||
cs := crypto.Service{}
|
||||
if cs.CompareHashAndData(user.Password, expectPassword) != nil {
|
||||
expectPasswordHash, err := cs.Hash(expectPassword)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "hash failed")
|
||||
}
|
||||
|
||||
if user.Password != expectPasswordHash {
|
||||
return fmt.Errorf("%s user password hash failure", user.Username)
|
||||
}
|
||||
|
||||
@@ -245,7 +277,7 @@ func (store *Store) testSettings(t *testing.T) {
|
||||
|
||||
// since many settings are default and basically nil, I'm going to update some and read them back
|
||||
expectedSettings, err := store.Settings().Settings()
|
||||
require.NoError(t, err, "Settings() should not return an error")
|
||||
is.NoError(err, "Settings() should not return an error")
|
||||
expectedSettings.TemplatesURL = "http://portainer.io/application-templates"
|
||||
expectedSettings.HelmRepositoryURL = "http://portainer.io/helm-repository"
|
||||
expectedSettings.EdgeAgentCheckinInterval = 60
|
||||
@@ -259,10 +291,10 @@ func (store *Store) testSettings(t *testing.T) {
|
||||
expectedSettings.SnapshotInterval = "10m"
|
||||
|
||||
err = store.Settings().UpdateSettings(expectedSettings)
|
||||
require.NoError(t, err, "UpdateSettings() should succeed")
|
||||
is.NoError(err, "UpdateSettings() should succeed")
|
||||
|
||||
settings, err := store.Settings().Settings()
|
||||
require.NoError(t, err, "Settings() should not return an error")
|
||||
is.NoError(err, "Settings() should not return an error")
|
||||
is.Equal(expectedSettings, settings, "stored settings should match")
|
||||
}
|
||||
|
||||
@@ -285,7 +317,7 @@ func (store *Store) testCustomTemplates(t *testing.T) {
|
||||
customTemplate.Create(expectedTemplate)
|
||||
|
||||
actualTemplate, err := customTemplate.Read(expectedTemplate.ID)
|
||||
require.NoError(t, err, "CustomTemplate should not return an error")
|
||||
is.NoError(err, "CustomTemplate should not return an error")
|
||||
is.Equal(expectedTemplate, actualTemplate, "expected and actual template do not match")
|
||||
}
|
||||
|
||||
@@ -313,17 +345,17 @@ func (store *Store) testRegistries(t *testing.T) {
|
||||
}
|
||||
|
||||
err := regService.Create(reg1)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
err = regService.Create(reg2)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
|
||||
actualReg1, err := regService.Read(reg1.ID)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(reg1, actualReg1, "registries differ")
|
||||
|
||||
actualReg2, err := regService.Read(reg2.ID)
|
||||
require.NoError(t, err)
|
||||
is.NoError(err)
|
||||
is.Equal(reg2, actualReg2, "registries differ")
|
||||
}
|
||||
|
||||
@@ -346,10 +378,10 @@ func (store *Store) testSchedules(t *testing.T) {
|
||||
}
|
||||
|
||||
err := schedule.CreateSchedule(s)
|
||||
require.NoError(t, err, "CreateSchedule should succeed")
|
||||
is.NoError(err, "CreateSchedule should succeed")
|
||||
|
||||
actual, err := schedule.Schedule(s.ID)
|
||||
require.NoError(t, err, "schedule should be found")
|
||||
is.NoError(err, "schedule should be found")
|
||||
is.Equal(s, actual, "schedules differ")
|
||||
}
|
||||
|
||||
@@ -369,16 +401,16 @@ func (store *Store) testTags(t *testing.T) {
|
||||
}
|
||||
|
||||
err := tags.Create(tag1)
|
||||
require.NoError(t, err, "Tags.Create should succeed")
|
||||
is.NoError(err, "Tags.Create should succeed")
|
||||
|
||||
err = tags.Create(tag2)
|
||||
require.NoError(t, err, "Tags.Create should succeed")
|
||||
is.NoError(err, "Tags.Create should succeed")
|
||||
|
||||
actual, err := tags.Read(tag1.ID)
|
||||
require.NoError(t, err, "tag1 should be found")
|
||||
is.NoError(err, "tag1 should be found")
|
||||
is.Equal(tag1, actual, "tags differ")
|
||||
|
||||
actual, err = tags.Read(tag2.ID)
|
||||
require.NoError(t, err, "tag2 should be found")
|
||||
is.NoError(err, "tag2 should be found")
|
||||
is.Equal(tag2, actual, "tags differ")
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
||||
KubectlShellImage: *store.flags.KubectlShellImage,
|
||||
|
||||
IsDockerDesktopExtension: isDDExtention,
|
||||
EnforceEdgeID: true,
|
||||
}
|
||||
|
||||
return store.SettingsService.UpdateSettings(defaultSettings)
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrateStackEntryPoint(t *testing.T) {
|
||||
@@ -30,25 +28,25 @@ func TestMigrateStackEntryPoint(t *testing.T) {
|
||||
|
||||
for _, s := range stacks {
|
||||
err := stackService.Create(s)
|
||||
require.NoError(t, err, "failed to create stack")
|
||||
assert.NoError(t, err, "failed to create stack")
|
||||
}
|
||||
|
||||
s, err := stackService.Read(1)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, s.GitConfig, "first stack should not have git config")
|
||||
|
||||
s, err = stackService.Read(2)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, s.GitConfig.ConfigFilePath, "not migrated yet migrated")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", s.GitConfig.ConfigFilePath, "not migrated yet migrated")
|
||||
|
||||
err = migrator.MigrateStackEntryPoint(stackService)
|
||||
require.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath")
|
||||
assert.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath")
|
||||
|
||||
s, err = stackService.Read(1)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, s.GitConfig, "first stack should not have git config")
|
||||
|
||||
s, err = stackService.Read(2)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "dir/sub/compose.yml", s.GitConfig.ConfigFilePath, "second stack should have config file path migrated")
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
|
||||
migratedEdgeGroup, err := edgeGroupService.Read(edgeGroup.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, migratedEdgeGroup.Endpoints)
|
||||
require.Len(t, migratedEdgeGroup.Endpoints, 0)
|
||||
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
|
||||
|
||||
// Run migration again to ensure the results didn't change
|
||||
@@ -50,6 +50,6 @@ func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
|
||||
migratedEdgeGroup, err = edgeGroupService.Read(edgeGroup.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, migratedEdgeGroup.Endpoints)
|
||||
require.Len(t, migratedEdgeGroup.Endpoints, 0)
|
||||
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
|
||||
}
|
||||
|
||||
@@ -258,8 +258,6 @@ func (m *Migrator) initMigrations() {
|
||||
|
||||
m.addMigrations("2.33.1", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
|
||||
|
||||
// WARNING: do not change migrations that have already been released!
|
||||
|
||||
// Add new migrations above...
|
||||
// One function per migration, each versions migration funcs in the same file.
|
||||
}
|
||||
|
||||
@@ -22,9 +22,8 @@ func TestMigrateGPUs(t *testing.T) {
|
||||
if strings.HasSuffix(r.URL.Path, "/containers/json") {
|
||||
containerSummary := []container.Summary{{ID: "container1"}}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(containerSummary); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
err := json.NewEncoder(w).Encode(containerSummary)
|
||||
require.NoError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -42,9 +41,8 @@ func TestMigrateGPUs(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(container); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
err := json.NewEncoder(w).Encode(container)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
@@ -131,12 +129,12 @@ func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
|
||||
EdgeID: "edgeID",
|
||||
}
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
is.NoError(err, "error creating endpoint")
|
||||
|
||||
// Create any existing pending actions
|
||||
for _, action := range tt.existingPendingActions {
|
||||
err = store.PendingActions().Create(action)
|
||||
require.NoError(t, err, "error creating pending action")
|
||||
is.NoError(err, "error creating pending action")
|
||||
}
|
||||
|
||||
migrator := NewPostInitMigrator(
|
||||
@@ -148,11 +146,11 @@ func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
|
||||
)
|
||||
|
||||
err = migrator.PostInitMigrate()
|
||||
require.NoError(t, err, "PostInitMigrate should not return error")
|
||||
is.NoError(err, "PostInitMigrate should not return error")
|
||||
|
||||
// Verify the results
|
||||
pendingActions, err := store.PendingActions().ReadAll()
|
||||
require.NoError(t, err, "error reading pending actions")
|
||||
is.NoError(err, "error reading pending actions")
|
||||
is.Len(pendingActions, tt.expectedPendingActions, "unexpected number of pending actions")
|
||||
|
||||
// If we expect any actions, verify at least one has the expected action type
|
||||
@@ -162,11 +160,9 @@ func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
|
||||
if action.Action == tt.expectedAction {
|
||||
hasExpectedAction = true
|
||||
is.Equal(endpoint.ID, action.EndpointID, "action should reference correct endpoint")
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
is.True(hasExpectedAction, "should have found action of expected type")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"MigrateIngresses": true
|
||||
},
|
||||
"PublicURL": "",
|
||||
"QueryDate": 0,
|
||||
"SecuritySettings": {
|
||||
"allowBindMountsForRegularUsers": true,
|
||||
"allowContainerCapabilitiesForRegularUsers": true,
|
||||
@@ -614,7 +615,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.37.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.33.8",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +944,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.37.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.33.8\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package docker
|
||||
|
||||
import "github.com/docker/docker/api/types"
|
||||
|
||||
type ContainerStats struct {
|
||||
Running int `json:"running"`
|
||||
Stopped int `json:"stopped"`
|
||||
Healthy int `json:"healthy"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func CalculateContainerStats(containers []types.Container) ContainerStats {
|
||||
var running, stopped, healthy, unhealthy int
|
||||
for _, container := range containers {
|
||||
switch container.State {
|
||||
case "running":
|
||||
running++
|
||||
case "healthy":
|
||||
running++
|
||||
healthy++
|
||||
case "unhealthy":
|
||||
running++
|
||||
unhealthy++
|
||||
case "exited", "stopped":
|
||||
stopped++
|
||||
}
|
||||
}
|
||||
|
||||
return ContainerStats{
|
||||
Running: running,
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCalculateContainerStats(t *testing.T) {
|
||||
containers := []types.Container{
|
||||
{State: "running"},
|
||||
{State: "running"},
|
||||
{State: "exited"},
|
||||
{State: "stopped"},
|
||||
{State: "healthy"},
|
||||
{State: "unhealthy"},
|
||||
}
|
||||
|
||||
stats := CalculateContainerStats(containers)
|
||||
|
||||
assert.Equal(t, 4, stats.Running)
|
||||
assert.Equal(t, 2, stats.Stopped)
|
||||
assert.Equal(t, 1, stats.Healthy)
|
||||
assert.Equal(t, 1, stats.Unhealthy)
|
||||
assert.Equal(t, 6, stats.Total)
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
|
||||
"github.com/containers/image/v5/docker"
|
||||
imagetypes "github.com/containers/image/v5/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.podman.io/image/v5/docker"
|
||||
imagetypes "go.podman.io/image/v5/types"
|
||||
)
|
||||
|
||||
// Options holds docker registry object options
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/containers/image/v5/docker/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"go.podman.io/image/v5/docker/reference"
|
||||
)
|
||||
|
||||
type ImageID string
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImageParser(t *testing.T) {
|
||||
@@ -15,7 +14,7 @@ func TestImageParser(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "portainer/portainer-ee",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
is.NoError(err, "")
|
||||
is.Equal("docker.io/portainer/portainer-ee:latest", image.FullName())
|
||||
is.Equal("portainer/portainer-ee", image.Opts.Name)
|
||||
is.Equal("latest", image.Tag)
|
||||
@@ -31,10 +30,10 @@ func TestImageParser(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
is.NoError(err, "")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Empty(image.Tag)
|
||||
is.Equal("", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink)
|
||||
@@ -48,7 +47,7 @@ func TestImageParser(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
is.NoError(err, "")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
@@ -69,9 +68,8 @@ func TestUpdateParsedImage(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = image.WithTag("v0.0.31")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err, "")
|
||||
_ = image.WithTag("v0.0.31")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.31", image.Tag)
|
||||
@@ -88,9 +86,8 @@ func TestUpdateParsedImage(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
|
||||
require.NoError(t, err)
|
||||
is.NoError(err, "")
|
||||
_ = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
@@ -107,9 +104,8 @@ func TestUpdateParsedImage(t *testing.T) {
|
||||
image, err := ParseImage(ParseImageOptions{
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = image.TrimDigest()
|
||||
require.NoError(t, err)
|
||||
is.NoError(err, "")
|
||||
_ = image.TrimDigest()
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
|
||||
@@ -3,8 +3,8 @@ package images
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"go.podman.io/image/v5/docker"
|
||||
"go.podman.io/image/v5/types"
|
||||
"github.com/containers/image/v5/docker"
|
||||
"github.com/containers/image/v5/types"
|
||||
)
|
||||
|
||||
func ParseReference(imageStr string) (types.ImageReference, error) {
|
||||
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
@@ -17,9 +15,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", false),
|
||||
createNewRegistry("hub-mirror.c.163.com", "", false)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.False(r.Authentication)
|
||||
is.NoError(err, "")
|
||||
is.NotNil(r, "")
|
||||
is.False(r.Authentication, "")
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
|
||||
@@ -28,9 +26,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "", false),
|
||||
createNewRegistry("hub-mirror.c.163.com", "USERNAME", false)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.False(r.Authentication)
|
||||
is.NoError(err, "")
|
||||
is.NotNil(r, "")
|
||||
is.False(r.Authentication, "")
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
|
||||
@@ -39,9 +37,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", true),
|
||||
createNewRegistry("hub-mirror.c.163.com", "", false)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.True(r.Authentication)
|
||||
is.NoError(err, "")
|
||||
is.NotNil(r, "")
|
||||
is.True(r.Authentication, "")
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
|
||||
@@ -49,9 +47,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
image := "portainer/portainer-ee:latest"
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "", true)}
|
||||
r, err := findBestMatchRegistry(image, registries)
|
||||
require.NoError(t, err)
|
||||
is.NotNil(r)
|
||||
is.True(r.Authentication)
|
||||
is.NoError(err, "")
|
||||
is.NotNil(r, "")
|
||||
is.True(r.Authentication, "")
|
||||
is.Equal("docker.io", r.URL)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
type ContainerStats struct {
|
||||
Running int `json:"running"`
|
||||
Stopped int `json:"stopped"`
|
||||
Healthy int `json:"healthy"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type DockerClient interface {
|
||||
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
|
||||
}
|
||||
|
||||
func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool, containers []container.Summary) (ContainerStats, error) {
|
||||
if isSwarm {
|
||||
return CalculateContainerStatsForSwarm(containers), nil
|
||||
}
|
||||
|
||||
var running, stopped, healthy, unhealthy int
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 5)
|
||||
|
||||
var aggErr error
|
||||
var aggMu sync.Mutex
|
||||
|
||||
for i := range containers {
|
||||
id := containers[i].ID
|
||||
semaphore <- struct{}{}
|
||||
wg.Go(func() {
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
containerInspection, err := cli.ContainerInspect(ctx, id)
|
||||
stat := ContainerStats{}
|
||||
if err != nil {
|
||||
aggMu.Lock()
|
||||
aggErr = errors.Join(aggErr, err)
|
||||
aggMu.Unlock()
|
||||
return
|
||||
}
|
||||
stat = getContainerStatus(containerInspection.State)
|
||||
|
||||
mu.Lock()
|
||||
running += stat.Running
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return ContainerStats{
|
||||
Running: running,
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
func getContainerStatus(state *container.State) ContainerStats {
|
||||
stat := ContainerStats{}
|
||||
if state == nil {
|
||||
return stat
|
||||
}
|
||||
|
||||
switch state.Status {
|
||||
case container.StateRunning:
|
||||
stat.Running++
|
||||
case container.StateExited, container.StateDead:
|
||||
stat.Stopped++
|
||||
}
|
||||
|
||||
if state.Health != nil {
|
||||
switch state.Health.Status {
|
||||
case container.Healthy:
|
||||
stat.Healthy++
|
||||
case container.Unhealthy:
|
||||
stat.Unhealthy++
|
||||
}
|
||||
}
|
||||
|
||||
return stat
|
||||
}
|
||||
|
||||
// This is a temporary workaround to calculate container stats for Swarm
|
||||
// TODO: Remove this once we have a proper way to calculate container stats for Swarm
|
||||
func CalculateContainerStatsForSwarm(containers []container.Summary) ContainerStats {
|
||||
var running, stopped, healthy, unhealthy int
|
||||
for _, container := range containers {
|
||||
switch container.State {
|
||||
case "running":
|
||||
running++
|
||||
case "exited", "stopped":
|
||||
stopped++
|
||||
}
|
||||
|
||||
if strings.Contains(container.Status, "(healthy)") {
|
||||
healthy++
|
||||
} else if strings.Contains(container.Status, "(unhealthy)") {
|
||||
unhealthy++
|
||||
}
|
||||
}
|
||||
|
||||
return ContainerStats{
|
||||
Running: running,
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockDockerClient implements the DockerClient interface for testing
|
||||
type MockDockerClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) {
|
||||
args := m.Called(ctx, containerID)
|
||||
return args.Get(0).(container.InspectResponse), args.Error(1)
|
||||
}
|
||||
|
||||
func TestCalculateContainerStats(t *testing.T) {
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers - using enough containers to test concurrent processing
|
||||
containers := []container.Summary{
|
||||
{ID: "container1"},
|
||||
{ID: "container2"},
|
||||
{ID: "container3"},
|
||||
{ID: "container4"},
|
||||
{ID: "container5"},
|
||||
{ID: "container6"},
|
||||
{ID: "container7"},
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
containerStates := []struct {
|
||||
id string
|
||||
status string
|
||||
health *container.Health
|
||||
expected ContainerStats
|
||||
}{
|
||||
{"container1", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
|
||||
{"container2", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
|
||||
{"container3", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
|
||||
{"container4", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container5", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container6", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
|
||||
{"container7", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
|
||||
{"container8", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container9", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
|
||||
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
}
|
||||
|
||||
expected := ContainerStats{}
|
||||
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
|
||||
for _, state := range containerStates {
|
||||
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Status: state.status,
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
|
||||
|
||||
expected.Running += state.expected.Running
|
||||
expected.Stopped += state.expected.Stopped
|
||||
expected.Healthy += state.expected.Healthy
|
||||
expected.Unhealthy += state.expected.Unhealthy
|
||||
expected.Total++
|
||||
}
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
require.NoError(t, err, "failed to calculate container stats")
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
assert.Equal(t, expected, stats)
|
||||
assert.Equal(t, expected.Running, stats.Running)
|
||||
assert.Equal(t, expected.Stopped, stats.Stopped)
|
||||
assert.Equal(t, expected.Healthy, stats.Healthy)
|
||||
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
mockClient.AssertExpectations(t)
|
||||
|
||||
// Test concurrency: With 5 workers and 10 containers taking 50ms each:
|
||||
// Sequential would take: 10 * 50ms = 500ms
|
||||
sequentialTime := 10 * 50 * time.Millisecond
|
||||
|
||||
// Verify that concurrent processing is actually faster than sequential
|
||||
// Allow some overhead for goroutine scheduling
|
||||
assert.Less(t, duration, sequentialTime, "Concurrent processing should be faster than sequential")
|
||||
// Concurrent should take: ~100-150ms (depending on scheduling)
|
||||
assert.Less(t, duration, 150*time.Millisecond, "Concurrent processing should be significantly faster")
|
||||
assert.Greater(t, duration, 100*time.Millisecond, "Concurrent processing should be longer than 100ms")
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsAllErrors(t *testing.T) {
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers
|
||||
containers := []container.Summary{
|
||||
{ID: "container1"},
|
||||
{ID: "container2"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with all calls returning errors
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container1").Return(container.InspectResponse{}, errors.New("network error"))
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied"))
|
||||
|
||||
// Call the function
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
|
||||
// Assert that an error was returned
|
||||
require.Error(t, err, "should return error when all containers fail to inspect")
|
||||
assert.Contains(t, err.Error(), "network error", "error should contain one of the original error messages")
|
||||
assert.Contains(t, err.Error(), "permission denied", "error should contain the other original error message")
|
||||
|
||||
// Assert that stats are zero since no containers were successfully processed
|
||||
expectedStats := ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
Total: 2, // total containers processed
|
||||
}
|
||||
assert.Equal(t, expectedStats, stats)
|
||||
|
||||
// Verify all mock calls were made
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetContainerStatus(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
state *container.State
|
||||
expected ContainerStats
|
||||
}{
|
||||
{
|
||||
name: "running healthy container",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
Health: &container.Health{
|
||||
Status: container.Healthy,
|
||||
},
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 1,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "running unhealthy container",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
Health: &container.Health{
|
||||
Status: container.Unhealthy,
|
||||
},
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "running container without health check",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exited container",
|
||||
state: &container.State{
|
||||
Status: container.StateExited,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 1,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dead container",
|
||||
state: &container.State{
|
||||
Status: container.StateDead,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 1,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil state",
|
||||
state: nil,
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stat := getContainerStatus(testCase.state)
|
||||
assert.Equal(t, testCase.expected, stat)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsForSwarm(t *testing.T) {
|
||||
containers := []container.Summary{
|
||||
{State: "running"},
|
||||
{State: "running", Status: "Up 5 minutes (healthy)"},
|
||||
{State: "exited"},
|
||||
{State: "stopped"},
|
||||
{State: "running", Status: "Up 10 minutes"},
|
||||
{State: "running", Status: "Up about an hour (unhealthy)"},
|
||||
}
|
||||
|
||||
stats := CalculateContainerStatsForSwarm(containers)
|
||||
|
||||
assert.Equal(t, 4, stats.Running)
|
||||
assert.Equal(t, 2, stats.Stopped)
|
||||
assert.Equal(t, 1, stats.Healthy)
|
||||
assert.Equal(t, 1, stats.Unhealthy)
|
||||
assert.Equal(t, 6, stats.Total)
|
||||
}
|
||||
+1
-21
@@ -60,23 +60,11 @@ type (
|
||||
// EnvVars is a list of environment variables to inject into the stack
|
||||
EnvVars []portainer.Pair
|
||||
|
||||
// ForceUpdate is a flag indicating if the agent must force the update of the stack.
|
||||
// Used only for EE
|
||||
ForceUpdate bool
|
||||
|
||||
DeployerOptionsPayload DeployerOptionsPayload
|
||||
|
||||
// Used only for EE async edge agent
|
||||
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
|
||||
// Deprecated(2.36): use DeployerOptionsPayload.ForceRecreate instead
|
||||
ReadyRePullImage bool
|
||||
|
||||
// CreatedBy is the username that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedBy string
|
||||
// CreatedByUserId is the user ID that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedByUserId string
|
||||
DeployerOptionsPayload DeployerOptionsPayload
|
||||
}
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
@@ -89,14 +77,6 @@ type (
|
||||
// This flag drives `docker compose down --volumes` option
|
||||
// Used only for EE
|
||||
RemoveVolumes bool
|
||||
|
||||
// ForceRecreate is a flag indicating if the agent must force the redeployment of the stack.
|
||||
// This field is only used when the Force Redeployment is triggered.
|
||||
// Once the stack is redeployed, this field will be reset to false.
|
||||
// For standard edge agent, this field is used in agent side
|
||||
// For async edge agent, this field is used in both agent side and server side.
|
||||
// This flag drives `docker compose up --force-recreate` option
|
||||
ForceRecreate bool
|
||||
}
|
||||
|
||||
// RegistryCredentials holds the credentials for a Docker registry.
|
||||
|
||||
@@ -8,9 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_createEnvFile(t *testing.T) {
|
||||
@@ -63,7 +61,7 @@ func Test_createEnvFile(t *testing.T) {
|
||||
|
||||
assert.Equal(t, tt.expected, string(content))
|
||||
} else {
|
||||
assert.Empty(t, result)
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -81,7 +79,7 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
|
||||
}
|
||||
result, err := createEnvFile(stack)
|
||||
assert.Equal(t, filepath.Join(stack.ProjectPath, "stack.env"), result)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, path.Join(dir, "stack.env"))
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := io.ReadAll(f)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockKubectlClient struct {
|
||||
@@ -69,7 +68,7 @@ func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
|
||||
manifests := []string{"manifest1.yaml", "manifest2.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "apply", manifests)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
@@ -87,7 +86,7 @@ func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
|
||||
manifests := []string{"error.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "apply", manifests)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), expectedErr.Error())
|
||||
assert.True(t, called)
|
||||
}
|
||||
@@ -105,7 +104,7 @@ func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
|
||||
manifests := []string{"manifest1.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "delete", manifests)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
@@ -123,7 +122,7 @@ func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
|
||||
manifests := []string{"error.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "delete", manifests)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), expectedErr.Error())
|
||||
assert.True(t, called)
|
||||
}
|
||||
@@ -141,7 +140,7 @@ func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
|
||||
resources := []string{"deployment/nginx"}
|
||||
err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
@@ -159,7 +158,7 @@ func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
|
||||
resources := []string{"deployment/error"}
|
||||
err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), expectedErr.Error())
|
||||
assert.True(t, called)
|
||||
}
|
||||
@@ -169,6 +168,6 @@ func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) {
|
||||
|
||||
err := testExecuteKubectlOperation(mockClient, "unsupported", []string{})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported operation")
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
|
||||
if !endpoint.TLSConfig.TLSSkipVerify {
|
||||
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
|
||||
} else {
|
||||
args = append(args, "--tlscacert", "''")
|
||||
args = append(args, "--tlscacert", "")
|
||||
}
|
||||
|
||||
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
|
||||
|
||||
@@ -3,7 +3,9 @@ package exec
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigFilePaths(t *testing.T) {
|
||||
@@ -13,3 +15,29 @@ func TestConfigFilePaths(t *testing.T) {
|
||||
output := configureFilePaths(args, filePaths)
|
||||
assert.ElementsMatch(t, expected, output, "wrong output file paths")
|
||||
}
|
||||
|
||||
func TestPrepareDockerCommandAndArgs(t *testing.T) {
|
||||
binaryPath := "/test/dist"
|
||||
configPath := "/test/config"
|
||||
manager := &SwarmStackManager{
|
||||
binaryPath: binaryPath,
|
||||
configPath: configPath,
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
URL: "tcp://test:9000",
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: true,
|
||||
TLSSkipVerify: true,
|
||||
},
|
||||
}
|
||||
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(binaryPath, configPath, endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedCommand := "/test/dist/docker"
|
||||
expectedArgs := []string{"--config", "/test/config", "-H", "tcp://test:9000", "--tls", "--tlscacert", ""}
|
||||
|
||||
require.Equal(t, expectedCommand, command)
|
||||
require.Equal(t, expectedArgs, args)
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
err := copyFile("does-not-exist", tmpdir)
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
@@ -22,7 +21,7 @@ func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
os.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
|
||||
|
||||
err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
copyContent, _ := os.ReadFile(path.Join(tmpdir, "copy"))
|
||||
assert.Equal(t, content, copyContent)
|
||||
@@ -31,7 +30,7 @@ func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
destination := t.TempDir()
|
||||
err := CopyDir("./testdata/copy_test", destination, true)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
@@ -41,7 +40,7 @@ func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) {
|
||||
destination := t.TempDir()
|
||||
err := CopyDir("./testdata/copy_test", destination, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "dir", ".dotfile"))
|
||||
@@ -51,7 +50,7 @@ func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) {
|
||||
func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
err := CopyPath("does-not-exists", tmpdir)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoFileExists(t, tmpdir)
|
||||
}
|
||||
@@ -63,17 +62,17 @@ func Test_CopyPath_shouldCopyFile(t *testing.T) {
|
||||
|
||||
os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
|
||||
err := CopyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
copyContent, err := os.ReadFile(path.Join(tmpdir, "backup", "file"))
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_CopyPath_shouldCopyDir(t *testing.T) {
|
||||
destination := t.TempDir()
|
||||
err := CopyPath("./testdata/copy_test", destination)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
|
||||
@@ -31,14 +30,14 @@ func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
|
||||
|
||||
func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) {
|
||||
file, err := os.CreateTemp("", t.Name())
|
||||
require.NoError(t, err, "CreateTemp should not fail")
|
||||
assert.NoError(t, err, "CreateTemp should not fail")
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(file.Name())
|
||||
})
|
||||
|
||||
exists, err := checker(file.Name())
|
||||
require.NoError(t, err, "FileExists should not fail")
|
||||
assert.NoError(t, err, "FileExists should not fail")
|
||||
|
||||
assert.True(t, exists)
|
||||
}
|
||||
@@ -47,10 +46,10 @@ func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string)
|
||||
filePath := path.Join(t.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
|
||||
|
||||
err := os.RemoveAll(filePath)
|
||||
require.NoError(t, err, "RemoveAll should not fail")
|
||||
assert.NoError(t, err, "RemoveAll should not fail")
|
||||
|
||||
exists, err := checker(filePath)
|
||||
require.NoError(t, err, "FileExists should not fail")
|
||||
assert.NoError(t, err, "FileExists should not fail")
|
||||
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var content = []byte("content")
|
||||
@@ -14,25 +13,25 @@ var content = []byte("content")
|
||||
func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
|
||||
sourceDir := "missing"
|
||||
destinationDir := t.TempDir()
|
||||
file1 := addFile(t, destinationDir, "dir", "file")
|
||||
file2 := addFile(t, destinationDir, "file")
|
||||
file1 := addFile(destinationDir, "dir", "file")
|
||||
file2 := addFile(destinationDir, "file")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir, false)
|
||||
require.Error(t, err, "move directory should fail when source path is missing")
|
||||
assert.Error(t, err, "move directory should fail when source path is missing")
|
||||
assert.FileExists(t, file1, "destination dir contents should remain")
|
||||
assert.FileExists(t, file2, "destination dir contents should remain")
|
||||
}
|
||||
|
||||
func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
|
||||
sourceDir := t.TempDir()
|
||||
file1 := addFile(t, sourceDir, "dir", "file")
|
||||
file2 := addFile(t, sourceDir, "file")
|
||||
file1 := addFile(sourceDir, "dir", "file")
|
||||
file2 := addFile(sourceDir, "file")
|
||||
destinationDir := t.TempDir()
|
||||
file3 := addFile(t, destinationDir, "dir", "file")
|
||||
file4 := addFile(t, destinationDir, "file")
|
||||
file3 := addFile(destinationDir, "dir", "file")
|
||||
file4 := addFile(destinationDir, "file")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir, false)
|
||||
require.Error(t, err, "move directory should fail when destination directory already exists")
|
||||
assert.Error(t, err, "move directory should fail when destination directory already exists")
|
||||
assert.FileExists(t, file1, "source dir contents should remain")
|
||||
assert.FileExists(t, file2, "source dir contents should remain")
|
||||
assert.FileExists(t, file3, "destination dir contents should remain")
|
||||
@@ -41,14 +40,14 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
|
||||
|
||||
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
|
||||
sourceDir := t.TempDir()
|
||||
file1 := addFile(t, sourceDir, "dir", "file")
|
||||
file2 := addFile(t, sourceDir, "file")
|
||||
file1 := addFile(sourceDir, "dir", "file")
|
||||
file2 := addFile(sourceDir, "file")
|
||||
destinationDir := t.TempDir()
|
||||
file3 := addFile(t, destinationDir, "dir", "file")
|
||||
file4 := addFile(t, destinationDir, "file")
|
||||
file3 := addFile(destinationDir, "dir", "file")
|
||||
file4 := addFile(destinationDir, "file")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir, true)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, file1, "source dir contents should be moved")
|
||||
assert.NoFileExists(t, file2, "source dir contents should be moved")
|
||||
assert.FileExists(t, file3, "destination dir contents should remain")
|
||||
@@ -59,34 +58,32 @@ func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T)
|
||||
tmp := t.TempDir()
|
||||
sourceDir := path.Join(tmp, "source")
|
||||
os.Mkdir(sourceDir, 0766)
|
||||
file1 := addFile(t, sourceDir, "dir", "file")
|
||||
file2 := addFile(t, sourceDir, "file")
|
||||
file1 := addFile(sourceDir, "dir", "file")
|
||||
file2 := addFile(sourceDir, "file")
|
||||
destinationDir := path.Join(tmp, "destination")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, file1, "source dir contents should be moved")
|
||||
assert.NoFileExists(t, file2, "source dir contents should be moved")
|
||||
assertFileContent(t, path.Join(destinationDir, "file"))
|
||||
assertFileContent(t, path.Join(destinationDir, "dir", "file"))
|
||||
}
|
||||
|
||||
func addFile(t *testing.T, fileParts ...string) (filepath string) {
|
||||
func addFile(fileParts ...string) (filepath string) {
|
||||
if len(fileParts) > 2 {
|
||||
dir := path.Join(fileParts[:len(fileParts)-1]...)
|
||||
err := os.MkdirAll(dir, 0766)
|
||||
require.NoError(t, err)
|
||||
os.MkdirAll(dir, 0766)
|
||||
}
|
||||
|
||||
p := path.Join(fileParts...)
|
||||
err := os.WriteFile(p, content, 0766)
|
||||
require.NoError(t, err)
|
||||
os.WriteFile(p, content, 0766)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, filePath string) {
|
||||
actualContent, err := os.ReadFile(filePath)
|
||||
require.NoError(t, err, "failed to read file %s", filePath)
|
||||
assert.NoErrorf(t, err, "failed to read file %s", filePath)
|
||||
assert.Equal(t, content, actualContent, "file %s content doesn't match", filePath)
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func createService(t *testing.T) *Service {
|
||||
dataStorePath := path.Join(t.TempDir(), t.Name())
|
||||
|
||||
service, err := NewService(dataStorePath, "")
|
||||
require.NoError(t, err, "NewService should not fail")
|
||||
assert.NoError(t, err, "NewService should not fail")
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(dataStorePath)
|
||||
|
||||
@@ -167,3 +167,21 @@ func DecodeDirEntries(dirEntries []DirEntry) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDirEntriesByFilenames returns the dir entries that are files and match the provided filenames
|
||||
func GetDirEntriesByFilenames(dirEntries []DirEntry, names []string) []DirEntry {
|
||||
var filteredDirEntries []DirEntry
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
if !dirEntry.IsFile {
|
||||
continue
|
||||
}
|
||||
for _, name := range names {
|
||||
if dirEntry.Name == name {
|
||||
filteredDirEntries = append(filteredDirEntries, dirEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredDirEntries
|
||||
}
|
||||
|
||||
@@ -30,6 +30,20 @@ func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, mu
|
||||
return deduplicate(filteredDirEntries), envFiles
|
||||
}
|
||||
|
||||
// MultiFilterDirForPerDevConfigsWithDefaults filers the given dirEntries with multiple filter args, returns the merged entries for the given device
|
||||
// and always includes the defaultFilenames
|
||||
func MultiFilterDirForPerDevConfigsWithDefaults(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string) ([]DirEntry, []string) {
|
||||
|
||||
filteredDirEntries, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
|
||||
|
||||
// Add files that should always be included
|
||||
// e.g. entrypoint files
|
||||
defaultDirEntries := GetDirEntriesByFilenames(dirEntries, defaultFilenames)
|
||||
filteredDirEntries = append(filteredDirEntries, defaultDirEntries...)
|
||||
|
||||
return deduplicate(filteredDirEntries), envFiles
|
||||
}
|
||||
|
||||
func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
var deduplicatedDirEntries []DirEntry
|
||||
|
||||
|
||||
@@ -49,8 +49,11 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
|
||||
MultiFilterArgs{
|
||||
{"file1", portainer.PerDevConfigsTypeFile},
|
||||
{"folder1", portainer.PerDevConfigsTypeDir},
|
||||
},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]},
|
||||
)
|
||||
|
||||
// Filter file1 and file2
|
||||
@@ -76,6 +79,106 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) {
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string, wantDirEntries []DirEntry) {
|
||||
t.Helper()
|
||||
|
||||
dirEntries, _ = MultiFilterDirForPerDevConfigsWithDefaults(dirEntries, configPath, multiFilterArgs, defaultFilenames)
|
||||
require.Equal(t, wantDirEntries, dirEntries)
|
||||
}
|
||||
|
||||
baseDirEntries := []DirEntry{
|
||||
{".env", "", true, 420},
|
||||
{"docker-compose.yaml", "", true, 420},
|
||||
{"configs", "", false, 420},
|
||||
{"configs/file1.conf", "", true, 420},
|
||||
{"configs/file2.conf", "", true, 420},
|
||||
{"configs/folder1", "", false, 420},
|
||||
{"configs/folder1/config1", "", true, 420},
|
||||
{"configs/folder2", "", false, 420},
|
||||
{"configs/folder2/config2", "", true, 420},
|
||||
{"configs/docker-compose-2.yaml", "", true, 420},
|
||||
{"configs/folder2/docker-compose-3.yaml", "", true, 420},
|
||||
}
|
||||
|
||||
// Filter file1
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
|
||||
nil,
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
|
||||
)
|
||||
|
||||
// Filter folder1
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
|
||||
nil,
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
|
||||
)
|
||||
|
||||
// Filter file1 and folder1
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"file1", portainer.PerDevConfigsTypeFile},
|
||||
{"folder1", portainer.PerDevConfigsTypeDir},
|
||||
},
|
||||
nil,
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]},
|
||||
)
|
||||
|
||||
// Filter file1 and file2
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"file1", portainer.PerDevConfigsTypeFile},
|
||||
{"file2", portainer.PerDevConfigsTypeFile},
|
||||
},
|
||||
nil,
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
|
||||
)
|
||||
|
||||
// Filter folder1 and folder2
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"folder1", portainer.PerDevConfigsTypeDir},
|
||||
{"folder2", portainer.PerDevConfigsTypeDir},
|
||||
},
|
||||
nil,
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8], baseDirEntries[10]},
|
||||
)
|
||||
|
||||
// Filter file1 and folder1 and docker-compose-2.yaml
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"file1", portainer.PerDevConfigsTypeFile},
|
||||
{"folder1", portainer.PerDevConfigsTypeDir},
|
||||
},
|
||||
[]string{"configs/docker-compose-2.yaml"},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6], baseDirEntries[9]},
|
||||
)
|
||||
|
||||
// Filter file1 and docker-compose-3.yaml
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"file1", portainer.PerDevConfigsTypeFile},
|
||||
},
|
||||
[]string{"configs/folder2/docker-compose-3.yaml"},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[10]},
|
||||
)
|
||||
}
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
|
||||
@@ -15,7 +14,7 @@ func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
|
||||
|
||||
content := []byte("content")
|
||||
err := WriteToFile(tmpFilePath, content)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileContent, _ := os.ReadFile(tmpFilePath)
|
||||
assert.Equal(t, content, fileContent)
|
||||
@@ -26,11 +25,11 @@ func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) {
|
||||
tmpFilePath := path.Join(tmpDir, "dummy")
|
||||
|
||||
err := WriteToFile(tmpFilePath, []byte("content"))
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
content := []byte("new content")
|
||||
err = WriteToFile(tmpFilePath, content)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileContent, _ := os.ReadFile(tmpFilePath)
|
||||
assert.Equal(t, content, fileContent)
|
||||
@@ -42,7 +41,7 @@ func Test_WriteFile_CanWriteANestedPath(t *testing.T) {
|
||||
|
||||
content := []byte("content")
|
||||
err := WriteToFile(tmpFilePath, content)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileContent, _ := os.ReadFile(tmpFilePath)
|
||||
assert.Equal(t, content, fileContent)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test"
|
||||
@@ -68,7 +67,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
gittypes.GitCredentialAuthType_Basic,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
})
|
||||
}
|
||||
@@ -91,7 +90,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||
gittypes.GitCredentialAuthType_Basic,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
@@ -109,7 +108,7 @@ func TestService_LatestCommitID_Azure(t *testing.T) {
|
||||
gittypes.GitCredentialAuthType_Basic,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
||||
}
|
||||
|
||||
@@ -128,7 +127,7 @@ func TestService_ListRefs_Azure(t *testing.T) {
|
||||
false,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(refs), 1)
|
||||
}
|
||||
|
||||
@@ -290,14 +289,14 @@ func TestService_ListFiles_Azure(t *testing.T) {
|
||||
false,
|
||||
)
|
||||
if tt.expect.shouldFail {
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
if tt.expect.err != nil {
|
||||
assert.Equal(t, tt.expect.err, err)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
if tt.expect.matchedCount > 0 {
|
||||
assert.NotEmpty(t, paths)
|
||||
assert.Greater(t, len(paths), 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+18
-23
@@ -9,9 +9,7 @@ import (
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_buildDownloadUrl(t *testing.T) {
|
||||
@@ -21,18 +19,15 @@ func Test_buildDownloadUrl(t *testing.T) {
|
||||
project: "project",
|
||||
repository: "repository",
|
||||
}, "refs/heads/main")
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedUrl, err := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0&versionDescriptor.versionType=branch")
|
||||
require.NoError(t, err)
|
||||
|
||||
actualUrl, err := url.Parse(u)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
|
||||
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
|
||||
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
|
||||
assert.Equal(t, expectedUrl.Query(), actualUrl.Query())
|
||||
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0&versionDescriptor.versionType=branch")
|
||||
actualUrl, _ := url.Parse(u)
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
|
||||
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
|
||||
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
|
||||
assert.Equal(t, expectedUrl.Query(), actualUrl.Query())
|
||||
}
|
||||
}
|
||||
|
||||
func Test_buildRootItemUrl(t *testing.T) {
|
||||
@@ -45,7 +40,7 @@ func Test_buildRootItemUrl(t *testing.T) {
|
||||
|
||||
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&api-version=6.0&versionDescriptor.version=main&versionDescriptor.versionType=branch")
|
||||
actualUrl, _ := url.Parse(u)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
|
||||
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
|
||||
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
|
||||
@@ -62,7 +57,7 @@ func Test_buildRefsUrl(t *testing.T) {
|
||||
|
||||
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/refs?api-version=6.0")
|
||||
actualUrl, _ := url.Parse(u)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
|
||||
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
|
||||
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
|
||||
@@ -79,7 +74,7 @@ func Test_buildTreeUrl(t *testing.T) {
|
||||
|
||||
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/trees/sha1?api-version=6.0&recursive=true")
|
||||
actualUrl, _ := url.Parse(u)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
|
||||
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
|
||||
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
|
||||
@@ -309,7 +304,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
},
|
||||
}
|
||||
_, err := a.downloadZipFromAzureDevOps(context.Background(), option)
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.want, zipRequestAuth)
|
||||
})
|
||||
}
|
||||
@@ -507,12 +502,12 @@ func Test_listRefs_azure(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
refs, err := client.listRefs(context.TODO(), tt.args)
|
||||
if tt.expect.err == nil {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
if tt.expect.refsCount > 0 {
|
||||
assert.NotEmpty(t, refs)
|
||||
assert.Greater(t, len(refs), 0)
|
||||
}
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.expect.err, err)
|
||||
}
|
||||
})
|
||||
@@ -618,14 +613,14 @@ func Test_listFiles_azure(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
paths, err := client.listFiles(context.TODO(), tt.args)
|
||||
if tt.expect.shouldFail {
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
if tt.expect.err != nil {
|
||||
assert.Equal(t, tt.expect.err, err)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
if tt.expect.matchedCount > 0 {
|
||||
assert.NotEmpty(t, paths)
|
||||
assert.Greater(t, len(paths), 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+51
-20
@@ -3,23 +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
|
||||
}
|
||||
@@ -30,8 +49,33 @@ func NewGitClient(preserveGitDir bool) *gitClient {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
||||
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 {
|
||||
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) download(ctx context.Context, dst string, opt cloneOption) error {
|
||||
gitOptions := git.CloneOptions{
|
||||
gitOptions := &git.CloneOptions{
|
||||
URL: opt.repositoryUrl,
|
||||
Depth: opt.depth,
|
||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||
@@ -43,23 +87,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
|
||||
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
|
||||
}
|
||||
|
||||
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return c.Download(ctx, dst, gitOptions)
|
||||
}
|
||||
|
||||
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
|
||||
@@ -78,6 +106,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
|
||||
if err.Error() == "authentication required" {
|
||||
return "", gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return "", errors.Wrap(err, "failed to list repository refs")
|
||||
}
|
||||
|
||||
@@ -159,6 +188,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
|
||||
if ref.Name().String() == "HEAD" {
|
||||
continue
|
||||
}
|
||||
|
||||
ret = append(ret, ref.Name().String())
|
||||
}
|
||||
|
||||
@@ -225,5 +255,6 @@ func checkGitError(err error) error {
|
||||
} else if errMsg == "authentication required" {
|
||||
return gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,7 +35,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
gittypes.GitCredentialAuthType_Basic,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
@@ -57,7 +55,7 @@ func TestService_LatestCommitID_GitHub(t *testing.T) {
|
||||
gittypes.GitCredentialAuthType_Basic,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
||||
}
|
||||
|
||||
@@ -70,7 +68,7 @@ func TestService_ListRefs_GitHub(t *testing.T) {
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(refs), 1)
|
||||
}
|
||||
|
||||
@@ -233,14 +231,14 @@ func TestService_ListFiles_GitHub(t *testing.T) {
|
||||
false,
|
||||
)
|
||||
if tt.expect.shouldFail {
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
if tt.expect.err != nil {
|
||||
assert.Equal(t, tt.expect.err, err)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
if tt.expect.matchedCount > 0 {
|
||||
assert.NotEmpty(t, paths)
|
||||
assert.Greater(t, len(paths), 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -363,12 +361,12 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(refs), 1)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
|
||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
}
|
||||
|
||||
@@ -381,7 +379,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(refs), 1)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
|
||||
@@ -396,7 +394,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||
[]string{},
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(files), 1)
|
||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||
|
||||
@@ -411,16 +409,16 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||
[]string{},
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(files), 1)
|
||||
assert.Equal(t, 2, service.repoFileCache.Len())
|
||||
|
||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false)
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
|
||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, true, false)
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
// The relevant file caches should be removed too
|
||||
assert.Equal(t, 0, service.repoFileCache.Len())
|
||||
@@ -444,7 +442,7 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
|
||||
[]string{},
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(files), 1)
|
||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||
|
||||
@@ -459,7 +457,7 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
|
||||
[]string{},
|
||||
false,
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0, service.repoFileCache.Len())
|
||||
}
|
||||
|
||||
|
||||
+125
-15
@@ -3,13 +3,17 @@ 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"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -18,7 +22,7 @@ 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, 0755)
|
||||
if err != nil {
|
||||
@@ -40,7 +44,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
@@ -52,8 +56,8 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
||||
assert.NoError(t, err)
|
||||
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
|
||||
}
|
||||
|
||||
func Test_cloneRepository(t *testing.T) {
|
||||
@@ -75,7 +79,7 @@ func Test_cloneRepository(t *testing.T) {
|
||||
depth: 10,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 4, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
@@ -87,7 +91,7 @@ func Test_latestCommitID(t *testing.T) {
|
||||
|
||||
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
|
||||
}
|
||||
|
||||
@@ -98,7 +102,7 @@ func Test_ListRefs(t *testing.T) {
|
||||
|
||||
fs, err := service.ListRefs(repositoryURL, "", "", gittypes.GitCredentialAuthType_Basic, false, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"refs/heads/main"}, fs)
|
||||
}
|
||||
|
||||
@@ -120,7 +124,7 @@ func Test_ListFiles(t *testing.T) {
|
||||
false,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"docker-compose.yml"}, fs)
|
||||
}
|
||||
|
||||
@@ -146,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)
|
||||
|
||||
@@ -215,12 +325,12 @@ func Test_listRefsPrivateRepository(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
refs, err := client.listRefs(context.TODO(), tt.args)
|
||||
if tt.expect.err == nil {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
if tt.expect.refsCount > 0 {
|
||||
assert.NotEmpty(t, refs)
|
||||
assert.Greater(t, len(refs), 0)
|
||||
}
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.expect.err, err)
|
||||
}
|
||||
})
|
||||
@@ -326,14 +436,14 @@ func Test_listFilesPrivateRepository(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
paths, err := client.listFiles(context.TODO(), tt.args)
|
||||
if tt.expect.shouldFail {
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
if tt.expect.err != nil {
|
||||
assert.Equal(t, tt.expect.err, err)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
if tt.expect.matchedCount > 0 {
|
||||
assert.NotEmpty(t, paths)
|
||||
assert.Greater(t, len(paths), 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// UpdateGitObject updates a git object based on its config
|
||||
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) {
|
||||
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, forceUpdate, enableVersionFolder bool, projectPath string) (bool, string, error) {
|
||||
if gitConfig == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||
|
||||
hashChanged := !strings.EqualFold(newHash, gitConfig.ConfigHash)
|
||||
|
||||
if !hashChanged {
|
||||
if !hashChanged && !forceUpdate {
|
||||
log.Debug().
|
||||
Str("hash", newHash).
|
||||
Str("url", gitConfig.URL).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
func TxResponse(err error, validResponse func() *httperror.HandlerError) *httperror.HandlerError {
|
||||
if err != nil {
|
||||
var handlerError *httperror.HandlerError
|
||||
if errors.As(err, &handlerError) {
|
||||
return handlerError
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return validResponse()
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) {
|
||||
@@ -65,12 +64,13 @@ func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) {
|
||||
adminMonitor,
|
||||
)
|
||||
|
||||
// backup
|
||||
//backup
|
||||
archive := backup(t, h, test.backupPassword)
|
||||
|
||||
// restore
|
||||
//restore
|
||||
w := httptest.NewRecorder()
|
||||
r := prepareMultipartRequest(t, test.restorePassword, archive)
|
||||
r, err := prepareMultipartRequest(test.restorePassword, archive)
|
||||
assert.Nil(t, err, "Shouldn't fail to write multipart form")
|
||||
|
||||
restoreErr := h.restore(w, r)
|
||||
assert.Equal(t, test.fails, restoreErr != nil, "Didn't meet expectation of failing restore handler")
|
||||
@@ -96,12 +96,13 @@ func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) {
|
||||
adminMonitor,
|
||||
)
|
||||
|
||||
// backup
|
||||
//backup
|
||||
archive := backup(t, h, "password")
|
||||
|
||||
// restore
|
||||
//restore
|
||||
w := httptest.NewRecorder()
|
||||
r := prepareMultipartRequest(t, "password", archive)
|
||||
r, err := prepareMultipartRequest("password", archive)
|
||||
assert.Nil(t, err, "Shouldn't fail to write multipart form")
|
||||
|
||||
restoreErr := h.restore(w, r)
|
||||
assert.NotNil(t, restoreErr, "Should fail, because system it already initialized")
|
||||
@@ -116,31 +117,31 @@ func backup(t *testing.T, h *Handler, password string) []byte {
|
||||
assert.Nil(t, backupErr, "Backup should not fail")
|
||||
|
||||
response := w.Result()
|
||||
archive, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
archive, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
|
||||
return archive
|
||||
}
|
||||
|
||||
func prepareMultipartRequest(t *testing.T, password string, file []byte) *http.Request {
|
||||
func prepareMultipartRequest(password string, file []byte) (*http.Request, error) {
|
||||
var body bytes.Buffer
|
||||
|
||||
w := multipart.NewWriter(&body)
|
||||
err := w.WriteField("password", password)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fw, err := w.CreateFormFile("file", "filename")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = io.Copy(fw, bytes.NewReader(file))
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
io.Copy(fw, bytes.NewReader(file))
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "http://localhost/", &body)
|
||||
r.Header.Set("Content-Type", w.FormDataContentType())
|
||||
|
||||
err = w.Close()
|
||||
require.NoError(t, err)
|
||||
w.Close()
|
||||
|
||||
return r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
|
||||
return httperror.BadRequest("Invalid custom template identifier route variable", err)
|
||||
}
|
||||
|
||||
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
|
||||
}
|
||||
|
||||
canEdit := userCanEditTemplate(customTemplate, securityContext)
|
||||
hasAccess := false
|
||||
|
||||
if resourceControl != nil {
|
||||
customTemplate.ResourceControl = resourceControl
|
||||
|
||||
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
|
||||
return m.TeamID
|
||||
})
|
||||
|
||||
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
|
||||
}
|
||||
|
||||
if canEdit || hasAccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||
}); err != nil {
|
||||
return response.TxErrorResponse(err)
|
||||
}
|
||||
|
||||
entryPath := customTemplate.EntryPoint
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCustomTemplateFile(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
templateContent := "some template content"
|
||||
templateEntrypoint := "entrypoint"
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||
2: portainer.AccessPolicy{RoleID: 0},
|
||||
3: portainer.AccessPolicy{RoleID: 0},
|
||||
}}))
|
||||
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
|
||||
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
|
||||
|
||||
// template 1
|
||||
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
// template 2
|
||||
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
|
||||
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
|
||||
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
|
||||
}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
|
||||
r = mux.SetURLVars(r, map[string]string{"id": templateID})
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
return rr, handler.customTemplateFile(rr, r)
|
||||
}
|
||||
|
||||
t.Run("unknown id should get not found error", func(t *testing.T) {
|
||||
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusNotFound, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin should access adminonly template", func(t *testing.T) {
|
||||
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access adminonly template", func(t *testing.T) {
|
||||
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("std should access template via direct user access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should access template via team access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access template without access", func(t *testing.T) {
|
||||
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,8 +25,6 @@ import (
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -113,14 +112,15 @@ func createTestFile(targetPath string) error {
|
||||
}
|
||||
|
||||
func prepareTestFolder(projectPath, filename string) error {
|
||||
if err := os.MkdirAll(projectPath, fs.ModePerm); err != nil {
|
||||
err := os.MkdirAll(projectPath, fs.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return createTestFile(filepath.Join(projectPath, filename))
|
||||
}
|
||||
|
||||
func singleAPIRequest(h *Handler, jwt string, expect string) error {
|
||||
func singleAPIRequest(h *Handler, jwt string, is *assert.Assertions, expect string) {
|
||||
type response struct {
|
||||
FileContent string
|
||||
}
|
||||
@@ -131,25 +131,15 @@ func singleAPIRequest(h *Handler, jwt string, expect string) error {
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
return errors.New("unexpected status code: " + http.StatusText(rr.Code))
|
||||
}
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
|
||||
var resp response
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.FileContent != expect {
|
||||
return errors.New("unexpected file content: " + resp.FileContent + ", expected: " + expect)
|
||||
}
|
||||
|
||||
return nil
|
||||
err = json.Unmarshal(body, &resp)
|
||||
is.NoError(err, "response should be list json")
|
||||
is.Equal(resp.FileContent, expect)
|
||||
}
|
||||
|
||||
func Test_customTemplateGitFetch(t *testing.T) {
|
||||
@@ -160,29 +150,28 @@ func Test_customTemplateGitFetch(t *testing.T) {
|
||||
// create user(s)
|
||||
user1 := &portainer.User{ID: 1, Username: "user-1", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err := store.User().Create(user1)
|
||||
require.NoError(t, err, "error creating user 1")
|
||||
is.NoError(err, "error creating user 1")
|
||||
|
||||
user2 := &portainer.User{ID: 2, Username: "user-2", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err = store.User().Create(user2)
|
||||
require.NoError(t, err, "error creating user 2")
|
||||
is.NoError(err, "error creating user 2")
|
||||
|
||||
dir, err := os.Getwd()
|
||||
require.NoError(t, err, "error to get working directory")
|
||||
is.NoError(err, "error to get working directory")
|
||||
|
||||
template1 := &portainer.CustomTemplate{ID: 1, Title: "custom-template-1", ProjectPath: filepath.Join(dir, "fixtures/custom_template_1"), GitConfig: &gittypes.RepoConfig{ConfigFilePath: "test-config-path.txt"}}
|
||||
err = store.CustomTemplateService.Create(template1)
|
||||
require.NoError(t, err, "error creating custom template 1")
|
||||
is.NoError(err, "error creating custom template 1")
|
||||
|
||||
// prepare testing folder
|
||||
err = prepareTestFolder(template1.ProjectPath, template1.GitConfig.ConfigFilePath)
|
||||
require.NoError(t, err, "error creating testing folder")
|
||||
is.NoError(err, "error creating testing folder")
|
||||
|
||||
defer os.RemoveAll(filepath.Join(dir, "fixtures"))
|
||||
|
||||
// setup services
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
require.NoError(t, err, "Error initiating jwt service")
|
||||
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
requestBouncer := security.NewRequestBouncer(store, jwtService, nil)
|
||||
|
||||
gitService := &TestGitService{
|
||||
@@ -193,55 +182,52 @@ func Test_customTemplateGitFetch(t *testing.T) {
|
||||
h := NewHandler(requestBouncer, store, fileService, gitService)
|
||||
|
||||
// generate two standard users' tokens
|
||||
jwt1, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role})
|
||||
require.NoError(t, err)
|
||||
|
||||
jwt2, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role})
|
||||
require.NoError(t, err)
|
||||
jwt1, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role})
|
||||
jwt2, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role})
|
||||
|
||||
t.Run("can return the expected file content by a single call from one user", func(t *testing.T) {
|
||||
err := singleAPIRequest(h, jwt1, "abcdefg")
|
||||
require.NoError(t, err)
|
||||
singleAPIRequest(h, jwt1, is, "abcdefg")
|
||||
})
|
||||
|
||||
t.Run("can return the expected file content by multiple calls from one user", func(t *testing.T) {
|
||||
var g errgroup.Group
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
|
||||
for range 5 {
|
||||
g.Go(func() error {
|
||||
return singleAPIRequest(h, jwt1, "abcdefg")
|
||||
})
|
||||
go func() {
|
||||
singleAPIRequest(h, jwt1, is, "abcdefg")
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
err := g.Wait()
|
||||
require.NoError(t, err)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("can return the expected file content by multiple calls from different users", func(t *testing.T) {
|
||||
var g errgroup.Group
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(10)
|
||||
|
||||
for i := range 10 {
|
||||
g.Go(func() error {
|
||||
if i%2 == 0 {
|
||||
return singleAPIRequest(h, jwt1, "abcdefg")
|
||||
go func(j int) {
|
||||
if j%2 == 0 {
|
||||
singleAPIRequest(h, jwt1, is, "abcdefg")
|
||||
} else {
|
||||
singleAPIRequest(h, jwt2, is, "abcdefg")
|
||||
}
|
||||
|
||||
return singleAPIRequest(h, jwt2, "abcdefg")
|
||||
})
|
||||
wg.Done()
|
||||
}(i)
|
||||
}
|
||||
|
||||
err := g.Wait()
|
||||
require.NoError(t, err)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("can return the expected file content after a new commit is made", func(t *testing.T) {
|
||||
err := singleAPIRequest(h, jwt1, "abcdefg")
|
||||
require.NoError(t, err)
|
||||
singleAPIRequest(h, jwt1, is, "abcdefg")
|
||||
|
||||
testFileContent = "gfedcba"
|
||||
|
||||
err = singleAPIRequest(h, jwt2, "gfedcba")
|
||||
require.NoError(t, err)
|
||||
singleAPIRequest(h, jwt2, is, "gfedcba")
|
||||
})
|
||||
|
||||
t.Run("restore git repository if it is failed to download the new git repository", func(t *testing.T) {
|
||||
@@ -260,11 +246,11 @@ func Test_customTemplateGitFetch(t *testing.T) {
|
||||
|
||||
var errResp httperror.HandlerError
|
||||
err = json.NewDecoder(rr.Body).Decode(&errResp)
|
||||
require.NoError(t, err, "failed to parse error body")
|
||||
assert.NoError(t, err, "failed to parse error body")
|
||||
|
||||
assert.FileExists(t, gitService.targetFilePath, "previous git repository is not restored")
|
||||
fileContent, err := os.ReadFile(gitService.targetFilePath)
|
||||
require.NoError(t, err, "failed to read target file")
|
||||
assert.NoError(t, err, "failed to read target file")
|
||||
assert.Equal(t, "gfedcba", string(fileContent))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/docker/stats"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/handler/docker/utils"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -25,12 +26,12 @@ type imagesCounters struct {
|
||||
}
|
||||
|
||||
type dashboardResponse struct {
|
||||
Containers stats.ContainerStats `json:"containers"`
|
||||
Services int `json:"services"`
|
||||
Images imagesCounters `json:"images"`
|
||||
Volumes int `json:"volumes"`
|
||||
Networks int `json:"networks"`
|
||||
Stacks int `json:"stacks"`
|
||||
Containers docker.ContainerStats `json:"containers"`
|
||||
Services int `json:"services"`
|
||||
Images imagesCounters `json:"images"`
|
||||
Volumes int `json:"volumes"`
|
||||
Networks int `json:"networks"`
|
||||
Stacks int `json:"stacks"`
|
||||
}
|
||||
|
||||
// @id dockerDashboard
|
||||
@@ -143,18 +144,13 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
|
||||
stackCount = len(stacks)
|
||||
}
|
||||
|
||||
containersStats, err := stats.CalculateContainerStats(r.Context(), cli, info.Swarm.ControlAvailable, containers)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve Docker containers stats", err)
|
||||
}
|
||||
|
||||
resp = dashboardResponse{
|
||||
Images: imagesCounters{
|
||||
Total: len(images),
|
||||
Size: totalSize,
|
||||
},
|
||||
Services: len(services),
|
||||
Containers: containersStats,
|
||||
Containers: docker.CalculateContainerStats(containers),
|
||||
Networks: len(networks),
|
||||
Volumes: len(volumes),
|
||||
Stacks: stackCount,
|
||||
@@ -163,5 +159,7 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
|
||||
return nil
|
||||
})
|
||||
|
||||
return response.TxResponse(w, resp, err)
|
||||
return errors.TxResponse(err, func() *httperror.HandlerError {
|
||||
return response.JSON(w, resp)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandler_getDockerStacks(t *testing.T) {
|
||||
@@ -70,7 +69,7 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
||||
stacksList, err := GetDockerStacks(datastore, &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
}, environment.ID, containers, services)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, stacksList, 3)
|
||||
|
||||
expectedStacks := []StackViewModel{
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type edgeGroupCreatePayload struct {
|
||||
@@ -78,8 +77,7 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
var edgeGroup *portainer.EdgeGroup
|
||||
|
||||
var shadowEdgeGroup shadowedEdgeGroup
|
||||
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeGroups, err := tx.EdgeGroup().ReadAll()
|
||||
if err != nil {
|
||||
@@ -92,7 +90,7 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroup = &portainer.EdgeGroup{
|
||||
edgeGroup := &portainer.EdgeGroup{
|
||||
Name: payload.Name,
|
||||
Dynamic: payload.Dynamic,
|
||||
TagIDs: []portainer.TagID{},
|
||||
@@ -109,8 +107,10 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.InternalServerError("Unable to persist the Edge group inside the database", err)
|
||||
}
|
||||
|
||||
shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return response.TxResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
|
||||
return txResponse(w, shadowEdgeGroup, err)
|
||||
}
|
||||
|
||||
@@ -60,3 +60,22 @@ func TestEdgeGroupCreateHandler(t *testing.T) {
|
||||
|
||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
|
||||
}
|
||||
|
||||
func TestEdgeGroupCreatePanic(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
err := store.EdgeGroup().Create(&portainer.EdgeGroup{ID: 1, Name: "New Edge Group"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/edge_groups",
|
||||
strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`),
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusBadRequest, rr.Result().StatusCode)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,16 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return deleteEdgeGroup(tx, portainer.EdgeGroupID(edgeGroupID))
|
||||
})
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return response.TxEmptyResponse(w, err)
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id EdgeGroupInspect
|
||||
@@ -29,15 +28,21 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.BadRequest("Invalid Edge group identifier route variable", err)
|
||||
}
|
||||
|
||||
var edgeGroup *portainer.EdgeGroup
|
||||
var shadowEdgeGroup shadowedEdgeGroup
|
||||
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeGroup, err = getEdgeGroup(tx, portainer.EdgeGroupID(edgeGroupID))
|
||||
return err
|
||||
edgeGroup, err := getEdgeGroup(tx, portainer.EdgeGroupID(edgeGroupID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
|
||||
|
||||
shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
|
||||
|
||||
return response.TxResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
|
||||
return txResponse(w, shadowEdgeGroup, err)
|
||||
}
|
||||
|
||||
func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) {
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestEmptyEdgeGroupInspectHandler(t *testing.T) {
|
||||
|
||||
// Make sure the frontend does not get a null value but a [] instead
|
||||
require.NotNil(t, responseGroup.Endpoints)
|
||||
require.Empty(t, responseGroup.Endpoints)
|
||||
require.Len(t, responseGroup.Endpoints, 0)
|
||||
}
|
||||
|
||||
func TestDynamicEdgeGroupInspectHandler(t *testing.T) {
|
||||
@@ -174,3 +174,16 @@ func TestDynamicEdgeGroupInspectHandler(t *testing.T) {
|
||||
|
||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
|
||||
}
|
||||
|
||||
func TestEdgeGroupInspectPanic(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/edge_groups/1", nil)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusNotFound, rr.Result().StatusCode)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type shadowedEdgeGroup struct {
|
||||
@@ -45,7 +44,7 @@ func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *h
|
||||
return err
|
||||
})
|
||||
|
||||
return response.TxResponse(w, decoratedEdgeGroups, err)
|
||||
return txResponse(w, decoratedEdgeGroups, err)
|
||||
}
|
||||
|
||||
func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) {
|
||||
|
||||
@@ -47,7 +47,7 @@ func Test_getEndpointTypes(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
ans, err := getEndpointTypes(datastore, roar.FromSlice(test.endpointIds))
|
||||
require.NoError(t, err, "getEndpointTypes shouldn't fail")
|
||||
assert.NoError(t, err, "getEndpointTypes shouldn't fail")
|
||||
|
||||
assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) {
|
||||
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
||||
|
||||
_, err := getEndpointTypes(datastore, roar.FromSlice([]portainer.EndpointID{1}))
|
||||
require.Error(t, err, "getEndpointTypes should fail")
|
||||
assert.Error(t, err, "getEndpointTypes should fail")
|
||||
}
|
||||
|
||||
func TestEdgeGroupListHandler(t *testing.T) {
|
||||
@@ -112,5 +112,5 @@ func TestEdgeGroupListHandler(t *testing.T) {
|
||||
|
||||
require.Len(t, responseGroups, 1)
|
||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroups[0].Endpoints)
|
||||
require.Empty(t, responseGroups[0].TrustedEndpoints)
|
||||
require.Len(t, responseGroups[0].TrustedEndpoints, 0)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type edgeGroupUpdatePayload struct {
|
||||
@@ -57,9 +56,9 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
var edgeGroup *portainer.EdgeGroup
|
||||
var shadowEdgeGroup shadowedEdgeGroup
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeGroup, err = tx.EdgeGroup().Read(portainer.EdgeGroupID(edgeGroupID))
|
||||
edgeGroup, err := tx.EdgeGroup().Read(portainer.EdgeGroupID(edgeGroupID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
@@ -156,10 +155,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return response.TxResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
|
||||
return txResponse(w, shadowEdgeGroup, err)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||
|
||||
@@ -68,3 +68,16 @@ func TestEdgeGroupUpdateHandler(t *testing.T) {
|
||||
|
||||
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
|
||||
}
|
||||
|
||||
func TestEdgeGroupUpdatePanic(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/edge_groups/1", strings.NewReader("{}"))
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusNotFound, rr.Result().StatusCode)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package edgegroups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -36,3 +38,16 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func txResponse(w http.ResponseWriter, r any, err error) *httperror.HandlerError {
|
||||
if err != nil {
|
||||
var handlerError *httperror.HandlerError
|
||||
if errors.As(err, &handlerError) {
|
||||
return handlerError
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, r)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
)
|
||||
|
||||
@@ -86,18 +85,19 @@ func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) er
|
||||
// @router /edge_jobs/create/string [post]
|
||||
func (handler *Handler) createEdgeJobFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload edgeJobCreateFromFileContentPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
var edgeJob *portainer.EdgeJob
|
||||
var err error
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeJob, err = handler.createEdgeJob(tx, &payload.edgeJobBasePayload, []byte(payload.FileContent))
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return response.TxResponse(w, edgeJob, err)
|
||||
return txResponse(w, edgeJob, err)
|
||||
}
|
||||
|
||||
func (handler *Handler) createEdgeJob(tx dataservices.DataStoreTx, payload *edgeJobBasePayload, fileContent []byte) (*portainer.EdgeJob, error) {
|
||||
@@ -191,18 +191,19 @@ func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error {
|
||||
// @router /edge_jobs/create/file [post]
|
||||
func (handler *Handler) createEdgeJobFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
payload := &edgeJobCreateFromFilePayload{}
|
||||
if err := payload.Validate(r); err != nil {
|
||||
err := payload.Validate(r)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
var edgeJob *portainer.EdgeJob
|
||||
var err error
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeJob, err = handler.createEdgeJob(tx, &payload.edgeJobBasePayload, payload.File)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return response.TxResponse(w, edgeJob, err)
|
||||
return txResponse(w, edgeJob, err)
|
||||
}
|
||||
|
||||
func (handler *Handler) createEdgeJobObjectFromPayload(tx dataservices.DataStoreTx, payload *edgeJobBasePayload) *portainer.EdgeJob {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package edgejobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockFileService struct {
|
||||
mock.Mock
|
||||
portainer.FileService
|
||||
}
|
||||
|
||||
func (m *mockFileService) StoreEdgeJobFileFromBytes(id string, file []byte) (string, error) {
|
||||
args := m.Called(id, file)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockFileService) GetEdgeJobFolder(id string) string {
|
||||
args := m.Called(id)
|
||||
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockFileService) RemoveDirectory(path string) error {
|
||||
args := m.Called(path)
|
||||
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func initStore(t *testing.T) *datastore.Store {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
require.NotNil(t, store)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 1,
|
||||
Name: "endpoint-1",
|
||||
EdgeID: "edge-id-1",
|
||||
GroupID: 1,
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
UserTrusted: true,
|
||||
}))
|
||||
|
||||
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 2,
|
||||
Name: "endpoint-2",
|
||||
EdgeID: "edge-id-2",
|
||||
GroupID: 1,
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
UserTrusted: false,
|
||||
}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func Test_edgeJobCreate_StringMethod_Success(t *testing.T) {
|
||||
store := initStore(t)
|
||||
|
||||
fileService := &mockFileService{}
|
||||
fileService.On("StoreEdgeJobFileFromBytes", mock.Anything, mock.Anything).Return("testfile.txt", nil)
|
||||
|
||||
handler := &Handler{
|
||||
DataStore: store,
|
||||
FileService: fileService,
|
||||
}
|
||||
|
||||
payload := edgeJobCreateFromFileContentPayload{
|
||||
edgeJobBasePayload: edgeJobBasePayload{
|
||||
Name: "testjob",
|
||||
CronExpression: "* * * * *",
|
||||
Endpoints: []portainer.EndpointID{1, 2},
|
||||
},
|
||||
FileContent: "echo hello",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/edge_jobs/create/string", bytes.NewReader(body))
|
||||
req = mux.SetURLVars(req, map[string]string{"method": "string"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call handler
|
||||
errh := handler.edgeJobCreate(w, req)
|
||||
require.Nil(t, errh)
|
||||
require.Equal(t, http.StatusOK, w.Result().StatusCode)
|
||||
|
||||
// Get edge job ID from response
|
||||
var resp struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
|
||||
edgeJob, err := store.EdgeJob().Read(portainer.EdgeJobID(resp.ID))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, edgeJob.Endpoints, 2)
|
||||
require.Contains(t, edgeJob.Endpoints, portainer.EndpointID(1))
|
||||
}
|
||||
|
||||
func Test_edgeJobCreate_FileMethod_Success(t *testing.T) {
|
||||
store := initStore(t)
|
||||
|
||||
fileService := &mockFileService{}
|
||||
fileService.On("StoreEdgeJobFileFromBytes", mock.Anything, mock.Anything).Return("testfile.txt", nil)
|
||||
|
||||
handler := &Handler{
|
||||
DataStore: store,
|
||||
FileService: fileService,
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("Name", "testjob"))
|
||||
require.NoError(t, writer.WriteField("CronExpression", "* * * * *"))
|
||||
require.NoError(t, writer.WriteField("Endpoints", "[1,2]"))
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("file", "test.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = io.Copy(fileWriter, strings.NewReader("echo hello"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/edge_jobs/create/file", &body)
|
||||
req = mux.SetURLVars(req, map[string]string{"method": "file"})
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handlerErr := handler.edgeJobCreate(w, req)
|
||||
require.Nil(t, handlerErr)
|
||||
require.Equal(t, http.StatusOK, w.Result().StatusCode)
|
||||
|
||||
var resp struct {
|
||||
ID int `json:"Id"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
|
||||
edgeJob, err := store.EdgeJob().Read(portainer.EdgeJobID(resp.ID))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, edgeJob.Endpoints, 2)
|
||||
require.Contains(t, edgeJob.Endpoints, portainer.EndpointID(1))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package edgejobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -34,11 +35,18 @@ func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *h
|
||||
return httperror.BadRequest("Invalid Edge job identifier route variable", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return handler.deleteEdgeJob(tx, portainer.EdgeJobID(edgeJobID))
|
||||
})
|
||||
}); err != nil {
|
||||
var handlerError *httperror.HandlerError
|
||||
if errors.As(err, &handlerError) {
|
||||
return handlerError
|
||||
}
|
||||
|
||||
return response.TxEmptyResponse(w, err)
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteEdgeJob(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package edgejobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -53,7 +54,7 @@ func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
updateEdgeJobFn := func(edgeJob *portainer.EdgeJob, endpointID portainer.EndpointID, endpointsFromGroups []portainer.EndpointID) error {
|
||||
mutationFn(edgeJob, endpointID, endpointsFromGroups)
|
||||
|
||||
@@ -61,9 +62,16 @@ func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
return handler.clearEdgeJobTaskLogs(tx, portainer.EdgeJobID(edgeJobID), portainer.EndpointID(taskID), updateEdgeJobFn)
|
||||
})
|
||||
}); err != nil {
|
||||
var handlerError *httperror.HandlerError
|
||||
if errors.As(err, &handlerError) {
|
||||
return handlerError
|
||||
}
|
||||
|
||||
return response.TxEmptyResponse(w, err)
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) clearEdgeJobTaskLogs(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID, endpointID portainer.EndpointID, updateEdgeJob func(*portainer.EdgeJob, portainer.EndpointID, []portainer.EndpointID) error) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package edgejobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
@@ -38,7 +39,7 @@ func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Reque
|
||||
return httperror.BadRequest("Invalid Task identifier route variable", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeJob, err := tx.EdgeJob().Read(portainer.EdgeJobID(edgeJobID))
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err)
|
||||
@@ -80,7 +81,14 @@ func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
var handlerError *httperror.HandlerError
|
||||
if errors.As(err, &handlerError) {
|
||||
return handlerError
|
||||
}
|
||||
|
||||
return response.TxEmptyResponse(w, err)
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type taskContainer struct {
|
||||
@@ -50,33 +49,31 @@ func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request)
|
||||
return err
|
||||
})
|
||||
|
||||
return response.TxFuncResponse(err, func() *httperror.HandlerError {
|
||||
results := filters.SearchOrderAndPaginate(tasks, params, filters.Config[*taskContainer]{
|
||||
SearchAccessors: []filters.SearchAccessor[*taskContainer]{
|
||||
func(tc *taskContainer) (string, error) {
|
||||
switch tc.LogsStatus {
|
||||
case portainer.EdgeJobLogsStatusPending:
|
||||
return "pending", nil
|
||||
case 0, portainer.EdgeJobLogsStatusIdle:
|
||||
return "idle", nil
|
||||
case portainer.EdgeJobLogsStatusCollected:
|
||||
return "collected", nil
|
||||
}
|
||||
return "", errors.New("unknown state")
|
||||
},
|
||||
func(tc *taskContainer) (string, error) {
|
||||
return tc.EndpointName, nil
|
||||
},
|
||||
results := filters.SearchOrderAndPaginate(tasks, params, filters.Config[*taskContainer]{
|
||||
SearchAccessors: []filters.SearchAccessor[*taskContainer]{
|
||||
func(tc *taskContainer) (string, error) {
|
||||
switch tc.LogsStatus {
|
||||
case portainer.EdgeJobLogsStatusPending:
|
||||
return "pending", nil
|
||||
case 0, portainer.EdgeJobLogsStatusIdle:
|
||||
return "idle", nil
|
||||
case portainer.EdgeJobLogsStatusCollected:
|
||||
return "collected", nil
|
||||
}
|
||||
return "", errors.New("unknown state")
|
||||
},
|
||||
SortBindings: []filters.SortBinding[*taskContainer]{
|
||||
{Key: "EndpointName", Fn: func(a, b *taskContainer) int { return strings.Compare(a.EndpointName, b.EndpointName) }},
|
||||
func(tc *taskContainer) (string, error) {
|
||||
return tc.EndpointName, nil
|
||||
},
|
||||
})
|
||||
|
||||
filters.ApplyFilterResultsHeaders(&w, results)
|
||||
|
||||
return response.JSON(w, results.Items)
|
||||
},
|
||||
SortBindings: []filters.SortBinding[*taskContainer]{
|
||||
{Key: "EndpointName", Fn: func(a, b *taskContainer) int { return strings.Compare(a.EndpointName, b.EndpointName) }},
|
||||
},
|
||||
})
|
||||
|
||||
filters.ApplyFilterResultsHeaders(&w, results)
|
||||
|
||||
return txResponse(w, results.Items, err)
|
||||
}
|
||||
|
||||
func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]*taskContainer, error) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -69,16 +68,14 @@ func Test_EdgeJobTasksListHandler(t *testing.T) {
|
||||
|
||||
tcStr := rr.Header().Get("x-total-count")
|
||||
assert.NotEmpty(t, tcStr)
|
||||
|
||||
totalCount, err := strconv.Atoi(tcStr)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedCount, totalCount)
|
||||
|
||||
taStr := rr.Header().Get("x-total-available")
|
||||
assert.NotEmpty(t, taStr)
|
||||
|
||||
totalAvailable, err := strconv.Atoi(taStr)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, envCount, totalAvailable)
|
||||
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
)
|
||||
|
||||
@@ -67,7 +66,7 @@ func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *h
|
||||
return err
|
||||
})
|
||||
|
||||
return response.TxResponse(w, edgeJob, err)
|
||||
return txResponse(w, edgeJob, err)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEdgeJob(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID, payload edgeJobUpdatePayload) (*portainer.EdgeJob, error) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package edgejobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -58,3 +60,16 @@ func convertEndpointsToMetaObject(endpoints []portainer.EndpointID) map[portaine
|
||||
|
||||
return endpointsMap
|
||||
}
|
||||
|
||||
func txResponse(w http.ResponseWriter, r any, err error) *httperror.HandlerError {
|
||||
if err != nil {
|
||||
var handlerError *httperror.HandlerError
|
||||
if errors.As(err, &handlerError) {
|
||||
return handlerError
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, r)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
var edgeStack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData, r)
|
||||
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData.ID, r)
|
||||
return err
|
||||
}); err != nil {
|
||||
switch {
|
||||
@@ -43,14 +43,14 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||
return response.JSON(w, edgeStack)
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, tokenData *portainer.TokenData, r *http.Request) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, userID portainer.UserID, r *http.Request) (*portainer.EdgeStack, error) {
|
||||
switch method {
|
||||
case "string":
|
||||
return handler.createEdgeStackFromFileContent(r, tx, tokenData, dryrun)
|
||||
return handler.createEdgeStackFromFileContent(r, tx, dryrun)
|
||||
case "repository":
|
||||
return handler.createEdgeStackFromGitRepository(r, tx, tokenData, dryrun)
|
||||
return handler.createEdgeStackFromGitRepository(r, tx, dryrun, userID)
|
||||
case "file":
|
||||
return handler.createEdgeStackFromFileUpload(r, tx, tokenData, dryrun)
|
||||
return handler.createEdgeStackFromFileUpload(r, tx, dryrun)
|
||||
}
|
||||
|
||||
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
@@ -101,7 +99,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
// @failure 500 "Internal server error"
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/create/file [post]
|
||||
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
payload := &edgeStackFromFileUploadPayload{}
|
||||
if err := payload.Validate(r); err != nil {
|
||||
return nil, err
|
||||
@@ -115,8 +113,6 @@ func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx datase
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeFileContent(tx, stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
@@ -104,7 +103,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
|
||||
// @failure 500 "Internal server error"
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/create/repository [post]
|
||||
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
|
||||
var payload edgeStackFromGitRepositoryPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -134,11 +133,8 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
|
||||
}
|
||||
}
|
||||
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeManifestFromGitRepository(tx, stackFolder, relatedEndpointIds, payload.DeploymentType, tokenData.ID, repoConfig)
|
||||
return handler.storeManifestFromGitRepository(tx, stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
@@ -75,7 +74,7 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
|
||||
// @failure 500 "Internal server error"
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/create/string [post]
|
||||
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
var payload edgeStackFromStringPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -86,9 +85,6 @@ func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx datas
|
||||
return nil, errors.Wrap(err, "failed to create Edge stack object")
|
||||
}
|
||||
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -29,11 +30,18 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID))
|
||||
})
|
||||
}); err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return response.TxEmptyResponse(w, err)
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID) error {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user