Compare commits

...

114 Commits

Author SHA1 Message Date
Ali
d16462195f chore(portainer): bump version to 2.26.0 (#302) 2025-01-15 11:36:18 +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
James Carppe
4ad3d70739 Update bug report template for 2.24.0 (#153) 2024-11-20 13:15:56 +13:00
andres-portainer
e6a1c29655 fix(compose): fix support for ECR BE-11392 (#151) 2024-11-18 16:42:53 -03:00
Yajith Dayarathna
333dfe1ebf refactor(edge/update): choose images from registry [BE-10964] (#6)
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2024-11-18 14:11:26 +13:00
andres-portainer
c59872553a fix(stacks): pass the registry credentials to Compose stacks BE-11388 (#147)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2024-11-18 08:39:13 +13:00
andres-portainer
1a39370f5b fix(libstack): add missing private registry credentials BE-11388 (#143) 2024-11-15 17:38:55 -03:00
Oscar Zhou
bc44056815 fix(swarm): failed to deploy app template [BE-11385] (#138) 2024-11-15 11:53:22 +13:00
andres-portainer
17c92343e0 fix(compose): avoid leftovers in Run() BE-11381 (#129) 2024-11-13 20:24:20 -03:00
andres-portainer
cd6935b07a feat(edgestacks): add a retry period to edge stack deployments BE-11155 (#109)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-11-13 20:13:30 -03:00
andres-portainer
47d428f3eb fix(libstack): fix compose run BE-11381 (#126) 2024-11-13 14:38:53 -03:00
LP B
2baae7072f fix(edge/stacks): use default namespace when none is specified in manifest (#124) 2024-11-13 16:30:08 +13:00
andres-portainer
2e9e459aa3 fix(libstack): add a different timeout for WaitForStatus BE-11376 (#120) 2024-11-12 19:31:44 -03:00
andres-portainer
7444e2c1c7 fix(compose): provide the project name for proper validation BE-11375 (#118) 2024-11-12 17:18:40 -03:00
Oscar Zhou
d6469eb33d fix(libstack): empty project name [BE-11375] (#116) 2024-11-12 10:20:45 -03:00
Ali
a2da6f1827 fix(configmap): create portainer configmap if it doesn't exist [r8s-141] (#113) 2024-11-12 18:23:00 +13:00
Oscar Zhou
e6508140f8 version: bump version to 2.24.0 (#102) 2024-11-12 12:13:27 +13:00
andres-portainer
a7127bc74f feat(libstack): remove the docker-compose binary BE-10801 (#111)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2024-11-11 19:05:56 -03:00
Malcolm Lockyer
55aa0c0c5d fix(ui): kubernetes create from file page - fix template load failed mistake in ce (#112) 2024-11-12 10:46:37 +13:00
Ali
d25de4f459 fix(more-resources): address CE review comments [r8s-103] (#110) 2024-11-12 10:41:43 +13:00
Yajith Dayarathna
6d31f4876a fix(more resources): fix porting and functionality [r8s-103] (#8)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2024-11-12 09:55:30 +13:00
Steven Kang
e6577ca269 kubernetes: improved the node view [r8s-47] (#108) 2024-11-12 09:42:14 +13:00
Ali
08d77b4333 fix(namespace): handle no accesses found [r8s-141] (#106) 2024-11-12 09:29:55 +13:00
Ali
1ead121c9b fix(apps): for helm uninstall, ignore manual associated resource deletion [r8s-124] (#103) 2024-11-12 09:03:22 +13:00
LP B
ad19b4a421 fix(app): relocate Skip TLS switch next to git repo URL field (#107) 2024-11-11 17:16:37 +01:00
LP B
6bc52dd39c feat(edge): kubernetes WaitForStatus support (#85) 2024-11-11 14:02:20 +01:00
Malcolm Lockyer
fd2b00bf3b fix(ui): kubernetes create from file page - fix template load failed message style [R8S-68] (#95) 2024-11-11 12:06:56 +13:00
Ali
cd8c6d1ce0 fix(apps): don't delete the 'kubernetes' service or duplicate service names [r8s-124] (#90) 2024-11-11 08:26:56 +13:00
Ali
e9fc6d5598 refactor(namespace): migrate namespace access view to react [r8s-141] (#87) 2024-11-11 08:17:20 +13:00
Steven Kang
8ed7cd80cb feat(ui): improve Kubernetes node view [r8s-47] (#84) 2024-11-07 14:10:19 +13:00
Malcolm Lockyer
81322664ea fix(ui): kubernetes create from manifest page misalignments and incorrect loading icon [R8S-68] (#88) 2024-11-07 09:04:24 +13:00
Ali
458d722d47 fix(ui): consistent widget padding [r8s-136] (#82) 2024-11-05 14:25:40 +13:00
Malcolm Lockyer
3c0d25f3bd fix(ui): rename create from manifest to create from file [BE-11335] (#86) 2024-11-05 14:10:08 +13:00
Oscar Zhou
ca7e4dd66e fix(edge/async): onboarding agent without predefined group cannot be associated [BE-11281] (#83) 2024-11-05 09:32:25 +13:00
Ali
c1316532eb fix(apps): update associated resources on deletion [r8s-124] (#75) 2024-11-01 21:03:49 +13:00
Ali
d418784346 fix(rbac): revert rbac detection logic [r8s-137] (#81) 2024-11-01 19:28:23 +13:00
andres-portainer
1061601714 feat(activity-log): set descending timestamps as the default sorting order BE-11343 (#66) 2024-10-31 18:07:26 -03:00
andres-portainer
2f3d4a5511 fix(activity-log): fix broken sorting BE-11342 (#65) 2024-10-31 17:25:38 -03:00
LP B
9ea62bda28 fix(app/image-details): export images to tar (#40) 2024-10-31 17:40:01 +01:00
Steven Kang
94b1d446c0 fix(ingresses): load cluster wide ingresses [r8s-78] (#78) 2024-10-31 13:08:09 +13:00
Ali
6c57a00a65 fix(cluster): UI RBAC alert fix [r8s-138] (#72) 2024-10-31 10:12:56 +13:00
Yajith Dayarathna
8808531cd5 update ci trigger paths for portainer-ee - develop (#68) 2024-10-29 12:23:31 +13:00
andres-portainer
966fca950b fix(oauth): add a timeout to getOAuthToken() BE-11283 (#63) 2024-10-28 17:28:22 -03:00
Yajith Dayarathna
e528cff615 bump golang version to 1.23.2 (#60) 2024-10-29 09:02:18 +13:00
andres-portainer
1d037f2f1f feat(websocket): improve websocket code sharing BE-11340 (#61) 2024-10-25 11:21:49 -03:00
James Carppe
b2d67795b3 Update bug report template for 2.21.4 (#62) 2024-10-25 15:49:31 +13:00
Ali
959c527be7 refactor(apps): migrate applications view to react [r8s-124] (#28) 2024-10-25 12:28:05 +13:00
andres-portainer
cc75167437 fix(swarm): fix service updates BE-11219 (#57) 2024-10-23 18:23:24 -03:00
andres-portainer
3114d4b5c5 fix(security): add initial support for HSTS and CSP BE-11311 (#47) 2024-10-21 13:52:11 -03:00
andres-portainer
ac293cda1c feat(database): share more database code between CE and EE BE-11303 (#43) 2024-10-18 10:33:10 -03:00
Ali
7b88975bcb fix(applications): scale resource usage by pod count [r8s-127] (#33) 2024-10-16 14:33:45 +13:00
James Carppe
da4b2e3a56 Updated bug report template for 2.23.0 (#32) 2024-10-16 09:23:02 +13:00
554 changed files with 14790 additions and 7657 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

@@ -11,6 +11,8 @@ body:
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
@@ -90,10 +92,17 @@ body:
- type: dropdown
attributes:
label: Portainer version
description: We only provide support for the most recent version of Portainer and the previous 3 versions. 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.
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.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'
- '2.21.1'
@@ -112,11 +121,6 @@ body:
- '2.18.3'
- '2.18.2'
- '2.18.1'
- '2.17.1'
- '2.17.0'
- '2.16.2'
- '2.16.1'
- '2.16.0'
validations:
required: true

View File

@@ -1,16 +0,0 @@
name: Label Conflicts
on:
push:
branches:
- develop
- 'release/**'
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: mschilde/auto-label-merge-conflicts@master
with:
CONFLICT_LABEL_NAME: 'has conflicts'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MAX_RETRIES: 10
WAIT_MS: 60000

View File

@@ -1,19 +0,0 @@
name: Automatic Rebase
on:
issue_comment:
types: [created]
jobs:
rebase:
name: Rebase
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
uses: actions/checkout@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
- name: Automatic Rebase
uses: cirrus-actions/rebase@1.4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,28 +0,0 @@
name: Close Stale Issues
on:
schedule:
- cron: '0 12 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v8
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Issue Config
days-before-issue-stale: 60
days-before-issue-close: 7
stale-issue-label: 'status/stale'
exempt-all-issue-milestones: true # Do not stale issues in a milestone
exempt-issue-labels: kind/enhancement, kind/style, kind/workaround, kind/refactor, bug/need-confirmation, bug/confirmed, status/discuss
stale-issue-message: 'This issue has been marked as stale as it has not had recent activity, it will be closed if no further activity occurs in the next 7 days. If you believe that it has been incorrectly labelled as stale, leave a comment and the label will be removed.'
close-issue-message: 'Since no further activity has appeared on this issue it will be closed. If you believe that it has been incorrectly closed, leave a comment mentioning `portainer/support` and one of our staff will then review the issue. Note - If it is an old bug report, make sure that it is reproduceable in the latest version of Portainer as it may have already been fixed.'
# Pull Request Config
days-before-pr-stale: -1 # Do not stale pull request
days-before-pr-close: -1 # Do not close pull request

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

@@ -9,7 +9,7 @@ ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM=go run gotest.tools/gotestsum@latest
# Don't change anything below this line unless you know what you're doing
@@ -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
@@ -64,22 +66,19 @@ clean: ## Remove all build and download artifacts
.PHONY: test test-client test-server
test: test-server test-client ## Run all tests
test-deps: init-dist
./build/download_docker_compose_binary.sh $(PLATFORM) $(ARCH) $(shell jq -r '.dockerCompose' < "./binary-version.json")
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
dev: ## Run both the client and server in development mode
dev: ## Run both the client and server in development mode
make dev-server
make dev-client
dev-client: ## Run the client in development mode
dev-client: ## Run the client in development mode
yarn dev
dev-server: build-server ## Run the server in development mode
@@ -119,7 +118,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml

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,9 +46,9 @@ 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"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/gofrs/uuid"
@@ -94,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 {
@@ -121,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
@@ -166,26 +165,6 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
if err != nil {
log.Fatal().Err(err).Msg("failed creating compose manager")
}
return composeWrapper
}
func initSwarmStackManager(
assetsPath string,
configPath string,
signatureService portainer.DigitalSignatureService,
fileService portainer.FileService,
reverseTunnelService portainer.ReverseTunnelService,
dataStore dataservices.DataStore,
) (portainer.SwarmStackManager, error) {
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
}
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
}
@@ -433,14 +412,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
dockerConfigPath := fileService.GetDockerConfigPath()
composeDeployer, err := compose.NewComposeDeployer(*flags.Assets, dockerConfigPath)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing compose deployer")
}
composeDeployer := compose.NewComposeDeployer()
composeStackManager := initComposeStackManager(composeDeployer, proxyManager)
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
}

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

@@ -49,8 +49,8 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
backup["__metadata"] = meta
}
err = connection.View(func(tx *bolt.Tx) error {
err = tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
if err := connection.View(func(tx *bolt.Tx) error {
return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
bucketName := string(name)
var list []any
version := make(map[string]string)
@@ -84,27 +84,22 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
return nil
}
if len(list) > 0 {
if bucketName == "ssl" ||
bucketName == "settings" ||
bucketName == "tunnel_server" {
backup[bucketName] = nil
if len(list) > 0 {
backup[bucketName] = list[0]
}
return nil
if bucketName == "ssl" ||
bucketName == "settings" ||
bucketName == "tunnel_server" {
backup[bucketName] = nil
if len(list) > 0 {
backup[bucketName] = list[0]
}
backup[bucketName] = list
return nil
}
backup[bucketName] = list
return nil
})
return err
})
if err != nil {
}); err != nil {
return []byte("{}"), err
}

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,9 +15,11 @@ 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
}
func (service *Service) BucketName() string {
@@ -76,6 +80,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,6 +100,10 @@ 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
@@ -108,27 +120,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

@@ -45,6 +45,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,6 +65,10 @@ 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
@@ -77,27 +85,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 +158,7 @@ func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationSta
}
numDeployments := 0
for _, r := range relations {
for sId, enabled := range r.EdgeStacks {
if enabled && sId == refStackId {

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

@@ -1,10 +1,15 @@
{
"api_key": null,
"customtemplates": null,
"dockerhub": [
{
"Authentication": false,
"Username": ""
}
],
"edge_stack": null,
"edgegroups": null,
"edgejobs": null,
"endpoint_groups": [
{
"AuthorizedTeams": null,
@@ -103,6 +108,9 @@
"UserAccessPolicies": {}
}
],
"extension": null,
"helm_user_repository": null,
"pending_actions": null,
"registries": [
{
"Authentication": true,
@@ -602,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.23.0",
"KubectlShellImage": "portainer/kubectl-shell:2.26.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -664,6 +672,7 @@
{
"Docker": {
"ContainerCount": 0,
"DiagnosticsData": {},
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
@@ -860,6 +869,8 @@
"UpdatedBy": ""
}
],
"tags": null,
"team_membership": null,
"teams": [
{
"Id": 1,
@@ -932,6 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.23.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
"VERSION": "{\"SchemaVersion\":\"2.26.0\",\"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

@@ -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

@@ -31,15 +31,18 @@ type (
// RegistryCredentials holds the credentials for a Docker registry.
// Used only for EE
RegistryCredentials []RegistryCredentials
// PrePullImage is a flag indicating if the agent should pull the image before deploying the stack.
// PrePullImage is a flag indicating if the agent must pull the image before deploying the stack.
// Used only for EE
PrePullImage bool
// RePullImage is a flag indicating if the agent should pull the image if it is already present on the node.
// RePullImage is a flag indicating if the agent must pull the image if it is already present on the node.
// Used only for EE
RePullImage bool
// RetryDeploy is a flag indicating if the agent should retry to deploy the stack if it fails.
// RetryDeploy is a flag indicating if the agent must retry to deploy the stack if it fails.
// Used only for EE
RetryDeploy bool
// RetryPeriod specifies the duration, in seconds, for which the agent should continue attempting to deploy the stack after a failure
// Used only for EE
RetryPeriod int
// EdgeUpdateID is the ID of the edge update related to this stack.
// Used only for EE
EdgeUpdateID int
@@ -55,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

@@ -9,27 +9,32 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack"
"github.com/docker/cli/cli/config/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ComposeStackManager is a wrapper for docker-compose binary
type ComposeStackManager struct {
deployer libstack.Deployer
proxyManager *proxy.Manager
dataStore dataservices.DataStore
}
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
// NewComposeStackManager returns a Compose stack manager
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager {
return &ComposeStackManager{
deployer: deployer,
proxyManager: proxyManager,
}, nil
dataStore: dataStore,
}
}
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
@@ -60,6 +65,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
@@ -90,6 +96,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
Remove: options.Remove,
Args: options.Args,
@@ -103,14 +110,15 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
} else if proxy != nil {
defer proxy.Close()
}
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{
WorkingDir: "",
Host: url,
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
Options: libstack.Options{
WorkingDir: "",
Host: url,
},
})
return errors.Wrap(err, "failed to remove a stack")
@@ -118,12 +126,11 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file,
// but does not start containers based on those images.
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
} else if proxy != nil {
defer proxy.Close()
}
@@ -138,6 +145,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
}
@@ -176,16 +184,16 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
// Copy from default .env file
defaultEnvPath := path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
if err = copyDefaultEnvFile(envfile, defaultEnvPath); err != nil {
if err := copyDefaultEnvFile(envfile, defaultEnvPath); err != nil {
return "", err
}
// Copy from stack env vars
if err = copyConfigEnvVars(envfile, stack.Env); err != nil {
if err := copyConfigEnvVars(envfile, stack.Env); err != nil {
return "", err
}
return "stack.env", nil
return envFilePath, nil
}
// copyDefaultEnvFile copies the default .env file if it exists to the provided writer
@@ -217,3 +225,49 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
}
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}

View File

@@ -42,20 +42,13 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
}
func Test_UpAndDown(t *testing.T) {
testhelpers.IntegrationTest(t)
stack, endpoint := setup(t)
deployer, err := compose.NewComposeDeployer("", "")
if err != nil {
t.Fatal(err)
}
deployer := compose.NewComposeDeployer()
w, err := NewComposeStackManager(deployer, nil)
if err != nil {
t.Fatalf("Failed creating manager: %s", err)
}
w := NewComposeStackManager(deployer, nil, nil)
ctx := context.TODO()

View File

@@ -4,6 +4,7 @@ import (
"io"
"os"
"path"
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
@@ -53,7 +54,7 @@ func Test_createEnvFile(t *testing.T) {
result, _ := createEnvFile(tt.stack)
if tt.expected != "" {
assert.Equal(t, "stack.env", result)
assert.Equal(t, filepath.Join(tt.stack.ProjectPath, "stack.env"), result)
f, _ := os.Open(path.Join(dir, "stack.env"))
content, _ := io.ReadAll(f)
@@ -77,7 +78,7 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
},
}
result, err := createEnvFile(stack)
assert.Equal(t, "stack.env", result)
assert.Equal(t, filepath.Join(stack.ProjectPath, "stack.env"), result)
assert.NoError(t, err)
assert.FileExists(t, path.Join(dir, "stack.env"))
f, _ := os.Open(path.Join(dir, "stack.env"))

View File

@@ -11,7 +11,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
@@ -46,8 +45,7 @@ func NewSwarmStackManager(
dataStore: datastore,
}
err := manager.updateDockerCLIConfiguration(manager.configPath)
if err != nil {
if err := manager.updateDockerCLIConfiguration(manager.configPath); err != nil {
return nil, err
}
@@ -63,33 +61,14 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
for _, registry := range registries {
if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
continue
}
username, password, err := registryutils.GetRegEffectiveCredential(&registry)
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
if err := runCommandAndCaptureStdErr(command, registryArgs, nil, ""); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
@@ -155,6 +134,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
@@ -167,8 +147,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
cmd.Env = append(cmd.Env, env...)
}
err := cmd.Run()
if err != nil {
if err := cmd.Run(); err != nil {
return errors.New(stderr.String())
}
@@ -192,6 +171,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
}
@@ -216,9 +196,10 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
configFilePath := path.Join(configPath, "config.json")
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)
@@ -246,8 +227,7 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
return make(map[string]any), nil
}
err = json.Unmarshal(raw, &config)
if err != nil {
if err := json.Unmarshal(raw, &config); err != nil {
return nil, err
}

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

@@ -11,8 +11,8 @@ 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/image"
)
type ImageResponse struct {
@@ -46,7 +46,7 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
return httpErr
}
images, err := cli.ImageList(r.Context(), types.ImageListOptions{})
images, err := cli.ImageList(r.Context(), image.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}

View File

@@ -26,11 +26,10 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
}
var edgeStack *portainer.EdgeStack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData.ID, r)
return err
})
if err != nil {
}); err != nil {
switch {
case httperrors.IsInvalidPayloadError(err):
return httperror.BadRequest("Invalid payload", err)

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

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
portainer "github.com/portainer/portainer/api"
@@ -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 {
@@ -69,6 +71,10 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
var stack *portainer.EdgeStack
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if r.Context().Err() != nil {
return err
}
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
return err
}); err != nil {
@@ -80,6 +86,10 @@ 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)
}
@@ -87,18 +97,23 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// skip error because agent tries to report on deleted stack
// 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)
}
if payload.Version > 0 && payload.Version < stack.Version {
return stack, nil
}
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")

View File

@@ -57,17 +57,15 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
}
var payload updateEdgeStackPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
var stack *portainer.EdgeStack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stack, err = handler.updateEdgeStack(tx, portainer.EdgeStackID(stackID), payload)
return err
})
if err != nil {
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -122,14 +120,12 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
stack.EdgeGroups = groupsIds
if payload.UpdateVersion {
err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds)
if err != nil {
if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
return nil, httperror.InternalServerError("Unable to update stack version", err)
}
}
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
if err != nil {
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
@@ -160,8 +156,7 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
delete(relation.EdgeStacks, edgeStackID)
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}
@@ -181,8 +176,7 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
relation.EdgeStacks[edgeStackID] = true
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}

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

@@ -88,13 +88,11 @@ func (handler *Handler) updateRelations(w http.ResponseWriter, r *http.Request)
}
if updateRelations {
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return errors.WithMessage(err, "Unable to update environment")
}
err = handler.updateEdgeRelations(tx, endpoint)
if err != nil {
if err := handler.updateEdgeRelations(tx, endpoint); err != nil {
return errors.WithMessage(err, "Unable to update environment relations")
}
}

View File

@@ -17,7 +17,16 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
return errors.WithMessage(err, "Unable to find environment relation inside the database")
if !tx.IsErrObjectNotFound(err) {
return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
}
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return errors.WithMessage(err, "Unable to create environment relation inside the database")
}
}
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)

View File

@@ -4,7 +4,10 @@ import (
"net/http"
"strings"
"github.com/gorilla/handlers"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/klauspost/compress/gzhttp"
)
// Handler represents an HTTP API handler for managing static files.
@@ -16,8 +19,10 @@ type Handler struct {
// NewHandler creates a handler to serve static files.
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
h := &Handler{
Handler: handlers.CompressHandler(
http.FileServer(http.Dir(assetPublicPath)),
Handler: security.MWSecureHeaders(
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"),
),
wasInstanceDisabled: wasInstanceDisabled,
}
@@ -53,7 +58,5 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
w.Header().Add("X-XSS-Protection", "1; mode=block")
w.Header().Add("X-Content-Type-Options", "nosniff")
handler.Handler.ServeHTTP(w, r)
}

View File

@@ -83,7 +83,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.23.0
// @version 2.26.0
// @description.markdown api-description.md
// @termsOfService

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

@@ -3,7 +3,9 @@ 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"
)
@@ -43,3 +45,39 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite
return response.JSON(w, clusterrolebindings)
}
// @id DeleteClusterRoleBindings
// @summary Delete cluster role bindings
// @description Delete the provided list of cluster role bindings.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sClusterRoleBindingDeleteRequests true "A list of cluster role bindings 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 cluster role binding."
// @failure 500 "Server error occurred while attempting to delete cluster role bindings."
// @router /kubernetes/{id}/cluster_role_bindings/delete [POST]
func (handler *Handler) deleteClusterRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sClusterRoleBindingDeleteRequests
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.DeleteClusterRoleBindings(payload)
if err != nil {
return httperror.InternalServerError("Failed to delete cluster role bindings", err)
}
return response.Empty(w)
}

View File

@@ -3,7 +3,9 @@ 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"
)
@@ -43,3 +45,39 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h
return response.JSON(w, clusterroles)
}
// @id DeleteClusterRoles
// @summary Delete cluster roles
// @description Delete the provided list of cluster roles.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sClusterRoleDeleteRequests true "A list of cluster roles 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 cluster role."
// @failure 500 "Server error occurred while attempting to delete cluster roles."
// @router /kubernetes/{id}/cluster_roles/delete [POST]
func (handler *Handler) deleteClusterRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sClusterRoleDeleteRequests
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.DeleteClusterRoles(payload)
if err != nil {
return httperror.InternalServerError("Failed to delete cluster roles", err)
}
return response.Empty(w)
}

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,8 +55,14 @@ 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)
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/dashboard", httperror.LoggerHandler(h.getKubernetesDashboard)).Methods(http.MethodGet)
@@ -72,23 +78,30 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost)
endpointRouter.Handle("/ingresses", httperror.LoggerHandler(h.GetAllKubernetesClusterIngresses)).Methods(http.MethodGet)
endpointRouter.Handle("/ingresses/count", httperror.LoggerHandler(h.getAllKubernetesClusterIngressesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
endpointRouter.Handle("/services", httperror.LoggerHandler(h.GetAllKubernetesServices)).Methods(http.MethodGet)
endpointRouter.Handle("/services/count", httperror.LoggerHandler(h.getAllKubernetesServicesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/secrets", httperror.LoggerHandler(h.GetAllKubernetesSecrets)).Methods(http.MethodGet)
endpointRouter.Handle("/secrets/count", httperror.LoggerHandler(h.getAllKubernetesSecretsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost)
endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet)
endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).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)
endpointRouter.Handle("/service_accounts/delete", httperror.LoggerHandler(h.deleteKubernetesServiceAccounts)).Methods(http.MethodPost)
endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/roles/delete", httperror.LoggerHandler(h.deleteRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).Methods(http.MethodGet)
endpointRouter.Handle("/role_bindings/delete", httperror.LoggerHandler(h.deleteRoleBindings)).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)
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
// namespaces
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
@@ -106,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
}
@@ -197,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

@@ -3,7 +3,9 @@ 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"
)
@@ -38,3 +40,38 @@ func (handler *Handler) getAllKubernetesRoleBindings(w http.ResponseWriter, r *h
return response.JSON(w, rolebindings)
}
// @id DeleteRoleBindings
// @summary Delete role bindings
// @description Delete the provided list of role bindings.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sRoleBindingDeleteRequests true "A map where the key is the namespace and the value is an array of role bindings 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 role binding."
// @failure 500 "Server error occurred while attempting to delete role bindings."
// @router /kubernetes/{id}/role_bindings/delete [POST]
func (h *Handler) deleteRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sRoleBindingDeleteRequests
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := h.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
if err := cli.DeleteRoleBindings(payload); err != nil {
return httperror.InternalServerError("Failed to delete role bindings", err)
}
return response.Empty(w)
}

View File

@@ -3,7 +3,9 @@ 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"
)
@@ -38,3 +40,39 @@ func (handler *Handler) getAllKubernetesRoles(w http.ResponseWriter, r *http.Req
return response.JSON(w, roles)
}
// @id DeleteRoles
// @summary Delete roles
// @description Delete the provided list of roles.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sRoleDeleteRequests true "A map where the key is the namespace and the value is an array of roles 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 role."
// @failure 500 "Server error occurred while attempting to delete roles."
// @router /kubernetes/{id}/roles/delete [POST]
func (h *Handler) deleteRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sRoleDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := h.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteRoles(payload)
if err != nil {
return httperror.InternalServerError("Failed to delete roles", err)
}
return response.Empty(w)
}

View File

@@ -3,7 +3,9 @@ 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"
)
@@ -38,3 +40,39 @@ func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r
return response.JSON(w, serviceAccounts)
}
// @id DeleteServiceAccounts
// @summary Delete service accounts
// @description Delete the provided list of service accounts.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sServiceAccountDeleteRequests true "A map where the key is the namespace and the value is an array of service accounts 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 service accounts."
// @router /kubernetes/{id}/service_accounts/delete [POST]
func (handler *Handler) deleteKubernetesServiceAccounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sServiceAccountDeleteRequests
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.DeleteServiceAccounts(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete service accounts", 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

@@ -56,8 +56,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
}
var payload stackMigratePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
@@ -79,8 +78,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err)
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access endpoint", err)
}
@@ -156,14 +154,12 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
newName := stack.Name
stack.Name = oldName
err = handler.deleteStack(securityContext.UserID, stack, endpoint)
if err != nil {
if err := handler.deleteStack(securityContext.UserID, stack, endpoint); err != nil {
return httperror.InternalServerError(err.Error(), err)
}
stack.Name = newName
err = handler.DataStore.Stack().Update(stack.ID, stack)
if err != nil {
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
@@ -210,10 +206,10 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
}
// Deploy the stack
err = composeDeploymentConfig.Deploy()
if err != nil {
if err := composeDeploymentConfig.Deploy(); err != nil {
return httperror.InternalServerError(err.Error(), err)
}
return nil
}
@@ -237,8 +233,7 @@ func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stac
}
// Deploy the stack
err = swarmDeploymentConfig.Deploy()
if err != nil {
if err := swarmDeploymentConfig.Deploy(); err != nil {
return httperror.InternalServerError(err.Error(), err)
}

View File

@@ -197,17 +197,14 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pul
switch stack.Type {
case portainer.DockerSwarmStack:
prune := false
if stack.Option != nil {
prune = stack.Option.Prune
}
// Create swarm deployment config
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
prune := stack.Option != nil && stack.Option.Prune
deploymentConfiger, err = deployments.CreateSwarmStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, prune, pullImage)
if err != nil {
return httperror.InternalServerError(err.Error(), 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()

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

@@ -5,10 +5,8 @@ import (
"net/http"
"time"
"github.com/portainer/portainer/api/http/security"
"github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -76,14 +74,6 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
}
func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
@@ -98,14 +88,13 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque
}
defer websocketConn.Close()
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID)
}
func hijackAttachStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
attachID string,
token string,
) error {
conn, err := initDial(endpoint)
if err != nil {
@@ -127,7 +116,7 @@ func hijackAttachStartOperation(
return err
}
return hijackRequest(websocketConn, conn, attachStartRequest, token)
return ws.HijackRequest(websocketConn, conn, attachStartRequest)
}
func createAttachStartRequest(attachID string) (*http.Request, error) {

View File

@@ -5,13 +5,12 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -79,14 +78,6 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
}
func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
@@ -102,14 +93,13 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request
defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID)
}
func hijackExecStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
execID string,
token string,
) error {
conn, err := initDial(endpoint)
if err != nil {
@@ -121,7 +111,7 @@ func hijackExecStartOperation(
return err
}
return hijackRequest(websocketConn, conn, execStartRequest, token)
return ws.HijackRequest(websocketConn, conn, execStartRequest)
}
func createExecStartRequest(execID string) (*http.Request, error) {

View File

@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -136,8 +137,8 @@ func (handler *Handler) hijackPodExecStartOperation(
// errorChan is used to propagate errors from the go routines to the caller.
errorChan := make(chan error, 1)
go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan)
go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan)
go ws.StreamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan)
go ws.StreamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan)
// StartExecProcess is a blocking operation which streams IO to/from pod;
// this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before

View File

@@ -1,70 +0,0 @@
package websocket
import (
"io"
"unicode/utf8"
"github.com/gorilla/websocket"
)
const readerBufferSize = 2048
func streamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer, errorChan chan error) {
for {
_, in, err := websocketConn.ReadMessage()
if err != nil {
errorChan <- err
break
}
_, err = writer.Write(in)
if err != nil {
errorChan <- err
break
}
}
}
func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) {
out := make([]byte, readerBufferSize)
for {
n, err := reader.Read(out)
if err != nil {
errorChan <- err
break
}
processedOutput := validString(string(out[:n]))
err = websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput))
if err != nil {
errorChan <- err
break
}
}
}
func validString(s string) string {
if utf8.ValidString(s) {
return s
}
v := make([]rune, 0, len(s))
for i, r := range s {
if r == utf8.RuneError {
_, size := utf8.DecodeRuneInString(s[i:])
if size == 1 {
continue
}
}
v = append(v, r)
}
return string(v)
}

View File

@@ -3,39 +3,41 @@ package kubernetes
import (
"time"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
)
type K8sApplication struct {
ID string `json:"Id"`
Name string `json:"Name"`
Image string `json:"Image"`
Containers []interface{} `json:"Containers,omitempty"`
Services []corev1.Service `json:"Services"`
CreationDate time.Time `json:"CreationDate"`
ApplicationOwner string `json:"ApplicationOwner,omitempty"`
StackName string `json:"StackName,omitempty"`
ResourcePool string `json:"ResourcePool"`
ApplicationType string `json:"ApplicationType"`
Metadata *Metadata `json:"Metadata,omitempty"`
Status string `json:"Status"`
TotalPodsCount int `json:"TotalPodsCount"`
RunningPodsCount int `json:"RunningPodsCount"`
DeploymentType string `json:"DeploymentType"`
Pods []Pod `json:"Pods,omitempty"`
Configurations []Configuration `json:"Configurations,omitempty"`
LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"`
PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"`
Namespace string `json:"Namespace,omitempty"`
UID string `json:"Uid,omitempty"`
StackID string `json:"StackId,omitempty"`
ServiceID string `json:"ServiceId,omitempty"`
ServiceName string `json:"ServiceName,omitempty"`
ServiceType string `json:"ServiceType,omitempty"`
Kind string `json:"Kind,omitempty"`
MatchLabels map[string]string `json:"MatchLabels,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
ID string `json:"Id"`
Name string `json:"Name"`
Image string `json:"Image"`
Containers []interface{} `json:"Containers,omitempty"`
Services []corev1.Service `json:"Services"`
CreationDate time.Time `json:"CreationDate"`
ApplicationOwner string `json:"ApplicationOwner,omitempty"`
StackName string `json:"StackName,omitempty"`
ResourcePool string `json:"ResourcePool"`
ApplicationType string `json:"ApplicationType"`
Metadata *Metadata `json:"Metadata,omitempty"`
Status string `json:"Status"`
TotalPodsCount int `json:"TotalPodsCount"`
RunningPodsCount int `json:"RunningPodsCount"`
DeploymentType string `json:"DeploymentType"`
Pods []Pod `json:"Pods,omitempty"`
Configurations []Configuration `json:"Configurations,omitempty"`
LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"`
PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"`
Namespace string `json:"Namespace,omitempty"`
UID string `json:"Uid,omitempty"`
StackID string `json:"StackId,omitempty"`
ServiceID string `json:"ServiceId,omitempty"`
ServiceName string `json:"ServiceName,omitempty"`
ServiceType string `json:"ServiceType,omitempty"`
Kind string `json:"Kind,omitempty"`
MatchLabels map[string]string `json:"MatchLabels,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
}
type Metadata struct {

View File

@@ -1,16 +1,33 @@
package kubernetes
import (
"errors"
"net/http"
"time"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/types"
)
type (
K8sClusterRoleBinding struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
RoleRef rbacv1.RoleRef `json:"roleRef"`
Subjects []rbacv1.Subject `json:"subjects"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
// K8sRoleBindingDeleteRequests slice of cluster role cluster bindings.
K8sClusterRoleBindingDeleteRequests []string
)
func (r K8sClusterRoleBindingDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
return nil
}

View File

@@ -1,8 +1,28 @@
package kubernetes
import "time"
import (
"errors"
"net/http"
"time"
type K8sClusterRole struct {
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
"k8s.io/apimachinery/pkg/types"
)
type (
K8sClusterRole struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
K8sClusterRoleDeleteRequests []string
)
func (r K8sClusterRoleDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
return nil
}

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

@@ -1,17 +1,38 @@
package kubernetes
import (
"errors"
"net/http"
"time"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/types"
)
type (
K8sRoleBinding struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
RoleRef rbacv1.RoleRef `json:"roleRef"`
Subjects []rbacv1.Subject `json:"subjects"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
// K8sRoleBindingDeleteRequests is a mapping of namespace names to a slice of role bindings.
K8sRoleBindingDeleteRequests map[string][]string
)
func (r K8sRoleBindingDeleteRequests) 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

@@ -1,9 +1,36 @@
package kubernetes
import "time"
import (
"errors"
"net/http"
"time"
type K8sRole struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
"k8s.io/apimachinery/pkg/types"
)
type (
K8sRole struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
// isSystem is true if prefixed with "system:" or exists in the kube-system namespace
// or is one of the portainer roles
IsSystem bool `json:"isSystem"`
}
// K8sRoleDeleteRequests is a mapping of namespace names to a slice of roles.
K8sRoleDeleteRequests map[string][]string
)
func (r K8sRoleDeleteRequests) 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

@@ -1,9 +1,34 @@
package kubernetes
import "time"
import (
"errors"
"net/http"
"time"
type K8sServiceAccount struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
"k8s.io/apimachinery/pkg/types"
)
type (
K8sServiceAccount struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
// K8sServiceAcountDeleteRequests is a mapping of namespace names to a slice of service account names.
K8sServiceAccountDeleteRequests map[string][]string
)
func (r K8sServiceAccountDeleteRequests) 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()
}

View File

@@ -11,6 +11,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -37,6 +38,8 @@ type (
dockerClientFactory *dockerclient.ClientFactory
gitService portainer.GitService
snapshotService portainer.SnapshotService
dockerID string
mu sync.Mutex
}
// TransportParameters is used to create a new Transport
@@ -679,9 +682,7 @@ func (transport *Transport) executeGenericResourceDeletionOperation(request *htt
}
if resourceControl != nil {
if err := transport.dataStore.ResourceControl().Delete(resourceControl.ID); err != nil {
return response, err
}
err = transport.dataStore.ResourceControl().Delete(resourceControl.ID)
}
return response, err

View File

@@ -14,7 +14,6 @@ import (
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
const volumeObjectIdentifier = "ResourceID"
@@ -50,15 +49,6 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
volumeData := responseObject["Volumes"].([]any)
if transport.snapshotService != nil {
// Filling snapshot data can improve the performance of getVolumeResourceID
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
log.Info().Err(err).
Int("endpoint id", int(transport.endpoint.ID)).
Msg("snapshot is not filled into the endpoint.")
}
}
for _, volumeObject := range volumeData {
volume := volumeObject.(map[string]any)
@@ -147,7 +137,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
}
defer cli.Close()
if _, err = cli.VolumeInspect(context.Background(), volumeID); err == nil {
if _, err := cli.VolumeInspect(context.Background(), volumeID); err == nil {
return &http.Response{
StatusCode: http.StatusConflict,
}, errors.New("a volume with the same name already exists")
@@ -222,14 +212,27 @@ func (transport *Transport) getVolumeResourceID(volumeName string) (string, erro
}
func (transport *Transport) getDockerID() (string, error) {
if len(transport.endpoint.Snapshots) > 0 {
dockerID, err := snapshot.FetchDockerID(transport.endpoint.Snapshots[0])
// ignore err - in case of error, just generate not from snapshot
if err == nil {
return dockerID, nil
transport.mu.Lock()
defer transport.mu.Unlock()
// Local cache
if transport.dockerID != "" {
return transport.dockerID, nil
}
// Snapshot cache
if transport.snapshotService != nil {
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
transport.dockerID = dockerID
return dockerID, nil
}
}
}
// Remote value
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
if err != nil {
return "", err
@@ -242,8 +245,11 @@ func (transport *Transport) getDockerID() (string, error) {
}
if info.Swarm.Cluster != nil {
return info.Swarm.Cluster.ID, nil
transport.dockerID = info.Swarm.Cluster.ID
return transport.dockerID, nil
}
return info.ID, nil
transport.dockerID = info.ID
return transport.dockerID, nil
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/featureflags"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/pkg/errors"
@@ -42,6 +43,8 @@ type (
jwtService portainer.JWTService
apiKeyService apikey.APIKeyService
revokedJWT sync.Map
hsts bool
csp bool
}
// RestrictedRequestContext is a data structure containing information
@@ -68,6 +71,8 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
dataStore: dataStore,
jwtService: jwtService,
apiKeyService: apiKeyService,
hsts: featureflags.IsEnabled("hsts"),
csp: featureflags.IsEnabled("csp"),
}
go b.cleanUpExpiredJWT()
@@ -78,7 +83,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
// PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
return mwSecureHeaders(h)
return MWSecureHeaders(h, bouncer.hsts, bouncer.csp)
}
// AdminAccess defines a security check for API endpoints that require an authorization check.
@@ -211,7 +216,7 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler
bouncer.CookieAuthLookup,
bouncer.JWTAuthLookup,
}, h)
h = mwSecureHeaders(h)
h = MWSecureHeaders(h, bouncer.hsts, bouncer.csp)
return h
}
@@ -517,10 +522,17 @@ func extractAPIKey(r *http.Request) (string, bool) {
return "", false
}
// mwSecureHeaders provides secure headers middleware for handlers.
func mwSecureHeaders(next http.Handler) http.Handler {
// MWSecureHeaders provides secure headers middleware for handlers.
func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-XSS-Protection", "1; mode=block")
if hsts {
w.Header().Set("Strict-Transport-Security", "max-age=31536000") // 365 days
}
if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})

View File

@@ -9,7 +9,6 @@ func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, rel
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
for _, environmentID := range relatedEnvironmentIDs {
newEnvStatus := portainer.EdgeStackStatus{
Status: []portainer.EdgeStackDeploymentStatus{},
EndpointID: environmentID,

View File

@@ -11,6 +11,8 @@ import (
log "github.com/rs/zerolog/log"
)
// TODO: this file should be migrated to package/server-ce/pkg/endpoints
// IsLocalEndpoint returns true if this is a local environment(endpoint)
func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
return strings.HasPrefix(endpoint.URL, "unix://") ||

View File

@@ -0,0 +1,18 @@
package errorlist
import "errors"
// Combine a slice of errors into a single error
// to use this, generate errors by appending to errorList in a loop, then return combine(errorList)
func Combine(errorList []error) error {
if len(errorList) == 0 {
return nil
}
errorMsg := "Multiple errors occurred:"
for _, err := range errorList {
errorMsg += "\n" + err.Error()
}
return errors.New(errorMsg)
}

View File

@@ -14,47 +14,51 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
}
func doGetRegToken(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
if err != nil {
return
return err
}
registry.AccessToken = *accessToken
registry.AccessTokenExpiry = expiryAt.Unix()
err = dataStore.Registry().Update(registry.ID, registry)
return
return tx.Registry().Update(registry.ID, registry)
}
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
return ecrClient.ParseAuthorizationToken(registry.AccessToken)
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
}
func EnsureRegTokenValid(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
if registry.Type == portainer.EcrRegistry {
if isRegTokenValid(registry) {
log.Debug().Msg("current ECR token is still valid")
} else {
err = doGetRegToken(dataStore, registry)
if err != nil {
log.Debug().Msg("refresh ECR token")
}
}
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if registry.Type != portainer.EcrRegistry {
return nil
}
return
if isRegTokenValid(registry) {
log.Debug().Msg("current ECR token is still valid")
return nil
}
if err := doGetRegToken(tx, registry); err != nil {
log.Debug().Msg("refresh ECR token")
return err
}
return nil
}
func GetRegEffectiveCredential(registry *portainer.Registry) (username, password string, err error) {
username = registry.Username
password = registry.Password
if registry.Type == portainer.EcrRegistry {
username, password, err = parseRegToken(registry)
} else {
username = registry.Username
password = registry.Password
}
return
}

View File

@@ -10,13 +10,13 @@ import (
"github.com/portainer/portainer/api/agent"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/pendingactions"
endpointsutils "github.com/portainer/portainer/pkg/endpoints"
"github.com/rs/zerolog/log"
)
// Service repesents a service to manage environment(endpoint) snapshots.
// Service represents a service to manage environment(endpoint) snapshots.
// It provides an interface to start background snapshots as well as
// specific Docker/Kubernetes environment(endpoint) snapshot methods.
type Service struct {
@@ -64,7 +64,7 @@ func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService po
}
for _, e := range endpoints {
if !endpointutils.IsEdgeEndpoint(&e) || e.Edge.AsyncMode || !e.UserTrusted {
if !endpointsutils.HasDirectConnectivity(&e) {
continue
}
@@ -174,30 +174,6 @@ func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
return FillSnapshotData(service.dataStore, endpoint)
}
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
snapshot, err := tx.Snapshot().Read(endpoint.ID)
if tx.IsErrObjectNotFound(err) {
endpoint.Snapshots = []portainer.DockerSnapshot{}
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
return nil
}
if err != nil {
return err
}
if snapshot.Docker != nil {
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
}
if snapshot.Kubernetes != nil {
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
}
return nil
}
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
kubernetesSnapshot, err := service.kubernetesSnapshotter.CreateSnapshot(endpoint)
if err != nil {
@@ -285,11 +261,16 @@ func (service *Service) snapshotEndpoints() error {
snapshotError := service.SnapshotEndpoint(&endpoint)
service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
updateEndpointStatus(tx, &endpoint, snapshotError, service.pendingActionsService)
return nil
})
}); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(endpoint.ID)).
Msg("unable to update environment status")
}
}
return nil
@@ -340,12 +321,31 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
return info.ID, nil
}
swarmInfo := info.Swarm
if swarmInfo.Cluster == nil {
if info.Swarm.Cluster == nil {
return "", errors.New("swarm environment is missing cluster info snapshot")
}
clusterInfo := swarmInfo.Cluster
return clusterInfo.ID, nil
return info.Swarm.Cluster.ID, nil
}
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
snapshot, err := tx.Snapshot().Read(endpoint.ID)
if tx.IsErrObjectNotFound(err) {
endpoint.Snapshots = []portainer.DockerSnapshot{}
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
return nil
} else if err != nil {
return err
}
if snapshot.Docker != nil {
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
}
if snapshot.Kubernetes != nil {
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
}
return nil
}

View File

@@ -64,8 +64,7 @@ func (service *Service) Init(host, certPath, keyPath string) error {
// path not supplied and certificates doesn't exist - generate self-signed
certPath, keyPath = service.fileService.GetDefaultSSLCertsPath()
err = generateSelfSignedCertificates(host, certPath, keyPath)
if err != nil {
if err := generateSelfSignedCertificates(host, certPath, keyPath); err != nil {
return errors.Wrap(err, "failed generating self signed certs")
}
@@ -98,8 +97,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
return errors.New("missing certificate files")
}
_, err := tls.X509KeyPair(certData, keyData)
if err != nil {
if _, err := tls.X509KeyPair(certData, keyData); err != nil {
return err
}
@@ -108,8 +106,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
return err
}
err = service.cacheInfo(certPath, keyPath, false)
if err != nil {
if err := service.cacheInfo(certPath, keyPath, false); err != nil {
return err
}
@@ -130,8 +127,7 @@ func (service *Service) SetHTTPEnabled(httpEnabled bool) error {
settings.HTTPEnabled = httpEnabled
err = service.dataStore.SSLSettings().UpdateSettings(settings)
if err != nil {
if err := service.dataStore.SSLSettings().UpdateSettings(settings); err != nil {
return err
}
@@ -152,8 +148,7 @@ func (service *Service) cacheCertificate(certPath, keyPath string) error {
}
func (service *Service) cacheInfo(certPath string, keyPath string, selfSigned bool) error {
err := service.cacheCertificate(certPath, keyPath)
if err != nil {
if err := service.cacheCertificate(certPath, keyPath); err != nil {
return err
}

View File

@@ -6,6 +6,8 @@ import (
portainer "github.com/portainer/portainer/api"
)
var _ portainer.ComposeStackManager = &composeStackManager{}
type composeStackManager struct{}
func NewComposeStackManager() *composeStackManager {
@@ -31,6 +33,6 @@ func (manager *composeStackManager) Down(ctx context.Context, stack *portainer.S
return nil
}
func (manager *composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
return nil
}

View File

@@ -124,7 +124,7 @@ func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]p
}
// GetNonAdminNamespaces retrieves namespaces for a non-admin user, excluding the default namespace if restricted.
func (kcl *KubeClient) GetNonAdminNamespaces(userID int, isRestrictDefaultNamespace bool) ([]string, error) {
func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) {
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return nil, fmt.Errorf("an error occurred during the getNonAdminNamespaces operation, unable to get namespace access policies via portainer-config. check if portainer-config configMap exists in the Kubernetes cluster: %w", err)
@@ -136,7 +136,7 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, isRestrictDefaultNamesp
}
for namespace, accessPolicy := range accessPolicies {
if hasUserAccessToNamespace(userID, nil, accessPolicy) {
if hasUserAccessToNamespace(userID, teamIDs, accessPolicy) {
nonAdminNamespaces = append(nonAdminNamespaces, namespace)
}
}

View File

@@ -6,6 +6,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
labels "k8s.io/apimachinery/pkg/labels"
@@ -31,20 +32,20 @@ func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDepende
}
if !withDependencies {
// TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
pods, replicaSets, deployments, statefulSets, daemonSets, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil)
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services)
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
}
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
@@ -62,20 +63,20 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
}
if !withDependencies {
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil)
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services)
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
if err != nil {
return nil, err
}
@@ -92,7 +93,7 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
}
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) ([]models.K8sApplication, error) {
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
applications := []models.K8sApplication{}
processedOwners := make(map[string]struct{})
@@ -105,7 +106,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
processedOwners[ownerUID] = struct{}{}
}
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, true)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
if err != nil {
return nil, err
}
@@ -133,7 +134,7 @@ func (kcl *KubeClient) GetApplicationsResource(namespace, node string) (models.K
}
for _, pod := range pods.Items {
podResources := calculateResourceUsage(pod)
podResources := calculatePodResourceUsage(pod)
resource.CPURequest += podResources.CPURequest
resource.CPULimit += podResources.CPULimit
resource.MemoryRequest += podResources.MemoryRequest
@@ -150,7 +151,7 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -167,7 +168,7 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -180,18 +181,23 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
}
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, withResource bool) (*models.K8sApplication, error) {
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
if isReplicaSetOwner(pod) {
updateOwnerReferenceToDeployment(&pod, replicaSets)
}
application := createApplication(&pod, deployments, statefulSets, daemonSets, services)
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
if application.ID == "" && application.Name == "" {
return nil, nil
}
if withResource {
application.Resource = calculateResourceUsage(pod)
podResources := calculatePodResourceUsage(pod)
// multiply by the number of requested pods in the application (not the running count)
application.Resource.CPURequest = podResources.CPURequest * float64(application.TotalPodsCount)
application.Resource.CPULimit = podResources.CPULimit * float64(application.TotalPodsCount)
application.Resource.MemoryRequest = podResources.MemoryRequest * int64(application.TotalPodsCount)
application.Resource.MemoryLimit = podResources.MemoryLimit * int64(application.TotalPodsCount)
}
return &application, nil
@@ -199,7 +205,7 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
// createApplication creates a K8sApplication object from a pod
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) models.K8sApplication {
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
kind := "Pod"
name := pod.Name
@@ -319,7 +325,11 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
}
if application.ID != "" && application.Name != "" && len(services) > 0 {
return updateApplicationWithService(application, services)
updateApplicationWithService(&application, services)
}
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
}
return application
@@ -327,21 +337,36 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
// updateApplicationWithService updates the application with the services that match the application's selector match labels
// and are in the same namespace as the application
func updateApplicationWithService(application models.K8sApplication, services []corev1.Service) models.K8sApplication {
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
for _, service := range services {
serviceSelector := labels.SelectorFromSet(service.Spec.Selector)
if service.Namespace == application.ResourcePool && serviceSelector.Matches(labels.Set(application.MatchLabels)) {
if service.Namespace == application.ResourcePool && !serviceSelector.Empty() && serviceSelector.Matches(labels.Set(application.MatchLabels)) {
application.ServiceType = string(service.Spec.Type)
application.Services = append(application.Services, service)
}
}
return application
}
// calculateResourceUsage calculates the resource usage for a pod in CPU cores and Bytes
func calculateResourceUsage(pod corev1.Pod) models.K8sApplicationResource {
func updateApplicationWithHorizontalPodAutoscaler(application *models.K8sApplication, hpas []autoscalingv2.HorizontalPodAutoscaler) {
for _, hpa := range hpas {
// Check if HPA is in the same namespace as the application
if hpa.Namespace != application.ResourcePool {
continue
}
// Check if the scale target ref matches the application
scaleTargetRef := hpa.Spec.ScaleTargetRef
if scaleTargetRef.Name == application.Name && scaleTargetRef.Kind == application.Kind {
hpaCopy := hpa // Create a local copy
application.HorizontalPodAutoscaler = &hpaCopy
break
}
}
}
// calculatePodResourceUsage calculates the resource usage for a pod in CPU cores and Bytes
func calculatePodResourceUsage(pod corev1.Pod) models.K8sApplicationResource {
resource := models.K8sApplicationResource{}
for _, container := range pod.Spec.Containers {
// CPU cores as a decimal
@@ -385,7 +410,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -411,7 +436,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}

View File

@@ -3,10 +3,14 @@ package cli
import (
"context"
"errors"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetClusterRoles gets all the clusterRoles for at the cluster level in a k8s endpoint.
@@ -21,7 +25,7 @@ func (kcl *KubeClient) GetClusterRoles() ([]models.K8sClusterRole, error) {
// fetchClusterRoles returns a list of all Roles in the specified namespace.
func (kcl *KubeClient) fetchClusterRoles() ([]models.K8sClusterRole, error) {
clusterRoles, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), metav1.ListOptions{})
clusterRoles, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), meta.ListOptions{})
if err != nil {
return nil, err
}
@@ -39,5 +43,61 @@ func parseClusterRole(clusterRole rbacv1.ClusterRole) models.K8sClusterRole {
return models.K8sClusterRole{
Name: clusterRole.Name,
CreationDate: clusterRole.CreationTimestamp.Time,
UID: clusterRole.UID,
IsSystem: isSystemClusterRole(&clusterRole),
}
}
func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error {
var errors []error
for _, name := range req {
client := kcl.cli.RbacV1().ClusterRoles()
clusterRole, err := client.Get(context.Background(), name, meta.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
// this is a more serious error to do with the client so we return right away
return err
}
if isSystemClusterRole(clusterRole) {
log.Warn().Str("role_name", name).Msg("ignoring delete of 'system' cluster role, not allowed")
}
err = client.Delete(context.Background(), name, meta.DeleteOptions{})
if err != nil {
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role")
errors = append(errors, err)
}
}
return errorlist.Combine(errors)
}
func isSystemClusterRole(role *rbacv1.ClusterRole) bool {
if role.Namespace == "kube-system" || role.Namespace == "kube-public" ||
role.Namespace == "kube-node-lease" || role.Namespace == "portainer" {
return true
}
if strings.HasPrefix(role.Name, "system:") {
return true
}
if role.Labels != nil {
if role.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" {
return true
}
}
roles := getPortainerDefaultK8sRoleNames()
for i := range roles {
if role.Name == roles[i] {
return true
}
}
return false
}

View File

@@ -3,9 +3,13 @@ package cli
import (
"context"
"errors"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -38,8 +42,70 @@ func (kcl *KubeClient) fetchClusterRoleBindings() ([]models.K8sClusterRoleBindin
func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) models.K8sClusterRoleBinding {
return models.K8sClusterRoleBinding{
Name: clusterRoleBinding.Name,
UID: clusterRoleBinding.UID,
Namespace: clusterRoleBinding.Namespace,
RoleRef: clusterRoleBinding.RoleRef,
Subjects: clusterRoleBinding.Subjects,
CreationDate: clusterRoleBinding.CreationTimestamp.Time,
IsSystem: isSystemClusterRoleBinding(&clusterRoleBinding),
}
}
// DeleteClusterRoleBindings processes a K8sClusterRoleBindingDeleteRequest
// by deleting each cluster role binding in its given namespace. If deleting a specific cluster role binding
// fails, the error is logged and we continue to delete the remaining cluster role bindings.
func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error {
var errors []error
for _, name := range reqs {
client := kcl.cli.RbacV1().ClusterRoleBindings()
clusterRoleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
// This is a more serious error to do with the client so we return right away
return err
}
if isSystemClusterRoleBinding(clusterRoleBinding) {
log.Warn().Str("role_name", name).Msg("ignoring delete of 'system' cluster role binding, not allowed")
}
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role binding")
errors = append(errors, err)
}
}
return errorlist.Combine(errors)
}
func isSystemClusterRoleBinding(binding *rbacv1.ClusterRoleBinding) bool {
if strings.HasPrefix(binding.Name, "system:") {
return true
}
if binding.Labels != nil {
if binding.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" {
return true
}
}
for _, sub := range binding.Subjects {
if strings.HasPrefix(sub.Name, "system:") {
return true
}
if sub.Namespace == "kube-system" ||
sub.Namespace == "kube-public" ||
sub.Namespace == "kube-node-lease" ||
sub.Namespace == "portainer" {
return true
}
}
return false
}

View File

@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}

View File

@@ -0,0 +1,123 @@
package cli
import (
"context"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
batchv1 "k8s.io/api/batch/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetCronJobs returns all cronjobs in the given namespace
// If the user is a kube admin, it returns all cronjobs in the namespace
// Otherwise, it returns only the cronjobs in the non-admin namespaces
func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) {
if kcl.IsKubeAdmin {
return kcl.fetchCronJobs(namespace)
}
return kcl.fetchCronJobsForNonAdmin(namespace)
}
// fetchCronJobsForNonAdmin returns all cronjobs in the given namespace
// It returns only the cronjobs in the non-admin namespaces
func (kcl *KubeClient) fetchCronJobsForNonAdmin(namespace string) ([]models.K8sCronJob, error) {
cronJobs, err := kcl.fetchCronJobs(namespace)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sCronJob, 0)
for _, cronJob := range cronJobs {
if _, ok := nonAdminNamespaceSet[cronJob.Namespace]; ok {
results = append(results, cronJob)
}
}
return results, nil
}
// fetchCronJobs returns all cronjobs in the given namespace
// It returns all cronjobs in the namespace
func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, error) {
cronJobs, err := kcl.cli.BatchV1().CronJobs(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}
jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}
results := make([]models.K8sCronJob, 0)
for _, cronJob := range cronJobs.Items {
results = append(results, kcl.parseCronJob(cronJob, jobs))
}
return results, nil
}
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
if err != nil {
return models.K8sCronJob{}
}
timezone := "<none>"
if cronJob.Spec.TimeZone != nil {
timezone = *cronJob.Spec.TimeZone
}
suspend := false
if cronJob.Spec.Suspend != nil {
suspend = *cronJob.Spec.Suspend
}
return models.K8sCronJob{
Id: string(cronJob.UID),
Name: cronJob.Name,
Namespace: cronJob.Namespace,
Command: strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " "),
Schedule: cronJob.Spec.Schedule,
Timezone: timezone,
Suspend: suspend,
Jobs: jobs,
IsSystem: kcl.isSystemCronJob(cronJob.Namespace),
}
}
func (kcl *KubeClient) isSystemCronJob(namespace string) bool {
return kcl.isSystemNamespace(namespace)
}
// DeleteCronJobs deletes the provided list of cronjobs in its namespace
// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs
func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error {
var errors []error
for namespace := range payload {
for _, cronJobName := range payload[namespace] {
client := kcl.cli.BatchV1().CronJobs(namespace)
_, err := client.Get(context.Background(), cronJobName, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
errors = append(errors, err)
}
if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
}
return errorlist.Combine(errors)
}

View File

@@ -0,0 +1,66 @@
package cli
import (
"context"
"testing"
models "github.com/portainer/portainer/api/http/models/kubernetes"
batchv1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
// TestFetchCronJobs tests the fetchCronJobs method for both admin and non-admin clients
// It creates a fake Kubernetes client and passes it to the fetchCronJobs method
// It then logs the fetched Cron Jobs
// non-admin client will have access to the default namespace only
func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = true
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Cron Jobs: %v", cronJobs)
})
t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = false
kcl.NonAdminNamespaces = []string{"default"}
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Cron Jobs: %v", cronJobs)
})
t.Run("delete Cron Jobs", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
_, err := kcl.cli.BatchV1().CronJobs("default").Create(context.Background(), &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob"},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create cron job: %v", err)
}
err = kcl.DeleteCronJobs(models.K8sCronJobDeleteRequests{
"default": []string{"test-cronjob"},
})
if err != nil {
t.Fatalf("Failed to delete Cron Jobs: %v", err)
}
t.Logf("Deleted Cron Jobs")
})
}

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