Compare commits
181 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 549e916c3e | |||
|
|
4c76462943 | ||
|
|
5a4d7e557b | ||
|
|
32f1b601b6 | ||
|
|
3c7400ca2a | ||
|
|
cdb1f8648a | ||
| 594312a777 | |||
|
|
492d3d01b0 | ||
| 6a4242b166 | |||
|
|
8829c8cfd2 | ||
|
|
331319f7f1 | ||
|
|
a60b7be55d | ||
|
|
5bb678d3ba | ||
|
|
f6752130ac | ||
| 394075c94c | |||
|
|
9a6dd0c408 | ||
|
|
eb35e9c47f | ||
|
|
7f02d20e54 | ||
|
|
6aecdfbe46 | ||
|
|
f379e8057e | ||
| 8366f4d25c | |||
|
|
4d8fb82e15 | ||
|
|
474c41ec8e | ||
| 0e572f4ccc | |||
| 1e8f10f9cc | |||
|
|
9205099f14 | ||
|
|
7257ae52d8 | ||
|
|
0bf4e71b79 | ||
|
|
637e96f236 | ||
|
|
343d36834a | ||
|
|
f27e44f5f2 | ||
|
|
f658d67ccb | ||
|
|
5f16799b4c | ||
|
|
8ad42a1a45 | ||
|
|
9768d7bb99 | ||
|
|
e9fae32b43 | ||
|
|
28a06e80a8 | ||
|
|
be3bfd0513 | ||
|
|
6171806528 | ||
|
|
da6933c218 | ||
|
|
90f51d48bb | ||
|
|
d520aec159 | ||
|
|
922f506fe5 | ||
|
|
b1b09e5da0 | ||
|
|
f4f296fc05 | ||
|
|
a1851417d1 | ||
|
|
70f7fe5e84 | ||
|
|
cdf17d904d | ||
|
|
32a2b7a9ae | ||
|
|
21b5ec3e05 | ||
|
|
b3ae5f3659 | ||
|
|
ccd5897915 | ||
|
|
f7cb0f3241 | ||
|
|
7eaff4dab0 | ||
|
|
f69eb3f9eb | ||
|
|
b233f75ab7 | ||
|
|
51957d2f98 | ||
|
|
b4d10a67b2 | ||
|
|
cb11b0fca4 | ||
|
|
960d43e70b | ||
|
|
c3cdb8007e | ||
|
|
8ca0608b21 | ||
|
|
b7df90905d | ||
|
|
ef47503bf8 | ||
|
|
76896e5916 | ||
|
|
7dc98df2b6 | ||
|
|
cddccd2a5f | ||
|
|
003a90c235 | ||
|
|
9e9bb1bbff | ||
|
|
d0a0395337 | ||
|
|
88589e4cb3 | ||
|
|
af74986e66 | ||
|
|
e664bf0e19 | ||
|
|
152c89972b | ||
|
|
25c69c6e9b | ||
|
|
a6370808ae | ||
|
|
6bfd2360d8 | ||
|
|
872d1e03f6 | ||
|
|
a5cacd712d | ||
|
|
f596c862b3 | ||
|
|
5395dee4c6 | ||
|
|
217fe870ef | ||
|
|
26334e9088 | ||
|
|
cc45af2873 | ||
|
|
37bd8c06b5 | ||
|
|
c821a1c59f | ||
|
|
f5d0b3d849 | ||
|
|
0dfd27f08c | ||
|
|
0dfa0266c7 | ||
|
|
9b807ca314 | ||
|
|
de5d84ade4 | ||
|
|
4d539a691d | ||
|
|
ee8e73d7f9 | ||
|
|
32c6bedb98 | ||
|
|
cd9bb18ba1 | ||
|
|
f365035563 | ||
|
|
d9673e33ec | ||
|
|
491df61fbf | ||
|
|
ca1d9dc6a2 | ||
|
|
16b5554f66 | ||
|
|
fcdd6b4510 | ||
|
|
04048c3818 | ||
|
|
1afbc621a4 | ||
|
|
ef807950f1 | ||
|
|
d37f3aa504 | ||
|
|
39b3eb3d64 | ||
|
|
8b21dfc318 | ||
|
|
f87fec6d61 | ||
|
|
391eb22d98 | ||
|
|
0da42c01b6 | ||
|
|
f3f0ca8e21 | ||
|
|
96dc79e253 | ||
|
|
ac3416c5a2 | ||
|
|
ade5b2a3db | ||
|
|
1cd6017df6 | ||
|
|
06caea7b16 | ||
|
|
114779d3af | ||
|
|
96d694b66b | ||
|
|
babb4ffb37 | ||
|
|
0c2f07988a | ||
|
|
d7a1d34be7 | ||
|
|
6a465637d4 | ||
|
|
154c19403a | ||
|
|
c9e1467244 | ||
|
|
1765e41fd4 | ||
|
|
d34ee82754 | ||
|
|
5cdd0023d7 | ||
|
|
df7a4b5d6f | ||
|
|
63eb96859d | ||
|
|
e3e2a3b782 | ||
|
|
eeafa5e0a5 | ||
|
|
7e5e71ae67 | ||
|
|
8daf0bb2a9 | ||
|
|
a779c839b7 | ||
|
|
0da57f8747 | ||
|
|
d01d241af1 | ||
|
|
dd08d09d14 | ||
|
|
0143393a8c | ||
|
|
d2b56efcb4 | ||
|
|
dab0cf48c6 | ||
|
|
916367dccb | ||
|
|
580a9fdfcf | ||
|
|
2ba8b582e2 | ||
|
|
bc81eb7a22 | ||
|
|
a54fc041b0 | ||
|
|
10a2b25527 | ||
|
|
cf476953d6 | ||
|
|
b233453cf7 | ||
|
|
bc5136a197 | ||
|
|
e08ee08fd8 | ||
|
|
eb5ee3bfdb | ||
|
|
86a84c3c6a | ||
|
|
edb348c273 | ||
|
|
ba91b41d36 | ||
|
|
99547044bc | ||
|
|
1fa756372e | ||
|
|
484af3c2c8 | ||
|
|
742551e592 | ||
|
|
50081cbdaa | ||
|
|
61198a0c04 | ||
|
|
67590aa27d | ||
|
|
6c059c41f9 | ||
|
|
f1db82934d | ||
|
|
28dd6b767f | ||
|
|
98b1d7f585 | ||
|
|
f7b8e3d84b | ||
|
|
4b4fa39670 | ||
|
|
ab4626e7de | ||
|
|
7164146626 | ||
|
|
3b4f688223 | ||
|
|
ee2706c5ee | ||
|
|
2d9fc5d8af | ||
|
|
49c9a4fdd3 | ||
|
|
bafdbc8313 | ||
|
|
eca28fd4b5 | ||
|
|
3d09c70e13 | ||
|
|
4cd8c04691 | ||
|
|
f7764cd5cb | ||
|
|
afae689ea9 | ||
|
|
e2d7491bc9 | ||
|
|
4c55508f01 |
4
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
4
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
@@ -3,13 +3,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
**DO NOT FILE DUPLICATE REQUESTS.**
|
||||
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,9 +94,11 @@ 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.42.0'
|
||||
- '2.41.1'
|
||||
- '2.41.0'
|
||||
- '2.40.0'
|
||||
- '2.39.3'
|
||||
- '2.39.2'
|
||||
- '2.39.1'
|
||||
- '2.39.0'
|
||||
|
||||
86
.github/workflows/build-image.yml
vendored
Normal file
86
.github/workflows/build-image.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Build image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/vvzvlad/portainer-ce
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install client dependencies
|
||||
# CI forces pnpm into --frozen-lockfile, which fails with
|
||||
# ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed lockfile lacks
|
||||
# the pnpmfileChecksum for the configDependencies in package.json.
|
||||
# Reconcile the lockfile explicitly; the later frozen install in
|
||||
# `make client-deps` then finds a matching lockfile. pnpm ignores the
|
||||
# npm_config_frozen_lockfile env var, so an explicit flag is required.
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build client and server
|
||||
env:
|
||||
SKIP_GO_GET: "true"
|
||||
CONTAINER_IMAGE_TAG: ${{ steps.ver.outputs.version }}
|
||||
BUILDNUMBER: ${{ github.run_number }}
|
||||
# Pin the embedded commit to the full SHA so it matches the image
|
||||
# GIT_COMMIT build-arg and does not depend on the shallow checkout.
|
||||
GIT_COMMIT_HASH: ${{ github.sha }}
|
||||
# ENV=production selects webpack/webpack.production.js (minified bundle),
|
||||
# matching the official CE image; the Makefile default is development.
|
||||
run: make build-all ENV=production
|
||||
|
||||
- name: Ensure storybook directory exists
|
||||
# make build-all does not produce dist/storybook, but alpine.Dockerfile
|
||||
# has `COPY dist/storybook* /storybook/`; without a match the docker build fails.
|
||||
run: mkdir -p dist/storybook
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image (linux/amd64, alpine base)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: build/linux/alpine.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
|
||||
${{ env.IMAGE }}:latest
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ dist
|
||||
portainer-checksum.txt
|
||||
api/cmd/portainer/portainer*
|
||||
storybook-static
|
||||
debug-storybook.log
|
||||
.tmp
|
||||
**/.vscode/settings.json
|
||||
**/.vscode/tasks.json
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: "2"
|
||||
version: '2'
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
version: "2"
|
||||
version: '2'
|
||||
|
||||
run:
|
||||
allow-parallel-runners: true
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- gocritic
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- depguard
|
||||
@@ -31,7 +32,7 @@ linters:
|
||||
- exptostd
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
|
||||
checks: ['all', '-ST1003', '-ST1005', '-ST1016', '-SA1019', '-QF1003']
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
@@ -76,6 +77,13 @@ linters:
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/hashicorp/go-version
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- ruleguard
|
||||
settings:
|
||||
ruleguard:
|
||||
rules: './analysis/ssrf.go,./analysis/git.go'
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^tls\.Config$
|
||||
@@ -83,9 +91,11 @@ linters:
|
||||
- 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"
|
||||
msg: 'Not allowed because of FIPS mode'
|
||||
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
|
||||
msg: "Not allowed because of FIPS mode"
|
||||
msg: 'Not allowed because of FIPS mode'
|
||||
- pattern: ^git\.PlainClone(Context|WithOptions)?$
|
||||
msg: Use git.CloneContext with NewNoSymlinkFS to prevent symlink traversal attacks
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
@@ -93,6 +103,14 @@ linters:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
rules:
|
||||
- path: pkg/libhttp/ssrf
|
||||
linters:
|
||||
- gocritic
|
||||
text: ruleguard
|
||||
- path: pkg/libhttp/ssrf/builder\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
coverage
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -57,3 +57,12 @@ make format # Format code
|
||||
|
||||
- Frontend: http://localhost:8999
|
||||
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
||||
|
||||
## Local demo stand
|
||||
|
||||
To build an image from one or more feature branches and run it (e.g. to demo open
|
||||
PRs together), see [docs/dev-stand.md](docs/dev-stand.md). **Read its Gotchas
|
||||
first** — most importantly, build the image with `make build-image ENV=production`
|
||||
(without it, `build-image` ships a development client bundle that the CSP blocks,
|
||||
leaving the UI stuck forever on "Loading Portainer…"), and note that the admin
|
||||
password must be simple/special-char-free but at least 12 characters long.
|
||||
|
||||
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
@@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contribute@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
|
||||
@@ -147,7 +147,9 @@ When adding a new route to an existing handler use the following as a template (
|
||||
// @router /{id} [get]
|
||||
```
|
||||
|
||||
explanation about each line can be found (here)[https://github.com/swaggo/swag#api-operation]
|
||||
explanation about each line can be found [here](https://github.com/swaggo/swag#api-operation)
|
||||
|
||||
After changing these annotations, regenerate the TypeScript API client and types — see [Generating API types](./README.md#generating-api-types).
|
||||
|
||||
## Licensing
|
||||
|
||||
|
||||
23
Makefile
23
Makefile
@@ -6,6 +6,7 @@ TAG=local
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
GOTESTSUM_VERSION?=v1.13.0
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
|
||||
GOLANGCI_LINT_VERSION := $(shell cat $(shell git rev-parse --show-toplevel)/.golangci-version)
|
||||
|
||||
# Don't change anything below this line unless you know what you're doing
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -90,13 +91,25 @@ format-server: ## Format server code
|
||||
go fmt ./...
|
||||
|
||||
##@ Lint
|
||||
.PHONY: lint lint-client lint-server
|
||||
.PHONY: lint lint-client lint-server check-lint-version
|
||||
lint: lint-client lint-server ## Lint all code
|
||||
|
||||
lint-client: ## Lint client code
|
||||
pnpm run lint
|
||||
|
||||
lint-server: tidy ## Lint server code
|
||||
check-lint-version:
|
||||
@installed=v$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
|
||||
if [ "$$installed" = "v" ]; then \
|
||||
echo "ERROR: golangci-lint not found, need $(GOLANGCI_LINT_VERSION)"; \
|
||||
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
|
||||
exit 1; \
|
||||
elif [ "$$installed" != "$(GOLANGCI_LINT_VERSION)" ]; then \
|
||||
echo "ERROR: golangci-lint $$installed installed, need $(GOLANGCI_LINT_VERSION)"; \
|
||||
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
lint-server: tidy check-lint-version ## Lint server code
|
||||
golangci-lint run --timeout=10m -c .golangci.yaml
|
||||
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
|
||||
|
||||
@@ -109,7 +122,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
|
||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||
docs-build: init-dist ## Build docs
|
||||
go mod download
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./ --overridesFile .swaggo
|
||||
|
||||
docs-validate: docs-build ## Validate docs
|
||||
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
@@ -121,6 +134,10 @@ docs-serve: docs-build ## Serve docs locally with Swagger UI on port 8080
|
||||
-e SWAGGER_JSON=/foo/swagger.yaml \
|
||||
-v $(PWD)/dist/docs:/foo \
|
||||
swaggerapi/swagger-ui
|
||||
|
||||
.PHONY: generate-api
|
||||
generate-api: docs-validate ## Generate API client and types from OpenAPI spec
|
||||
pnpm generate-api
|
||||
|
||||
##@ Helpers
|
||||
.PHONY: help
|
||||
|
||||
26
README.md
26
README.md
@@ -44,6 +44,32 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
|
||||
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
|
||||
- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://docs.portainer.io/contribute/contribute) to build it locally and make a pull request.
|
||||
|
||||
## Generating API types
|
||||
|
||||
The frontend consumes a TypeScript API client (SDK functions and request/response types) that is generated from the Go API's Swagger annotations. Regenerate it after any API change — a new endpoint, a changed request/response shape, or a removed endpoint:
|
||||
|
||||
```bash
|
||||
make generate-api
|
||||
```
|
||||
|
||||
This runs the following pipeline:
|
||||
|
||||
```
|
||||
Go Swagger annotations
|
||||
→ dist/docs/swagger.yaml (make docs-build, via swaggo/swag)
|
||||
→ dist/docs/openapi.yaml (swagger2openapi + validation)
|
||||
→ app/react/portainer/generated-api/portainer/ (hey-api/openapi-ts)
|
||||
```
|
||||
|
||||
The generator is configured in [`openapi-ts.config.ts`](./openapi-ts.config.ts), which controls the output path, plugins, and tag filters (for example, `deprecated` endpoints and `edge_agent`-tagged routes are excluded).
|
||||
|
||||
The generated files live in `app/react/portainer/generated-api/portainer/` and must **not** be edited by hand — your changes would be overwritten on the next run. Import the generated SDK functions and types instead of writing direct HTTP calls:
|
||||
|
||||
- `@api/sdk.gen` — SDK functions
|
||||
- `@api/types.gen` — request/response types
|
||||
|
||||
See [Adding api docs](./CONTRIBUTING.md#adding-api-docs) for how to annotate handlers so they are picked up by the generator.
|
||||
|
||||
## Security
|
||||
|
||||
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
|
||||
|
||||
18
analysis/git.go
Normal file
18
analysis/git.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build ignore
|
||||
|
||||
package gorules
|
||||
|
||||
import "github.com/quasilyte/go-ruleguard/dsl"
|
||||
|
||||
// inMemoryCloneWithWorktree flags git clone calls that use memory.NewStorage() as
|
||||
// the storer while also writing files to a real worktree. This holds all git objects
|
||||
// in heap for the duration of the clone, which is unbounded for user-supplied repos.
|
||||
func inMemoryCloneWithWorktree(m dsl.Matcher) {
|
||||
m.Match(`git.CloneContext($_, memory.NewStorage(), $wt, $_)`).
|
||||
Where(m["wt"].Text != "nil").
|
||||
Report(`git.CloneContext with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
|
||||
|
||||
m.Match(`git.Clone(memory.NewStorage(), $wt, $_)`).
|
||||
Where(m["wt"].Text != "nil").
|
||||
Report(`git.Clone with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
|
||||
}
|
||||
75
analysis/ssrf.go
Normal file
75
analysis/ssrf.go
Normal file
@@ -0,0 +1,75 @@
|
||||
//go:build ignore
|
||||
|
||||
package gorules
|
||||
|
||||
import "github.com/quasilyte/go-ruleguard/dsl"
|
||||
|
||||
// unwrappedHTTPTransport flags any bare http.Transport composite literal.
|
||||
// All transports must be created via ssrf.NewTransport or ssrf.NewInternalTransport,
|
||||
// which clone http.DefaultTransport and handle SSRF protection internally.
|
||||
func unwrappedHTTPTransport(m dsl.Matcher) {
|
||||
m.Match(`$f(&http.Transport{$*_})`).
|
||||
Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
|
||||
m.Match(`$_ := &http.Transport{$*_}`).
|
||||
Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
|
||||
m.Match(`$_.Transport = &http.Transport{$*_}`).
|
||||
Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
}
|
||||
|
||||
// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport.
|
||||
// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport
|
||||
// passed here must be created via ssrf.NewTransport.
|
||||
func helmGetterTransport(m dsl.Matcher) {
|
||||
m.Match(`getter.WithTransport(&http.Transport{$*_})`).
|
||||
Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`)
|
||||
}
|
||||
|
||||
// cloneDefaultTransport flags direct clones of *http.Transport outside main.go.
|
||||
// The one legitimate clone is in main.go where http.DefaultTransport is globally
|
||||
// wrapped with SSRF protection at server startup.
|
||||
func cloneDefaultTransport(m dsl.Matcher) {
|
||||
m.Match(`$_.(*http.Transport).Clone()`).
|
||||
Where(!m.File().Name.Matches(`^main\.go$`)).
|
||||
Report(`cloning *http.Transport directly is forbidden; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
}
|
||||
|
||||
// internalTransportMisuse flags calls to NewInternalTransport outside the proxy
|
||||
// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions.
|
||||
func internalTransportMisuse(m dsl.Matcher) {
|
||||
m.Match(`ssrf.NewInternalTransport($*_)`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport|docker_unix|docker_windows)\.go$`))).
|
||||
Report(`NewInternalTransport bypasses SSRF validation; only valid in the proxy factory files for local sockets and internally-routed endpoints`)
|
||||
}
|
||||
|
||||
// dialerOverride flags direct assignments to any of the dialer fields on a transport.
|
||||
// The only valid assignments are in docker_unix.go and docker_windows.go where a
|
||||
// custom dialer is required for unix sockets and named pipes.
|
||||
func dialerOverride(m dsl.Matcher) {
|
||||
m.Match(`$_.DialContext = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.Dial = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct Dial assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.DialTLSContext = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialTLSContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.DialTLS = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialTLS assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
}
|
||||
5
analysis/tools.go
Normal file
5
analysis/tools.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build tools
|
||||
|
||||
package gorules
|
||||
|
||||
import _ "github.com/quasilyte/go-ruleguard/dsl"
|
||||
1
api/.swaggo
Normal file
1
api/.swaggo
Normal file
@@ -0,0 +1 @@
|
||||
replace k8s.io/apimachinery/pkg/apis/meta/v1.Duration string
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/url"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -19,10 +21,14 @@ import (
|
||||
//
|
||||
// it sends a ping to the agent and parses the version and platform from the headers
|
||||
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
|
||||
if err := ssrf.CheckURL(context.Background(), endpointUrl); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
httpCli := &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
if tlsConfig != nil {
|
||||
httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig}
|
||||
httpCli.Transport = ssrf.NewTransport(tlsConfig)
|
||||
}
|
||||
|
||||
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
||||
|
||||
@@ -53,7 +53,7 @@ To do so, use the `/endpoints/{id}/docker` endpoint. Note that this endpoint is
|
||||
# Private Registry
|
||||
|
||||
When using a private registry, include a Base64-encoded JSON string in the request header. The header parameter name is `X-Registry-Auth` and the value should encode the following structure: ‘{"registryId":\<registryId\>}’ where `<registryId>` is the ID of the registry where the repository was created.
|
||||
|
||||
|
||||
Example encoded value:
|
||||
|
||||
```
|
||||
|
||||
@@ -306,6 +306,10 @@ func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPo
|
||||
Err(err).
|
||||
Msg("unable to snapshot Edge environment")
|
||||
|
||||
if service.dataStore.IsErrObjectNotFound(err) {
|
||||
service.close(endpointID)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,24 @@ func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) {
|
||||
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure")
|
||||
}
|
||||
|
||||
func TestCheckTunnelsClosesStaleEntryForDeletedEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
// Endpoint is not created in the store, simulates deletion while tunnel stays open.
|
||||
s := NewService(store, nil, nil)
|
||||
s.activeTunnels[1] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50010,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.Nil(t, s.activeTunnels[1], "stale tunnel for deleted endpoint must be removed immediately")
|
||||
}
|
||||
|
||||
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -82,17 +82,24 @@ func (s *Service) Open(endpoint *portainer.Endpoint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// close removes the tunnel from the map so the agent will close it
|
||||
// close removes the tunnel from the map so the agent will close it.
|
||||
// The lock is released before cleaning up the chisel user and proxy to avoid
|
||||
// blocking Config/Open callers while DeleteUser interacts with chisel internals.
|
||||
func (s *Service) close(endpointID portainer.EndpointID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tun, ok := s.activeTunnels[endpointID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if len(tun.Credentials) > 0 && s.chiselServer != nil {
|
||||
delete(s.activeTunnels, endpointID)
|
||||
cache.Del(endpointID)
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.chiselServer != nil {
|
||||
user, _, _ := strings.Cut(tun.Credentials, ":")
|
||||
s.chiselServer.DeleteUser(user)
|
||||
}
|
||||
@@ -100,10 +107,6 @@ func (s *Service) close(endpointID portainer.EndpointID) {
|
||||
if s.ProxyManager != nil {
|
||||
s.ProxyManager.DeleteEndpointProxy(endpointID)
|
||||
}
|
||||
|
||||
delete(s.activeTunnels, endpointID)
|
||||
|
||||
cache.Del(endpointID)
|
||||
}
|
||||
|
||||
// Config returns the tunnel details needed for the agent to connect
|
||||
|
||||
@@ -56,6 +56,8 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
|
||||
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
|
||||
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
|
||||
NoSetupToken: kingpin.Flag("no-setup-token", "Disable the setup token requirement for admin initialization and restore on an uninitialized instance").Envar(portainer.NoSetupTokenEnvVar).Bool(),
|
||||
SetupToken: kingpin.Flag("setup-token", "Set a custom setup token for admin initialization and restore on an uninitialized instance (overrides auto-generation)").Envar(portainer.SetupTokenEnvVar).String(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
nethttp "net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
"github.com/portainer/portainer/api/containerautomation"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/database"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
@@ -29,6 +31,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security/setuptoken"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -51,10 +54,15 @@ import (
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
gogitraw "github.com/go-git/go-git/v5/plumbing/transport/git"
|
||||
gogithttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
gogitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -225,6 +233,32 @@ func initSnapshotService(
|
||||
return snapshotService, nil
|
||||
}
|
||||
|
||||
func resolveSetupToken(tx dataservices.DataStoreTx, providedToken string) (string, error) {
|
||||
admins, err := tx.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(admins) > 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if providedToken != "" {
|
||||
log.Info().Msg("using custom setup token; admin initialization and backup restore require this token in the X-Setup-Token header")
|
||||
return providedToken, nil
|
||||
}
|
||||
|
||||
token, err := setuptoken.Generate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("setup_token", token).
|
||||
Msg("no administrator account configured; admin initialization and backup restore require this setup token in the X-Setup-Token header. Start with --no-setup-token to disable.")
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func initStatus(instanceID string) *portainer.Status {
|
||||
return &portainer.Status{
|
||||
Version: portainer.APIVersion,
|
||||
@@ -374,6 +408,19 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
|
||||
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
|
||||
}
|
||||
|
||||
if err := ssrf.Configure(dataStore.AllowList()); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing ssrf service")
|
||||
}
|
||||
|
||||
if !ssrf.WrapDefaultTransport() {
|
||||
log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection")
|
||||
}
|
||||
|
||||
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})
|
||||
gogitclient.InstallProtocol("git", git.NewSSRFGitTransport(gogitraw.DefaultClient))
|
||||
gogitclient.InstallProtocol("ssh", git.NewSSRFGitTransport(gogitssh.DefaultClient))
|
||||
gogitclient.InstallProtocol("file", nil)
|
||||
|
||||
instanceID, err := dataStore.Version().InstanceID()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed getting instance id")
|
||||
@@ -510,6 +557,17 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
|
||||
}
|
||||
}
|
||||
|
||||
setupToken := ""
|
||||
if adminPasswordHash == "" && !*flags.NoSetupToken {
|
||||
if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var txErr error
|
||||
setupToken, txErr = resolveSetupToken(tx, *flags.SetupToken)
|
||||
return txErr
|
||||
}); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing setup token")
|
||||
}
|
||||
}
|
||||
|
||||
if err := reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed starting tunnel server")
|
||||
}
|
||||
@@ -520,6 +578,10 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
|
||||
log.Fatal().Err(err).Msg("failed to start stack scheduler")
|
||||
}
|
||||
|
||||
containerService := docker.NewContainerService(dockerClientFactory, dataStore)
|
||||
containerAutomationService := containerautomation.NewService(shutdownCtx, scheduler, dataStore, dockerClientFactory, containerService, stackDeployer)
|
||||
containerAutomationService.Start()
|
||||
|
||||
sslDBSettings, err := dataStore.SSLSettings().Settings()
|
||||
if err != nil {
|
||||
log.Fatal().Msg("failed to fetch SSL settings from DB")
|
||||
@@ -593,6 +655,7 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
|
||||
DockerClientFactory: dockerClientFactory,
|
||||
KubernetesClientFactory: kubernetesClientFactory,
|
||||
Scheduler: scheduler,
|
||||
ContainerAutomationService: containerAutomationService,
|
||||
ShutdownTrigger: shutdownTrigger,
|
||||
StackDeployer: stackDeployer,
|
||||
UpgradeService: upgradeService,
|
||||
@@ -601,6 +664,7 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
|
||||
PlatformService: platformService,
|
||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||
TrustedOrigins: trustedOrigins,
|
||||
SetupToken: setupToken,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,44 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_resolveSetupToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("admin already exists — returns empty token", func(t *testing.T) {
|
||||
admin := portainer.User{Role: portainer.AdministratorRole}
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
|
||||
token, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("no admin — generates a 64-char hex token", func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
|
||||
token, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, token, 64)
|
||||
|
||||
token2, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, token, token2)
|
||||
})
|
||||
|
||||
t.Run("no admin — uses provided token", func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
|
||||
token, err := resolveSetupToken(store, "mysecrettoken")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mysecrettoken", token)
|
||||
})
|
||||
|
||||
t.Run("admin already exists — ignores provided token", func(t *testing.T) {
|
||||
admin := portainer.User{Role: portainer.AdministratorRole}
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
|
||||
token, err := resolveSetupToken(store, "mysecrettoken")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
const secretFileName = "secret.txt"
|
||||
|
||||
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
||||
|
||||
@@ -46,7 +46,7 @@ type Connection interface {
|
||||
|
||||
IsEncryptedStore() bool
|
||||
NeedsEncryptionMigration() (bool, error)
|
||||
SetEncrypted(encrypted bool)
|
||||
SetEncrypted(encrypted bool) error
|
||||
|
||||
BackupMetadata() (map[string]any, error)
|
||||
RestoreMetadata(s map[string]any) error
|
||||
|
||||
201
api/containerautomation/autoheal.go
Normal file
201
api/containerautomation/autoheal.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// retryWindow is the rolling window over which max restarts per container are counted.
|
||||
retryWindow = 10 * time.Minute
|
||||
// restartCooldown is the minimum delay between two restarts of the same container,
|
||||
// giving its healthcheck time to recover before we try again.
|
||||
restartCooldown = 60 * time.Second
|
||||
// endpointTimeout bounds the container-list call for a single endpoint.
|
||||
endpointTimeout = 30 * time.Second
|
||||
// restartTimeoutBuffer is added on top of a container's stop-timeout to derive
|
||||
// the deadline of its own restart context, leaving room for the engine to kill
|
||||
// and start the container after the graceful stop window elapses.
|
||||
restartTimeoutBuffer = 15 * time.Second
|
||||
)
|
||||
|
||||
// retryState tracks restart accounting for a single container across ticks.
|
||||
type retryState struct {
|
||||
attempts int
|
||||
windowStart time.Time
|
||||
lastRestart time.Time
|
||||
}
|
||||
|
||||
// retryPolicy holds the cooldown/window parameters applied to a container.
|
||||
type retryPolicy struct {
|
||||
maxRetries int
|
||||
window time.Duration
|
||||
cooldown time.Duration
|
||||
}
|
||||
|
||||
// decideRestart is a pure function that decides whether an unhealthy container
|
||||
// should be restarted now, given its current retry state and policy. It returns
|
||||
// the decision and the updated state to persist.
|
||||
//
|
||||
// Rules, in order:
|
||||
// - reset the window (and attempts) when the window has elapsed;
|
||||
// - deny while still within the cooldown since the last restart;
|
||||
// - deny once the max number of restarts in the current window is reached;
|
||||
// - otherwise restart, incrementing the attempt counter.
|
||||
func decideRestart(state retryState, policy retryPolicy, now time.Time) (bool, retryState) {
|
||||
if state.windowStart.IsZero() || now.Sub(state.windowStart) >= policy.window {
|
||||
state.windowStart = now
|
||||
state.attempts = 0
|
||||
}
|
||||
|
||||
if !state.lastRestart.IsZero() && now.Sub(state.lastRestart) < policy.cooldown {
|
||||
return false, state
|
||||
}
|
||||
|
||||
if state.attempts >= policy.maxRetries {
|
||||
return false, state
|
||||
}
|
||||
|
||||
state.attempts++
|
||||
state.lastRestart = now
|
||||
|
||||
return true, state
|
||||
}
|
||||
|
||||
// heal runs a single auto-heal pass over every reachable Docker endpoint.
|
||||
// It is registered with the scheduler and guarded against overlapping ticks by
|
||||
// the Service. Errors are logged per endpoint/container so one failure does not
|
||||
// abort the whole pass; it always returns nil so the scheduler keeps the job.
|
||||
func (s *Service) heal() error {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
log.Debug().Msg("auto-heal: previous run still in progress, skipping tick")
|
||||
return nil
|
||||
}
|
||||
defer s.running.Store(false)
|
||||
|
||||
scope := s.scope()
|
||||
|
||||
endpoints, err := s.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-heal: unable to list environments")
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
|
||||
// M1 scope: native Docker endpoints only. Kubernetes is not applicable and
|
||||
// Edge/async endpoints are not reachable synchronously from the scheduler.
|
||||
if !endpointutils.IsDockerEndpoint(endpoint) || endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-endpoint opt-out (M5): skip environments where automation is disabled,
|
||||
// independently of the global switch. Zero value participates, so existing
|
||||
// installs are unaffected.
|
||||
if !AutomationEnabledForEndpoint(endpoint) {
|
||||
log.Debug().Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-heal: automation disabled for this environment, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
s.healEndpoint(endpoint, scope)
|
||||
}
|
||||
|
||||
// Drop retry state only for containers whose retry window has fully elapsed
|
||||
// since their last restart. A container that briefly leaves the unhealthy
|
||||
// filter (e.g. while "starting" after a restart) keeps its accounting, so the
|
||||
// cooldown / max-retries storm guard survives flapping.
|
||||
s.pruneRetries(time.Now())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// healEndpoint restarts the in-scope unhealthy containers of a single endpoint.
|
||||
func (s *Service) healEndpoint(endpoint *portainer.Endpoint, scope string) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
// Swarm note (M1 limitation): we connect to the endpoint's primary node only
|
||||
// (nodeName ""). Containers scheduled on other Swarm nodes are not healed here;
|
||||
// per-node iteration is deferred to a later milestone.
|
||||
clientTimeout := endpointTimeout
|
||||
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-heal: unable to create Docker client")
|
||||
return
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
listCtx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
defer cancel()
|
||||
|
||||
// List running unhealthy containers only (All:false). Docker keeps
|
||||
// Health.Status=="unhealthy" on stopped containers, so listing with All:true
|
||||
// would let us "restart" (i.e. start) an intentionally-stopped container.
|
||||
listFilters := filters.NewArgs(filters.Arg("health", "unhealthy"))
|
||||
containers, err := cli.ContainerList(listCtx, container.ListOptions{All: false, Filters: listFilters})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-heal: unable to list containers")
|
||||
return
|
||||
}
|
||||
|
||||
s.healContainers(cli, endpoint, scope, containers)
|
||||
}
|
||||
|
||||
// healContainers applies the restart decision + heal-restart to each listed
|
||||
// unhealthy container of an endpoint. It is split out from healEndpoint (which
|
||||
// creates the client and lists the containers) so the restart loop can be
|
||||
// exercised with a fake dockerClient in tests. cli is typed as the interface;
|
||||
// the concrete *dockerclient.Client returned by CreateClient satisfies it.
|
||||
func (s *Service) healContainers(cli dockerClient, endpoint *portainer.Endpoint, scope string, containers []container.Summary) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
for _, c := range containers {
|
||||
if !InScope(scope, c.Labels) {
|
||||
continue
|
||||
}
|
||||
|
||||
policy := retryPolicy{
|
||||
maxRetries: MaxRetries(c.Labels),
|
||||
window: retryWindow,
|
||||
cooldown: restartCooldown,
|
||||
}
|
||||
|
||||
ok, newState := decideRestart(s.getRetry(c.ID), policy, time.Now())
|
||||
s.setRetry(c.ID, newState)
|
||||
if !ok {
|
||||
log.Debug().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-heal: restart skipped (cooldown or max retries reached)")
|
||||
continue
|
||||
}
|
||||
|
||||
timeout := StopTimeout(c.Labels)
|
||||
|
||||
// Each restart gets its own context, bounded by the container's stop-timeout
|
||||
// plus a buffer, so one slow restart cannot starve the others and a hung
|
||||
// engine call is bounded independently of the list deadline.
|
||||
restartTimeout := time.Duration(timeout)*time.Second + restartTimeoutBuffer
|
||||
restartCtx, restartCancel := context.WithTimeout(s.baseCtx, restartTimeout)
|
||||
err := cli.ContainerRestart(restartCtx, c.ID, container.StopOptions{Timeout: &timeout})
|
||||
restartCancel()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-heal: failed to restart unhealthy container")
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).Int("attempt", newState.attempts).
|
||||
Msg("auto-heal: restarted unhealthy container")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventHealRestarted, EndpointID: endpointID, ContainerID: c.ID, ContainerName: containerName(c.Names),
|
||||
Message: "restarted unhealthy container",
|
||||
})
|
||||
}
|
||||
}
|
||||
137
api/containerautomation/autoheal_test.go
Normal file
137
api/containerautomation/autoheal_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecideRestart(t *testing.T) {
|
||||
policy := retryPolicy{
|
||||
maxRetries: 3,
|
||||
window: 10 * time.Minute,
|
||||
cooldown: 60 * time.Second,
|
||||
}
|
||||
base := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("first restart on empty state", func(t *testing.T) {
|
||||
ok, state := decideRestart(retryState{}, policy, base)
|
||||
if !ok {
|
||||
t.Fatal("expected restart on first unhealthy observation")
|
||||
}
|
||||
if state.attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", state.attempts)
|
||||
}
|
||||
if !state.windowStart.Equal(base) || !state.lastRestart.Equal(base) {
|
||||
t.Error("windowStart/lastRestart should be set to now")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blocked during cooldown", func(t *testing.T) {
|
||||
_, state := decideRestart(retryState{}, policy, base)
|
||||
ok, _ := decideRestart(state, policy, base.Add(30*time.Second))
|
||||
if ok {
|
||||
t.Error("expected restart to be blocked within cooldown")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowed after cooldown", func(t *testing.T) {
|
||||
_, state := decideRestart(retryState{}, policy, base)
|
||||
ok, state := decideRestart(state, policy, base.Add(61*time.Second))
|
||||
if !ok {
|
||||
t.Error("expected restart allowed after cooldown")
|
||||
}
|
||||
if state.attempts != 2 {
|
||||
t.Errorf("attempts = %d, want 2", state.attempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max retries enforced within window", func(t *testing.T) {
|
||||
state := retryState{}
|
||||
now := base
|
||||
allowed := 0
|
||||
for i := 0; i < 6; i++ {
|
||||
ok, newState := decideRestart(state, policy, now)
|
||||
state = newState
|
||||
if ok {
|
||||
allowed++
|
||||
}
|
||||
now = now.Add(policy.cooldown + time.Second)
|
||||
}
|
||||
if allowed != policy.maxRetries {
|
||||
t.Errorf("allowed %d restarts, want %d (max per window)", allowed, policy.maxRetries)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("counter resets after window elapses", func(t *testing.T) {
|
||||
state := retryState{attempts: 3, windowStart: base, lastRestart: base}
|
||||
ok, newState := decideRestart(state, policy, base.Add(policy.window+time.Second))
|
||||
if !ok {
|
||||
t.Error("expected restart allowed once the window elapsed")
|
||||
}
|
||||
if newState.attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1 after window reset", newState.attempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPruneRetries(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
s := &Service{retries: map[string]retryState{
|
||||
// within the window -> retained
|
||||
"fresh": {attempts: 1, windowStart: now.Add(-time.Minute), lastRestart: now.Add(-time.Minute)},
|
||||
// exactly at the window boundary -> pruned
|
||||
"edge": {attempts: 2, windowStart: now.Add(-retryWindow), lastRestart: now.Add(-retryWindow)},
|
||||
// long past the window -> pruned
|
||||
"stale": {attempts: 3, windowStart: now.Add(-2 * retryWindow), lastRestart: now.Add(-2 * retryWindow)},
|
||||
}}
|
||||
|
||||
s.pruneRetries(now)
|
||||
|
||||
if _, ok := s.retries["fresh"]; !ok {
|
||||
t.Error("entry within the retry window should be retained")
|
||||
}
|
||||
if _, ok := s.retries["edge"]; ok {
|
||||
t.Error("entry exactly at the window boundary should be pruned")
|
||||
}
|
||||
if _, ok := s.retries["stale"]; ok {
|
||||
t.Error("entry past the retry window should be pruned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryStateSurvivesStartingTick locks in the F1 fix: a container that flaps
|
||||
// through "starting" right after a restart (and so briefly drops out of the
|
||||
// health=unhealthy filter) must keep its retry accounting across the tick where
|
||||
// it is not observed, otherwise the cooldown / max-retries storm guard is
|
||||
// defeated and the next unhealthy observation triggers an immediate restart.
|
||||
func TestRetryStateSurvivesStartingTick(t *testing.T) {
|
||||
policy := retryPolicy{maxRetries: 3, window: retryWindow, cooldown: restartCooldown}
|
||||
const id = "flapper"
|
||||
s := &Service{retries: make(map[string]retryState)}
|
||||
|
||||
t0 := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Tick 1: container is unhealthy -> first restart.
|
||||
ok, state := decideRestart(s.getRetry(id), policy, t0)
|
||||
s.setRetry(id, state)
|
||||
if !ok || state.attempts != 1 {
|
||||
t.Fatalf("tick 1: ok=%v attempts=%d, want restart with attempts=1", ok, state.attempts)
|
||||
}
|
||||
|
||||
// Tick 2 (t0+30s): the container is "starting" and not in the unhealthy list.
|
||||
// Prune must NOT drop its state because the window has not elapsed.
|
||||
s.pruneRetries(t0.Add(30 * time.Second))
|
||||
if _, kept := s.retries[id]; !kept {
|
||||
t.Fatal("tick 2: retry state was pruned while the container was 'starting'")
|
||||
}
|
||||
|
||||
// Tick 3 (t0+45s): unhealthy again, still within the cooldown. The surviving
|
||||
// state must block the restart and the attempt count must not be reset.
|
||||
ok, state = decideRestart(s.getRetry(id), policy, t0.Add(45*time.Second))
|
||||
s.setRetry(id, state)
|
||||
if ok {
|
||||
t.Error("tick 3: restart should be blocked by the surviving cooldown")
|
||||
}
|
||||
if state.attempts != 1 {
|
||||
t.Errorf("tick 3: attempts = %d, want 1 (state survived, not reset)", state.attempts)
|
||||
}
|
||||
}
|
||||
597
api/containerautomation/autoupdate.go
Normal file
597
api/containerautomation/autoupdate.go
Normal file
@@ -0,0 +1,597 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// statusCheckTimeout bounds a single container image-status resolution
|
||||
// (container inspect + remote digest fetch).
|
||||
statusCheckTimeout = 30 * time.Second
|
||||
// recreateTimeout bounds a standalone recreate (pull + stop + create + start).
|
||||
// Pulls can be slow, so it is generous.
|
||||
recreateTimeout = 10 * time.Minute
|
||||
// stackRedeployTimeout bounds a single stack redeploy-with-pull.
|
||||
stackRedeployTimeout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// update runs a single auto-update pass over every reachable Docker endpoint.
|
||||
// It is registered with the scheduler and guarded against overlapping ticks by
|
||||
// the Service. Errors are logged per endpoint/container so one failure does not
|
||||
// abort the whole pass; it always returns nil so the scheduler keeps the job.
|
||||
func (s *Service) update() error {
|
||||
if !s.updateRunning.CompareAndSwap(false, true) {
|
||||
log.Debug().Msg("auto-update: previous run still in progress, skipping tick")
|
||||
return nil
|
||||
}
|
||||
defer s.updateRunning.Store(false)
|
||||
|
||||
settings, err := s.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: unable to read settings")
|
||||
return nil
|
||||
}
|
||||
|
||||
scope := ScopeLabeled
|
||||
if settings.ContainerAutomation.AutoUpdate.Scope == ScopeAll {
|
||||
scope = ScopeAll
|
||||
}
|
||||
|
||||
opts := updateOptions{
|
||||
cleanup: settings.ContainerAutomation.AutoUpdate.Cleanup,
|
||||
rollback: settings.ContainerAutomation.AutoUpdate.RollbackOnFailure,
|
||||
rollbackTimeout: parseRollbackTimeout(settings.ContainerAutomation.AutoUpdate.RollbackTimeout),
|
||||
}
|
||||
|
||||
endpoints, err := s.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: unable to list environments")
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
|
||||
// Native Docker endpoints only: Kubernetes is not applicable and
|
||||
// Edge/async endpoints are not reachable synchronously from the scheduler.
|
||||
if !endpointutils.IsDockerEndpoint(endpoint) || endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-endpoint opt-out (M5): skip environments where automation is disabled,
|
||||
// independently of the global switch. Zero value participates, so existing
|
||||
// installs are unaffected.
|
||||
if !AutomationEnabledForEndpoint(endpoint) {
|
||||
log.Debug().Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: automation disabled for this environment, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
s.updateEndpoint(endpoint, scope, opts)
|
||||
}
|
||||
|
||||
// Drop rolled-back records whose cooldown has fully elapsed (mirrors auto-heal's
|
||||
// pruneRetries), so the loop-guard map cannot grow unbounded.
|
||||
s.pruneRolledBack(time.Now())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateOptions carries the per-pass auto-update toggles resolved from settings.
|
||||
type updateOptions struct {
|
||||
// cleanup removes the now-dangling old image after a confirmed-good update.
|
||||
cleanup bool
|
||||
// rollback enables the health gate + rollback of a failed standalone update.
|
||||
rollback bool
|
||||
// rollbackTimeout bounds how long the health gate waits before rolling back.
|
||||
rollbackTimeout time.Duration
|
||||
}
|
||||
|
||||
// parseRollbackTimeout resolves the configured rollback timeout, falling back to
|
||||
// the default when empty or unparseable.
|
||||
func parseRollbackTimeout(raw string) time.Duration {
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
return defaultRollbackTimeout
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// updateEndpoint applies image updates to the in-scope, outdated containers of a
|
||||
// single endpoint, routing each container to the standalone / stack / external
|
||||
// apply path. Stack-managed candidates are grouped so each owning stack is
|
||||
// redeployed at most once per tick.
|
||||
func (s *Service) updateEndpoint(endpoint *portainer.Endpoint, scope string, opts updateOptions) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
// Swarm note (M4 limitation, mirrors auto-heal): we connect to the endpoint's
|
||||
// primary node only (nodeName ""). Containers scheduled on other Swarm nodes
|
||||
// are not updated here; stacks are redeployed cluster-wide by the swarm engine.
|
||||
clientTimeout := endpointTimeout
|
||||
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-update: unable to create Docker client")
|
||||
return
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
listCtx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Running containers only: a stopped container has nothing to update now and
|
||||
// would be started by a bare recreate.
|
||||
containers, err := cli.ContainerList(listCtx, container.ListOptions{All: false})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-update: unable to list containers")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect the in-scope, outdated, non-monitor-only containers as candidates.
|
||||
// An in-scope monitor-only container is still status-checked (keeping its badge
|
||||
// cache warm) but never auto-applied. This only covers in-scope containers: in
|
||||
// "labeled" scope a monitor-only container without the enable label is filtered
|
||||
// out below before any status check, so its badge is not refreshed here.
|
||||
var candidates []UpdateCandidate
|
||||
for _, c := range containers {
|
||||
if !InUpdateScope(scope, c.Labels) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve the image status. This also refreshes the package-level status
|
||||
// cache that backs the badge, so in-scope monitor-only containers are still
|
||||
// checked even though they are never auto-applied.
|
||||
statusCtx, statusCancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
|
||||
status, err := s.digestClient.ContainerImageStatus(statusCtx, c.ID, endpoint, "")
|
||||
statusCancel()
|
||||
if err != nil {
|
||||
// Pull / registry-auth / network failure: leave the running container
|
||||
// untouched, never recreate on a failed check.
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: image status check failed, leaving container untouched")
|
||||
continue
|
||||
}
|
||||
|
||||
if status != images.Outdated {
|
||||
continue
|
||||
}
|
||||
|
||||
// Monitor-only: detect-only, never auto-apply (status already cached above).
|
||||
if IsMonitorOnly(c.Labels) {
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: outdated image detected but container is monitor-only, not applying")
|
||||
continue
|
||||
}
|
||||
|
||||
candidates = append(candidates, UpdateCandidate{ID: c.ID, Name: containerName(c.Names), ImageID: c.ImageID, Image: c.Image, Labels: c.Labels})
|
||||
}
|
||||
|
||||
// Route and de-duplicate: one redeploy per stack per tick.
|
||||
grouped := groupContainersForUpdate(candidates, s.stackLookupForEndpoint(endpoint.ID))
|
||||
|
||||
for _, ext := range grouped.External {
|
||||
log.Debug().Str("container_id", ext.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: outdated externally-managed compose container, detect only")
|
||||
}
|
||||
|
||||
for _, c := range grouped.Standalone {
|
||||
s.updateStandalone(cli, endpoint, c, opts)
|
||||
}
|
||||
|
||||
for _, st := range grouped.Stacks {
|
||||
s.updateStack(cli, endpoint, st)
|
||||
}
|
||||
}
|
||||
|
||||
// stackLookupForEndpoint builds a compose-project-name -> Portainer compose stack
|
||||
// resolver for a single endpoint. Only Docker Compose stacks on this endpoint
|
||||
// match; a same-named swarm/kubernetes stack is treated as external (mirrors
|
||||
// M3's resolveContainerUpdatePath).
|
||||
func (s *Service) stackLookupForEndpoint(endpointID portainer.EndpointID) func(project string) *StackMatch {
|
||||
stacks, err := s.dataStore.Stack().ReadAll()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpoint_id", int(endpointID)).
|
||||
Msg("auto-update: unable to read stacks, treating compose containers as external")
|
||||
return func(string) *StackMatch { return nil }
|
||||
}
|
||||
|
||||
byName := make(map[string]*StackMatch)
|
||||
for i := range stacks {
|
||||
st := &stacks[i]
|
||||
if st.EndpointID != endpointID || st.Type != portainer.DockerComposeStack {
|
||||
continue
|
||||
}
|
||||
|
||||
byName[st.Name] = &StackMatch{StackID: int(st.ID), IsGit: st.WorkflowID != 0}
|
||||
}
|
||||
|
||||
return func(project string) *StackMatch {
|
||||
return byName[project]
|
||||
}
|
||||
}
|
||||
|
||||
// updateStandalone recreates a standalone container with a re-pull of its image,
|
||||
// then (when rollback is enabled and the container has a healthcheck) holds a
|
||||
// health gate over the new container and rolls back to the previous image if it
|
||||
// fails to become healthy. The old-image cleanup is deliberately ordered AFTER
|
||||
// the health gate, so the rollback target is never removed before the update is
|
||||
// confirmed good.
|
||||
//
|
||||
// Sequence: capture old image id + original ref + healthcheck -> recreate(pull)
|
||||
// -> [health gate] -> on healthy: cleanup (if enabled); on unhealthy: rollback
|
||||
// (never cleanup).
|
||||
func (s *Service) updateStandalone(cli dockerClient, endpoint *portainer.Endpoint, c UpdateCandidate, opts updateOptions) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
// Loop-guard safety: the rolled-back map is keyed by endpoint+name (the only
|
||||
// identifier that survives a recreate). An unnamed container cannot be recorded
|
||||
// (recordRolledBack skips it), so with rollback enabled a container that keeps
|
||||
// failing its health gate would update->rollback every tick with NO suppression.
|
||||
// Skip the unnamed case when rollback is on so it cannot enter that
|
||||
// unsuppressable loop; detection/badge refresh already happened upstream and is
|
||||
// unaffected. (With rollback off there is no rollback to loop, so we proceed.)
|
||||
if skipUnnamedForRollback(opts.rollback, c.Name) {
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: skipping unnamed standalone container, rollback is enabled but there is no stable name to key the loop guard")
|
||||
return
|
||||
}
|
||||
|
||||
// Update->rollback loop guard: if this container's update was rolled back
|
||||
// recently and the remote still points at the SAME failed image, skip it until
|
||||
// the cooldown elapses. A genuinely new upstream image (a changed remote digest)
|
||||
// is not blocked.
|
||||
rollbackMapKey := rollbackKey(endpoint.ID, c.Name)
|
||||
if rec, ok := s.getRolledBack(rollbackMapKey); ok && s.shouldSkipRolledBack(rollbackMapKey, rec) {
|
||||
log.Info().Str("container_id", c.ID).Str("container", c.Name).Str("image", rec.ref).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: skipping update, a recent rollback failed on this image and the remote is unchanged (cooldown)")
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the pre-update image identity for a possible rollback. The container
|
||||
// list gives us the old image id; an inspect adds the original reference (re-tag
|
||||
// target), whether a usable healthcheck exists, and the healthcheck start_period
|
||||
// (which must be waited out before deciding). We only health-gate when rollback
|
||||
// is enabled, the container has a healthcheck, we resolved both the old image id
|
||||
// and its reference, and that reference is a proper tag (a digest-pinned or bare
|
||||
// image id cannot be re-tagged, so the gate could never roll back).
|
||||
oldImageID := c.ImageID
|
||||
var originalRef string
|
||||
var startPeriod time.Duration
|
||||
healthGated := false
|
||||
if opts.rollback {
|
||||
// Bound the inspect like every other engine call so a hung/unreachable engine
|
||||
// cannot block the whole sequential tick until shutdown.
|
||||
inspectCtx, inspectCancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
inspect, err := cli.ContainerInspect(inspectCtx, c.ID)
|
||||
inspectCancel()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: unable to inspect container before update, proceeding without a health gate")
|
||||
} else {
|
||||
originalRef = inspect.Config.Image
|
||||
if oldImageID == "" {
|
||||
oldImageID = inspect.Image
|
||||
}
|
||||
if hc := inspect.Config.Healthcheck; hc != nil {
|
||||
startPeriod = hc.StartPeriod
|
||||
}
|
||||
|
||||
switch {
|
||||
case !hasHealthGate(inspect.Config.Healthcheck):
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: container has no healthcheck, updating without a rollback gate")
|
||||
case oldImageID == "" || originalRef == "":
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: unable to resolve previous image identity, updating without a rollback gate")
|
||||
case !isTagReference(originalRef):
|
||||
log.Info().Str("container_id", c.ID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: health gate skipped, image is digest-pinned and cannot be rolled back")
|
||||
default:
|
||||
healthGated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, recreateTimeout)
|
||||
defer cancel()
|
||||
|
||||
newContainer, err := s.containerService.Recreate(ctx, endpoint, c.ID, true, "", "")
|
||||
if err != nil {
|
||||
// Recreate preserves config and rolls back on a create failure; a pull or
|
||||
// create failure leaves the original container running.
|
||||
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: failed to recreate standalone container")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: c.ID, ContainerName: c.Name,
|
||||
Message: "failed to recreate standalone container", Err: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: recreated standalone container with updated image")
|
||||
newImage := ""
|
||||
if newContainer != nil {
|
||||
newImage = newContainer.Config.Image
|
||||
}
|
||||
|
||||
// Health gate: roll back if the new container does not become healthy in time.
|
||||
// The old image is preserved (not cleaned up) until the gate confirms health,
|
||||
// so the rollback target is still available. The "updated" event is held until
|
||||
// the gate confirms health, so an observer never sees a misleading
|
||||
// "updated" -> "rollback" sequence for the same container; on the rollback path
|
||||
// only EventRollback (or update-failed) is emitted.
|
||||
if healthGated {
|
||||
switch s.healthGate(cli, newContainer.ID, opts.rollbackTimeout, startPeriod) {
|
||||
case gateAborted:
|
||||
// Server shutdown mid-gate: leave the new container in place, do not roll
|
||||
// back and do not emit an event (we never observed a real failure).
|
||||
return
|
||||
case gateRollback:
|
||||
s.rollback(cli, endpoint, newContainer.ID, oldImageID, originalRef, c.Name)
|
||||
return
|
||||
case gateHealthy:
|
||||
// Confirmed healthy: fall through to emit "updated" and clean up.
|
||||
}
|
||||
}
|
||||
|
||||
// Emit "updated" now: either there was no gate (emitted right after recreate,
|
||||
// as before), or the gate confirmed the new container is healthy.
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdated, EndpointID: endpointID, ContainerID: newContainer.ID, ContainerName: c.Name,
|
||||
Image: newImage, OldDigest: oldImageID, NewDigest: newContainer.Image,
|
||||
Message: "updated standalone container",
|
||||
})
|
||||
|
||||
if opts.cleanup && newContainer != nil && newContainer.Image != oldImageID {
|
||||
s.cleanupOldImage(cli, endpoint, oldImageID)
|
||||
}
|
||||
}
|
||||
|
||||
// containerName returns a container's primary name without the leading slash, or
|
||||
// "" when none is reported. The name is stable across a recreate (Recreate
|
||||
// assigns a new container ID but preserves the name), so it keys the rolled-back
|
||||
// loop-guard map.
|
||||
func containerName(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(names[0], "/")
|
||||
}
|
||||
|
||||
// skipUnnamedForRollback reports whether a standalone update must be skipped
|
||||
// because rollback is enabled but the container has no stable name to key the
|
||||
// loop guard. The rolled-back map is keyed by endpoint+name (the only identifier
|
||||
// that survives a recreate); without a name the guard cannot record a failed
|
||||
// target, so a repeatedly-failing update would loop update->rollback every tick
|
||||
// with no suppression. When rollback is off there is nothing to loop, so an
|
||||
// unnamed container is still allowed to update.
|
||||
func skipUnnamedForRollback(rollback bool, name string) bool {
|
||||
return rollback && name == ""
|
||||
}
|
||||
|
||||
// rollbackKey identifies a standalone container in the rolled-back map by its
|
||||
// endpoint and (recreate-stable) name. A recreate assigns a new container ID, so
|
||||
// the ID cannot key state across an update; the name is preserved.
|
||||
func rollbackKey(endpointID portainer.EndpointID, name string) string {
|
||||
return fmt.Sprintf("%d/%s", int(endpointID), name)
|
||||
}
|
||||
|
||||
// resolveRemoteDigest fetches the current remote image digest for a reference. It
|
||||
// tells whether a rolled-back container's upstream target is still the same
|
||||
// failed image (skip) or a new push (retry).
|
||||
func (s *Service) resolveRemoteDigest(ctx context.Context, ref string) (string, error) {
|
||||
img, err := images.ParseImage(images.ParseImageOptions{Name: ref})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dig, err := s.digestClient.RemoteDigest(ctx, img)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dig.String(), nil
|
||||
}
|
||||
|
||||
// recordRolledBack stores the failed target after a successful rollback so the
|
||||
// next poll skips re-pulling the same broken image. The failed remote digest is
|
||||
// resolved now (the registry is reachable, the image was just pulled); if it
|
||||
// cannot be resolved the record is still stored with an empty digest and the
|
||||
// guard skips conservatively until the cooldown elapses.
|
||||
func (s *Service) recordRolledBack(endpoint *portainer.Endpoint, name, ref string) {
|
||||
if name == "" {
|
||||
// Without a stable key we cannot reliably match the container next tick.
|
||||
log.Debug().Str("image", ref).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: rolled-back container has no name, loop guard not recorded")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
|
||||
digest, err := s.resolveRemoteDigest(ctx, ref)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("image", ref).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: could not resolve failed remote digest, loop guard will skip conservatively until cooldown")
|
||||
}
|
||||
|
||||
s.setRolledBack(rollbackKey(endpoint.ID, name), rolledBackTarget{ref: ref, digest: digest, at: time.Now()})
|
||||
}
|
||||
|
||||
// shouldSkipRolledBack reports whether a standalone container must be skipped this
|
||||
// tick to avoid the update->rollback loop, clearing the record once the skip no
|
||||
// longer applies (cooldown elapsed or a new upstream image). It resolves the
|
||||
// current remote digest so a genuinely new image is never blocked.
|
||||
func (s *Service) shouldSkipRolledBack(key string, rec rolledBackTarget) bool {
|
||||
now := time.Now()
|
||||
|
||||
// Fast paths that avoid a registry call: cooldown elapsed -> clear & proceed;
|
||||
// no recorded digest -> skip conservatively while the cooldown is open.
|
||||
if now.Sub(rec.at) >= updateRollbackCooldown {
|
||||
s.clearRolledBack(key)
|
||||
return false
|
||||
}
|
||||
if rec.digest == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
|
||||
currentDigest, err := s.resolveRemoteDigest(ctx, rec.ref)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// Cannot confirm the upstream target changed: stay conservative and skip to
|
||||
// avoid re-entering the loop, until the cooldown elapses.
|
||||
log.Debug().Err(err).Str("image", rec.ref).
|
||||
Msg("auto-update: cannot resolve remote digest for a rolled-back container, skipping until cooldown")
|
||||
return true
|
||||
}
|
||||
|
||||
if decideUpdateSkip(rec, currentDigest, now, updateRollbackCooldown) {
|
||||
return true
|
||||
}
|
||||
|
||||
// New upstream image (changed digest): the failed target is gone, clear the
|
||||
// record and let the update proceed.
|
||||
s.clearRolledBack(key)
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanupOldImage attempts a conservative removal of the previous image after a
|
||||
// standalone update. The removal is NOT forced: Docker refuses to delete an
|
||||
// image that still carries tags or is referenced by any container, so this only
|
||||
// succeeds when the old image has become genuinely dangling (untagged and
|
||||
// unused). It never touches a tagged image still in use.
|
||||
func (s *Service) cleanupOldImage(cli dockerClient, endpoint *portainer.Endpoint, oldImageID string) {
|
||||
if oldImageID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := cli.ImageRemove(ctx, oldImageID, image.RemoveOptions{Force: false, PruneChildren: false}); err != nil {
|
||||
log.Debug().Err(err).Str("image_id", oldImageID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: old image not removed (still tagged or in use)")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("image_id", oldImageID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: removed dangling old image after update")
|
||||
}
|
||||
|
||||
// updateStack applies an image update to a Portainer-managed compose stack so its
|
||||
// containers are recreated by the stack engine and stay part of the stack. It is
|
||||
// called at most once per stack per tick.
|
||||
//
|
||||
// - git stacks: detect-only here. A git stack's source of truth is its commit;
|
||||
// this tick's trigger is an image-only update (same compose manifest, newer
|
||||
// upstream digest), which the git redeploy path (RedeployWhenChanged) would
|
||||
// short-circuit without applying — while still doing a real git fetch every
|
||||
// tick. So we skip git stacks: the image update lands on the stack's next git
|
||||
// change or via a manual "Update now", and we do not fetch git every tick.
|
||||
// - file stacks: the deployer is driven directly with forcePullImage=true,
|
||||
// applying the image update immediately.
|
||||
//
|
||||
// On a successful file-stack redeploy it emits one EventUpdated per member
|
||||
// container that triggered the update (not a single aggregate stack event), each
|
||||
// carrying the stack name and a best-effort post-redeploy new image id.
|
||||
func (s *Service) updateStack(cli dockerClient, endpoint *portainer.Endpoint, st StackUpdate) {
|
||||
if st.IsGit {
|
||||
// Detect-only: leave git bookkeeping to the git redeploy path. Logged at
|
||||
// debug so it does not repeat at info on every tick (it would otherwise
|
||||
// fire for an unchanged git stack indefinitely).
|
||||
log.Debug().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: outdated git stack image detected, detect only (applied on next git change or manual update)")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, stackRedeployTimeout)
|
||||
defer cancel()
|
||||
|
||||
stack, err := s.dataStore.Stack().Read(portainer.StackID(st.StackID))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: unable to read stack for redeploy")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve registries the same way the established userless/system redeploy does
|
||||
// (RedeployWhenChanged): scope them to the stack author's access on the endpoint
|
||||
// and refresh ECR tokens, so an ECR-backed stack authenticates with fresh
|
||||
// credentials instead of the stale token a raw ReadAll() would pass.
|
||||
registries, err := deployments.ResolveStackRegistries(s.dataStore, stack, endpoint.ID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: unable to resolve registries for stack redeploy")
|
||||
return
|
||||
}
|
||||
|
||||
// prune=false (conservative: do not remove resources the user may rely on),
|
||||
// forcePullImage=true (the whole point), forceRecreate=false.
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = s.stackDeployer.DeployRemoteComposeStack(ctx, stack, endpoint, registries, false, true, false)
|
||||
} else {
|
||||
err = s.stackDeployer.DeployComposeStack(ctx, stack, endpoint, registries, false, true, false)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: failed to redeploy compose stack with re-pull")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("auto-update: redeployed compose stack with updated images")
|
||||
|
||||
// One notification PER updated container (the maintainer's requirement), each
|
||||
// showing the container's stack name. The stack was redeployed as a whole, so the
|
||||
// per-container new image id is not in hand; re-inspect each container by its
|
||||
// (compose-stable) name to fill in the "new" digest best-effort. A failed inspect
|
||||
// leaves NewDigest empty and the message falls back to "image updated" — never a
|
||||
// blocked delivery.
|
||||
for _, c := range st.Containers {
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdated, EndpointID: int(endpoint.ID), StackID: st.StackID,
|
||||
StackName: c.Labels[composeProjectLabel], ContainerName: c.Name,
|
||||
Image: c.Image, OldDigest: c.ImageID, NewDigest: s.inspectImageID(cli, c.Name),
|
||||
Message: "updated stack container",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// inspectImageID re-inspects a container by its (compose-stable) name after a stack
|
||||
// redeploy to recover the new local image id for the update notification. It is
|
||||
// best-effort: any failure (or an empty name) yields "", and the caller degrades the
|
||||
// message to "image updated" rather than blocking delivery. The inspect is bounded
|
||||
// like every other engine call so a hung engine cannot stall the tick.
|
||||
func (s *Service) inspectImageID(cli dockerClient, containerName string) string {
|
||||
if containerName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
|
||||
defer cancel()
|
||||
|
||||
inspect, err := cli.ContainerInspect(ctx, containerName)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("container", containerName).
|
||||
Msg("auto-update: unable to inspect stack container for its new image id, notifying without it")
|
||||
return ""
|
||||
}
|
||||
|
||||
return inspect.Image
|
||||
}
|
||||
148
api/containerautomation/autoupdate_test.go
Normal file
148
api/containerautomation/autoupdate_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newStackInspectClient builds a Docker client wired to a test server that answers
|
||||
// ContainerInspect by name, returning the given new image id. It is the seam the
|
||||
// post-redeploy best-effort "new digest" re-inspect uses.
|
||||
func newStackInspectClient(t *testing.T, newImageIDByName map[string]string) *dockerclient.Client {
|
||||
t.Helper()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for name, imageID := range newImageIDByName {
|
||||
if strings.HasSuffix(r.URL.Path, "/containers/"+name+"/json") {
|
||||
_ = json.NewEncoder(w).Encode(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{ID: name, Image: imageID},
|
||||
Config: &container.Config{},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli, err := dockerclient.NewClientWithOpts(
|
||||
dockerclient.WithHost(srv.URL),
|
||||
dockerclient.WithHTTPClient(http.DefaultClient),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return cli
|
||||
}
|
||||
|
||||
// TestUpdateStackEmitsPerContainerEvents proves the maintainer's requirement: a
|
||||
// (file) stack redeploy emits one EventUpdated PER updated member container, each
|
||||
// carrying the compose stack name (from the container's label, not a Stack().Read)
|
||||
// and a best-effort post-redeploy new image id — never a single aggregate stack
|
||||
// event.
|
||||
func TestUpdateStackEmitsPerContainerEvents(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
// A stack author must exist for registry resolution; an admin resolves to the
|
||||
// (empty) registry set without needing endpoint/team wiring.
|
||||
require.NoError(t, store.User().Create(&portainer.User{ID: 1, Username: "auto", Role: portainer.AdministratorRole}))
|
||||
|
||||
endpoint := &portainer.Endpoint{ID: 1, Name: "nebula.lc"}
|
||||
require.NoError(t, store.Endpoint().Create(endpoint))
|
||||
|
||||
require.NoError(t, store.Stack().Create(&portainer.Stack{
|
||||
ID: 7, EndpointID: 1, Name: "cache-demo", Type: portainer.DockerComposeStack, CreatedBy: "auto",
|
||||
}))
|
||||
|
||||
const (
|
||||
oldEsphome = "sha256:59b94983c73a000000000000000000000000000000000000000000000000aaaa"
|
||||
newEsphome = "sha256:2231ca5d676d000000000000000000000000000000000000000000000000bbbb"
|
||||
oldOther = "sha256:1111111111110000000000000000000000000000000000000000000000000000"
|
||||
newOther = "sha256:2222222222220000000000000000000000000000000000000000000000000000"
|
||||
)
|
||||
|
||||
cli := newStackInspectClient(t, map[string]string{
|
||||
"esphome": newEsphome,
|
||||
"other": newOther,
|
||||
})
|
||||
|
||||
rec := &recordingNotifier{}
|
||||
s := &Service{
|
||||
baseCtx: context.Background(),
|
||||
dataStore: store,
|
||||
stackDeployer: testhelpers.NewTestStackDeployer(),
|
||||
notifier: rec,
|
||||
}
|
||||
|
||||
st := StackUpdate{
|
||||
StackID: 7,
|
||||
IsGit: false,
|
||||
Containers: []UpdateCandidate{
|
||||
{Name: "esphome", ImageID: oldEsphome, Image: "esphome/esphome:latest", Labels: map[string]string{composeProjectLabel: "cache-demo"}},
|
||||
{Name: "other", ImageID: oldOther, Image: "redis:7", Labels: map[string]string{composeProjectLabel: "cache-demo"}},
|
||||
},
|
||||
}
|
||||
|
||||
s.updateStack(cli, endpoint, st)
|
||||
|
||||
require.Len(t, rec.events, 2, "one EventUpdated per updated member container, not one aggregate stack event")
|
||||
|
||||
byContainer := map[string]Event{}
|
||||
for _, e := range rec.events {
|
||||
require.Equal(t, EventUpdated, e.Kind)
|
||||
require.Equal(t, "cache-demo", e.StackName, "each per-container event carries the compose stack name")
|
||||
require.Equal(t, 7, e.StackID)
|
||||
byContainer[e.ContainerName] = e
|
||||
}
|
||||
|
||||
esphome, ok := byContainer["esphome"]
|
||||
require.True(t, ok, "expected a per-container event for esphome")
|
||||
require.Equal(t, oldEsphome, esphome.OldDigest)
|
||||
require.Equal(t, newEsphome, esphome.NewDigest, "the new image id is recovered by re-inspecting the container after redeploy")
|
||||
|
||||
other, ok := byContainer["other"]
|
||||
require.True(t, ok, "expected a per-container event for other")
|
||||
require.Equal(t, oldOther, other.OldDigest)
|
||||
require.Equal(t, newOther, other.NewDigest)
|
||||
}
|
||||
|
||||
// TestUpdateStackGitIsDetectOnly guards that a git stack stays detect-only: it is
|
||||
// not redeployed and emits no notification (its image update lands on the next git
|
||||
// change or a manual update).
|
||||
func TestUpdateStackGitIsDetectOnly(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
endpoint := &portainer.Endpoint{ID: 1, Name: "nebula.lc"}
|
||||
require.NoError(t, store.Endpoint().Create(endpoint))
|
||||
|
||||
deployer := testhelpers.NewTestStackDeployer()
|
||||
rec := &recordingNotifier{}
|
||||
s := &Service{
|
||||
baseCtx: context.Background(),
|
||||
dataStore: store,
|
||||
stackDeployer: deployer,
|
||||
notifier: rec,
|
||||
}
|
||||
|
||||
cli := newStackInspectClient(t, nil)
|
||||
|
||||
s.updateStack(cli, endpoint, StackUpdate{
|
||||
StackID: 9, IsGit: true,
|
||||
Containers: []UpdateCandidate{{Name: "esphome", Labels: map[string]string{composeProjectLabel: "cache-demo"}}},
|
||||
})
|
||||
|
||||
require.Empty(t, rec.events, "a git stack is detect-only, no per-container notification")
|
||||
require.Zero(t, deployer.DeployComposeCallCount, "a git stack must not be redeployed here")
|
||||
}
|
||||
236
api/containerautomation/daemon_paths_test.go
Normal file
236
api/containerautomation/daemon_paths_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// 64-hex content-addressable image ids for the pre/post-update identities.
|
||||
oldImageID = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
newImageID = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
// preUpdateInspect is the pre-update ContainerInspect the standalone path issues
|
||||
// on the OLD container to capture its original image ref + healthcheck (so the
|
||||
// rollback health gate is armed).
|
||||
func preUpdateInspect(id, ref string) container.InspectResponse {
|
||||
return container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{ID: id, Image: oldImageID},
|
||||
Config: &container.Config{
|
||||
Image: ref,
|
||||
Healthcheck: &container.HealthConfig{Test: []string{"CMD", "true"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// healthInspect is the health-gate ContainerInspect on the NEW container,
|
||||
// reporting the given health status.
|
||||
func healthInspect(id string, status container.HealthStatus) container.InspectResponse {
|
||||
return container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: id,
|
||||
State: &container.State{Running: true, Health: &container.Health{Status: status}},
|
||||
},
|
||||
Config: &container.Config{},
|
||||
}
|
||||
}
|
||||
|
||||
func countPrefix(calls []string, prefix string) int {
|
||||
n := 0
|
||||
for _, c := range calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestUpdateStandaloneHappyPathCleansUpAfterHealthGate locks in the happy-path
|
||||
// wiring #20 calls out: pull/recreate -> health gate confirms healthy -> the
|
||||
// "updated" event is emitted only AFTER health is confirmed -> the old-image
|
||||
// cleanup runs strictly AFTER the healthy gate (so a rollback target could never
|
||||
// be deleted before the update is confirmed good).
|
||||
func TestUpdateStandaloneHappyPathCleansUpAfterHealthGate(t *testing.T) {
|
||||
const (
|
||||
oldID = "old-id"
|
||||
newID = "new-id"
|
||||
ref = "nginx:1.21"
|
||||
)
|
||||
|
||||
seq := &callSeq{}
|
||||
notif := &seqNotifier{seq: seq}
|
||||
|
||||
cli := newFakeDockerClient(seq)
|
||||
cli.inspectByID[oldID] = preUpdateInspect(oldID, ref)
|
||||
cli.inspectByID[newID] = healthInspect(newID, container.Healthy)
|
||||
|
||||
rec := &fakeRecreator{seq: seq, result: &types.ContainerJSON{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{ID: newID, Image: newImageID},
|
||||
Config: &container.Config{Image: ref},
|
||||
}}
|
||||
|
||||
s := &Service{
|
||||
baseCtx: context.Background(),
|
||||
containerService: rec,
|
||||
notifier: notif,
|
||||
rolledBack: map[string]rolledBackTarget{},
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{ID: 1}
|
||||
c := UpdateCandidate{ID: oldID, Name: "web", ImageID: oldImageID, Image: ref}
|
||||
opts := updateOptions{cleanup: true, rollback: true, rollbackTimeout: 60 * time.Second}
|
||||
|
||||
s.updateStandalone(cli, endpoint, c, opts)
|
||||
|
||||
calls := seq.snapshot()
|
||||
iRecreate := seq.indexOf("recreate:" + oldID)
|
||||
iGate := seq.indexOf("inspect:" + newID)
|
||||
iUpdated := seq.indexOf("event:" + string(EventUpdated))
|
||||
iCleanup := seq.indexOf("imageremove:" + oldImageID)
|
||||
|
||||
require.NotEqual(t, -1, iRecreate, "recreate must run")
|
||||
require.NotEqual(t, -1, iGate, "the health gate must poll the new container")
|
||||
require.NotEqual(t, -1, iUpdated, "an updated event must be emitted")
|
||||
require.NotEqual(t, -1, iCleanup, "cleanup must remove the old image")
|
||||
|
||||
require.Less(t, iRecreate, iGate, "recreate must happen before the health gate")
|
||||
require.Less(t, iGate, iUpdated, "the updated event must be held until health is confirmed")
|
||||
require.Less(t, iUpdated, iCleanup, "cleanup must run strictly AFTER the healthy gate/updated event")
|
||||
|
||||
require.Equal(t, oldImageID, calls[iCleanup][len("imageremove:"):], "cleanup targets the OLD image, never the new/rollback target")
|
||||
|
||||
updated, n := notif.only(EventUpdated)
|
||||
require.Equal(t, 1, n, "exactly one updated event")
|
||||
require.Equal(t, newID, updated.ContainerID)
|
||||
require.Equal(t, oldImageID, updated.OldDigest)
|
||||
require.Equal(t, newImageID, updated.NewDigest)
|
||||
|
||||
_, rollbacks := notif.only(EventRollback)
|
||||
require.Zero(t, rollbacks, "no rollback on the happy path")
|
||||
}
|
||||
|
||||
// TestUpdateStandaloneRollbackPreservesTarget locks in the rollback-path wiring:
|
||||
// a new container that fails the health gate is rolled back to the previous image
|
||||
// (re-tag -> recreate on the old image with NO pull), EventRollback (not
|
||||
// EventUpdated) is emitted, and the old-image cleanup NEVER runs — so the rollback
|
||||
// target is never deleted early.
|
||||
func TestUpdateStandaloneRollbackPreservesTarget(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
const (
|
||||
oldID = "old-id"
|
||||
newID = "new-id"
|
||||
// A registry ref that resolves to a fast connection-refused so the loop-guard's
|
||||
// best-effort remote-digest resolution fails immediately (offline-safe) without
|
||||
// blocking; the record is still stored with an empty digest.
|
||||
ref = "localhost:1/web:v1"
|
||||
)
|
||||
|
||||
seq := &callSeq{}
|
||||
notif := &seqNotifier{seq: seq}
|
||||
|
||||
cli := newFakeDockerClient(seq)
|
||||
cli.inspectByID[oldID] = preUpdateInspect(oldID, ref)
|
||||
cli.inspectByID[newID] = healthInspect(newID, container.Unhealthy)
|
||||
|
||||
rec := &fakeRecreator{seq: seq, result: &types.ContainerJSON{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{ID: newID, Image: newImageID},
|
||||
Config: &container.Config{Image: ref},
|
||||
}}
|
||||
|
||||
s := &Service{
|
||||
baseCtx: context.Background(),
|
||||
containerService: rec,
|
||||
digestClient: images.NewClientWithRegistry(images.NewRegistryClient(store), nil),
|
||||
notifier: notif,
|
||||
rolledBack: map[string]rolledBackTarget{},
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{ID: 1}
|
||||
c := UpdateCandidate{ID: oldID, Name: "web", ImageID: oldImageID, Image: ref}
|
||||
// cleanup enabled to prove it is NOT run on the rollback path.
|
||||
opts := updateOptions{cleanup: true, rollback: true, rollbackTimeout: 60 * time.Second}
|
||||
|
||||
s.updateStandalone(cli, endpoint, c, opts)
|
||||
|
||||
calls := seq.snapshot()
|
||||
iGate := seq.indexOf("inspect:" + newID)
|
||||
iTag := seq.indexOf("imagetag:" + oldImageID + "->" + ref)
|
||||
iRollbackRecreate := seq.indexOf("recreate:" + newID)
|
||||
iRollbackEvent := seq.indexOf("event:" + string(EventRollback))
|
||||
|
||||
require.NotEqual(t, -1, iGate, "the health gate must poll the new container")
|
||||
require.NotEqual(t, -1, iTag, "rollback must re-tag the previous image onto the original ref")
|
||||
require.NotEqual(t, -1, iRollbackRecreate, "rollback must recreate on the previous image")
|
||||
require.NotEqual(t, -1, iRollbackEvent, "a rollback event must be emitted")
|
||||
|
||||
require.Less(t, iGate, iTag, "rollback happens after the failed gate")
|
||||
require.Less(t, iTag, iRollbackRecreate, "re-tag the old image before recreating on it")
|
||||
require.Less(t, iRollbackRecreate, iRollbackEvent, "the rollback event follows the rollback recreate")
|
||||
|
||||
require.Zero(t, countPrefix(calls, "imageremove:"), "cleanup must NOT run on the rollback path (rollback target preserved)")
|
||||
|
||||
_, updates := notif.only(EventUpdated)
|
||||
require.Zero(t, updates, "no updated event on the rollback path")
|
||||
|
||||
rollback, n := notif.only(EventRollback)
|
||||
require.Equal(t, 1, n, "exactly one rollback event")
|
||||
require.Equal(t, ref, rollback.Image, "the rollback event carries the restored original ref")
|
||||
|
||||
// The recreate seam saw two calls: the initial pull-recreate on the old container,
|
||||
// then the rollback recreate on the new container WITHOUT a pull (resolves the
|
||||
// re-tagged previous image).
|
||||
require.Len(t, rec.calls, 2)
|
||||
require.Equal(t, recreateCall{containerID: oldID, forcePullImage: true}, rec.calls[0])
|
||||
require.Equal(t, recreateCall{containerID: newID, forcePullImage: false}, rec.calls[1])
|
||||
}
|
||||
|
||||
// TestHealContainersRestartsUnhealthyThenRespectsCooldown locks in the auto-heal
|
||||
// restart loop: an unhealthy container is restarted (restart precedes the heal
|
||||
// event), and a second immediate pass is suppressed by the restart cooldown/loop
|
||||
// guard so a flapping container is not restart-stormed.
|
||||
func TestHealContainersRestartsUnhealthyThenRespectsCooldown(t *testing.T) {
|
||||
seq := &callSeq{}
|
||||
notif := &seqNotifier{seq: seq}
|
||||
cli := newFakeDockerClient(seq)
|
||||
|
||||
s := &Service{
|
||||
baseCtx: context.Background(),
|
||||
notifier: notif,
|
||||
retries: map[string]retryState{},
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{ID: 1}
|
||||
containers := []container.Summary{{ID: "c1", Names: []string{"/web"}}}
|
||||
|
||||
// First pass: the unhealthy container is restarted and a heal event is emitted.
|
||||
s.healContainers(cli, endpoint, ScopeAll, containers)
|
||||
|
||||
require.Equal(t, []string{"restart:c1", "event:" + string(EventHealRestarted)}, seq.snapshot(),
|
||||
"the restart must precede the heal event")
|
||||
require.Equal(t, 1, s.getRetry("c1").attempts, "the restart is accounted against the retry budget")
|
||||
|
||||
ev, n := notif.only(EventHealRestarted)
|
||||
require.Equal(t, 1, n)
|
||||
require.Equal(t, "c1", ev.ContainerID)
|
||||
require.Equal(t, "web", ev.ContainerName)
|
||||
|
||||
// Second immediate pass: within the restart cooldown, the loop guard suppresses a
|
||||
// second restart (no new restart call, no new event).
|
||||
s.healContainers(cli, endpoint, ScopeAll, containers)
|
||||
|
||||
require.Equal(t, 1, countPrefix(seq.snapshot(), "restart:"), "no second restart within the cooldown")
|
||||
_, n2 := notif.only(EventHealRestarted)
|
||||
require.Equal(t, 1, n2, "the flap is suppressed by the restart cooldown")
|
||||
}
|
||||
152
api/containerautomation/fakes_test.go
Normal file
152
api/containerautomation/fakes_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
)
|
||||
|
||||
// callSeq is a shared, ordered recorder the daemon-path fakes write to, so a test
|
||||
// can assert the SEQUENCE of operations across the docker client, the recreator
|
||||
// and the notifier on a single timeline (e.g. cleanup strictly after the healthy
|
||||
// gate, "updated" held until health is confirmed).
|
||||
type callSeq struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (s *callSeq) record(entry string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls = append(s.calls, entry)
|
||||
}
|
||||
|
||||
// snapshot returns a copy of the recorded calls in order.
|
||||
func (s *callSeq) snapshot() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, len(s.calls))
|
||||
copy(out, s.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
// indexOf returns the position of the first recorded call equal to entry, or -1.
|
||||
func (s *callSeq) indexOf(entry string) int {
|
||||
for i, c := range s.snapshot() {
|
||||
if c == entry {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fakeDockerClient is a programmable, call-recording implementation of the
|
||||
// dockerClient seam. Inspect responses/errors are keyed by container id so the
|
||||
// pre-update inspect (old id) and the health-gate poll (new id) can return
|
||||
// different states within one update.
|
||||
type fakeDockerClient struct {
|
||||
seq *callSeq
|
||||
|
||||
inspectByID map[string]container.InspectResponse
|
||||
inspectErrByID map[string]error
|
||||
|
||||
imageTagErr error
|
||||
imageRemoveErr error
|
||||
restartErrByID map[string]error
|
||||
}
|
||||
|
||||
func newFakeDockerClient(seq *callSeq) *fakeDockerClient {
|
||||
return &fakeDockerClient{
|
||||
seq: seq,
|
||||
inspectByID: map[string]container.InspectResponse{},
|
||||
inspectErrByID: map[string]error{},
|
||||
restartErrByID: map[string]error{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDockerClient) ContainerInspect(_ context.Context, containerID string) (container.InspectResponse, error) {
|
||||
f.seq.record("inspect:" + containerID)
|
||||
if err := f.inspectErrByID[containerID]; err != nil {
|
||||
return container.InspectResponse{}, err
|
||||
}
|
||||
resp, ok := f.inspectByID[containerID]
|
||||
if !ok {
|
||||
return container.InspectResponse{}, fmt.Errorf("fake: no inspect programmed for %q", containerID)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (f *fakeDockerClient) ContainerRestart(_ context.Context, containerID string, _ container.StopOptions) error {
|
||||
f.seq.record("restart:" + containerID)
|
||||
return f.restartErrByID[containerID]
|
||||
}
|
||||
|
||||
func (f *fakeDockerClient) ImageTag(_ context.Context, source, target string) error {
|
||||
f.seq.record("imagetag:" + source + "->" + target)
|
||||
return f.imageTagErr
|
||||
}
|
||||
|
||||
func (f *fakeDockerClient) ImageRemove(_ context.Context, imageID string, _ image.RemoveOptions) ([]image.DeleteResponse, error) {
|
||||
f.seq.record("imageremove:" + imageID)
|
||||
if f.imageRemoveErr != nil {
|
||||
return nil, f.imageRemoveErr
|
||||
}
|
||||
return []image.DeleteResponse{{Deleted: imageID}}, nil
|
||||
}
|
||||
|
||||
// recreateCall records the salient arguments of a single Recreate invocation.
|
||||
type recreateCall struct {
|
||||
containerID string
|
||||
forcePullImage bool
|
||||
}
|
||||
|
||||
// fakeRecreator is a programmable, call-recording implementation of the
|
||||
// containerRecreator seam. It returns the same result for every call; the
|
||||
// standalone rollback path recreates a second time (on the previous image) and
|
||||
// ignores that return value.
|
||||
type fakeRecreator struct {
|
||||
seq *callSeq
|
||||
result *types.ContainerJSON
|
||||
err error
|
||||
calls []recreateCall
|
||||
}
|
||||
|
||||
func (f *fakeRecreator) Recreate(_ context.Context, _ *portainer.Endpoint, containerID string, forcePullImage bool, _, _ string) (*types.ContainerJSON, error) {
|
||||
f.seq.record("recreate:" + containerID)
|
||||
f.calls = append(f.calls, recreateCall{containerID: containerID, forcePullImage: forcePullImage})
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.result, nil
|
||||
}
|
||||
|
||||
// seqNotifier records each event onto the shared timeline (as "event:<kind>") and
|
||||
// keeps the full events for content assertions.
|
||||
type seqNotifier struct {
|
||||
seq *callSeq
|
||||
events []Event
|
||||
}
|
||||
|
||||
func (n *seqNotifier) Notify(event Event) {
|
||||
n.seq.record("event:" + string(event.Kind))
|
||||
n.events = append(n.events, event)
|
||||
}
|
||||
|
||||
// only returns the single recorded event of the given kind, requiring exactly one.
|
||||
func (n *seqNotifier) only(kind EventKind) (Event, int) {
|
||||
var found Event
|
||||
count := 0
|
||||
for _, e := range n.events {
|
||||
if e.Kind == kind {
|
||||
found = e
|
||||
count++
|
||||
}
|
||||
}
|
||||
return found, count
|
||||
}
|
||||
257
api/containerautomation/labels.go
Normal file
257
api/containerautomation/labels.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package containerautomation
|
||||
|
||||
import "strconv"
|
||||
|
||||
const (
|
||||
// Scope values shared by the auto-heal and auto-update global settings.
|
||||
ScopeLabeled = "labeled"
|
||||
ScopeAll = "all"
|
||||
|
||||
// Primary labels (with community aliases) controlling per-container auto-heal.
|
||||
labelEnable = "io.portainer.autoheal.enable"
|
||||
labelEnableAlias = "autoheal"
|
||||
labelStopTimeout = "io.portainer.autoheal.stop-timeout"
|
||||
labelStopTimeoutAlias = "autoheal.stop.timeout"
|
||||
labelRetries = "io.portainer.autoheal.retries"
|
||||
|
||||
// Primary labels (with watchtower aliases) controlling per-container auto-update.
|
||||
labelUpdateEnable = "io.portainer.update.enable"
|
||||
labelUpdateEnableAlias = "com.centurylinklabs.watchtower.enable"
|
||||
labelUpdateMonitorOnly = "io.portainer.update.monitor-only"
|
||||
labelUpdateMonitorOnlyAlias = "com.centurylinklabs.watchtower.monitor-only"
|
||||
|
||||
// composeProjectLabel identifies the compose project a container belongs to.
|
||||
composeProjectLabel = "com.docker.compose.project"
|
||||
|
||||
// Defaults used when a label is missing or holds an invalid value.
|
||||
defaultStopTimeout = 10
|
||||
defaultRetries = 3
|
||||
)
|
||||
|
||||
// InScope reports whether a container is subject to auto-heal given the global
|
||||
// scope and the container's labels.
|
||||
//
|
||||
// - "all": every container is in scope, unless it explicitly opts out with the
|
||||
// enable label set to false.
|
||||
// - "labeled" (default): only containers with the enable label set to true.
|
||||
func InScope(scope string, labels map[string]string) bool {
|
||||
enabled, present := boolLabel(labels, labelEnable, labelEnableAlias)
|
||||
|
||||
switch scope {
|
||||
case ScopeAll:
|
||||
if present && !enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
default: // ScopeLabeled
|
||||
return present && enabled
|
||||
}
|
||||
}
|
||||
|
||||
// boolLabel resolves a boolean label (primary key first, alias second).
|
||||
// It returns the parsed value and whether the label was present at all.
|
||||
// Invalid values are treated as false but still count as "present".
|
||||
func boolLabel(labels map[string]string, key, alias string) (value bool, present bool) {
|
||||
raw, ok := labels[key]
|
||||
if !ok {
|
||||
raw, ok = labels[alias]
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// InUpdateScope reports whether a container is subject to auto-update given the
|
||||
// global scope and the container's labels. It mirrors InScope but reads the
|
||||
// update enable label (io.portainer.update.enable / watchtower alias):
|
||||
//
|
||||
// - "all": every container is in scope, unless it explicitly opts out with the
|
||||
// update enable label set to false.
|
||||
// - "labeled" (default): only containers with the update enable label true.
|
||||
func InUpdateScope(scope string, labels map[string]string) bool {
|
||||
enabled, present := boolLabel(labels, labelUpdateEnable, labelUpdateEnableAlias)
|
||||
|
||||
switch scope {
|
||||
case ScopeAll:
|
||||
if present && !enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
default: // ScopeLabeled
|
||||
return present && enabled
|
||||
}
|
||||
}
|
||||
|
||||
// IsMonitorOnly reports whether a container is flagged detect-only via the
|
||||
// monitor-only label (io.portainer.update.monitor-only / watchtower alias).
|
||||
// Such containers have their image status resolved (for the badge cache) but are
|
||||
// never auto-applied.
|
||||
func IsMonitorOnly(labels map[string]string) bool {
|
||||
value, present := boolLabel(labels, labelUpdateMonitorOnly, labelUpdateMonitorOnlyAlias)
|
||||
|
||||
return present && value
|
||||
}
|
||||
|
||||
// UpdateKind is the apply path resolved for an outdated container.
|
||||
type UpdateKind string
|
||||
|
||||
const (
|
||||
// UpdateStandalone: recreate-with-pull (no compose project).
|
||||
UpdateStandalone UpdateKind = "standalone"
|
||||
// UpdateStack: redeploy the owning Portainer compose stack with re-pull, so
|
||||
// the container stays part of its stack.
|
||||
UpdateStack UpdateKind = "stack"
|
||||
// UpdateExternal: compose-managed but with no matching Portainer compose
|
||||
// stack record; Portainer must not touch it (would detach it / drift).
|
||||
UpdateExternal UpdateKind = "external"
|
||||
)
|
||||
|
||||
// StackMatch is the Portainer Docker Compose stack a compose project resolves to.
|
||||
type StackMatch struct {
|
||||
StackID int
|
||||
// IsGit routes file vs git redeploy at apply time.
|
||||
IsGit bool
|
||||
}
|
||||
|
||||
// UpdateRouting is the decision returned by resolveContainerUpdateRouting.
|
||||
type UpdateRouting struct {
|
||||
Kind UpdateKind
|
||||
StackID int
|
||||
IsGit bool
|
||||
}
|
||||
|
||||
// resolveContainerUpdateRouting decides how a container's image update must be
|
||||
// applied, given a lookup that resolves a compose project name to a matching
|
||||
// Portainer Docker Compose stack (nil when none exists or it is not a compose
|
||||
// stack). It is the Go equivalent of M3's TS resolveContainerUpdatePath: pure
|
||||
// and side-effect free so it can be unit-tested without Docker or the datastore.
|
||||
//
|
||||
// - No compose project label -> standalone (recreate-with-pull).
|
||||
// - Compose project matching a Portainer compose stack -> stack
|
||||
// (redeploy-with-pull, keeps the container in its stack).
|
||||
// - Compose project with no matching Portainer compose stack -> external
|
||||
// (managed outside Portainer / a same-named stack of another type), left
|
||||
// untouched to avoid detaching it or drifting.
|
||||
func resolveContainerUpdateRouting(labels map[string]string, stackLookup func(project string) *StackMatch) UpdateRouting {
|
||||
project := labels[composeProjectLabel]
|
||||
if project == "" {
|
||||
return UpdateRouting{Kind: UpdateStandalone}
|
||||
}
|
||||
|
||||
match := stackLookup(project)
|
||||
if match == nil {
|
||||
return UpdateRouting{Kind: UpdateExternal}
|
||||
}
|
||||
|
||||
return UpdateRouting{Kind: UpdateStack, StackID: match.StackID, IsGit: match.IsGit}
|
||||
}
|
||||
|
||||
// UpdateCandidate is an outdated, in-scope container considered for auto-update.
|
||||
type UpdateCandidate struct {
|
||||
ID string
|
||||
// Name is the container's primary name (no leading slash). It is stable across
|
||||
// a recreate and keys the update->rollback loop guard.
|
||||
Name string
|
||||
// ImageID is the pre-update local image id ("sha256:..."), the "old" digest in a
|
||||
// per-container update notification.
|
||||
ImageID string
|
||||
// Image is the container's image reference (e.g. "nginx:latest"), carried for the
|
||||
// notification.
|
||||
Image string
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// StackUpdate identifies a Portainer stack to redeploy once, together with the
|
||||
// affected member containers so each updated container can emit its own
|
||||
// notification (with the stack name) after the redeploy.
|
||||
type StackUpdate struct {
|
||||
StackID int
|
||||
IsGit bool
|
||||
// Containers are the outdated member containers that triggered this stack
|
||||
// redeploy, threaded through from detection so a per-container notification can
|
||||
// be emitted for each (name + old image id + image + labels/stack name).
|
||||
Containers []UpdateCandidate
|
||||
}
|
||||
|
||||
// GroupedUpdates partitions candidates into their apply paths, de-duplicating
|
||||
// stack containers so each owning stack is redeployed at most once per tick
|
||||
// (the overlap guard for stack fan-out). Pure and unit-testable, the Go analogue
|
||||
// of M3's groupContainersForUpdate.
|
||||
type GroupedUpdates struct {
|
||||
Standalone []UpdateCandidate
|
||||
External []UpdateCandidate
|
||||
Stacks []StackUpdate
|
||||
}
|
||||
|
||||
// groupContainersForUpdate routes each candidate and collapses stack candidates
|
||||
// so a stack with several outdated containers is redeployed only once.
|
||||
func groupContainersForUpdate(candidates []UpdateCandidate, stackLookup func(project string) *StackMatch) GroupedUpdates {
|
||||
grouped := GroupedUpdates{}
|
||||
// stackIndex maps a stack id to its slot in grouped.Stacks so a stack is
|
||||
// redeployed once, while every member container is still collected for its own
|
||||
// notification (rather than discarded at the collapse).
|
||||
stackIndex := make(map[int]int)
|
||||
|
||||
for _, c := range candidates {
|
||||
routing := resolveContainerUpdateRouting(c.Labels, stackLookup)
|
||||
switch routing.Kind {
|
||||
case UpdateStandalone:
|
||||
grouped.Standalone = append(grouped.Standalone, c)
|
||||
case UpdateExternal:
|
||||
grouped.External = append(grouped.External, c)
|
||||
case UpdateStack:
|
||||
idx, ok := stackIndex[routing.StackID]
|
||||
if !ok {
|
||||
grouped.Stacks = append(grouped.Stacks, StackUpdate{StackID: routing.StackID, IsGit: routing.IsGit})
|
||||
idx = len(grouped.Stacks) - 1
|
||||
stackIndex[routing.StackID] = idx
|
||||
}
|
||||
|
||||
grouped.Stacks[idx].Containers = append(grouped.Stacks[idx].Containers, c)
|
||||
}
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
// StopTimeout returns the per-container stop timeout (in seconds) from labels,
|
||||
// falling back to the default when missing or invalid.
|
||||
func StopTimeout(labels map[string]string) int {
|
||||
return positiveIntLabel(labels, labelStopTimeout, labelStopTimeoutAlias, defaultStopTimeout)
|
||||
}
|
||||
|
||||
// MaxRetries returns the per-container max restarts per window from labels,
|
||||
// falling back to the default when missing or invalid.
|
||||
func MaxRetries(labels map[string]string) int {
|
||||
return positiveIntLabel(labels, labelRetries, "", defaultRetries)
|
||||
}
|
||||
|
||||
// positiveIntLabel reads an integer label (primary first, optional alias second)
|
||||
// and returns it when strictly positive, otherwise the provided default.
|
||||
func positiveIntLabel(labels map[string]string, key, alias string, fallback int) int {
|
||||
raw, ok := labels[key]
|
||||
if !ok && alias != "" {
|
||||
raw, ok = labels[alias]
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
248
api/containerautomation/labels_test.go
Normal file
248
api/containerautomation/labels_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package containerautomation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"labeled: no labels", ScopeLabeled, nil, false},
|
||||
{"labeled: enable true (primary)", ScopeLabeled, map[string]string{labelEnable: "true"}, true},
|
||||
{"labeled: enable true (alias)", ScopeLabeled, map[string]string{labelEnableAlias: "true"}, true},
|
||||
{"labeled: enable false", ScopeLabeled, map[string]string{labelEnable: "false"}, false},
|
||||
{"labeled: enable bad value", ScopeLabeled, map[string]string{labelEnable: "yepp"}, false},
|
||||
{"labeled: primary wins over alias", ScopeLabeled, map[string]string{labelEnable: "true", labelEnableAlias: "false"}, true},
|
||||
{"all: no labels", ScopeAll, nil, true},
|
||||
{"all: enable true", ScopeAll, map[string]string{labelEnable: "true"}, true},
|
||||
{"all: explicit opt-out", ScopeAll, map[string]string{labelEnable: "false"}, false},
|
||||
{"all: opt-out via alias", ScopeAll, map[string]string{labelEnableAlias: "0"}, false},
|
||||
{"all: bad value is not opt-out", ScopeAll, map[string]string{labelEnable: "nope"}, false},
|
||||
{"unknown scope falls back to labeled", "weird", map[string]string{labelEnable: "true"}, true},
|
||||
{"unknown scope, no label", "weird", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := InScope(tt.scope, tt.labels); got != tt.want {
|
||||
t.Errorf("InScope(%q, %v) = %v, want %v", tt.scope, tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want int
|
||||
}{
|
||||
{"default when missing", nil, defaultStopTimeout},
|
||||
{"primary value", map[string]string{labelStopTimeout: "25"}, 25},
|
||||
{"alias value", map[string]string{labelStopTimeoutAlias: "15"}, 15},
|
||||
{"primary wins over alias", map[string]string{labelStopTimeout: "25", labelStopTimeoutAlias: "15"}, 25},
|
||||
{"bad value falls back", map[string]string{labelStopTimeout: "abc"}, defaultStopTimeout},
|
||||
{"zero falls back", map[string]string{labelStopTimeout: "0"}, defaultStopTimeout},
|
||||
{"negative falls back", map[string]string{labelStopTimeout: "-5"}, defaultStopTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := StopTimeout(tt.labels); got != tt.want {
|
||||
t.Errorf("StopTimeout(%v) = %d, want %d", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxRetries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want int
|
||||
}{
|
||||
{"default when missing", nil, defaultRetries},
|
||||
{"explicit value", map[string]string{labelRetries: "5"}, 5},
|
||||
{"bad value falls back", map[string]string{labelRetries: "lots"}, defaultRetries},
|
||||
{"zero falls back", map[string]string{labelRetries: "0"}, defaultRetries},
|
||||
{"no alias for retries", map[string]string{"autoheal.retries": "7"}, defaultRetries},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := MaxRetries(tt.labels); got != tt.want {
|
||||
t.Errorf("MaxRetries(%v) = %d, want %d", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInUpdateScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"labeled: no labels", ScopeLabeled, nil, false},
|
||||
{"labeled: enable true (primary)", ScopeLabeled, map[string]string{labelUpdateEnable: "true"}, true},
|
||||
{"labeled: enable true (watchtower alias)", ScopeLabeled, map[string]string{labelUpdateEnableAlias: "true"}, true},
|
||||
{"labeled: enable false", ScopeLabeled, map[string]string{labelUpdateEnable: "false"}, false},
|
||||
{"labeled: enable bad value", ScopeLabeled, map[string]string{labelUpdateEnable: "soon"}, false},
|
||||
{"labeled: primary wins over alias", ScopeLabeled, map[string]string{labelUpdateEnable: "true", labelUpdateEnableAlias: "false"}, true},
|
||||
{"all: no labels", ScopeAll, nil, true},
|
||||
{"all: enable true", ScopeAll, map[string]string{labelUpdateEnable: "true"}, true},
|
||||
{"all: explicit opt-out", ScopeAll, map[string]string{labelUpdateEnable: "false"}, false},
|
||||
{"all: opt-out via watchtower alias", ScopeAll, map[string]string{labelUpdateEnableAlias: "0"}, false},
|
||||
{"all: bad value is not opt-out", ScopeAll, map[string]string{labelUpdateEnable: "nope"}, false},
|
||||
{"unknown scope falls back to labeled", "weird", map[string]string{labelUpdateEnable: "true"}, true},
|
||||
{"unknown scope, no label", "weird", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := InUpdateScope(tt.scope, tt.labels); got != tt.want {
|
||||
t.Errorf("InUpdateScope(%q, %v) = %v, want %v", tt.scope, tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMonitorOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"no labels", nil, false},
|
||||
{"primary true", map[string]string{labelUpdateMonitorOnly: "true"}, true},
|
||||
{"watchtower alias true", map[string]string{labelUpdateMonitorOnlyAlias: "true"}, true},
|
||||
{"primary false", map[string]string{labelUpdateMonitorOnly: "false"}, false},
|
||||
{"bad value", map[string]string{labelUpdateMonitorOnly: "maybe"}, false},
|
||||
{"primary wins over alias", map[string]string{labelUpdateMonitorOnly: "true", labelUpdateMonitorOnlyAlias: "false"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsMonitorOnly(tt.labels); got != tt.want {
|
||||
t.Errorf("IsMonitorOnly(%v) = %v, want %v", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContainerUpdateRouting(t *testing.T) {
|
||||
// stackLookup resolves "my-stack" to compose stack 7 (git) and nothing else,
|
||||
// mirroring how the job builds a per-endpoint compose-stack index.
|
||||
stackLookup := func(project string) *StackMatch {
|
||||
if project == "my-stack" {
|
||||
return &StackMatch{StackID: 7, IsGit: true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want UpdateRouting
|
||||
}{
|
||||
{
|
||||
name: "no compose label -> standalone",
|
||||
labels: map[string]string{"foo": "bar"},
|
||||
want: UpdateRouting{Kind: UpdateStandalone},
|
||||
},
|
||||
{
|
||||
name: "empty compose label -> standalone",
|
||||
labels: map[string]string{composeProjectLabel: ""},
|
||||
want: UpdateRouting{Kind: UpdateStandalone},
|
||||
},
|
||||
{
|
||||
name: "compose project matching a portainer compose stack -> stack",
|
||||
labels: map[string]string{composeProjectLabel: "my-stack"},
|
||||
want: UpdateRouting{Kind: UpdateStack, StackID: 7, IsGit: true},
|
||||
},
|
||||
{
|
||||
name: "compose project with no matching stack -> external",
|
||||
labels: map[string]string{composeProjectLabel: "other"},
|
||||
want: UpdateRouting{Kind: UpdateExternal},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveContainerUpdateRouting(tt.labels, stackLookup)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveContainerUpdateRouting(%v) = %+v, want %+v", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupContainersForUpdate(t *testing.T) {
|
||||
// stackLookup: "web" -> compose stack 3 (file), "api" -> compose stack 4 (git).
|
||||
stackLookup := func(project string) *StackMatch {
|
||||
switch project {
|
||||
case "web":
|
||||
return &StackMatch{StackID: 3, IsGit: false}
|
||||
case "api":
|
||||
return &StackMatch{StackID: 4, IsGit: true}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []UpdateCandidate{
|
||||
{ID: "standalone-1"},
|
||||
{ID: "web-a", Name: "web-a", Labels: map[string]string{composeProjectLabel: "web"}},
|
||||
{ID: "web-b", Name: "web-b", Labels: map[string]string{composeProjectLabel: "web"}}, // same stack -> deduped redeploy, both kept as members
|
||||
{ID: "api-a", Name: "api-a", Labels: map[string]string{composeProjectLabel: "api"}},
|
||||
{ID: "ext-1", Labels: map[string]string{composeProjectLabel: "unknown"}},
|
||||
}
|
||||
|
||||
grouped := groupContainersForUpdate(candidates, stackLookup)
|
||||
|
||||
if len(grouped.Standalone) != 1 || grouped.Standalone[0].ID != "standalone-1" {
|
||||
t.Errorf("Standalone = %+v, want one entry standalone-1", grouped.Standalone)
|
||||
}
|
||||
|
||||
if len(grouped.External) != 1 || grouped.External[0].ID != "ext-1" {
|
||||
t.Errorf("External = %+v, want one entry ext-1", grouped.External)
|
||||
}
|
||||
|
||||
// One redeploy per stack: web appears twice in input but once in output.
|
||||
if len(grouped.Stacks) != 2 {
|
||||
t.Fatalf("Stacks = %+v, want 2 deduped stacks", grouped.Stacks)
|
||||
}
|
||||
|
||||
got := map[int]bool{}
|
||||
for _, st := range grouped.Stacks {
|
||||
got[st.StackID] = st.IsGit
|
||||
}
|
||||
|
||||
if isGit, ok := got[3]; !ok || isGit {
|
||||
t.Errorf("stack 3 = (%v, present=%v), want present file stack", isGit, ok)
|
||||
}
|
||||
|
||||
if isGit, ok := got[4]; !ok || !isGit {
|
||||
t.Errorf("stack 4 = (%v, present=%v), want present git stack", isGit, ok)
|
||||
}
|
||||
|
||||
// The stack is redeployed once, but every member container is threaded through
|
||||
// (not discarded at the collapse) so each can emit its own notification.
|
||||
members := map[int][]string{}
|
||||
for _, st := range grouped.Stacks {
|
||||
for _, c := range st.Containers {
|
||||
members[st.StackID] = append(members[st.StackID], c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if got := members[3]; len(got) != 2 || got[0] != "web-a" || got[1] != "web-b" {
|
||||
t.Errorf("stack 3 members = %v, want [web-a web-b]", got)
|
||||
}
|
||||
|
||||
if got := members[4]; len(got) != 1 || got[0] != "api-a" {
|
||||
t.Errorf("stack 4 members = %v, want [api-a]", got)
|
||||
}
|
||||
}
|
||||
113
api/containerautomation/notify.go
Normal file
113
api/containerautomation/notify.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package containerautomation
|
||||
|
||||
import "github.com/rs/zerolog/log"
|
||||
|
||||
// EventKind enumerates the container-automation events surfaced to a Notifier.
|
||||
// The set is intentionally small: it is the seam future milestones extend with
|
||||
// real senders (Slack/email/webhook) without touching the daemon call sites.
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
// EventUpdated is emitted after a container/stack image was updated.
|
||||
EventUpdated EventKind = "updated"
|
||||
// EventRollback is emitted after a health-gated rollback to the previous image.
|
||||
EventRollback EventKind = "rollback"
|
||||
// EventUpdateFailed is emitted when an update (or its rollback) could not be applied.
|
||||
EventUpdateFailed EventKind = "update-failed"
|
||||
// EventHealRestarted is emitted after an unhealthy container was restarted.
|
||||
EventHealRestarted EventKind = "heal-restarted"
|
||||
)
|
||||
|
||||
// Event is a structured container-automation notification. Optional fields are
|
||||
// left zero when not applicable to the event (e.g. StackID for a standalone
|
||||
// update, ContainerID for a stack redeploy).
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
EndpointID int
|
||||
ContainerID string
|
||||
// ContainerName is the human-readable container name (no leading slash), used
|
||||
// by the webhook message. It may be empty for events keyed only by ID.
|
||||
ContainerName string
|
||||
StackID int
|
||||
// StackName is the compose project (stack) name a container belongs to, sourced
|
||||
// from its com.docker.compose.project label at detection time. It is set on a
|
||||
// per-container update event for a stack member so the webhook can print a
|
||||
// "Stack [name]" line without a StackID/Stack().Read round-trip; empty for
|
||||
// standalone containers.
|
||||
StackName string
|
||||
Image string
|
||||
// OldDigest and NewDigest carry the pre/post image identities for an update
|
||||
// (image IDs, e.g. "sha256:59b9..."). They are threaded from the update call
|
||||
// site where they are known and left empty otherwise; the webhook notifier
|
||||
// short-forms them into the "old → new" part of the message.
|
||||
OldDigest string
|
||||
NewDigest string
|
||||
Message string
|
||||
// Err carries the underlying error for failure events; nil otherwise.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Notifier receives container-automation events. CE has no generic notification
|
||||
// subsystem, so the only implementation is logNotifier; this interface is the
|
||||
// seam external senders plug into later.
|
||||
type Notifier interface {
|
||||
Notify(event Event)
|
||||
}
|
||||
|
||||
// logNotifier is the default Notifier: it emits each event as a structured log
|
||||
// line. It never blocks and never errors, so it is safe to call from the daemon
|
||||
// hot path.
|
||||
type logNotifier struct{}
|
||||
|
||||
// Notify logs the event with its kind and context fields. Failure events are
|
||||
// logged at warn (with the error), the rest at info.
|
||||
func (logNotifier) Notify(event Event) {
|
||||
entry := log.Info()
|
||||
if event.Kind == EventUpdateFailed {
|
||||
entry = log.Warn()
|
||||
if event.Err != nil {
|
||||
entry = entry.Err(event.Err)
|
||||
}
|
||||
}
|
||||
|
||||
entry = entry.Str("event", string(event.Kind)).Int("endpoint_id", event.EndpointID)
|
||||
if event.ContainerID != "" {
|
||||
entry = entry.Str("container_id", event.ContainerID)
|
||||
}
|
||||
if event.StackID != 0 {
|
||||
entry = entry.Int("stack_id", event.StackID)
|
||||
}
|
||||
if event.Image != "" {
|
||||
entry = entry.Str("image", event.Image)
|
||||
}
|
||||
|
||||
message := event.Message
|
||||
if message == "" {
|
||||
message = "container automation event"
|
||||
}
|
||||
|
||||
entry.Msg("container automation: " + message)
|
||||
}
|
||||
|
||||
// multiNotifier fans an event out to several notifiers in order. It is how the
|
||||
// service composes the always-on logNotifier with the optional webhookNotifier
|
||||
// without either implementation having to know about the other. Each notifier is
|
||||
// itself non-blocking, so multiNotifier stays safe on the daemon hot path.
|
||||
type multiNotifier []Notifier
|
||||
|
||||
// Notify forwards the event to every wrapped notifier. Each call is isolated by
|
||||
// a recover() so one misbehaving notifier can neither abort the others nor let a
|
||||
// panic reach the daemon hot path; logNotifier is kept first and unchanged.
|
||||
func (m multiNotifier) Notify(event Event) {
|
||||
for _, n := range m {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Warn().Interface("panic", r).Msg("container automation: recovered from panic in notifier")
|
||||
}
|
||||
}()
|
||||
|
||||
n.Notify(event)
|
||||
}()
|
||||
}
|
||||
}
|
||||
90
api/containerautomation/notify_test.go
Normal file
90
api/containerautomation/notify_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// recordingNotifier captures emitted events for assertions in tests.
|
||||
type recordingNotifier struct {
|
||||
events []Event
|
||||
}
|
||||
|
||||
func (r *recordingNotifier) Notify(event Event) {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
|
||||
func TestLogNotifierDoesNotPanic(t *testing.T) {
|
||||
n := logNotifier{}
|
||||
|
||||
// Every event kind, including a failure carrying an error, must log without
|
||||
// panicking and without requiring any optional field.
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerID: "abc", Image: "nginx:latest"})
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, StackID: 7})
|
||||
n.Notify(Event{Kind: EventRollback, EndpointID: 2, ContainerID: "def", Image: "nginx:1.0"})
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 3, ContainerID: "ghi"})
|
||||
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4, ContainerID: "jkl", Err: errors.New("boom")})
|
||||
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4}) // failure without an error
|
||||
n.Notify(Event{}) // zero value
|
||||
}
|
||||
|
||||
func TestRecordingNotifierCapturesEvents(t *testing.T) {
|
||||
r := &recordingNotifier{}
|
||||
r.Notify(Event{Kind: EventUpdated, EndpointID: 1})
|
||||
r.Notify(Event{Kind: EventRollback, EndpointID: 1})
|
||||
|
||||
if len(r.events) != 2 {
|
||||
t.Fatalf("captured %d events, want 2", len(r.events))
|
||||
}
|
||||
if r.events[0].Kind != EventUpdated || r.events[1].Kind != EventRollback {
|
||||
t.Errorf("unexpected event kinds: %v, %v", r.events[0].Kind, r.events[1].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// panicNotifier always panics, standing in for a misbehaving notifier.
|
||||
type panicNotifier struct{}
|
||||
|
||||
func (panicNotifier) Notify(Event) {
|
||||
panic("boom")
|
||||
}
|
||||
|
||||
// TestMultiNotifierIsolatesPanics verifies a panicking notifier neither aborts
|
||||
// the sibling notifiers nor lets the panic reach the caller.
|
||||
func TestMultiNotifierIsolatesPanics(t *testing.T) {
|
||||
before := &recordingNotifier{}
|
||||
after := &recordingNotifier{}
|
||||
|
||||
m := multiNotifier{before, panicNotifier{}, after}
|
||||
|
||||
// Must not panic even though a wrapped notifier does.
|
||||
m.Notify(Event{Kind: EventUpdated, EndpointID: 1})
|
||||
|
||||
if len(before.events) != 1 {
|
||||
t.Errorf("notifier before the panicking one got %d events, want 1", len(before.events))
|
||||
}
|
||||
if len(after.events) != 1 {
|
||||
t.Errorf("notifier after the panicking one got %d events, want 1 (panic must not abort the loop)", len(after.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationEnabledForEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *portainer.Endpoint
|
||||
want bool
|
||||
}{
|
||||
{name: "nil endpoint is not enabled", endpoint: nil, want: false},
|
||||
{name: "default (zero value) participates", endpoint: &portainer.Endpoint{}, want: true},
|
||||
{name: "explicitly disabled opts out", endpoint: &portainer.Endpoint{ContainerAutomationDisabled: true}, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AutomationEnabledForEndpoint(tt.endpoint); got != tt.want {
|
||||
t.Errorf("AutomationEnabledForEndpoint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
386
api/containerautomation/rollback.go
Normal file
386
api/containerautomation/rollback.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.podman.io/image/v5/docker/reference"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultRollbackTimeout bounds how long the health gate waits for a freshly
|
||||
// updated standalone container to become healthy before rolling back.
|
||||
defaultRollbackTimeout = 120 * time.Second
|
||||
// rollbackPollInterval is the delay between two health probes of the new
|
||||
// container while the rollback window is open.
|
||||
rollbackPollInterval = 3 * time.Second
|
||||
// rollbackGateBuffer is added to the rollback timeout when deriving the inspect
|
||||
// context deadline, leaving room for the final probe to complete after the
|
||||
// decision deadline elapses.
|
||||
rollbackGateBuffer = 10 * time.Second
|
||||
// startPeriodBuffer is added to a container's healthcheck start_period when it
|
||||
// is longer than the rollback timeout, so the gate waits through the whole
|
||||
// start period (during which Docker reports "starting") plus a small grace
|
||||
// before deciding. Without it a legitimately slow-starting container would be
|
||||
// rolled back while it is still initializing normally.
|
||||
startPeriodBuffer = 15 * time.Second
|
||||
// maxConsecutiveInspectErrors is how many back-to-back inspect failures the
|
||||
// health gate tolerates before declaring the update failed. A single transient
|
||||
// Docker API blip must not trigger a false rollback, so the gate keeps polling
|
||||
// and only gives up once the failures are clearly not transient.
|
||||
maxConsecutiveInspectErrors = 3
|
||||
// updateRollbackCooldown is how long a standalone container whose update was
|
||||
// rolled back is skipped from updating to the SAME failed image again. It
|
||||
// breaks the update->rollback loop: without it a persistently-unhealthy new
|
||||
// image would be re-pulled and rolled back on every poll tick. A genuinely new
|
||||
// upstream image (a changed remote digest) is not blocked; the cooldown only
|
||||
// suppresses the exact target that just failed. It is generous because a broken
|
||||
// upstream image is normally fixed by a new push, which lifts the skip at once.
|
||||
updateRollbackCooldown = 24 * time.Hour
|
||||
)
|
||||
|
||||
// rolledBackTarget records that a standalone container's update to a specific
|
||||
// remote image was rolled back, so the same target is skipped until the cooldown
|
||||
// elapses or the upstream digest changes.
|
||||
type rolledBackTarget struct {
|
||||
// ref is the container's original image reference (the re-tag target), used to
|
||||
// re-resolve the current remote digest on later ticks.
|
||||
ref string
|
||||
// digest is the remote image digest that failed the health gate. A later tick
|
||||
// resolving a DIFFERENT digest (a new upstream push) is allowed through; the
|
||||
// same digest is skipped until the cooldown elapses. Empty when it could not be
|
||||
// resolved at rollback time, in which case the guard skips conservatively.
|
||||
digest string
|
||||
// at is when the rollback happened; the cooldown is measured from it.
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// decideUpdateSkip is the pure core of the update->rollback loop guard: given a
|
||||
// recorded rolled-back target and the freshly-resolved current remote digest, it
|
||||
// reports whether the standalone update must be skipped this tick. The skip holds
|
||||
// only while the cooldown is open AND the remote still points at the same failed
|
||||
// image; once the cooldown elapses the skip is lifted. An unknown recorded digest
|
||||
// is skipped conservatively (we cannot prove the target changed). Mirrors the
|
||||
// decideRestart pattern so it is unit-testable without Docker.
|
||||
func decideUpdateSkip(rec rolledBackTarget, currentDigest string, now time.Time, cooldown time.Duration) bool {
|
||||
if now.Sub(rec.at) >= cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
if rec.digest == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return currentDigest == rec.digest
|
||||
}
|
||||
|
||||
// rollbackOutcome is the decision produced from a single health sample.
|
||||
type rollbackOutcome int
|
||||
|
||||
const (
|
||||
// rollbackContinue: still starting and before the deadline, keep polling.
|
||||
rollbackContinue rollbackOutcome = iota
|
||||
// rollbackHealthy: the new container is healthy, accept the update.
|
||||
rollbackHealthy
|
||||
// rollbackTrigger: the new container failed the health gate, roll back.
|
||||
rollbackTrigger
|
||||
)
|
||||
|
||||
// gateResult is the terminal outcome of healthGate. It is a tri-state because a
|
||||
// shutdown mid-gate must be distinguished from a genuine failure: only a real
|
||||
// unhealthy/not-running/deadline outcome may roll back.
|
||||
type gateResult int
|
||||
|
||||
const (
|
||||
// gateHealthy: the new container became healthy in time, accept the update.
|
||||
gateHealthy gateResult = iota
|
||||
// gateRollback: the new container failed the gate, roll back to the old image.
|
||||
gateRollback
|
||||
// gateAborted: the service base context was cancelled (server shutdown) while
|
||||
// the gate was open. The new container is left running as-is; no rollback and
|
||||
// no failure event, since we never observed an actual failure.
|
||||
gateAborted
|
||||
)
|
||||
|
||||
// imageIDReference matches a content-addressable image id carried verbatim in a
|
||||
// container's Config.Image when it was started from a bare id (e.g.
|
||||
// "sha256:ab12…"). Such an id is not a tag and cannot be re-tagged, so it must
|
||||
// not enable the health gate. A full bare hex id (no algorithm prefix) is
|
||||
// already rejected by reference.ParseNormalizedNamed; this catches the
|
||||
// algorithm-prefixed digest form, which otherwise parses as a bogus tag.
|
||||
var imageIDReference = regexp.MustCompile(`^[a-z0-9]+:[0-9a-f]{64}$`)
|
||||
|
||||
// containerHealth is the minimal health signal the gate polls. It is built from
|
||||
// a container inspect but kept independent of the Docker SDK so the decision
|
||||
// logic can be unit-tested without a Docker engine.
|
||||
type containerHealth struct {
|
||||
// Running reports whether the container is currently running. A container that
|
||||
// has exited within the window is a failed update.
|
||||
Running bool
|
||||
// Status is the Docker health status: "starting", "healthy", "unhealthy" or
|
||||
// "none"/"" when there is no healthcheck.
|
||||
Status string
|
||||
}
|
||||
|
||||
// decideRollback is a pure decision over a single health sample taken at time
|
||||
// `now`, given the rollback `deadline`. It is the testable core of the health
|
||||
// gate: callers feed it successive samples and act on the outcome.
|
||||
//
|
||||
// Rules, in order:
|
||||
// - healthy -> accept the update (rollbackHealthy);
|
||||
// - unhealthy -> roll back immediately (Docker only reports unhealthy after the
|
||||
// configured retries fail, so it is a definitive signal);
|
||||
// - not running (crashed/exited post-start) -> roll back;
|
||||
// - still starting past the deadline -> roll back (never became healthy in time);
|
||||
// - otherwise keep waiting (rollbackContinue).
|
||||
func decideRollback(h containerHealth, now, deadline time.Time) rollbackOutcome {
|
||||
switch h.Status {
|
||||
case string(container.Healthy):
|
||||
return rollbackHealthy
|
||||
case string(container.Unhealthy):
|
||||
return rollbackTrigger
|
||||
}
|
||||
|
||||
if !h.Running {
|
||||
return rollbackTrigger
|
||||
}
|
||||
|
||||
if !now.Before(deadline) {
|
||||
return rollbackTrigger
|
||||
}
|
||||
|
||||
return rollbackContinue
|
||||
}
|
||||
|
||||
// effectiveRollbackDeadline derives the health-gate deadline from the gate start
|
||||
// time, the configured rollback timeout, and the container's healthcheck
|
||||
// start_period. While a container is within its start_period Docker keeps
|
||||
// reporting "starting" (it never reports unhealthy yet), so a start_period
|
||||
// longer than the rollback timeout would otherwise trip a premature rollback
|
||||
// while the container is initializing normally. The deadline is therefore the
|
||||
// later of (start + timeout) and (start + start_period + buffer).
|
||||
func effectiveRollbackDeadline(start time.Time, timeout, startPeriod time.Duration) time.Time {
|
||||
window := timeout
|
||||
if startPeriod > 0 {
|
||||
if d := startPeriod + startPeriodBuffer; d > window {
|
||||
window = d
|
||||
}
|
||||
}
|
||||
|
||||
return start.Add(window)
|
||||
}
|
||||
|
||||
// inspectErrorTolerated reports whether the health gate should keep polling after
|
||||
// `consecutive` back-to-back inspect failures rather than declaring the update
|
||||
// failed. Up to maxConsecutiveInspectErrors transient errors are tolerated; the
|
||||
// counter is reset by the caller on any successful inspect.
|
||||
func inspectErrorTolerated(consecutive int) bool {
|
||||
return consecutive <= maxConsecutiveInspectErrors
|
||||
}
|
||||
|
||||
// hasHealthGate reports whether a container's healthcheck config yields a usable
|
||||
// health signal. A nil config, an empty test, or an explicit {"NONE"} disable all
|
||||
// mean Docker never reports healthy/unhealthy, so there is nothing to gate on.
|
||||
func hasHealthGate(hc *container.HealthConfig) bool {
|
||||
if hc == nil || len(hc.Test) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return hc.Test[0] != "NONE"
|
||||
}
|
||||
|
||||
// isTagReference reports whether ref is a proper tag reference that the health
|
||||
// gate can roll back. Rolling back re-tags the previous image id onto ref via
|
||||
// ImageTag, which Docker rejects for a digest-pinned reference (repo@sha256:…)
|
||||
// with "refusing to create a tag with a digest reference", and which is
|
||||
// meaningless for a bare image id. Such containers are detected here so the gate
|
||||
// is skipped instead of silently no-op'ing.
|
||||
func isTagReference(ref string) bool {
|
||||
if ref == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Algorithm-prefixed image id (e.g. "sha256:<64 hex>"): a bare id, not a tag.
|
||||
if imageIDReference.MatchString(ref) {
|
||||
return false
|
||||
}
|
||||
|
||||
named, err := reference.ParseNormalizedNamed(ref)
|
||||
if err != nil {
|
||||
// Unparseable (e.g. a full bare hex image id): not a usable tag target.
|
||||
return false
|
||||
}
|
||||
|
||||
// A digest-pinned reference (with or without a tag) cannot be re-tagged.
|
||||
if _, ok := named.(reference.Canonical); ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// healthGate polls the new container's health until it becomes healthy, fails, or
|
||||
// the rollback window elapses, returning the terminal gateResult.
|
||||
//
|
||||
// The polling context is derived from the service base context, so a server
|
||||
// shutdown ends the wait. A shutdown is reported as gateAborted (leave the new
|
||||
// container in place, do not roll back): we never observed a real failure, and a
|
||||
// rollback derived from the cancelled context would itself fail and emit a
|
||||
// misleading "rollback failed" event on every shutdown during a gate window.
|
||||
//
|
||||
// Transient inspect failures (a brief Docker API blip) are tolerated: the gate
|
||||
// keeps polling and only declares the update failed after more than
|
||||
// maxConsecutiveInspectErrors consecutive failures, resetting on any success.
|
||||
//
|
||||
// Scheduling note (known limitation): this poll runs inside the sequential update
|
||||
// tick, so N unhealthy standalone containers with rollback enabled can each hold
|
||||
// the tick for up to their rollback window, delaying other containers/endpoints
|
||||
// in the same tick. The overlap guard in update() still prevents ticks from
|
||||
// piling up; this is accepted rather than re-architected (no per-container
|
||||
// goroutine) to keep the update path simple and ordered.
|
||||
func (s *Service) healthGate(cli dockerClient, containerID string, timeout, startPeriod time.Duration) gateResult {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultRollbackTimeout
|
||||
}
|
||||
|
||||
deadline := effectiveRollbackDeadline(time.Now(), timeout, startPeriod)
|
||||
|
||||
ctx, cancel := context.WithDeadline(s.baseCtx, deadline.Add(rollbackGateBuffer))
|
||||
defer cancel()
|
||||
|
||||
consecutiveErrors := 0
|
||||
for {
|
||||
inspect, err := cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
// Server shutdown cancelled the base context: abort without rolling back.
|
||||
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(s.baseCtx.Err(), context.Canceled) {
|
||||
log.Debug().Str("container_id", containerID).
|
||||
Msg("auto-update: health gate aborted due to shutdown")
|
||||
|
||||
return gateAborted
|
||||
}
|
||||
|
||||
consecutiveErrors++
|
||||
if !inspectErrorTolerated(consecutiveErrors) {
|
||||
// Repeated failures: the container vanished or the engine is
|
||||
// unreachable, treat as a failed update so the rollback can restore
|
||||
// the previous image.
|
||||
log.Warn().Err(err).Str("container_id", containerID).Int("consecutive_errors", consecutiveErrors).
|
||||
Msg("auto-update: health gate inspect failed repeatedly, treating as unhealthy")
|
||||
|
||||
return gateRollback
|
||||
}
|
||||
|
||||
// Tolerate a transient blip: keep polling until the data resolves or the
|
||||
// deadline passes.
|
||||
log.Debug().Err(err).Str("container_id", containerID).Int("consecutive_errors", consecutiveErrors).
|
||||
Msg("auto-update: health gate inspect failed, retrying (transient)")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return s.gateDeadlineResult()
|
||||
case <-time.After(rollbackPollInterval):
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
consecutiveErrors = 0
|
||||
|
||||
h := containerHealth{Running: inspect.State != nil && inspect.State.Running}
|
||||
if inspect.State != nil && inspect.State.Health != nil {
|
||||
h.Status = string(inspect.State.Health.Status)
|
||||
}
|
||||
|
||||
switch decideRollback(h, time.Now(), deadline) {
|
||||
case rollbackHealthy:
|
||||
return gateHealthy
|
||||
case rollbackTrigger:
|
||||
return gateRollback
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return s.gateDeadlineResult()
|
||||
case <-time.After(rollbackPollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gateDeadlineResult maps a context-done gate exit to its outcome: a base-context
|
||||
// cancellation (shutdown) aborts without rolling back, while a plain deadline
|
||||
// (the container never became healthy in time) rolls back.
|
||||
func (s *Service) gateDeadlineResult() gateResult {
|
||||
if errors.Is(s.baseCtx.Err(), context.Canceled) {
|
||||
log.Debug().Msg("auto-update: health gate aborted due to shutdown")
|
||||
|
||||
return gateAborted
|
||||
}
|
||||
|
||||
return gateRollback
|
||||
}
|
||||
|
||||
// rollback restores the previous image after a failed health-gated update. It
|
||||
// re-tags the old image id back onto the container's original reference (which
|
||||
// the new image currently owns), then recreates the new container on that
|
||||
// reference with no pull, so Recreate's full config-preservation + create-failure
|
||||
// rollback is reused while resolving to the old image.
|
||||
//
|
||||
// Side effect: re-tagging moves `originalRef` from the new image to the old one,
|
||||
// leaving the new (unhealthy) image untagged/dangling. It is intentionally left
|
||||
// in place (not pruned) so an operator can inspect why the update failed.
|
||||
//
|
||||
// If any step fails the previous image cannot be safely restored, so the
|
||||
// (unhealthy) new container is left running rather than destroyed, and a loud
|
||||
// failure notification is emitted.
|
||||
func (s *Service) rollback(cli dockerClient, endpoint *portainer.Endpoint, newContainerID, oldImageID, originalRef, containerName string) {
|
||||
endpointID := int(endpoint.ID)
|
||||
|
||||
log.Warn().Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: new container failed the health gate, rolling back to the previous image")
|
||||
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, recreateTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Re-tag the previous image id back onto the original reference. After the
|
||||
// update the reference points at the new image; this moves it back so Recreate
|
||||
// resolves the old image without a pull.
|
||||
if err := cli.ImageTag(ctx, oldImageID, originalRef); err != nil {
|
||||
log.Error().Err(err).Str("image_id", oldImageID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: rollback failed to re-tag the previous image, leaving the unhealthy container in place")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID, ContainerName: containerName,
|
||||
Image: originalRef, Message: "rollback failed: could not re-tag previous image", Err: err,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.containerService.Recreate(ctx, endpoint, newContainerID, false, "", ""); err != nil {
|
||||
log.Error().Err(err).Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: rollback recreate failed, leaving the unhealthy container in place")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID, ContainerName: containerName,
|
||||
Image: originalRef, Message: "rollback failed: could not recreate on previous image", Err: err,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
|
||||
Msg("auto-update: rolled back to the previous image after a failed update")
|
||||
s.notifier.Notify(Event{
|
||||
Kind: EventRollback, EndpointID: endpointID, ContainerID: newContainerID, ContainerName: containerName,
|
||||
Image: originalRef, Message: "rolled back to previous image after failed health check",
|
||||
})
|
||||
|
||||
// Record the failed target so the next poll does not immediately re-pull the
|
||||
// same broken image and roll back again (the update->rollback loop). Recorded
|
||||
// only after a SUCCESSFUL rollback; a changed remote digest later lifts the skip.
|
||||
s.recordRolledBack(endpoint, containerName, originalRef)
|
||||
}
|
||||
333
api/containerautomation/rollback_test.go
Normal file
333
api/containerautomation/rollback_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
func TestDecideRollback(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
deadline := now.Add(120 * time.Second)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
health containerHealth
|
||||
at time.Time
|
||||
want rollbackOutcome
|
||||
}{
|
||||
{
|
||||
name: "healthy within the window accepts the update",
|
||||
health: containerHealth{Running: true, Status: string(container.Healthy)},
|
||||
at: now.Add(10 * time.Second),
|
||||
want: rollbackHealthy,
|
||||
},
|
||||
{
|
||||
name: "unhealthy triggers an immediate rollback",
|
||||
health: containerHealth{Running: true, Status: string(container.Unhealthy)},
|
||||
at: now.Add(10 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "still starting before the deadline keeps polling",
|
||||
health: containerHealth{Running: true, Status: string(container.Starting)},
|
||||
at: now.Add(10 * time.Second),
|
||||
want: rollbackContinue,
|
||||
},
|
||||
{
|
||||
name: "still starting past the deadline rolls back",
|
||||
health: containerHealth{Running: true, Status: string(container.Starting)},
|
||||
at: now.Add(121 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "starting exactly at the deadline rolls back",
|
||||
health: containerHealth{Running: true, Status: string(container.Starting)},
|
||||
at: deadline,
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "exited container rolls back even before the deadline",
|
||||
health: containerHealth{Running: false, Status: string(container.Starting)},
|
||||
at: now.Add(5 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "unhealthy wins over a stopped state",
|
||||
health: containerHealth{Running: false, Status: string(container.Unhealthy)},
|
||||
at: now.Add(5 * time.Second),
|
||||
want: rollbackTrigger,
|
||||
},
|
||||
{
|
||||
name: "healthy wins even past the deadline",
|
||||
health: containerHealth{Running: true, Status: string(container.Healthy)},
|
||||
at: now.Add(200 * time.Second),
|
||||
want: rollbackHealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := decideRollback(tt.health, tt.at, deadline); got != tt.want {
|
||||
t.Errorf("decideRollback() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRollbackDeadline(t *testing.T) {
|
||||
start := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
timeout := 120 * time.Second
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startPeriod time.Duration
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "no start period uses the timeout",
|
||||
startPeriod: 0,
|
||||
want: start.Add(timeout),
|
||||
},
|
||||
{
|
||||
name: "start period shorter than timeout uses the timeout",
|
||||
startPeriod: 30 * time.Second,
|
||||
want: start.Add(timeout),
|
||||
},
|
||||
{
|
||||
name: "start period longer than timeout extends to start period plus buffer",
|
||||
startPeriod: 300 * time.Second,
|
||||
want: start.Add(300*time.Second + startPeriodBuffer),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := effectiveRollbackDeadline(start, timeout, tt.startPeriod); !got.Equal(tt.want) {
|
||||
t.Errorf("effectiveRollbackDeadline() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideRollbackWithLongStartPeriod proves the F3 fix end to end at the
|
||||
// decision layer: with a start_period longer than the configured rollback
|
||||
// timeout, the start-period-aware deadline keeps a still-starting container
|
||||
// alive while it is within the start period, and only rolls back after it.
|
||||
func TestDecideRollbackWithLongStartPeriod(t *testing.T) {
|
||||
start := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
timeout := 60 * time.Second
|
||||
startPeriod := 300 * time.Second
|
||||
|
||||
deadline := effectiveRollbackDeadline(start, timeout, startPeriod)
|
||||
|
||||
starting := containerHealth{Running: true, Status: string(container.Starting)}
|
||||
|
||||
// Past the bare timeout but still within the start period: keep waiting.
|
||||
if got := decideRollback(starting, start.Add(120*time.Second), deadline); got != rollbackContinue {
|
||||
t.Errorf("within start_period: decideRollback() = %v, want rollbackContinue", got)
|
||||
}
|
||||
|
||||
// After the start period (plus buffer): roll back.
|
||||
if got := decideRollback(starting, start.Add(330*time.Second), deadline); got != rollbackTrigger {
|
||||
t.Errorf("after start_period: decideRollback() = %v, want rollbackTrigger", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectErrorTolerated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
consecutive int
|
||||
want bool
|
||||
}{
|
||||
{name: "first transient error is tolerated", consecutive: 1, want: true},
|
||||
{name: "second consecutive error is tolerated", consecutive: 2, want: true},
|
||||
{name: "at the threshold is still tolerated", consecutive: maxConsecutiveInspectErrors, want: true},
|
||||
{name: "beyond the threshold is a failure", consecutive: maxConsecutiveInspectErrors + 1, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := inspectErrorTolerated(tt.consecutive); got != tt.want {
|
||||
t.Errorf("inspectErrorTolerated(%d) = %v, want %v", tt.consecutive, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTagReference(t *testing.T) {
|
||||
const digest = "sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{name: "tagged reference is rollbackable", ref: "nginx:1.21", want: true},
|
||||
{name: "untagged reference (implicit latest) is rollbackable", ref: "nginx", want: true},
|
||||
{name: "fully-qualified tagged reference is rollbackable", ref: "registry.example.com/team/app:v2", want: true},
|
||||
{name: "digest-pinned reference cannot be re-tagged", ref: "nginx@" + digest, want: false},
|
||||
{name: "tagged-and-digest-pinned reference cannot be re-tagged", ref: "nginx:1.21@" + digest, want: false},
|
||||
{name: "algorithm-prefixed bare image id cannot be re-tagged", ref: digest, want: false},
|
||||
{name: "full bare hex image id cannot be re-tagged", ref: "02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", want: false},
|
||||
{name: "empty reference is not rollbackable", ref: "", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isTagReference(tt.ref); got != tt.want {
|
||||
t.Errorf("isTagReference(%q) = %v, want %v", tt.ref, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipUnnamedForRollback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rollback bool
|
||||
cName string
|
||||
want bool
|
||||
}{
|
||||
{name: "rollback on, unnamed -> skip (unsuppressable loop otherwise)", rollback: true, cName: "", want: true},
|
||||
{name: "rollback on, named -> proceed (guard can key it)", rollback: true, cName: "web", want: false},
|
||||
{name: "rollback off, unnamed -> proceed (no rollback to loop)", rollback: false, cName: "", want: false},
|
||||
{name: "rollback off, named -> proceed", rollback: false, cName: "web", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := skipUnnamedForRollback(tt.rollback, tt.cName); got != tt.want {
|
||||
t.Errorf("skipUnnamedForRollback(%v, %q) = %v, want %v", tt.rollback, tt.cName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasHealthGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hc *container.HealthConfig
|
||||
want bool
|
||||
}{
|
||||
{name: "nil config has no gate", hc: nil, want: false},
|
||||
{name: "empty test inherits, no usable gate", hc: &container.HealthConfig{Test: nil}, want: false},
|
||||
{name: "explicit NONE disables the gate", hc: &container.HealthConfig{Test: []string{"NONE"}}, want: false},
|
||||
{name: "CMD healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD", "curl", "-f", "localhost"}}, want: true},
|
||||
{name: "CMD-SHELL healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD-SHELL", "exit 0"}}, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasHealthGate(tt.hc); got != tt.want {
|
||||
t.Errorf("hasHealthGate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRollbackTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "valid duration", raw: "90s", want: 90 * time.Second},
|
||||
{name: "empty falls back to default", raw: "", want: defaultRollbackTimeout},
|
||||
{name: "unparseable falls back to default", raw: "nope", want: defaultRollbackTimeout},
|
||||
{name: "zero falls back to default", raw: "0s", want: defaultRollbackTimeout},
|
||||
{name: "negative falls back to default", raw: "-5s", want: defaultRollbackTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := parseRollbackTimeout(tt.raw); got != tt.want {
|
||||
t.Errorf("parseRollbackTimeout(%q) = %v, want %v", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideUpdateSkip(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
cooldown := 24 * time.Hour
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rec rolledBackTarget
|
||||
currentDigest string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same digest within cooldown is skipped",
|
||||
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-1 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "new digest within cooldown is not skipped",
|
||||
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-1 * time.Hour)},
|
||||
currentDigest: "sha256:bbb",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "same digest after cooldown is not skipped",
|
||||
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-25 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown recorded digest is skipped conservatively within cooldown",
|
||||
rec: rolledBackTarget{digest: "", at: now.Add(-1 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unknown recorded digest after cooldown is not skipped",
|
||||
rec: rolledBackTarget{digest: "", at: now.Add(-25 * time.Hour)},
|
||||
currentDigest: "sha256:aaa",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := decideUpdateSkip(tt.rec, tt.currentDigest, now, cooldown); got != tt.want {
|
||||
t.Errorf("decideUpdateSkip() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneRolledBack locks in the F8 fix: pruneRolledBack must iterate the
|
||||
// rolledBack map and drop only entries whose cooldown has fully elapsed, keeping
|
||||
// fresh ones, so the map cannot grow unbounded. It mirrors TestPruneRetries. The
|
||||
// boundary is inclusive (production uses now.Sub(at) >= updateRollbackCooldown),
|
||||
// so an entry exactly at the cooldown is pruned.
|
||||
func TestPruneRolledBack(t *testing.T) {
|
||||
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
||||
s := &Service{rolledBack: map[string]rolledBackTarget{
|
||||
// within the cooldown -> retained
|
||||
"fresh": {ref: "img:fresh", digest: "sha256:aaa", at: now.Add(-updateRollbackCooldown / 2)},
|
||||
// exactly at the cooldown boundary -> pruned (>= is inclusive)
|
||||
"edge": {ref: "img:edge", digest: "sha256:bbb", at: now.Add(-updateRollbackCooldown)},
|
||||
// long past the cooldown -> pruned
|
||||
"stale": {ref: "img:stale", digest: "sha256:ccc", at: now.Add(-2 * updateRollbackCooldown)},
|
||||
}}
|
||||
|
||||
s.pruneRolledBack(now)
|
||||
|
||||
if _, ok := s.rolledBack["fresh"]; !ok {
|
||||
t.Error("entry within the rollback cooldown should be retained")
|
||||
}
|
||||
if _, ok := s.rolledBack["edge"]; ok {
|
||||
t.Error("entry exactly at the cooldown boundary should be pruned")
|
||||
}
|
||||
if _, ok := s.rolledBack["stale"]; ok {
|
||||
t.Error("entry past the rollback cooldown should be pruned")
|
||||
}
|
||||
if len(s.rolledBack) != 1 {
|
||||
t.Errorf("rolledBack length = %d, want 1", len(s.rolledBack))
|
||||
}
|
||||
}
|
||||
40
api/containerautomation/seams.go
Normal file
40
api/containerautomation/seams.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
)
|
||||
|
||||
// dockerClient is the minimal subset of the Docker SDK client
|
||||
// (*github.com/docker/docker/client.Client) that the auto-update and auto-heal
|
||||
// apply paths call. Threading this interface — instead of the concrete client —
|
||||
// through the daemon paths lets their wiring/sequencing be exercised with a fake
|
||||
// in tests, while the real client returned by ClientFactory.CreateClient
|
||||
// satisfies it unchanged at the call sites.
|
||||
type dockerClient interface {
|
||||
// ContainerInspect backs the pre-update image-identity capture
|
||||
// (updateStandalone), the health-gate poll (healthGate) and the post-redeploy
|
||||
// new-image re-inspect (inspectImageID).
|
||||
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
|
||||
// ContainerRestart backs the auto-heal restart of an unhealthy container.
|
||||
ContainerRestart(ctx context.Context, containerID string, options container.StopOptions) error
|
||||
// ImageTag backs the rollback re-tag of the previous image onto the original ref.
|
||||
ImageTag(ctx context.Context, source, target string) error
|
||||
// ImageRemove backs the conservative cleanup of the dangling old image.
|
||||
ImageRemove(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error)
|
||||
}
|
||||
|
||||
// containerRecreator is the minimal seam over *docker.ContainerService used by the
|
||||
// standalone update and rollback paths to recreate a container (pull + stop +
|
||||
// create + start). The concrete *docker.ContainerService satisfies it; threading
|
||||
// the interface lets the recreate step be faked so the surrounding sequencing
|
||||
// (recreate -> health gate -> cleanup / rollback) is testable without a live
|
||||
// engine.
|
||||
type containerRecreator interface {
|
||||
Recreate(ctx context.Context, endpoint *portainer.Endpoint, containerID string, forcePullImage bool, imageTag, nodeName string) (*types.ContainerJSON, error)
|
||||
}
|
||||
321
api/containerautomation/service.go
Normal file
321
api/containerautomation/service.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Package containerautomation provides native container automation that runs as
|
||||
// background scheduler jobs. M1 implements auto-heal (restarting Docker
|
||||
// containers whose healthcheck reports "unhealthy", replacing the
|
||||
// willfarrell/autoheal sidecar); M4 adds auto-update (periodically detecting
|
||||
// outdated images and applying updates, replacing the containrrr/watchtower
|
||||
// sidecar).
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultCheckInterval is used when the configured auto-heal interval is empty or unparseable.
|
||||
defaultCheckInterval = 30 * time.Second
|
||||
// defaultPollInterval is used when the configured auto-update interval is empty or unparseable.
|
||||
// It is conservative (hours) to stay within registry rate limits; the image-status cache is
|
||||
// short-lived (keyed by the local imageID), so each poll re-checks the remote digest.
|
||||
defaultPollInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
// Service manages the lifecycle of the auto-heal and auto-update scheduler jobs
|
||||
// and keeps the per-container retry state in memory across ticks.
|
||||
type Service struct {
|
||||
// baseCtx is the application shutdown context. It is the base for every
|
||||
// per-operation timeout context, so a server shutdown cancels in-flight heal
|
||||
// restarts and update redeploys instead of letting them run detached.
|
||||
baseCtx context.Context
|
||||
|
||||
scheduler *scheduler.Scheduler
|
||||
dataStore dataservices.DataStore
|
||||
clientFactory *dockerclient.ClientFactory
|
||||
|
||||
// Dependencies used by the auto-update job (M4).
|
||||
digestClient *images.DigestClient
|
||||
// containerService is the recreate seam (satisfied by *docker.ContainerService);
|
||||
// an interface so the standalone update/rollback recreate step can be faked in
|
||||
// tests. See containerRecreator.
|
||||
containerService containerRecreator
|
||||
stackDeployer deployments.StackDeployer
|
||||
|
||||
// notifier receives automation events (update/rollback/failure/heal). The
|
||||
// default is logNotifier; the field is the seam external senders plug into.
|
||||
notifier Notifier
|
||||
|
||||
mu sync.Mutex
|
||||
healJobID string
|
||||
updateJobID string
|
||||
|
||||
// running guards against overlapping heal ticks.
|
||||
running atomic.Bool
|
||||
// updateRunning guards against overlapping update ticks.
|
||||
updateRunning atomic.Bool
|
||||
|
||||
retryMu sync.Mutex
|
||||
retries map[string]retryState
|
||||
|
||||
// rolledBackMu guards rolledBack.
|
||||
rolledBackMu sync.Mutex
|
||||
// rolledBack records standalone containers whose update was rolled back, keyed
|
||||
// by endpoint+name, so the auto-update job does not immediately re-pull the
|
||||
// same failed image and roll back again on the next tick (the update->rollback
|
||||
// loop guard, mirroring the auto-heal retries map).
|
||||
//
|
||||
// This state is in-memory only and is NOT persisted: after a Portainer restart
|
||||
// the map is empty, so at most one extra update->rollback cycle per restart is
|
||||
// possible before the guard re-records the failed target. Persisting it would
|
||||
// require a datastore schema (key + digest + timestamp) and is intentionally out
|
||||
// of scope here; the cooldown-bounded single extra cycle is an acceptable
|
||||
// trade-off against that complexity.
|
||||
rolledBack map[string]rolledBackTarget
|
||||
}
|
||||
|
||||
// NewService creates a new container automation service. Call Start to schedule
|
||||
// the jobs according to the persisted settings. baseCtx is the application
|
||||
// shutdown context: it bounds the job operation contexts so a shutdown cancels
|
||||
// any in-flight heal/update. The stackDeployer and containerService are used by
|
||||
// the auto-update job; they may be nil only in tests that do not exercise
|
||||
// auto-update.
|
||||
func NewService(
|
||||
baseCtx context.Context,
|
||||
scheduler *scheduler.Scheduler,
|
||||
dataStore dataservices.DataStore,
|
||||
clientFactory *dockerclient.ClientFactory,
|
||||
containerService *docker.ContainerService,
|
||||
stackDeployer deployments.StackDeployer,
|
||||
) *Service {
|
||||
if baseCtx == nil {
|
||||
baseCtx = context.Background()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
baseCtx: baseCtx,
|
||||
scheduler: scheduler,
|
||||
dataStore: dataStore,
|
||||
clientFactory: clientFactory,
|
||||
digestClient: images.NewClientWithRegistry(images.NewRegistryClient(dataStore), clientFactory),
|
||||
containerService: containerService,
|
||||
stackDeployer: stackDeployer,
|
||||
// Compose the always-on log notifier with the optional webhook notifier.
|
||||
// The webhook reads the current settings per-event from the datastore, so a
|
||||
// URL change in the UI takes effect without a restart; logNotifier keeps the
|
||||
// existing structured log output unchanged.
|
||||
notifier: multiNotifier{logNotifier{}, newWebhookNotifier(dataStore)},
|
||||
retries: make(map[string]retryState),
|
||||
rolledBack: make(map[string]rolledBackTarget),
|
||||
}
|
||||
}
|
||||
|
||||
// AutomationEnabledForEndpoint reports whether container automation (auto-heal and
|
||||
// auto-update) should run for an environment. It is the per-endpoint opt-out (M5)
|
||||
// layered on top of the global switch: an environment participates unless it has
|
||||
// been explicitly disabled. The zero value (not disabled) preserves the
|
||||
// pre-M5 behavior for every existing environment.
|
||||
func AutomationEnabledForEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint != nil && !endpoint.ContainerAutomationDisabled
|
||||
}
|
||||
|
||||
// Start schedules the enabled jobs according to the persisted settings.
|
||||
func (s *Service) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.start()
|
||||
}
|
||||
|
||||
// Reload re-applies the current settings: it stops the running jobs and starts
|
||||
// fresh ones with the new intervals, or leaves them stopped if disabled. It is
|
||||
// safe to call after a settings update.
|
||||
//
|
||||
// Note: stopping a job unschedules future ticks but does not interrupt a tick
|
||||
// already in progress. An in-flight heal/update pass runs to completion on its
|
||||
// original (pre-reload) context and is only cancelled by a server shutdown (via
|
||||
// baseCtx); the new interval takes effect from the next scheduled tick. The
|
||||
// overlap guards (running/updateRunning) and the per-map mutexes keep this safe
|
||||
// against data races, so this is a deliberate behavioural nuance, not a bug.
|
||||
func (s *Service) Reload() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.stop()
|
||||
s.start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// start (re)schedules the enabled jobs from settings. Caller must hold s.mu.
|
||||
func (s *Service) start() {
|
||||
settings, err := s.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("container automation: unable to read settings, jobs not scheduled")
|
||||
return
|
||||
}
|
||||
|
||||
s.startHeal(settings)
|
||||
s.startUpdate(settings)
|
||||
}
|
||||
|
||||
// startHeal schedules the auto-heal job if enabled. Caller must hold s.mu.
|
||||
func (s *Service) startHeal(settings *portainer.Settings) {
|
||||
if s.healJobID != "" {
|
||||
return
|
||||
}
|
||||
|
||||
autoHeal := settings.ContainerAutomation.AutoHeal
|
||||
if !autoHeal.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(autoHeal.CheckInterval)
|
||||
if err != nil || interval <= 0 {
|
||||
log.Warn().Str("interval", autoHeal.CheckInterval).Dur("default", defaultCheckInterval).
|
||||
Msg("auto-heal: invalid check interval, falling back to default")
|
||||
interval = defaultCheckInterval
|
||||
}
|
||||
|
||||
s.healJobID = s.scheduler.StartJobEvery(interval, s.heal)
|
||||
log.Info().Dur("interval", interval).Msg("auto-heal: job scheduled")
|
||||
}
|
||||
|
||||
// startUpdate schedules the auto-update job if enabled. Caller must hold s.mu.
|
||||
func (s *Service) startUpdate(settings *portainer.Settings) {
|
||||
if s.updateJobID != "" {
|
||||
return
|
||||
}
|
||||
|
||||
autoUpdate := settings.ContainerAutomation.AutoUpdate
|
||||
if !autoUpdate.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(autoUpdate.PollInterval)
|
||||
if err != nil || interval <= 0 {
|
||||
log.Warn().Str("interval", autoUpdate.PollInterval).Dur("default", defaultPollInterval).
|
||||
Msg("auto-update: invalid poll interval, falling back to default")
|
||||
interval = defaultPollInterval
|
||||
}
|
||||
|
||||
s.updateJobID = s.scheduler.StartJobEvery(interval, s.update)
|
||||
log.Info().Dur("interval", interval).Msg("auto-update: job scheduled")
|
||||
}
|
||||
|
||||
// stop cancels the running jobs, if any. Caller must hold s.mu.
|
||||
func (s *Service) stop() {
|
||||
if s.healJobID != "" {
|
||||
if err := s.scheduler.StopJob(s.healJobID); err != nil {
|
||||
log.Warn().Err(err).Msg("auto-heal: could not stop the job")
|
||||
}
|
||||
|
||||
s.healJobID = ""
|
||||
}
|
||||
|
||||
if s.updateJobID != "" {
|
||||
if err := s.scheduler.StopJob(s.updateJobID); err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: could not stop the job")
|
||||
}
|
||||
|
||||
s.updateJobID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// scope returns the configured auto-heal scope, defaulting to "labeled".
|
||||
func (s *Service) scope() string {
|
||||
settings, err := s.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return ScopeLabeled
|
||||
}
|
||||
|
||||
if settings.ContainerAutomation.AutoHeal.Scope == ScopeAll {
|
||||
return ScopeAll
|
||||
}
|
||||
|
||||
return ScopeLabeled
|
||||
}
|
||||
|
||||
// getRetry returns the retry state for a container (zero value if unknown).
|
||||
func (s *Service) getRetry(containerID string) retryState {
|
||||
s.retryMu.Lock()
|
||||
defer s.retryMu.Unlock()
|
||||
|
||||
return s.retries[containerID]
|
||||
}
|
||||
|
||||
// setRetry stores the retry state for a container.
|
||||
func (s *Service) setRetry(containerID string, state retryState) {
|
||||
s.retryMu.Lock()
|
||||
defer s.retryMu.Unlock()
|
||||
|
||||
s.retries[containerID] = state
|
||||
}
|
||||
|
||||
// getRolledBack returns the rolled-back target for a key and whether it exists.
|
||||
func (s *Service) getRolledBack(key string) (rolledBackTarget, bool) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
rec, ok := s.rolledBack[key]
|
||||
|
||||
return rec, ok
|
||||
}
|
||||
|
||||
// setRolledBack records a rolled-back target for a key.
|
||||
func (s *Service) setRolledBack(key string, rec rolledBackTarget) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
s.rolledBack[key] = rec
|
||||
}
|
||||
|
||||
// clearRolledBack drops the rolled-back record for a key (cooldown elapsed or a
|
||||
// new upstream image lifted the skip).
|
||||
func (s *Service) clearRolledBack(key string) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
delete(s.rolledBack, key)
|
||||
}
|
||||
|
||||
// pruneRolledBack drops rolled-back records whose cooldown has fully elapsed, so
|
||||
// the map cannot grow unbounded. It mirrors pruneRetries.
|
||||
func (s *Service) pruneRolledBack(now time.Time) {
|
||||
s.rolledBackMu.Lock()
|
||||
defer s.rolledBackMu.Unlock()
|
||||
|
||||
for key, rec := range s.rolledBack {
|
||||
if now.Sub(rec.at) >= updateRollbackCooldown {
|
||||
delete(s.rolledBack, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneRetries drops retry state for containers whose retry window has fully
|
||||
// elapsed since their last restart. A container is kept regardless of whether it
|
||||
// appeared in the current tick: one that briefly leaves the unhealthy filter
|
||||
// (e.g. while "starting" right after a restart) must not lose its accounting, or
|
||||
// the cooldown / max-retries storm guard would be defeated. A container that has
|
||||
// recovered and stayed quiet for longer than the window is cleaned up (fresh
|
||||
// budget next incident, no unbounded growth).
|
||||
func (s *Service) pruneRetries(now time.Time) {
|
||||
s.retryMu.Lock()
|
||||
defer s.retryMu.Unlock()
|
||||
|
||||
for id, state := range s.retries {
|
||||
if now.Sub(state.lastRestart) >= retryWindow {
|
||||
delete(s.retries, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
261
api/containerautomation/webhook.go
Normal file
261
api/containerautomation/webhook.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// webhookMessagePlaceholder is the token replaced in the configured webhook
|
||||
// URL with the URL-encoded event message. When present, the notifier issues a
|
||||
// GET on the substituted URL ("message in the address"); when absent, it POSTs
|
||||
// the plain-text message as the request body.
|
||||
webhookMessagePlaceholder = "{{message}}"
|
||||
|
||||
// webhookTimeout bounds each webhook HTTP call so a slow or unresponsive
|
||||
// endpoint cannot pile up goroutines. The call already runs off the hot path.
|
||||
webhookTimeout = 10 * time.Second
|
||||
|
||||
// shortDigestLen is how many leading hex characters of an image digest the
|
||||
// message keeps (matches the maintainer's example, e.g. "59b94983c73a").
|
||||
shortDigestLen = 12
|
||||
)
|
||||
|
||||
// webhookNotifier delivers container-automation events to a user-configured HTTP
|
||||
// endpoint. It reads the current webhook URL from the datastore on every event
|
||||
// so a settings change takes effect without a restart, formats a human-readable
|
||||
// message, and performs the HTTP call in a background goroutine so a slow or
|
||||
// broken endpoint never delays or fails the daemon hot path.
|
||||
type webhookNotifier struct {
|
||||
dataStore dataservices.DataStore
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// newWebhookNotifier builds a webhookNotifier bound to the datastore. The HTTP
|
||||
// client carries the per-call timeout so a request cannot hang indefinitely.
|
||||
func newWebhookNotifier(dataStore dataservices.DataStore) webhookNotifier {
|
||||
return webhookNotifier{
|
||||
dataStore: dataStore,
|
||||
client: &http.Client{Timeout: webhookTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// webhookURLForKind selects the configured webhook URL for an event kind: the
|
||||
// update-family events (image update, rollback, update-failed) route to the
|
||||
// update endpoint, and the auto-heal restart routes to the heal endpoint. This
|
||||
// lets a user enable notifications for one mechanism without the other — an
|
||||
// empty URL for a mechanism means "no webhook for that mechanism".
|
||||
func webhookURLForKind(notification portainer.ContainerAutomationNotificationSettings, kind EventKind) string {
|
||||
switch kind {
|
||||
case EventUpdated, EventRollback, EventUpdateFailed:
|
||||
return notification.UpdateWebhookURL
|
||||
case EventHealRestarted:
|
||||
return notification.HealWebhookURL
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Notify reads the webhook URL for the event's mechanism (update vs heal) and,
|
||||
// when set, dispatches the event in a background goroutine. Only the settings
|
||||
// read and the empty-URL short-circuit run synchronously (they decide whether
|
||||
// to spawn at all); message formatting — which itself reads Endpoint()/Stack()
|
||||
// from the datastore — and the HTTP call both happen off the daemon hot path,
|
||||
// under a single recover(). It never blocks the caller and never returns an
|
||||
// error: the webhook is strictly best-effort. When the URL for the event's
|
||||
// mechanism is empty, the event is skipped and the other mechanism is
|
||||
// unaffected.
|
||||
func (n webhookNotifier) Notify(event Event) {
|
||||
settings, err := n.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("container automation webhook: unable to read settings, skipping notification")
|
||||
return
|
||||
}
|
||||
|
||||
webhookURL := strings.TrimSpace(webhookURLForKind(settings.ContainerAutomation.Notification, event.Kind))
|
||||
if webhookURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort delivery: never block or fail the caller (the update/heal hot
|
||||
// path). Everything below — the env/stack datastore reads in formatMessage and
|
||||
// the bounded HTTP call — runs in its own goroutine, and any panic there is
|
||||
// recovered so it can never crash the daemon.
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Warn().Interface("panic", r).Msg("container automation webhook: recovered from panic during delivery")
|
||||
}
|
||||
}()
|
||||
|
||||
message := n.formatMessage(settings, event)
|
||||
n.deliver(webhookURL, message)
|
||||
}()
|
||||
}
|
||||
|
||||
// deliver performs the HTTP call for a single event. It is always invoked from
|
||||
// the Notify goroutine (which recovers any panic), so a broken endpoint can
|
||||
// never block or crash the daemon.
|
||||
func (n webhookNotifier) deliver(webhookURL, message string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), webhookTimeout)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
req *http.Request
|
||||
err error
|
||||
)
|
||||
|
||||
if strings.Contains(webhookURL, webhookMessagePlaceholder) {
|
||||
// Substitution mode: replace the placeholder with the URL-encoded message
|
||||
// and GET the resulting address (the maintainer's "message in the URL").
|
||||
target := strings.ReplaceAll(webhookURL, webhookMessagePlaceholder, url.QueryEscape(message))
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
} else {
|
||||
// No placeholder: POST the plain-text message as the body, useful for
|
||||
// generic POST-style webhooks.
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, strings.NewReader(message))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("container automation webhook: unable to build request")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("container automation webhook: delivery failed")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
log.Warn().Int("status", resp.StatusCode).Msg("container automation webhook: endpoint returned an error status")
|
||||
}
|
||||
}
|
||||
|
||||
// formatMessage builds the human-readable message for an event. It resolves the
|
||||
// environment name from the endpoint and the stack name from the stack via the
|
||||
// datastore, mirroring the maintainer's example:
|
||||
//
|
||||
// Environment | nebula.lc
|
||||
// Stack [cache-demo]
|
||||
// Update [esphome]: 59b94983c73a → 2231ca5d676d
|
||||
//
|
||||
// The context line is the stack for stack-scoped events, otherwise the container;
|
||||
// the action line is adapted per event kind (update / rollback / update-failed /
|
||||
// auto-heal restart). Auto-heal renders as:
|
||||
//
|
||||
// Environment | nebula.lc
|
||||
// Container [nginx]
|
||||
// Auto-heal: restarted unhealthy container
|
||||
func (n webhookNotifier) formatMessage(settings *portainer.Settings, event Event) string {
|
||||
lines := []string{"Environment | " + n.environmentName(event.EndpointID)}
|
||||
|
||||
// Context line: the stack for stack-scoped events, otherwise the container. A
|
||||
// per-container stack-member update carries StackName (from the compose label),
|
||||
// preferred over a StackID/Stack().Read round-trip; the container itself still
|
||||
// names the action line below.
|
||||
switch {
|
||||
case event.StackName != "":
|
||||
lines = append(lines, fmt.Sprintf("Stack [%s]", event.StackName))
|
||||
case event.StackID != 0:
|
||||
lines = append(lines, fmt.Sprintf("Stack [%s]", n.stackName(event.StackID)))
|
||||
case event.ContainerName != "":
|
||||
lines = append(lines, fmt.Sprintf("Container [%s]", event.ContainerName))
|
||||
}
|
||||
|
||||
// Subject for the action line: the container name when known, else the stack
|
||||
// name, else a short container id.
|
||||
subject := event.ContainerName
|
||||
if subject == "" && event.StackID != 0 {
|
||||
subject = n.stackName(event.StackID)
|
||||
}
|
||||
if subject == "" {
|
||||
subject = shortDigest(event.ContainerID)
|
||||
}
|
||||
|
||||
switch event.Kind {
|
||||
case EventUpdated:
|
||||
if event.OldDigest != "" && event.NewDigest != "" {
|
||||
lines = append(lines, fmt.Sprintf("Update [%s]: %s → %s", subject, shortDigest(event.OldDigest), shortDigest(event.NewDigest)))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("Update [%s]: image updated", subject))
|
||||
}
|
||||
case EventRollback:
|
||||
lines = append(lines, fmt.Sprintf("Rollback [%s]: rolled back to previous image after failed health check", subject))
|
||||
case EventUpdateFailed:
|
||||
line := fmt.Sprintf("Update failed [%s]", subject)
|
||||
if event.Message != "" {
|
||||
line += ": " + event.Message
|
||||
}
|
||||
if event.Err != nil {
|
||||
line += fmt.Sprintf(" (%s)", event.Err)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case EventHealRestarted:
|
||||
lines = append(lines, "Auto-heal: restarted unhealthy container")
|
||||
default:
|
||||
if event.Message != "" {
|
||||
lines = append(lines, event.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// environmentName resolves an endpoint id to its display name, degrading to a
|
||||
// "#<id>" placeholder when the endpoint cannot be read (deleted, or a zero id).
|
||||
func (n webhookNotifier) environmentName(endpointID int) string {
|
||||
if endpointID == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
endpoint, err := n.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil || endpoint == nil {
|
||||
return fmt.Sprintf("#%d", endpointID)
|
||||
}
|
||||
|
||||
return endpoint.Name
|
||||
}
|
||||
|
||||
// stackName resolves a stack id to its name, degrading to a "#<id>" placeholder
|
||||
// when the stack cannot be read.
|
||||
func (n webhookNotifier) stackName(stackID int) string {
|
||||
stack, err := n.dataStore.Stack().Read(portainer.StackID(stackID))
|
||||
if err != nil || stack == nil {
|
||||
return fmt.Sprintf("#%d", stackID)
|
||||
}
|
||||
|
||||
return stack.Name
|
||||
}
|
||||
|
||||
// shortDigest trims an image id/digest to a short, human-friendly hex form
|
||||
// (shortDigestLen chars), matching the maintainer's example. It drops a leading
|
||||
// "sha256:" algorithm prefix so "sha256:59b94983c73a..." -> "59b94983c73a".
|
||||
func shortDigest(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if i := strings.LastIndex(s, "sha256:"); i >= 0 {
|
||||
s = s[i+len("sha256:"):]
|
||||
}
|
||||
|
||||
if len(s) > shortDigestLen {
|
||||
return s[:shortDigestLen]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
437
api/containerautomation/webhook_test.go
Normal file
437
api/containerautomation/webhook_test.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
)
|
||||
|
||||
// newTestWebhookNotifier builds an initialized test datastore, sets both the
|
||||
// update and heal webhook URLs to the same value (so the notifier fires for
|
||||
// every event kind), and returns a webhookNotifier bound to it. Use
|
||||
// newTestWebhookNotifierSplit to configure the two URLs independently.
|
||||
func newTestWebhookNotifier(t *testing.T, webhookURL string) (webhookNotifier, *datastore.Store) {
|
||||
t.Helper()
|
||||
|
||||
return newTestWebhookNotifierSplit(t, webhookURL, webhookURL)
|
||||
}
|
||||
|
||||
// newTestWebhookNotifierSplit builds an initialized test datastore with the
|
||||
// auto-update and auto-heal webhook URLs set independently, and returns a
|
||||
// webhookNotifier bound to it.
|
||||
func newTestWebhookNotifierSplit(t *testing.T, updateURL, healURL string) (webhookNotifier, *datastore.Store) {
|
||||
t.Helper()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
settings, err := store.Settings().Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
|
||||
settings.ContainerAutomation.Notification.UpdateWebhookURL = updateURL
|
||||
settings.ContainerAutomation.Notification.HealWebhookURL = healURL
|
||||
if err := store.Settings().UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("update settings: %v", err)
|
||||
}
|
||||
|
||||
return newWebhookNotifier(store), store
|
||||
}
|
||||
|
||||
func createEndpoint(t *testing.T, store *datastore.Store, id int, name string) {
|
||||
t.Helper()
|
||||
|
||||
if err := store.Endpoint().Create(&portainer.Endpoint{ID: portainer.EndpointID(id), Name: name}); err != nil {
|
||||
t.Fatalf("create endpoint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createStack(t *testing.T, store *datastore.Store, id int, name string) {
|
||||
t.Helper()
|
||||
|
||||
if err := store.Stack().Create(&portainer.Stack{ID: portainer.StackID(id), Name: name}); err != nil {
|
||||
t.Fatalf("create stack: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookNotifierGETPlaceholder verifies the placeholder is replaced with the
|
||||
// URL-encoded message and the URL is fetched with GET.
|
||||
func TestWebhookNotifierGETPlaceholder(t *testing.T) {
|
||||
reqs := make(chan *http.Request, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reqs <- r
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifier(t, srv.URL+"/hook?msg="+webhookMessagePlaceholder)
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
|
||||
|
||||
select {
|
||||
case r := <-reqs:
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
|
||||
got := r.URL.Query().Get("msg")
|
||||
want := "Environment | prod\nContainer [nginx]\nAuto-heal: restarted unhealthy container"
|
||||
if got != want {
|
||||
t.Errorf("decoded msg = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// The raw query must be URL-encoded: no literal spaces/newlines on the wire.
|
||||
if strings.ContainsAny(r.URL.RawQuery, " \n") {
|
||||
t.Errorf("raw query is not URL-encoded: %q", r.URL.RawQuery)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("webhook GET was not received")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookNotifierPOSTFallback verifies that a URL without the placeholder is
|
||||
// POSTed with the plain-text message as the body.
|
||||
func TestWebhookNotifierPOSTFallback(t *testing.T) {
|
||||
type captured struct {
|
||||
method string
|
||||
body string
|
||||
}
|
||||
|
||||
ch := make(chan captured, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
ch <- captured{method: r.Method, body: string(b)}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifier(t, srv.URL+"/hook")
|
||||
createEndpoint(t, store, 2, "staging")
|
||||
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 2, ContainerName: "api"})
|
||||
|
||||
select {
|
||||
case c := <-ch:
|
||||
if c.method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", c.method)
|
||||
}
|
||||
|
||||
want := "Environment | staging\nContainer [api]\nAuto-heal: restarted unhealthy container"
|
||||
if c.body != want {
|
||||
t.Errorf("body = %q, want %q", c.body, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("webhook POST was not received")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookNotifierEmptyURLNoCall verifies no HTTP call is made when the URL is
|
||||
// empty.
|
||||
func TestWebhookNotifierEmptyURLNoCall(t *testing.T) {
|
||||
called := make(chan struct{}, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called <- struct{}{}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n, _ := newTestWebhookNotifier(t, "")
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "x"})
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("webhook should not be called when the URL is empty")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
// No call, as expected.
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRequest returns the first request seen on ch, or fails after a short
|
||||
// grace period.
|
||||
func waitForRequest(t *testing.T, ch <-chan *http.Request, what string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s was not received", what)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// expectNoRequest asserts nothing arrives on ch within a short grace period.
|
||||
func expectNoRequest(t *testing.T, ch <-chan *http.Request, what string) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatalf("%s should not have been called", what)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
// No call, as expected.
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookNotifierUpdateEventRoutesToUpdateURL verifies an update-family event
|
||||
// dispatches to the auto-update URL only; the heal URL is set but never called.
|
||||
func TestWebhookNotifierUpdateEventRoutesToUpdateURL(t *testing.T) {
|
||||
updateReqs := make(chan *http.Request, 1)
|
||||
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
updateReqs <- r
|
||||
}))
|
||||
defer updateSrv.Close()
|
||||
|
||||
healReqs := make(chan *http.Request, 1)
|
||||
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
healReqs <- r
|
||||
}))
|
||||
defer healSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", healSrv.URL+"/heal")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
for _, kind := range []EventKind{EventUpdated, EventRollback, EventUpdateFailed} {
|
||||
n.Notify(Event{Kind: kind, EndpointID: 1, ContainerName: "c"})
|
||||
|
||||
r := waitForRequest(t, updateReqs, "update webhook for "+string(kind))
|
||||
if r.URL.Path != "/update" {
|
||||
t.Errorf("kind %s hit %q, want /update", kind, r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
expectNoRequest(t, healReqs, "heal webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierHealEventRoutesToHealURL verifies a heal event dispatches to
|
||||
// the auto-heal URL only; the update URL is set but never called.
|
||||
func TestWebhookNotifierHealEventRoutesToHealURL(t *testing.T) {
|
||||
updateReqs := make(chan *http.Request, 1)
|
||||
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
updateReqs <- r
|
||||
}))
|
||||
defer updateSrv.Close()
|
||||
|
||||
healReqs := make(chan *http.Request, 1)
|
||||
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
healReqs <- r
|
||||
}))
|
||||
defer healSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", healSrv.URL+"/heal")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
|
||||
|
||||
r := waitForRequest(t, healReqs, "heal webhook")
|
||||
if r.URL.Path != "/heal" {
|
||||
t.Errorf("heal event hit %q, want /heal", r.URL.Path)
|
||||
}
|
||||
|
||||
expectNoRequest(t, updateReqs, "update webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierEmptyUpdateURLSkipsUpdateOnly verifies that an empty
|
||||
// auto-update URL suppresses update-family events while heal still fires.
|
||||
func TestWebhookNotifierEmptyUpdateURLSkipsUpdateOnly(t *testing.T) {
|
||||
healReqs := make(chan *http.Request, 1)
|
||||
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
healReqs <- r
|
||||
}))
|
||||
defer healSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, "", healSrv.URL+"/heal")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
// Update-family event: no URL configured, so nothing is delivered.
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
|
||||
expectNoRequest(t, healReqs, "heal webhook on an update event")
|
||||
|
||||
// Heal event: the heal URL is set, so it still fires.
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
|
||||
waitForRequest(t, healReqs, "heal webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierEmptyHealURLSkipsHealOnly verifies that an empty auto-heal
|
||||
// URL suppresses heal events while update-family events still fire.
|
||||
func TestWebhookNotifierEmptyHealURLSkipsHealOnly(t *testing.T) {
|
||||
updateReqs := make(chan *http.Request, 1)
|
||||
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
updateReqs <- r
|
||||
}))
|
||||
defer updateSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", "")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
// Heal event: no URL configured, so nothing is delivered.
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
|
||||
expectNoRequest(t, updateReqs, "update webhook on a heal event")
|
||||
|
||||
// Update event: the update URL is set, so it still fires.
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
|
||||
waitForRequest(t, updateReqs, "update webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierFailingEndpointDoesNotBlock verifies that a broken endpoint
|
||||
// neither blocks the caller nor panics.
|
||||
func TestWebhookNotifierFailingEndpointDoesNotBlock(t *testing.T) {
|
||||
// Start then immediately close a server so its address refuses connections.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
deadURL := srv.URL
|
||||
srv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifier(t, deadURL+"/hook?msg="+webhookMessagePlaceholder)
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Notify returned promptly despite the failing endpoint.
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Notify blocked on a failing endpoint")
|
||||
}
|
||||
|
||||
// Give the background delivery goroutine time to hit the error path; it must
|
||||
// log-and-return, never panic.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestFormatMessageStandaloneUpdate covers the maintainer's update format for a
|
||||
// standalone container, with the old->new short digests.
|
||||
func TestFormatMessageStandaloneUpdate(t *testing.T) {
|
||||
n, store := newTestWebhookNotifier(t, "unused")
|
||||
createEndpoint(t, store, 1, "nebula.lc")
|
||||
|
||||
settings, _ := store.Settings().Settings()
|
||||
|
||||
msg := n.formatMessage(settings, Event{
|
||||
Kind: EventUpdated, EndpointID: 1, ContainerName: "esphome",
|
||||
OldDigest: "sha256:59b94983c73aabcd", NewDigest: "sha256:2231ca5d676dabcd",
|
||||
})
|
||||
|
||||
want := "Environment | nebula.lc\nContainer [esphome]\nUpdate [esphome]: 59b94983c73a → 2231ca5d676d"
|
||||
if msg != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageStackUpdate covers a stack-scoped update (no per-container
|
||||
// digests): the context line is the stack name.
|
||||
func TestFormatMessageStackUpdate(t *testing.T) {
|
||||
n, store := newTestWebhookNotifier(t, "unused")
|
||||
createEndpoint(t, store, 1, "nebula.lc")
|
||||
createStack(t, store, 7, "cache-demo")
|
||||
|
||||
settings, _ := store.Settings().Settings()
|
||||
|
||||
msg := n.formatMessage(settings, Event{
|
||||
Kind: EventUpdated, EndpointID: 1, StackID: 7,
|
||||
})
|
||||
|
||||
want := "Environment | nebula.lc\nStack [cache-demo]\nUpdate [cache-demo]: image updated"
|
||||
if msg != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageStackMemberUpdate covers the per-container update of a
|
||||
// stack-member container: the context line is the compose stack name (from
|
||||
// StackName, no Stack().Read), the action line names the container with its
|
||||
// old->new digests. This is the maintainer's target output.
|
||||
func TestFormatMessageStackMemberUpdate(t *testing.T) {
|
||||
n, store := newTestWebhookNotifier(t, "unused")
|
||||
createEndpoint(t, store, 1, "nebula.lc")
|
||||
|
||||
settings, _ := store.Settings().Settings()
|
||||
|
||||
msg := n.formatMessage(settings, Event{
|
||||
Kind: EventUpdated, EndpointID: 1, StackID: 7, StackName: "cache-demo",
|
||||
ContainerName: "esphome",
|
||||
OldDigest: "sha256:59b94983c73aabcd", NewDigest: "sha256:2231ca5d676dabcd",
|
||||
})
|
||||
|
||||
want := "Environment | nebula.lc\nStack [cache-demo]\nUpdate [esphome]: 59b94983c73a → 2231ca5d676d"
|
||||
if msg != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageStackMemberUpdateNoNewDigest covers the best-effort fallback:
|
||||
// when the post-redeploy new image id could not be recovered, the message still
|
||||
// carries the stack and container and degrades the action line to "image updated"
|
||||
// rather than blocking delivery.
|
||||
func TestFormatMessageStackMemberUpdateNoNewDigest(t *testing.T) {
|
||||
n, store := newTestWebhookNotifier(t, "unused")
|
||||
createEndpoint(t, store, 1, "nebula.lc")
|
||||
|
||||
settings, _ := store.Settings().Settings()
|
||||
|
||||
msg := n.formatMessage(settings, Event{
|
||||
Kind: EventUpdated, EndpointID: 1, StackID: 7, StackName: "cache-demo",
|
||||
ContainerName: "esphome", OldDigest: "sha256:59b94983c73aabcd",
|
||||
})
|
||||
|
||||
want := "Environment | nebula.lc\nStack [cache-demo]\nUpdate [esphome]: image updated"
|
||||
if msg != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageAutoHeal covers the auto-heal message design.
|
||||
func TestFormatMessageAutoHeal(t *testing.T) {
|
||||
n, store := newTestWebhookNotifier(t, "unused")
|
||||
createEndpoint(t, store, 3, "prod")
|
||||
|
||||
settings, _ := store.Settings().Settings()
|
||||
|
||||
msg := n.formatMessage(settings, Event{
|
||||
Kind: EventHealRestarted, EndpointID: 3, ContainerName: "nginx",
|
||||
})
|
||||
|
||||
want := "Environment | prod\nContainer [nginx]\nAuto-heal: restarted unhealthy container"
|
||||
if msg != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageUnknownEndpoint verifies the "#<id>" fallback when the
|
||||
// endpoint cannot be resolved.
|
||||
func TestFormatMessageUnknownEndpoint(t *testing.T) {
|
||||
n, store := newTestWebhookNotifier(t, "unused")
|
||||
|
||||
settings, _ := store.Settings().Settings()
|
||||
|
||||
msg := n.formatMessage(settings, Event{
|
||||
Kind: EventHealRestarted, EndpointID: 99, ContainerName: "ghost",
|
||||
})
|
||||
|
||||
want := "Environment | #99\nContainer [ghost]\nAuto-heal: restarted unhealthy container"
|
||||
if msg != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortDigest covers digest short-forming.
|
||||
func TestShortDigest(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"sha256:59b94983c73a1122334455": "59b94983c73a",
|
||||
"59b94983c73a1122334455": "59b94983c73a",
|
||||
"short": "short",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for in, want := range cases {
|
||||
if got := shortDigest(in); got != want {
|
||||
t.Errorf("shortDigest(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type Nonce struct {
|
||||
@@ -45,7 +46,7 @@ func (n *Nonce) Value() []byte {
|
||||
|
||||
func (n *Nonce) Increment() error {
|
||||
// Start incrementing from the least significant byte
|
||||
for i := len(n.val) - 1; i >= 0; i-- {
|
||||
for i := range slices.Backward(n.val) {
|
||||
// Increment the current byte
|
||||
n.val[i]++
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package boltdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -40,6 +42,8 @@ type DbConnection struct {
|
||||
isEncrypted bool
|
||||
Compact bool
|
||||
|
||||
gcm cipher.AEAD
|
||||
|
||||
*bolt.DB
|
||||
}
|
||||
|
||||
@@ -75,8 +79,28 @@ func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
|
||||
return file.Size(), nil
|
||||
}
|
||||
|
||||
func (connection *DbConnection) SetEncrypted(flag bool) {
|
||||
func (connection *DbConnection) SetEncrypted(flag bool) error {
|
||||
connection.isEncrypted = flag
|
||||
|
||||
if !flag || connection.EncryptionKey == nil {
|
||||
connection.gcm = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(connection.EncryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating AES cipher for database encryption: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating GCM cipher for database encryption: %w", err)
|
||||
}
|
||||
|
||||
connection.gcm = gcm
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return true if the database is encrypted
|
||||
@@ -100,7 +124,9 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
||||
|
||||
// If we have a loaded encryption key, always set encrypted
|
||||
if connection.EncryptionKey != nil {
|
||||
connection.SetEncrypted(true)
|
||||
if err := connection.SetEncrypted(true); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check for portainer.db
|
||||
|
||||
@@ -131,7 +131,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
}
|
||||
|
||||
if tc.key {
|
||||
connection.EncryptionKey = []byte("secret")
|
||||
connection.EncryptionKey = secretToEncryptionKey("secret")
|
||||
}
|
||||
|
||||
result, err := connection.NeedsEncryptionMigration()
|
||||
@@ -142,6 +142,57 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEncrypted_InvalidKeyReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{EncryptionKey: []byte("bad")}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
}
|
||||
|
||||
func TestSetEncrypted_NilKeyDoesNotSetGCM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
}
|
||||
|
||||
func TestSetEncrypted_EnableThenDisableStopsEncryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn.gcm)
|
||||
|
||||
err = conn.SetEncrypted(false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
|
||||
// MarshalObject must return plaintext after encryption is disabled
|
||||
data, err := conn.MarshalObject("hello")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", string(data))
|
||||
}
|
||||
|
||||
func TestNeedsEncryptionMigration_InvalidKeyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{
|
||||
Path: t.TempDir(),
|
||||
EncryptionKey: []byte("bad"),
|
||||
}
|
||||
|
||||
result, err := conn.NeedsEncryptionMigration()
|
||||
require.Error(t, err)
|
||||
require.False(t, result)
|
||||
}
|
||||
|
||||
func TestDBCompaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := &DbConnection{Path: t.TempDir()}
|
||||
|
||||
@@ -2,7 +2,6 @@ package boltdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -28,18 +27,18 @@ func (connection *DbConnection) MarshalObject(object any) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if connection.getEncryptionKey() == nil {
|
||||
if connection.gcm == nil {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
return encrypt(buf.Bytes(), connection.getEncryptionKey())
|
||||
return encrypt(buf.Bytes(), connection.gcm), nil
|
||||
}
|
||||
|
||||
// UnmarshalObject decodes an object from binary data
|
||||
func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
var err error
|
||||
if connection.getEncryptionKey() != nil {
|
||||
data, err = decrypt(data, connection.getEncryptionKey())
|
||||
if connection.gcm != nil {
|
||||
data, err = decrypt(data, connection.gcm)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
@@ -59,48 +58,23 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// mmm, don't have a KMS .... aes GCM seems the most likely from
|
||||
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
|
||||
|
||||
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
||||
block, err := aes.NewCipher(passphrase)
|
||||
if err != nil {
|
||||
return encrypted, err
|
||||
}
|
||||
|
||||
// NewGCMWithRandomNonce in go 1.24 handles setting up the nonce and adding it to the encrypted output
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
return encrypted, err
|
||||
}
|
||||
|
||||
return gcm.Seal(nil, nil, plaintext, nil), nil
|
||||
func encrypt(plaintext []byte, gcm cipher.AEAD) []byte {
|
||||
return gcm.Seal(nil, nil, plaintext, nil)
|
||||
}
|
||||
|
||||
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
|
||||
func decrypt(encrypted []byte, gcm cipher.AEAD) ([]byte, error) {
|
||||
if string(encrypted) == "false" {
|
||||
return []byte("false"), nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(passphrase)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
||||
}
|
||||
|
||||
// NewGCMWithRandomNonce in go 1.24 handles reading the nonce from the encrypted input for us
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error creating GCM")
|
||||
}
|
||||
|
||||
if len(encrypted) < gcm.NonceSize() {
|
||||
if len(encrypted) < gcm.Overhead() {
|
||||
return encrypted, errEncryptedStringTooShort
|
||||
}
|
||||
|
||||
plaintextByte, err = gcm.Open(nil, nil, encrypted, nil)
|
||||
plaintextByte, err := gcm.Open(nil, nil, encrypted, nil)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error decrypting text")
|
||||
}
|
||||
|
||||
return plaintextByte, err
|
||||
return plaintextByte, nil
|
||||
}
|
||||
|
||||
@@ -170,7 +170,10 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
}
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key, isEncrypted: true}
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
|
||||
@@ -232,13 +235,16 @@ func Test_NonceSources(t *testing.T) {
|
||||
return plaintext, err
|
||||
}
|
||||
|
||||
encryptNewFn := encrypt
|
||||
decryptNewFn := decrypt
|
||||
|
||||
passphrase := make([]byte, 32)
|
||||
_, err := io.ReadFull(rand.Reader, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
block, err := aes.NewCipher(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
junk := make([]byte, 1024)
|
||||
_, err = io.ReadFull(rand.Reader, junk)
|
||||
require.NoError(t, err)
|
||||
@@ -263,13 +269,12 @@ func Test_NonceSources(t *testing.T) {
|
||||
enc, err = encryptOldFn(plain, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
dec, err = decryptNewFn(enc, passphrase)
|
||||
dec, err = decrypt(enc, gcm)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, plain, dec)
|
||||
|
||||
enc, err = encryptNewFn(plain, passphrase)
|
||||
require.NoError(t, err)
|
||||
enc = encrypt(plain, gcm)
|
||||
|
||||
dec, err = decryptOldFn(enc, passphrase)
|
||||
require.NoError(t, err)
|
||||
@@ -277,3 +282,110 @@ func Test_NonceSources(t *testing.T) {
|
||||
require.Equal(t, plain, dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_FalseStringBypassesDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := decrypt([]byte("false"), gcm)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("false"), result)
|
||||
}
|
||||
|
||||
func TestDecrypt_ShortDataReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
short := []byte("short")
|
||||
result, err := decrypt(short, gcm)
|
||||
require.ErrorIs(t, err, errEncryptedStringTooShort)
|
||||
require.Equal(t, short, result)
|
||||
}
|
||||
|
||||
func TestDecrypt_CorruptDataReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 30 bytes passes the length check but fails authentication
|
||||
corrupted := make([]byte, 30)
|
||||
_, err = io.ReadFull(rand.Reader, corrupted)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := decrypt(corrupted, gcm)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, corrupted, result)
|
||||
}
|
||||
|
||||
// BenchmarkEncryptCachedCipher measures the new approach: cipher created once and reused.
|
||||
func BenchmarkEncryptCachedCipher(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(b, err)
|
||||
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
_ = encrypt(data, conn.gcm)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncryptPerCallCipher measures the old approach: cipher created on every call.
|
||||
func BenchmarkEncryptPerCallCipher(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
_ = gcm.Seal(nil, nil, data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncryptCachedCipherParallel verifies the cached cipher is safe for concurrent use.
|
||||
func BenchmarkEncryptCachedCipherParallel(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(b, err)
|
||||
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = encrypt(data, conn.gcm)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, err
|
||||
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
||||
}
|
||||
|
||||
if tx.conn.getEncryptionKey() != nil {
|
||||
if tx.conn.gcm != nil {
|
||||
var err error
|
||||
|
||||
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
|
||||
if value, err = decrypt(value, tx.conn.gcm); err != nil {
|
||||
return value, errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package boltdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -23,10 +24,10 @@ func TestTxs(t *testing.T) {
|
||||
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
t.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
})
|
||||
|
||||
// Error propagation
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
@@ -103,3 +104,57 @@ func TestTxs(t *testing.T) {
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func BenchmarkGetAll(b *testing.B) {
|
||||
const endpointBucket = "endpoints"
|
||||
const n = 10000
|
||||
|
||||
conn := DbConnection{Path: b.TempDir()}
|
||||
|
||||
err := conn.Open()
|
||||
require.NoError(b, err)
|
||||
b.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
require.NoError(b, err)
|
||||
})
|
||||
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
if err := tx.SetServiceName(endpointBucket); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 1; i <= n; i++ {
|
||||
ep := portainer.Endpoint{
|
||||
ID: portainer.EndpointID(i),
|
||||
Name: "env-" + strconv.Itoa(i),
|
||||
Type: portainer.DockerEnvironment,
|
||||
URL: "tcp://192.168.1." + strconv.Itoa(i%254+1) + ":2375",
|
||||
PublicURL: "https://env-" + strconv.Itoa(i) + ".example.com",
|
||||
GroupID: portainer.EndpointGroupID(i%10 + 1),
|
||||
TagIDs: []portainer.TagID{portainer.TagID(i%5 + 1), portainer.TagID(i%3 + 1)},
|
||||
LastCheckInDate: int64(i) * 1000,
|
||||
EdgeID: "edge-" + strconv.Itoa(i),
|
||||
}
|
||||
|
||||
if err := tx.CreateObjectWithId(endpointBucket, i, &ep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(b, err)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for b.Loop() {
|
||||
var collection []portainer.Endpoint
|
||||
|
||||
if err := conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetAll(endpointBucket, new(portainer.Endpoint), dataservices.AppendFn(&collection))
|
||||
}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
api/dataservices/allowlist/allowlist.go
Normal file
131
api/dataservices/allowlist/allowlist.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package allowlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
)
|
||||
|
||||
const (
|
||||
BucketName = "allowlist"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
baseService dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]
|
||||
cache *lru.Cache
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
return service.baseService.BucketName()
|
||||
}
|
||||
|
||||
func NewService(connection portainer.Connection) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
baseService: dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]{
|
||||
Bucket: BucketName,
|
||||
Connection: connection,
|
||||
},
|
||||
}
|
||||
|
||||
err = service.populateCache()
|
||||
|
||||
return service, err
|
||||
}
|
||||
|
||||
func (service *Service) populateCache() error {
|
||||
allowListKeys := []portainer.AllowListKey{portainer.AllowListSSRF}
|
||||
cache, err := lru.New(len(allowListKeys))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, k := range allowListKeys {
|
||||
allowList, err := service.baseService.Read(k)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
allowList = &portainer.AllowList{
|
||||
ID: k,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAllowList := ssrf.ParseAllowedHosts(allowList.Entries)
|
||||
parsedAllowList.Mode = allowList.Mode
|
||||
|
||||
cache.Add(k, &parsedAllowList)
|
||||
}
|
||||
|
||||
service.cache = cache
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) *ServiceTx {
|
||||
return &ServiceTx{
|
||||
baseService: service.baseService.Tx(tx),
|
||||
cache: service.cache,
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
|
||||
var result *portainer.AllowList
|
||||
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
result, err = service.Tx(tx).Read(id)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) ReadAll() ([]portainer.AllowList, error) {
|
||||
var result []portainer.AllowList
|
||||
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
result, err = service.Tx(tx).ReadAll()
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
|
||||
allowListAny, ok := service.cache.Get(id)
|
||||
if ok {
|
||||
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
|
||||
}
|
||||
|
||||
return allowList, nil
|
||||
}
|
||||
|
||||
var result *portainer.ParsedAllowList
|
||||
err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
result, err = service.Tx(tx).ReadParsed(id)
|
||||
return err
|
||||
})
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (service *Service) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
|
||||
return service.baseService.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).Update(id, allowList)
|
||||
})
|
||||
}
|
||||
89
api/dataservices/allowlist/allowlist_test.go
Normal file
89
api/dataservices/allowlist/allowlist_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package allowlist_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAllowListReadEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestAllowListUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com", "10.0.0.0/8"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, expected))
|
||||
|
||||
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
got, err := ds.AllowList().ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []portainer.AllowList{}, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllAfterUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com", "10.0.0.0/8"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &expected))
|
||||
|
||||
got, err := ds.AllowList().ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []portainer.AllowList{expected}, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadParsedAfterUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com"},
|
||||
}))
|
||||
|
||||
expected := &portainer.ParsedAllowList{
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Nets: []*net.IPNet{},
|
||||
Hosts: map[string]bool{
|
||||
"example.com": true,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := ds.AllowList().ReadParsed(portainer.AllowListSSRF)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
77
api/dataservices/allowlist/tx.go
Normal file
77
api/dataservices/allowlist/tx.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package allowlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
baseService dataservices.BaseDataServiceTx[portainer.AllowList, portainer.AllowListKey]
|
||||
cache *lru.Cache
|
||||
}
|
||||
|
||||
func (service *ServiceTx) BucketName() string {
|
||||
return service.baseService.BucketName()
|
||||
}
|
||||
|
||||
func (service *ServiceTx) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
|
||||
allowListAny, ok := service.cache.Get(id)
|
||||
if ok {
|
||||
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
|
||||
}
|
||||
|
||||
return allowList, nil
|
||||
}
|
||||
|
||||
allowList, err := service.Read(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
|
||||
parsed.Mode = allowList.Mode
|
||||
service.cache.Add(id, &parsed)
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (service *ServiceTx) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
|
||||
allowList, err := service.baseService.Read(id)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
allowList = &portainer.AllowList{
|
||||
ID: id,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return allowList, nil
|
||||
}
|
||||
|
||||
func (service *ServiceTx) ReadAll() ([]portainer.AllowList, error) {
|
||||
allowLists, err := service.baseService.ReadAll()
|
||||
if err != nil && !dataservices.IsErrObjectNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return allowLists, nil
|
||||
}
|
||||
|
||||
func (service *ServiceTx) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
|
||||
if err := service.baseService.Update(id, allowList); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
|
||||
parsed.Mode = allowList.Mode
|
||||
service.cache.Add(id, &parsed)
|
||||
return nil
|
||||
}
|
||||
92
api/dataservices/allowlist/tx_test.go
Normal file
92
api/dataservices/allowlist/tx_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package allowlist_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAllowListReadTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
var got *portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
|
||||
return err
|
||||
}))
|
||||
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeOff,
|
||||
Entries: []string{},
|
||||
}
|
||||
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllEmptyTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
var got []portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().ReadAll()
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Equal(t, []portainer.AllowList{}, got)
|
||||
}
|
||||
|
||||
func TestAllowListReadAllAfterUpdateTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.AllowList().Update(portainer.AllowListSSRF, &expected)
|
||||
}))
|
||||
|
||||
var got []portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().ReadAll()
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Equal(t, []portainer.AllowList{expected}, got)
|
||||
}
|
||||
|
||||
func TestAllowListUpdateTx(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
expected := &portainer.AllowList{
|
||||
ID: portainer.AllowListSSRF,
|
||||
Mode: portainer.SSRFModeEnforce,
|
||||
Entries: []string{"example.com"},
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.AllowList().Update(portainer.AllowListSSRF, expected)
|
||||
}))
|
||||
|
||||
var got *portainer.AllowList
|
||||
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
@@ -2,13 +2,10 @@ package apikeyrepository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
@@ -40,19 +37,10 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
|
||||
err := service.Connection.GetAll(
|
||||
BucketName,
|
||||
&portainer.APIKey{},
|
||||
func(obj any) (any, error) {
|
||||
record, ok := obj.(*portainer.APIKey)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
||||
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
|
||||
}
|
||||
|
||||
if record.UserID == userID {
|
||||
result = append(result, *record)
|
||||
}
|
||||
|
||||
return &portainer.APIKey{}, nil
|
||||
})
|
||||
dataservices.FilterFn(&result, func(record portainer.APIKey) bool {
|
||||
return record.UserID == userID
|
||||
}),
|
||||
)
|
||||
|
||||
return result, err
|
||||
}
|
||||
@@ -60,27 +48,18 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
|
||||
// GetAPIKeyByDigest returns the API key for the associated digest.
|
||||
// Note: there is a 1-to-1 mapping of api-key and digest
|
||||
func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) {
|
||||
var k *portainer.APIKey
|
||||
stop := errors.New("ok")
|
||||
var found portainer.APIKey
|
||||
|
||||
err := service.Connection.GetAll(
|
||||
BucketName,
|
||||
&portainer.APIKey{},
|
||||
func(obj any) (any, error) {
|
||||
key, ok := obj.(*portainer.APIKey)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
||||
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
|
||||
}
|
||||
if key.Digest == digest {
|
||||
k = key
|
||||
return nil, stop
|
||||
}
|
||||
dataservices.FirstFn(&found, func(key portainer.APIKey) bool {
|
||||
return key.Digest == digest
|
||||
}),
|
||||
)
|
||||
|
||||
return &portainer.APIKey{}, nil
|
||||
})
|
||||
|
||||
if errors.Is(err, stop) {
|
||||
return k, nil
|
||||
if errors.Is(err, dataservices.ErrStop) {
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package edgestack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
@@ -24,17 +21,8 @@ func (service ServiceTx) EdgeStacks() ([]portainer.EdgeStack, error) {
|
||||
err := service.tx.GetAll(
|
||||
BucketName,
|
||||
&portainer.EdgeStack{},
|
||||
func(obj any) (any, error) {
|
||||
stack, ok := obj.(*portainer.EdgeStack)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to EdgeStack object")
|
||||
return nil, fmt.Errorf("failed to convert to EdgeStack object: %s", obj)
|
||||
}
|
||||
|
||||
stacks = append(stacks, *stack)
|
||||
|
||||
return &portainer.EdgeStack{}, nil
|
||||
})
|
||||
dataservices.AppendFn(&stacks),
|
||||
)
|
||||
|
||||
return stacks, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package edgestackstatus
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
@@ -85,5 +87,9 @@ func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsID
|
||||
}
|
||||
|
||||
func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte {
|
||||
return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...)
|
||||
k := make([]byte, 16)
|
||||
binary.BigEndian.PutUint64(k[:8], uint64(edgeStackID))
|
||||
binary.BigEndian.PutUint64(k[8:], uint64(endpointID))
|
||||
|
||||
return k
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ func AppendFn[T any](collection *[]T) func(obj any) (any, error) {
|
||||
|
||||
*collection = append(*collection, *element)
|
||||
|
||||
return new(T), nil
|
||||
var zero T
|
||||
*element = zero
|
||||
|
||||
return element, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +47,10 @@ func FilterFn[T any](collection *[]T, predicate func(T) bool) func(obj any) (any
|
||||
*collection = append(*collection, *element)
|
||||
}
|
||||
|
||||
return new(T), nil
|
||||
var zero T
|
||||
*element = zero
|
||||
|
||||
return element, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +66,12 @@ func FirstFn[T any](element *T, predicate func(T) bool) func(obj any) (any, erro
|
||||
|
||||
if predicate(*e) {
|
||||
*element = *e
|
||||
return new(T), ErrStop
|
||||
return e, ErrStop
|
||||
}
|
||||
|
||||
return new(T), nil
|
||||
var zero T
|
||||
*e = zero
|
||||
|
||||
return e, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
type (
|
||||
DataStoreTx interface {
|
||||
IsErrObjectNotFound(err error) bool
|
||||
AllowList() AllowListService
|
||||
CustomTemplate() CustomTemplateService
|
||||
EdgeGroup() EdgeGroupService
|
||||
EdgeJob() EdgeJobService
|
||||
@@ -24,6 +25,7 @@ type (
|
||||
Settings() SettingsService
|
||||
Snapshot() SnapshotService
|
||||
SSLSettings() SSLSettingsService
|
||||
Source() SourceService
|
||||
Stack() StackService
|
||||
Tag() TagService
|
||||
TeamMembership() TeamMembershipService
|
||||
@@ -32,6 +34,7 @@ type (
|
||||
User() UserService
|
||||
Version() VersionService
|
||||
Webhook() WebhookService
|
||||
Workflow() WorkflowService
|
||||
PendingActions() PendingActionsService
|
||||
}
|
||||
|
||||
@@ -51,6 +54,15 @@ type (
|
||||
DataStoreTx
|
||||
}
|
||||
|
||||
// AllowListService represents a service for managing the URL allow list
|
||||
AllowListService interface {
|
||||
Read(id portainer.AllowListKey) (*portainer.AllowList, error)
|
||||
ReadAll() ([]portainer.AllowList, error)
|
||||
ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error)
|
||||
Update(id portainer.AllowListKey, allowList *portainer.AllowList) error
|
||||
BucketName() string
|
||||
}
|
||||
|
||||
// CustomTemplateService represents a service to manage custom templates
|
||||
CustomTemplateService interface {
|
||||
BaseCRUD[portainer.CustomTemplate, portainer.CustomTemplateID]
|
||||
@@ -183,6 +195,11 @@ type (
|
||||
BucketName() string
|
||||
}
|
||||
|
||||
// SourceService represents a service for managing GitOps source data
|
||||
SourceService interface {
|
||||
BaseCRUD[portainer.Source, portainer.SourceID]
|
||||
}
|
||||
|
||||
// StackService represents a service for managing stack data
|
||||
StackService interface {
|
||||
BaseCRUD[portainer.Stack, portainer.StackID]
|
||||
@@ -245,4 +262,9 @@ type (
|
||||
WebhookByResourceID(resourceID string) (*portainer.Webhook, error)
|
||||
WebhookByToken(token string) (*portainer.Webhook, error)
|
||||
}
|
||||
|
||||
// WorkflowService represents a service for managing GitOps workflow data
|
||||
WorkflowService interface {
|
||||
BaseCRUD[portainer.Workflow, portainer.WorkflowID]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,13 +2,10 @@ package resourcecontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
@@ -48,35 +45,26 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
// to the main ResourceID or in SubResourceIDs. It also performs a check on the resource type. Return nil
|
||||
// if no ResourceControl was found.
|
||||
func (service *Service) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) {
|
||||
var resourceControl *portainer.ResourceControl
|
||||
stop := errors.New("ok")
|
||||
var found portainer.ResourceControl
|
||||
|
||||
err := service.Connection.GetAll(
|
||||
BucketName,
|
||||
&portainer.ResourceControl{},
|
||||
func(obj any) (any, error) {
|
||||
rc, ok := obj.(*portainer.ResourceControl)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to ResourceControl object")
|
||||
return nil, fmt.Errorf("failed to convert to ResourceControl object: %s", obj)
|
||||
}
|
||||
dataservices.FirstFn(&found, func(rc portainer.ResourceControl) bool {
|
||||
return (rc.ResourceID == resourceID && rc.Type == resourceType) ||
|
||||
slices.Contains(rc.SubResourceIDs, resourceID)
|
||||
}),
|
||||
)
|
||||
|
||||
if rc.ResourceID == resourceID && rc.Type == resourceType {
|
||||
resourceControl = rc
|
||||
return nil, stop
|
||||
}
|
||||
|
||||
if slices.Contains(rc.SubResourceIDs, resourceID) {
|
||||
resourceControl = rc
|
||||
return nil, stop
|
||||
}
|
||||
|
||||
return &portainer.ResourceControl{}, nil
|
||||
})
|
||||
if errors.Is(err, stop) {
|
||||
return resourceControl, nil
|
||||
if errors.Is(err, dataservices.ErrStop) {
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CreateResourceControl creates a new ResourceControl object
|
||||
|
||||
@@ -2,13 +2,10 @@ package resourcecontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
@@ -19,35 +16,26 @@ type ServiceTx struct {
|
||||
// to the main ResourceID or in SubResourceIDs. It also performs a check on the resource type. Return nil
|
||||
// if no ResourceControl was found.
|
||||
func (service ServiceTx) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) {
|
||||
var resourceControl *portainer.ResourceControl
|
||||
stop := errors.New("ok")
|
||||
var found portainer.ResourceControl
|
||||
|
||||
err := service.Tx.GetAll(
|
||||
BucketName,
|
||||
&portainer.ResourceControl{},
|
||||
func(obj any) (any, error) {
|
||||
rc, ok := obj.(*portainer.ResourceControl)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to ResourceControl object")
|
||||
return nil, fmt.Errorf("failed to convert to ResourceControl object: %s", obj)
|
||||
}
|
||||
dataservices.FirstFn(&found, func(rc portainer.ResourceControl) bool {
|
||||
return (rc.ResourceID == resourceID && rc.Type == resourceType) ||
|
||||
slices.Contains(rc.SubResourceIDs, resourceID)
|
||||
}),
|
||||
)
|
||||
|
||||
if rc.ResourceID == resourceID && rc.Type == resourceType {
|
||||
resourceControl = rc
|
||||
return nil, stop
|
||||
}
|
||||
|
||||
if slices.Contains(rc.SubResourceIDs, resourceID) {
|
||||
resourceControl = rc
|
||||
return nil, stop
|
||||
}
|
||||
|
||||
return &portainer.ResourceControl{}, nil
|
||||
})
|
||||
if errors.Is(err, stop) {
|
||||
return resourceControl, nil
|
||||
if errors.Is(err, dataservices.ErrStop) {
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CreateResourceControl creates a new ResourceControl object
|
||||
|
||||
50
api/dataservices/source/source.go
Normal file
50
api/dataservices/source/source.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
const BucketName = "sources"
|
||||
|
||||
// Service represents a service for managing GitOps source data.
|
||||
type Service struct {
|
||||
dataservices.BaseDataService[portainer.Source, portainer.SourceID]
|
||||
}
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(connection portainer.Connection) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
BaseDataService: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
|
||||
Bucket: BucketName,
|
||||
Connection: connection,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
|
||||
Bucket: BucketName,
|
||||
Connection: service.Connection,
|
||||
Tx: tx,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates a new source.
|
||||
func (service *Service) Create(source *portainer.Source) error {
|
||||
return service.Connection.CreateObject(
|
||||
BucketName,
|
||||
func(id uint64) (int, any) {
|
||||
source.ID = portainer.SourceID(id)
|
||||
return int(source.ID), source
|
||||
},
|
||||
)
|
||||
}
|
||||
21
api/dataservices/source/tx.go
Normal file
21
api/dataservices/source/tx.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
|
||||
}
|
||||
|
||||
// Create creates a new source.
|
||||
func (service ServiceTx) Create(source *portainer.Source) error {
|
||||
return service.Tx.CreateObject(
|
||||
BucketName,
|
||||
func(id uint64) (int, any) {
|
||||
source.ID = portainer.SourceID(id)
|
||||
return int(source.ID), source
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
@@ -81,9 +83,21 @@ func (service *Service) GetNextIdentifier() int {
|
||||
|
||||
// CreateStack creates a new stack.
|
||||
func (service *Service) Create(stack *portainer.Stack) error {
|
||||
if stack.GitConfig != nil {
|
||||
log.Warn().Int("stackID", int(stack.ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
|
||||
}
|
||||
|
||||
return service.Connection.CreateObjectWithId(BucketName, int(stack.ID), stack)
|
||||
}
|
||||
|
||||
func (service *Service) Update(ID portainer.StackID, stack *portainer.Stack) error {
|
||||
if stack.GitConfig != nil {
|
||||
log.Warn().Int("stackID", int(ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
|
||||
}
|
||||
|
||||
return service.BaseDataService.Update(ID, stack)
|
||||
}
|
||||
|
||||
// StackByWebhookID returns a pointer to a stack object by webhook ID.
|
||||
// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID.
|
||||
func (service *Service) StackByWebhookID(id string) (*portainer.Stack, error) {
|
||||
@@ -116,7 +130,7 @@ func (service *Service) RefreshableStacks() ([]portainer.Stack, error) {
|
||||
BucketName,
|
||||
&portainer.Stack{},
|
||||
dataservices.FilterFn(&stacks, func(e portainer.Stack) bool {
|
||||
return e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
|
||||
return e.WorkflowID != 0 && e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,14 +93,15 @@ func Test_RefreshableStacks(t *testing.T) {
|
||||
|
||||
staticStack := portainer.Stack{ID: 1}
|
||||
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}}
|
||||
refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
|
||||
intervalNoWorkflow := portainer.Stack{ID: 3, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
|
||||
refreshableStack := portainer.Stack{ID: 4, WorkflowID: 1, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
|
||||
|
||||
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
|
||||
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &intervalNoWorkflow, &refreshableStack} {
|
||||
err := store.Stack().Create(stack)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
stacks, err := store.Stack().RefreshableStacks()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
|
||||
require.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
@@ -56,9 +58,21 @@ func (service ServiceTx) GetNextIdentifier() int {
|
||||
|
||||
// CreateStack creates a new stack.
|
||||
func (service ServiceTx) Create(stack *portainer.Stack) error {
|
||||
if stack.GitConfig != nil {
|
||||
log.Warn().Int("stackID", int(stack.ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
|
||||
}
|
||||
|
||||
return service.Tx.CreateObjectWithId(BucketName, int(stack.ID), stack)
|
||||
}
|
||||
|
||||
func (service ServiceTx) Update(ID portainer.StackID, stack *portainer.Stack) error {
|
||||
if stack.GitConfig != nil {
|
||||
log.Warn().Int("stackID", int(ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
|
||||
}
|
||||
|
||||
return service.BaseDataServiceTx.Update(ID, stack)
|
||||
}
|
||||
|
||||
// StackByWebhookID returns a pointer to a stack object by webhook ID.
|
||||
// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID.
|
||||
func (service ServiceTx) StackByWebhookID(id string) (*portainer.Stack, error) {
|
||||
@@ -92,7 +106,7 @@ func (service ServiceTx) RefreshableStacks() ([]portainer.Stack, error) {
|
||||
BucketName,
|
||||
&portainer.Stack{},
|
||||
dataservices.FilterFn(&stacks, func(e portainer.Stack) bool {
|
||||
return e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
|
||||
return e.WorkflowID != 0 && e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
46
api/dataservices/workflow/service.go
Normal file
46
api/dataservices/workflow/service.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
const BucketName = "workflows"
|
||||
|
||||
type Service struct {
|
||||
dataservices.BaseDataService[portainer.Workflow, portainer.WorkflowID]
|
||||
}
|
||||
|
||||
func NewService(connection portainer.Connection) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
BaseDataService: dataservices.BaseDataService[portainer.Workflow, portainer.WorkflowID]{
|
||||
Bucket: BucketName,
|
||||
Connection: connection,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Workflow, portainer.WorkflowID]{
|
||||
Bucket: BucketName,
|
||||
Connection: service.Connection,
|
||||
Tx: tx,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) Create(workflow *portainer.Workflow) error {
|
||||
return service.Connection.CreateObject(
|
||||
BucketName,
|
||||
func(id uint64) (int, any) {
|
||||
workflow.ID = portainer.WorkflowID(id)
|
||||
return int(workflow.ID), workflow
|
||||
},
|
||||
)
|
||||
}
|
||||
20
api/dataservices/workflow/tx.go
Normal file
20
api/dataservices/workflow/tx.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
dataservices.BaseDataServiceTx[portainer.Workflow, portainer.WorkflowID]
|
||||
}
|
||||
|
||||
func (service ServiceTx) Create(workflow *portainer.Workflow) error {
|
||||
return service.Tx.CreateObject(
|
||||
BucketName,
|
||||
func(id uint64) (int, any) {
|
||||
workflow.ID = portainer.WorkflowID(id)
|
||||
return int(workflow.ID), workflow
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -130,7 +130,8 @@ func TestBackupDBFileUsesCorrectPath(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
|
||||
t.Run("backs up unencrypted db when encrypted flag is false", func(t *testing.T) {
|
||||
store.connection.SetEncrypted(false)
|
||||
err := store.connection.SetEncrypted(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
backupFilename, err := store.backupDBFile("")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -35,7 +35,9 @@ func (store *Store) Open() (newStore bool, err error) {
|
||||
// NeedsEncryptionMigration() sets encrypted=true as a side effect when a key exists.
|
||||
// We need to set it back to false so GetDatabaseFilePath() returns the path to the
|
||||
// actual unencrypted file (portainer.db) that we want to back up.
|
||||
store.connection.SetEncrypted(false)
|
||||
if err := store.connection.SetEncrypted(false); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Use backupDBFile directly since connection isn't open yet
|
||||
// and we don't want to trigger the close/open cycle of Backup()
|
||||
@@ -124,7 +126,10 @@ func (store *Store) Rollback(force bool) error {
|
||||
}
|
||||
|
||||
func (store *Store) encryptDB() error {
|
||||
store.connection.SetEncrypted(false)
|
||||
if err := store.connection.SetEncrypted(false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := store.connection.Open(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -72,12 +72,16 @@ func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, n
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
TagIDs: []portainer.TagID{},
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
Kubernetes: portainer.KubernetesDefault(),
|
||||
TagIDs: nil,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: nil,
|
||||
Kubernetes: portainer.KubernetesData{
|
||||
Configuration: portainer.KubernetesConfiguration{
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
EnableResourceOverCommit: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if TLS {
|
||||
|
||||
@@ -62,6 +62,22 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
||||
EnforceEdgeID: true,
|
||||
}
|
||||
|
||||
defaultSettings.ContainerAutomation.AutoHeal.Enabled = false
|
||||
defaultSettings.ContainerAutomation.AutoHeal.CheckInterval = "30s"
|
||||
defaultSettings.ContainerAutomation.AutoHeal.Scope = "labeled"
|
||||
|
||||
defaultSettings.ContainerAutomation.AutoUpdate.Enabled = false
|
||||
defaultSettings.ContainerAutomation.AutoUpdate.PollInterval = "6h"
|
||||
defaultSettings.ContainerAutomation.AutoUpdate.Scope = "labeled"
|
||||
defaultSettings.ContainerAutomation.AutoUpdate.Cleanup = false
|
||||
defaultSettings.ContainerAutomation.AutoUpdate.RollbackOnFailure = false
|
||||
defaultSettings.ContainerAutomation.AutoUpdate.RollbackTimeout = "120s"
|
||||
|
||||
// The automation notification webhooks are opt-in per mechanism: both the
|
||||
// auto-update and auto-heal endpoints are empty (disabled) by default.
|
||||
defaultSettings.ContainerAutomation.Notification.UpdateWebhookURL = ""
|
||||
defaultSettings.ContainerAutomation.Notification.HealWebhookURL = ""
|
||||
|
||||
return store.SettingsService.UpdateSettings(defaultSettings)
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -88,6 +88,9 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai
|
||||
EdgeGroupService: store.EdgeGroupService,
|
||||
TunnelServerService: store.TunnelServerService,
|
||||
PendingActionsService: store.PendingActionsService,
|
||||
CustomTemplateService: store.CustomTemplateService,
|
||||
SourceService: store.SourceService,
|
||||
WorkflowService: store.WorkflowService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
288
api/datastore/migrator/migrate_2_43_0.go
Normal file
288
api/datastore/migrator/migrate_2_43_0.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices/stack"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type legacyRepoConfig struct {
|
||||
URL string
|
||||
ReferenceName string
|
||||
ConfigFilePath string
|
||||
Authentication *legacyGitAuthentication
|
||||
ConfigHash string
|
||||
TLSSkipVerify bool
|
||||
}
|
||||
|
||||
type legacyGitAuthentication struct {
|
||||
Username string
|
||||
Password string
|
||||
Provider int `json:",omitempty"`
|
||||
AuthorizationType int `json:",omitempty"`
|
||||
}
|
||||
|
||||
func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
|
||||
if lrc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &gittypes.RepoConfig{
|
||||
URL: lrc.URL,
|
||||
ReferenceName: lrc.ReferenceName,
|
||||
ConfigFilePath: lrc.ConfigFilePath,
|
||||
ConfigHash: lrc.ConfigHash,
|
||||
TLSSkipVerify: lrc.TLSSkipVerify,
|
||||
}
|
||||
|
||||
if lrc.Authentication != nil {
|
||||
cfg.Authentication = &gittypes.GitAuthentication{
|
||||
Username: lrc.Authentication.Username,
|
||||
Password: lrc.Authentication.Password,
|
||||
Provider: gittypes.GitProvider(lrc.Authentication.Provider),
|
||||
AuthorizationType: gittypes.GitCredentialAuthType(lrc.Authentication.AuthorizationType),
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
type legacyStack struct {
|
||||
ID int `json:"Id"`
|
||||
GitConfig *legacyRepoConfig `json:"GitConfig"`
|
||||
WorkflowID *int
|
||||
}
|
||||
|
||||
// sourceDedupeKey is the identity used to detect duplicate Sources during migration.
|
||||
// Two stacks sharing the same URL and credentials must reuse the same Source record.
|
||||
type sourceDedupeKey struct {
|
||||
url string
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func gitSourceKey(cfg *gittypes.RepoConfig) sourceDedupeKey {
|
||||
key := sourceDedupeKey{url: cfg.URL}
|
||||
if cfg.Authentication != nil {
|
||||
key.username = cfg.Authentication.Username
|
||||
key.password = cfg.Authentication.Password
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
|
||||
log.Info().Msg("migrating git-backed stacks to Source+Workflow records")
|
||||
|
||||
var legacyStacks []legacyStack
|
||||
|
||||
err := m.stackService.Connection.GetAll(
|
||||
stack.BucketName,
|
||||
new(legacyStack),
|
||||
func(obj any) (any, error) {
|
||||
s, ok := obj.(*legacyStack)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected type reading stack bucket: %T", obj)
|
||||
}
|
||||
|
||||
legacyStacks = append(legacyStacks, *s)
|
||||
|
||||
return new(legacyStack), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingSources, err := m.sourceService.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourcesByKey := make(map[sourceDedupeKey]portainer.SourceID, len(existingSources))
|
||||
for _, src := range existingSources {
|
||||
if src.Git != nil {
|
||||
sourcesByKey[gitSourceKey(src.Git)] = src.ID
|
||||
}
|
||||
}
|
||||
|
||||
for _, ls := range legacyStacks {
|
||||
if ls.GitConfig == nil || (ls.WorkflowID != nil && *ls.WorkflowID != 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg := ls.GitConfig.toRepoConfig()
|
||||
cfg.URL = gittypes.SanitizeURL(cfg.URL)
|
||||
key := gitSourceKey(cfg)
|
||||
|
||||
var newSrcID portainer.SourceID
|
||||
|
||||
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
srcID, exists := sourcesByKey[key]
|
||||
|
||||
if !exists {
|
||||
src := &portainer.Source{
|
||||
Name: gittypes.RepoName(cfg.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: cfg,
|
||||
}
|
||||
if err := m.sourceService.Tx(tx).Create(src); err != nil {
|
||||
return fmt.Errorf("failed to create source for stack %d: %w", ls.ID, err)
|
||||
}
|
||||
srcID = src.ID
|
||||
newSrcID = src.ID
|
||||
}
|
||||
|
||||
liveStack, err := m.stackService.Tx(tx).Read(portainer.StackID(ls.ID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read stack %d: %w", ls.ID, err)
|
||||
}
|
||||
|
||||
wf := &portainer.Workflow{
|
||||
Name: liveStack.Name,
|
||||
Artifacts: []portainer.Artifact{{
|
||||
StackID: portainer.StackID(ls.ID),
|
||||
Files: []portainer.ArtifactFile{{
|
||||
SourceID: srcID,
|
||||
Path: cfg.ConfigFilePath,
|
||||
Ref: cfg.ReferenceName,
|
||||
Hash: cfg.ConfigHash,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if err := m.workflowService.Tx(tx).Create(wf); err != nil {
|
||||
return fmt.Errorf("failed to create workflow for stack %d: %w", ls.ID, err)
|
||||
}
|
||||
|
||||
liveStack.WorkflowID = wf.ID
|
||||
liveStack.GitConfig = nil
|
||||
|
||||
return m.stackService.Tx(tx).Update(portainer.StackID(ls.ID), liveStack)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to migrate stack %d: %w", ls.ID, err)
|
||||
}
|
||||
|
||||
if newSrcID != 0 {
|
||||
sourcesByKey[key] = newSrcID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
|
||||
log.Info().Msg("migrating git-backed custom templates to Source records")
|
||||
|
||||
templates, err := m.customTemplateService.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingSources, err := m.sourceService.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourcesByKey := make(map[sourceDedupeKey]portainer.SourceID, len(existingSources))
|
||||
for _, src := range existingSources {
|
||||
if src.Git != nil {
|
||||
sourcesByKey[gitSourceKey(src.Git)] = src.ID
|
||||
}
|
||||
}
|
||||
|
||||
for i := range templates {
|
||||
t := &templates[i]
|
||||
if t.GitConfig == nil || t.Artifact != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg := &gittypes.RepoConfig{
|
||||
URL: gittypes.SanitizeURL(t.GitConfig.URL),
|
||||
Authentication: t.GitConfig.Authentication,
|
||||
TLSSkipVerify: t.GitConfig.TLSSkipVerify,
|
||||
}
|
||||
|
||||
key := gitSourceKey(cfg)
|
||||
|
||||
var newSrcID portainer.SourceID
|
||||
|
||||
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
srcID, exists := sourcesByKey[key]
|
||||
|
||||
if !exists {
|
||||
src := &portainer.Source{
|
||||
Name: gittypes.RepoName(cfg.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: cfg,
|
||||
}
|
||||
if err := m.sourceService.Tx(tx).Create(src); err != nil {
|
||||
return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err)
|
||||
}
|
||||
srcID = src.ID
|
||||
newSrcID = src.ID
|
||||
}
|
||||
|
||||
t.Artifact = &portainer.Artifact{
|
||||
Files: []portainer.ArtifactFile{{
|
||||
SourceID: srcID,
|
||||
Path: t.GitConfig.ConfigFilePath,
|
||||
Ref: t.GitConfig.ReferenceName,
|
||||
Hash: t.GitConfig.ConfigHash,
|
||||
}},
|
||||
}
|
||||
t.GitConfig = nil
|
||||
|
||||
return m.customTemplateService.Tx(tx).Update(t.ID, t)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to migrate custom template %d: %w", t.ID, err)
|
||||
}
|
||||
|
||||
if newSrcID != 0 {
|
||||
sourcesByKey[key] = newSrcID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateContainerAutomationSettings_2_43_0 backfills the native container
|
||||
// automation defaults into existing installs so the new ContainerAutomation
|
||||
// block is populated without changing behavior: auto-heal (disabled, 30s
|
||||
// interval, "labeled" scope) and auto-update (disabled, 6h interval, "labeled"
|
||||
// scope, no cleanup, no rollback with a 120s rollback timeout).
|
||||
func (m *Migrator) migrateContainerAutomationSettings_2_43_0() error {
|
||||
log.Info().Msg("backfilling container automation (auto-heal, auto-update) settings")
|
||||
|
||||
settings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
autoHeal := &settings.ContainerAutomation.AutoHeal
|
||||
if autoHeal.CheckInterval == "" {
|
||||
autoHeal.CheckInterval = "30s"
|
||||
}
|
||||
|
||||
if autoHeal.Scope == "" {
|
||||
autoHeal.Scope = "labeled"
|
||||
}
|
||||
|
||||
autoUpdate := &settings.ContainerAutomation.AutoUpdate
|
||||
if autoUpdate.PollInterval == "" {
|
||||
autoUpdate.PollInterval = "6h"
|
||||
}
|
||||
|
||||
if autoUpdate.Scope == "" {
|
||||
autoUpdate.Scope = "labeled"
|
||||
}
|
||||
|
||||
if autoUpdate.RollbackTimeout == "" {
|
||||
autoUpdate.RollbackTimeout = "120s"
|
||||
}
|
||||
|
||||
return m.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
462
api/datastore/migrator/migrate_2_43_0_test.go
Normal file
462
api/datastore/migrator/migrate_2_43_0_test.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/dataservices/customtemplate"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
"github.com/portainer/portainer/api/dataservices/stack"
|
||||
"github.com/portainer/portainer/api/dataservices/workflow"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
workflowSvc, err := workflow.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
WorkflowService: workflowSvc,
|
||||
})
|
||||
|
||||
gitStack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "git-stack",
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/example/repo",
|
||||
ReferenceName: "refs/heads/main",
|
||||
ConfigHash: "abc123",
|
||||
},
|
||||
}
|
||||
err = conn.CreateObjectWithId(stack.BucketName, int(gitStack.ID), gitStack)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
migrated, err := stackSvc.Read(gitStack.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, migrated.WorkflowID)
|
||||
require.Nil(t, migrated.GitConfig)
|
||||
|
||||
wf, err := workflowSvc.Read(migrated.WorkflowID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, wf.Artifacts, 1)
|
||||
require.Len(t, wf.Artifacts[0].Files, 1)
|
||||
|
||||
src, err := sourceSvc.Read(wf.Artifacts[0].Files[0].SourceID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.SourceTypeGit, src.Type)
|
||||
require.Equal(t, gitStack.GitConfig.URL, src.Git.URL)
|
||||
require.Equal(t, gitStack.GitConfig.ReferenceName, src.Git.ReferenceName)
|
||||
}
|
||||
|
||||
func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
workflowSvc, err := workflow.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
WorkflowService: workflowSvc,
|
||||
})
|
||||
|
||||
plainStack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "plain-stack",
|
||||
}
|
||||
err = conn.CreateObjectWithId(stack.BucketName, int(plainStack.ID), plainStack)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := stackSvc.Read(plainStack.ID)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, result.WorkflowID)
|
||||
require.Nil(t, result.GitConfig)
|
||||
|
||||
sources, err := sourceSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, sources)
|
||||
|
||||
workflows, err := workflowSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, workflows)
|
||||
}
|
||||
|
||||
func TestMigrateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
workflowSvc, err := workflow.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
WorkflowService: workflowSvc,
|
||||
})
|
||||
|
||||
sharedURL := "https://github.com/example/shared-repo"
|
||||
|
||||
stack1 := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "stack-a",
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: sharedURL,
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
stack2 := &portainer.Stack{
|
||||
ID: 2,
|
||||
Name: "stack-b",
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: sharedURL,
|
||||
ReferenceName: "refs/heads/develop",
|
||||
},
|
||||
}
|
||||
err = conn.CreateObjectWithId(stack.BucketName, int(stack1.ID), stack1)
|
||||
require.NoError(t, err)
|
||||
err = conn.CreateObjectWithId(stack.BucketName, int(stack2.ID), stack2)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
sources, err := sourceSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sources, 1, "two stacks with the same URL must share one Source")
|
||||
|
||||
workflows, err := workflowSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workflows, 2, "each stack must get its own Workflow")
|
||||
|
||||
sharedSourceID := sources[0].ID
|
||||
for _, wf := range workflows {
|
||||
require.Len(t, wf.Artifacts, 1)
|
||||
require.Len(t, wf.Artifacts[0].Files, 1)
|
||||
require.Equal(t, sharedSourceID, wf.Artifacts[0].Files[0].SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
workflowSvc, err := workflow.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
WorkflowService: workflowSvc,
|
||||
})
|
||||
|
||||
gitStack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "git-stack",
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/example/repo",
|
||||
},
|
||||
}
|
||||
err = conn.CreateObjectWithId(stack.BucketName, int(gitStack.ID), gitStack)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second run must not create duplicate Source/Workflow records
|
||||
err = m.migrateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
sources, err := sourceSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sources, 1)
|
||||
|
||||
workflows, err := workflowSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workflows, 1)
|
||||
}
|
||||
|
||||
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_GitTemplateMigrated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
customTemplateSvc, err := customtemplate.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
CustomTemplateService: customTemplateSvc,
|
||||
})
|
||||
|
||||
tmpl := &portainer.CustomTemplate{
|
||||
ID: 1,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/example/repo",
|
||||
ReferenceName: "refs/heads/main",
|
||||
ConfigFilePath: "docker-compose.yml",
|
||||
ConfigHash: "abc123",
|
||||
},
|
||||
}
|
||||
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
migrated, err := customTemplateSvc.Read(tmpl.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, migrated.Artifact)
|
||||
require.Nil(t, migrated.GitConfig)
|
||||
require.Len(t, migrated.Artifact.Files, 1)
|
||||
require.Equal(t, "refs/heads/main", migrated.Artifact.Files[0].Ref)
|
||||
require.Equal(t, "docker-compose.yml", migrated.Artifact.Files[0].Path)
|
||||
require.Equal(t, "abc123", migrated.Artifact.Files[0].Hash)
|
||||
|
||||
src, err := sourceSvc.Read(migrated.Artifact.Files[0].SourceID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.SourceTypeGit, src.Type)
|
||||
require.Equal(t, "https://github.com/example/repo", src.Git.URL)
|
||||
}
|
||||
|
||||
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
customTemplateSvc, err := customtemplate.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
CustomTemplateService: customTemplateSvc,
|
||||
})
|
||||
|
||||
tmpl := &portainer.CustomTemplate{ID: 1, Title: "plain-template"}
|
||||
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := customTemplateSvc.Read(tmpl.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result.Artifact)
|
||||
require.Nil(t, result.GitConfig)
|
||||
|
||||
sources, err := sourceSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, sources)
|
||||
}
|
||||
|
||||
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
customTemplateSvc, err := customtemplate.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
CustomTemplateService: customTemplateSvc,
|
||||
})
|
||||
|
||||
// Template already has Artifact set (already migrated)
|
||||
srcID := portainer.SourceID(99)
|
||||
tmpl := &portainer.CustomTemplate{
|
||||
ID: 1,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/example/repo",
|
||||
},
|
||||
Artifact: &portainer.Artifact{
|
||||
Files: []portainer.ArtifactFile{{SourceID: srcID}},
|
||||
},
|
||||
}
|
||||
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
sources, err := sourceSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, sources, "no new sources should be created for already-migrated templates")
|
||||
}
|
||||
|
||||
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
customTemplateSvc, err := customtemplate.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
CustomTemplateService: customTemplateSvc,
|
||||
})
|
||||
|
||||
sharedURL := "https://github.com/example/shared-repo"
|
||||
|
||||
tmpl1 := &portainer.CustomTemplate{
|
||||
ID: 1,
|
||||
Title: "template-a",
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: sharedURL,
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
tmpl2 := &portainer.CustomTemplate{
|
||||
ID: 2,
|
||||
Title: "template-b",
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: sharedURL,
|
||||
ReferenceName: "refs/heads/develop",
|
||||
},
|
||||
}
|
||||
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl1.ID), tmpl1)
|
||||
require.NoError(t, err)
|
||||
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl2.ID), tmpl2)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
sources, err := sourceSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sources, 1, "two templates with the same URL must share one Source")
|
||||
|
||||
sharedSrcID := sources[0].ID
|
||||
|
||||
migrated1, err := customTemplateSvc.Read(tmpl1.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, migrated1.Artifact)
|
||||
require.Equal(t, sharedSrcID, migrated1.Artifact.Files[0].SourceID)
|
||||
|
||||
migrated2, err := customTemplateSvc.Read(tmpl2.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, migrated2.Artifact)
|
||||
require.Equal(t, sharedSrcID, migrated2.Artifact.Files[0].SourceID)
|
||||
}
|
||||
|
||||
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(conn)
|
||||
|
||||
stackSvc, err := stack.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
sourceSvc, err := source.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
customTemplateSvc, err := customtemplate.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{
|
||||
StackService: stackSvc,
|
||||
SourceService: sourceSvc,
|
||||
CustomTemplateService: customTemplateSvc,
|
||||
})
|
||||
|
||||
tmpl := &portainer.CustomTemplate{
|
||||
ID: 1,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/example/repo",
|
||||
},
|
||||
}
|
||||
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second run must not create duplicate Source records
|
||||
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
sources, err := sourceSvc.ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sources, 1)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices/customtemplate"
|
||||
"github.com/portainer/portainer/api/dataservices/dockerhub"
|
||||
"github.com/portainer/portainer/api/dataservices/edgegroup"
|
||||
"github.com/portainer/portainer/api/dataservices/edgejob"
|
||||
@@ -21,12 +22,14 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/schedule"
|
||||
"github.com/portainer/portainer/api/dataservices/settings"
|
||||
"github.com/portainer/portainer/api/dataservices/snapshot"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
"github.com/portainer/portainer/api/dataservices/stack"
|
||||
"github.com/portainer/portainer/api/dataservices/tag"
|
||||
"github.com/portainer/portainer/api/dataservices/teammembership"
|
||||
"github.com/portainer/portainer/api/dataservices/tunnelserver"
|
||||
"github.com/portainer/portainer/api/dataservices/user"
|
||||
"github.com/portainer/portainer/api/dataservices/version"
|
||||
"github.com/portainer/portainer/api/dataservices/workflow"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
@@ -64,6 +67,9 @@ type (
|
||||
edgeGroupService *edgegroup.Service
|
||||
TunnelServerService *tunnelserver.Service
|
||||
pendingActionsService *pendingactions.Service
|
||||
customTemplateService *customtemplate.Service
|
||||
sourceService *source.Service
|
||||
workflowService *workflow.Service
|
||||
}
|
||||
|
||||
// MigratorParameters represents the required parameters to create a new Migrator instance.
|
||||
@@ -94,6 +100,9 @@ type (
|
||||
EdgeGroupService *edgegroup.Service
|
||||
TunnelServerService *tunnelserver.Service
|
||||
PendingActionsService *pendingactions.Service
|
||||
CustomTemplateService *customtemplate.Service
|
||||
SourceService *source.Service
|
||||
WorkflowService *workflow.Service
|
||||
}
|
||||
)
|
||||
|
||||
@@ -126,6 +135,9 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
|
||||
edgeGroupService: parameters.EdgeGroupService,
|
||||
TunnelServerService: parameters.TunnelServerService,
|
||||
pendingActionsService: parameters.PendingActionsService,
|
||||
customTemplateService: parameters.CustomTemplateService,
|
||||
sourceService: parameters.SourceService,
|
||||
workflowService: parameters.WorkflowService,
|
||||
}
|
||||
|
||||
migrator.initMigrations()
|
||||
@@ -260,6 +272,12 @@ func (m *Migrator) initMigrations() {
|
||||
|
||||
m.addMigrations("2.40.0", m.migrateRegistryAccessSASecrets_2_40_0)
|
||||
|
||||
m.addMigrations("2.43.0",
|
||||
m.migrateGitConfigToSources_2_43_0,
|
||||
m.migrateCustomTemplateGitConfigToSources_2_43_0,
|
||||
m.migrateContainerAutomationSettings_2_43_0,
|
||||
)
|
||||
|
||||
// WARNING: do not change migrations that have already been released!
|
||||
|
||||
// Add new migrations above...
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/dataservices/allowlist"
|
||||
"github.com/portainer/portainer/api/dataservices/apikeyrepository"
|
||||
"github.com/portainer/portainer/api/dataservices/customtemplate"
|
||||
"github.com/portainer/portainer/api/dataservices/dockerhub"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/schedule"
|
||||
"github.com/portainer/portainer/api/dataservices/settings"
|
||||
"github.com/portainer/portainer/api/dataservices/snapshot"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
"github.com/portainer/portainer/api/dataservices/ssl"
|
||||
"github.com/portainer/portainer/api/dataservices/stack"
|
||||
"github.com/portainer/portainer/api/dataservices/tag"
|
||||
@@ -35,6 +37,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/user"
|
||||
"github.com/portainer/portainer/api/dataservices/version"
|
||||
"github.com/portainer/portainer/api/dataservices/webhook"
|
||||
"github.com/portainer/portainer/api/dataservices/workflow"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
@@ -49,6 +52,7 @@ type Store struct {
|
||||
connection portainer.Connection
|
||||
|
||||
fileService portainer.FileService
|
||||
AllowListService *allowlist.Service
|
||||
CustomTemplateService *customtemplate.Service
|
||||
DockerHubService *dockerhub.Service
|
||||
EdgeGroupService *edgegroup.Service
|
||||
@@ -67,6 +71,7 @@ type Store struct {
|
||||
ScheduleService *schedule.Service
|
||||
SettingsService *settings.Service
|
||||
SnapshotService *snapshot.Service
|
||||
SourceService *source.Service
|
||||
SSLSettingsService *ssl.Service
|
||||
StackService *stack.Service
|
||||
TagService *tag.Service
|
||||
@@ -76,10 +81,17 @@ type Store struct {
|
||||
UserService *user.Service
|
||||
VersionService *version.Service
|
||||
WebhookService *webhook.Service
|
||||
WorkflowService *workflow.Service
|
||||
PendingActionsService *pendingactions.Service
|
||||
}
|
||||
|
||||
func (store *Store) initServices() error {
|
||||
allowListService, err := allowlist.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.AllowListService = allowListService
|
||||
|
||||
authorizationsetService, err := role.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -179,6 +191,12 @@ func (store *Store) initServices() error {
|
||||
}
|
||||
store.SnapshotService = snapshotService
|
||||
|
||||
sourceService, err := source.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.SourceService = sourceService
|
||||
|
||||
sslSettingsService, err := ssl.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -239,6 +257,12 @@ func (store *Store) initServices() error {
|
||||
}
|
||||
store.WebhookService = webhookService
|
||||
|
||||
workflowService, err := workflow.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.WorkflowService = workflowService
|
||||
|
||||
scheduleService, err := schedule.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -259,6 +283,11 @@ func (store *Store) PendingActions() dataservices.PendingActionsService {
|
||||
return store.PendingActionsService
|
||||
}
|
||||
|
||||
// AllowList gives access to the AllowList data management layer
|
||||
func (store *Store) AllowList() dataservices.AllowListService {
|
||||
return store.AllowListService
|
||||
}
|
||||
|
||||
// CustomTemplate gives access to the CustomTemplate data management layer
|
||||
func (store *Store) CustomTemplate() dataservices.CustomTemplateService {
|
||||
return store.CustomTemplateService
|
||||
@@ -332,6 +361,11 @@ func (store *Store) Snapshot() dataservices.SnapshotService {
|
||||
return store.SnapshotService
|
||||
}
|
||||
|
||||
// Source gives access to the Source data management layer
|
||||
func (store *Store) Source() dataservices.SourceService {
|
||||
return store.SourceService
|
||||
}
|
||||
|
||||
// SSLSettings gives access to the SSL Settings data management layer
|
||||
func (store *Store) SSLSettings() dataservices.SSLSettingsService {
|
||||
return store.SSLSettingsService
|
||||
@@ -377,6 +411,11 @@ func (store *Store) Webhook() dataservices.WebhookService {
|
||||
return store.WebhookService
|
||||
}
|
||||
|
||||
// Workflow gives access to the Workflow data management layer
|
||||
func (store *Store) Workflow() dataservices.WorkflowService {
|
||||
return store.WorkflowService
|
||||
}
|
||||
|
||||
type storeExport struct {
|
||||
CustomTemplate []portainer.CustomTemplate `json:"customtemplates,omitempty"`
|
||||
EdgeGroup []portainer.EdgeGroup `json:"edgegroups,omitempty"`
|
||||
@@ -394,6 +433,7 @@ type storeExport struct {
|
||||
Settings portainer.Settings `json:"settings,omitzero"`
|
||||
Snapshot []portainer.Snapshot `json:"snapshots,omitempty"`
|
||||
SSLSettings portainer.SSLSettings `json:"ssl,omitzero"`
|
||||
Source []portainer.Source `json:"sources,omitempty"`
|
||||
Stack []portainer.Stack `json:"stacks,omitempty"`
|
||||
Tag []portainer.Tag `json:"tags,omitempty"`
|
||||
TeamMembership []portainer.TeamMembership `json:"team_membership,omitempty"`
|
||||
@@ -402,6 +442,7 @@ type storeExport struct {
|
||||
User []portainer.User `json:"users,omitempty"`
|
||||
Version models.Version `json:"version,omitzero"`
|
||||
Webhook []portainer.Webhook `json:"webhooks,omitempty"`
|
||||
Workflow []portainer.Workflow `json:"workflows,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -536,6 +577,14 @@ func (store *Store) Export(filename string) (err error) {
|
||||
backup.SSLSettings = *settings
|
||||
}
|
||||
|
||||
if s, err := store.Source().ReadAll(); err != nil {
|
||||
if !store.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Msg("exporting Sources")
|
||||
}
|
||||
} else {
|
||||
backup.Source = s
|
||||
}
|
||||
|
||||
if t, err := store.Stack().ReadAll(); err != nil {
|
||||
if !store.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Msg("exporting Stacks")
|
||||
@@ -592,6 +641,14 @@ func (store *Store) Export(filename string) (err error) {
|
||||
backup.Webhook = webhooks
|
||||
}
|
||||
|
||||
if w, err := store.Workflow().ReadAll(); err != nil {
|
||||
if !store.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Msg("exporting Workflows")
|
||||
}
|
||||
} else {
|
||||
backup.Workflow = w
|
||||
}
|
||||
|
||||
if version, err := store.Version().Version(); err != nil {
|
||||
if !store.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Msg("exporting Version")
|
||||
@@ -610,7 +667,7 @@ func (store *Store) Export(filename string) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filename, b, 0600)
|
||||
return os.WriteFile(filename, b, 0o600)
|
||||
}
|
||||
|
||||
func (store *Store) Import(filename string) (err error) {
|
||||
@@ -710,6 +767,18 @@ func (store *Store) Import(filename string) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range backup.Source {
|
||||
if err := store.Source().Update(v.ID, &v); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to update the source in the database")
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range backup.Workflow {
|
||||
if err := store.Workflow().Update(v.ID, &v); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to update the workflow in the database")
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range backup.Stack {
|
||||
if err := store.Stack().Update(v.ID, &v); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to update the stack in the database")
|
||||
|
||||
@@ -14,6 +14,10 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
|
||||
return tx.store.IsErrObjectNotFound(err)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) AllowList() dataservices.AllowListService {
|
||||
return tx.store.AllowListService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService {
|
||||
return tx.store.CustomTemplateService.Tx(tx.tx)
|
||||
}
|
||||
@@ -74,6 +78,10 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
|
||||
return tx.store.SnapshotService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) Source() dataservices.SourceService {
|
||||
return tx.store.SourceService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
|
||||
return tx.store.SSLSettingsService.Tx(tx.tx)
|
||||
}
|
||||
@@ -102,3 +110,7 @@ func (tx *StoreTx) User() dataservices.UserService {
|
||||
|
||||
func (tx *StoreTx) Version() dataservices.VersionService { return nil }
|
||||
func (tx *StoreTx) Webhook() dataservices.WebhookService { return nil }
|
||||
|
||||
func (tx *StoreTx) Workflow() dataservices.WorkflowService {
|
||||
return tx.store.WorkflowService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"allowlist": null,
|
||||
"api_key": null,
|
||||
"customtemplates": null,
|
||||
"dockerhub": [
|
||||
@@ -33,11 +34,7 @@
|
||||
],
|
||||
"endpoints": [
|
||||
{
|
||||
"Agent": {
|
||||
"Version": ""
|
||||
},
|
||||
"AuthorizedTeams": null,
|
||||
"AuthorizedUsers": null,
|
||||
"Agent": {},
|
||||
"AzureCredentials": {
|
||||
"ApplicationID": "",
|
||||
"AuthenticationKey": "",
|
||||
@@ -53,7 +50,6 @@
|
||||
},
|
||||
"EdgeCheckinInterval": 0,
|
||||
"EdgeKey": "",
|
||||
"Gpus": [],
|
||||
"GroupId": 1,
|
||||
"Heartbeat": false,
|
||||
"Id": 1,
|
||||
@@ -62,10 +58,8 @@
|
||||
"AllowNoneIngressClass": false,
|
||||
"EnableResourceOverCommit": false,
|
||||
"IngressAvailabilityPerNamespace": true,
|
||||
"IngressClasses": null,
|
||||
"ResourceOverCommitPercentage": 0,
|
||||
"RestrictDefaultNamespace": false,
|
||||
"StorageClasses": null,
|
||||
"UseLoadBalancer": false,
|
||||
"UseServerMetrics": false
|
||||
},
|
||||
@@ -73,8 +67,7 @@
|
||||
"IsServerIngressClassDetected": false,
|
||||
"IsServerMetricsDetected": false,
|
||||
"IsServerStorageDetected": false
|
||||
},
|
||||
"Snapshots": []
|
||||
}
|
||||
},
|
||||
"LastCheckInDate": 0,
|
||||
"Name": "local",
|
||||
@@ -96,18 +89,13 @@
|
||||
"allowVolumeBrowserForRegularUsers": false,
|
||||
"enableHostManagementFeatures": false
|
||||
},
|
||||
"Snapshots": [],
|
||||
"Status": 1,
|
||||
"TLSConfig": {
|
||||
"TLS": false,
|
||||
"TLSSkipVerify": false
|
||||
},
|
||||
"TagIds": [],
|
||||
"Tags": null,
|
||||
"TeamAccessPolicies": {},
|
||||
"Type": 1,
|
||||
"URL": "unix:///var/run/docker.sock",
|
||||
"UserAccessPolicies": {}
|
||||
"URL": "unix:///var/run/docker.sock"
|
||||
}
|
||||
],
|
||||
"extension": null,
|
||||
@@ -597,6 +585,25 @@
|
||||
"AllowStackManagementForRegularUsers": true,
|
||||
"AuthenticationMethod": 1,
|
||||
"BlackListedLabels": [],
|
||||
"ContainerAutomation": {
|
||||
"AutoHeal": {
|
||||
"CheckInterval": "30s",
|
||||
"Enabled": false,
|
||||
"Scope": "labeled"
|
||||
},
|
||||
"AutoUpdate": {
|
||||
"Cleanup": false,
|
||||
"Enabled": false,
|
||||
"PollInterval": "6h",
|
||||
"RollbackOnFailure": false,
|
||||
"RollbackTimeout": "120s",
|
||||
"Scope": "labeled"
|
||||
},
|
||||
"Notification": {
|
||||
"HealWebhookURL": "",
|
||||
"UpdateWebhookURL": ""
|
||||
}
|
||||
},
|
||||
"Edge": {
|
||||
"CommandInterval": 0,
|
||||
"PingInterval": 0,
|
||||
@@ -616,7 +623,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.42.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.43.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -669,8 +676,6 @@
|
||||
"ContainerCount": 0,
|
||||
"DiagnosticsData": {},
|
||||
"DockerSnapshotRaw": {
|
||||
"Containers": null,
|
||||
"Images": null,
|
||||
"Info": {
|
||||
"Architecture": "",
|
||||
"CDISpecDirs": null,
|
||||
@@ -746,7 +751,6 @@
|
||||
"SystemTime": "",
|
||||
"Warnings": null
|
||||
},
|
||||
"Networks": null,
|
||||
"Version": {
|
||||
"ApiVersion": "",
|
||||
"Arch": "",
|
||||
@@ -765,12 +769,10 @@
|
||||
},
|
||||
"DockerVersion": "20.10.13",
|
||||
"GpuUseAll": false,
|
||||
"GpuUseList": null,
|
||||
"HealthyContainerCount": 0,
|
||||
"ImageCount": 9,
|
||||
"IsPodman": false,
|
||||
"NodeCount": 0,
|
||||
"PerformanceMetrics": null,
|
||||
"RunningContainerCount": 5,
|
||||
"ServiceCount": 0,
|
||||
"StackCount": 2,
|
||||
@@ -786,6 +788,7 @@
|
||||
"Kubernetes": null
|
||||
}
|
||||
],
|
||||
"sources": null,
|
||||
"ssl": {
|
||||
"certPath": "",
|
||||
"httpEnabled": true,
|
||||
@@ -937,7 +940,8 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.42.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
"webhooks": null,
|
||||
"workflows": null
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
@@ -88,7 +90,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
|
||||
client.WithHTTPClient(httpCli),
|
||||
}
|
||||
|
||||
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
|
||||
if endpoint.TLSConfig.TLS {
|
||||
opts = append(opts, client.WithScheme("https"))
|
||||
}
|
||||
|
||||
@@ -122,7 +124,7 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
|
||||
client.WithHTTPHeaders(headers),
|
||||
}
|
||||
|
||||
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
|
||||
if endpoint.TLSConfig.TLS {
|
||||
opts = append(opts, client.WithScheme("https"))
|
||||
}
|
||||
|
||||
@@ -184,17 +186,20 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
||||
transport := &NodeNameTransport{
|
||||
Transport: &http.Transport{},
|
||||
}
|
||||
|
||||
var transport *NodeNameTransport
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = tlsConfig
|
||||
transport = &NodeNameTransport{
|
||||
Transport: ssrf.NewTransport(tlsConfig),
|
||||
}
|
||||
} else {
|
||||
transport = &NodeNameTransport{
|
||||
Transport: ssrf.NewTransport(nil),
|
||||
}
|
||||
}
|
||||
|
||||
clientTimeout := defaultDockerRequestTimeout
|
||||
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -15,28 +16,33 @@ import (
|
||||
imagetypes "go.podman.io/image/v5/types"
|
||||
)
|
||||
|
||||
// Options holds docker registry object options
|
||||
type Options struct {
|
||||
Auth imagetypes.DockerAuthConfig
|
||||
Timeout time.Duration
|
||||
const digestFetchTimeout = 5 * time.Second
|
||||
|
||||
// ClientFactory creates Docker clients for a given environment.
|
||||
type ClientFactory interface {
|
||||
CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*dockerclient.Client, error)
|
||||
}
|
||||
|
||||
// RegistryAuthProvider looks up registry credentials for an image.
|
||||
type RegistryAuthProvider interface {
|
||||
RegistryAuth(image Image) (string, string, error)
|
||||
}
|
||||
|
||||
type DigestClient struct {
|
||||
clientFactory *dockerclient.ClientFactory
|
||||
opts Options
|
||||
clientFactory ClientFactory
|
||||
sysCtx *imagetypes.SystemContext
|
||||
registryClient *RegistryClient
|
||||
registryClient RegistryAuthProvider
|
||||
}
|
||||
|
||||
func NewClientWithRegistry(registryClient *RegistryClient, clientFactory *dockerclient.ClientFactory) *DigestClient {
|
||||
func NewClientWithRegistry(registryClient RegistryAuthProvider, clientFactory ClientFactory) *DigestClient {
|
||||
return &DigestClient{
|
||||
clientFactory: clientFactory,
|
||||
registryClient: registryClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
|
||||
ctx, cancel := c.timeoutContext()
|
||||
func (c *DigestClient) RemoteDigest(ctx context.Context, image Image) (digest.Digest, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, digestFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Docker references with both a tag and digest are currently not supported
|
||||
@@ -170,14 +176,3 @@ func ParseRepoTag(repoTag string) *Image {
|
||||
|
||||
return &image
|
||||
}
|
||||
|
||||
func (c *DigestClient) timeoutContext() (context.Context, context.CancelFunc) {
|
||||
ctx := context.Background()
|
||||
var cancel context.CancelFunc = func() {}
|
||||
|
||||
if c.opts.Timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, c.opts.Timeout)
|
||||
}
|
||||
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
@@ -24,14 +24,22 @@ func NewRegistryClient(dataStore dataservices.DataStore) *RegistryClient {
|
||||
}
|
||||
|
||||
func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) {
|
||||
registries, err := c.dataStore.Registry().ReadAll()
|
||||
registry, err := cachedRegistry(image.Opts.Name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var registries []portainer.Registry
|
||||
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
registries, err = tx.Registry().ReadAll()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
registry, err = findBestMatchRegistry(image.Opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.Authentication {
|
||||
@@ -54,14 +62,22 @@ func (c *RegistryClient) CertainRegistryAuth(registry *portainer.Registry) (stri
|
||||
}
|
||||
|
||||
func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) {
|
||||
registries, err := c.dataStore.Registry().ReadAll()
|
||||
registry, err := cachedRegistry(image.Opts.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var registries []portainer.Registry
|
||||
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
registries, err = tx.Registry().ReadAll()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", err
|
||||
registry, err = findBestMatchRegistry(image.Opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.Authentication {
|
||||
@@ -121,7 +137,7 @@ func findBestMatchRegistry(repository string, registries []portainer.Registry) (
|
||||
return nil, errors.New("no registries matched")
|
||||
}
|
||||
|
||||
registriesCache.Set(repository, match, 0)
|
||||
registriesCache.Set(repository, *match, 0)
|
||||
|
||||
return match, nil
|
||||
}
|
||||
|
||||
@@ -57,6 +57,21 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindBestMatchRegistryCachesResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repository := "caching-test/nginx:latest"
|
||||
registries := []portainer.Registry{createNewRegistry("docker.io", "", true)}
|
||||
|
||||
r, err := findBestMatchRegistry(repository, registries)
|
||||
require.NoError(t, err)
|
||||
|
||||
cached, err := cachedRegistry(repository)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, r.URL, cached.URL)
|
||||
require.Equal(t, r.Authentication, cached.Authentication)
|
||||
}
|
||||
|
||||
func createNewRegistry(domain, username string, auth bool) portainer.Registry {
|
||||
registry := portainer.Registry{
|
||||
URL: domain,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
consts "github.com/portainer/portainer/api/docker/consts"
|
||||
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
"github.com/patrickmn/go-cache"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// Status constants
|
||||
@@ -28,10 +31,45 @@ const (
|
||||
Error = Status("error")
|
||||
)
|
||||
|
||||
const (
|
||||
// statusCacheTTL bounds how long a computed image status is served from the
|
||||
// statusCache. It is intentionally short (tied to the auto-update poll window),
|
||||
// NOT the previous 24h: the cache key is the LOCAL imageID, which does not
|
||||
// change when upstream pushes a new image under the same tag. A long TTL would
|
||||
// therefore keep serving a stale "updated" status for up to a day, and the
|
||||
// auto-update daemon (which resolves status through this same path) could not
|
||||
// see a freshly-pushed image within its poll interval. A few minutes still
|
||||
// absorbs bursts of badge lookups for the same image while re-checking the
|
||||
// remote digest soon after an upstream push.
|
||||
statusCacheTTL = 5 * time.Minute
|
||||
errorStatusCacheTTL = 5 * time.Minute
|
||||
maxConcurrentStatusChecks = 8
|
||||
|
||||
// forcedRecheckMinInterval bounds how often a forced re-check (?force=true)
|
||||
// actually contacts the registry for the same local imageID. A manual re-check
|
||||
// bypasses the read caches and issues an outbound registry HEAD; because the
|
||||
// endpoint is available to any env-authorized user with no throttle, an
|
||||
// unbounded loop of forced calls would burn the instance's shared registry
|
||||
// pull-rate quota. Within this window a forced call reuses the just-computed
|
||||
// fresh result instead of re-HEADing. It is tied to the remoteDigestCache TTL so
|
||||
// a genuine manual re-check still gets a fresh answer the first time.
|
||||
forcedRecheckMinInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
statusCache = cache.New(24*time.Hour, 24*time.Hour)
|
||||
statusCache = cache.New(statusCacheTTL, statusCacheTTL)
|
||||
remoteDigestCache = cache.New(5*time.Second, 5*time.Second)
|
||||
swarmID2NameCache = cache.New(5*time.Second, 5*time.Second)
|
||||
|
||||
// forcedRecheckGroup coalesces concurrent forced re-checks of the SAME local
|
||||
// imageID so N simultaneous ?force=true calls collapse to ONE registry HEAD
|
||||
// (singleflight), rather than each issuing its own outbound HEAD.
|
||||
forcedRecheckGroup singleflight.Group
|
||||
// forcedResultCache holds the last successful forced-recompute result per
|
||||
// imageID for forcedRecheckMinInterval, so rapid successive forced calls reuse it
|
||||
// instead of re-HEADing the registry. Only successful results are stored (errors
|
||||
// are never cached, so a transient failure does not suppress a real re-check).
|
||||
forcedResultCache = cache.New(forcedRecheckMinInterval, forcedRecheckMinInterval)
|
||||
)
|
||||
|
||||
// Status holds Docker image analysis
|
||||
@@ -46,13 +84,17 @@ func (c *DigestClient) ContainersImageStatus(ctx context.Context, containers []t
|
||||
}
|
||||
|
||||
statuses := make([]Status, len(containers))
|
||||
for i, ct := range containers {
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(maxConcurrentStatusChecks)
|
||||
|
||||
containerStatus := func(ct types.Container) Status {
|
||||
var nodeName string
|
||||
if swarmNodeId := ct.Labels[consts.SwarmNodeIDLabel]; swarmNodeId != "" {
|
||||
if swarmNodeName, ok := swarmID2NameCache.Get(swarmNodeId); ok {
|
||||
nodeName, _ = swarmNodeName.(string)
|
||||
} else {
|
||||
node, _, err := cli.NodeInspectWithRaw(ctx, ct.Labels[consts.SwarmNodeIDLabel])
|
||||
node, _, err := cli.NodeInspectWithRaw(ctx, swarmNodeId)
|
||||
if err != nil {
|
||||
return Error
|
||||
}
|
||||
@@ -64,23 +106,26 @@ func (c *DigestClient) ContainersImageStatus(ctx context.Context, containers []t
|
||||
|
||||
s, err := c.ContainerImageStatus(ctx, ct.ID, endpoint, nodeName)
|
||||
if err != nil {
|
||||
statuses[i] = Error
|
||||
log.Warn().Str("containerId", ct.ID).Err(err).Msg("error when fetching image status for container")
|
||||
|
||||
continue
|
||||
return Error
|
||||
}
|
||||
|
||||
statuses[i] = s
|
||||
|
||||
if s == Outdated || s == Processing {
|
||||
break
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
return FigureOut(statuses)
|
||||
for i, ct := range containers {
|
||||
g.Go(func() error {
|
||||
statuses[i] = containerStatus(ct)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
_ = g.Wait()
|
||||
|
||||
return AggregateImageStatus(statuses)
|
||||
}
|
||||
|
||||
func FigureOut(statuses []Status) Status {
|
||||
func AggregateImageStatus(statuses []Status) Status {
|
||||
if allMatch(statuses, Skipped) {
|
||||
return Skipped
|
||||
}
|
||||
@@ -100,7 +145,23 @@ func FigureOut(statuses []Status) Status {
|
||||
return Updated
|
||||
}
|
||||
|
||||
// ContainerImageStatus returns the image status for a container, serving a recent
|
||||
// value from the statusCache when available (the default used by per-row badges,
|
||||
// ContainersImageStatus and the auto-update daemon).
|
||||
func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) {
|
||||
return c.containerImageStatus(ctx, containerID, endpoint, nodeName, false)
|
||||
}
|
||||
|
||||
// ContainerImageStatusForced recomputes the image status against the remote
|
||||
// registry, bypassing the cached read while still refreshing the cache with the
|
||||
// fresh result. It backs the UI's manual "re-check" action, where the user
|
||||
// explicitly asks for an up-to-date registry comparison rather than the value
|
||||
// cached for statusCacheTTL.
|
||||
func (c *DigestClient) ContainerImageStatusForced(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) {
|
||||
return c.containerImageStatus(ctx, containerID, endpoint, nodeName, true)
|
||||
}
|
||||
|
||||
func (c *DigestClient) containerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string, force bool) (Status, error) {
|
||||
cli, err := c.clientFactory.CreateClient(endpoint, nodeName, nil)
|
||||
if err != nil {
|
||||
log.Warn().Str("swarmNodeId", nodeName).Msg("Cannot create new docker client.")
|
||||
@@ -121,9 +182,42 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
|
||||
return Skipped, nil
|
||||
}
|
||||
|
||||
// statusCache is keyed by the LOCAL imageID and read here so every caller
|
||||
// (handler, ContainersImageStatus, the auto-update job) can skip the expensive,
|
||||
// rate-limited remote registry digest lookup below on a hit; the container/image
|
||||
// inspects above are cheap local Docker calls, the registry HEAD is the part
|
||||
// worth avoiding. The entry TTL is deliberately short (statusCacheTTL): because
|
||||
// the key is the local imageID, a new upstream image pushed under the same tag
|
||||
// leaves the key unchanged, so a long TTL would keep serving a stale "updated"
|
||||
// status (the full computation would now return "outdated") until it expired. A
|
||||
// short TTL re-checks the remote digest within the poll window. Both Outdated
|
||||
// and Skipped are cached too (only the error paths return early without caching).
|
||||
//
|
||||
// A forced re-check (force=true) skips this cached read and recomputes against
|
||||
// the registry (see forcedImageStatus), then writes the fresh result back into
|
||||
// the cache below.
|
||||
if !force {
|
||||
if s, err := CachedResourceImageStatus(imageID); err == nil {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
return c.computeImageStatus(ctx, cli, imageID, container.Config.Image, false)
|
||||
}
|
||||
|
||||
return c.forcedImageStatus(ctx, cli, imageID, container.Config.Image)
|
||||
}
|
||||
|
||||
// computeImageStatus performs the full, uncached image-status computation for a
|
||||
// resolved local imageID: it parses the configured image reference, inspects the
|
||||
// local image for its repo digests/tags, compares them against the remote registry
|
||||
// digest (honoring force for the short-lived remoteDigestCache read in checkStatus)
|
||||
// and writes the fresh result back into the statusCache. It is the shared body of
|
||||
// both the default (cache-miss) and forced-recompute paths; the default path's
|
||||
// behaviour is unchanged from the previous inline implementation.
|
||||
func (c *DigestClient) computeImageStatus(ctx context.Context, cli *dockerclient.Client, imageID, configImage string, force bool) (Status, error) {
|
||||
digs := make([]digest.Digest, 0)
|
||||
images := make([]*Image, 0)
|
||||
if i, err := ParseImage(ParseImageOptions{Name: container.Config.Image}); err == nil {
|
||||
if i, err := ParseImage(ParseImageOptions{Name: configImage}); err == nil {
|
||||
images = append(images, &i)
|
||||
}
|
||||
|
||||
@@ -141,15 +235,56 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
|
||||
images = append(images, ParseRepoTags(imageInspect.RepoTags)...)
|
||||
}
|
||||
|
||||
s, err := c.checkStatus(images, digs)
|
||||
s, err := c.checkStatus(ctx, images, digs, force)
|
||||
if err != nil {
|
||||
log.Debug().Str("image", container.Image).Err(err).Msg("fetching a certain image status")
|
||||
log.Debug().Str("image", configImage).Err(err).Msg("fetching a certain image status")
|
||||
return Error, err
|
||||
}
|
||||
|
||||
statusCache.Set(imageID, s, 0)
|
||||
|
||||
return s, err
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// forcedImageStatus recomputes the image status against the registry for a manual
|
||||
// re-check while bounding the registry load a ?force=true call can cause:
|
||||
//
|
||||
// - forcedResultCache serves the just-computed fresh result for
|
||||
// forcedRecheckMinInterval, so rapid successive forced calls reuse it instead
|
||||
// of re-HEADing the registry (min-interval throttle).
|
||||
// - forcedRecheckGroup (singleflight) shares one in-flight computation per
|
||||
// imageID, so N concurrent forced calls collapse to a single registry HEAD.
|
||||
//
|
||||
// A successful recompute still repopulates both the statusCache and (via
|
||||
// checkStatus) the remoteDigestCache, so an immediately following default read is
|
||||
// served from cache. Failures are not cached, so a transient error never suppresses
|
||||
// a genuine re-check.
|
||||
func (c *DigestClient) forcedImageStatus(ctx context.Context, cli *dockerclient.Client, imageID, configImage string) (Status, error) {
|
||||
if s, ok := forcedResultCache.Get(imageID); ok {
|
||||
return s.(Status), nil
|
||||
}
|
||||
|
||||
v, err, _ := forcedRecheckGroup.Do(imageID, func() (any, error) {
|
||||
// A concurrent forced call may have populated the result between our read
|
||||
// above and entering the flight; reuse it rather than HEAD the registry again.
|
||||
if s, ok := forcedResultCache.Get(imageID); ok {
|
||||
return s.(Status), nil
|
||||
}
|
||||
|
||||
s, err := c.computeImageStatus(ctx, cli, imageID, configImage, true)
|
||||
if err != nil {
|
||||
return Error, err
|
||||
}
|
||||
|
||||
forcedResultCache.Set(imageID, s, 0)
|
||||
|
||||
return s, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Error, err
|
||||
}
|
||||
|
||||
return v.(Status), nil
|
||||
}
|
||||
|
||||
func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string, endpoint *portainer.Endpoint) (Status, error) {
|
||||
@@ -191,7 +326,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string,
|
||||
return c.ContainersImageStatus(ctx, nonExistedOrStoppedContainers, endpoint), nil
|
||||
}
|
||||
|
||||
func (c *DigestClient) checkStatus(images []*Image, digests []digest.Digest) (Status, error) {
|
||||
func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests []digest.Digest, force bool) (Status, error) {
|
||||
if digests == nil {
|
||||
digests = make([]digest.Digest, 0)
|
||||
}
|
||||
@@ -212,11 +347,17 @@ func (c *DigestClient) checkStatus(images []*Image, digests []digest.Digest) (St
|
||||
for _, img := range images {
|
||||
var remoteDigest digest.Digest
|
||||
var err error
|
||||
if rd, ok := remoteDigestCache.Get(img.FullName()); ok {
|
||||
remoteDigest, _ = rd.(digest.Digest)
|
||||
// A forced re-check skips the short-lived remoteDigestCache read so it
|
||||
// actually HEADs the registry; the fresh digest is still written back
|
||||
// below. The default path keeps reusing the cache (auto-badges and the
|
||||
// auto-update daemon must not add registry load).
|
||||
if !force {
|
||||
if rd, ok := remoteDigestCache.Get(img.FullName()); ok {
|
||||
remoteDigest, _ = rd.(digest.Digest)
|
||||
}
|
||||
}
|
||||
if remoteDigest == "" {
|
||||
remoteDigest, err = c.RemoteDigest(*img)
|
||||
remoteDigest, err = c.RemoteDigest(ctx, *img)
|
||||
if err != nil {
|
||||
log.Error().Str("image", img.String()).Msg("error when fetch remote digest for image")
|
||||
return Error, err
|
||||
@@ -263,6 +404,10 @@ func CacheResourceImageStatus(resourceID string, status Status) {
|
||||
statusCache.Set(resourceID, status, 0)
|
||||
}
|
||||
|
||||
func CacheErrorImageStatus(resourceID string) {
|
||||
statusCache.Set(resourceID, Error, errorStatusCacheTTL)
|
||||
}
|
||||
|
||||
func CachedImageDigest(resourceID string) (Status, error) {
|
||||
if s, ok := statusCache.Get(resourceID); ok {
|
||||
return s.(Status), nil
|
||||
|
||||
345
api/docker/images/status_test.go
Normal file
345
api/docker/images/status_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/stretchr/testify/require"
|
||||
imagetypes "go.podman.io/image/v5/types"
|
||||
)
|
||||
|
||||
// fakeClientFactory hands the DigestClient a Docker client wired to a test server.
|
||||
type fakeClientFactory struct {
|
||||
cli *dockerclient.Client
|
||||
}
|
||||
|
||||
func (f fakeClientFactory) CreateClient(*portainer.Endpoint, string, *time.Duration) (*dockerclient.Client, error) {
|
||||
return f.cli, nil
|
||||
}
|
||||
|
||||
// TestStatusCacheTTLIsShort is a regression guard for the stale-detection bug: the
|
||||
// statusCache is keyed by the LOCAL imageID, which does not change when upstream
|
||||
// pushes a new image under the same tag. A long TTL (the previous 24h) would serve
|
||||
// a stale "updated" status and hide a freshly-pushed image from both the badge and
|
||||
// the auto-update daemon for up to a day. The TTL must stay tied to the poll window
|
||||
// (a few minutes), and entries set with the default expiration (0) must actually
|
||||
// expire rather than live forever.
|
||||
func TestStatusCacheTTLIsShort(t *testing.T) {
|
||||
require.LessOrEqual(t, statusCacheTTL, 10*time.Minute, "status cache TTL must be short, not 24h")
|
||||
|
||||
key := "status-test-ttl-key"
|
||||
CacheResourceImageStatus(key, Updated)
|
||||
defer EvictImageStatus(key)
|
||||
|
||||
_, exp, ok := statusCache.GetWithExpiration(key)
|
||||
require.True(t, ok)
|
||||
require.False(t, exp.IsZero(), "status entries must expire, not live forever")
|
||||
require.LessOrEqual(t, time.Until(exp), statusCacheTTL)
|
||||
}
|
||||
|
||||
// TestContainerImageStatusForcedBypassesCache verifies the manual "re-check"
|
||||
// contract: the default read serves the value cached under the local imageID,
|
||||
// while the forced read skips that cached read and recomputes (here the image
|
||||
// inspect is stubbed to fail, so the forced call surfaces a non-cached result
|
||||
// instead of the seeded sentinel).
|
||||
func TestContainerImageStatusForcedBypassesCache(t *testing.T) {
|
||||
const containerID = "force-test-container"
|
||||
const imageID = "sha256:0000000000000000000000000000000000000000000000000000000000000001"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/containers/"+containerID+"/json") {
|
||||
resp := container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: containerID,
|
||||
Image: imageID,
|
||||
},
|
||||
Config: &container.Config{Image: "nginx:latest"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
|
||||
// Any other call (notably the image inspect on the forced recompute path)
|
||||
// fails, so the forced status is deterministically not the cached sentinel
|
||||
// and no real registry is contacted.
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cli, err := dockerclient.NewClientWithOpts(
|
||||
dockerclient.WithHost(srv.URL),
|
||||
dockerclient.WithHTTPClient(http.DefaultClient),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := &DigestClient{clientFactory: fakeClientFactory{cli: cli}}
|
||||
|
||||
// Seed the cache for the local imageID with a sentinel the recompute here
|
||||
// could never legitimately reproduce.
|
||||
CacheResourceImageStatus(imageID, Updated)
|
||||
defer EvictImageStatus(imageID)
|
||||
|
||||
// Default (cached) read serves the seeded sentinel without recomputing.
|
||||
cached, err := c.ContainerImageStatus(context.Background(), containerID, &portainer.Endpoint{}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, cached)
|
||||
|
||||
// Forced read must bypass the cache: it recomputes and, with the image inspect
|
||||
// stubbed to fail, does not return the cached sentinel.
|
||||
forced, _ := c.ContainerImageStatusForced(context.Background(), containerID, &portainer.Endpoint{}, "")
|
||||
require.NotEqual(t, Updated, forced)
|
||||
}
|
||||
|
||||
// TestCheckStatusForcedBypassesRemoteDigestCache proves the forced re-check also
|
||||
// bypasses the short-lived remoteDigestCache (not just the statusCache): with a
|
||||
// warm cache entry that matches the local digest, the default path returns
|
||||
// "updated" without any registry HEAD, while the forced path ignores the cache
|
||||
// and performs a fresh remote lookup (here against an unroutable registry, so it
|
||||
// errors instead of serving the cached match).
|
||||
func TestCheckStatusForcedBypassesRemoteDigestCache(t *testing.T) {
|
||||
// A tag on an unroutable registry: RemoteDigest fails fast (connection
|
||||
// refused) rather than reaching a real network.
|
||||
img, err := ParseImage(ParseImageOptions{Name: "127.0.0.1:1/portainer/forcecheck:latest"})
|
||||
require.NoError(t, err)
|
||||
|
||||
localDigest := digest.Digest("sha256:" + strings.Repeat("a", 64))
|
||||
|
||||
// Warm the remote digest cache with a value that matches the local digest.
|
||||
remoteDigestCache.Set(img.FullName(), localDigest, 0)
|
||||
defer remoteDigestCache.Delete(img.FullName())
|
||||
|
||||
c := &DigestClient{}
|
||||
|
||||
// Default path: reads the warm cache, remote == local -> Updated, no HEAD.
|
||||
cached, err := c.checkStatus(context.Background(), []*Image{&img}, []digest.Digest{localDigest}, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, cached)
|
||||
|
||||
// Forced path: skips the cache and attempts a fresh remote lookup, which
|
||||
// fails against the unroutable registry instead of serving the cached match.
|
||||
forced, err := c.checkStatus(context.Background(), []*Image{&img}, []digest.Digest{localDigest}, true)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, Error, forced)
|
||||
}
|
||||
|
||||
// forcedStubs wires a DigestClient to a fake Docker engine (plain HTTP) and a fake
|
||||
// registry (TLS, insecure-skipped) so a forced recompute succeeds end-to-end and
|
||||
// resolves an "updated" status. It exposes hit counters for the two expensive calls
|
||||
// a forced recompute makes exactly once: the local image inspect (Docker) and the
|
||||
// remote manifest HEAD (registry).
|
||||
type forcedStubs struct {
|
||||
client *DigestClient
|
||||
containerID string
|
||||
imageID string
|
||||
imageInspect *int64 // Docker ImageInspectWithRaw hits
|
||||
registryHEAD *int64 // registry manifest HEAD hits (the pull-rate-costly call)
|
||||
}
|
||||
|
||||
// newForcedStubs builds the stubs so the local repo digest matches the remote HEAD
|
||||
// digest, i.e. a successful recompute yields images.Updated. registryDelay is slept
|
||||
// inside the manifest HEAD handler to widen the window in which concurrent forced
|
||||
// calls overlap (used by the singleflight-collapse test).
|
||||
func newForcedStubs(t *testing.T, containerID, imageID string, registryDelay time.Duration) forcedStubs {
|
||||
t.Helper()
|
||||
|
||||
const matchDigest = "sha256:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
|
||||
var imageInspect, registryHEAD int64
|
||||
|
||||
registry := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v2/" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.URL.Path, "/manifests/") {
|
||||
atomic.AddInt64(®istryHEAD, 1)
|
||||
if registryDelay > 0 {
|
||||
time.Sleep(registryDelay)
|
||||
}
|
||||
w.Header().Set("Docker-Content-Digest", matchDigest)
|
||||
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(registry.Close)
|
||||
|
||||
regHost := strings.TrimPrefix(registry.URL, "https://")
|
||||
imageRef := regHost + "/repo/app:latest"
|
||||
|
||||
docker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/containers/"+containerID+"/json"):
|
||||
_ = json.NewEncoder(w).Encode(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{ID: containerID, Image: imageID},
|
||||
Config: &container.Config{Image: imageRef},
|
||||
})
|
||||
case strings.Contains(r.URL.Path, "/images/") && strings.HasSuffix(r.URL.Path, "/json"):
|
||||
atomic.AddInt64(&imageInspect, 1)
|
||||
_ = json.NewEncoder(w).Encode(image.InspectResponse{
|
||||
ID: imageID,
|
||||
RepoTags: []string{imageRef},
|
||||
RepoDigests: []string{regHost + "/repo/app@" + matchDigest},
|
||||
})
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(docker.Close)
|
||||
|
||||
cli, err := dockerclient.NewClientWithOpts(
|
||||
dockerclient.WithHost(docker.URL),
|
||||
dockerclient.WithHTTPClient(http.DefaultClient),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// registryClient nil so RemoteDigest keeps c.sysCtx (insecure skip) rather than
|
||||
// replacing it with an auth-only context; the stub registry uses a self-signed cert.
|
||||
c := &DigestClient{
|
||||
clientFactory: fakeClientFactory{cli: cli},
|
||||
sysCtx: &imagetypes.SystemContext{DockerInsecureSkipTLSVerify: imagetypes.OptionalBoolTrue},
|
||||
}
|
||||
|
||||
return forcedStubs{
|
||||
client: c,
|
||||
containerID: containerID,
|
||||
imageID: imageID,
|
||||
imageInspect: &imageInspect,
|
||||
registryHEAD: ®istryHEAD,
|
||||
}
|
||||
}
|
||||
|
||||
// TestContainerImageStatusForcedSuccessRepopulatesCache proves the other half of
|
||||
// the forced-recheck contract (the error-path tests only cover the bypass): a
|
||||
// forced recompute that SUCCEEDS writes the fresh value back into the caches, so a
|
||||
// subsequent DEFAULT (non-force) read is served from cache without a second local
|
||||
// image inspect or a second registry HEAD.
|
||||
func TestContainerImageStatusForcedSuccessRepopulatesCache(t *testing.T) {
|
||||
const containerID = "force-success-container"
|
||||
const imageID = "sha256:0000000000000000000000000000000000000000000000000000000000000002"
|
||||
|
||||
stubs := newForcedStubs(t, containerID, imageID, 0)
|
||||
defer EvictImageStatus(imageID)
|
||||
defer forcedResultCache.Delete(imageID)
|
||||
|
||||
forced, err := stubs.client.ContainerImageStatusForced(context.Background(), containerID, &portainer.Endpoint{}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, forced)
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(stubs.imageInspect), "forced recompute inspects the local image once")
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(stubs.registryHEAD), "forced recompute HEADs the registry once")
|
||||
|
||||
// The forced recompute must have repopulated the statusCache under the imageID.
|
||||
cachedStatus, err := CachedResourceImageStatus(imageID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, cachedStatus)
|
||||
|
||||
// A following default read is served from the statusCache: no extra image
|
||||
// inspect and, crucially, no extra registry HEAD.
|
||||
def, err := stubs.client.ContainerImageStatus(context.Background(), containerID, &portainer.Endpoint{}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, def)
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(stubs.imageInspect), "default read after a forced success must not re-inspect")
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(stubs.registryHEAD), "default read after a forced success must not re-HEAD the registry")
|
||||
}
|
||||
|
||||
// TestForcedRechecksCollapseToSingleRegistryHead proves the abuse mitigation: many
|
||||
// concurrent forced re-checks of the same imageID collapse (singleflight) to a
|
||||
// single outbound registry HEAD instead of one HEAD per call.
|
||||
func TestForcedRechecksCollapseToSingleRegistryHead(t *testing.T) {
|
||||
const containerID = "force-collapse-container"
|
||||
const imageID = "sha256:0000000000000000000000000000000000000000000000000000000000000003"
|
||||
|
||||
// A small delay in the manifest HEAD widens the overlap window so the concurrent
|
||||
// callers pile up on the shared in-flight computation.
|
||||
stubs := newForcedStubs(t, containerID, imageID, 100*time.Millisecond)
|
||||
defer EvictImageStatus(imageID)
|
||||
defer forcedResultCache.Delete(imageID)
|
||||
|
||||
const callers = 16
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(callers)
|
||||
for range callers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s, err := stubs.client.ContainerImageStatusForced(context.Background(), containerID, &portainer.Endpoint{}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, s)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(stubs.registryHEAD),
|
||||
"concurrent forced re-checks of the same image must collapse to a single registry HEAD")
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(stubs.imageInspect),
|
||||
"concurrent forced re-checks of the same image must share a single local inspect")
|
||||
}
|
||||
|
||||
func TestAggregateImageStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := func(statuses []Status, expected Status) {
|
||||
t.Helper()
|
||||
require.Equal(t, expected, AggregateImageStatus(statuses))
|
||||
}
|
||||
|
||||
f([]Status{Skipped, Skipped, Skipped}, Skipped)
|
||||
f([]Status{Preparing, Preparing}, Preparing)
|
||||
f([]Status{Updated, Outdated, Processing, Error}, Outdated)
|
||||
f([]Status{Updated, Processing, Error}, Processing)
|
||||
f([]Status{Updated, Error}, Error)
|
||||
f([]Status{Updated, Updated}, Updated)
|
||||
f([]Status{}, Updated)
|
||||
f([]Status{Updated, Skipped}, Updated)
|
||||
}
|
||||
|
||||
func TestCachedResourceImageStatusMiss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := CachedResourceImageStatus("status-test-miss-key")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCachedResourceImageStatusHitAndEvict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := "status-test-hit-evict-key"
|
||||
|
||||
CacheResourceImageStatus(key, Updated)
|
||||
|
||||
s, err := CachedResourceImageStatus(key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Updated, s)
|
||||
|
||||
EvictImageStatus(key)
|
||||
|
||||
_, err = CachedResourceImageStatus(key)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCacheErrorImageStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := "status-test-error-key"
|
||||
|
||||
CacheErrorImageStatus(key)
|
||||
|
||||
s, err := CachedResourceImageStatus(key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Error, s)
|
||||
|
||||
EvictImageStatus(key)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func fetchEndpointProxy(proxyManager *proxy.Manager, endpoint *portainer.Endpoin
|
||||
|
||||
// portainerRegistriesToAuthConfigs converts registries to Docker auth configs.
|
||||
// Callers must ensure ECR tokens are valid before calling this function (e.g. via
|
||||
// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function
|
||||
// registryutils.RefreshAndPersistECRTokens with a real DataStoreTx). This function
|
||||
// intentionally performs no DB writes to avoid write-lock contention when called inside
|
||||
// an active BoltDB write transaction.
|
||||
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
|
||||
|
||||
@@ -89,7 +89,7 @@ func JoinPaths(trustedRoot string, untrustedPaths ...string) string {
|
||||
trustedRoot = "."
|
||||
}
|
||||
|
||||
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...)) //nolint:forbidigo
|
||||
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...))
|
||||
|
||||
// avoid setting a volume name from the untrusted paths
|
||||
vnp := filepath.VolumeName(p)
|
||||
|
||||
@@ -15,7 +15,7 @@ type DirEntry struct {
|
||||
Name string
|
||||
Content string
|
||||
IsFile bool
|
||||
Permissions os.FileMode
|
||||
Permissions os.FileMode `swaggertype:"integer"`
|
||||
}
|
||||
|
||||
// FilterDirForEntryFile filers the given dirEntries, returns entries of the entryFile and .env file
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
@@ -64,15 +65,10 @@ func NewAzureClient() *azureClient {
|
||||
}
|
||||
|
||||
func newHttpClientForAzure(insecureSkipVerify bool) *http.Client {
|
||||
httpsCli := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: crypto.CreateTLSConfiguration(insecureSkipVerify),
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
Timeout: 300 * time.Second,
|
||||
return &http.Client{
|
||||
Transport: ssrf.NewTransport(crypto.CreateTLSConfiguration(insecureSkipVerify)),
|
||||
Timeout: 300 * time.Second,
|
||||
}
|
||||
|
||||
return httpsCli
|
||||
}
|
||||
|
||||
func (a *azureClient) Download(ctx context.Context, destination string, opt *git.CloneOptions) error {
|
||||
|
||||
@@ -3,6 +3,7 @@ package git
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
@@ -47,11 +48,19 @@ func NewGitClient(preserveGitDir bool) *gitClient {
|
||||
}
|
||||
|
||||
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
||||
resolved, err := filepath.EvalSymlinks(dst)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Wrap(err, "failed to resolve destination path")
|
||||
}
|
||||
if err == nil {
|
||||
dst = resolved
|
||||
}
|
||||
|
||||
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)
|
||||
_, err = git.CloneContext(ctx, storer, wt, opt)
|
||||
if err != nil {
|
||||
if err.Error() == "authentication required" {
|
||||
return gittypes.ErrAuthenticationFailure
|
||||
@@ -77,7 +86,7 @@ func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, reference
|
||||
URLs: []string{repositoryUrl},
|
||||
})
|
||||
|
||||
refs, err := remote.List(opt)
|
||||
refs, err := remote.ListContext(ctx, opt)
|
||||
if err != nil {
|
||||
if err.Error() == "authentication required" {
|
||||
return "", gittypes.ErrAuthenticationFailure
|
||||
@@ -109,7 +118,7 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
|
||||
URLs: []string{repositoryUrl},
|
||||
})
|
||||
|
||||
refs, err := rem.List(opt)
|
||||
refs, err := rem.ListContext(ctx, opt)
|
||||
if err != nil {
|
||||
return nil, checkGitError(err)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,19 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
|
||||
}
|
||||
|
||||
func Test_ClonePublicRepository_NonExistentDst(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := Service{git: NewGitClient(false)}
|
||||
repositoryURL := setup(t)
|
||||
referenceName := "refs/heads/main"
|
||||
|
||||
dir := filesystem.JoinPaths(t.TempDir(), "sub", "dir")
|
||||
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
|
||||
require.NoError(t, err)
|
||||
assert.DirExists(t, dir)
|
||||
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
|
||||
}
|
||||
|
||||
func Test_latestCommitID(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
|
||||
@@ -262,6 +275,7 @@ func createBareRepoWithSymlink(t *testing.T) string {
|
||||
}
|
||||
|
||||
func Test_Download_RejectsSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := NewGitClient(false)
|
||||
repoURL := createBareRepoWithSymlink(t)
|
||||
|
||||
|
||||
53
api/git/ssrf_transport.go
Normal file
53
api/git/ssrf_transport.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
|
||||
)
|
||||
|
||||
const gitDefaultPort = 9418
|
||||
|
||||
// ssrfGitTransport wraps a git:// transport and validates the resolved IP
|
||||
// against the SSRF policy before establishing connections.
|
||||
type ssrfGitTransport struct {
|
||||
inner gittransport.Transport
|
||||
}
|
||||
|
||||
// NewSSRFGitTransport wraps inner and blocks connections to private IP ranges
|
||||
// according to the active SSRF policy.
|
||||
func NewSSRFGitTransport(inner gittransport.Transport) gittransport.Transport {
|
||||
return &ssrfGitTransport{inner: inner}
|
||||
}
|
||||
|
||||
func (t *ssrfGitTransport) NewUploadPackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.UploadPackSession, error) {
|
||||
if err := checkEndpointSSRF(ep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t.inner.NewUploadPackSession(ep, auth)
|
||||
}
|
||||
|
||||
func (t *ssrfGitTransport) NewReceivePackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.ReceivePackSession, error) {
|
||||
if err := checkEndpointSSRF(ep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t.inner.NewReceivePackSession(ep, auth)
|
||||
}
|
||||
|
||||
func checkEndpointSSRF(ep *gittransport.Endpoint) error {
|
||||
port := ep.Port
|
||||
if port <= 0 {
|
||||
port = gitDefaultPort
|
||||
}
|
||||
|
||||
rawURL := fmt.Sprintf("git://%s/", net.JoinHostPort(ep.Host, strconv.Itoa(port)))
|
||||
|
||||
return ssrf.CheckURL(context.Background(), rawURL)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package gittypes
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"net/url"
|
||||
"path"
|
||||
@@ -27,7 +28,7 @@ type RepoConfig struct {
|
||||
// NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit.
|
||||
ConfigFilePath string `example:"docker-compose.yml"`
|
||||
// Git credentials
|
||||
Authentication *GitAuthentication
|
||||
Authentication *GitAuthentication `json:",omitempty"`
|
||||
// Repository hash
|
||||
ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"`
|
||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
@@ -40,6 +41,24 @@ func RepoName(rawURL string) string {
|
||||
return strings.TrimSuffix(path.Base(rawURL), ".git")
|
||||
}
|
||||
|
||||
// NormalizeURL returns a canonical form of rawURL for deduplication purposes:
|
||||
// scheme and host are lowercased, embedded credentials are removed, trailing
|
||||
// slashes and the .git suffix are stripped from the path. If the scheme is
|
||||
// absent it defaults to https.
|
||||
func NormalizeURL(rawURL string) (string, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u.Scheme = strings.ToLower(cmp.Or(u.Scheme, "https"))
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.User = nil
|
||||
u.Path = strings.TrimSuffix(strings.TrimRight(u.Path, "/"), ".git")
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// SanitizeURL strips any userinfo (username/password) embedded in rawURL,
|
||||
// returning a URL safe to store or return to clients.
|
||||
func SanitizeURL(rawURL string) string {
|
||||
@@ -53,13 +72,28 @@ func SanitizeURL(rawURL string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// SanitizeRepoConfig returns a copy of gc with the URL sanitized and password cleared,
|
||||
// safe to return to clients.
|
||||
func SanitizeRepoConfig(gc *RepoConfig) *RepoConfig {
|
||||
if gc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := *gc
|
||||
result.URL = SanitizeURL(result.URL)
|
||||
|
||||
if result.Authentication != nil && result.Authentication.Password != "" {
|
||||
auth := *result.Authentication
|
||||
auth.Password = ""
|
||||
result.Authentication = &auth
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
type GitAuthentication struct {
|
||||
Username string
|
||||
Password string
|
||||
Provider GitProvider `json:",omitempty"`
|
||||
AuthorizationType GitCredentialAuthType `json:",omitempty"`
|
||||
// Git credentials identifier when the value is not 0
|
||||
// When the value is 0, Username and Password are set without using saved credential
|
||||
// This is introduced since 2.15.0
|
||||
GitCredentialID int `example:"0"`
|
||||
}
|
||||
|
||||
26
api/git/types/types_test.go
Normal file
26
api/git/types/types_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package gittypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := func(input, expected string) {
|
||||
t.Helper()
|
||||
got, err := NormalizeURL(input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
f("https://github.com/org/repo.git", "https://github.com/org/repo")
|
||||
f("https://github.com/org/repo/", "https://github.com/org/repo")
|
||||
f("https://github.com/org/repo.git/", "https://github.com/org/repo")
|
||||
f("HTTPS://github.com/org/repo", "https://github.com/org/repo")
|
||||
f("https://GitHub.COM/org/repo", "https://github.com/org/repo")
|
||||
f("https://user:pass@github.com/org/repo.git", "https://github.com/org/repo")
|
||||
f("https://github.com/org/repo", "https://github.com/org/repo")
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
@@ -27,15 +29,21 @@ func UpdateGitObject(ctx context.Context, gitService portainer.GitService, objId
|
||||
|
||||
username, password := git.GetCredentials(gitConfig.Authentication)
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
newHash, err := gitService.LatestCommitID(
|
||||
ctx,
|
||||
fetchCtx,
|
||||
gitConfig.URL,
|
||||
gitConfig.ReferenceName,
|
||||
username,
|
||||
password,
|
||||
gitConfig.TLSSkipVerify,
|
||||
)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if fetchCtx.Err() == context.DeadlineExceeded {
|
||||
log.Error().Str("object", objId).Msg("git fetch timed out after 1 minute")
|
||||
}
|
||||
|
||||
return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId)
|
||||
}
|
||||
|
||||
@@ -71,6 +79,11 @@ func UpdateGitObject(ctx context.Context, gitService portainer.GitService, objId
|
||||
}
|
||||
|
||||
if err := cloneGitRepository(ctx, gitService, cloneParams); err != nil {
|
||||
if enableVersionFolder {
|
||||
if removeErr := os.RemoveAll(toDir); removeErr != nil {
|
||||
log.Warn().Err(removeErr).Str("dir", toDir).Msg("failed to remove partial clone directory")
|
||||
}
|
||||
}
|
||||
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
|
||||
}
|
||||
|
||||
|
||||
55
api/gitops/sources/repo_config.go
Normal file
55
api/gitops/sources/repo_config.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
// RepoConfigInput holds the raw payload fields needed to resolve a git RepoConfig.
|
||||
// Set SourceID to resolve URL/auth from a stored source; otherwise provide the inline fields.
|
||||
type RepoConfigInput struct {
|
||||
SourceID portainer.SourceID
|
||||
ReferenceName string
|
||||
ConfigFilePath string
|
||||
RepositoryURL string
|
||||
TLSSkipVerify bool
|
||||
RepositoryAuthentication bool
|
||||
Username string
|
||||
Password string
|
||||
Provider gittypes.GitProvider
|
||||
AuthorizationType gittypes.GitCredentialAuthType
|
||||
}
|
||||
|
||||
// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields.
|
||||
func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
|
||||
cfg := gittypes.RepoConfig{
|
||||
ReferenceName: input.ReferenceName,
|
||||
ConfigFilePath: input.ConfigFilePath,
|
||||
}
|
||||
|
||||
if input.SourceID != 0 {
|
||||
src, httpErr := ValidateGitSourceAccess(tx, input.SourceID)
|
||||
if httpErr != nil {
|
||||
return gittypes.RepoConfig{}, httpErr
|
||||
}
|
||||
cfg.URL = src.Git.URL
|
||||
cfg.Authentication = src.Git.Authentication
|
||||
cfg.TLSSkipVerify = src.Git.TLSSkipVerify
|
||||
} else {
|
||||
cfg.URL = input.RepositoryURL
|
||||
cfg.TLSSkipVerify = input.TLSSkipVerify
|
||||
if input.RepositoryAuthentication {
|
||||
cfg.Authentication = &gittypes.GitAuthentication{
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
Provider: input.Provider,
|
||||
AuthorizationType: input.AuthorizationType,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.TLSSkipVerify = cfg.TLSSkipVerify && fips.CanTLSSkipVerify()
|
||||
return cfg, nil
|
||||
}
|
||||
70
api/gitops/sources/repo_config_test.go
Normal file
70
api/gitops/sources/repo_config_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
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 init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
src := &portainer.Source{
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/org/repo",
|
||||
TLSSkipVerify: true,
|
||||
Authentication: &gittypes.GitAuthentication{
|
||||
Username: "user",
|
||||
Password: "token",
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.Source().Create(src))
|
||||
|
||||
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
|
||||
SourceID: src.ID,
|
||||
ReferenceName: "refs/heads/main",
|
||||
ConfigFilePath: "docker-compose.yml",
|
||||
RepositoryURL: "https://ignored.example.com",
|
||||
})
|
||||
|
||||
require.Nil(t, httpErr)
|
||||
assert.Equal(t, src.Git.URL, cfg.URL)
|
||||
assert.Equal(t, src.Git.Authentication, cfg.Authentication)
|
||||
assert.Equal(t, src.Git.TLSSkipVerify, cfg.TLSSkipVerify)
|
||||
assert.Equal(t, "refs/heads/main", cfg.ReferenceName)
|
||||
assert.Equal(t, "docker-compose.yml", cfg.ConfigFilePath)
|
||||
}
|
||||
|
||||
func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
|
||||
ReferenceName: "refs/heads/main",
|
||||
ConfigFilePath: "docker-compose.yml",
|
||||
RepositoryURL: "https://github.com/org/repo",
|
||||
TLSSkipVerify: true,
|
||||
RepositoryAuthentication: true,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
})
|
||||
|
||||
require.Nil(t, httpErr)
|
||||
assert.Equal(t, "https://github.com/org/repo", cfg.URL)
|
||||
assert.True(t, cfg.TLSSkipVerify)
|
||||
require.NotNil(t, cfg.Authentication)
|
||||
assert.Equal(t, "user", cfg.Authentication.Username)
|
||||
assert.Equal(t, "pass", cfg.Authentication.Password)
|
||||
}
|
||||
38
api/gitops/sources/source_access.go
Normal file
38
api/gitops/sources/source_access.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need.
|
||||
// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types.
|
||||
type gitSourceStore interface {
|
||||
Source() dataservices.SourceService
|
||||
IsErrObjectNotFound(err error) bool
|
||||
}
|
||||
|
||||
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
|
||||
// TODO(BE-12905): enforce per-user access policies once Source ownership is introduced.
|
||||
func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
|
||||
src, err := tx.Source().Read(sourceID)
|
||||
if err != nil {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Source not found", err)
|
||||
}
|
||||
return nil, httperror.InternalServerError("Unable to read source", err)
|
||||
}
|
||||
|
||||
if src.Type != portainer.SourceTypeGit {
|
||||
return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil)
|
||||
}
|
||||
|
||||
if src.Git == nil {
|
||||
return nil, httperror.BadRequest("Source has no git configuration", nil)
|
||||
}
|
||||
|
||||
return src, nil
|
||||
}
|
||||
49
api/gitops/sources/source_access_test.go
Normal file
49
api/gitops/sources/source_access_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
src := &portainer.Source{
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"},
|
||||
}
|
||||
require.NoError(t, store.Source().Create(src))
|
||||
|
||||
_, httpErr := ValidateGitSourceAccess(store, src.ID)
|
||||
assert.Nil(t, httpErr)
|
||||
}
|
||||
|
||||
func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
_, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999))
|
||||
require.NotNil(t, httpErr)
|
||||
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
|
||||
}
|
||||
|
||||
func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
src := &portainer.Source{
|
||||
Type: portainer.SourceType(99), // not a git source
|
||||
}
|
||||
require.NoError(t, store.Source().Create(src))
|
||||
|
||||
_, httpErr := ValidateGitSourceAccess(store, src.ID)
|
||||
require.NotNil(t, httpErr)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode)
|
||||
}
|
||||
@@ -14,93 +14,190 @@ import (
|
||||
// FetchWorkflows returns all GitOps workflows visible to the given user.
|
||||
func FetchWorkflows(
|
||||
ctx context.Context,
|
||||
dataStore dataservices.DataStore,
|
||||
tx dataservices.DataStoreTx,
|
||||
gitService portainer.GitService,
|
||||
k8sFactory *cli.ClientFactory,
|
||||
sc *security.RestrictedRequestContext,
|
||||
endpointIDSet set.Set[portainer.EndpointID],
|
||||
) ([]Workflow, error) {
|
||||
var entries []portainer.Stack
|
||||
var endpointMap map[portainer.EndpointID]portainer.Endpoint
|
||||
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
|
||||
|
||||
err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
||||
return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpointMap, err = buildEndpointMap(tx, stacks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range stacks {
|
||||
s := stacks[i]
|
||||
|
||||
if ep, ok := endpointMap[s.EndpointID]; ok && !EndpointMatchesStackType(ep, s.Type) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, s)
|
||||
}
|
||||
|
||||
return nil
|
||||
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
||||
return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointMap, err := buildEndpointMap(tx, stacks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First pass: filter by endpoint/stack-type match and collect workflow IDs.
|
||||
preFiltered := make([]portainer.Stack, 0, len(stacks))
|
||||
workflowIDSet := make(set.Set[portainer.WorkflowID], len(stacks))
|
||||
for _, stack := range stacks {
|
||||
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
|
||||
continue
|
||||
}
|
||||
preFiltered = append(preFiltered, stack)
|
||||
workflowIDSet.Add(stack.WorkflowID)
|
||||
}
|
||||
|
||||
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, workflowIDSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Second pass: build filtered list using in-memory lookups.
|
||||
var filtered []portainer.Stack
|
||||
for _, stack := range preFiltered {
|
||||
wf := workflowMap[stack.WorkflowID]
|
||||
|
||||
outer:
|
||||
for _, as := range wf.Artifacts {
|
||||
if as.StackID != stack.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range as.Files {
|
||||
src := sourceMap[f.SourceID]
|
||||
if src.Type == portainer.SourceTypeGit {
|
||||
gitConfigs[stack.ID] = MergeSourceAndFile(&src, &f)
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, stack)
|
||||
}
|
||||
stacks = filtered
|
||||
|
||||
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err = filterK8SStacks(entries, endpointMap, k8sFactory, accessMap)
|
||||
stacks, err = filterK8SStacks(stacks, endpointMap, k8sFactory, accessMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]Workflow, 0, len(entries))
|
||||
for _, s := range entries {
|
||||
gitEntries := []GitEntries{
|
||||
{Name: s.GitConfig.ConfigFilePath, IsFile: true},
|
||||
}
|
||||
for _, additionalPath := range s.AdditionalFiles {
|
||||
gitEntries = append(gitEntries, GitEntries{Name: additionalPath, IsFile: true})
|
||||
}
|
||||
|
||||
source, artifact := computePhases(ctx, gitService, s.GitConfig, gitEntries)
|
||||
items = append(items, MapStackToWorkflow(s, s.GitConfig, source, artifact))
|
||||
items := make([]Workflow, 0, len(stacks))
|
||||
for _, stack := range stacks {
|
||||
gitConfig := gitConfigs[stack.ID]
|
||||
source, artifact := ComputeGitPhasesForConfig(ctx, gitService, gitConfig)
|
||||
items = append(items, MapStackToWorkflow(stack, gitConfig, source, artifact))
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func computePhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig, gitEntries []GitEntries) (source, artifact WorkflowPhaseStatus) {
|
||||
if gitSvc == nil || cfg == nil {
|
||||
return WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
|
||||
username, password := gitCredentials(cfg)
|
||||
return ComputeGitPhases(ctx, cfg.ReferenceName, gitEntries,
|
||||
func(ctx context.Context) ([]string, error) {
|
||||
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
|
||||
},
|
||||
func(ctx context.Context, exts []string, dirOnly bool) ([]string, error) {
|
||||
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, dirOnly, false, exts, cfg.TLSSkipVerify)
|
||||
},
|
||||
)
|
||||
// SourceStats holds aggregated statistics for a GitOps source.
|
||||
type SourceStats struct {
|
||||
WorkflowCount int
|
||||
EndpointIDs set.Set[portainer.EndpointID]
|
||||
LastSync int64
|
||||
}
|
||||
|
||||
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
|
||||
if cfg.Authentication != nil {
|
||||
return cfg.Authentication.Username, cfg.Authentication.Password
|
||||
// FetchSourceStats returns all sources and per-source stats for sources accessible to the given user.
|
||||
// It applies the same access control as FetchWorkflows but skips git phase checks.
|
||||
func FetchSourceStats(
|
||||
tx dataservices.DataStoreTx,
|
||||
k8sFactory *cli.ClientFactory,
|
||||
sc *security.RestrictedRequestContext,
|
||||
) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) {
|
||||
sources, err := tx.Source().ReadAll()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
allStacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { return s.WorkflowID != 0 })
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
endpointMap, err := buildEndpointMap(tx, allStacks)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
allStacks, err = filterDockerStacksByAccess(tx, allStacks, sc)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
workflowIDSet := make(set.Set[portainer.WorkflowID], len(allStacks))
|
||||
preFiltered := make([]portainer.Stack, 0, len(allStacks))
|
||||
for _, stack := range allStacks {
|
||||
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
|
||||
continue
|
||||
}
|
||||
preFiltered = append(preFiltered, stack)
|
||||
workflowIDSet.Add(stack.WorkflowID)
|
||||
}
|
||||
|
||||
wfMap, err := LoadWorkflowMap(tx, workflowIDSet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
wfSources := make(map[portainer.WorkflowID][]portainer.SourceID, len(wfMap))
|
||||
for id, wf := range wfMap {
|
||||
for _, as := range wf.Artifacts {
|
||||
for _, f := range as.Files {
|
||||
wfSources[id] = append(wfSources[id], f.SourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stackSourceIDs := make(map[portainer.StackID][]portainer.SourceID)
|
||||
for _, stack := range preFiltered {
|
||||
if srcIDs := wfSources[stack.WorkflowID]; len(srcIDs) > 0 {
|
||||
stackSourceIDs[stack.ID] = srcIDs
|
||||
}
|
||||
}
|
||||
|
||||
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
stacks, err := filterK8SStacks(preFiltered, endpointMap, k8sFactory, accessMap)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
stats := make(map[portainer.SourceID]SourceStats)
|
||||
|
||||
for _, stack := range stacks {
|
||||
var epIDs []portainer.EndpointID
|
||||
if stack.EndpointID != 0 {
|
||||
epIDs = []portainer.EndpointID{stack.EndpointID}
|
||||
}
|
||||
addSourceStats(stats, stackSourceIDs[stack.ID], epIDs, StackLastSyncDate(stack))
|
||||
}
|
||||
|
||||
return sources, stats, nil
|
||||
}
|
||||
|
||||
func addSourceStats(result map[portainer.SourceID]SourceStats, srcIDs []portainer.SourceID, epIDs []portainer.EndpointID, lastSync int64) {
|
||||
for _, srcID := range srcIDs {
|
||||
st := result[srcID]
|
||||
if st.EndpointIDs == nil {
|
||||
st.EndpointIDs = make(set.Set[portainer.EndpointID])
|
||||
}
|
||||
st.WorkflowCount++
|
||||
for _, epID := range epIDs {
|
||||
st.EndpointIDs.Add(epID)
|
||||
}
|
||||
st.LastSync = max(lastSync, st.LastSync)
|
||||
result[srcID] = st
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
282
api/gitops/workflows/fetch_test.go
Normal file
282
api/gitops/workflows/fetch_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func adminContext() *security.RestrictedRequestContext {
|
||||
return &security.RestrictedRequestContext{IsAdmin: true, UserID: 1}
|
||||
}
|
||||
|
||||
func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) {
|
||||
t.Helper()
|
||||
|
||||
cfg := stack.GitConfig
|
||||
|
||||
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: cfg}
|
||||
require.NoError(t, tx.Source().Create(src))
|
||||
|
||||
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
|
||||
StackID: stack.ID,
|
||||
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
|
||||
}}}
|
||||
require.NoError(t, tx.Workflow().Create(wf))
|
||||
|
||||
stack.WorkflowID = wf.ID
|
||||
stack.GitConfig = nil
|
||||
|
||||
require.NoError(t, tx.Stack().Create(stack))
|
||||
}
|
||||
|
||||
func TestAddSourceStats_NoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, nil, nil, 0)
|
||||
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestAddSourceStats_AccumulatesWorkflowCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 0)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 0)
|
||||
|
||||
require.Equal(t, 2, result[1].WorkflowCount)
|
||||
}
|
||||
|
||||
func TestAddSourceStats_CollectsUniqueEndpointIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{10, 20}, 0)
|
||||
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{20, 30}, 0)
|
||||
|
||||
require.Len(t, result[1].EndpointIDs, 3)
|
||||
require.True(t, result[1].EndpointIDs[10])
|
||||
require.True(t, result[1].EndpointIDs[20])
|
||||
require.True(t, result[1].EndpointIDs[30])
|
||||
}
|
||||
|
||||
func TestAddSourceStats_MaxLastSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 100)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 500)
|
||||
addSourceStats(result, []portainer.SourceID{1}, nil, 200)
|
||||
|
||||
require.Equal(t, int64(500), result[1].LastSync)
|
||||
}
|
||||
|
||||
func TestAddSourceStats_MultipleSourceIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := make(map[portainer.SourceID]SourceStats)
|
||||
addSourceStats(result, []portainer.SourceID{1, 2}, []portainer.EndpointID{10}, 100)
|
||||
|
||||
require.Equal(t, 1, result[1].WorkflowCount)
|
||||
require.Equal(t, 1, result[2].WorkflowCount)
|
||||
require.True(t, result[1].EndpointIDs[10])
|
||||
require.True(t, result[2].EndpointIDs[10])
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_ReturnsOnlyGitopsStacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
mustCreateGitWorkflow(t, tx, &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "gitops-stack",
|
||||
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/repo"},
|
||||
})
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"}))
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "gitops-stack", items[0].Name)
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_FiltersByEndpointID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for i := 1; i <= 3; i++ {
|
||||
mustCreateGitWorkflow(t, tx, &portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: "stack-" + strconv.Itoa(i),
|
||||
EndpointID: portainer.EndpointID(i),
|
||||
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), set.ToSet([]portainer.EndpointID{1, 2}))
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 2)
|
||||
|
||||
names := []string{items[0].Name, items[1].Name}
|
||||
require.Contains(t, names, "stack-1")
|
||||
require.Contains(t, names, "stack-2")
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_EmptyWhenNoGitopsStacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "plain-1"}))
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-2"}))
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Empty(t, items)
|
||||
}
|
||||
|
||||
func TestFetchWorkflows_NilEndpointSetReturnsAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
for i := 1; i <= 3; i++ {
|
||||
mustCreateGitWorkflow(t, tx, &portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: "stack-" + strconv.Itoa(i),
|
||||
EndpointID: portainer.EndpointID(i),
|
||||
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var items []Workflow
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
|
||||
return err
|
||||
}))
|
||||
require.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestFetchSourceStats_ReturnsAllSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit}))
|
||||
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit}))
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var sources []portainer.Source
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
sources, _, err = FetchSourceStats(tx, nil, adminContext())
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
require.Len(t, sources, 2)
|
||||
}
|
||||
|
||||
func TestFetchSourceStats_TracksWorkflowCountAndEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
var srcID portainer.SourceID
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit}
|
||||
require.NoError(t, tx.Source().Create(src))
|
||||
srcID = src.ID
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: srcID}}}}}
|
||||
require.NoError(t, tx.Workflow().Create(wf))
|
||||
require.NoError(t, tx.Stack().Create(&portainer.Stack{
|
||||
ID: portainer.StackID(i),
|
||||
Name: "stack-" + strconv.Itoa(i),
|
||||
EndpointID: portainer.EndpointID(i),
|
||||
WorkflowID: wf.ID,
|
||||
}))
|
||||
}
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var stats map[portainer.SourceID]SourceStats
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
_, stats, err = FetchSourceStats(tx, nil, adminContext())
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
st := stats[srcID]
|
||||
require.Equal(t, 2, st.WorkflowCount)
|
||||
require.Len(t, st.EndpointIDs, 2)
|
||||
}
|
||||
|
||||
func TestFetchSourceStats_UnusedSourceHasZeroStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
var unusedID portainer.SourceID
|
||||
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit}
|
||||
require.NoError(t, tx.Source().Create(src))
|
||||
unusedID = src.ID
|
||||
|
||||
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
|
||||
}))
|
||||
|
||||
var stats map[portainer.SourceID]SourceStats
|
||||
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
_, stats, err = FetchSourceStats(tx, nil, adminContext())
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
st := stats[unusedID]
|
||||
require.Zero(t, st.WorkflowCount)
|
||||
require.Empty(t, st.EndpointIDs)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/portainer/portainer/api/set"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
|
||||
@@ -115,7 +117,8 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict
|
||||
|
||||
access, err := resolveKubeAccess(k8sFactory, sc, &ep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Warn().Err(err).Str("context", "buildEndpointAccessMap").Int("endpoint_id", int(epID)).Msg("Failed to resolve kube access for endpoint, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
result[epID] = access
|
||||
@@ -148,7 +151,8 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
|
||||
|
||||
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube client for endpoint, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
access := accessMap[envID]
|
||||
@@ -157,7 +161,8 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
|
||||
|
||||
apps, err := kcl.GetApplications("", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube applications for endpoint, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range stacks {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user