Compare commits

..

104 Commits

Author SHA1 Message Date
matias.spinarolli
f900af4871 fix(oauth): show asterisks placeholder in secret key input field EE-5664 2023-12-05 17:03:10 -03:00
Chaim Lev-Ari
4410394ede Revert "fix(images): sort by tags [EE-6410]" (#10754) 2023-12-05 05:28:55 +02:00
Ali
e5eb354d7b Revert "fix(app): shift external to the top [EE-6392] (#10718)" (#10749)
This reverts commit b051629f13.
2023-12-05 09:16:40 +13:00
Ali
b660feafbf Revert "fix(gitops): correct commit hash link [EE-6346] (#10722)" (#10750)
This reverts commit 83cd5d9b2f.
2023-12-05 09:16:22 +13:00
Chaim Lev-Ari
b75f0e561b fix(images): sort by tags [EE-6410] (#10739) 2023-12-04 08:47:19 +02:00
Ali
83cd5d9b2f fix(gitops): correct commit hash link [EE-6346] (#10722) 2023-12-04 11:18:05 +13:00
Ali
b051629f13 fix(app): shift external to the top [EE-6392] (#10718)
Co-authored-by: testa113 <testa113>
2023-12-04 07:43:50 +13:00
andres-portainer
32da62cdc8 feat(version): bump to v2.19.4 EE-6407 (#10730) 2023-12-01 11:18:52 -03:00
Matt Hook
93124f75cf fix(rollback): reimplement rollback feature [EE-6367] (#10720) 2023-12-01 13:02:37 +13:00
Matt Hook
0fce4c98a0 fix(backups): fix rollback feature [EE-6367] (#10691) (#10703) 2023-12-01 10:03:31 +13:00
Chaim Lev-Ari
5dad419f60 fix(swarm/services): avoid sending credSpec object when empty [EE-6322] (#10636)
Co-authored-by: matias-portainer <104775949+matias-portainer@users.noreply.github.com>
2023-11-26 07:01:58 +02:00
andres-portainer
cd9ad97235 fix(gitops): change the condition that checks if the environment is online EE-6321 (#10664) 2023-11-20 23:59:22 -03:00
Prabhat Khera
67308838fd version bump to 2.19.3 (#10645) 2023-11-17 09:51:21 +13:00
andres-portainer
3360576e07 fix(gitops): handle the local environment in isEnvironmentOnline() EE-6321 (#10632) 2023-11-16 09:40:24 -03:00
yi-portainer
c5a51a9fb7 * remove line break 2023-11-13 14:17:00 +13:00
Prabhat Khera
280a2fe093 fix(kubernetes): clear user token from kube token cache on logout + update cluster rolebindings for user on change of team/user authorization [EE-6298] (#10603) 2023-11-10 10:06:50 +13:00
Prabhat Khera
ddd30dd17a fix(app): disable deploy when there are no namespaces [EE-6295] (#10608)
* fix(app): hide services section when there are no namespaces [EE-6295] (#10588)

Co-authored-by: testa113 <testa113>

* fix(app): disable deploy when there are no namespaces [EE-6295] (#10606)

Co-authored-by: testa113 <testa113>

---------

Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2023-11-09 20:02:02 +13:00
Chaim Lev-Ari
15df3277ca fix(edge/updates): hide sidebar item when disabled [EE-6294] (#10581) 2023-11-05 13:41:16 +02:00
Prabhat Khera
47845523a5 fix(users): hide admin users for non admins from user list API [EE-6290] (#10579)
* hide admin users for non admins from user list API

* address review comments
2023-11-02 16:08:22 +13:00
LP B
2af2827cba fix(app/logout): always perform API logout + make API logout route public [EE-6198] (#10447)
* feat(api/logout): make logout route public

* feat(app/logout): always perform API logout on /logout redirect

* fix(app): send a logout event to AngularJS when axios hits a 401
2023-10-27 14:02:18 +02:00
andres-portainer
8f4f5fddcc fix(gitops): only attempt to redeploy when the environment appears to be online EE-6182 (#10463) 2023-10-24 11:20:54 -03:00
Oscar Zhou
8b7436e4d0 fix(edge): introduce pause and rollback status [EE-5992] (#10466) 2023-10-19 11:25:43 +13:00
Chaim Lev-Ari
5b8a0471e9 fix(edge/updates): allow group search [EE-6179] (#10407) 2023-10-12 08:30:25 +03:00
Oscar Zhou
0b9e5c564f feat(fs): support to update stack file by version (#10417) 2023-10-06 09:08:34 +13:00
Chaim Lev-Ari
1ed2c8b346 chore(deps): upgrade golangci [EE-5685] (#10413) 2023-10-05 10:31:48 +03:00
Ali
c43f771a88 fix(teasers): add teaser message full stops [EE-6035] (#10402) 2023-10-02 21:22:52 +01:00
Matt Hook
8755a22fee add support for forward proxy (#10334) 2023-09-29 12:54:53 +13:00
cmeng
8e3c47719e fix(websocket): abort websocket when logout EE-6058 (#10371) 2023-09-29 12:13:18 +13:00
Matt Hook
157393c965 support proxy for helm repo validation (#10359) 2023-09-29 11:37:30 +13:00
Ali
6163aaa577 fix(teasers): updated muted styles from qa feedback [EE-6035] (#10391)
* fix(teasers): updated muted styles from qa feedback [EE-6035]
2023-09-28 11:32:48 +01:00
Prabhat Khera
d9a3b98275 fix team lead access to view user names (#10389) 2023-09-28 12:40:58 +13:00
Chaim Lev-Ari
c0c689c2af fix(docker/services): show cred spec configs [EE-5276] (#10082) 2023-09-27 07:57:43 +03:00
Chaim Lev-Ari
4efe66d33f fix(stacks): mark stack as start after autoupdate [EE-6165] (#10375) 2023-09-27 07:53:36 +03:00
Prabhat Khera
80415ab68f fix(authorization): disable user list api call if not authorised [EE-5825] (#10380)
* fix tests
* disable user list api call if not authorised
* fix lint issues
2023-09-27 10:12:40 +13:00
Chaim Lev-Ari
fa087f0bb9 style(kubernetes): disable autoFocus warning [EE-5752] (#10367) 2023-09-25 20:13:35 +03:00
LP B
3994d74c71 feat(app/home): tooltip aside edge agent version on mismatch with Portainer version (#10288)
* feat(app/home): tooltip aside edge agent version on mismatch with Portainer version

* fix(app/home): split agent and edge version display + display warning for agents before 2.15
2023-09-25 11:56:03 +02:00
Matt Hook
537585e78c chore: bump version 2.19.2 [EE-6153] (#10370) 2023-09-25 14:26:54 +13:00
Prabhat Khera
78202cfb25 fix(permissions): non admin access to view users [EE-5825] (#10353)
* fix(security): added restrictions to see user names [EE-5825]
2023-09-25 09:08:37 +13:00
Ali
b60f32a25b fix(be-teaser): mute styles [EE-6035] (#10350) 2023-09-24 19:56:18 +01:00
Matt Hook
8f42ba0254 allow libhelm to use forward proxy (#10330) 2023-09-19 18:07:41 +12:00
Chaim Lev-Ari
6f81fcc169 fix(api): restore deleted apis [EE-6090] (#10266) 2023-09-19 13:44:55 +12:00
Oscar Zhou
46949508a4 fix(db/migration): avoid fatal error from being overwritten (#10317) 2023-09-18 14:32:57 +12:00
Matt Hook
034157be9a improved user update validation (#10322) 2023-09-18 12:29:12 +12:00
Dakota Walsh
011a1ce720 fix(kubernetes): add prefix only when needed EE-6068 (#3918) (#10311) 2023-09-15 07:59:37 +12:00
Prabhat Khera
a4922eb693 fix(docker): revert PR #10297 and #10242 [EE-5825] (#10308)
* revert PR #10297 and #10242
2023-09-14 15:51:19 +12:00
cmeng
8c77c5ffbe fix(backup): add chisel key to backup EE-6105 (#10282) 2023-09-13 09:01:31 +12:00
andres-portainer
a062c36ff5 fix(gitops): avoid cancelling the auto updates for any error EE-5604 (#10295) 2023-09-12 17:52:52 -03:00
Oscar Zhou
122fd835dc fix(db/init): check server version and db schema version (#10299) 2023-09-12 15:55:15 +12:00
Prabhat Khera
f7ff07833f fix(security): added restrictions to see user names [EE-5825] (#10297)
* fix(security): added restrictions to see user names [EE-5825]

* use pluralize method
2023-09-12 13:15:29 +12:00
matias-portainer
8010167006 fix(authentication): allow nested whitespaces on AD OU names EE-5206 (#10261) 2023-09-07 11:03:04 -03:00
Matt Hook
4c79e9ef6b prevent regular users changing their username (#10246) 2023-09-06 08:44:24 +12:00
Matt Hook
88ea0cb64f non-admins must supply existing passwd when changing passwd (#10248) 2023-09-06 07:53:31 +12:00
Dakota Walsh
5f50f20a7a fix(security): block user access policies for non admins EE-5826 (#10244) 2023-09-05 09:18:17 +12:00
Dakota Walsh
bbc26682dd fix(security): block non-admins from user info listing EE-5825 (#10242) 2023-09-05 09:17:10 +12:00
Matt Hook
f74704fca4 Bump 2.19.0 release to 2.19.1 (#10237) 2023-09-04 12:06:47 +12:00
Chaim Lev-Ari
9b52bd50d9 fix(ui/switch): reduce label size [EE-3803] (#10018) 2023-09-03 10:26:33 +01:00
Prabhat Khera
04073f0d1f add tls options to the tls dropdown (#10222) 2023-09-01 10:42:26 +12:00
Ali
c035e4a778 fix(k8sconfigure): make ingress restrict be only [EE-6062] (#10217)
Co-authored-by: testa113 <testa113>
2023-09-01 06:11:43 +12:00
Prabhat Khera
7abed624d9 fix showing default ns for ingresses on edit (#10196) 2023-08-29 15:12:40 +12:00
cmeng
1e24451cc9 fix(relative-path): not deploy git stack via unpacker EE-6043 (#10194) 2023-08-29 11:48:57 +12:00
Prabhat Khera
adcfcdd6e3 fix ECR registry token refresh (#10190) 2023-08-29 10:32:47 +12:00
Dakota Walsh
e6e3810fa4 fix(registry): ecr secret fix [EE-5673] (#10108) 2023-08-28 08:38:40 +12:00
andres-portainer
5e20854f86 fix(docker): use version negotiation for the Docker client EE-5797 (#9251) 2023-08-22 17:59:46 -03:00
Chaim Lev-Ari
69f3670ce5 fix(ui/datatables): sync page count with filtering [EE-5890] (#10009) 2023-08-22 09:36:27 +03:00
Chaim Lev-Ari
f24555c6c9 feat(ui): add confirmation to delete actions [EE-4612] (#10002) 2023-08-19 19:18:58 +03:00
cmeng
1c79f10ae8 fix(migrator): prevent duplicated migration EE-5777 (#10076) 2023-08-18 21:40:42 +12:00
Chaim Lev-Ari
dc76900a28 feat(edge/stacks): reload edge stacks from server [EE-5970] (#10062) 2023-08-17 14:09:43 +03:00
cmeng
74eeb9da06 fix(datatable): image page not loading image list EE-5978 (#10070) 2023-08-17 09:53:25 +12:00
Chaim Lev-Ari
77120abf33 fix(edge/groups): filter selected environments [EE-5891] (#10016) 2023-08-16 12:24:43 +03:00
Chaim Lev-Ari
dffdf6783c fix(edge/stacks): show pending envs [EE-5913] (#10051) 2023-08-16 10:22:37 +03:00
Ali
55236129ea fix(ingress): empty initial selection + fixes [EE-5852] (#10067)
Co-authored-by: testa113 <testa113>
2023-08-16 18:07:49 +12:00
Ali
d54dd47b21 fix(environments): fix env table [EE-5971] (#10060)
Co-authored-by: testa113 <testa113>
2023-08-16 13:21:16 +12:00
Prabhat Khera
360969c93e fix edit namespace resource quota issue (#10063) 2023-08-16 10:24:55 +12:00
Chaim Lev-Ari
3ea6d2b9d9 feat(edge/configs): add context help [EE-5963] (#10054) 2023-08-15 18:46:53 +03:00
Chaim Lev-Ari
577a36e04e fix(edge/devices): search waiting room devices [EE-5895] (#10015) 2023-08-15 06:05:14 +03:00
matias-portainer
6aa978d5e9 fix(authentication): allow whitespaces when loading AD OU name EE-5206 (#9978) 2023-08-14 12:18:21 -03:00
matias-portainer
0b8d72bfd4 fix(edge/stacks): add pagination to environments list EE-5908 (#10043) 2023-08-14 12:16:49 -03:00
Chaim Lev-Ari
faa1387110 feat(edge/stacks): info for old agent status [EE-5792] (#10012) 2023-08-14 16:04:20 +03:00
Ali
f5cc245c63 fix(app): use correct withCurrentUser wrapper [EE-5928] (#10041)
Co-authored-by: testa113 <testa113>
2023-08-14 16:53:36 +12:00
cmeng
20c6965ce0 fix(stack): fail to start swarm stack with private image EE-4797 (#10046) 2023-08-14 16:13:15 +12:00
Ali
53679f9381 fix(microk8s): PO ui fixes [EE-5900] (#10032)
Co-authored-by: testa113 <testa113>
2023-08-14 12:35:03 +12:00
andres-portainer
e1951baac0 fix(unpacker): implement unpacker error parsing EE-5779 (#10006) 2023-08-10 10:26:09 -03:00
Oscar Zhou
187ec2aa9a fix(stagger): introduce stack version into DeploymentInfo struct (#10027) 2023-08-10 11:58:47 +12:00
matias-portainer
125db4f0de fix(edge/stacks): fix UI issues EE-5844 (#10022) 2023-08-09 10:09:15 -03:00
cmeng
59be96e9e8 fix(edge-stack): detaching swarm stack from git repository EE-5812 (#9997) 2023-08-07 10:33:08 +12:00
Oscar Zhou
d3420f39c1 fix(react/datatable): override getColumnCanGlobalFilter method (#9991) 2023-08-07 10:30:31 +12:00
cmeng
004c86578d fix(edge-stack): detaching from git repository EE-5812 (#9988) 2023-08-04 15:17:51 +12:00
cmeng
b3d404b378 fix(registry): registry login failure for regular stack EE-5832 (#9985) 2023-08-04 15:17:04 +12:00
Ali
82faf20c68 fix(app): update summary with ingresses [EE-5847] (#9974)
Co-authored-by: testa113 <testa113>
2023-08-04 13:48:18 +12:00
Chaim Lev-Ari
18e40cd973 fix(home): empty default sort [EE-5822] (#9950) 2023-08-03 16:21:00 -03:00
Chaim Lev-Ari
9c4d512a4c fix(docker/images): show empty size cell [EE-5823] (#9953) 2023-08-03 16:19:50 -03:00
Ali
ce5c38f841 fix(ingress): ingress ui feedback [EE-5852] (#9983)
Co-authored-by: testa113 <testa113>
2023-08-03 23:03:07 +12:00
cmeng
dbb79a181e fix(edge-stack): unable to edit edge stack EE-5845 (#9980) 2023-08-03 17:20:56 +12:00
matias-portainer
2177c27dc4 fix(endpoints): fix nil pointer dereference EE-5843 (#9970) 2023-08-02 11:06:43 -03:00
Matt Hook
bfdd72d644 show kube icon for custom template (#9967) 2023-08-02 09:43:39 +12:00
Ali
998bf481f7 fix(ingress): loading and ui fixes [EE-5132] (#9960) 2023-08-01 19:31:29 +12:00
Matt Hook
c97ef40cc0 bump compose to 2.20.2 (#9965) 2023-08-01 12:27:28 +12:00
Ali
cbae7bdf82 fix(app): improve perceived ingress load time [EE-5805] (#9948)
Co-authored-by: testa113 <testa113>
2023-07-31 20:18:52 +12:00
cmeng
f4ec4d6175 fix(stack): update gitops updates tooltip EE-5827 (#9961) 2023-07-31 18:46:04 +12:00
Prabhat Khera
ec39d5a88e upgrade helm binary to v3.12.2 (#9264) 2023-07-28 15:06:53 +12:00
Matt Hook
d0d9c2a93b post po review changes (#9265) 2023-07-28 07:53:21 +12:00
Ali
73010efd8d fix(UI): PO review tweaks [EE-5776] (#9268)
Co-authored-by: testa113 <testa113>
2023-07-28 07:50:46 +12:00
Dakota Walsh
88de50649f fix(metrics): node chart race condition EE-5447 (#9252) 2023-07-27 11:46:46 +12:00
Dakota Walsh
fc89066846 fix(jwt): replace deprecated gorilla/securecookie [EE-5153] (#9262) 2023-07-27 09:44:43 +12:00
304 changed files with 4006 additions and 3862 deletions

View File

@@ -1,11 +0,0 @@
body:
- type: markdown
attributes:
value: |
Before asking a question, make sure it hasn't been already asked and answered. You can search our [discussions](https://github.com/orgs/portainer/discussions) and [bug reports](https://github.com/portainer/portainer/issues) in GitHub. Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io/) first.
- type: textarea
attributes:
label: Ask a Question!
validations:
required: true

View File

@@ -1,38 +0,0 @@
body:
- type: markdown
attributes:
value: |
# Welcome!
Thanks for suggesting an idea for Portainer!
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion cagetory](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
**DO NOT FILE DUPLICATE REQUESTS.**
- type: textarea
attributes:
label: Is your feature request related to a problem? Please describe
description: Short list of what the feature request aims to address.
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true
- type: textarea
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false

54
.github/ISSUE_TEMPLATE/Bug_report.md vendored Normal file
View File

@@ -0,0 +1,54 @@
---
name: Bug report
about: Create a bug report
title: ''
labels: bug/need-confirmation, kind/bug
assignees: ''
---
<!--
Thanks for reporting a bug for Portainer !
You can find more information about Portainer support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/
Do you need help or have a question? Come chat with us on Slack https://portainer.io/slack/
Before opening a new issue, make sure that we do not have any duplicates
already open. You can ensure this by searching the issue list for this
repository. If there is a duplicate, please close your issue and add a comment
to the existing issue instead.
Also, be sure to check our FAQ and documentation first: https://documentation.portainer.io/
-->
**Bug description**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Portainer Logs**
Provide the logs of your Portainer container or Service.
You can see how [here](https://documentation.portainer.io/r/portainer-logs)
**Steps to reproduce the issue:**
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Technical details:**
- Portainer version:
- Docker version (managed by Portainer):
- Kubernetes version (managed by Portainer):
- Platform (windows/linux):
- Command used to start Portainer (`docker run -p 9443:9443 portainer/portainer`):
- Browser:
- Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commercial setup.
- Have you reviewed our technical documentation and knowledge base? Yes/No
**Additional context**
Add any other context about the problem here.

View File

@@ -1,164 +0,0 @@
name: Bug Report
description: Create a report to help us improve.
labels: kind/bug,bug/need-confirmation
body:
- type: markdown
attributes:
value: |
# Welcome!
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).
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
- type: checkboxes
id: terms
attributes:
label: Before you start please confirm the following.
options:
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
required: true
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io) or [knowledge base](https://portal.portainer.io/knowledge).
required: true
- type: markdown
attributes:
value: |
# About your issue
Tell us a bit about the issue you're having.
How to write a good bug report:
- Respect the issue template as much as possible.
- Summarize the issue so that we understand what is going wrong.
- Describe what you would have expected to have happened, and what actually happened instead.
- Provide easy to follow steps to reproduce the issue.
- Remain clear and concise.
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown).
- type: textarea
attributes:
label: Problem Description
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: A clear and concise description of what actually happens.
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: Please be as detailed as possible when providing steps to reproduce.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Portainer logs or screenshots
description: Provide Portainer container logs or any screenshots related to the issue.
validations:
required: false
- type: markdown
attributes:
value: |
# About your environment
Tell us a bit about your Portainer environment.
- 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.
multiple: false
options:
- '2.18.4'
- '2.18.3'
- '2.18.2'
- '2.18.1'
- '2.17.1'
- '2.17.0'
- '2.16.2'
- '2.16.1'
- '2.16.0'
- '2.15.1'
- '2.15.0'
validations:
required: true
- type: dropdown
attributes:
label: Portainer Edition
multiple: false
options:
- 'Business Edition (BE/EE) with 5NF / 3NF license'
- 'Business Edition (BE/EE) with Home & Student license'
- 'Business Edition (BE/EE) with Starter license'
- 'Business Edition (BE/EE) with Professional or Enterprise license'
- 'Community Edition (CE)'
validations:
required: true
- type: input
attributes:
label: Platform and Version
description: |
Enter your container management platform (Docker | Swarm | Kubernetes) along with the version.
Example: Docker 24.0.3 | Docker Swarm 24.0.3 | Kubernetes 1.26
You can find our supported platforms [in our documentation](https://docs.portainer.io/start/requirements-and-prerequisites).
validations:
required: true
- type: input
attributes:
label: OS and Architecture
description: |
Enter your Operating System, Version and Architecture. Example: Ubuntu 22.04, AMD64 | Raspbian OS, ARM64
validations:
required: true
- type: input
attributes:
label: Browser
description: |
Enter your browser and version. Example: Google Chrome 114.0
validations:
required: false
- type: textarea
attributes:
label: What command did you use to deploy Portainer?
description: |
Example: `docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest`
If you deployed Portainer using a compose file or manifest you can provide this here as well.
render: bash
validations:
required: false
- type: textarea
attributes:
label: Additional Information
description: Any additional information about your environment, the bug, or anything else you think might be helpful.
validations:
required: false

View File

@@ -1,11 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Question
url: https://github.com/orgs/portainer/discussions/new?category=help
about: Ask us a question about Portainer usage or deployment.
- name: Idea or Feature Request
url: https://github.com/orgs/portainer/discussions/new?category=ideas
about: Suggest an idea or feature/enhancement that should be added in Portainer.
- name: Portainer Business Edition - Get 3 Nodes Free
url: https://www.portainer.io/take-3
- name: Portainer Business Edition - Get 3 nodes free
url: https://www.portainer.io/take-3
about: Portainer Business Edition has more features, more support and you can now get 3 nodes free for as long as you want.

View File

@@ -41,6 +41,6 @@ jobs:
- name: GolangCI-Lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.52.2
version: v1.54.1
working-directory: api
args: --timeout=10m -c .golangci.yaml

View File

@@ -1,8 +1,7 @@
name: Close Stale Issues
on:
schedule:
- cron: '0 12 * * *'
workflow_dispatch:
- cron: '0 12 * * *'
jobs:
stale:
runs-on: ubuntu-latest
@@ -10,7 +9,7 @@ jobs:
issues: write
steps:
- uses: actions/stale@v8
- uses: actions/stale@v4.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -13,7 +13,7 @@ jobs:
- run: yarn --frozen-lockfile
- name: Run tests
run: make test-client ARGS="--maxWorkers=2"
run: yarn jest --maxWorkers=2
test-server:
runs-on: ubuntu-latest
steps:

View File

@@ -65,7 +65,7 @@ clean: ## Remove all build and download artifacts
test: test-server test-client ## Run all tests
test-client: ## Run client tests
yarn test $(ARGS)
yarn test
test-server: ## Run server tests
cd api && $(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...

View File

@@ -10,17 +10,17 @@ linters:
- exportloopref
linters-settings:
depguard:
list-type: denylist
include-go-root: true
packages:
- github.com/sirupsen/logrus
- golang.org/x/exp
packages-with-error-message:
- github.com/sirupsen/logrus: 'logging is allowed only by github.com/rs/zerolog'
ignore-file-rules:
- '**/*_test.go'
- '**/base.go'
- '**/base_tx.go'
rules:
main:
deny:
- 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'
files:
- '!**/*_test.go'
- '!**/base.go'
- '!**/base_tx.go'
# errorlint is causing a typecheck error for some reason. The go compiler will report these
# anyway, so ignore them from the linter

View File

@@ -30,6 +30,7 @@ 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.

View File

@@ -75,10 +75,11 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("start")
Msg("KeepTunnelAlive: start")
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
@@ -91,13 +92,13 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("ping agent")
Msg("KeepTunnelAlive: ping agent")
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("tunnel keep alive timeout")
Msg("KeepTunnelAlive: tunnel keep alive timeout")
return
case <-ctx.Done():
@@ -105,7 +106,7 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("tunnel stop")
Msg("KeepTunnelAlive: tunnel stop")
return
}
@@ -126,8 +127,8 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotService por
}
config := &chserver.Config{
Reverse: true,
KeyFile: privateKeyFile,
Reverse: true,
PrivateKeyFile: privateKeyFile,
}
chiselServer, err := chserver.NewServer(config)

View File

@@ -49,7 +49,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
Rollback: kingpin.Flag("rollback", "Rollback the database store to the previous version").Bool(),
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),

View File

@@ -9,7 +9,7 @@ import (
// Confirm starts a rollback db cli application
func Confirm(message string) (bool, error) {
fmt.Printf("%s [y/N]", message)
fmt.Printf("%s [y/N] ", message)
reader := bufio.NewReader(os.Stdin)

View File

@@ -39,9 +39,9 @@ func setLoggingMode(mode string) {
case "PRETTY":
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
NoColor: true,
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
})
FormatMessage: formatMessage})
case "JSON":
log.Logger = log.Output(os.Stderr)
}
@@ -51,6 +51,5 @@ func formatMessage(i interface{}) string {
if i == nil {
return ""
}
return fmt.Sprintf("%s |", i)
}

View File

@@ -157,6 +157,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
return store
}
// checkDBSchemaServerVersionMatch checks if the server version matches the db scehma version
func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersion string, serverEdition int) bool {
v, err := dbStore.Version().Version()
if err != nil {
return false
}
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 {
@@ -388,6 +398,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("")
}
// check if the db schema version matches with server version
if !checkDBSchemaServerVersionMatch(dataStore, portainer.APIVersion, int(portainer.Edition)) {
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
}
instanceID, err := dataStore.Version().InstanceID()
if err != nil {
log.Fatal().Err(err).Msg("failed getting instance id")

View File

@@ -5,6 +5,7 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
// BucketName represents the name of the bucket where this service stores data.
@@ -144,6 +145,23 @@ func (service *Service) Create(endpoint *portainer.Endpoint) error {
})
}
func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
return endpoints, service.connection.GetAll(
BucketName,
&portainer.Endpoint{},
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
for t := range e.TeamAccessPolicies {
if t == teamID {
return true
}
}
return false
}),
)
}
// GetNextIdentifier returns the next identifier for an environment(endpoint).
func (service *Service) GetNextIdentifier() int {
var identifier int

View File

@@ -122,6 +122,23 @@ func (service ServiceTx) Create(endpoint *portainer.Endpoint) error {
return nil
}
func (service ServiceTx) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
return endpoints, service.tx.GetAll(
BucketName,
&portainer.Endpoint{},
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
for t := range e.TeamAccessPolicies {
if t == teamID {
return true
}
}
return false
}),
)
}
// GetNextIdentifier returns the next identifier for an environment(endpoint).
func (service ServiceTx) GetNextIdentifier() int {
return service.tx.GetNextIdentifier(BucketName)

View File

@@ -89,6 +89,7 @@ type (
EndpointService interface {
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
Heartbeat(endpointID portainer.EndpointID) (int64, bool)
UpdateHeartbeat(endpointID portainer.EndpointID)
Endpoints() ([]portainer.Endpoint, error)

View File

@@ -1,189 +1,77 @@
package datastore
import (
"fmt"
"os"
"path"
"time"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
)
var backupDefaults = struct {
backupDir string
commonDir string
}{
"backups",
"common",
func (store *Store) Backup() (string, error) {
if err := store.createBackupPath(); err != nil {
return "", err
}
backupFilename := store.backupFilename()
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
err := store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true)
if err != nil {
log.Warn().Err(err).Msg("failed to create backup file")
return "", err
}
return backupFilename, nil
}
//
// Backup Helpers
//
func (store *Store) Restore() error {
backupFilename := store.backupFilename()
return store.RestoreFromFile(backupFilename)
}
// createBackupFolders create initial folders for backups
func (store *Store) createBackupFolders() {
// create common dir
commonDir := store.commonBackupDir()
if exists, _ := store.fileService.FileExists(commonDir); !exists {
if err := os.MkdirAll(commonDir, 0700); err != nil {
log.Error().Err(err).Msg("error while creating common backup folder")
func (store *Store) RestoreFromFile(backupFilename string) error {
if exists, _ := store.fileService.FileExists(backupFilename); !exists {
log.Error().Str("backupFilename", backupFilename).Msg("backup file does not exist")
return os.ErrNotExist
}
if err := store.fileService.Copy(backupFilename, store.connection.GetDatabaseFilePath(), true); err != nil {
log.Error().Err(err).Msg("error while restoring backup.")
return err
}
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("database restored")
// determine the db version
store.Open()
version, err := store.VersionService.Version()
edition := "CE"
if version.Edition == 2 {
edition = "EE"
}
if err == nil {
log.Info().Str("version", version.SchemaVersion).Msgf("Restored database version: Portainer %s %s", edition, version.SchemaVersion)
}
return nil
}
func (store *Store) createBackupPath() error {
backupDir := path.Join(store.connection.GetStorePath(), "backups")
if exists, _ := store.fileService.FileExists(backupDir); !exists {
if err := os.MkdirAll(backupDir, 0700); err != nil {
log.Error().Err(err).Msg("error while creating backup folder")
return err
}
}
return nil
}
func (store *Store) backupFilename() string {
return path.Join(store.connection.GetStorePath(), "backups", store.connection.GetDatabaseFileName()+".bak")
}
func (store *Store) databasePath() string {
return store.connection.GetDatabaseFilePath()
}
func (store *Store) commonBackupDir() string {
return path.Join(store.connection.GetStorePath(), backupDefaults.backupDir, backupDefaults.commonDir)
}
func (store *Store) copyDBFile(from string, to string) error {
log.Info().Str("from", from).Str("to", to).Msg("copying DB file")
err := store.fileService.Copy(from, to, true)
if err != nil {
log.Error().Err(err).Msg("failed")
}
return err
}
// BackupOptions provide a helper to inject backup options
type BackupOptions struct {
Version string
BackupDir string
BackupFileName string
BackupPath string
}
// getBackupRestoreOptions returns options to store db at common backup dir location; used by:
// - db backup prior to version upgrade
// - db rollback
func getBackupRestoreOptions(backupDir string) *BackupOptions {
return &BackupOptions{
BackupDir: backupDir, //connection.commonBackupDir(),
BackupFileName: beforePortainerVersionUpgradeBackup,
}
}
// Backup current database with default options
func (store *Store) Backup(version *models.Version) (string, error) {
if version == nil {
return store.backupWithOptions(nil)
}
return store.backupWithOptions(&BackupOptions{
Version: version.SchemaVersion,
})
}
func (store *Store) setupOptions(options *BackupOptions) *BackupOptions {
if options == nil {
options = &BackupOptions{}
}
if options.Version == "" {
v, err := store.VersionService.Version()
if err != nil {
options.Version = ""
}
options.Version = v.SchemaVersion
}
if options.BackupDir == "" {
options.BackupDir = store.commonBackupDir()
}
if options.BackupFileName == "" {
options.BackupFileName = fmt.Sprintf("%s.%s.%s", store.connection.GetDatabaseFileName(), options.Version, time.Now().Format("20060102150405"))
}
if options.BackupPath == "" {
options.BackupPath = path.Join(options.BackupDir, options.BackupFileName)
}
return options
}
// BackupWithOptions backup current database with options
func (store *Store) backupWithOptions(options *BackupOptions) (string, error) {
log.Info().Msg("creating DB backup")
store.createBackupFolders()
options = store.setupOptions(options)
dbPath := store.databasePath()
if err := store.Close(); err != nil {
return options.BackupPath, fmt.Errorf(
"error closing datastore before creating backup: %w",
err,
)
}
if err := store.copyDBFile(dbPath, options.BackupPath); err != nil {
return options.BackupPath, err
}
if _, err := store.Open(); err != nil {
return options.BackupPath, fmt.Errorf(
"error opening datastore after creating backup: %w",
err,
)
}
return options.BackupPath, nil
}
// RestoreWithOptions previously saved backup for the current Edition with options
// Restore strategies:
// - default: restore latest from current edition
// - restore a specific
func (store *Store) restoreWithOptions(options *BackupOptions) error {
options = store.setupOptions(options)
// Check if backup file exist before restoring
_, err := os.Stat(options.BackupPath)
if os.IsNotExist(err) {
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to restore does not exist %s")
return err
}
err = store.Close()
if err != nil {
log.Error().Err(err).Msg("error while closing store before restore")
return err
}
log.Info().Msg("restoring DB backup")
err = store.copyDBFile(options.BackupPath, store.databasePath())
if err != nil {
return err
}
_, err = store.Open()
return err
}
// RemoveWithOptions removes backup database based on supplied options
func (store *Store) removeWithOptions(options *BackupOptions) error {
log.Info().Msg("removing DB backup")
options = store.setupOptions(options)
_, err := os.Stat(options.BackupPath)
if os.IsNotExist(err) {
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to remove does not exist")
return err
}
log.Info().Str("path", options.BackupPath).Msg("removing DB file")
err = os.Remove(options.BackupPath)
if err != nil {
log.Error().Err(err).Msg("failed")
return err
}
return nil
}

View File

@@ -2,106 +2,79 @@ package datastore
import (
"fmt"
"os"
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
)
func TestCreateBackupFolders(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
connection := store.GetConnection()
backupPath := path.Join(connection.GetStorePath(), backupDefaults.backupDir)
if isFileExist(backupPath) {
t.Error("Expect backups folder to not exist")
}
store.createBackupFolders()
if !isFileExist(backupPath) {
t.Error("Expect backups folder to exist")
}
}
func TestStoreCreation(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
if store == nil {
t.Error("Expect to create a store")
t.Fatal("Expect to create a store")
}
if store.CheckCurrentEdition() != nil {
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if portainer.SoftwareEdition(v.Edition) != portainer.PortainerCE {
t.Error("Expect to get CE Edition")
}
if v.SchemaVersion != portainer.APIVersion {
t.Error("Expect to get APIVersion")
}
}
func TestBackup(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
connection := store.GetConnection()
t.Run("Backup should create default db backup", func(t *testing.T) {
backupFileName := store.backupFilename()
t.Run(fmt.Sprintf("Backup should create %s", backupFileName), func(t *testing.T) {
v := models.Version{
Edition: int(portainer.PortainerCE),
SchemaVersion: portainer.APIVersion,
}
store.VersionService.UpdateVersion(&v)
store.backupWithOptions(nil)
store.Backup()
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", fmt.Sprintf("portainer.edb.%s.*", portainer.APIVersion))
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
}
})
t.Run("BackupWithOption should create a name specific backup at common path", func(t *testing.T) {
store.backupWithOptions(&BackupOptions{
BackupFileName: beforePortainerVersionUpgradeBackup,
BackupDir: store.commonBackupDir(),
})
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", beforePortainerVersionUpgradeBackup)
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
}
})
}
func TestRemoveWithOptions(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
func TestRestore(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
t.Run("successfully removes file if existent", func(t *testing.T) {
store.createBackupFolders()
options := &BackupOptions{
BackupDir: store.commonBackupDir(),
BackupFileName: "test.txt",
}
t.Run(fmt.Sprintf("Basic Restore"), func(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
filePath := path.Join(options.BackupDir, options.BackupFileName)
f, err := os.Create(filePath)
if err != nil {
t.Fatalf("file should be created; err=%s", err)
}
f.Close()
store.Backup()
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err = store.removeWithOptions(options)
if err != nil {
t.Errorf("RemoveWithOptions should successfully remove file; err=%v", err)
}
if isFileExist(f.Name()) {
t.Errorf("RemoveWithOptions should successfully remove file; file=%s", f.Name())
}
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
})
t.Run("fails to removes file if non-existent", func(t *testing.T) {
options := &BackupOptions{
BackupDir: store.commonBackupDir(),
BackupFileName: "test.txt",
}
t.Run(fmt.Sprintf("Basic Restore After Multiple Backups"), func(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup()
updateVersion(store, "2.14")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err := store.removeWithOptions(options)
if err == nil {
t.Error("RemoveWithOptions should fail for non-existent file")
}
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
})
}

View File

@@ -31,8 +31,14 @@ func (store *Store) Open() (newStore bool, err error) {
}
if encryptionReq {
backupFilename, err := store.Backup()
if err != nil {
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
}
err = store.encryptDB()
if err != nil {
store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
return false, err
}
}

View File

@@ -0,0 +1,68 @@
package datastore
import (
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
// isFileExist is helper function to check for file existence
func isFileExist(path string) bool {
matches, err := filepath.Glob(path)
if err != nil {
return false
}
return len(matches) > 0
}
func updateVersion(store *Store, v string) {
version, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
version.SchemaVersion = v
err = store.VersionService.UpdateVersion(version)
if err != nil {
log.Fatal().Err(err).Msg("")
}
}
func updateEdition(store *Store, edition portainer.SoftwareEdition) {
version, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
version.Edition = int(edition)
err = store.VersionService.UpdateVersion(version)
if err != nil {
log.Fatal().Err(err).Msg("")
}
}
// testVersion is a helper which tests current store version against wanted version
func testVersion(store *Store, versionWant string, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if v.SchemaVersion != versionWant {
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
}
}
func testEdition(store *Store, editionWant portainer.SoftwareEdition, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if portainer.SoftwareEdition(v.Edition) != editionWant {
t.Errorf("Expect store edition to be %s but was %s", editionWant.GetEditionLabel(), portainer.SoftwareEdition(v.Edition).GetEditionLabel())
}
}

View File

@@ -2,6 +2,7 @@ package datastore
import (
"fmt"
"os"
"runtime/debug"
portainer "github.com/portainer/portainer/api"
@@ -15,8 +16,6 @@ import (
"github.com/rs/zerolog/log"
)
const beforePortainerVersionUpgradeBackup = "portainer.db.bak"
func (store *Store) MigrateData() error {
updating, err := store.VersionService.IsUpdating()
if err != nil {
@@ -41,7 +40,7 @@ func (store *Store) MigrateData() error {
}
// before we alter anything in the DB, create a backup
backupPath, err := store.Backup(version)
_, err = store.Backup()
if err != nil {
return errors.Wrap(err, "while backing up database")
}
@@ -51,9 +50,9 @@ func (store *Store) MigrateData() error {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if err != nil {
return errors.Wrap(err, "failed to restore database")
restoreErr := store.Restore()
if restoreErr != nil {
return errors.Wrap(restoreErr, "failed to restore database")
}
log.Info().Msg("database restored to previous version")
@@ -117,6 +116,11 @@ func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version *models
return err
}
// Special test code to simulate a failure (used by migrate_data_test.go). Do not remove...
if os.Getenv("PORTAINER_TEST_MIGRATE_FAIL") == "FAIL" {
panic("test migration failure")
}
err = store.VersionService.StoreIsUpdating(false)
if err != nil {
return errors.Wrap(err, "failed to update the store")
@@ -135,9 +139,7 @@ func (store *Store) connectionRollback(force bool) error {
}
}
options := getBackupRestoreOptions(store.commonBackupDir())
err := store.restoreWithOptions(options)
err := store.Restore()
if err != nil {
return err
}

View File

@@ -7,29 +7,20 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/rs/zerolog/log"
"github.com/google/go-cmp/cmp"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
)
// testVersion is a helper which tests current store version against wanted version
func testVersion(store *Store, versionWant string, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Expect store version to be %s but was %s with error: %s", versionWant, v.SchemaVersion, err)
}
if v.SchemaVersion != versionWant {
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
}
}
func TestMigrateData(t *testing.T) {
snapshotTests := []struct {
tests := []struct {
testName string
srcPath string
wantPath string
@@ -42,7 +33,7 @@ func TestMigrateData(t *testing.T) {
overrideInstanceId: true,
},
}
for _, test := range snapshotTests {
for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
err := migrateDBTestHelper(t, test.srcPath, test.wantPath, test.overrideInstanceId)
if err != nil {
@@ -55,147 +46,133 @@ func TestMigrateData(t *testing.T) {
})
}
// t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) {
// newStore, store, teardown := MustNewTestStore(t, true, false)
// defer teardown()
t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) {
newStore, store := MustNewTestStore(t, true, false)
if !newStore {
t.Error("Expect a new DB")
}
// if !newStore {
// t.Error("Expect a new DB")
// }
testVersion(store, portainer.APIVersion, t)
store.Close()
// testVersion(store, portainer.APIVersion, t)
// store.Close()
newStore, _ = store.Open()
if newStore {
t.Error("Expect store to NOT be new DB")
}
})
// newStore, _ = store.Open()
// if newStore {
// t.Error("Expect store to NOT be new DB")
// }
// })
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "1.0", Edition: int(portainer.PortainerCE)})
store.MigrateData()
// tests := []struct {
// version string
// expectedVersion string
// }{
// {version: "1.24.1", expectedVersion: portainer.APIVersion},
// {version: "2.0.0", expectedVersion: portainer.APIVersion},
// }
// for _, tc := range tests {
// _, store, teardown := MustNewTestStore(t, true, true)
// defer teardown()
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
t.Errorf("Expect backup file to be created %s", backupfilename)
}
})
// // Setup data
// v := models.Version{SchemaVersion: tc.version}
// store.VersionService.UpdateVersion(&v)
t.Run("MigrateData should recover and restore backup during migration critical failure", func(t *testing.T) {
os.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL")
// // Required roles by migrations 22.2
// store.RoleService.Create(&portainer.Role{ID: 1})
// store.RoleService.Create(&portainer.Role{ID: 2})
// store.RoleService.Create(&portainer.Role{ID: 3})
// store.RoleService.Create(&portainer.Role{ID: 4})
version := "2.15"
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
store.MigrateData()
// t.Run(fmt.Sprintf("MigrateData for version %s", tc.version), func(t *testing.T) {
// store.MigrateData()
// testVersion(store, tc.expectedVersion, t)
// })
store.Open()
testVersion(store, version, t)
})
// t.Run(fmt.Sprintf("Restoring DB after migrateData for version %s", tc.version), func(t *testing.T) {
// store.Rollback(true)
// store.Open()
// testVersion(store, tc.version, t)
// })
// }
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.StoreIsUpdating(true)
store.MigrateData()
// t.Run("Error in MigrateData should restore backup before MigrateData", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); exists {
t.Errorf("Backup file should not exist for dirty database")
}
})
// v := models.Version{SchemaVersion: "1.24.1"}
// store.VersionService.UpdateVersion(&v)
t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
// store.MigrateData()
// Set migrator the count to match our migrations array (simulate no changes).
// Should not create a backup
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Unable to read version from db: %s", err)
t.FailNow()
}
// testVersion(store, v.SchemaVersion, t)
// })
migratorParams := store.newMigratorParameters(v)
m := migrator.NewMigrator(migratorParams)
latestMigrations := m.LatestMigrations()
// t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
v.MigratorCount = len(latestMigrations.MigrationFuncs)
store.VersionService.UpdateVersion(v)
}
// v := models.Version{SchemaVersion: "0.0.0"}
// store.VersionService.UpdateVersion(&v)
store.MigrateData()
// store.MigrateData()
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); exists {
t.Errorf("Backup file should not exist for dirty database")
}
})
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
t.Run("MigrateData should create backup on startup if portainer version matches db and migrationFuncs counts differ", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
// if !isFileExist(options.BackupPath) {
// t.Errorf("Backup file should exist; file=%s", options.BackupPath)
// }
// })
// Set migrator count very large to simulate changes
// Should not create a backup
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Unable to read version from db: %s", err)
t.FailNow()
}
// t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
v.MigratorCount = 1000
store.VersionService.UpdateVersion(v)
store.MigrateData()
// store.VersionService.StoreIsUpdating(true)
// store.MigrateData()
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
// if isFileExist(options.BackupPath) {
// t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
// }
// })
// t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
// store.MigrateData()
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
// if isFileExist(options.BackupPath) {
// t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
// }
// })
}
func Test_getBackupRestoreOptions(t *testing.T) {
_, store := MustNewTestStore(t, false, true)
options := getBackupRestoreOptions(store.commonBackupDir())
wantDir := store.commonBackupDir()
if !strings.HasSuffix(options.BackupDir, wantDir) {
log.Fatal().Str("got", options.BackupDir).Str("want", wantDir).Msg("incorrect backup dir")
}
wantFilename := "portainer.db.bak"
if options.BackupFileName != wantFilename {
log.Fatal().Str("got", options.BackupFileName).Str("want", wantFilename).Msg("incorrect backup file")
}
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
t.Errorf("DB backup should exist and there should be no error")
}
})
}
func TestRollback(t *testing.T) {
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := models.Version{SchemaVersion: "2.4.0"}
_, store := MustNewTestStore(t, true, false)
version := "2.11"
err := store.VersionService.UpdateVersion(&version)
if err != nil {
t.Errorf("Failed updating version: %v", err)
v := models.Version{
SchemaVersion: version,
}
_, err = store.backupWithOptions(getBackupRestoreOptions(store.commonBackupDir()))
_, store := MustNewTestStore(t, false, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
if err != nil {
log.Fatal().Err(err).Msg("")
}
// Change the current version
version2 := models.Version{SchemaVersion: "2.6.0"}
err = store.VersionService.UpdateVersion(&version2)
v.SchemaVersion = "2.14"
// Change the current edition
err = store.VersionService.UpdateVersion(&v)
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -207,26 +184,45 @@ func TestRollback(t *testing.T) {
return
}
_, err = store.Open()
store.Open()
testVersion(store, version, t)
})
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := "2.15"
v := models.Version{
SchemaVersion: version,
Edition: int(portainer.PortainerCE),
}
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
if err != nil {
t.Logf("Open failed: %s", err)
log.Fatal().Err(err).Msg("")
}
v.SchemaVersion = "2.14"
// Change the current edition
err = store.VersionService.UpdateVersion(&v)
if err != nil {
log.Fatal().Err(err).Msg("")
}
err = store.Rollback(true)
if err != nil {
t.Logf("Rollback failed: %s", err)
t.Fail()
return
}
testVersion(store, version.SchemaVersion, t)
store.Open()
testVersion(store, version, t)
})
}
// isFileExist is helper function to check for file existence
func isFileExist(path string) bool {
matches, err := filepath.Glob(path)
if err != nil {
return false
}
return len(matches) > 0
}
// migrateDBTestHelper loads a json representation of a bolt database from srcPath,
// parses it into a database, runs a migration on that database, and then
// compares it with an expected output database.

View File

@@ -1,7 +1,7 @@
package datastore
import (
portaineree "github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
)
@@ -72,7 +72,7 @@ func dbVersionToSemanticVersion(dbVersion int) string {
func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
// Very old versions of portainer did not have a version bucket, lets set some defaults
dbVersion := 24
edition := int(portaineree.PortainerCE)
edition := int(portainer.PortainerCE)
instanceId := ""
// If we already have a version key, we don't need to migrate

View File

@@ -944,6 +944,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.19.4\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -302,6 +302,38 @@ func (service *Service) UpdateStoreStackFileFromBytes(stackIdentifier, fileName
return service.wrapFileStore(stackStorePath), nil
}
// UpdateStoreStackFileFromBytesByVersion makes stack file backup and updates a new file from bytes.
// It returns the path to the folder where the file is stored.
func (service *Service) UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error) {
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)
versionStr := ""
if version != 0 {
versionStr = fmt.Sprintf("v%d", version)
}
if commitHash != "" {
versionStr = commitHash
}
if versionStr != "" {
stackStorePath = JoinPaths(stackStorePath, versionStr)
}
composeFilePath := JoinPaths(stackStorePath, fileName)
err := service.createBackupFileInStore(composeFilePath)
if err != nil {
return "", err
}
r := bytes.NewReader(data)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
return "", err
}
return service.wrapFileStore(stackStorePath), nil
}
// RemoveStackFileBackup removes the stack file backup in the ComposeStorePath.
func (service *Service) RemoveStackFileBackup(stackIdentifier, fileName string) error {
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)

View File

@@ -30,7 +30,7 @@ require (
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0
github.com/jpillora/chisel v1.9.0
github.com/jpillora/chisel v0.0.0-20190724232113-f3a8df20e389
github.com/json-iterator/go v1.1.12
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
github.com/opencontainers/go-digest v1.0.0
@@ -40,7 +40,7 @@ require (
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
github.com/portainer/libhttp v0.0.0-20230615144939-a999f666d9a9
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73
github.com/robfig/cron/v3 v3.0.1
@@ -48,11 +48,11 @@ require (
github.com/stretchr/testify v1.8.2
github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.3.7
golang.org/x/crypto v0.12.0
golang.org/x/crypto v0.7.0
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
golang.org/x/mod v0.9.0
golang.org/x/oauth2 v0.6.0
golang.org/x/sync v0.3.0
golang.org/x/sync v0.1.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.27.4
@@ -109,7 +109,7 @@ require (
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/ansi v1.0.3 // indirect
github.com/jpillora/ansi v1.0.2 // indirect
github.com/jpillora/requestlog v1.0.0 // indirect
github.com/jpillora/sizestr v1.0.0 // indirect
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
@@ -143,10 +143,10 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/term v0.11.0 // indirect
golang.org/x/text v0.12.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect
golang.org/x/tools v0.7.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
@@ -161,3 +161,6 @@ require (
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
// Remove below line when the "determinstic key" patch for Chisel merged
replace github.com/jpillora/chisel => github.com/portainer/chisel v0.0.0-20230704222304-426f515c6c25

View File

@@ -227,10 +227,9 @@ github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/ansi v1.0.3 h1:nn4Jzti0EmRfDxm7JtEs5LzCbNwd5sv+0aE+LdS9/ZQ=
github.com/jpillora/ansi v1.0.3/go.mod h1:D2tT+6uzJvN1nBVQILYWkIdq7zG+b5gcFN5WI/VyjMY=
github.com/jpillora/chisel v1.9.0 h1:pGZuxCZZ3W56Y2wX5bcXUvtB3r6wdaXRruJLAev8xzk=
github.com/jpillora/chisel v1.9.0/go.mod h1:qvgGfFR9ZhiDoYJM4IM1omX1HLbQSkZag8miP9u4SsQ=
github.com/jpillora/ansi v1.0.2 h1:+Ei5HCAH0xsrQRCT2PDr4mq9r4Gm4tg+arNdXRkB22s=
github.com/jpillora/ansi v1.0.2/go.mod h1:D2tT+6uzJvN1nBVQILYWkIdq7zG+b5gcFN5WI/VyjMY=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/jpillora/requestlog v1.0.0 h1:bg++eJ74T7DYL3DlIpiwknrtfdUA9oP/M4fL+PpqnyA=
github.com/jpillora/requestlog v1.0.0/go.mod h1:HTWQb7QfDc2jtHnWe2XEIEeJB7gJPnVdpNn52HXPvy8=
github.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw=
@@ -311,6 +310,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/portainer/chisel v0.0.0-20230704222304-426f515c6c25 h1:OfU9WGqE8bYdKB1dH3jgQpM2tP1+l5wGdNLO8Kk7nww=
github.com/portainer/chisel v0.0.0-20230704222304-426f515c6c25/go.mod h1:jhzGKO7NT6pNc/qto8YrNBGnuWZdqswvY6+n4zwE/Zc=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
github.com/portainer/libhttp v0.0.0-20230615144939-a999f666d9a9 h1:Jq8g/pDcFL1Z/DnZgn6DyaWu29y9+RiB5aOJ/Xw4960=
@@ -319,6 +320,10 @@ github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e1
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146/go.mod h1:x4Lpq/BjFhZmuNB8e8FO0ObRPQ/Z/V9rTe54bMedf1A=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146 h1:1qW7quKyFG4tOnMcnnqyYsDVfL09etO1h/Cu/3ak7KU=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479 h1:DbmhSQZpDo5f0cr+CKLJqoqhQiuxp8QFXdZsjPS1lI4=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce h1:DQTMXYH1zn2DzuAe+4rT40JqdHLhpHHJ2pzRFhvZ/+c=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146 h1:ZGj+j5HoajaO+mXgCm6NzOU+zUdIlJK2amagB+QIDvc=
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146/go.mod h1:+zCK2UbsH6A3yEGi0yZ45ec5VFRP7svob5Q2lW6LFgk=
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 h1:7bPOnwucE0nor0so1BQJxQKCL5t+vCWO4nAz/S0lci0=
@@ -397,8 +402,9 @@ golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
@@ -421,8 +427,9 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
@@ -432,8 +439,9 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -449,6 +457,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -461,17 +471,19 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -24,6 +24,7 @@ type Handler struct {
ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
passwordStrengthChecker security.PasswordStrengthChecker
bouncer security.BouncerService
}
// NewHandler creates a handler to manage authentication operations.
@@ -31,6 +32,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
h := &Handler{
Router: mux.NewRouter(),
passwordStrengthChecker: passwordStrengthChecker,
bouncer: bouncer,
}
h.Handle("/auth/oauth/validate",
@@ -38,7 +40,6 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
h.Handle("/auth",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)
h.Handle("/auth/logout",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
bouncer.PublicAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
return h
}

View File

@@ -5,12 +5,12 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/logoutcontext"
)
// @id Logout
// @summary Logout
// @description **Access policy**: authenticated
// @description **Access policy**: public
// @security ApiKeyAuth
// @security jwt
// @tags auth
@@ -18,12 +18,12 @@ import (
// @failure 500 "Server error"
// @router /auth/logout [post]
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
}
tokenData := handler.bouncer.JWTAuthLookup(r)
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
logoutcontext.Cancel(tokenData.Token)
}
return response.Empty(w)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/edge_stacks/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}",

View File

@@ -66,11 +66,6 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
}
edgeGroups, err := handler.DataStore.EdgeGroup().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve edge groups from the database", err)
}
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environments from the database", err)
@@ -93,7 +88,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, edgeGroups, settings)
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, settings)
if err != nil {
return httperror.InternalServerError("Unable to filter endpoints", err)
}

View File

@@ -12,7 +12,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/slices"
"github.com/portainer/portainer/api/internal/unique"
@@ -119,13 +118,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
}, nil
}
func (handler *Handler) filterEndpointsByQuery(
filteredEndpoints []portainer.Endpoint,
query EnvironmentsQuery,
groups []portainer.EndpointGroup,
edgeGroups []portainer.EdgeGroup,
settings *portainer.Settings,
) ([]portainer.Endpoint, int, error) {
func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.Endpoint, query EnvironmentsQuery, groups []portainer.EndpointGroup, settings *portainer.Settings) ([]portainer.Endpoint, int, error) {
totalAvailableEndpoints := len(filteredEndpoints)
if len(query.endpointIds) > 0 {
@@ -197,7 +190,7 @@ func (handler *Handler) filterEndpointsByQuery(
tagsMap[tag.ID] = tag.Name
}
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, groups, edgeGroups, tagsMap, query.search)
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, groups, tagsMap, query.search)
}
if len(query.types) > 0 {
@@ -292,13 +285,7 @@ func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs
return endpoints[:n]
}
func filterEndpointsBySearchCriteria(
endpoints []portainer.Endpoint,
endpointGroups []portainer.EndpointGroup,
edgeGroups []portainer.EdgeGroup,
tagsMap map[portainer.TagID]string,
searchCriteria string,
) []portainer.Endpoint {
func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) []portainer.Endpoint {
n := 0
for _, endpoint := range endpoints {
endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
@@ -312,15 +299,6 @@ func filterEndpointsBySearchCriteria(
if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, tagsMap, searchCriteria) {
endpoints[n] = endpoint
n++
continue
}
if edgeGroupMatchSearchCriteria(&endpoint, edgeGroups, searchCriteria, endpoints, endpointGroups) {
endpoints[n] = endpoint
n++
continue
}
}
@@ -400,29 +378,6 @@ func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGrou
return false
}
// search endpoint's related edgegroups
func edgeGroupMatchSearchCriteria(
endpoint *portainer.Endpoint,
edgeGroups []portainer.EdgeGroup,
searchCriteria string,
endpoints []portainer.Endpoint,
endpointGroups []portainer.EndpointGroup,
) bool {
for _, edgeGroup := range edgeGroups {
relatedEndpointIDs := edge.EdgeGroupRelatedEndpoints(&edgeGroup, endpoints, endpointGroups)
for _, endpointID := range relatedEndpointIDs {
if endpointID == endpoint.ID {
if strings.Contains(strings.ToLower(edgeGroup.Name), searchCriteria) {
return true
}
}
}
}
return false
}
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []portainer.EndpointType) []portainer.Endpoint {
typeSet := map[portainer.EndpointType]bool{}
for _, endpointType := range endpointTypes {

View File

@@ -158,13 +158,7 @@ func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []po
func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portainer.Endpoint) {
is := assert.New(t)
filteredEndpoints, _, err := handler.filterEndpointsByQuery(
endpoints,
test.query,
[]portainer.EndpointGroup{},
[]portainer.EdgeGroup{},
&portainer.Settings{},
)
filteredEndpoints, _, err := handler.filterEndpointsByQuery(endpoints, test.query, []portainer.EndpointGroup{}, &portainer.Settings{})
is.NoError(err)

View File

@@ -84,7 +84,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.20.0
// @version 2.19.4
// @description.markdown api-description.md
// @termsOfService

View File

@@ -13,6 +13,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -176,6 +177,14 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
handler.KubernetesDeployer,
user)
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, payload.Namespace)
}
stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder)
_, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint)
if httpErr != nil {

View File

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

View File

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

View File

@@ -190,7 +190,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerSwarmStack {
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteSwarmStack(stack, endpoint)
}
@@ -200,7 +200,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerComposeStack {
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint)
}

View File

@@ -141,34 +141,34 @@ func (handler *Handler) startStack(
endpoint *portainer.Endpoint,
securityContext *security.RestrictedRequestContext,
) error {
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return fmt.Errorf("unable to load user information from the database: %w", err)
}
registries, err := handler.DataStore.Registry().ReadAll()
if err != nil {
return fmt.Errorf("unable to retrieve registries from the database: %w", err)
}
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
switch stack.Type {
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint, filteredRegistries)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint)
}
return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint, false)
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint, filteredRegistries)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint)
}
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return fmt.Errorf("unable to load user information from the database: %w", err)
}
registries, err := handler.DataStore.Registry().ReadAll()
if err != nil {
return fmt.Errorf("unable to retrieve registries from the database: %w", err)
}
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
return handler.StackDeployer.DeploySwarmStack(stack, endpoint, filteredRegistries, true, true)
}

View File

@@ -125,7 +125,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint)
}
@@ -133,7 +133,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint)
}

View File

@@ -13,6 +13,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -113,6 +114,14 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err)
}
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, stack.Namespace)
}
//use temp dir as the stack project path for deployment
//so if the deployment failed, the original file won't be over-written
stack.ProjectPath = tempFileDir

View File

@@ -4,8 +4,12 @@ import (
"net/http"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
"github.com/gorilla/mux"
)
@@ -13,7 +17,8 @@ import (
// Handler is the HTTP handler used to handle team membership operations.
type Handler struct {
*mux.Router
DataStore dataservices.DataStore
DataStore dataservices.DataStore
K8sClientFactory *cli.ClientFactory
}
// NewHandler creates a handler to manage team membership operations.
@@ -31,3 +36,27 @@ func NewHandler(bouncer security.BouncerService) *Handler {
return h
}
func (handler *Handler) updateUserServiceAccounts(membership *portainer.TeamMembership) {
endpoints, err := handler.DataStore.Endpoint().EndpointsByTeamID(membership.TeamID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environments for team %d", membership.TeamID)
return
}
for _, endpoint := range endpoints {
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
// update kubernenets service accounts if the team is associated with a kubernetes environment
if endpointutils.IsKubernetesEndpoint(&endpoint) {
kubecli, err := handler.K8sClientFactory.GetKubeClient(&endpoint)
if err != nil {
log.Error().Err(err).Msgf("failed getting kube client for environment %d", endpoint.ID)
continue
}
teamIDs := []int{int(membership.TeamID)}
err = kubecli.SetupUserServiceAccount(int(membership.UserID), teamIDs, restrictDefaultNamespace)
if err != nil {
log.Error().Err(err).Msgf("failed setting-up service account for user %d", membership.UserID)
}
}
}
}

View File

@@ -91,5 +91,7 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist team memberships inside the database", err)
}
defer handler.updateUserServiceAccounts(membership)
return response.JSON(w, membership)
}

View File

@@ -52,5 +52,7 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to remove the team membership from the database", err)
}
defer handler.updateUserServiceAccounts(membership)
return response.Empty(w)
}

View File

@@ -90,5 +90,7 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist membership changes inside the database", err)
}
defer handler.updateUserServiceAccounts(membership)
return response.JSON(w, membership)
}

View File

@@ -22,6 +22,7 @@ var (
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
errCryptoHashFailure = errors.New("Unable to hash data")
errWrongPassword = errors.New("Wrong password")
)
func hideFields(user *portainer.User) {

View File

@@ -10,6 +10,13 @@ import (
"github.com/portainer/portainer/api/http/security"
)
type User struct {
ID portainer.UserID `json:"Id" example:"1"`
Username string `json:"Username" example:"bob"`
// User role (1 for administrator account and 2 for regular account)
Role portainer.UserRole `json:"Role" example:"1"`
}
// @id UserList
// @summary List users
// @description List Portainer users.
@@ -26,24 +33,25 @@ import (
// @failure 500 "Server error"
// @router /users [get]
func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
users, err := handler.DataStore.User().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
availableUsers := security.FilterUsers(users, securityContext)
for i := range availableUsers {
hideFields(&availableUsers[i])
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
return httperror.Forbidden("Permission denied to access users list", err)
}
users, err := handler.DataStore.User().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
}
availableUsers := security.FilterUsers(users, securityContext)
endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true)
if endpointID == 0 {
return response.JSON(w, availableUsers)
return response.JSON(w, sanitizeUsers(availableUsers))
}
// filter out users who do not have access to the specific endpoint
@@ -57,11 +65,11 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
}
canAccessEndpoint := make([]portainer.User, 0)
canAccessEndpoint := make([]User, 0)
for _, user := range availableUsers {
// the users who have the endpoint authorization
if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok {
canAccessEndpoint = append(canAccessEndpoint, user)
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
continue
}
@@ -72,9 +80,25 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
}
if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) {
canAccessEndpoint = append(canAccessEndpoint, user)
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
}
}
return response.JSON(w, canAccessEndpoint)
}
func sanitizeUser(user portainer.User) User {
return User{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}
}
func sanitizeUsers(users []portainer.User) []User {
u := make([]User, len(users))
for i := range users {
u[i] = sanitizeUser(users[i])
}
return u
}

View File

@@ -111,28 +111,14 @@ func Test_userList(t *testing.T) {
}
})
t.Run("standard user cannot list amdin users", func(t *testing.T) {
t.Run("standard user cannot list users", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
is.Equal(http.StatusOK, rr.Code)
body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error")
var resp []portainer.User
err = json.Unmarshal(body, &resp)
is.NoError(err, "response should be list json")
is.Len(resp, 2)
if len(resp) > 0 {
for _, user := range resp {
is.NotEqual(portainer.AdministratorRole, user.Role)
}
}
is.Equal(http.StatusForbidden, rr.Code)
})
// Case 2: the user is under an environment group and the environment group has endpoint access.

View File

@@ -21,9 +21,10 @@ type themePayload struct {
}
type userUpdatePayload struct {
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
Theme *themePayload
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
NewPassword string `validate:"required" example:"asfj2emv"`
Theme *themePayload
// User role (1 for administrator account and 2 for regular account)
Role int `validate:"required" enums:"1,2" example:"2"`
@@ -37,12 +38,14 @@ func (payload *userUpdatePayload) Validate(r *http.Request) error {
if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 {
return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)")
}
return nil
}
// @id UserUpdate
// @summary Update a user
// @description Update user details. A regular user account can only update his details.
// @description A regular user account cannot change their username or role.
// @description **Access policy**: authenticated
// @tags users
// @security ApiKeyAuth
@@ -95,6 +98,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
}
if payload.Username != "" && payload.Username != user.Username {
if tokenData.Role != portainer.AdministratorRole {
return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied)
}
sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
@@ -106,8 +113,28 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
user.Username = payload.Username
}
if payload.Password != "" {
user.Password, err = handler.CryptoService.Hash(payload.Password)
if payload.Password != "" && payload.NewPassword == "" {
if tokenData.Role == portainer.AdministratorRole {
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request"))
}
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request"))
}
if payload.NewPassword != "" {
// Non-admins need to supply the previous password
if tokenData.Role != portainer.AdministratorRole {
err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again"))
}
}
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
if err != nil {
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
}

View File

@@ -87,7 +87,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
}
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the requirements", nil)
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)

View File

@@ -9,9 +9,11 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
// @summary Attach a websocket
@@ -74,6 +76,13 @@ 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")
@@ -89,10 +98,15 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque
}
defer websocketConn.Close()
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID)
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
}
func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, attachID string) error {
func hijackAttachStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
attachID string,
token string,
) error {
dial, err := initDial(endpoint)
if err != nil {
return err
@@ -116,7 +130,7 @@ func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portain
return err
}
return hijackRequest(websocketConn, httpConn, attachStartRequest)
return hijackRequest(websocketConn, httpConn, attachStartRequest, token)
}
func createAttachStartRequest(attachID string) (*http.Request, error) {

View File

@@ -11,9 +11,11 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
type execStartOperationPayload struct {
@@ -80,6 +82,14 @@ 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 {
@@ -94,10 +104,15 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request
}
defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID)
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
}
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
func hijackExecStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
execID string,
token string,
) error {
dial, err := initDial(endpoint)
if err != nil {
return err
@@ -121,7 +136,7 @@ func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer
return err
}
return hijackRequest(websocketConn, httpConn, execStartRequest)
return hijackRequest(websocketConn, httpConn, execStartRequest, token)
}
func createExecStartRequest(execID string) (*http.Request, error) {

View File

@@ -7,9 +7,15 @@ import (
"net/http/httputil"
"github.com/gorilla/websocket"
"github.com/portainer/portainer/api/internal/logoutcontext"
)
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error {
func hijackRequest(
websocketConn *websocket.Conn,
httpConn *httputil.ClientConn,
request *http.Request,
token string,
) error {
// Server hijacks the connection, error 'connection closed' expected
resp, err := httpConn.Do(request)
if !errors.Is(err, httputil.ErrPersistEOF) {
@@ -29,9 +35,15 @@ func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn,
go streamFromReaderToWebsocket(websocketConn, brw, errorChan)
go streamFromWebsocketToWriter(websocketConn, tcpConn, errorChan)
err = <-errorChan
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
logoutCtx := logoutcontext.GetContext(token)
select {
case <-logoutCtx.Done():
return fmt.Errorf("Your session has been logged out.")
case err = <-errorChan:
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
}
}
return nil

View File

@@ -1,15 +1,20 @@
package websocket
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/logoutcontext"
"github.com/gorilla/websocket"
"github.com/koding/websocketproxy"
"github.com/portainer/portainer/api/crypto"
"github.com/rs/zerolog/log"
)
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
@@ -18,33 +23,12 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r
return err
}
endpointURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
agentURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
if err != nil {
return err
}
endpointURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(endpointURL)
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
}
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
proxy.ServeHTTP(w, r)
return nil
return handler.doProxyWebsocketRequest(w, r, params, agentURL, true)
}
func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
@@ -59,17 +43,41 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
}
agentURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(agentURL)
return handler.doProxyWebsocketRequest(w, r, params, agentURL, false)
}
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify {
func (handler *Handler) doProxyWebsocketRequest(
w http.ResponseWriter,
r *http.Request,
params *webSocketRequestParams,
agentURL *url.URL,
isEdge bool,
) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.
Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
enableTLS := !isEdge && (params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify)
agentURL.Scheme = "ws"
if enableTLS {
agentURL.Scheme = "wss"
}
proxy := websocketproxy.NewProxy(agentURL)
proxyDialer := *websocket.DefaultDialer
proxy.Dialer = &proxyDialer
if enableTLS {
tlsConfig := crypto.CreateTLSConfiguration()
tlsConfig.InsecureSkipVerify = params.endpoint.TLSConfig.TLSSkipVerify
proxy.Dialer = &websocket.Dialer{
TLSClientConfig: tlsConfig,
}
proxyDialer.TLSClientConfig = tlsConfig
}
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
@@ -84,7 +92,46 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
}
if isEdge {
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}
abortProxyOnLogout(r.Context(), proxy, tokenData.Token)
proxy.ServeHTTP(w, r)
return nil
}
func abortProxyOnLogout(ctx context.Context, proxy *websocketproxy.WebsocketProxy, token string) {
var wsConn net.Conn
proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) {
netDialer := &net.Dialer{}
conn, err := netDialer.DialContext(context.Background(), network, addr)
wsConn = conn
return conn, err
}
logoutCtx := logoutcontext.GetContext(token)
go func() {
log.Debug().
Msg("logout watcher for websocket proxy started")
select {
case <-logoutCtx.Done():
log.Debug().
Msg("logout watcher for websocket proxy stopped as user logged out")
if wsConn != nil {
wsConn.Close()
}
case <-ctx.Done():
log.Debug().
Msg("logout watcher for websocket proxy stopped as the ws connection closed")
}
}()
}

View File

@@ -0,0 +1,25 @@
package middlewares
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/rs/zerolog/log"
)
// deprecate api route
func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
newUrl, err := urlBuilder(w, r)
if err != nil {
httperror.WriteError(w, err.StatusCode, err.Error(), err)
return
}
log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl)
redirectedRequest := r.Clone(r.Context())
redirectedRequest.URL.Path = newUrl
router.ServeHTTP(w, redirectedRequest)
})
}

View File

@@ -1,10 +1,12 @@
package kubernetes
import (
"fmt"
"os"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
@@ -43,28 +45,62 @@ func (manager *tokenManager) GetAdminServiceAccountToken() string {
return manager.adminToken
}
func (manager *tokenManager) setupUserServiceAccounts(userID portainer.UserID, endpoint *portainer.Endpoint) error {
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return err
}
teamIds := make([]int, 0, len(memberships))
for _, membership := range memberships {
teamIds = append(teamIds, int(membership.TeamID))
}
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
err = manager.kubecli.SetupUserServiceAccount(int(userID), teamIds, restrictDefaultNamespace)
if err != nil {
return err
}
return nil
}
func (manager *tokenManager) UpdateUserServiceAccountsForEndpoint(endpointID portainer.EndpointID) {
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environments %d", endpointID)
return
}
userIDs := make([]portainer.UserID, 0)
for u := range endpoint.UserAccessPolicies {
userIDs = append(userIDs, u)
}
for t := range endpoint.TeamAccessPolicies {
memberships, _ := manager.dataStore.TeamMembership().TeamMembershipsByTeamID(portainer.TeamID(t))
for _, membership := range memberships {
userIDs = append(userIDs, membership.UserID)
}
}
for _, userID := range userIDs {
if err := manager.setupUserServiceAccounts(userID, endpoint); err != nil {
log.Error().Err(err).Msgf("failed setting-up service account for user %d", userID)
}
}
}
// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token
func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) {
tokenFunc := func() (string, error) {
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID))
if err != nil {
return "", err
}
teamIds := make([]int, 0, len(memberships))
for _, membership := range memberships {
teamIds = append(teamIds, int(membership.TeamID))
}
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environment %d", endpointID)
return "", err
}
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds, restrictDefaultNamespace)
if err != nil {
return "", err
if err := manager.setupUserServiceAccounts(portainer.UserID(userID), endpoint); err != nil {
return "", fmt.Errorf("failed setting-up service account for user %d: %w", userID, err)
}
return manager.kubecli.GetServiceAccountBearerToken(userID)

View File

@@ -49,7 +49,17 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`)
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
endpointRe := regexp.MustCompile(`([0-9]+)`)
endpointIDMatch := endpointRe.FindAllString(request.RequestURI, 1)
endpointID := 0
if len(endpointIDMatch) > 0 {
endpointID, _ = strconv.Atoi(endpointIDMatch[0])
}
switch {
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
return transport.executeKubernetesRequest(request)
case strings.EqualFold(requestPath, "/namespaces"):
return transport.executeKubernetesRequest(request)
case strings.HasPrefix(requestPath, "/namespaces"):

View File

@@ -60,15 +60,15 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices
}
}
// PublicAccess defines a security check for public API environments(endpoints).
// No authentication is required to access these environments(endpoints).
// 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)
}
// AdminAccess defines a security check for API environments(endpoints) that require an authorization check.
// Authentication is required to access these environments(endpoints).
// The administrator role is required to use these environments(endpoints).
// AdminAccess defines a security check for API endpoints that require an authorization check.
// Authentication is required to access these endpoints.
// The administrator role is required to use these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
@@ -79,8 +79,8 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
return h
}
// RestrictedAccess defines a security check for restricted API environments(endpoints).
// Authentication is required to access these environments(endpoints).
// RestrictedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
@@ -104,8 +104,8 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
return h
}
// AuthenticatedAccess defines a security check for restricted API environments(endpoints).
// Authentication is required to access these environments(endpoints).
// AuthenticatedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.

View File

@@ -100,6 +100,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
endpointGroup := getAssociatedGroup(&endpoint, groups)
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
endpoint.UserAccessPolicies = nil
endpoints[n] = endpoint
n++
}

View File

@@ -259,6 +259,7 @@ func (server *Server) Start() error {
var teamMembershipHandler = teammemberships.NewHandler(requestBouncer)
teamMembershipHandler.DataStore = server.DataStore
teamMembershipHandler.K8sClientFactory = server.KubernetesClientFactory
var systemHandler = system.NewHandler(requestBouncer,
server.Status,

View File

@@ -0,0 +1,20 @@
package logoutcontext
import (
"context"
)
const LogoutPrefix = "logout-"
func GetContext(token string) context.Context {
return GetService(logoutToken(token)).GetLogoutCtx()
}
func Cancel(token string) {
GetService(logoutToken(token)).Cancel()
RemoveService(logoutToken(token))
}
func logoutToken(token string) string {
return LogoutPrefix + token
}

View File

@@ -0,0 +1,28 @@
package logoutcontext
import (
"context"
)
type (
Service struct {
ctx context.Context
cancel context.CancelFunc
}
)
func NewService() *Service {
ctx, cancel := context.WithCancel(context.Background())
return &Service{
ctx: ctx,
cancel: cancel,
}
}
func (s *Service) Cancel() {
s.cancel()
}
func (s *Service) GetLogoutCtx() context.Context {
return s.ctx
}

View File

@@ -0,0 +1,34 @@
package logoutcontext
import "sync"
type (
ServiceFactory struct {
mu sync.Mutex
services map[string]*Service
}
)
var serviceFactory = ServiceFactory{
services: make(map[string]*Service),
}
func GetService(token string) *Service {
serviceFactory.mu.Lock()
defer serviceFactory.mu.Unlock()
service, ok := serviceFactory.services[token]
if !ok {
service = NewService()
serviceFactory.services[token] = service
}
return service
}
func RemoveService(token string) {
serviceFactory.mu.Lock()
defer serviceFactory.mu.Unlock()
delete(serviceFactory.services, token)
}

View File

@@ -301,6 +301,19 @@ func (s *stubEndpointService) GetNextIdentifier() int {
return len(s.endpoints)
}
func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
for _, e := range s.endpoints {
for t := range e.TeamAccessPolicies {
if t == teamID {
endpoints = append(endpoints, e)
}
}
}
return endpoints, nil
}
// WithEndpoints option will instruct testDatastore to return provided environments(endpoints)
func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
return func(d *testDatastore) {

View File

@@ -137,6 +137,7 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
ID: portainer.UserID(cl.UserID),
Username: cl.Username,
Role: portainer.UserRole(cl.Role),
Token: token,
}, nil
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -154,17 +155,29 @@ func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.En
}, nil
}
// CreateClient returns a pointer to a new Clientset instance
// CreateClient returns a pointer to a new Clientset instance.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return buildLocalClient()
case portainer.AgentOnKubernetesEnvironment:
return factory.buildAgentClient(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.buildEdgeClient(endpoint)
case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment:
c, err := factory.CreateConfig(endpoint)
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(c)
}
return nil, errors.New("unsupported environment type")
}
// CreateConfig returns a pointer to a new kubeconfig ready to create a client.
func (factory *ClientFactory) CreateConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return buildLocalConfig()
case portainer.AgentOnKubernetesEnvironment:
return factory.buildAgentConfig(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.buildEdgeConfig(endpoint)
}
return nil, errors.New("unsupported environment type")
}
@@ -184,20 +197,64 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
return rt.roundTripper.RoundTrip(req)
}
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
var clientURL strings.Builder
if !strings.HasPrefix(endpoint.URL, "http") {
clientURL.WriteString("https://")
}
clientURL.WriteString(endpoint.URL)
clientURL.WriteString("/kubernetes")
return factory.createRemoteClient(endpointURL)
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
config, err := clientcmd.BuildConfigFromFlags(clientURL.String(), "")
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
}
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed activating tunnel")
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
return factory.createRemoteClient(endpointURL)
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
}
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
@@ -227,34 +284,14 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
}
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
config, err := factory.CreateConfig(endpoint)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create metrics KubeConfig")
}
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return metricsv.NewForConfig(config)
}
func buildLocalClient() (*kubernetes.Clientset, error) {
func buildLocalConfig() (*rest.Config, error) {
config, err := rest.InClusterConfig()
if err != nil {
return nil, err
@@ -263,7 +300,7 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
return kubernetes.NewForConfig(config)
return config, nil
}
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {

View File

@@ -1269,6 +1269,7 @@ type (
Username string
Role UserRole
ForceChangePassword bool
Token string
}
// TunnelDetails represents information associated to a tunnel
@@ -1403,6 +1404,7 @@ type (
StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
StoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, data []byte) (string, error)
UpdateStoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error)
RemoveStackFileBackup(stackIdentifier, fileName string) error
RemoveStackFileBackupByVersion(stackIdentifier string, version int, fileName string) error
RollbackStackFile(stackIdentifier, fileName string) error
@@ -1559,7 +1561,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.20.0"
APIVersion = "2.19.4"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1680,6 +1682,12 @@ const (
EdgeStackStatusDeploying
// EdgeStackStatusRemoving represents an Edge stack which is being removed
EdgeStackStatusRemoving
// EdgeStackStatusPausedDeploying represents a paused Edge stack
EdgeStackStatusPausedDeploying
// EdgeStackStatusRollingBack represents an Edge stack which is being rolled back
EdgeStackStatusRollingBack
// EdgeStackStatusRolledBack represents an Edge stack which has rolled back
EdgeStackStatusRolledBack
)
const (
@@ -2057,6 +2065,20 @@ const (
OperationIntegrationStoridgeAdmin Authorization = "IntegrationStoridgeAdmin"
)
// GetEditionLabel returns the portainer edition label
func (e SoftwareEdition) GetEditionLabel() string {
switch e {
case PortainerCE:
return "CE"
case PortainerBE:
return "BE"
case PortainerEE:
return "EE"
}
return "CE"
}
const (
AzurePathContainerGroups = "/subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups"
AzurePathContainerGroup = "/subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*"

View File

@@ -17,6 +17,18 @@ type Scheduler struct {
mu sync.Mutex
}
type PermanentError struct {
err error
}
func NewPermanentError(err error) *PermanentError {
return &PermanentError{err: err}
}
func (e *PermanentError) Error() string {
return e.err.Error()
}
func NewScheduler(ctx context.Context) *Scheduler {
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
crontab.Start()
@@ -84,14 +96,24 @@ func (s *Scheduler) StopJob(jobID string) error {
func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
ctx, cancel := context.WithCancel(context.Background())
j := cron.FuncJob(func() {
if err := job(); err != nil {
log.Debug().Msg("job returned an error")
cancel()
jobFn := cron.FuncJob(func() {
err := job()
if err == nil {
return
}
var permErr *PermanentError
if errors.As(err, &permErr) {
log.Error().Err(permErr).Msg("job returned a permanent error, it will be stopped")
cancel()
return
}
log.Error().Err(err).Msg("job returned an error, it will be rescheduled")
})
entryID := s.crontab.Schedule(cron.Every(duration), j)
entryID := s.crontab.Schedule(cron.Every(duration), jobFn)
s.mu.Lock()
s.activeJobs[entryID] = cancel

View File

@@ -49,7 +49,7 @@ func Test_JobCanBeStopped(t *testing.T) {
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_JobShouldStop_UponError(t *testing.T) {
func Test_JobShouldStop_UponPermError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
@@ -58,7 +58,7 @@ func Test_JobShouldStop_UponError(t *testing.T) {
s.StartJobEvery(jobInterval, func() error {
acc++
close(ch)
return fmt.Errorf("failed")
return NewPermanentError(fmt.Errorf("failed"))
})
<-time.After(3 * jobInterval)
@@ -66,6 +66,28 @@ func Test_JobShouldStop_UponError(t *testing.T) {
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
}
func Test_JobShouldNotStop_UponError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
var acc int
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
acc++
if acc == 2 {
close(ch)
return NewPermanentError(fmt.Errorf("failed"))
}
return errors.New("non-permanent error")
})
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, 2, acc)
}
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
s := NewScheduler(context.Background())

View File

@@ -100,7 +100,6 @@ func buildComposeStartCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions
cmd = appendSkipTLSVerifyIfNeeded(cmd, stack)
cmd = append(cmd, "-k")
cmd = append(cmd, env...)
cmd = append(cmd, registries...)
cmd = append(cmd, stack.GitConfig.URL)
cmd = append(cmd, stack.GitConfig.ReferenceName)
cmd = append(cmd, stack.Name)
@@ -163,7 +162,6 @@ func buildSwarmStartCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions,
cmd = append(cmd, UnpackerCmdSwarmDeploy, "-f", "-r", "-k")
cmd = appendSkipTLSVerifyIfNeeded(cmd, stack)
cmd = append(cmd, getEnv(stack.Env)...)
cmd = append(cmd, registries...)
cmd = append(cmd, stack.GitConfig.URL)
cmd = append(cmd, stack.GitConfig.ReferenceName)
cmd = append(cmd, stack.Name)

View File

@@ -1,13 +1,17 @@
package deployments
import (
"crypto/tls"
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/agent"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
@@ -29,7 +33,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
stack, err := datastore.Stack().Read(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
} else if err != nil {
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
}
@@ -38,7 +44,15 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
}
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(
errors.WithMessagef(err,
"failed to find the environment %v associated to the stack %v",
stack.EndpointID,
stack.ID,
),
)
} else if err != nil {
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
}
@@ -59,6 +73,10 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return &StackAuthorMissingErr{int(stack.ID), author}
}
if !isEnvironmentOnline(endpoint) {
return nil
}
var gitCommitChangedOrForceUpdate bool
if !stack.FromAppTemplate {
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stackID), stack.GitConfig, false, false, stack.ProjectPath)
@@ -78,14 +96,16 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
}
registries, err := getUserRegistries(datastore, user, endpoint.ID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(err)
} else if err != nil {
return err
}
switch stack.Type {
case portainer.DockerComposeStack:
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
} else {
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
@@ -95,7 +115,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
}
case portainer.DockerSwarmStack:
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
} else {
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
@@ -116,6 +136,8 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
}
stack.Status = portainer.StackStatusActive
if err := datastore.Stack().Update(stack.ID, stack); err != nil {
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
}
@@ -147,3 +169,22 @@ func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, e
return filteredRegistries, nil
}
func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
if endpoint.Type != portainer.AgentOnDockerEnvironment &&
endpoint.Type != portainer.AgentOnKubernetesEnvironment {
return true
}
var err error
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return false
}
}
_, _, err = agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig)
return err == nil
}

View File

@@ -1,18 +1,78 @@
package deployments
import (
"context"
"crypto/tls"
"errors"
"net/http"
"strconv"
"strings"
"testing"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const localhostCert = `-----BEGIN CERTIFICATE-----
MIIEOjCCAiKgAwIBAgIRALg8rJET2/9LjKSxHj0dQhYwDQYJKoZIhvcNAQELBQAw
FzEVMBMGA1UEAxMMUG9ydGFpbmVyIENBMB4XDTIzMTAxMTE5NDcxMVoXDTI1MDQx
MTE5NTM0MVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmz
YqOIYGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3
c2FzQ3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw
6scV6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X0
38Pu178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdt
Z+GN26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABo4GDMIGAMA4GA1Ud
DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O
BBYEFPCefmK5Szzlfs8FRCa5+kRCIEWuMB8GA1UdIwQYMBaAFKZZ074SR/ajD3zE
gxpLGRvFT3XAMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBABcQ
/WPSUpuQvrcVBsmIlOMz74cDZYuIDls/mAcB/yP3mm+oWlO0qvH/F/BMs1P/bkgj
fByQZq8Zmi6/TEZNlGvW7KGx077VxDKi8jd1jL3gLDPmkFjYuGeIWQusgxBu1y3m
0WoTTqnkoism1mzV/dgNwrm3YQIV4H/fi9EEdQSm0UFRTKSAGBkwS7N2pmNb5yQO
U8glFpyznCv4evDJbs/JUUXKYExgFFhWUd25P7iBRLXg/BFfqdSTiUGUj/Msz0pO
Evqmq78eIiXjyyKSxzve6/mEIeq6AE3AC9zH+fwTd6Mhp+T2P/S/iO4EU19IMR4m
sbNBd6h/3GvRekO1KbqQ42awuMnxvWT0NVclSxiU1lMpAmRmk/w9z7wB3r4n7oh4
iiOTl5VSw1UBkcLDOJw+HB/FU2PdVFfIJKRfjLCZOGrcJX9vEcz7dYGpB5HrdqOc
/8q5j1g6f/pGE+20HITrtz6ChguETzqw5dLNeKeolC6bVH8yEtmpnP2n8VPnT9Di
V+hnONcJ+wd/dkBqabGr7LPG24Kj1F2Zp3CDDvJA94FaEsgaLfSg3JD+43uRCOWM
RuqU8bGuhQRqilR2dSIOrFaW2+MeUHsb24cUn/pkHqKpSg+RBEnf6QfGDlIgqYEl
19f/HFVBc/a8lM/D81lMyDbjQ9zH4LDYj4ipBbkL
-----END CERTIFICATE-----`
const localhostKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmzYqOI
YGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3c2Fz
Q3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw6scV
6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X038Pu
178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdtZ+GN
26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABAoIBAQCqSP6BPG195A52
iEeCISksw9ERsou+fflKNvIcQvV7swP0xOyooERUhhiVwQMKpx9QDUXXLRV8CHch
JExR+OEYQdv4GhJM/b6XYafLYQfe80thKyQLzTXQWSdUeffe4OEMShODKOKoRUyp
oO9Qj9/wKfX3V6S2iwnU4dxdofztv+YP9rYQyjnhKbv/9OfeCp2Pb9eFKKRsA+QQ
xneDz1+wr8ToTuiTn8HBPNSeSAKvhzXuzyluI7VAetRloNgCtumrA9kpVbW2cDgE
Gk0q3RY125ejFELQO/cOJFuBsqoJlvPxzg8/vHyfyF9hFMqbqvcUw2e1eqHpnJd5
dP4+ZGYZAoGBAOOFuPXMLBts0rN9mfNbVfx36H+aOCL77SafZvWm0D+rH69QN3/q
/ZSWQEjwH5Tzn1e+NVcl/Um2vL/dIyEGBklXQ7yAyJo25gpEOD/rt1U94HKzMOwy
yKtsKghRAOx0piie7ORS6MGbEOQxU3/1Eg1uvd0qoSnALqJ/le75QpFXAoGBAOCD
aZQTszzDddr1cFPzLyqjIGJWfPcDYSONXVcCeQmhvC7mkfw9SWdIfku7JbdNgFYq
ZAAU0klsLX0lEe8f4A12FnHNylKoxmTWdE3wWPptejdA1KUgzt/2kNljgOMFuY0Q
rlCEW/Jabrg5aFMwVVG8bHLZR0xalfniDvXLvnFFAoGACdztJLKiIto31BIYz2Th
OF2WVZnA3ztej3MPioydsHThnb7zePcd4QgWZ1MJe3KIMMyNEWcTMNPcINEcSb0y
HpHK3OwURiMlG8LTUWoNe4OALFi6QTL+YfgBZnTkflucLFyfVlKFxobLV6kPvpdI
Hg7z6heD/wRWwTKYtFBX42cCgYBIeoQJ9rYlRqB0eEm0AEzYweLBfFRJVgD0/j0E
ytqSPnFG3s6AFLTur9t9zUPmwhFNP9Aaqp4cb9zbiq0YejzVe6rRQHMxbiTmBslz
I8VFyzPqRHahfE7sxGeMlm/UWlPFc34ipigcvA8EUBwaxv60LVUBWp2Gy7OhANZ9
iTHI1QKBgQCdHFj9dnbpaEHA426CoaPsyj5cv2nBLRf8p1cs71sq+qQOGlGJfajm
L9x22ol5c5rToZa1qKSnSdSDCud298MyRujMUy2UcUKHeNs3MK9AT41sDv266I7b
vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg==
-----END RSA PRIVATE KEY-----`
type noopDeployer struct{}
// without unpacker
@@ -35,7 +95,7 @@ func (s *noopDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint
func (s *noopDeployer) UndeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (s *noopDeployer) StartRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
func (s *noopDeployer) StartRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (s *noopDeployer) StopRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
@@ -47,13 +107,49 @@ func (s *noopDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *
func (s *noopDeployer) UndeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (s *noopDeployer) StartRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
func (s *noopDeployer) StartRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (s *noopDeployer) StopRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func agentServer(t *testing.T) string {
h := http.NewServeMux()
h.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.PortainerAgentHeader, "v2.19.0")
w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformDocker)))
response.Empty(w)
})
cert, err := tls.X509KeyPair([]byte(localhostCert), []byte(localhostKey))
require.NoError(t, err)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
}
l, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
require.NoError(t, err)
s := &http.Server{
Handler: h,
}
go func() {
err := s.Serve(l)
require.ErrorIs(t, err, http.ErrServerClosed)
}()
t.Cleanup(func() {
s.Shutdown(context.Background())
})
return "http://" + l.Addr().String()
}
func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
@@ -114,7 +210,12 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
assert.NoError(t, err, "error creating an admin")
err = store.Endpoint().Create(&portainer.Endpoint{
ID: 0,
ID: 0,
URL: agentServer(t),
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
})
assert.NoError(t, err, "error creating environment")

View File

@@ -33,29 +33,22 @@ type RemoteStackDeployer interface {
// compose
DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRecreate bool) error
UndeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
StartRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
StartRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
StopRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
// swarm
DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error
UndeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
StartRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
StartRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
StopRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
}
// Deploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) DeployRemoteComposeStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
registries []portainer.Registry,
forcePullImage bool,
forceRecreate bool,
) error {
func (d *stackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRecreate bool) error {
d.lock.Lock()
defer d.lock.Unlock()
d.swarmStackManager.Login(registries, endpoint)
defer d.swarmStackManager.Logout(endpoint)
// --force-recreate doesn't pull updated images
if forcePullImage {
err := d.composeStackManager.Pull(context.TODO(), stack, endpoint)
@@ -64,14 +57,9 @@ func (d *stackDeployer) DeployRemoteComposeStack(
}
}
return d.remoteStack(
stack,
endpoint,
OperationDeploy,
unpackerCmdBuilderOptions{
registries: registries,
},
)
return d.remoteStack(stack, endpoint, OperationDeploy, unpackerCmdBuilderOptions{
registries: registries,
})
}
// Undeploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
@@ -83,19 +71,8 @@ func (d *stackDeployer) UndeployRemoteComposeStack(stack *portainer.Stack, endpo
}
// Start a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) StartRemoteComposeStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
registries []portainer.Registry,
) error {
return d.remoteStack(
stack,
endpoint,
OperationComposeStart,
unpackerCmdBuilderOptions{
registries: registries,
},
)
func (d *stackDeployer) StartRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return d.remoteStack(stack, endpoint, OperationComposeStart, unpackerCmdBuilderOptions{})
}
// Stop a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
@@ -104,13 +81,7 @@ func (d *stackDeployer) StopRemoteComposeStack(stack *portainer.Stack, endpoint
}
// Deploy a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) DeployRemoteSwarmStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
registries []portainer.Registry,
prune bool,
pullImage bool,
) error {
func (d *stackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error {
d.lock.Lock()
defer d.lock.Unlock()
@@ -134,19 +105,8 @@ func (d *stackDeployer) UndeployRemoteSwarmStack(stack *portainer.Stack, endpoin
}
// Start a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) StartRemoteSwarmStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
registries []portainer.Registry,
) error {
return d.remoteStack(
stack,
endpoint,
OperationSwarmStart,
unpackerCmdBuilderOptions{
registries: registries,
},
)
func (d *stackDeployer) StartRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return d.remoteStack(stack, endpoint, OperationSwarmStart, unpackerCmdBuilderOptions{})
}
// Stop a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container

View File

@@ -84,7 +84,7 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
return err
}
}
if stackutils.IsGitStack(config.stack) {
if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
}

View File

@@ -78,7 +78,7 @@ func (config *SwarmStackDeploymentConfig) Deploy() error {
}
}
if stackutils.IsGitStack(config.stack) {
if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteSwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
}

View File

@@ -47,3 +47,10 @@ func SanitizeLabel(value string) string {
func IsGitStack(stack *portainer.Stack) bool {
return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0
}
// IsRelativePathStack checks if the stack is a git stack or not
func IsRelativePathStack(stack *portainer.Stack) bool {
// Always return false in CE
// This function is only for code consistency with EE
return false
}

2
app/__mocks__/svg.js Normal file
View File

@@ -0,0 +1,2 @@
export default 'SvgrURL';
export const ReactComponent = 'div';

View File

@@ -1,8 +0,0 @@
import { forwardRef } from 'react';
const SvgrMock = forwardRef<HTMLSpanElement>((props, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<span ref={ref} {...props} />
));
export default SvgrMock;

View File

@@ -87,7 +87,7 @@
--orange-1: #e86925;
--BE-only: var(--ui-warning-7);
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
@@ -259,8 +259,7 @@
/* Dark Theme */
[theme='dark'] {
--BE-only: var(--ui-blue-8);
--bg-BE-only: rgba(225, 223, 223, 0.08);
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
@@ -434,6 +433,7 @@
/* High Contrast Theme */
[theme='highcontrast'] {
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);

View File

@@ -0,0 +1,109 @@
<div class="inner-datatable">
<table class="table-condensed table-hover nowrap-cells table">
<thead>
<tr>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open" class="w-[10%]">
<div class="flex">
<table-column-header
col-title="'Status'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Status.State'"
is-sorted-desc="$ctrl.state.orderBy === 'Status.State' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Status.State')"
></table-column-header>
<span class="space-left">
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled"
>Filter
<pr-icon icon="'filter'"></pr-icon>
</span>
<span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.state.enabled"
>Filter
<pr-icon icon="'check'"></pr-icon>
</span>
</span>
<div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Filter by state </div>
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
<input id="filter_state_{{ $ctrl.serviceId }}_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
<label for="filter_state_{{ $ctrl.serviceId }}_{{ $index }}">{{ filter.label }}</label>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
</div>
</div>
</div>
</div>
</th>
<th style="width: 22%">Task</th>
<th>Actions</th>
<th>
<table-column-header
col-title="'Slot'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Slot'"
is-sorted-desc="$ctrl.state.orderBy === 'Slot' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Slot')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Node'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'NodeId'"
is-sorted-desc="$ctrl.state.orderBy === 'NodeId' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('NodeId')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Last Update'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Updated'"
is-sorted-desc="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Updated')"
></table-column-header>
</th>
</tr>
</thead>
<tbody>
<tr
ng-repeat="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder))"
>
<td class="text-center">
<span class="label label-{{ item.Status.State | taskstatusbadge }} space-right">{{ item.Status.State }}</span>
</td>
<td>
<a ng-if="!$ctrl.agentProxy || !item.Container" ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.Id }}</a>
<a ng-if="$ctrl.agentProxy && item.Container" ui-sref="docker.containers.container({ id: item.Container.Id, nodeName: item.Container.NodeName })" class="monospaced">{{
item.Id
}}</a>
</td>
<td>
<container-quick-actions
ng-if="!$ctrl.agentProxy || !item.Container"
container-id="item.ContainerId"
task-id="item.Id"
status="item.Status.State"
state="$ctrl.state"
></container-quick-actions>
<container-quick-actions
ng-if="$ctrl.agentProxy && item.Container"
container-id="item.Container.Id"
node-name="item.Container.NodeName"
status="item.Status.State"
state="$ctrl.state"
></container-quick-actions>
</td>
<td>{{ item.Slot ? item.Slot : '-' }}</td>
<td>{{ item.NodeId | tasknodename : $ctrl.nodes }}</td>
<td>{{ item.Updated | getisodate }}</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-muted text-center">No task matching filter.</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,15 @@
angular.module('portainer.docker').component('serviceTasksDatatable', {
templateUrl: './serviceTasksDatatable.html',
controller: 'ServiceTasksDatatableController',
bindings: {
dataset: '<',
serviceId: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
nodes: '<',
agentProxy: '<',
textFilter: '=',
showTaskLogsButton: '<',
},
});

View File

@@ -0,0 +1,94 @@
import _ from 'lodash-es';
angular.module('portainer.docker').controller('ServiceTasksDatatableController', [
'$scope',
'$controller',
'DatatableService',
function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
var ctrl = this;
this.state = Object.assign(this.state, {
showQuickActionStats: true,
showQuickActionLogs: true,
showQuickActionConsole: true,
showQuickActionInspect: true,
showQuickActionExec: true,
showQuickActionAttach: false,
});
this.filters = {
state: {
open: false,
enabled: false,
values: [],
},
};
this.applyFilters = function (item) {
var filters = ctrl.filters;
for (var i = 0; i < filters.state.values.length; i++) {
var filter = filters.state.values[i];
if (item.Status.State === filter.label && filter.display) {
return true;
}
}
return false;
};
this.onStateFilterChange = function () {
var filters = this.filters.state.values;
var filtered = false;
for (var i = 0; i < filters.length; i++) {
var filter = filters[i];
if (!filter.display) {
filtered = true;
}
}
this.filters.state.enabled = filtered;
};
this.prepareTableFromDataset = function () {
var availableStateFilters = [];
for (var i = 0; i < this.dataset.length; i++) {
var item = this.dataset[i];
availableStateFilters.push({ label: item.Status.State, display: true });
}
this.filters.state.values = _.uniqBy(availableStateFilters, 'label');
};
this.$onInit = function () {
this.setDefaults();
this.prepareTableFromDataset();
this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
};
},
]);

View File

@@ -231,7 +231,16 @@
<tr dir-paginate-end ng-show="item.Expanded">
<td></td>
<td colspan="8">
<docker-service-tasks-datatable dataset="item.Tasks" search="$ctrl.state.textFilter"></docker-service-tasks-datatable>
<service-tasks-datatable
dataset="item.Tasks"
service-id="item.Id"
table-key="service-tasks"
order-by="Status.State"
nodes="$ctrl.nodes"
agent-proxy="$ctrl.agentProxy"
show-task-logs-button="$ctrl.showTaskLogsButton"
text-filter="$ctrl.state.textFilter"
></service-tasks-datatable>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">

View File

@@ -89,7 +89,13 @@
>
</td>
<td>
<task-table-quick-actions ng-if="!$ctrl.agentProxy || !item.Container" task-id="item.Id" state="$ctrl.state"></task-table-quick-actions>
<container-quick-actions
ng-if="!$ctrl.agentProxy || !item.Container"
container-id="item.ContainerId"
task-id="item.Id"
status="item.Status.State"
state="$ctrl.state"
></container-quick-actions>
<container-quick-actions
ng-if="$ctrl.agentProxy && item.Container"
container-id="item.Container.Id"

View File

@@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { joinCommand, taskStatusBadge, trimSHA } from './utils';
import { joinCommand, trimSHA } from './utils';
function includeString(text, values) {
return values.some(function (val) {
@@ -49,7 +49,22 @@ angular
})
.filter('taskstatusbadge', function () {
'use strict';
return taskStatusBadge;
return function (text) {
var status = _.toLower(text);
var labelStyle = 'default';
if (includeString(status, ['new', 'allocated', 'assigned', 'accepted', 'preparing', 'ready', 'starting', 'remove'])) {
labelStyle = 'info';
} else if (includeString(status, ['pending'])) {
labelStyle = 'warning';
} else if (includeString(status, ['shutdown', 'failed', 'rejected', 'orphaned'])) {
labelStyle = 'danger';
} else if (includeString(status, ['complete'])) {
labelStyle = 'primary';
} else if (includeString(status, ['running'])) {
labelStyle = 'success';
}
return labelStyle;
};
})
.filter('taskhaslogs', function () {
'use strict';

View File

@@ -1,5 +1,4 @@
import _ from 'lodash';
import { TaskState } from 'docker-types/generated/1.41';
export function trimSHA(imageName: string) {
if (!imageName) {
@@ -18,38 +17,3 @@ export function joinCommand(command: null | Array<string> = []) {
return command.join(' ');
}
export function taskStatusBadge(text?: TaskState) {
const status = _.toLower(text);
if (
[
'new',
'allocated',
'assigned',
'accepted',
'preparing',
'ready',
'starting',
'remove',
].includes(status)
) {
return 'info';
}
if (['pending'].includes(status)) {
return 'warning';
}
if (['shutdown', 'failed', 'rejected', 'orphaned'].includes(status)) {
return 'danger';
}
if (['complete'].includes(status)) {
return 'primary';
}
if (['running'].includes(status)) {
return 'success';
}
return 'default';
}

View File

@@ -7,10 +7,8 @@ angular.module('portainer.docker').factory('ConfigHelper', [
return {
Id: config.ConfigID,
Name: config.ConfigName,
FileName: config.File.Name,
Uid: config.File.UID,
Gid: config.File.GID,
Mode: config.File.Mode,
...(config.File ? { FileName: config.File.Name, Uid: config.File.UID, Gid: config.File.GID, Mode: config.File.Mode } : {}),
credSpec: !!config.Runtime,
};
}
return {};
@@ -20,12 +18,15 @@ angular.module('portainer.docker').factory('ConfigHelper', [
return {
ConfigID: config.Id,
ConfigName: config.Name,
File: {
Name: config.FileName || config.Name,
UID: config.Uid || '0',
GID: config.Gid || '0',
Mode: config.Mode || 292,
},
File: config.credSpec
? null
: {
Name: config.FileName || config.Name,
UID: config.Uid || '0',
GID: config.Gid || '0',
Mode: config.Mode || 292,
},
Runtime: config.credSpec ? {} : null,
};
}
return {};

14
app/docker/models/task.js Normal file
View File

@@ -0,0 +1,14 @@
export function TaskViewModel(data) {
this.Id = data.ID;
this.Created = data.CreatedAt;
this.Updated = data.UpdatedAt;
this.Slot = data.Slot;
this.Spec = data.Spec;
this.Status = data.Status;
this.DesiredState = data.DesiredState;
this.ServiceId = data.ServiceID;
this.NodeId = data.NodeID;
if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) {
this.ContainerId = data.Status.ContainerStatus.ContainerID;
}
}

View File

@@ -1,36 +0,0 @@
import { Task, TaskSpec, TaskState } from 'docker-types/generated/1.41';
export class TaskViewModel {
Id: string;
Created: string;
Updated: string;
Slot: number;
Spec?: TaskSpec;
Status: Task['Status'];
DesiredState: TaskState;
ServiceId: string;
NodeId: string;
ContainerId: string = '';
constructor(data: Task) {
this.Id = data.ID || '';
this.Created = data.CreatedAt || '';
this.Updated = data.UpdatedAt || '';
this.Slot = data.Slot || 0;
this.Spec = data.Spec;
this.Status = data.Status;
this.DesiredState = data.DesiredState || 'pending';
this.ServiceId = data.ServiceID || '';
this.NodeId = data.NodeID || '';
this.ContainerId = data.Status?.ContainerStatus?.ContainerID || '';
}
}

View File

@@ -13,6 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDetails';
import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus';
import { GpusList } from '@/react/docker/host/SetupView/GpusList';
import { GpusInsights } from '@/react/docker/host/SetupView/GpusInsights';
import { InsightsBox } from '@/react/components/InsightsBox';
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
import { ImagesDatatable } from '@/react/docker/images/ListView/ImagesDatatable/ImagesDatatable';
@@ -21,10 +22,8 @@ import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatab
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser';
import { servicesModule } from './services';
const ngModule = angular
.module('portainer.docker.react.components', [servicesModule])
.module('portainer.docker.react.components', [])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
.component(
@@ -34,6 +33,7 @@ const ngModule = angular
'nodeName',
'state',
'status',
'taskId',
])
)
.component('templateListDropdown', TemplateListDropdownAngular)
@@ -71,6 +71,7 @@ const ngModule = angular
])
)
.component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml']))
.component('gpusInsights', r2a(GpusInsights, []))
.component(
'dockerImagesDatatable',
r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [

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