Compare commits

..

122 Commits

Author SHA1 Message Date
James Player
b57855f20d fix(app): datatable global checkbox doesn't reflect the selected state (#470) 2025-03-10 09:21:20 +13:00
Cara Ryan
438b1f9815 fix(helm): Remove duplicate helm instructions in CE [BE-11670] (#482) 2025-03-06 09:35:31 +13:00
LP B
2bccb3589e fix(app/images): nodeName on images list links (#484) 2025-03-05 16:04:16 +01:00
James Player
52bb06eb7b chore(helm): Convert helm details view to react (#476) 2025-03-03 11:29:58 +13:00
Malcolm Lockyer
8e6d0e7d42 perf(endpointrelation): Part 2 of fixing endpointrelation perf [be-11616] (#471) 2025-02-28 14:41:54 +13:00
Steven Kang
5526fd8296 chore: bump 2.27.1 - develop (#468) 2025-02-27 11:02:25 +13:00
Anthony Lapenna
a554a8c49f api: remove server-ce swagger.json (#467) 2025-02-26 16:10:02 +13:00
James Player
7759d762ab chore(react): Convert cluster details to react CE (#466) 2025-02-26 14:13:50 +13:00
Oscar Zhou
dd98097897 fix(libstack): miss to read default .env file [BE-11638] (#458) 2025-02-26 13:00:25 +13:00
Steven Kang
cc73b7831f fix: cve-2024-50338 - develop (#461) 2025-02-25 12:55:44 +13:00
James Carppe
9c243cc8dd Update bug report template for 2.27.0 (#450) 2025-02-20 13:38:26 +13:00
Oscar Zhou
5d568a3f32 fix(edge): edge stack pending when yaml file is under same root folder of edge configs [BE-11620] (#447) 2025-02-20 12:09:26 +13:00
Steven Kang
1b83542d41 chore: bump version to 2.27.0 - develop (#445) 2025-02-20 09:42:52 +13:00
LP B
cf95d91db3 fix(swarm): keep swarm stack stop command attached (#444) 2025-02-19 19:25:28 +01:00
Viktor Pettersson
41c1d88615 fix(edge): configure persisted mTLS certificates on start-up [BE-11622] (#437)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
2025-02-19 14:46:39 +13:00
Steven Kang
df8673ba40 version: bump version to 2.27.0-rc3 - develop (#426) 2025-02-14 08:39:02 +13:00
andres-portainer
96b1869a0c fix(swarm): fix the Host field when listing images BE-10827 (#352)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2025-02-12 00:47:45 +01:00
Oscar Zhou
e45b852c09 fix(platform): remove error log when local env is not found [BE-11353] (#364) 2025-02-12 09:23:52 +13:00
Steven Kang
2d3e5c3499 workaround: leave the globally set helm repo to empty and add disclaimer - develop (#409) 2025-02-11 15:36:29 +13:00
Oscar Zhou
b25bf1e341 fix(podman): missing filter in homepage [BE-11502] (#404) 2025-02-10 21:08:27 +13:00
Oscar Zhou
4bb80d3e3a fix(setting): failed to persist edge computer setting [BE-11403] (#395) 2025-02-10 21:05:15 +13:00
Steven Kang
03575186a7 remove deprecated api endpoints - develop [BE-11510] (#399) 2025-02-10 10:46:36 +13:00
Steven Kang
935c7dd496 feat: improve diagnostics stability - develop (#355) 2025-02-10 10:45:47 +13:00
Steven Kang
1b2dc6a133 version: bump version to 2.27.0-rc2 - develop (#402) 2025-02-07 14:47:49 +13:00
Steven Kang
d4e2b2188e chore: bump go version to 1.23.5 develop (#392) 2025-02-07 08:48:19 +13:00
viktigpetterr
9658f757c2 fix(endpoints): use the post method for batch delete API operations [BE-11573] (#394) 2025-02-06 18:14:43 +01:00
Ali
371e84d9a5 fix(podman): create new image from a container in podman [r8s-90] (#347) 2025-02-05 20:22:33 +13:00
Steven Kang
5423a2f1b9 security: cve-2025-21613 develop (#390) 2025-02-05 15:56:30 +13:00
Oscar Zhou
7001f8e088 fix(edge): check all endpoint_relation db query logic [BE-11602] (#378) 2025-02-05 15:20:20 +13:00
Steven Kang
678cd54553 security: cve-2024-45338 develop (#386) 2025-02-05 15:03:39 +13:00
Oscar Zhou
bc19d6592f fix(libstack): cannot open std edge stack log page [BE-11603] (#384) 2025-02-05 12:17:51 +13:00
James Player
5af0859f67 fix(datatables): "Select all" should select only elements of the current page (#376) 2025-02-04 15:34:33 +13:00
Oscar Zhou
379711951c fix(edgegroup): failed to associate env to static edge group [BE-11599] (#368) 2025-02-04 09:41:24 +13:00
LP B
a50a9c5617 fix(app/edge): edge stacks webhooks cannot be disabled once created (#372) 2025-02-03 20:50:24 +01:00
LP B
c0d30a455f fix(api/edge): backend panic on edge stack removal (#371) 2025-02-03 20:25:25 +01:00
LP B
9a3f6b21d2 feat(app/service-details): hide view while loading data (#348) 2025-02-03 14:20:35 +01:00
Steven Kang
9ea41f68bc version: bump version to 2.27.0-rc1 (#363)
Co-authored-by: steven <steven@stevens-Mini.hub>
2025-02-03 11:38:38 +13:00
James Player
e943aa8f03 feat(documentation): change docs to use LTS/STS instead of version number (#357) 2025-02-03 11:17:36 +13:00
James Player
17a4750d8e fix(kubernetes): Resource reservation wasn't displaying properly in business edition and remove leader status (#362) 2025-02-03 11:02:23 +13:00
Malcolm Lockyer
7d18c22aa1 fix(ui): bring back k8s applications page row expand published urls [r8s-145] (#356) 2025-01-31 13:16:18 +13:00
Ali
c80cc6e268 chore(automation): give unique selectors [r8s-168] (#345)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-01-30 15:42:32 +13:00
andres-portainer
b30a1b5250 fix(edgestacks): avoid repeated statuses BE-11561 (#351) 2025-01-27 16:00:05 -03:00
LP B
b753371700 fix(app/edge-stack): edge stack create form validation (#343) 2025-01-24 17:02:52 +01:00
andres-portainer
3ca5ab180f fix(system): optimize the memory usage when counting nodes BE-11575 (#342) 2025-01-23 20:41:09 -03:00
Ali
4971f5510c fix(app): edit app with configmap [r8s-95] (#341) 2025-01-24 11:35:47 +13:00
andres-portainer
20fa7e508d fix(edgestacks): decouple the EdgeStackStatusUpdateCoordinator so it can be used by other packages BE-11572 (#340) 2025-01-23 17:10:46 -03:00
James Player
ebffc340d9 fix(k8s): Changed 'Deploy from file' button text to 'Deploy from code' (#338) 2025-01-23 16:47:52 +13:00
andres-portainer
9a86737caa fix(edgestacks): add a status update coordinator to increase performance BE-11572 (#337) 2025-01-22 20:24:54 -03:00
Steven Kang
d35d8a7307 feat(oauth): fix mapping (#330) 2025-01-23 09:03:51 +13:00
andres-portainer
701ff5d6bc refactor(edgestacks): move handlerDBErr() out of the handler BE-11572 (#336) 2025-01-22 16:35:06 -03:00
LP B
9044b25a23 fix(app): remove passwords from registries list response (#334) 2025-01-22 17:40:21 +01:00
Ali
7f089fab86 fix(apps): use replicas from application spec [r8s-142] (#335) 2025-01-22 12:31:27 +13:00
James Carppe
a259c28678 Update bug report template for 2.26.1 (#329) 2025-01-21 16:19:03 +13:00
LP B
db48da185a fix(app/editor): reduce editor slowness by debouncing onChange calls (#326) 2025-01-17 22:41:06 +01:00
LP B
cab667c23b fix(app/edge-stack): UI notification on creation error (#325) 2025-01-17 20:33:01 +01:00
andres-portainer
154ca9f1b1 fix(edge): return proper error from context BE-11564 (#323) 2025-01-16 20:18:51 -03:00
Oscar Zhou
2abe40b786 fix(edgestack): remove project folder after deleting edgestack [BE-11559] (#320) 2025-01-16 09:16:09 +13:00
James Carppe
6be2420b32 Update bug report template for 2.26.0 (#319) 2025-01-15 14:38:59 +13:00
Ali
9405cc0e04 chore(portainer): bump version to 2.26.0 (#302) 2025-01-14 07:20:11 +13:00
Yajith Dayarathna
55c98912ed feat(omni): support for omni [R8S-75] (#105)
Co-authored-by: stevensbkang <skan070@gmail.com>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2025-01-13 17:06:10 +13:00
Ali
45bd7984b0 fit(jobs): remove redundant checkboxes in executions datatable [r8s-182] (#295) 2025-01-12 18:24:22 +13:00
andres-portainer
1ed9a0106e feat(edge): optimize Edge Stack retrieval BE-11555 (#294) 2025-01-10 16:44:19 -03:00
LP B
f8b2ee8c0d fix(app/edge-stack): local filesystem path is not retained (#292) 2025-01-10 18:20:44 +01:00
Steven Kang
d32b0f8b7e feat(kubernetes): support for jobs and cron jobs - r8s-182 (#260)
Co-authored-by: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-01-10 13:21:27 +13:00
andres-portainer
24fdb1f600 fix(libstack): redirect the Docker and Compose logging to zerolog BE-11518 (#289) 2025-01-08 16:26:04 -03:00
Oscar Zhou
4010174f66 fix(docker/volume): failed to list volume before snapshot is created [BE-11544] (#286) 2025-01-08 09:45:13 +13:00
andres-portainer
e2b812a611 fix(edgestacks): check the version of the edge stack before updating the status BE-11488 (#287) 2025-01-07 17:31:57 -03:00
andres-portainer
d72b3a9ba2 feat(edgestacks): optimize the Edge Stack status update endpoint BE-11539 (#279) 2025-01-06 15:39:24 -03:00
LP B
85f52d2574 feat(app/stack): ability to prune volumes on stack/edge stack delete (#232)
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2025-01-01 10:44:49 +13:00
andres-portainer
33ea22c0a9 feat(ssl): improve caching behavior BE-11527 (#273) 2024-12-30 11:10:13 -03:00
andres-portainer
0d52f9dd0e feat(async): avoid sending CSRF token for async edge polling requests BE-1152 (#272) 2024-12-30 10:58:44 -03:00
andres-portainer
3caffe1e85 feat(async): filter out Docker snapshot diffs without meaningful changes BE-11527 (#265) 2024-12-26 18:45:20 -03:00
Oscar Zhou
87b8dd61c3 fix: replace strings.ToLower with strings.EqualFold [BE-11524] (#263) 2024-12-24 11:15:16 +13:00
andres-portainer
ad77cd195c fix(docker): fix a data race in the Docker transport BE-10873 (#255) 2024-12-23 09:54:11 -03:00
James Carppe
eb2a754580 Update bug report template for 2.21.5 / 2.25.1 (#261) 2024-12-20 14:39:33 +13:00
Steven Kang
9258db58db feat(auth): add 30m session timeout - r8s-178 (#259) 2024-12-20 10:49:13 +13:00
andres-portainer
8d1c90f912 fix(platform): fix a data race in GetPlatform() BE-11522 (#253) 2024-12-19 09:37:50 -03:00
Steven Kang
1c62bd6ca5 fix: security - CVE-2024-45337 - portainer-suite develop (#247) 2024-12-19 10:55:34 +13:00
andres-portainer
13317ec43c feat(stacks): simplify WaitForStatus() BE-11505 (#241) 2024-12-17 16:25:49 -03:00
James Carppe
35dcb5ca46 Update bug report template for 2.25.0 (#245) 2024-12-16 13:53:15 +13:00
AndrewHucklesby
4454b6b890 bump version to 2.25.0 (#240) 2024-12-12 16:42:55 +13:00
Ali
117e3500ae fix(edge-stack): revert useEffect, to call matchRegistry less often [BE-11501] (#239) 2024-12-12 15:22:19 +13:00
andres-portainer
94fda6a720 fix(offlinegate): avoid leaking an RLock when the handler panics BE-11495 (#234) 2024-12-11 16:38:03 -03:00
Ali
e1388eff84 fix(annotations): parse annotation keys in angular forms [r8s-170] (#233) 2024-12-11 17:50:08 +13:00
Ali
94d2e32b49 fix(apps): simplify helm status [r8s-155] (#230) 2024-12-11 13:18:34 +13:00
Ali
069f22afa4 fix(services): separate table state [BE-11401] (#152) 2024-12-11 11:58:43 +13:00
LP B
52c90d4d0a feat(app/edge-stack): ability to prune containers on edge stack update (#216) 2024-12-10 22:54:02 +01:00
Ali
ce7e0d8d60 refactor(namespace): migrate namespace edit to react [r8s-125] (#38) 2024-12-11 10:15:46 +13:00
Oscar Zhou
40c7742e46 fix(edgestack): validate edge stack name for api [BE-11365] (#222) 2024-12-11 08:21:46 +13:00
Malcolm Lockyer
05e872337a feat(support): add db and activity db file size to support bundle [r8s-169] (#221) 2024-12-10 09:35:30 +13:00
Ali
aac9d001f7 feat(askai): hide askAI for CE [BE-11409] (#220) 2024-12-10 09:11:51 +13:00
andres-portainer
d295968948 feat(libstack): update Compose to v2.31.0 BE-11416 (#223) 2024-12-09 16:36:57 -03:00
Ali
97e7a3c5e2 fix(edge-stacks): various custom template issues [BE-11414] (#189) 2024-12-09 17:48:34 +13:00
Ali
16a1825990 feat(version): remove brackets for sts/lts [BE-11409] (#215) 2024-12-06 22:52:47 +13:00
Ali
441afead10 feat(ask-ai): integrate kapa-ai page [BE-11409] (#214) 2024-12-06 18:41:32 +13:00
Malcolm Lockyer
783ab253af feat(support): collect system info bundle to assist support troubleshooting [r8s-157] (#154) 2024-12-06 15:38:10 +13:00
Yajith Dayarathna
17648d12fe codecov integration with portainer-suite [PLA-119] (#210) 2024-12-06 12:09:09 +13:00
andres-portainer
2f4f1be99c feat(performance): increase HTTP compression performance BE-11417 (#211) 2024-12-05 19:10:56 -03:00
Ali
5d4d3888b8 fix(rbac): use team ids to get namespace access [r8s-154] (#209) 2024-12-05 17:29:45 +13:00
andres-portainer
473084e915 fix(edgestacks): remove edge stacks even after a system crash or power-off BE-10822 (#208) 2024-12-04 19:52:53 -03:00
Anthony Lapenna
a8147b9713 build: tidy up packages by removing unused scripts and files (#207) 2024-12-05 11:18:49 +13:00
Yajith Dayarathna
3c3dc547b2 fix(app/edge-stack): hide non-working BE fields from CE (#205)
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-12-04 19:00:40 +01:00
James Carppe
c5accd0f16 Update bug report template for 2.24.1 (#191) 2024-12-04 08:34:59 +13:00
Oscar Zhou
cb949e443e fix(volume): unable to inspect and browse volume [BE-11216] (#186) 2024-12-03 09:10:10 +13:00
Anthony Lapenna
bb6815f681 build: introduce central Makefile and live-reload for Go (#184) 2024-12-03 08:49:03 +13:00
Anthony Lapenna
a261f60764 version: display dependencies versions (#188)
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-12-03 08:45:44 +13:00
LP B
d393529026 fix(app): passing an initial table state overrides the default global filter state (#180) 2024-11-29 21:06:11 +01:00
Oscar Zhou
219c9593e0 fix(container): binding ip disappear after duplicate container [BE-11413] (#177) 2024-11-29 08:56:44 +13:00
andres-portainer
faa6b2b790 fix(libstack): add the build step for Compose BE-11448 (#173) 2024-11-27 18:43:25 -03:00
Oscar Zhou
4046bf7b31 feat(image): build image with file [BE-11372] (#171) 2024-11-27 18:33:35 -03:00
Ali
4f708309af fix(activity logs): decode base64 [BE-11418] (#172) 2024-11-28 08:54:32 +13:00
andres-portainer
f2e7680bf3 fix(compose): fix path resolution for env files BE-11428 (#167) 2024-11-26 22:09:58 -03:00
andres-portainer
5d2689b139 fix(compose): avoid creating a default network unnecessarily BE-11427 (#169) 2024-11-26 19:48:49 -03:00
andres-portainer
145ffeea40 fix(libstack): resolve env vars correctly in Compose BE-11420 (#166) 2024-11-26 18:09:12 -03:00
andres-portainer
13143bc7ea fix(libstack): fix environment variable handling in compose BE- (#165) 2024-11-26 17:37:22 -03:00
Oscar Zhou
ee0dbf2d22 feat(init): allow to customize kubectl-shell image by cli flag [BE-11419] (#162) 2024-11-26 10:17:46 +13:00
andres-portainer
4265ae4dae feat(offlinegate): improve error message BE-11402 (#163) 2024-11-25 17:40:17 -03:00
andres-portainer
821c1fdbef feat(swarm): do not prevent server startup when Swarm config.json file is invalid BE-11402 (#160) 2024-11-25 17:40:10 -03:00
andres-portainer
fe29d6aee3 feat(backup): reduce the locking time of the offline gate BE-11402 (#157) 2024-11-25 10:10:11 -03:00
Ali
c0c7144539 fix(app templates): load app template for deployment [BE-11382] (#141) 2024-11-25 17:41:09 +13:00
Anthony Lapenna
20e3d3a15b fix: review snapshot and post init migration logic (#158) 2024-11-25 11:03:12 +13:00
James Carppe
07d1eedae3 Update template to include lifecycle policy link (#156) 2024-11-21 17:11:20 +13:00
464 changed files with 12229 additions and 12166 deletions

52
.air.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = ".tmp"
[build]
args_bin = []
bin = "./dist/portainer"
cmd = "SKIP_GO_GET=true make build-server"
delay = 1000
exclude_dir = []
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = "./dist/portainer --log-level=DEBUG"
include_dir = ["api"]
include_ext = ["go"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@@ -95,9 +95,17 @@ 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 [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.27.1'
- '2.27.0'
- '2.26.1'
- '2.26.0'
- '2.25.1'
- '2.25.0'
- '2.24.1'
- '2.24.0'
- '2.23.0'
- '2.22.0'
- '2.21.5'
- '2.21.4'
- '2.21.3'
- '2.21.2'
@@ -113,12 +121,6 @@ body:
- '2.19.2'
- '2.19.1'
- '2.19.0'
- '2.18.4'
- '2.18.3'
- '2.18.2'
- '2.18.1'
- '2.17.1'
- '2.17.0'
validations:
required: true

1
.godir
View File

@@ -1 +0,0 @@
portainer

View File

@@ -20,8 +20,6 @@ linters-settings:
deny:
- pkg: 'encoding/json'
desc: 'use github.com/segmentio/encoding/json'
- pkg: 'github.com/sirupsen/logrus'
desc: 'logging is allowed only by github.com/rs/zerolog'
- pkg: 'golang.org/x/exp'
desc: 'exp is not allowed'
- pkg: 'github.com/portainer/libcrypto'

View File

@@ -17,11 +17,13 @@ GOTESTSUM=go run gotest.tools/gotestsum@latest
##@ Building
.PHONY: init-dist build-storybook build build-client build-server build-image devops
.PHONY: all init-dist build-storybook build build-client build-server build-image devops
init-dist:
@mkdir -p dist
build-all: deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
all: tidy deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
build-all: all ## Alias for the 'all' target (used by CI)
build-client: init-dist ## Build the client
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
@@ -50,7 +52,7 @@ client-deps: ## Install client dependencies
yarn
tidy: ## Tidy up the go.mod file
cd api && go mod tidy
@go mod tidy
##@ Cleanup
@@ -65,10 +67,10 @@ clean: ## Remove all build and download artifacts
test: test-server test-client ## Run all tests
test-client: ## Run client tests
yarn test $(ARGS)
yarn test $(ARGS) --coverage
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
##@ Dev
.PHONY: dev dev-client dev-server

View File

@@ -21,6 +21,7 @@ const rwxr__r__ os.FileMode = 0o744
var filesToBackup = []string{
"certs",
"chisel",
"compose",
"config.json",
"custom_templates",
@@ -30,40 +31,13 @@ var filesToBackup = []string{
"portainer.key",
"portainer.pub",
"tls",
"chisel",
}
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
unlock := gate.Lock()
defer unlock()
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
return "", errors.Wrap(err, "Failed to create backup dir")
}
{
// new export
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
err := datastore.Export(exportFilename)
if err != nil {
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
} else {
log.Debug().Str("filename", exportFilename).Msg("file exported")
}
}
if err := backupDb(backupDirPath, datastore); err != nil {
return "", errors.Wrap(err, "Failed to backup database")
}
for _, filename := range filesToBackup {
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
if err != nil {
return "", errors.Wrap(err, "Failed to create backup file")
}
backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath)
if err != nil {
return "", err
}
archivePath, err := archive.TarGzDir(backupDirPath)
@@ -81,6 +55,37 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
return archivePath, nil
}
func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
unlock := gate.Lock()
defer unlock()
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
return "", errors.Wrap(err, "Failed to create backup dir")
}
// new export
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
if err := datastore.Export(exportFilename); err != nil {
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
} else {
log.Debug().Str("filename", exportFilename).Msg("file exported")
}
if err := backupDb(backupDirPath, datastore); err != nil {
return "", errors.Wrap(err, "Failed to backup database")
}
for _, filename := range filesToBackup {
if err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath); err != nil {
return "", errors.Wrap(err, "Failed to create backup file")
}
}
return backupDirPath, nil
}
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
dbFileName := datastore.Connection().GetDatabaseFileName()
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))

View File

@@ -1,12 +0,0 @@
package build
import "runtime"
// Variables to be set during the build time
var BuildNumber string
var ImageTag string
var NodejsVersion string
var YarnVersion string
var WebpackVersion string
var GoVersion string = runtime.Version()
var GitCommit string

View File

@@ -59,6 +59,7 @@ func CLIFlags() *portainer.CLIFlags {
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
}
}

View File

@@ -19,7 +19,5 @@ func Confirm(message string) (bool, error) {
}
answer = strings.ReplaceAll(answer, "\n", "")
answer = strings.ToLower(answer)
return answer == "y" || answer == "yes", nil
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
}

View File

@@ -10,7 +10,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/build"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/cli"
"github.com/portainer/portainer/api/crypto"
@@ -47,6 +46,7 @@ import (
"github.com/portainer/portainer/api/platform"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/build"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libstack/compose"
@@ -93,7 +93,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
}
store := datastore.NewStore(*flags.Data, fileService, connection)
store := datastore.NewStore(flags, fileService, connection)
isNew, err := store.Open()
if err != nil {
@@ -120,7 +120,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
log.Fatal().Err(err).Msg("failed generating instance id")
}
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{Flags: flags})
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
// from MigrateData
@@ -238,10 +238,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
return err
}
settings.SnapshotInterval = *cmp.Or(flags.SnapshotInterval, &settings.SnapshotInterval)
settings.LogoURL = *cmp.Or(flags.Logo, &settings.LogoURL)
settings.EnableEdgeComputeFeatures = *cmp.Or(flags.EnableEdgeComputeFeatures, &settings.EnableEdgeComputeFeatures)
settings.TemplatesURL = *cmp.Or(flags.Templates, &settings.TemplatesURL)
settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels

View File

@@ -40,6 +40,7 @@ type Connection interface {
GetDatabaseFileName() string
GetDatabaseFilePath() string
GetStorePath() string
GetDatabaseFileSize() (int64, error)
IsEncryptedStore() bool
NeedsEncryptionMigration() (bool, error)

View File

@@ -62,6 +62,15 @@ func (connection *DbConnection) GetStorePath() string {
return connection.Path
}
func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
file, err := os.Stat(connection.GetDatabaseFilePath())
if err != nil {
return 0, fmt.Errorf("Failed to stat database file path: %s err: %w", connection.GetDatabaseFilePath(), err)
}
return file.Size(), nil
}
func (connection *DbConnection) SetEncrypted(flag bool) {
connection.isEncrypted = flag
}

View File

@@ -10,7 +10,7 @@ import (
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://kubernetes.github.io/ingress-nginx","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)

View File

@@ -15,7 +15,7 @@ type Service struct {
connection portainer.Connection
idxVersion map[portainer.EdgeStackID]int
mu sync.RWMutex
cacheInvalidationFn func(portainer.EdgeStackID)
cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)
}
func (service *Service) BucketName() string {
@@ -23,7 +23,7 @@ func (service *Service) BucketName() string {
}
// NewService creates a new instance of a service.
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
@@ -36,7 +36,7 @@ func NewService(connection portainer.Connection, cacheInvalidationFn func(portai
}
if s.cacheInvalidationFn == nil {
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {}
}
es, err := s.EdgeStacks()
@@ -106,7 +106,7 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
service.mu.Lock()
service.idxVersion[id] = edgeStack.Version
service.cacheInvalidationFn(id)
service.cacheInvalidationFn(service.connection, id)
service.mu.Unlock()
return nil
@@ -125,7 +125,7 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
}
service.idxVersion[ID] = edgeStack.Version
service.cacheInvalidationFn(ID)
service.cacheInvalidationFn(service.connection, ID)
return nil
}
@@ -142,7 +142,7 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
updateFunc(edgeStack)
service.idxVersion[ID] = edgeStack.Version
service.cacheInvalidationFn(ID)
service.cacheInvalidationFn(service.connection, ID)
})
}
@@ -165,7 +165,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
delete(service.idxVersion, ID)
service.cacheInvalidationFn(ID)
service.cacheInvalidationFn(service.connection, ID)
return nil
}

View File

@@ -44,8 +44,7 @@ func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeSta
var stack portainer.EdgeStack
identifier := service.service.connection.ConvertToKey(int(ID))
err := service.tx.GetObject(BucketName, identifier, &stack)
if err != nil {
if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil {
return nil, err
}
@@ -65,18 +64,17 @@ func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
edgeStack.ID = id
err := service.tx.CreateObjectWithId(
if err := service.tx.CreateObjectWithId(
BucketName,
int(edgeStack.ID),
edgeStack,
)
if err != nil {
); err != nil {
return err
}
service.service.mu.Lock()
service.service.idxVersion[id] = edgeStack.Version
service.service.cacheInvalidationFn(id)
service.service.cacheInvalidationFn(service.tx, id)
service.service.mu.Unlock()
return nil
@@ -89,13 +87,12 @@ func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *po
identifier := service.service.connection.ConvertToKey(int(ID))
err := service.tx.UpdateObject(BucketName, identifier, edgeStack)
if err != nil {
if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil {
return err
}
service.service.idxVersion[ID] = edgeStack.Version
service.service.cacheInvalidationFn(ID)
service.service.cacheInvalidationFn(service.tx, ID)
return nil
}
@@ -119,14 +116,13 @@ func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error {
identifier := service.service.connection.ConvertToKey(int(ID))
err := service.tx.DeleteObject(BucketName, identifier)
if err != nil {
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
return err
}
delete(service.service.idxVersion, ID)
service.service.cacheInvalidationFn(ID)
service.service.cacheInvalidationFn(service.tx, ID)
return nil
}

View File

@@ -1,6 +1,8 @@
package endpointrelation
import (
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge/cache"
@@ -13,11 +15,15 @@ const BucketName = "endpoint_relations"
// Service represents a service for managing environment(endpoint) relation data.
type Service struct {
connection portainer.Connection
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
connection portainer.Connection
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
endpointRelationsCache []portainer.EndpointRelation
mu sync.Mutex
}
var _ dataservices.EndpointRelationService = &Service{}
func (service *Service) BucketName() string {
return BucketName
}
@@ -76,6 +82,10 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) err
err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
cache.Del(endpointRelation.EndpointID)
service.mu.Lock()
service.endpointRelationsCache = nil
service.mu.Unlock()
return err
}
@@ -92,11 +102,27 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
updatedRelationState, _ := service.EndpointRelation(endpointID)
service.mu.Lock()
service.endpointRelationsCache = nil
service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
return nil
}
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error {
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
})
}
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error {
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
})
}
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
deletedRelation, _ := service.EndpointRelation(endpointID)
@@ -108,27 +134,15 @@ func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID)
return err
}
service.mu.Lock()
service.endpointRelationsCache = nil
service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
return nil
}
func (service *Service) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
rels, err := service.EndpointRelations()
if err != nil {
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
return
}
for _, rel := range rels {
for id := range rel.EdgeStacks {
if edgeStackID == id {
cache.Del(rel.EndpointID)
}
}
}
}
func (service *Service) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
relations, _ := service.EndpointRelations()

View File

@@ -13,6 +13,8 @@ type ServiceTx struct {
tx portainer.Transaction
}
var _ dataservices.EndpointRelationService = &ServiceTx{}
func (service ServiceTx) BucketName() string {
return BucketName
}
@@ -45,6 +47,10 @@ func (service ServiceTx) Create(endpointRelation *portainer.EndpointRelation) er
err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
cache.Del(endpointRelation.EndpointID)
service.service.mu.Lock()
service.service.endpointRelationsCache = nil
service.service.mu.Unlock()
return err
}
@@ -61,11 +67,67 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
updatedRelationState, _ := service.EndpointRelation(endpointID)
service.service.mu.Lock()
service.service.endpointRelationsCache = nil
service.service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
return nil
}
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
for _, endpointID := range endpointIDs {
rel, err := service.EndpointRelation(endpointID)
if err != nil {
return err
}
rel.EdgeStacks[edgeStackID] = true
identifier := service.service.connection.ConvertToKey(int(endpointID))
err = service.tx.UpdateObject(BucketName, identifier, rel)
cache.Del(endpointID)
if err != nil {
return err
}
}
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments += len(endpointIDs)
}); err != nil {
log.Error().Err(err).Msg("could not update the number of deployments")
}
return nil
}
func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
for _, endpointID := range endpointIDs {
rel, err := service.EndpointRelation(endpointID)
if err != nil {
return err
}
delete(rel.EdgeStacks, edgeStackID)
identifier := service.service.connection.ConvertToKey(int(endpointID))
err = service.tx.UpdateObject(BucketName, identifier, rel)
cache.Del(endpointID)
if err != nil {
return err
}
}
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments -= len(endpointIDs)
}); err != nil {
log.Error().Err(err).Msg("could not update the number of deployments")
}
return nil
}
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
deletedRelation, _ := service.EndpointRelation(endpointID)
@@ -77,27 +139,44 @@ func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID)
return err
}
service.service.mu.Lock()
service.service.endpointRelationsCache = nil
service.service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
return nil
}
func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
rels, err := service.EndpointRelations()
rels, err := service.cachedEndpointRelations()
if err != nil {
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
return
}
for _, rel := range rels {
for id := range rel.EdgeStacks {
if edgeStackID == id {
cache.Del(rel.EndpointID)
}
if _, ok := rel.EdgeStacks[edgeStackID]; ok {
cache.Del(rel.EndpointID)
}
}
}
func (service ServiceTx) cachedEndpointRelations() ([]portainer.EndpointRelation, error) {
service.service.mu.Lock()
defer service.service.mu.Unlock()
if service.service.endpointRelationsCache == nil {
var err error
service.service.endpointRelationsCache, err = service.EndpointRelations()
if err != nil {
return nil, err
}
}
return service.service.endpointRelationsCache, nil
}
func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
relations, _ := service.EndpointRelations()
@@ -133,6 +212,7 @@ func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationSta
}
numDeployments := 0
for _, r := range relations {
for sId, enabled := range r.EdgeStacks {
if enabled && sId == refStackId {

View File

@@ -115,6 +115,8 @@ type (
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
Create(endpointRelation *portainer.EndpointRelation) error
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
BucketName() string
}

View File

@@ -16,8 +16,9 @@ import (
)
// NewStore initializes a new Store and the associated services
func NewStore(storePath string, fileService portainer.FileService, connection portainer.Connection) *Store {
func NewStore(cliFlags *portainer.CLIFlags, fileService portainer.FileService, connection portainer.Connection) *Store {
return &Store{
flags: cliFlags,
fileService: fileService,
connection: connection,
}

View File

@@ -57,7 +57,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
KubectlShellImage: portainer.DefaultKubectlShellImage,
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
}

View File

@@ -32,7 +32,7 @@ func (store *Store) MigrateData() error {
return errors.Wrap(err, "while migrating legacy version")
}
migratorParams := store.newMigratorParameters(version)
migratorParams := store.newMigratorParameters(version, store.flags)
migrator := migrator.NewMigrator(migratorParams)
if !migrator.NeedsMigration() {
@@ -62,8 +62,9 @@ func (store *Store) MigrateData() error {
return nil
}
func (store *Store) newMigratorParameters(version *models.Version) *migrator.MigratorParameters {
func (store *Store) newMigratorParameters(version *models.Version, flags *portainer.CLIFlags) *migrator.MigratorParameters {
return &migrator.MigratorParameters{
Flags: flags,
CurrentDBVersion: version,
EndpointGroupService: store.EndpointGroupService,
EndpointService: store.EndpointService,

View File

@@ -109,7 +109,7 @@ func TestMigrateData(t *testing.T) {
t.FailNow()
}
migratorParams := store.newMigratorParameters(v)
migratorParams := store.newMigratorParameters(v, store.flags)
m := migrator.NewMigrator(migratorParams)
latestMigrations := m.LatestMigrations()

View File

@@ -48,6 +48,7 @@ func TestMigrateSettings(t *testing.T) {
}
m := migrator.NewMigrator(&migrator.MigratorParameters{
Flags: store.flags,
EndpointGroupService: store.EndpointGroupService,
EndpointService: store.EndpointService,
EndpointRelationService: store.EndpointRelationService,

View File

@@ -1,8 +1,6 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
@@ -20,7 +18,7 @@ func (m *Migrator) migrateSettingsToDB33() error {
}
log.Info().Msg("setting default kubectl shell image")
settings.KubectlShellImage = portainer.DefaultKubectlShellImage
settings.KubectlShellImage = *m.flags.KubectlShellImage
return m.settingsService.UpdateSettings(settings)
}

View File

@@ -33,6 +33,7 @@ import (
type (
// Migrator defines a service to migrate data after a Portainer version update.
Migrator struct {
flags *portainer.CLIFlags
currentDBVersion *models.Version
migrations []Migrations
@@ -62,6 +63,7 @@ type (
// MigratorParameters represents the required parameters to create a new Migrator instance.
MigratorParameters struct {
Flags *portainer.CLIFlags
CurrentDBVersion *models.Version
EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service
@@ -91,6 +93,7 @@ type (
// NewMigrator creates a new Migrator.
func NewMigrator(parameters *MigratorParameters) *Migrator {
migrator := &Migrator{
flags: parameters.Flags,
currentDBVersion: parameters.CurrentDBVersion,
endpointGroupService: parameters.EndpointGroupService,
endpointService: parameters.EndpointService,

View File

@@ -11,6 +11,7 @@ import (
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/portainer/portainer/pkg/endpoints"
"github.com/rs/zerolog/log"
)
@@ -49,17 +50,29 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
if endpointutils.IsEdgeEndpoint(&environment) {
log.Info().Msgf("Adding pending action 'PostInitMigrateEnvironment' for environment %d", environment.ID)
err = postInitMigrator.createPostInitMigrationPendingAction(environment.ID)
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environment.ID)
if endpoints.IsEdgeEndpoint(&environment) {
// Skip edge environments that do not have direct connectivity
if !endpoints.HasDirectConnectivity(&environment) {
continue
}
log.Info().
Int("endpoint_id", int(environment.ID)).
Msg("adding pending action 'PostInitMigrateEnvironment' for environment")
if err := postInitMigrator.createPostInitMigrationPendingAction(environment.ID); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating pending action for environment")
}
} else {
// non-edge environments will run before the server starts.
err = postInitMigrator.MigrateEnvironment(&environment)
if err != nil {
log.Error().Err(err).Msgf("Error running post-init migrations for non-edge environment %d", environment.ID)
// Non-edge environments will run before the server starts.
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error running post-init migrations for non-edge environment")
}
}

View File

@@ -42,6 +42,7 @@ import (
// Store defines the implementation of portainer.DataStore using
// BoltDB as the storage system.
type Store struct {
flags *portainer.CLIFlags
connection portainer.Connection
fileService portainer.FileService
@@ -99,7 +100,9 @@ func (store *Store) initServices() error {
}
store.EndpointRelationService = endpointRelationService
edgeStackService, err := edgestack.NewService(store.connection, endpointRelationService.InvalidateEdgeCacheForEdgeStack)
edgeStackService, err := edgestack.NewService(store.connection, func(tx portainer.Transaction, ID portainer.EdgeStackID) {
endpointRelationService.Tx(tx).InvalidateEdgeCacheForEdgeStack(ID)
})
if err != nil {
return err
}

File diff suppressed because it is too large Load Diff

View File

@@ -605,12 +605,12 @@
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
"HelmRepositoryURL": "",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.24.0",
"KubectlShellImage": "portainer/kubectl-shell:2.27.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -672,6 +672,7 @@
{
"Docker": {
"ContainerCount": 0,
"DiagnosticsData": {},
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
@@ -942,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.24.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.27.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -29,6 +29,10 @@ func MustNewTestStore(t testing.TB, init, secure bool) (bool, *Store) {
func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error) {
// Creates unique temp directory in a concurrency friendly manner.
storePath := t.TempDir()
defaultKubectlShellImage := portainer.DefaultKubectlShellImage
flags := &portainer.CLIFlags{
KubectlShellImage: &defaultKubectlShellImage,
}
fileService, err := filesystem.NewService(storePath, "")
if err != nil {
@@ -45,7 +49,7 @@ func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error)
panic(err)
}
store := NewStore(storePath, fileService, connection)
store := NewStore(flags, fileService, connection)
newStore, err := store.Open()
if err != nil {
return newStore, nil, nil, err

View File

@@ -3,8 +3,8 @@ package client
import (
"bytes"
"errors"
"fmt"
"io"
"maps"
"net/http"
"strings"
"time"
@@ -141,7 +141,6 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
type NodeNameTransport struct {
*http.Transport
nodeNames map[string]string
}
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -176,18 +175,19 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
return resp, nil
}
t.nodeNames = make(map[string]string)
for _, r := range rs {
t.nodeNames[r.ID] = r.Portainer.Agent.NodeName
nodeNames, ok := req.Context().Value("nodeNames").(map[string]string)
if ok {
for idx, r := range rs {
// as there is no way to differentiate the same image available in multiple nodes only by their ID
// we append the index of the image in the payload response to match the node name later
// from the image.Summary[] list returned by docker's client.ImageList()
nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName
}
}
return resp, err
}
func (t *NodeNameTransport) NodeNames() map[string]string {
return maps.Clone(t.nodeNames)
}
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
transport := &NodeNameTransport{
Transport: &http.Transport{},

View File

@@ -6,7 +6,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
@@ -25,18 +25,18 @@ func NewPuller(client *client.Client, registryClient *RegistryClient, dataStore
}
}
func (puller *Puller) Pull(ctx context.Context, image Image) error {
log.Debug().Str("image", image.FullName()).Msg("starting to pull the image")
func (puller *Puller) Pull(ctx context.Context, img Image) error {
log.Debug().Str("image", img.FullName()).Msg("starting to pull the image")
registryAuth, err := puller.registryClient.EncodedRegistryAuth(image)
registryAuth, err := puller.registryClient.EncodedRegistryAuth(img)
if err != nil {
log.Debug().
Str("image", image.FullName()).
Str("image", img.FullName()).
Err(err).
Msg("failed to get an encoded registry auth via image, try to pull image without registry auth")
}
out, err := puller.client.ImagePull(ctx, image.FullName(), types.ImagePullOptions{
out, err := puller.client.ImagePull(ctx, img.FullName(), image.PullOptions{
RegistryAuth: registryAuth,
})
if err != nil {

View File

@@ -1,20 +1,9 @@
package docker
import (
"context"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/consts"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
_container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"github.com/portainer/portainer/pkg/snapshot"
)
// Snapshotter represents a service used to create environment(endpoint) snapshots
@@ -37,247 +26,5 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
}
defer cli.Close()
return snapshot(cli, endpoint)
}
func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
if _, err := cli.Ping(context.Background()); err != nil {
return nil, err
}
snapshot := &portainer.DockerSnapshot{
StackCount: 0,
}
if err := snapshotInfo(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot engine information")
}
if snapshot.Swarm {
if err := snapshotSwarmServices(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot Swarm services")
}
if err := snapshotNodes(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot Swarm nodes")
}
}
if err := snapshotContainers(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot containers")
}
if err := snapshotImages(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot images")
}
if err := snapshotVolumes(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot volumes")
}
if err := snapshotNetworks(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot networks")
}
if err := snapshotVersion(snapshot, cli); err != nil {
log.Warn().Str("environment", endpoint.Name).Err(err).Msg("unable to snapshot engine version")
}
snapshot.Time = time.Now().Unix()
return snapshot, nil
}
func snapshotInfo(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
info, err := cli.Info(context.Background())
if err != nil {
return err
}
snapshot.Swarm = info.Swarm.ControlAvailable
snapshot.DockerVersion = info.ServerVersion
snapshot.TotalCPU = info.NCPU
snapshot.TotalMemory = info.MemTotal
snapshot.SnapshotRaw.Info = info
return nil
}
func snapshotNodes(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
nodes, err := cli.NodeList(context.Background(), types.NodeListOptions{})
if err != nil {
return err
}
var nanoCpus int64
var totalMem int64
for _, node := range nodes {
nanoCpus += node.Description.Resources.NanoCPUs
totalMem += node.Description.Resources.MemoryBytes
}
snapshot.TotalCPU = int(nanoCpus / 1e9)
snapshot.TotalMemory = totalMem
snapshot.NodeCount = len(nodes)
return nil
}
func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
stacks := make(map[string]struct{})
services, err := cli.ServiceList(context.Background(), types.ServiceListOptions{})
if err != nil {
return err
}
for _, service := range services {
for k, v := range service.Spec.Labels {
if k == "com.docker.stack.namespace" {
stacks[v] = struct{}{}
}
}
}
snapshot.ServiceCount = len(services)
snapshot.StackCount += len(stacks)
return nil
}
func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
return err
}
stacks := make(map[string]struct{})
gpuUseSet := make(map[string]struct{})
gpuUseAll := false
for _, container := range containers {
if container.State == "running" {
// Snapshot GPUs
response, err := cli.ContainerInspect(context.Background(), container.ID)
if err != nil {
// Inspect a container will fail when the container runs on a different
// Swarm node, so it is better to log the error instead of return error
// when the Swarm mode is enabled
if !snapshot.Swarm {
return err
} else {
if !strings.Contains(err.Error(), "No such container") {
return err
}
// It is common to have containers running on different Swarm nodes,
// so we just log the error in the debug level
log.Debug().Str("container", container.ID).Err(err).Msg("unable to inspect container in other Swarm nodes")
}
} else {
var gpuOptions *_container.DeviceRequest = nil
for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests {
if deviceRequest.Driver == "nvidia" || deviceRequest.Capabilities[0][0] == "gpu" {
gpuOptions = &deviceRequest
}
}
if gpuOptions != nil {
if gpuOptions.Count == -1 {
gpuUseAll = true
}
for _, id := range gpuOptions.DeviceIDs {
gpuUseSet[id] = struct{}{}
}
}
}
}
for k, v := range container.Labels {
if k == consts.ComposeStackNameLabel {
stacks[v] = struct{}{}
}
}
}
gpuUseList := make([]string, 0, len(gpuUseSet))
for gpuUse := range gpuUseSet {
gpuUseList = append(gpuUseList, gpuUse)
}
snapshot.GpuUseAll = gpuUseAll
snapshot.GpuUseList = gpuUseList
stats := CalculateContainerStats(containers)
snapshot.ContainerCount = stats.Total
snapshot.RunningContainerCount = stats.Running
snapshot.StoppedContainerCount = stats.Stopped
snapshot.HealthyContainerCount = stats.Healthy
snapshot.UnhealthyContainerCount = stats.Unhealthy
snapshot.StackCount += len(stacks)
for _, container := range containers {
snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container})
}
return nil
}
func snapshotImages(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
images, err := cli.ImageList(context.Background(), types.ImageListOptions{})
if err != nil {
return err
}
snapshot.ImageCount = len(images)
snapshot.SnapshotRaw.Images = images
return nil
}
func snapshotVolumes(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
volumes, err := cli.VolumeList(context.Background(), volume.ListOptions{})
if err != nil {
return err
}
snapshot.VolumeCount = len(volumes.Volumes)
snapshot.SnapshotRaw.Volumes = volumes
return nil
}
func snapshotNetworks(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{})
if err != nil {
return err
}
snapshot.SnapshotRaw.Networks = networks
return nil
}
func snapshotVersion(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
version, err := cli.ServerVersion(context.Background())
if err != nil {
return err
}
snapshot.SnapshotRaw.Version = version
snapshot.IsPodman = isPodman(version)
return nil
}
// isPodman checks if the version is for Podman by checking if any of the components contain "podman".
// If it's podman, a component name should be "Podman Engine"
func isPodman(version types.Version) bool {
for _, component := range version.Components {
if strings.Contains(strings.ToLower(component.Name), "podman") {
return true
}
}
return false
return snapshot.CreateDockerSnapshot(cli)
}

View File

@@ -58,6 +58,20 @@ type (
// Used only for EE async edge agent
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
DeployerOptionsPayload DeployerOptionsPayload
}
DeployerOptionsPayload struct {
// Prune is a flag indicating if the agent must prune the containers or not when creating/updating an edge stack
// This flag drives `docker compose up --remove-orphans` and `docker stack up --prune` options
// Used only for EE
Prune bool
// RemoveVolumes is a flag indicating if the agent must remove the named volumes declared
// in the compose file and anonymouse volumes attached to containers
// This flag drives `docker compose down --volumes` option
// Used only for EE
RemoveVolumes bool
}
// RegistryCredentials holds the credentials for a Docker registry.

View File

@@ -127,7 +127,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
return err
}
args = append(args, "stack", "rm", stack.Name)
args = append(args, "stack", "rm", "--detach=false", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
@@ -199,7 +199,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
return err
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
}
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)

View File

@@ -841,11 +841,11 @@ func (service *Service) GetDefaultSSLCertsPath() (string, string) {
}
func defaultMTLSCertPathUnderFileStore() (string, string, string) {
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename)
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename)
return certPath, caCertPath, keyPath
return caCertPath, certPath, keyPath
}
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
@@ -1014,26 +1014,45 @@ func CreateFile(path string, r io.Reader) error {
return err
}
func (service *Service) StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error) {
certPath, caCertPath, keyPath := defaultMTLSCertPathUnderFileStore()
func (service *Service) StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) {
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
r := bytes.NewReader(cert)
err := service.createFileInStore(certPath, r)
if err != nil {
r := bytes.NewReader(caCert)
if err := service.createFileInStore(caCertPath, r); err != nil {
return "", "", "", err
}
r = bytes.NewReader(caCert)
err = service.createFileInStore(caCertPath, r)
if err != nil {
r = bytes.NewReader(cert)
if err := service.createFileInStore(certPath, r); err != nil {
return "", "", "", err
}
r = bytes.NewReader(key)
err = service.createFileInStore(keyPath, r)
if err != nil {
if err := service.createFileInStore(keyPath, r); err != nil {
return "", "", "", err
}
return service.wrapFileStore(certPath), service.wrapFileStore(caCertPath), service.wrapFileStore(keyPath), nil
return service.wrapFileStore(caCertPath), service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil
}
func (service *Service) GetMTLSCertificates() (string, string, string, error) {
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
caCertPath = service.wrapFileStore(caCertPath)
certPath = service.wrapFileStore(certPath)
keyPath = service.wrapFileStore(keyPath)
paths := [...]string{caCertPath, certPath, keyPath}
for _, path := range paths {
exists, err := service.FileExists(path)
if err != nil {
return "", "", "", err
}
if !exists {
return "", "", "", fmt.Errorf("file %s does not exist", path)
}
}
return caCertPath, certPath, keyPath, nil
}

View File

@@ -44,11 +44,10 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
// For given configPath A/B/C, return entries:
// 1. all entries outside of dir A
// 2. dir entries A, A/B, A/B/C
// 3. For filterType file:
// 1. all entries outside of dir A/B/C
// 2. For filterType file:
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
// 4. For filterType dir:
// 3. For filterType dir:
// dir entry: A/B/C/<deviceName>
// all entries: A/B/C/<deviceName>/*
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
@@ -66,12 +65,7 @@ func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath str
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
// Include all entries outside of dir A
if !isInConfigRootDir(dirEntry, configPath) {
return true
}
// Include dir entries A, A/B, A/B/C
if isParentDir(dirEntry, configPath) {
if !isInConfigDir(dirEntry, configPath) {
return true
}
@@ -90,21 +84,9 @@ func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filter
return false
}
func isInConfigRootDir(dirEntry DirEntry, configPath string) bool {
// get the first element of the configPath
rootDir := strings.Split(configPath, string(os.PathSeparator))[0]
// return true if entry name starts with "A/"
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(rootDir))
}
func isParentDir(dirEntry DirEntry, configPath string) bool {
if dirEntry.IsFile {
return false
}
// return true for dir entries A, A/B, A/B/C
return strings.HasPrefix(appendTailSeparator(configPath), appendTailSeparator(dirEntry.Name))
func isInConfigDir(dirEntry DirEntry, configPath string) bool {
// return true if entry name starts with "A/B"
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
}
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {

View File

@@ -90,3 +90,24 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
})
}
}
func TestIsInConfigDir(t *testing.T) {
f := func(dirEntry DirEntry, configPath string, expect bool) {
t.Helper()
actual := isInConfigDir(dirEntry, configPath)
assert.Equal(t, expect, actual)
}
f(DirEntry{Name: "edge-configs"}, "edge-configs", false)
f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false)
f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true)
f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false)
f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
}

View File

@@ -44,13 +44,13 @@ func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfi
}
func parseAction(actionRaw string) (portainer.PowerState, error) {
switch strings.ToLower(actionRaw) {
case "power on":
if strings.EqualFold(actionRaw, "power on") {
return powerOnState, nil
case "power off":
} else if strings.EqualFold(actionRaw, "power off") {
return powerOffState, nil
case "restart":
} else if strings.EqualFold(actionRaw, "restart") {
return restartState, nil
}
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
}

View File

@@ -13,6 +13,12 @@ import (
"github.com/urfave/negroni"
)
const csrfSkipHeader = "X-CSRF-Token-Skip"
func SkipCSRFToken(w http.ResponseWriter) {
w.Header().Set(csrfSkipHeader, "1")
}
func WithProtect(handler http.Handler) (http.Handler, error) {
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
@@ -42,10 +48,14 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
sw := negroni.NewResponseWriter(w)
sw.Before(func(sw negroni.ResponseWriter) {
statusCode := sw.Status()
if statusCode >= 200 && statusCode < 300 {
csrfToken := gorillacsrf.Token(r)
sw.Header().Set("X-CSRF-Token", csrfToken)
if len(sw.Header().Get(csrfSkipHeader)) > 0 {
sw.Header().Del(csrfSkipHeader)
return
}
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
}
})

View File

@@ -482,28 +482,3 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
return customTemplate, nil
}
// @id CustomTemplateCreate
// @summary Create a custom template
// @description Create a custom template.
// @description **Access policy**: authenticated
// @tags custom_templates
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param method query string true "method for creating template" Enums(string, file, repository)
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
// @success 200 {object} portainer.CustomTemplate
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /custom_templates [post]
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method", err)
}
return "/custom_templates/create/" + method, nil
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
@@ -33,7 +32,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/custom_templates/create/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
h.Handle("/custom_templates",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
h.Handle("/custom_templates/{id}",

View File

@@ -1,18 +1,19 @@
package images
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/handler/docker/utils"
"github.com/portainer/portainer/api/set"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
)
type ImageResponse struct {
@@ -46,17 +47,16 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
return httpErr
}
images, err := cli.ImageList(r.Context(), types.ImageListOptions{})
nodeNames := make(map[string]string)
// Pass the node names map to the context so the custom NodeNameTransport can use it
ctx := context.WithValue(r.Context(), "nodeNames", nodeNames)
images, err := cli.ImageList(ctx, image.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}
// Extract the node name from the custom transport
nodeNames := make(map[string]string)
if t, ok := cli.HTTPClient().Transport.(*client.NodeNameTransport); ok {
nodeNames = t.NodeNames()
}
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
if err != nil {
return httperror.BadRequest("Invalid query parameter: withUsage", err)
@@ -85,8 +85,12 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
}
imagesList[i] = ImageResponse{
Created: image.Created,
NodeName: nodeNames[image.ID],
Created: image.Created,
// Only works if the order of `images` is not changed between unmarshaling the agent's response
// in NodeNameTransport.RoundTrip() (api/docker/client/client.go)
// and docker's cli.ImageList()
// As both functions unmarshal the same response body, the resulting array will be ordered the same way.
NodeName: nodeNames[fmt.Sprintf("%s-%d", image.ID, i)],
ID: image.ID,
Size: image.Size,
Tags: image.RepoTags,

View File

@@ -167,7 +167,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return err
}
@@ -183,6 +183,12 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
edgeStackSet[edgeStackID] = true
}
if relation == nil {
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
relation.EdgeStacks = edgeStackSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)

View File

@@ -271,26 +271,3 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
}
// @id EdgeJobCreate
// @summary Create an EdgeJob
// @description **Access policy**: administrator
// @tags edge_jobs
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file, string)
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
// @success 200 {object} portainer.EdgeGroup
// @failure 503 "Edge compute features are disabled"
// @failure 500
// @deprecated
// @router /edge_jobs [post]
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return "/edge_jobs/create/" + method, nil
}

View File

@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -30,8 +29,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/{id}",

View File

@@ -55,26 +55,3 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
}
// @id EdgeStackCreate
// @summary Create an EdgeStack
// @description **Access policy**: administrator
// @tags edge_stacks
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file,string,repository)
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
// @success 200 {object} portainer.EdgeStack
// @failure 500
// @failure 503 "Edge compute features are disabled"
// @deprecated
// @router /edge_stacks [post]
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return "/edge_stacks/create/" + method, nil
}

View File

@@ -6,12 +6,18 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors"
)
type edgeStackFromFileUploadPayload struct {
// Name of the stack
// Max length: 255
// Name must only contains lowercase characters, numbers, hyphens, or underscores
// Name must start with a lowercase character or number
// Example: stack-name or stack_123 or stackName
Name string
StackFileContent []byte
EdgeGroups []portainer.EdgeGroupID
@@ -32,6 +38,10 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
}
payload.Name = name
if !edge.IsValidEdgeStackName(payload.Name) {
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file")
if err != nil {
return httperrors.NewInvalidPayloadError("Invalid Compose file. Ensure that the Compose file is uploaded correctly")
@@ -75,7 +85,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
// @security jwt
// @accept multipart/form-data
// @produce json
// @param Name formData string true "Name of the stack"
// @param Name formData string true "Name of the stack. it must only consist of lowercase alphanumeric characters, hyphens, or underscores as well as start with a letter or number"
// @param file formData file true "Content of the Stack file"
// @param EdgeGroups formData string true "JSON stringified array of Edge Groups ids"
// @param DeploymentType formData int true "deploy type 0 - 'compose', 1 - 'kubernetes'"

View File

@@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/asaskevich/govalidator"
@@ -17,7 +18,11 @@ import (
type edgeStackFromGitRepositoryPayload struct {
// Name of the stack
Name string `example:"myStack" validate:"required"`
// Max length: 255
// Name must only contains lowercase characters, numbers, hyphens, or underscores
// Name must start with a lowercase character or number
// Example: stack-name or stack_123 or stackName
Name string `example:"stack-name" validate:"required"`
// URL of a Git repository hosting the Stack file
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file
@@ -50,6 +55,10 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
return httperrors.NewInvalidPayloadError("Invalid stack name")
}
if !edge.IsValidEdgeStackName(payload.Name) {
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors"
@@ -15,7 +16,11 @@ import (
type edgeStackFromStringPayload struct {
// Name of the stack
Name string `example:"myStack" validate:"required"`
// Max length: 255
// Name must only contains lowercase characters, numbers, hyphens, or underscores
// Name must start with a lowercase character or number
// Example: stack-name or stack_123 or stackName
Name string `example:"stack-name" validate:"required"`
// Content of the Stack file
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"`
// List of identifiers of EdgeGroups
@@ -36,6 +41,10 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
return httperrors.NewInvalidPayloadError("Invalid stack name")
}
if !edge.IsValidEdgeStackName(payload.Name) {
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
if len(payload.StackFileContent) == 0 {
return httperrors.NewInvalidPayloadError("Invalid stack file content")
}

View File

@@ -43,7 +43,7 @@ func TestCreateAndInspect(t *testing.T) {
}
payload := edgeStackFromStringPayload{
Name: "Test Stack",
Name: "test-stack",
StackFileContent: "stack content",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentCompose,
@@ -161,7 +161,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
{
Name: "EdgeStackDeploymentKubernetes with Docker endpoint",
Payload: edgeStackFromStringPayload{
Name: "Stack name",
Name: "stack-name",
StackFileContent: "content",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
@@ -172,7 +172,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
{
Name: "Empty Stack File Content",
Payload: edgeStackFromStringPayload{
Name: "Stack name",
Name: "stack-name",
StackFileContent: "",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentCompose,
@@ -183,7 +183,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
{
Name: "Clone Git repository error",
Payload: edgeStackFromGitRepositoryPayload{
Name: "Stack name",
Name: "stack-name",
RepositoryURL: "github.com/portainer/portainer",
RepositoryReferenceName: "ref name",
RepositoryAuthentication: false,

View File

@@ -3,6 +3,7 @@ package edgestacks
import (
"errors"
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -52,10 +53,14 @@ func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
}
err = handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups)
if err != nil {
if err := handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups); err != nil {
return httperror.InternalServerError("Unable to delete edge stack", err)
}
stackFolder := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(edgeStack.ID)))
if err := handler.FileService.RemoveDirectory(stackFolder); err != nil {
return httperror.InternalServerError("Unable to remove edge stack project folder", err)
}
return nil
}

View File

@@ -1,12 +1,14 @@
package edgestacks
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/segmentio/encoding/json"
)
@@ -101,3 +103,52 @@ func TestDeleteInvalidEdgeStack(t *testing.T) {
})
}
}
func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
handler, rawAPIKey := setupHandler(t)
edgeGroup := createEdgeGroup(t, handler.DataStore)
payload := edgeStackFromStringPayload{
Name: "test-stack",
DeploymentType: portainer.EdgeStackDeploymentCompose,
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
StackFileContent: "version: '3.7'\nservices:\n test:\n image: test",
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
t.Fatal("error encoding payload:", err)
}
// Create
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
}
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
// Delete
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
}
assert.NoDirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
}

View File

@@ -34,7 +34,7 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if err != nil {
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
}
fileName := stack.EntryPoint

View File

@@ -30,7 +30,7 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if err != nil {
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
}
return response.JSON(w, edgeStack)

View File

@@ -1,87 +0,0 @@
package edgestacks
import (
"errors"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id EdgeStackStatusDelete
// @summary Delete an EdgeStack status
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
// @tags edge_stacks
// @produce json
// @param id path int true "EdgeStack Id"
// @param environmentId path int true "Environment identifier"
// @success 200 {object} portainer.EdgeStack
// @failure 500
// @failure 400
// @failure 404
// @failure 403
// @deprecated
// @router /edge_stacks/{id}/status/{environmentId} [delete]
func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid stack identifier route variable", err)
}
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a valid endpoint from the handler context", err)
}
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
var stack *portainer.EdgeStack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stack, err = handler.deleteEdgeStackStatus(tx, portainer.EdgeStackID(stackID), endpoint)
return err
})
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
}
return httperror.InternalServerError("Unexpected error", err)
}
return response.JSON(w, stack)
}
func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, endpoint *portainer.Endpoint) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
}
environmentStatus, ok := stack.Status[endpoint.ID]
if !ok {
environmentStatus = portainer.EdgeStackStatus{}
}
environmentStatus.Status = append(environmentStatus.Status, portainer.EdgeStackDeploymentStatus{
Time: time.Now().Unix(),
Type: portainer.EdgeStackStatusRemoved,
})
stack.Status[endpoint.ID] = environmentStatus
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
return stack, nil
}

View File

@@ -1,30 +0,0 @@
package edgestacks
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
)
func TestDeleteStatus(t *testing.T) {
handler, _ := setupHandler(t)
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
}
}

View File

@@ -4,10 +4,11 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -20,6 +21,7 @@ type updateStatusPayload struct {
Status *portainer.EdgeStackStatusType
EndpointID portainer.EndpointID
Time int64
Version int
}
func (payload *updateStatusPayload) Validate(r *http.Request) error {
@@ -67,11 +69,21 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID))
}
var stack *portainer.EdgeStack
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
return err
}); err != nil {
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
if err != nil {
return handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
}
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -80,32 +92,16 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unexpected error", err)
}
if ok, _ := strconv.ParseBool(r.Header.Get("X-Portainer-No-Body")); ok {
return nil
}
return response.JSON(w, stack)
}
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// skip error because agent tries to report on deleted stack
log.Debug().
Err(err).
Int("stackID", int(stackID)).
Int("status", int(*payload.Status)).
Msg("Unable to find a stack inside the database, skipping error")
return nil, nil
}
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w. Environment ID: %d", err, payload.EndpointID)
}
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
if err != nil {
return nil, handler.handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
if payload.Version > 0 && payload.Version < stack.Version {
return stack, nil
}
status := *payload.Status
@@ -123,10 +119,6 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
if err := tx.EdgeStack().UpdateEdgeStack(stackID, stack); err != nil {
return nil, handler.handlerDBErr(fmt.Errorf("unable to update Edge stack to the database: %w. Environment name: %s", err, endpoint.Name), "unable to update Edge stack")
}
return stack, nil
}
@@ -145,7 +137,11 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
}
}
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
if containsStatus := slices.ContainsFunc(environmentStatus.Status, func(e portainer.EdgeStackDeploymentStatus) bool {
return e.Type == deploymentStatus.Type
}); !containsStatus {
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
}
stack.Status[environmentId] = environmentStatus
}

View File

@@ -0,0 +1,155 @@
package edgestacks
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
type statusRequest struct {
respCh chan statusResponse
stackID portainer.EdgeStackID
updateFn statusUpdateFn
}
type statusResponse struct {
Stack *portainer.EdgeStack
Error error
}
type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error)
type EdgeStackStatusUpdateCoordinator struct {
updateCh chan statusRequest
dataStore dataservices.DataStore
}
var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress")
func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator {
return &EdgeStackStatusUpdateCoordinator{
updateCh: make(chan statusRequest),
dataStore: dataStore,
}
}
func (c *EdgeStackStatusUpdateCoordinator) Start() {
for {
c.loop()
}
}
func (c *EdgeStackStatusUpdateCoordinator) loop() {
u := <-c.updateCh
respChs := []chan statusResponse{u.respCh}
var stack *portainer.EdgeStack
err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
// 1. Load the edge stack
var err error
stack, err = loadEdgeStack(tx, u.stackID)
if err != nil {
return err
}
// Return early when the agent tries to update the status on a deleted stack
if stack == nil {
return nil
}
// 2. Mutate the edge stack opportunistically until there are no more pending updates
for {
stack, err = u.updateFn(stack)
if err != nil {
return err
}
if m, ok := c.getNextUpdate(stack.ID); ok {
u = m
} else {
break
}
respChs = append(respChs, u.respCh)
}
// 3. Save the changes back to the database
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database")
}
return nil
})
// 4. Send back the responses
for _, ch := range respChs {
ch <- statusResponse{Stack: stack, Error: err}
}
}
func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// Skip the error when the agent tries to update the status on a deleted stack
log.Debug().
Err(err).
Int("stackID", int(stackID)).
Msg("Unable to find a stack inside the database, skipping error")
return nil, nil
}
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err)
}
return stack, nil
}
func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) {
for {
select {
case u := <-c.updateCh:
// Discard the update and let the agent retry
if u.stackID != stackID {
u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress}
continue
}
return u, true
default:
return statusRequest{}, false
}
}
}
func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) {
respCh := make(chan statusResponse)
defer close(respCh)
msg := statusRequest{
respCh: respCh,
stackID: stackID,
updateFn: updateFn,
}
select {
case c.updateCh <- msg:
r := <-respCh
return r.Stack, r.Error
case <-r.Context().Done():
return nil, r.Context().Err()
}
}

View File

@@ -51,10 +51,14 @@ func setupHandler(t *testing.T) (*Handler, string) {
t.Fatal(err)
}
coord := NewEdgeStackStatusUpdateCoordinator(store)
go coord.Start()
handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService),
store,
edgestacks.NewService(store),
coord,
)
handler.FileService = fs
@@ -144,3 +148,15 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
return edgeStack
}
func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeGroup {
edgeGroup := portainer.EdgeGroup{
ID: 1,
Name: "EdgeGroup 1",
}
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
t.Fatal(err)
}
return edgeGroup
}

View File

@@ -80,7 +80,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
}
relationConfig, err := edge.FetchEndpointRelationsConfig(tx)
@@ -107,7 +107,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
if err != nil {
return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err)
return nil, httperror.InternalServerError("unable to check for existence of non fitting environments: %w", err)
}
if hasWrongType {
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
@@ -138,48 +138,19 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
}
oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
endpointsToRemove := set.Set[portainer.EndpointID]{}
for endpointID := range oldRelatedSet {
if !newRelatedSet[endpointID] {
endpointsToRemove[endpointID] = true
}
relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
if len(relatedEnvironmentsToRemove) > 0 {
tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
}
for endpointID := range endpointsToRemove {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
delete(relation.EdgeStacks, edgeStackID)
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
if len(relatedEnvironmentsToAdd) > 0 {
tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID)
}
endpointsToAdd := set.Set[portainer.EndpointID]{}
for endpointID := range newRelatedSet {
if !oldRelatedSet[endpointID] {
endpointsToAdd[endpointID] = true
}
}
for endpointID := range endpointsToAdd {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
relation.EdgeStacks[edgeStackID] = true
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}
return newRelatedEnvironmentIDs, endpointsToAdd, nil
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
}

View File

@@ -22,21 +22,21 @@ type Handler struct {
GitService portainer.GitService
edgeStacksService *edgestackservice.Service
KubernetesDeployer portainer.KubernetesDeployer
stackCoordinator *EdgeStackStatusUpdateCoordinator
}
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
DataStore: dataStore,
edgeStacksService: edgeStacksService,
stackCoordinator: stackCoordinator,
}
h.Handle("/edge_stacks/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}",
@@ -53,15 +53,13 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
edgeStackStatusRouter := h.NewRoute().Subrouter()
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
edgeStackStatusRouter.PathPrefix("/edge_stacks/{id}/status/{endpoint_id}").Handler(bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusDelete))).Methods(http.MethodDelete)
return h
}
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
func handlerDBErr(err error, msg string) *httperror.HandlerError {
httpErr := httperror.InternalServerError(msg, err)
if handler.DataStore.IsErrObjectNotFound(err) {
if dataservices.IsErrObjectNotFound(err) {
httpErr.StatusCode = http.StatusNotFound
}

View File

@@ -1,71 +0,0 @@
package edgetemplates
import (
"net/http"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/client"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/segmentio/encoding/json"
)
type templateFileFormat struct {
Version string `json:"version"`
Templates []portainer.Template `json:"templates"`
}
// @id EdgeTemplateList
// @deprecated
// @summary Fetches the list of Edge Templates
// @description **Access policy**: administrator
// @tags edge_templates
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @success 200 {array} portainer.Template
// @failure 500
// @router /edge_templates [get]
func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
url := portainer.DefaultTemplatesURL
if settings.TemplatesURL != "" {
url = settings.TemplatesURL
}
var templateData []byte
templateData, err = client.Get(url, 10)
if err != nil {
return httperror.InternalServerError("Unable to retrieve external templates", err)
}
var templateFile templateFileFormat
err = json.Unmarshal(templateData, &templateFile)
if err != nil {
return httperror.InternalServerError("Unable to parse template file", err)
}
// We only support version 3 of the template format
// this is only a temporary fix until we have custom edge templates
if templateFile.Version != "3" {
return httperror.InternalServerError("Unsupported template version", nil)
}
filteredTemplates := make([]portainer.Template, 0)
for _, template := range templateFile.Templates {
if slices.Contains(template.Categories, "edge") && slices.Contains([]portainer.TemplateType{portainer.ComposeStackTemplate, portainer.SwarmStackTemplate}, template.Type) {
filteredTemplates = append(filteredTemplates, template)
}
}
return response.JSON(w, filteredTemplates)
}

View File

@@ -1,32 +0,0 @@
package edgetemplates
import (
"net/http"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
)
// Handler is the HTTP handler used to handle edge environment(endpoint) operations.
type Handler struct {
*mux.Router
requestBouncer security.BouncerService
DataStore dataservices.DataStore
}
// NewHandler creates a handler to manage environment(endpoint) operations.
func NewHandler(bouncer security.BouncerService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
h.Handle("/edge_templates",
bouncer.AdminAccess(middlewares.Deprecated(httperror.LoggerHandler(h.edgeTemplateList), func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { return "", nil }))).Methods(http.MethodGet)
return h
}

View File

@@ -1,8 +1,10 @@
package endpointedge
import (
"errors"
"fmt"
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/edge"
@@ -13,8 +15,12 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"golang.org/x/sync/singleflight"
)
var edgeStackSingleFlightGroup = singleflight.Group{}
// @summary Inspect an Edge Stack for an Environment(Endpoint)
// @description **Access policy**: public
// @tags edge, endpoints, edge_stacks
@@ -42,13 +48,26 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
}
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
} else if err != nil {
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
}
return edgeStack, err
})
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
}
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
}
// WARNING: this variable must not be mutated
edgeStack := s.(*portainer.EdgeStack)
fileName := edgeStack.EntryPoint
if endpointutils.IsDockerEndpoint(endpoint) {
if fileName == "" {

View File

@@ -264,6 +264,9 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil, nil
}
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
}

View File

@@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
}
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
if err != nil && !tx.IsErrObjectNotFound(err) {
return err
}
if endpointRelation == nil {
endpointRelation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
edgeGroups, err := tx.EdgeGroup().ReadAll()
if err != nil {
return err
@@ -32,6 +39,9 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil
}
return err
}

View File

@@ -91,7 +91,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete the specified environments."
// @router /endpoints [delete]
// @router /endpoints/delete [post]
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var p endpointDeleteBatchPayload
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
@@ -127,6 +127,27 @@ func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Reque
return response.Empty(w)
}
// @id EndpointDeleteBatchDeprecated
// @summary Remove multiple environments
// @deprecated
// @description Deprecated: use the `POST` endpoint instead.
// @description Remove multiple environments and optionally clean-up associated resources.
// @description **Access policy**: Administrator only.
// @tags endpoints
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up associated resources (cloud environments only)"
// @success 204 "Environment(s) successfully deleted."
// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete the specified environments."
// @router /endpoints [delete]
func (handler *Handler) endpointDeleteBatchDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
return handler.endpointDeleteBatch(w, r)
}
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {

View File

@@ -68,8 +68,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
h.Handle("/endpoints",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
h.Handle("/endpoints/delete",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/snapshot",
@@ -85,6 +85,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
// DEPRECATED
h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
h.Handle("/endpoints", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatchDeprecated))).Methods(http.MethodDelete)
return h
}

View File

@@ -23,6 +23,7 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return errors.WithMessage(err, "Unable to create environment relation inside the database")

View File

@@ -7,7 +7,7 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/gorilla/handlers"
"github.com/klauspost/compress/gzhttp"
)
// Handler represents an HTTP API handler for managing static files.
@@ -20,7 +20,7 @@ type Handler struct {
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
h := &Handler{
Handler: security.MWSecureHeaders(
handlers.CompressHandler(http.FileServer(http.Dir(assetPublicPath))),
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"),
),

View File

@@ -11,7 +11,6 @@ import (
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
"github.com/portainer/portainer/api/http/handler/edgetemplates"
"github.com/portainer/portainer/api/http/handler/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy"
@@ -50,7 +49,6 @@ type Handler struct {
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler
EdgeTemplatesHandler *edgetemplates.Handler
EndpointEdgeHandler *endpointedge.Handler
EndpointGroupHandler *endpointgroups.Handler
EndpointHandler *endpoints.Handler
@@ -83,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.24.0
// @version 2.27.1
// @description.markdown api-description.md
// @termsOfService
@@ -190,8 +188,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_jobs"):
http.StripPrefix("/api", h.EdgeJobsHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_templates"):
http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):

View File

@@ -53,12 +53,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm",
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
// Deprecated
h.Handle("/{id}/kubernetes/helm/repositories",
httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
h.Handle("/{id}/kubernetes/helm/repositories",
httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
return h
}

View File

@@ -45,7 +45,7 @@ func Test_helmInstall(t *testing.T) {
is.NotNil(h, "Handler should not fail")
// Install a single chart. We expect to get these values back
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"}
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
optdata, err := json.Marshal(options)
is.NoError(err)

View File

@@ -20,7 +20,7 @@ func Test_helmRepoSearch(t *testing.T) {
assert.NotNil(t, h, "Handler should not fail")
repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
for _, repo := range repos {
t.Run(repo, func(t *testing.T) {

View File

@@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
t.Run(cmd, func(t *testing.T) {
is.NotNil(h, "Handler should not fail")
repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami")
repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
chart := "nginx"
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
rr := httptest.NewRecorder()

View File

@@ -1,127 +0,0 @@
package helm
import (
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/libhelm"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
)
type helmUserRepositoryResponse struct {
GlobalRepository string `json:"GlobalRepository"`
UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"`
}
type addHelmRepoUrlPayload struct {
URL string `json:"url"`
}
func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
}
// @id HelmUserRepositoryCreateDeprecated
// @summary Create a user helm repository
// @description Create a user helm repository.
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param payload body addHelmRepoUrlPayload true "Helm Repository"
// @success 200 {object} portainer.HelmUserRepository "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 500 "Server error"
// @deprecated
// @router /endpoints/{id}/kubernetes/helm/repositories [post]
func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
userID := tokenData.ID
p := new(addHelmRepoUrlPayload)
err = request.DecodeAndValidateJSONPayload(r, p)
if err != nil {
return httperror.BadRequest("Invalid Helm repository URL", err)
}
// lowercase, remove trailing slash
p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/")
records, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to access the DataStore", err)
}
// check if repo already exists - by doing case insensitive comparison
for _, record := range records {
if strings.EqualFold(record.URL, p.URL) {
errMsg := "Helm repo already registered for user"
return httperror.BadRequest(errMsg, errors.New(errMsg))
}
}
record := portainer.HelmUserRepository{
UserID: userID,
URL: p.URL,
}
err = handler.dataStore.HelmUserRepository().Create(&record)
if err != nil {
return httperror.InternalServerError("Unable to save a user Helm repository URL", err)
}
return response.JSON(w, record)
}
// @id HelmUserRepositoriesListDeprecated
// @summary List a users helm repositories
// @description Inspect a user helm repositories.
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path int true "User identifier"
// @success 200 {object} helmUserRepositoryResponse "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 500 "Server error"
// @deprecated
// @router /endpoints/{id}/kubernetes/helm/repositories [get]
func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
userID := tokenData.ID
settings, err := handler.dataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
userRepos, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to get user Helm repositories", err)
}
resp := helmUserRepositoryResponse{
GlobalRepository: settings.HelmRepositoryURL,
UserRepositories: userRepos,
}
return response.JSON(w, resp)
}

View File

@@ -13,9 +13,9 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
@@ -131,7 +131,7 @@ func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *porta
// TODO: add k8s implementation
// TODO: work out registry auth
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
out, err := docker.ImagePull(ctx, imageName, image.PullOptions{})
if err != nil {
log.Error().Str("image_name", imageName).Err(err).Msg("could not pull image from registry")

View File

@@ -0,0 +1,78 @@
package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id GetKubernetesCronJobs
// @summary Get a list of kubernetes Cron Jobs
// @description Get a list of kubernetes Cron Jobs that the user has access to.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @success 200 {array} models.K8sCronJob "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve the list of Cron Jobs."
// @router /kubernetes/{id}/cron_jobs [get]
func (handler *Handler) getAllKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to prepare kube client")
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
}
cronJobs, err := cli.GetCronJobs("")
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to fetch Cron Jobs across all namespaces")
return httperror.InternalServerError("unable to fetch Cron Jobs. Error: ", err)
}
return response.JSON(w, cronJobs)
}
// @id DeleteCronJobs
// @summary Delete Cron Jobs
// @description Delete the provided list of Cron Jobs.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sCronJobDeleteRequests true "A map where the key is the namespace and the value is an array of Cron Jobs to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account."
// @failure 500 "Server error occurred while attempting to delete Cron Jobs."
// @router /kubernetes/{id}/cron_jobs/delete [POST]
func (handler *Handler) deleteKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sCronJobDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteCronJobs(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete Cron Jobs", err)
}
return response.Empty(w)
}

View File

@@ -0,0 +1,51 @@
package kubernetes
import (
"bytes"
"io"
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
)
// @id UpdateKubernetesNamespaceDeprecated
// @summary Update a namespace
// @description Update a namespace within the given environment.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace"
// @param body body models.K8sNamespaceDetails true "Namespace details"
// @success 200 {object} portainer.K8sNamespaceInfo "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace."
// @failure 500 "Server error occurred while attempting to update the namespace."
// @router /kubernetes/{id}/namespaces [put]
func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
environmentId, err := request.RetrieveRouteVariableValue(r, "id")
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: id", err)
}
// Restore the original body for further use
bodyBytes, err := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
payload := models.K8sNamespaceDetails{}
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err)
}
namespaceName := payload.Name
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
return "/kubernetes/" + environmentId + "/namespaces/" + namespaceName, nil
}

View File

@@ -55,6 +55,10 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
@@ -81,11 +85,11 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost)
endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces/count", httperror.LoggerHandler(h.getKubernetesNamespacesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet)
endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
@@ -115,8 +119,12 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
// Deprecated
endpointRouter.Handle("/namespaces", middlewares.Deprecated(endpointRouter, deprecatedNamespaceParser)).Methods(http.MethodPut)
return h
}
@@ -206,7 +214,17 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), endpoint.Kubernetes.Configuration.RestrictDefaultNamespace)
teamMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to get team memberships for user: ", err)
return
}
teamIDs := []int{}
for _, membership := range teamMemberships {
teamIDs = append(teamIDs, int(membership.TeamID))
}
nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), teamIDs, endpoint.Kubernetes.Configuration.RestrictDefaultNamespace)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to retrieve non-admin namespaces. Error: ", err)
return

View File

@@ -0,0 +1,85 @@
package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id GetKubernetesJobs
// @summary Get a list of kubernetes Jobs
// @description Get a list of kubernetes Jobs that the user has access to.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param includeCronJobChildren query bool false "Whether to include Jobs that have a cronjob owner"
// @success 200 {array} models.K8sJob "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve the list of Jobs."
// @router /kubernetes/{id}/jobs [get]
func (handler *Handler) getAllKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
includeCronJobChildren, err := request.RetrieveBooleanQueryParameter(r, "includeCronJobChildren", true)
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Invalid query parameter includeCronJobChildren")
return httperror.BadRequest("an error occurred during the GetAllKubernetesJobs operation, invalid query parameter includeCronJobChildren. Error: ", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesJobs").Msg("Unable to prepare kube client")
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
}
jobs, err := cli.GetJobs("", includeCronJobChildren)
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Unable to fetch Jobs across all namespaces")
return httperror.InternalServerError("unable to fetch Jobs. Error: ", err)
}
return response.JSON(w, jobs)
}
// @id DeleteJobs
// @summary Delete Jobs
// @description Delete the provided list of Jobs.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sJobDeleteRequests true "A map where the key is the namespace and the value is an array of Jobs to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account."
// @failure 500 "Server error occurred while attempting to delete Jobs."
// @router /kubernetes/{id}/jobs/delete [POST]
func (handler *Handler) deleteKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sJobDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteJobs(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete Jobs", err)
}
return response.Empty(w)
}

View File

@@ -27,7 +27,7 @@ import (
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes."
// @router /kubernetes/{id}/volumes [get]
func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
volumes, err := handler.getKubernetesVolumes(r)
volumes, err := handler.getKubernetesVolumes(r, "")
if err != nil {
return err
}
@@ -49,7 +49,7 @@ func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.R
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes count."
// @router /kubernetes/{id}/volumes/count [get]
func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
volumes, err := handler.getKubernetesVolumes(r)
volumes, err := handler.getKubernetesVolumes(r, "")
if err != nil {
return err
}
@@ -57,6 +57,36 @@ func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *h
return response.JSON(w, len(volumes))
}
// @id GetKubernetesVolumesInNamespace
// @summary Get Kubernetes volumes within a namespace in the given Portainer environment
// @description Get a list of kubernetes volumes within the specified namespace in the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace identifier"
// @param withApplications query boolean false "When set to True, include the applications that are using the volumes. It is set to false by default"
// @success 200 {object} map[string]kubernetes.K8sVolumeInfo "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes in the namespace."
// @router /kubernetes/{id}/namespaces/{namespace}/volumes [get]
func (handler *Handler) GetKubernetesVolumesInNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesVolumesInNamespace").Msg("Unable to retrieve namespace identifier")
return httperror.BadRequest("Invalid namespace identifier", err)
}
volumes, httpErr := handler.getKubernetesVolumes(r, namespace)
if httpErr != nil {
return httpErr
}
return response.JSON(w, volumes)
}
// @id GetKubernetesVolume
// @summary Get a Kubernetes volume within the given Portainer environment
// @description Get a Kubernetes volume within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
@@ -109,7 +139,7 @@ func (handler *Handler) getKubernetesVolume(w http.ResponseWriter, r *http.Reque
return response.JSON(w, volume)
}
func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
func (handler *Handler) getKubernetesVolumes(r *http.Request, namespace string) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true)
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Bool("withApplications", withApplications).Msg("Unable to parse query parameter")
@@ -122,7 +152,7 @@ func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolum
return nil, httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr)
}
volumes, err := cli.GetVolumes("")
volumes, err := cli.GetVolumes(namespace)
if err != nil {
if k8serrors.IsUnauthorized(err) {
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Unauthorized access")

View File

@@ -36,5 +36,9 @@ func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *ht
return httperror.InternalServerError("Unable to retrieve registries from the database", err)
}
for idx := range registries {
hideFields(&registries[idx], false)
}
return response.JSON(w, registries)
}

View File

@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
// Whether telemetry is enabled
EnableTelemetry *bool `example:"false"`
// Helm repository URL
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
// Kubectl Shell Image
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default

View File

@@ -11,7 +11,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -62,8 +61,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/stacks/create/{type}/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
h.Handle("/stacks",
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
h.Handle("/stacks",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
h.Handle("/stacks/{id}",

View File

@@ -1,7 +1,6 @@
package stacks
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -141,53 +140,3 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
return response.JSON(w, stack)
}
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
if err != nil {
return "", err
}
switch stackType {
case 1:
return "swarm", nil
case 2:
return "standalone", nil
case 3:
return "kubernetes", nil
}
return "", errors.New(request.ErrInvalidQueryParameter)
}
// @id StackCreate
// @summary Deploy a new stack
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
// @description **Access policy**: authenticated
// @tags stacks
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /stacks [post]
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
stackType, err := getStackTypeFromQueryParameter(r)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: type", err)
}
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
}

View File

@@ -59,10 +59,6 @@ func NewHandler(bouncer security.BouncerService,
// Deprecated /status endpoint, will be removed in the future.
h.Handle("/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet)
h.Handle("/status/version",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.versionDeprecated))).Methods(http.MethodGet)
h.Handle("/status/nodes",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusNodesCountDeprecated))).Methods(http.MethodGet)
return h
}

View File

@@ -3,12 +3,11 @@ package system
import (
"net/http"
portainer "github.com/portainer/portainer/api"
statusutil "github.com/portainer/portainer/api/internal/nodes"
"github.com/portainer/portainer/api/internal/snapshot"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
type nodesCountResponse struct {
@@ -31,32 +30,15 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Failed to get environment list", err)
}
for i := range endpoints {
err = snapshot.FillSnapshotData(handler.dataStore, &endpoints[i])
if err != nil {
var nodes int
for _, endpoint := range endpoints {
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
nodes := statusutil.NodesCount(endpoints)
nodes += statusutil.NodesCount([]portainer.Endpoint{endpoint})
}
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
}
// @id statusNodesCount
// @summary Retrieve the count of nodes
// @deprecated
// @description Deprecated: use the `/system/nodes` endpoint instead.
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags status
// @produce json
// @success 200 {object} nodesCountResponse "Success"
// @failure 500 "Server error"
// @router /status/nodes [get]
func (handler *Handler) statusNodesCountDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
log.Warn().Msg("The /status/nodes endpoint is deprecated, please use the /system/nodes endpoint instead")
return handler.systemNodesCount(w, r)
}

View File

@@ -3,6 +3,7 @@ package system
import (
"net/http"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
plf "github.com/portainer/portainer/api/platform"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -46,7 +47,12 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
platform, err := handler.platformService.GetPlatform()
if err != nil {
return httperror.InternalServerError("Failed to get platform", err)
if !errors.Is(err, plf.ErrNoLocalEnvironment) {
return httperror.InternalServerError("Failed to get platform", err)
}
// If no local environment is detected, we assume the platform is Docker
// UI will stop showing the upgrade banner
platform = plf.PlatformDocker
}
return response.JSON(w, &systemInfoResponse{

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"regexp"
ceplf "github.com/portainer/portainer/api/platform"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -45,6 +46,9 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
environment, err := handler.platformService.GetLocalEnvironment()
if err != nil {
if errors.Is(err, ceplf.ErrNoLocalEnvironment) {
return httperror.NotFound("The system upgrade feature is disabled because no local environment was detected.", err)
}
return httperror.InternalServerError("Failed to get local environment", err)
}
@@ -53,8 +57,7 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Failed to get platform", err)
}
err = handler.upgradeService.Upgrade(platform, environment, payload.License)
if err != nil {
if err := handler.upgradeService.Upgrade(platform, environment, payload.License); err != nil {
return httperror.InternalServerError("Failed to upgrade Portainer", err)
}

View File

@@ -2,12 +2,11 @@ package system
import (
"net/http"
"os"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/build"
"github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/build"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -23,20 +22,12 @@ type versionResponse struct {
LatestVersion string `json:"LatestVersion" example:"2.0.0"`
ServerVersion string
VersionSupport string `json:"VersionSupport" example:"STS/LTS"`
ServerEdition string `json:"ServerEdition" example:"CE/EE"`
DatabaseVersion string
Build BuildInfo
}
type BuildInfo struct {
BuildNumber string
ImageTag string
NodejsVersion string
YarnVersion string
WebpackVersion string
GoVersion string
GitCommit string
Env []string `json:",omitempty"`
Build build.BuildInfo
Dependencies build.DependenciesInfo
Runtime build.RuntimeInfo
}
// @id systemVersion
@@ -57,21 +48,15 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperr
result := &versionResponse{
ServerVersion: portainer.APIVersion,
VersionSupport: portainer.APIVersionSupport,
DatabaseVersion: portainer.APIVersion,
ServerEdition: portainer.Edition.GetEditionLabel(),
Build: BuildInfo{
BuildNumber: build.BuildNumber,
ImageTag: build.ImageTag,
NodejsVersion: build.NodejsVersion,
YarnVersion: build.YarnVersion,
WebpackVersion: build.WebpackVersion,
GoVersion: build.GoVersion,
GitCommit: build.GitCommit,
},
Build: build.GetBuildInfo(),
Dependencies: build.GetDependenciesInfo(),
}
if isAdmin {
result.Build.Env = os.Environ()
result.Runtime = build.GetRuntimeInfo()
}
latestVersion := GetLatestVersion()
@@ -121,21 +106,3 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
return currentVersionSemver.LessThan(*latestVersionSemver)
}
// @id Version
// @summary Check for portainer updates
// @deprecated
// @description Deprecated: use the `/system/version` endpoint instead.
// @description Check if portainer has an update available
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags status
// @produce json
// @success 200 {object} versionResponse "Success"
// @router /status/version [get]
func (handler *Handler) versionDeprecated(w http.ResponseWriter, r *http.Request) {
log.Warn().Msg("The /status/version endpoint is deprecated, please use the /system/version endpoint instead")
handler.version(w, r)
}

View File

@@ -133,10 +133,17 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
if err != nil && !tx.IsErrObjectNotFound(err) {
return err
}
if endpointRelation == nil {
endpointRelation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
if err != nil {
return err
@@ -147,6 +154,7 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
for _, edgeStackID := range endpointStacks {
stacksSet[edgeStackID] = true
}
endpointRelation.EdgeStacks = stacksSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)

View File

@@ -29,7 +29,5 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
h.Handle("/templates/{id}/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
h.Handle("/templates/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFileOld))).Methods(http.MethodPost)
return h
}

View File

@@ -1,93 +0,0 @@
package templates
import (
"errors"
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
type filePayload struct {
// URL of a git repository where the file is stored
RepositoryURL string `example:"https://github.com/portainer/portainer-compose" validate:"required"`
// Path to the file inside the git repository
ComposeFilePathInRepository string `example:"./subfolder/docker-compose.yml" validate:"required"`
}
func (payload *filePayload) Validate(r *http.Request) error {
if len(payload.RepositoryURL) == 0 {
return errors.New("Invalid repository url")
}
if len(payload.ComposeFilePathInRepository) == 0 {
return errors.New("Invalid file path")
}
return nil
}
func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperror.HandlerError {
response, httpErr := handler.fetchTemplates()
if httpErr != nil {
return httpErr
}
for _, t := range response.Templates {
if t.Repository.URL == payload.RepositoryURL && t.Repository.StackFile == payload.ComposeFilePathInRepository {
return nil
}
}
return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
}
// @id TemplateFileOld
// @summary Get a template's file
// @deprecated
// @description Get a template's file
// @description **Access policy**: authenticated
// @tags templates
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param body body filePayload true "File details"
// @success 200 {object} fileResponse "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /templates/file [post]
func (handler *Handler) templateFileOld(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
log.Warn().Msg("This api is deprecated. Please use /templates/{id}/file instead")
var payload filePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
if err := handler.ifRequestedTemplateExists(&payload); err != nil {
return err
}
projectPath, err := handler.FileService.GetTemporaryPath()
if err != nil {
return httperror.InternalServerError("Unable to create temporary folder", err)
}
defer handler.cleanUp(projectPath)
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false)
if err != nil {
return httperror.InternalServerError("Unable to clone git repository", err)
}
fileContent, err := handler.FileService.GetFileContent(projectPath, payload.ComposeFilePathInRepository)
if err != nil {
return httperror.InternalServerError("Failed loading file content", err)
}
return response.JSON(w, fileResponse{FileContent: string(fileContent)})
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
)
// @summary Execute a webhook
@@ -79,16 +80,16 @@ func (handler *Handler) executeServiceWebhook(
service.Spec.TaskTemplate.ForceUpdate++
var imageName = strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
imageName := strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0]
service.Spec.TaskTemplate.ContainerSpec.Image = imageName
if imageTag != "" {
var tagIndex = strings.LastIndex(imageName, ":")
tagIndex := strings.LastIndex(imageName, ":")
if tagIndex == -1 {
tagIndex = len(imageName)
}
service.Spec.TaskTemplate.ContainerSpec.Image = imageName[:tagIndex] + ":" + imageTag
} else {
service.Spec.TaskTemplate.ContainerSpec.Image = imageName
}
serviceUpdateOptions := dockertypes.ServiceUpdateOptions{
@@ -109,8 +110,9 @@ func (handler *Handler) executeServiceWebhook(
}
}
}
if imageTag != "" {
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, dockertypes.ImagePullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, image.PullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
if err != nil {
return httperror.NotFound("Error pulling image with the specified tag", err)
}

View File

@@ -0,0 +1,36 @@
package kubernetes
import (
"errors"
"net/http"
)
type K8sCronJob struct {
Id string `json:"Id"`
Name string `json:"Name"`
Namespace string `json:"Namespace"`
Command string `json:"Command"`
Schedule string `json:"Schedule"`
Timezone string `json:"Timezone"`
Suspend bool `json:"Suspend"`
Jobs []K8sJob `json:"Jobs"`
IsSystem bool `json:"IsSystem"`
}
type (
K8sCronJobDeleteRequests map[string][]string
)
func (r K8sCronJobDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
for ns := range r {
if len(ns) == 0 {
return errors.New("deletion given with empty namespace")
}
}
return nil
}

View File

@@ -0,0 +1,44 @@
package kubernetes
import (
"errors"
"net/http"
corev1 "k8s.io/api/core/v1"
)
// K8sJob struct
type K8sJob struct {
ID string `json:"Id"`
Namespace string `json:"Namespace"`
Name string `json:"Name"`
PodName string `json:"PodName"`
Container corev1.Container `json:"Container,omitempty"`
Command string `json:"Command,omitempty"`
BackoffLimit int32 `json:"BackoffLimit,omitempty"`
Completions int32 `json:"Completions,omitempty"`
StartTime string `json:"StartTime"`
FinishTime string `json:"FinishTime"`
Duration string `json:"Duration"`
Status string `json:"Status"`
FailedReason string `json:"FailedReason"`
IsSystem bool `json:"IsSystem"`
}
type (
K8sJobDeleteRequests map[string][]string
)
func (r K8sJobDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
for ns := range r {
if len(ns) == 0 {
return errors.New("deletion given with empty namespace")
}
}
return nil
}

View File

@@ -41,11 +41,13 @@ func (o *OfflineGate) WaitingMiddleware(timeout time.Duration, next http.Handler
}
if !o.lock.RTryLockWithTimeout(timeout) {
log.Error().Msg("timeout waiting for the offline gate to signal")
httperror.WriteError(w, http.StatusRequestTimeout, "Timeout waiting for the offline gate to signal", http.ErrHandlerTimeout)
log.Error().Str("url", r.URL.Path).Msg("request timed out while waiting for the backup process to finish")
httperror.WriteError(w, http.StatusRequestTimeout, "Request timed out while waiting for the backup process to finish", http.ErrHandlerTimeout)
return
}
defer o.lock.RUnlock()
next.ServeHTTP(w, r)
o.lock.RUnlock()
})
}

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_canLockAndUnlock(t *testing.T) {
@@ -146,3 +147,30 @@ func Test_waitingMiddleware_mayTimeout_whenLockedForTooLong(t *testing.T) {
assert.Equal(t, http.StatusRequestTimeout, response.Result().StatusCode, "Request support to timeout waiting for the gate")
}
func Test_waitingMiddleware_handlerPanics(t *testing.T) {
o := NewOfflineGate()
request := httptest.NewRequest(http.MethodPost, "/", nil)
response := httptest.NewRecorder()
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer func() {
recover()
wg.Done()
}()
o.WaitingMiddleware(time.Second, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("panic")
})).ServeHTTP(response, request)
}()
wg.Wait()
require.True(t, o.lock.TryLock())
o.lock.Unlock()
}

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