Compare commits
328 Commits
feat/EE-59
...
feat/EE-97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa0fe253b | ||
|
|
8902bae7a4 | ||
|
|
08bcfa284c | ||
|
|
1345396c8c | ||
|
|
df4da34a63 | ||
|
|
e9675400ab | ||
|
|
49691de937 | ||
|
|
ef2161abe9 | ||
|
|
c3f8ec2380 | ||
|
|
06f62825ee | ||
|
|
4cf8082591 | ||
|
|
96d1230461 | ||
|
|
07e83d1e6e | ||
|
|
8b01f8681c | ||
|
|
9c724d4a82 | ||
|
|
099e58d458 | ||
|
|
a67331f1d4 | ||
|
|
55e52ee79a | ||
|
|
c42756ae23 | ||
|
|
dbcbef0953 | ||
|
|
1d7ed11462 | ||
|
|
100e8ebec2 | ||
|
|
8e0f681dd3 | ||
|
|
e9e04cb61c | ||
|
|
8ab58bc959 | ||
|
|
aacda65d8c | ||
|
|
3634b5a10f | ||
|
|
afecc263a3 | ||
|
|
d52e38a323 | ||
|
|
d6fce4931d | ||
|
|
1437f79458 | ||
|
|
2fefff0536 | ||
|
|
d8ff5a27df | ||
|
|
29de473e0e | ||
|
|
4aa004e4e7 | ||
|
|
a6cab5f439 | ||
|
|
aa43d2e7e8 | ||
|
|
54ddb902e4 | ||
|
|
efd973ff63 | ||
|
|
d64cc63a96 | ||
|
|
6aefdadc36 | ||
|
|
93f763db1c | ||
|
|
4837ab6a60 | ||
|
|
81ea206f48 | ||
|
|
be4454edc2 | ||
|
|
08e3f6ac1a | ||
|
|
a748857999 | ||
|
|
f98ca82bee | ||
|
|
86a7a7820f | ||
|
|
432c2e7751 | ||
|
|
b8c6c978b1 | ||
|
|
c7bac163c5 | ||
|
|
dc86024078 | ||
|
|
4444de1971 | ||
|
|
9bb90b0ff3 | ||
|
|
5520585ac9 | ||
|
|
3ec649c6c6 | ||
|
|
a1bdc99217 | ||
|
|
769e885492 | ||
|
|
cb0fe58ef4 | ||
|
|
4799a0a38d | ||
|
|
7bcadb7dc8 | ||
|
|
13f921bf0d | ||
|
|
9887daaded | ||
|
|
a57ab5ad72 | ||
|
|
341e0418e0 | ||
|
|
b1d397e375 | ||
|
|
f35979c7f5 | ||
|
|
27ad7d077f | ||
|
|
5596a3bc99 | ||
|
|
933177948d | ||
|
|
ac4820da9f | ||
|
|
36d6df7885 | ||
|
|
d97cd56d1f | ||
|
|
cbc34bdd6d | ||
|
|
81759c4dfc | ||
|
|
03d7246b4a | ||
|
|
9cc0d780fb | ||
|
|
db15482adc | ||
|
|
6c5ea68573 | ||
|
|
a0f2ab2a2d | ||
|
|
24d52ca395 | ||
|
|
e66924271d | ||
|
|
cb6fb3e47b | ||
|
|
32b4b9c132 | ||
|
|
10a4d5a0e6 | ||
|
|
7d5641860a | ||
|
|
e3ae899a5a | ||
|
|
53a89a173a | ||
|
|
0cf9bd90c4 | ||
|
|
0295552a7a | ||
|
|
c5488a8fc0 | ||
|
|
ef94b69718 | ||
|
|
8d53b5c60e | ||
|
|
3d3bc9b692 | ||
|
|
e6e5885fa2 | ||
|
|
99a372fb88 | ||
|
|
37baabe134 | ||
|
|
19b8117903 | ||
|
|
2f3b64a742 | ||
|
|
aec5356a0c | ||
|
|
eca9e04c20 | ||
|
|
1b9a7d2f52 | ||
|
|
7666d32e97 | ||
|
|
7d3790fc18 | ||
|
|
39a01cda29 | ||
|
|
915bb3ea78 | ||
|
|
aeadb5c375 | ||
|
|
e48b6940e7 | ||
|
|
6eb3dfd3c2 | ||
|
|
4682056058 | ||
|
|
2fb60a29de | ||
|
|
edb05e6e00 | ||
|
|
b8ecadb314 | ||
|
|
e15b908983 | ||
|
|
f9cf76234f | ||
|
|
590755071f | ||
|
|
6e8208cea8 | ||
|
|
0eec606ebe | ||
|
|
efd9c4c5e5 | ||
|
|
1c938516ee | ||
|
|
65028ed96f | ||
|
|
914476618d | ||
|
|
ceda8b1975 | ||
|
|
78cf608990 | ||
|
|
0df3909909 | ||
|
|
55205efe44 | ||
|
|
fa124b4fbe | ||
|
|
0eda4ff41d | ||
|
|
b401ab5081 | ||
|
|
0cf7e6f2eb | ||
|
|
0f67a71da2 | ||
|
|
6767d19c2c | ||
|
|
89af2b71e4 | ||
|
|
3809ce1546 | ||
|
|
3398cbf279 | ||
|
|
27870be241 | ||
|
|
c4ffaa4da2 | ||
|
|
14550db3b5 | ||
|
|
92d5eba499 | ||
|
|
31dcf031f3 | ||
|
|
61c7379312 | ||
|
|
ff08fffdce | ||
|
|
aa9cb52575 | ||
|
|
5bb43432b0 | ||
|
|
b17d296783 | ||
|
|
006d19cd63 | ||
|
|
a0001305cc | ||
|
|
e71fc3bb0e | ||
|
|
766e865bd2 | ||
|
|
880755125b | ||
|
|
dc19061c97 | ||
|
|
d04cf56f37 | ||
|
|
85cc619dc6 | ||
|
|
1b432ad1af | ||
|
|
62fe32fe31 | ||
|
|
4ee52a5062 | ||
|
|
1d50068003 | ||
|
|
4ceae3a43c | ||
|
|
e78756ccfa | ||
|
|
d618d05ee1 | ||
|
|
401a471748 | ||
|
|
74f3fb0ba2 | ||
|
|
0c20e788f2 | ||
|
|
1cbda51517 | ||
|
|
924bfdee2a | ||
|
|
7549ae2c11 | ||
|
|
fd0c6ea868 | ||
|
|
c3f82f51c9 | ||
|
|
2d6c96a89d | ||
|
|
86c378b561 | ||
|
|
c8f18adfc3 | ||
|
|
a761412bd9 | ||
|
|
b0f7dee463 | ||
|
|
7690a1c894 | ||
|
|
461bc9b931 | ||
|
|
06fe256f40 | ||
|
|
92d597608e | ||
|
|
5e8e6d2821 | ||
|
|
d46844fa7c | ||
|
|
f6824ce11c | ||
|
|
5f9ece92ae | ||
|
|
674d20bfb9 | ||
|
|
50bd34632d | ||
|
|
e316a5ebe1 | ||
|
|
db9a1826e5 | ||
|
|
02b1ccd521 | ||
|
|
d4929f06f8 | ||
|
|
6a31ef1f12 | ||
|
|
fc5b5368f1 | ||
|
|
e3b38d0b0a | ||
|
|
05cd7094a5 | ||
|
|
5cdcbbc604 | ||
|
|
f717cf3eda | ||
|
|
7254703449 | ||
|
|
8fed4181ed | ||
|
|
249b8762f8 | ||
|
|
93f112672e | ||
|
|
414e62503b | ||
|
|
9a16af37af | ||
|
|
9dbe6d9474 | ||
|
|
ec327411b7 | ||
|
|
ab796b6896 | ||
|
|
cb90635016 | ||
|
|
2e2d635f6e | ||
|
|
fe66252df7 | ||
|
|
8f66414be9 | ||
|
|
2378d4cc9d | ||
|
|
44fa68407d | ||
|
|
428ac54b08 | ||
|
|
911898371b | ||
|
|
d41676ec02 | ||
|
|
0dacb828b8 | ||
|
|
4897f3a87c | ||
|
|
9dcc76b218 | ||
|
|
faa04c188b | ||
|
|
7da5336158 | ||
|
|
563c1405d5 | ||
|
|
597397abfc | ||
|
|
febc77933f | ||
|
|
2460dfe6dc | ||
|
|
58f8b2aaef | ||
|
|
5829da5560 | ||
|
|
60e7875889 | ||
|
|
7d9454eed5 | ||
|
|
0e489aa898 | ||
|
|
98c0d53541 | ||
|
|
3a6b6cc7a3 | ||
|
|
59446e1853 | ||
|
|
4971e8ce14 | ||
|
|
0e7577b696 | ||
|
|
ff480aa226 | ||
|
|
32c3467c18 | ||
|
|
be46cc52f2 | ||
|
|
bbcb6b29c1 | ||
|
|
1b2f3ded58 | ||
|
|
d8e9849bb2 | ||
|
|
8a8c38ef91 | ||
|
|
16c0838124 | ||
|
|
b7ed564108 | ||
|
|
a549b7408a | ||
|
|
51f15603da | ||
|
|
b11499dee1 | ||
|
|
b405cbedf5 | ||
|
|
65c51eef3c | ||
|
|
513a5a7f8c | ||
|
|
a6f80eb246 | ||
|
|
856922f25c | ||
|
|
10b853b699 | ||
|
|
c140ea3451 | ||
|
|
01e2442409 | ||
|
|
62808822c6 | ||
|
|
9df9a645b0 | ||
|
|
e9d5f44c85 | ||
|
|
9a83f19a4e | ||
|
|
dc437084f2 | ||
|
|
085ee043d9 | ||
|
|
ac7e7b015b | ||
|
|
aeeee84530 | ||
|
|
0de11465d0 | ||
|
|
99adb8a3d6 | ||
|
|
da36dbd37b | ||
|
|
707f1b9041 | ||
|
|
097674ebca | ||
|
|
6d446dbafd | ||
|
|
4cbadb5ed1 | ||
|
|
117290e960 | ||
|
|
1d4c2c9078 | ||
|
|
3c0b33265b | ||
|
|
ceb6cfb83e | ||
|
|
819db2dbc0 | ||
|
|
81483aeec1 | ||
|
|
5f9e342960 | ||
|
|
c158c52837 | ||
|
|
9e2e1810ce | ||
|
|
72cf5d8ede | ||
|
|
cec0ef17e0 | ||
|
|
7e768a54d5 | ||
|
|
c302364dd7 | ||
|
|
3376f730a2 | ||
|
|
2247d8c3a2 | ||
|
|
0e7cb4cb42 | ||
|
|
812c0b34ea | ||
|
|
162b32a47b | ||
|
|
3b670c1f54 | ||
|
|
3448a23033 | ||
|
|
82297ba990 | ||
|
|
a23daeb5cf | ||
|
|
15ce12e7b7 | ||
|
|
9591e1012c | ||
|
|
b357cb54f0 | ||
|
|
d645b4ce6d | ||
|
|
7b576bcd1d | ||
|
|
c23d2a33da | ||
|
|
41eb89cdb1 | ||
|
|
fe9964a405 | ||
|
|
211def5b51 | ||
|
|
454a39f83d | ||
|
|
1f26bc6e8b | ||
|
|
96c6fbf1ed | ||
|
|
336399d482 | ||
|
|
8dba19694a | ||
|
|
1a57f656e8 | ||
|
|
6fa16ff49b | ||
|
|
d84dd8643f | ||
|
|
4a5bb7c761 | ||
|
|
4c6f10fc41 | ||
|
|
ce7400fa9d | ||
|
|
2271aed31f | ||
|
|
66375454a7 | ||
|
|
c6a8eba1e8 | ||
|
|
92872435c4 | ||
|
|
5459c5cc5b | ||
|
|
81703dfd0b | ||
|
|
8b3119cf83 | ||
|
|
e87f2f12ec | ||
|
|
49730157d8 | ||
|
|
c2075fee29 | ||
|
|
7f329bb484 | ||
|
|
65bdc2ed6f | ||
|
|
38c1c72d38 | ||
|
|
8eb432b0b0 | ||
|
|
5437d9db7c | ||
|
|
8850bc3dcd | ||
|
|
3b02596704 | ||
|
|
18c1425b8e | ||
|
|
957dd43aa3 | ||
|
|
2972dbeafb |
9
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
9
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -1,10 +1,6 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug/need-confirmation, kind/bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -13,7 +9,7 @@ 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 http://portainer.slack.com/
|
||||
Do you need help or have a question? Come chat with us on Slack http://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
|
||||
@@ -44,12 +40,9 @@ You can see how [here](https://documentation.portainer.io/archive/1.23.2/faq/#ho
|
||||
|
||||
- Portainer version:
|
||||
- Docker version (managed by Portainer):
|
||||
- Kubernetes version (managed by Portainer):
|
||||
- Platform (windows/linux):
|
||||
- Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`):
|
||||
- Browser:
|
||||
- Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commerical setup.
|
||||
- Have you reviewed our technical documentation and knowledge base? Yes/No
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
42
.github/ISSUE_TEMPLATE/Custom.md
vendored
42
.github/ISSUE_TEMPLATE/Custom.md
vendored
@@ -1,25 +1,17 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask us a question about Portainer usage or deployment
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
Before you start, we need a little bit more information from you:
|
||||
|
||||
Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commerical setup.
|
||||
|
||||
Have you reviewed our technical documentation and knowledge base? Yes/No
|
||||
|
||||
<!--
|
||||
|
||||
You can find more information about Portainer support framework policy here: https://old.portainer.io/2019/04/portainer-support-policy/
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://portainer.slack.com/
|
||||
|
||||
Also, be sure to check our FAQ and documentation first: https://documentation.portainer.io/
|
||||
-->
|
||||
|
||||
**Question**:
|
||||
How can I deploy Portainer on... ?
|
||||
---
|
||||
name: Question
|
||||
about: Ask us a question about Portainer usage or deployment
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
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 http://portainer.io/slack/
|
||||
|
||||
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
|
||||
-->
|
||||
|
||||
**Question**:
|
||||
How can I deploy Portainer on... ?
|
||||
|
||||
65
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
65
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
@@ -1,34 +1,31 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a feature/enhancement that should be added in Portainer
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Thanks for opening a feature request for Portainer !
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://portainer.slack.com/
|
||||
|
||||
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/
|
||||
-->
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a feature/enhancement that should be added in Portainer
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Thanks for opening a feature request for Portainer !
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://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://portainer.readthedocs.io
|
||||
-->
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
@@ -15,7 +15,6 @@ issues:
|
||||
- kind/question
|
||||
- kind/style
|
||||
- kind/workaround
|
||||
- kind/refactor
|
||||
- bug/need-confirmation
|
||||
- bug/confirmed
|
||||
- status/discuss
|
||||
|
||||
19
.github/workflows/rebase.yml
vendored
19
.github/workflows/rebase.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Automatic Rebase
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@1.4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,6 +8,10 @@ api/cmd/portainer/portainer*
|
||||
**/.vscode/tasks.json
|
||||
|
||||
.eslintcache
|
||||
.idea
|
||||
test/e2e/cypress/screenshots
|
||||
*.db
|
||||
*.log
|
||||
__debug_bin
|
||||
|
||||
api/docs
|
||||
.env
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Open Source License Attribution
|
||||
|
||||
This application uses Open Source components. You can find the source
|
||||
code of their open source projects along with license information below.
|
||||
We acknowledge and are grateful to these developers for their contributions
|
||||
to open source.
|
||||
|
||||
### [angular-json-tree](https://github.com/awendland/angular-json-tree)
|
||||
|
||||
by [Alex Wendland](https://github.com/awendland) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
### [caniuse-db](https://github.com/Fyrd/caniuse)
|
||||
|
||||
by [caniuse.com](caniuse.com) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
### [caniuse-lite](https://github.com/ben-eb/caniuse-lite)
|
||||
|
||||
by [caniuse.com](caniuse.com) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
### [spdx-exceptions](https://github.com/jslicense/spdx-exceptions.json)
|
||||
|
||||
by Kyle Mitchell using [SPDX](https://spdx.dev/) from Linux Foundation licensed under [CC BY 3.0 License](https://creativecommons.org/licenses/by/3.0/)
|
||||
|
||||
### [fontawesome-free](https://github.com/FortAwesome/Font-Awesome) Icons
|
||||
|
||||
by [Fort Awesome](https://fortawesome.com/) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
Portainer also contains the following code, which is licensed under the [MIT license](https://opensource.org/licenses/MIT):
|
||||
|
||||
UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io)
|
||||
|
||||
rdash-angular: Copyright (c) [2014][elliot hesp]
|
||||
@@ -127,9 +127,3 @@ When adding a new route to an existing handler use the following as a template (
|
||||
```
|
||||
|
||||
explanation about each line can be found (here)[https://github.com/swaggo/swag#api-operation]
|
||||
|
||||
## Licensing
|
||||
|
||||
See the [LICENSE](https://github.com/portainer/portainer/blob/develop/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
|
||||
|
||||
We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes.
|
||||
|
||||
15
README.md
15
README.md
@@ -28,15 +28,14 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart
|
||||
|
||||
## Getting started
|
||||
|
||||
- [Deploy Portainer](https://documentation.portainer.io/quickstart/)
|
||||
- [Deploy Portainer](https://www.portainer.io/installation/)
|
||||
- [Documentation](https://documentation.portainer.io)
|
||||
- [Building Portainer](https://documentation.portainer.io/contributing/instructions/)
|
||||
|
||||
## Getting help
|
||||
|
||||
For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products/portainer-business
|
||||
For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products-services/portainer-business-support/
|
||||
|
||||
For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/products/community-edition/customer-success
|
||||
For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/
|
||||
|
||||
- Issues: https://github.com/portainer/portainer/issues
|
||||
- FAQ: https://documentation.portainer.io
|
||||
@@ -45,7 +44,7 @@ For community support: You can find more information about Portainer's community
|
||||
## Reporting bugs and contributing
|
||||
|
||||
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
|
||||
- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://documentation.portainer.io/contributing/instructions/) to build it locally and make a pull request. We need all the help we can get!
|
||||
- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://www.portainer.io/documentation/how-to-contribute/) to build it locally and make a pull request. We need all the help we can get!
|
||||
|
||||
## Security
|
||||
|
||||
@@ -65,4 +64,8 @@ Portainer supports "Current - 2 docker versions only. Prior versions may operate
|
||||
|
||||
Portainer is licensed under the zlib license. See [LICENSE](./LICENSE) for reference.
|
||||
|
||||
Portainer also contains code from open source projects. See [ATTRIBUTIONS.md](./ATTRIBUTIONS.md) for a list.
|
||||
Portainer also contains the following code, which is licensed under the [MIT license](https://opensource.org/licenses/MIT):
|
||||
|
||||
UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io)
|
||||
|
||||
rdash-angular: Copyright (c) [2014][elliot hesp]
|
||||
|
||||
@@ -37,7 +37,7 @@ func (m *Monitor) Start() {
|
||||
case <-time.After(m.timeout):
|
||||
initialized, err := m.WasInitialized()
|
||||
if err != nil {
|
||||
logFatalf("%s", err)
|
||||
logFatalf("failed getting admin user: %s", err)
|
||||
}
|
||||
if !initialized {
|
||||
logFatalf("[FATAL] [internal,init] No administrator account was created in %f mins. Shutting down the Portainer instance for security reasons", m.timeout.Minutes())
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -27,7 +26,7 @@ func listFiles(dir string) []string {
|
||||
}
|
||||
|
||||
func Test_shouldCreateArhive(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
@@ -40,7 +39,7 @@ func Test_shouldCreateArhive(t *testing.T) {
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath)
|
||||
|
||||
extractionDir, _ := ioutils.TempDir("", "extract")
|
||||
extractionDir, _ := ioutil.TempDir("", "extract")
|
||||
defer os.RemoveAll(extractionDir)
|
||||
|
||||
cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir)
|
||||
@@ -63,7 +62,7 @@ func Test_shouldCreateArhive(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_shouldCreateArhiveXXXXX(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
@@ -76,7 +75,7 @@ func Test_shouldCreateArhiveXXXXX(t *testing.T) {
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath)
|
||||
|
||||
extractionDir, _ := ioutils.TempDir("", "extract")
|
||||
extractionDir, _ := ioutil.TempDir("", "extract")
|
||||
defer os.RemoveAll(extractionDir)
|
||||
|
||||
r, _ := os.Open(gzPath)
|
||||
|
||||
BIN
api/archive/testdata/sample_archive.zip
vendored
Normal file
BIN
api/archive/testdata/sample_archive.zip
vendored
Normal file
Binary file not shown.
@@ -3,10 +3,13 @@ package archive
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
|
||||
@@ -52,3 +55,60 @@ func extractFileFromArchive(file *zip.File, dest string) error {
|
||||
|
||||
return outFile.Close()
|
||||
}
|
||||
|
||||
// UnzipFile will decompress a zip archive, moving all files and folders
|
||||
// within the zip file (parameter 1) to an output directory (parameter 2).
|
||||
func UnzipFile(src string, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
p := filepath.Join(dest, f.Name)
|
||||
|
||||
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
|
||||
if !strings.HasPrefix(p, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("%s: illegal file path", p)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
// Make Folder
|
||||
os.MkdirAll(p, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
err = unzipFile(f, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzipFile(f *zip.File, p string) error {
|
||||
// Make File
|
||||
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
||||
}
|
||||
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
||||
}
|
||||
defer outFile.Close()
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
32
api/archive/zip_test.go
Normal file
32
api/archive/zip_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnzipFile(t *testing.T) {
|
||||
dir, err := ioutil.TempDir("", "unzip-test-")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dir)
|
||||
/*
|
||||
Archive structure.
|
||||
├── 0
|
||||
│ ├── 1
|
||||
│ │ └── 2.txt
|
||||
│ └── 1.txt
|
||||
└── 0.txt
|
||||
*/
|
||||
|
||||
err = UnzipFile("./testdata/sample_archive.zip", dir)
|
||||
|
||||
assert.NoError(t, err)
|
||||
archiveDir := dir + "/sample_archive"
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1", "2.txt"))
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -11,12 +12,40 @@ import (
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/portainer/portainer/api/s3"
|
||||
)
|
||||
|
||||
const rwxr__r__ os.FileMode = 0744
|
||||
|
||||
var filesToBackup = []string{"compose", "config.json", "custom_templates", "edge_jobs", "edge_stacks", "extensions", "portainer.key", "portainer.pub", "tls"}
|
||||
|
||||
func BackupToS3(settings portainer.S3BackupSettings, gate *offlinegate.OfflineGate, datastore portainer.DataStore, filestorePath string) error {
|
||||
archivePath, err := CreateBackupArchive(settings.Password, gate, datastore, filestorePath)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] failed to backup: %s \n", err)
|
||||
return err
|
||||
}
|
||||
archiveReader, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
log.Println("[ERROR] failed to open backup file")
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(filepath.Dir(archivePath))
|
||||
|
||||
archiveName := fmt.Sprintf("portainer-backup_%s", filepath.Base(archivePath))
|
||||
|
||||
s3session, err := s3.NewSession(settings.Region, settings.AccessKeyID, settings.SecretAccessKey)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] %s \n", err)
|
||||
return err
|
||||
}
|
||||
if err := s3.Upload(s3session, archiveReader, settings.BucketName, archiveName); err != nil {
|
||||
log.Printf("[ERROR] failed to upload backup to S3: %s \n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore portainer.DataStore, filestorePath string) (string, error) {
|
||||
unlock := gate.Lock()
|
||||
|
||||
118
api/backup/backup_scheduler.go
Normal file
118
api/backup/backup_scheduler.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// BackupScheduler orchestrates S3 settings and active backup cron jobs
|
||||
type BackupScheduler struct {
|
||||
cronmanager *cron.Cron
|
||||
s3backupService portainer.S3BackupService
|
||||
gate *offlinegate.OfflineGate
|
||||
datastore portainer.DataStore
|
||||
filestorePath string
|
||||
}
|
||||
|
||||
func NewBackupScheduler(offlineGate *offlinegate.OfflineGate, datastore portainer.DataStore, filestorePath string) *BackupScheduler {
|
||||
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
|
||||
s3backupService := datastore.S3Backup()
|
||||
|
||||
return &BackupScheduler{
|
||||
cronmanager: crontab,
|
||||
s3backupService: s3backupService,
|
||||
gate: offlineGate,
|
||||
datastore: datastore,
|
||||
filestorePath: filestorePath,
|
||||
}
|
||||
}
|
||||
|
||||
// Start fetches latest backup settings and starts cron job if configured
|
||||
func (s *BackupScheduler) Start() error {
|
||||
s.cronmanager.Start()
|
||||
|
||||
settings, err := s.s3backupService.GetSettings()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to fetch settings")
|
||||
}
|
||||
|
||||
if canBeScheduled(settings) {
|
||||
return s.startJob(settings)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the scheduler if it is running; otherwise it does nothing.
|
||||
// A context is returned so the caller can wait for running jobs to complete.
|
||||
func (s *BackupScheduler) Stop() context.Context {
|
||||
if s.cronmanager != nil {
|
||||
log.Println("[DEBUG] Stopping backup scheduler")
|
||||
return s.cronmanager.Stop()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates stored S3 backup settings and orchestrates cron jobs.
|
||||
// When scheduler has an active cron job, then it shuts it down.
|
||||
// When a provided settings has a cron, then starts a new cron job.
|
||||
// When ever current cron is being shut down, last cron error going to be dropped.
|
||||
func (s *BackupScheduler) Update(settings portainer.S3BackupSettings) error {
|
||||
|
||||
if err := s.s3backupService.UpdateSettings(settings); err != nil {
|
||||
return errors.Wrap(err, "failed to update settings")
|
||||
}
|
||||
|
||||
if err := s.stopJobs(); err != nil {
|
||||
return errors.Wrap(err, "failed to stop current cronjob")
|
||||
}
|
||||
|
||||
if canBeScheduled(settings) {
|
||||
return s.startJob(settings)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stops current backup cron job and drops last cron error if any
|
||||
func (s *BackupScheduler) stopJobs() error {
|
||||
// stopping all cron jobs as there should be only one (c)
|
||||
for _, job := range s.cronmanager.Entries() {
|
||||
s.cronmanager.Remove(job.ID)
|
||||
}
|
||||
|
||||
return s.s3backupService.DropStatus()
|
||||
}
|
||||
|
||||
func (s *BackupScheduler) startJob(settings portainer.S3BackupSettings) error {
|
||||
_, err := s.cronmanager.AddFunc(settings.CronRule, s.backup(settings))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to start a new backup cron job")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func canBeScheduled(s portainer.S3BackupSettings) bool {
|
||||
return s.AccessKeyID != "" && s.SecretAccessKey != "" && s.Region != "" && s.BucketName != "" && s.CronRule != ""
|
||||
}
|
||||
|
||||
func (s *BackupScheduler) backup(settings portainer.S3BackupSettings) func() {
|
||||
return func() {
|
||||
err := BackupToS3(settings, s.gate, s.datastore, s.filestorePath)
|
||||
status := portainer.S3BackupStatus{
|
||||
Failed: err != nil,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err = s.s3backupService.UpdateStatus(status); err != nil {
|
||||
log.Printf("[ERROR] failed to update status of last scheduled backup. Status: %+v . Err: %s \n", status, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
112
api/backup/backup_scheduler_test.go
Normal file
112
api/backup/backup_scheduler_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
i "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newScheduler(status *portainer.S3BackupStatus, settings *portainer.S3BackupSettings) *BackupScheduler {
|
||||
scheduler := NewBackupScheduler(nil, i.NewDatastore(i.WithS3BackupService(status, settings)), "")
|
||||
scheduler.Start()
|
||||
|
||||
return scheduler
|
||||
}
|
||||
|
||||
func settings(cronRule string,
|
||||
accessKeyID string,
|
||||
secretAccessKey string,
|
||||
region string,
|
||||
bucketName string) *portainer.S3BackupSettings {
|
||||
return &portainer.S3BackupSettings{
|
||||
CronRule: cronRule,
|
||||
AccessKeyID: accessKeyID,
|
||||
SecretAccessKey: secretAccessKey,
|
||||
Region: region,
|
||||
BucketName: bucketName,
|
||||
}
|
||||
}
|
||||
|
||||
func Test_startWithoutCron_shouldNotStartAJob(t *testing.T) {
|
||||
scheduler := newScheduler(&portainer.S3BackupStatus{}, &portainer.S3BackupSettings{})
|
||||
defer scheduler.Stop()
|
||||
|
||||
jobs := scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 0, "should have empty job list")
|
||||
}
|
||||
|
||||
func Test_startWitACron_shouldAlsoStartAJob(t *testing.T) {
|
||||
scheduler := newScheduler(&portainer.S3BackupStatus{}, settings("*/10 * * * *", "id", "key", "region", "bucket"))
|
||||
defer scheduler.Stop()
|
||||
|
||||
jobs := scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 1, "should have 1 active job")
|
||||
}
|
||||
|
||||
func Test_update_shouldDropStatus(t *testing.T) {
|
||||
storedStatus := &portainer.S3BackupStatus{Failed: true, Timestamp: time.Now().Add(-time.Hour)}
|
||||
scheduler := newScheduler(storedStatus, &portainer.S3BackupSettings{})
|
||||
defer scheduler.Stop()
|
||||
|
||||
scheduler.Update(*settings("*/10 * * * *", "id", "key", "region", "bucket"))
|
||||
assert.Equal(t, portainer.S3BackupStatus{}, *storedStatus, "stasus should be dropped")
|
||||
}
|
||||
|
||||
func Test_update_shouldUpdateSettings(t *testing.T) {
|
||||
storedSettings := &portainer.S3BackupSettings{}
|
||||
scheduler := newScheduler(&portainer.S3BackupStatus{}, storedSettings)
|
||||
defer scheduler.Stop()
|
||||
|
||||
newSettings := settings("", "id2", "key2", "region2", "bucket2")
|
||||
scheduler.Update(*newSettings)
|
||||
|
||||
assert.EqualValues(t, *storedSettings, *newSettings, "updated settings should match stored settings")
|
||||
}
|
||||
|
||||
func Test_updateWithCron_shouldStartAJob(t *testing.T) {
|
||||
scheduler := newScheduler(&portainer.S3BackupStatus{}, &portainer.S3BackupSettings{})
|
||||
defer scheduler.Stop()
|
||||
|
||||
jobs := scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 0, "should have empty job list upon startup")
|
||||
|
||||
scheduler.Update(*settings("*/10 * * * *", "id", "key", "region", "bucket"))
|
||||
|
||||
jobs = scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 1, "should have 1 active job")
|
||||
}
|
||||
|
||||
func Test_updateWithoutCron_shouldStopActiveJob(t *testing.T) {
|
||||
scheduler := newScheduler(&portainer.S3BackupStatus{}, &portainer.S3BackupSettings{})
|
||||
defer scheduler.Stop()
|
||||
|
||||
scheduler.Update(*settings("*/10 * * * *", "id", "key", "region", "bucket"))
|
||||
|
||||
jobs := scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 1, "should have 1 active job")
|
||||
|
||||
scheduler.Update(*settings("", "id2", "key2", "region2", "bucket2"))
|
||||
|
||||
jobs = scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 0, "should have no active jobs")
|
||||
}
|
||||
|
||||
func Test_updateWithACron_shouldStopActiveJob_andStartNewJob(t *testing.T) {
|
||||
scheduler := newScheduler(&portainer.S3BackupStatus{}, &portainer.S3BackupSettings{})
|
||||
defer scheduler.Stop()
|
||||
|
||||
scheduler.Update(*settings("*/10 * * * *", "id", "key", "region", "bucket"))
|
||||
|
||||
jobs := scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 1, "should have 1 active job")
|
||||
initJobId := jobs[0].ID
|
||||
|
||||
scheduler.Update(*settings("*/10 * * * *", "id", "key", "region", "bucket"))
|
||||
|
||||
jobs = scheduler.cronmanager.Entries()
|
||||
assert.Len(t, jobs, 1, "should have 1 active job")
|
||||
assert.NotEqual(t, initJobId, jobs[0].ID, "new job should have a diffent id")
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -31,7 +30,7 @@ func contains(t *testing.T, list []string, path string) {
|
||||
}
|
||||
|
||||
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := copyFile("does-not-exist", tmpdir)
|
||||
@@ -39,7 +38,7 @@ func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
@@ -53,7 +52,7 @@ func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
destination, _ := ioutils.TempDir("", "destination")
|
||||
destination, _ := ioutil.TempDir("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := copyDir("./test_assets/copy_test", destination)
|
||||
assert.Nil(t, err)
|
||||
@@ -66,7 +65,7 @@ func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := copyPath("does-not-exists", tmpdir)
|
||||
@@ -76,7 +75,7 @@ func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldCopyFile(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
@@ -92,7 +91,7 @@ func Test_backupPath_shouldCopyFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldCopyDir(t *testing.T) {
|
||||
destination, _ := ioutils.TempDir("", "destination")
|
||||
destination, _ := ioutil.TempDir("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := copyPath("./test_assets/copy_test", destination)
|
||||
assert.Nil(t, err)
|
||||
|
||||
181
api/bolt/backup.go
Normal file
181
api/bolt/backup.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
plog "github.com/portainer/portainer/api/bolt/log"
|
||||
)
|
||||
|
||||
var backupDefaults = struct {
|
||||
backupDir string
|
||||
editions []string
|
||||
databaseFileName string
|
||||
}{
|
||||
"backups",
|
||||
[]string{"CE", "BE", "EE"},
|
||||
databaseFileName,
|
||||
}
|
||||
|
||||
var backupLog = plog.NewScopedLog("bolt, backup")
|
||||
|
||||
//
|
||||
// Backup Helpers
|
||||
//
|
||||
|
||||
// createBackupFolders create initial folders for backups
|
||||
func (store *Store) createBackupFolders() {
|
||||
for _, e := range backupDefaults.editions {
|
||||
|
||||
p := path.Join(store.path, backupDefaults.backupDir, e)
|
||||
|
||||
if exists, _ := store.fileService.FileExists(p); !exists {
|
||||
err := os.MkdirAll(p, 0700)
|
||||
if err != nil {
|
||||
backupLog.Error("Error while creating backup folders", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) databasePath() string {
|
||||
return path.Join(store.path, databaseFileName)
|
||||
}
|
||||
|
||||
func (store *Store) editionBackupDir(edition portainer.SoftwareEdition) string {
|
||||
return path.Join(store.path, backupDefaults.backupDir, edition.GetEditionLabel())
|
||||
}
|
||||
|
||||
func (store *Store) copyDBFile(from string, to string) error {
|
||||
backupLog.Info(fmt.Sprintf("Copying db file from %s to %s", from, to))
|
||||
err := store.fileService.Copy(from, to, true)
|
||||
if err != nil {
|
||||
backupLog.Error("Failed", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// BackupOptions provide a helper to inject backup options
|
||||
type BackupOptions struct {
|
||||
Edition portainer.SoftwareEdition
|
||||
Version int
|
||||
BackupDir string
|
||||
BackupFileName string
|
||||
BackupPath string
|
||||
}
|
||||
|
||||
func (store *Store) setupOptions(options *BackupOptions) *BackupOptions {
|
||||
if options == nil {
|
||||
options = &BackupOptions{}
|
||||
}
|
||||
if options.Edition == 0 {
|
||||
options.Edition = store.edition()
|
||||
}
|
||||
if options.Version == 0 {
|
||||
options.Version, _ = store.version()
|
||||
}
|
||||
if options.BackupDir == "" {
|
||||
options.BackupDir = store.editionBackupDir(options.Edition)
|
||||
}
|
||||
if options.BackupFileName == "" {
|
||||
options.BackupFileName = fmt.Sprintf("%s.%s.%s", backupDefaults.databaseFileName, fmt.Sprintf("%03d", options.Version), time.Now().Format("20060102150405"))
|
||||
}
|
||||
if options.BackupPath == "" {
|
||||
options.BackupPath = path.Join(options.BackupDir, options.BackupFileName)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (store *Store) listEditionBackups(edition portainer.SoftwareEdition) ([]string, error) {
|
||||
var fileNames = []string{}
|
||||
|
||||
files, err := ioutil.ReadDir(store.editionBackupDir(edition))
|
||||
|
||||
if err != nil {
|
||||
backupLog.Error("Error while retrieving backup files", err)
|
||||
return fileNames, err
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
fileNames = append(fileNames, f.Name())
|
||||
}
|
||||
|
||||
return fileNames, nil
|
||||
}
|
||||
|
||||
func (store *Store) lastestEditionBackup() (string, error) {
|
||||
edition := store.edition()
|
||||
|
||||
files, err := store.listEditionBackups(edition)
|
||||
if err != nil {
|
||||
backupLog.Error("Error while retrieving backup files", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return files[len(files)-1], nil
|
||||
}
|
||||
|
||||
// BackupWithOptions backup current database with options
|
||||
func (store *Store) BackupWithOptions(options *BackupOptions) (string, error) {
|
||||
backupLog.Info("creating db backup")
|
||||
store.createBackupFolders()
|
||||
|
||||
options = store.setupOptions(options)
|
||||
|
||||
return options.BackupPath, store.copyDBFile(store.databasePath(), options.BackupPath)
|
||||
}
|
||||
|
||||
// Backup current database with default options
|
||||
func (store *Store) Backup() (string, error) {
|
||||
return store.BackupWithOptions(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 {
|
||||
|
||||
// Check if backup file exist before restoring
|
||||
|
||||
options = store.setupOptions(options)
|
||||
|
||||
_, err := os.Stat(options.BackupPath)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
backupLog.Error(fmt.Sprintf("Backup file to restore does not exist %s", options.BackupPath), err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = store.Close()
|
||||
if err != nil {
|
||||
backupLog.Error("Error while closing store before restore", err)
|
||||
return err
|
||||
}
|
||||
backupLog.Info("Restoring db backup")
|
||||
err = store.copyDBFile(options.BackupPath, store.databasePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.Open()
|
||||
}
|
||||
|
||||
// Restore previously saved backup for the current Edition with default options
|
||||
func (store *Store) Restore() error {
|
||||
var options = &BackupOptions{}
|
||||
var err error
|
||||
options.BackupFileName, err = store.lastestEditionBackup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.RestoreWithOptions(options)
|
||||
|
||||
}
|
||||
118
api/bolt/backup_test.go
Normal file
118
api/bolt/backup_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func TestCreateBackupFolders(t *testing.T) {
|
||||
store := NewTestStore(portainer.PortainerEE, portainer.DBVersionEE, false)
|
||||
if exists, _ := store.fileService.FileExists("tmp/backups"); exists {
|
||||
t.Error("Expect backups folder to not exist")
|
||||
}
|
||||
store.createBackupFolders()
|
||||
if exists, _ := store.fileService.FileExists("tmp/backups"); !exists {
|
||||
t.Error("Expect backups folder to exist")
|
||||
}
|
||||
store.createBackupFolders()
|
||||
store.Close()
|
||||
teardown()
|
||||
}
|
||||
|
||||
func TestStoreCreation(t *testing.T) {
|
||||
store := NewTestStore(portainer.PortainerEE, portainer.DBVersionEE, false)
|
||||
if store == nil {
|
||||
t.Error("Expect to create a store")
|
||||
}
|
||||
|
||||
if store.edition() != portainer.PortainerEE {
|
||||
t.Error("Expect to get EE Edition")
|
||||
}
|
||||
|
||||
version, err := store.version()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if version != portainer.DBVersionEE {
|
||||
t.Error("Expect to get EE DBVersion")
|
||||
}
|
||||
|
||||
store.Close()
|
||||
teardown()
|
||||
}
|
||||
|
||||
func TestBackup(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
edition portainer.SoftwareEdition
|
||||
version int
|
||||
}{
|
||||
{edition: portainer.PortainerCE, version: portainer.DBVersion},
|
||||
{edition: portainer.PortainerEE, version: portainer.DBVersionEE},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
backupFileName := fmt.Sprintf("tmp/backups/%s/portainer.db.%03d.*", tc.edition.GetEditionLabel(), tc.version)
|
||||
t.Run(fmt.Sprintf("Backup should create %s", backupFileName), func(t *testing.T) {
|
||||
store := NewTestStore(tc.edition, tc.version, false)
|
||||
store.Backup()
|
||||
|
||||
if !isFileExist(backupFileName) {
|
||||
t.Errorf("Expect backup file to be created %s", backupFileName)
|
||||
}
|
||||
store.Close()
|
||||
})
|
||||
}
|
||||
t.Run("BackupWithOption should create a name specific backup", func(t *testing.T) {
|
||||
edition := portainer.PortainerCE
|
||||
version := portainer.DBVersion
|
||||
store := NewTestStore(edition, version, false)
|
||||
store.BackupWithOptions(&BackupOptions{
|
||||
BackupFileName: beforePortainerUpgradeToEEBackup,
|
||||
Edition: portainer.PortainerCE,
|
||||
})
|
||||
backupFileName := fmt.Sprintf("tmp/backups/%s/%s", edition.GetEditionLabel(), beforePortainerUpgradeToEEBackup)
|
||||
if !isFileExist(backupFileName) {
|
||||
t.Errorf("Expect backup file to be created %s", backupFileName)
|
||||
}
|
||||
store.Close()
|
||||
})
|
||||
|
||||
teardown()
|
||||
}
|
||||
|
||||
// TODO restore / backup failed test cases
|
||||
func TestRestore(t *testing.T) {
|
||||
|
||||
editions := []portainer.SoftwareEdition{portainer.PortainerCE, portainer.PortainerEE}
|
||||
var currentVersion = 0
|
||||
|
||||
for i, e := range editions {
|
||||
editionLabel := e.GetEditionLabel()
|
||||
currentVersion = 10 ^ i + 1
|
||||
store := NewTestStore(e, currentVersion, false)
|
||||
t.Run(fmt.Sprintf("Basic Restore for %s", editionLabel), func(t *testing.T) {
|
||||
store.Backup()
|
||||
updateVersion(store, currentVersion+1)
|
||||
testVersion(store, currentVersion+1, t)
|
||||
store.Restore()
|
||||
testVersion(store, currentVersion, t)
|
||||
})
|
||||
t.Run(fmt.Sprintf("Basic Restore After Multiple Backup for %s", editionLabel), func(t *testing.T) {
|
||||
currentVersion = currentVersion + 5
|
||||
updateVersion(store, currentVersion)
|
||||
store.Backup()
|
||||
updateVersion(store, currentVersion+2)
|
||||
testVersion(store, currentVersion+2, t)
|
||||
store.Restore()
|
||||
testVersion(store, currentVersion, t)
|
||||
})
|
||||
store.Close()
|
||||
}
|
||||
|
||||
teardown()
|
||||
}
|
||||
73
api/bolt/bolttest/datastore.go
Normal file
73
api/bolt/bolttest/datastore.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package bolttest
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
var errTempDir = errors.New("can't create a temp dir")
|
||||
|
||||
func MustNewTestStore(init bool) (*bolt.Store, func()) {
|
||||
store, teardown, err := NewTestStore(init)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errTempDir) {
|
||||
teardown()
|
||||
}
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return store, teardown
|
||||
}
|
||||
|
||||
func NewTestStore(init bool) (*bolt.Store, func(), error) {
|
||||
// Creates unique temp directory in a concurrency friendly manner.
|
||||
dataStorePath, err := ioutil.TempDir("", "boltdb")
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errTempDir, err.Error())
|
||||
}
|
||||
|
||||
fileService, err := filesystem.NewService(dataStorePath, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
store, err := bolt.NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = store.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if init {
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
teardown := func() {
|
||||
teardown(store, dataStorePath)
|
||||
}
|
||||
|
||||
return store, teardown, nil
|
||||
}
|
||||
|
||||
func teardown(store *bolt.Store, dataStorePath string) {
|
||||
err := store.Close()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(dataStorePath)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package bolt
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
"github.com/portainer/portainer/api/bolt/license"
|
||||
"github.com/portainer/portainer/api/bolt/s3backup"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/customtemplate"
|
||||
@@ -16,10 +20,7 @@ import (
|
||||
"github.com/portainer/portainer/api/bolt/endpoint"
|
||||
"github.com/portainer/portainer/api/bolt/endpointgroup"
|
||||
"github.com/portainer/portainer/api/bolt/endpointrelation"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/bolt/extension"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
"github.com/portainer/portainer/api/bolt/migrator"
|
||||
"github.com/portainer/portainer/api/bolt/registry"
|
||||
"github.com/portainer/portainer/api/bolt/resourcecontrol"
|
||||
"github.com/portainer/portainer/api/bolt/role"
|
||||
@@ -33,10 +34,9 @@ import (
|
||||
"github.com/portainer/portainer/api/bolt/user"
|
||||
"github.com/portainer/portainer/api/bolt/version"
|
||||
"github.com/portainer/portainer/api/bolt/webhook"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
const (
|
||||
var (
|
||||
databaseFileName = "portainer.db"
|
||||
)
|
||||
|
||||
@@ -56,9 +56,11 @@ type Store struct {
|
||||
EndpointService *endpoint.Service
|
||||
EndpointRelationService *endpointrelation.Service
|
||||
ExtensionService *extension.Service
|
||||
LicenseService *license.Service
|
||||
RegistryService *registry.Service
|
||||
ResourceControlService *resourcecontrol.Service
|
||||
RoleService *role.Service
|
||||
S3BackupService *s3backup.Service
|
||||
ScheduleService *schedule.Service
|
||||
SettingsService *settings.Service
|
||||
StackService *stack.Service
|
||||
@@ -71,6 +73,14 @@ type Store struct {
|
||||
WebhookService *webhook.Service
|
||||
}
|
||||
|
||||
func (store *Store) version() (int, error) {
|
||||
version, err := store.VersionService.DBVersion()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
version = 0
|
||||
}
|
||||
return version, err
|
||||
}
|
||||
|
||||
func (store *Store) edition() portainer.SoftwareEdition {
|
||||
edition, err := store.VersionService.Edition()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
@@ -127,63 +137,6 @@ func (store *Store) IsNew() bool {
|
||||
return store.isNew
|
||||
}
|
||||
|
||||
// CheckCurrentEdition checks if current edition is community edition
|
||||
func (store *Store) CheckCurrentEdition() error {
|
||||
if store.edition() != portainer.PortainerCE {
|
||||
return errors.ErrWrongDBEdition
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateData automatically migrate the data based on the DBVersion.
|
||||
// This process is only triggered on an existing database, not if the database was just created.
|
||||
// if force is true, then migrate regardless.
|
||||
func (store *Store) MigrateData(force bool) error {
|
||||
if store.isNew && !force {
|
||||
return store.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
}
|
||||
|
||||
version, err := store.VersionService.DBVersion()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
version = 0
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if version < portainer.DBVersion {
|
||||
migratorParams := &migrator.Parameters{
|
||||
DB: store.connection.DB,
|
||||
DatabaseVersion: version,
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
EndpointService: store.EndpointService,
|
||||
EndpointRelationService: store.EndpointRelationService,
|
||||
ExtensionService: store.ExtensionService,
|
||||
RegistryService: store.RegistryService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
RoleService: store.RoleService,
|
||||
ScheduleService: store.ScheduleService,
|
||||
SettingsService: store.SettingsService,
|
||||
StackService: store.StackService,
|
||||
TagService: store.TagService,
|
||||
TeamMembershipService: store.TeamMembershipService,
|
||||
UserService: store.UserService,
|
||||
VersionService: store.VersionService,
|
||||
FileService: store.fileService,
|
||||
AuthorizationService: authorization.NewService(store),
|
||||
}
|
||||
migrator := migrator.NewMigrator(migratorParams)
|
||||
|
||||
log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion)
|
||||
err = migrator.Migrate()
|
||||
if err != nil {
|
||||
log.Printf("An error occurred during database migration: %s\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupTo backs up db to a provided writer.
|
||||
// It does hot backup and doesn't block other database reads and writes
|
||||
func (store *Store) BackupTo(w io.Writer) error {
|
||||
|
||||
@@ -4,5 +4,5 @@ import "errors"
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("Object not found inside the database")
|
||||
ErrWrongDBEdition = errors.New("The Portainer database is set for Portainer Business Edition, please follow the instructions in our documention to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
ErrMigrationToCE = errors.New("DB is already on CE edition")
|
||||
)
|
||||
|
||||
115
api/bolt/helpers_test.go
Normal file
115
api/bolt/helpers_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
var (
|
||||
dataStorePath string
|
||||
testBackupPath string
|
||||
)
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
databaseFileName = fmt.Sprintf("portainer-%08d.db", rand.Intn(100000000))
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
dataStorePath = path.Join(pwd, "tmp")
|
||||
testBackupPath = path.Join(dataStorePath, "backups")
|
||||
|
||||
teardown()
|
||||
}
|
||||
|
||||
func NewTestStore(edition portainer.SoftwareEdition, version int, init bool) *Store {
|
||||
fileService, err := filesystem.NewService(dataStorePath, "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.Open()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if init {
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = store.VersionService.StoreEdition(edition)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.VersionService.StoreDBVersion(version)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
err := os.RemoveAll(testBackupPath)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(path.Join(dataStorePath, "portainer-*.*"))
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := os.Remove(f); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isFileExist(path string) bool {
|
||||
matches, err := filepath.Glob(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(matches) > 0
|
||||
}
|
||||
|
||||
func updateVersion(store *Store, v int) {
|
||||
err := store.VersionService.StoreDBVersion(v)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func testVersion(store *Store, versionWant int, t *testing.T) {
|
||||
if v, _ := store.version(); v != versionWant {
|
||||
t.Errorf("Expect store version to be %d but was %d", versionWant, v)
|
||||
}
|
||||
}
|
||||
|
||||
func testEdition(store *Store, editionWant portainer.SoftwareEdition, t *testing.T) {
|
||||
if e := store.edition(); e != editionWant {
|
||||
t.Errorf("Expect store edition to be %s but was %s", editionWant.GetEditionLabel(), e.GetEditionLabel())
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func (store *Store) Init() error {
|
||||
AnonymousMode: true,
|
||||
AutoCreateUsers: true,
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
URLs: []string{},
|
||||
SearchSettings: []portainer.LDAPSearchSettings{
|
||||
portainer.LDAPSearchSettings{},
|
||||
},
|
||||
@@ -40,8 +41,11 @@ func (store *Store) Init() error {
|
||||
portainer.LDAPGroupSearchSettings{},
|
||||
},
|
||||
},
|
||||
OAuthSettings: portainer.OAuthSettings{},
|
||||
|
||||
OAuthSettings: portainer.OAuthSettings{
|
||||
TeamMemberships: portainer.TeamMemberships{
|
||||
OAuthClaimMappings: make([]portainer.OAuthClaimMappings, 0),
|
||||
},
|
||||
},
|
||||
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
|
||||
TemplatesURL: portainer.DefaultTemplatesURL,
|
||||
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
|
||||
@@ -92,5 +96,17 @@ func (store *Store) Init() error {
|
||||
}
|
||||
}
|
||||
|
||||
roles, err := store.RoleService.Roles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(roles) == 0 {
|
||||
err := store.RoleService.CreateOrUpdatePredefinedRoles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
92
api/bolt/license/license.go
Normal file
92
api/bolt/license/license.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"github.com/portainer/liblicense"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
const (
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
BucketName = "license"
|
||||
)
|
||||
|
||||
// Service represents a service for managing endpoint data.
|
||||
type Service struct {
|
||||
connection *internal.DbConnection
|
||||
}
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(connection *internal.DbConnection) (*Service, error) {
|
||||
err := internal.CreateBucket(connection, BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
connection: connection,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// License returns a license by licenseKey
|
||||
func (service *Service) License(licenseKey string) (*liblicense.PortainerLicense, error) {
|
||||
var license liblicense.PortainerLicense
|
||||
identifier := []byte(licenseKey)
|
||||
|
||||
err := internal.GetObject(service.connection, BucketName, identifier, &license)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &license, nil
|
||||
}
|
||||
|
||||
// Licenses return an array containing all the licenses.
|
||||
func (service *Service) Licenses() ([]liblicense.PortainerLicense, error) {
|
||||
var licenses = make([]liblicense.PortainerLicense, 0)
|
||||
|
||||
err := service.connection.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var license liblicense.PortainerLicense
|
||||
err := internal.UnmarshalObject(v, &license)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
licenses = append(licenses, license)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return licenses, err
|
||||
}
|
||||
|
||||
// AddLicense persists a license inside the database.
|
||||
func (service *Service) AddLicense(licenseKey string, license *liblicense.PortainerLicense) error {
|
||||
return service.connection.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
data, err := internal.MarshalObject(license)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bucket.Put([]byte(licenseKey), data)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateLicense updates a license.
|
||||
func (service *Service) UpdateLicense(licenseKey string, license *liblicense.PortainerLicense) error {
|
||||
identifier := []byte(licenseKey)
|
||||
return internal.UpdateObject(service.connection, BucketName, identifier, license)
|
||||
}
|
||||
|
||||
// DeleteLicense deletes a License.
|
||||
func (service *Service) DeleteLicense(licenseKey string) error {
|
||||
identifier := []byte(licenseKey)
|
||||
return internal.DeleteObject(service.connection, BucketName, identifier)
|
||||
}
|
||||
41
api/bolt/log/log.go
Normal file
41
api/bolt/log/log.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
const (
|
||||
INFO = "INFO"
|
||||
ERROR = "ERROR"
|
||||
DEBUG = "DEBUG"
|
||||
FATAL = "FATAL"
|
||||
)
|
||||
|
||||
type ScopedLog struct {
|
||||
scope string
|
||||
}
|
||||
|
||||
func NewScopedLog(scope string) *ScopedLog {
|
||||
return &ScopedLog{scope: scope}
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) print(kind string, message string) {
|
||||
log.Printf("[%s] [%s] %s", kind, slog.scope, message)
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) Debug(message string) {
|
||||
slog.print(DEBUG, fmt.Sprintf("[message: %s]", message))
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) Info(message string) {
|
||||
slog.print(INFO, fmt.Sprintf("[message: %s]", message))
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) Error(message string, err error) {
|
||||
slog.print(ERROR, fmt.Sprintf("[message: %s] [error: %s]", message, err))
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) NotImplemented(method string) {
|
||||
log.Fatalf("[%s] [%s] [%s]", FATAL, slog.scope, fmt.Sprintf("%s is not yet implemented", method))
|
||||
}
|
||||
1
api/bolt/log/log.test.go
Normal file
1
api/bolt/log/log.test.go
Normal file
@@ -0,0 +1 @@
|
||||
package log
|
||||
207
api/bolt/migrate_data.go
Normal file
207
api/bolt/migrate_data.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
errors "github.com/portainer/portainer/api/bolt/errors"
|
||||
plog "github.com/portainer/portainer/api/bolt/log"
|
||||
"github.com/portainer/portainer/api/bolt/migrator"
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
const beforePortainerUpgradeToEEBackup = "portainer.db.before-EE-upgrade"
|
||||
|
||||
var migrateLog = plog.NewScopedLog("bolt, migrate")
|
||||
|
||||
// FailSafeMigrate backup and restore DB if migration fail
|
||||
func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version int) error {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
migrateLog.Info(fmt.Sprintf("Error during migration, recovering [%v]", err))
|
||||
store.Restore()
|
||||
}
|
||||
}()
|
||||
return migrator.Migrate(version)
|
||||
}
|
||||
|
||||
// MigrateData automatically migrate the data based on the DBVersion.
|
||||
// This process is only triggered on an existing database, not if the database was just created.
|
||||
// if force is true, then migrate regardless.
|
||||
func (store *Store) MigrateData(force bool) error {
|
||||
// 0 – if DB is new then we don't need to migrate any data and just set version and edition to latest EE
|
||||
if store.isNew && !force {
|
||||
err := store.VersionService.StoreDBVersion(portainer.DBVersionEE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = store.VersionService.StoreEdition(portainer.PortainerEE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
migrator, err := store.newMigrator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if migrator.Edition() == portainer.PortainerCE {
|
||||
// backup before migrating
|
||||
store.BackupWithOptions(&BackupOptions{
|
||||
BackupFileName: beforePortainerUpgradeToEEBackup,
|
||||
Edition: portainer.PortainerCE,
|
||||
})
|
||||
|
||||
store.VersionService.StorePreviousDBVersion(migrator.Version())
|
||||
|
||||
// 1 – We need to migrate DB to latest CE version
|
||||
|
||||
if migrator.Version() < portainer.DBVersion {
|
||||
store.Backup()
|
||||
err = store.FailSafeMigrate(migrator, portainer.DBVersion)
|
||||
if err != nil {
|
||||
store.Restore()
|
||||
migrateLog.Error("An error occurred while migrating CE database to latest version", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if portainer.Edition == portainer.PortainerEE {
|
||||
// 2 – if DB is CE Edition we need to upgrade settings to EE
|
||||
if migrator.Edition() < portainer.PortainerEE {
|
||||
err = migrator.UpgradeToEE()
|
||||
if err != nil {
|
||||
migrateLog.Error("An error occurred while upgrading database to EE", err)
|
||||
store.RollbackFailedUpgradeToEE()
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 3 – if DB is EE Edition we need to migrate to latest version of EE
|
||||
if migrator.Edition() == portainer.PortainerEE && migrator.Version() < portainer.DBVersionEE {
|
||||
store.Backup()
|
||||
err = store.FailSafeMigrate(migrator, portainer.DBVersionEE)
|
||||
if err != nil {
|
||||
migrateLog.Error("An error occurred while migrating EE database to latest version", err)
|
||||
store.Restore()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackFailedUpgradeToEE down migrate to previous version
|
||||
func (store *Store) RollbackFailedUpgradeToEE() error {
|
||||
return store.RestoreWithOptions(&BackupOptions{
|
||||
BackupFileName: beforePortainerUpgradeToEEBackup,
|
||||
Edition: portainer.PortainerCE,
|
||||
})
|
||||
}
|
||||
|
||||
// RollbackToCE rollbacks the store to the current ce version
|
||||
func (store *Store) RollbackToCE() error {
|
||||
|
||||
migrator, err := store.newMigrator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrateLog.Info(fmt.Sprintf("Current Software Edition: %s", migrator.Edition().GetEditionLabel()))
|
||||
migrateLog.Info(fmt.Sprintf("Current DB Version: %d", migrator.Version()))
|
||||
|
||||
if migrator.Edition() == portainer.PortainerCE {
|
||||
return errors.ErrMigrationToCE
|
||||
}
|
||||
|
||||
previousVersion, err := store.VersionService.PreviousDBVersion()
|
||||
if err != nil {
|
||||
migrateLog.Error("An Error occurred with retrieving previous DB version", err)
|
||||
return err
|
||||
}
|
||||
|
||||
confirmed, err := cli.Confirm(fmt.Sprintf("Are you sure you want to rollback your database to %d?", previousVersion))
|
||||
if err != nil || !confirmed {
|
||||
return err
|
||||
}
|
||||
|
||||
if previousVersion < 25 {
|
||||
migrator.DowngradeSettingsFrom25()
|
||||
}
|
||||
|
||||
err = store.VersionService.StoreDBVersion(previousVersion)
|
||||
if err != nil {
|
||||
migrateLog.Error(fmt.Sprintf("An Error occurred with rolling back to CE Edition, DB Version %d", previousVersion), err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = store.VersionService.StoreEdition(portainer.PortainerCE)
|
||||
if err != nil {
|
||||
migrateLog.Error(fmt.Sprintf("An Error occurred with rolling back to CE Edition, DB Version %d", previousVersion), err)
|
||||
return err
|
||||
}
|
||||
|
||||
migrateLog.Info(fmt.Sprintf("Rolled back to CE Edition, DB Version %d", previousVersion))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) newMigrator() (*migrator.Migrator, error) {
|
||||
|
||||
version, err := store.version()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edition := store.edition()
|
||||
|
||||
params := &migrator.Parameters{
|
||||
DB: store.connection.DB,
|
||||
DatabaseVersion: version,
|
||||
CurrentEdition: edition,
|
||||
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
EndpointService: store.EndpointService,
|
||||
EndpointRelationService: store.EndpointRelationService,
|
||||
ExtensionService: store.ExtensionService,
|
||||
RegistryService: store.RegistryService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
RoleService: store.RoleService,
|
||||
ScheduleService: store.ScheduleService,
|
||||
SettingsService: store.SettingsService,
|
||||
StackService: store.StackService,
|
||||
TagService: store.TagService,
|
||||
TeamMembershipService: store.TeamMembershipService,
|
||||
UserService: store.UserService,
|
||||
VersionService: store.VersionService,
|
||||
FileService: store.fileService,
|
||||
AuthorizationService: authorization.NewService(store),
|
||||
}
|
||||
|
||||
return migrator.NewMigrator(params), nil
|
||||
}
|
||||
|
||||
// RollbackVersion down migrate to previous version
|
||||
func (store *Store) RollbackVersion(version int) error {
|
||||
// TODO
|
||||
backupLog.NotImplemented("RollbackVersion")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackEdition downgrade to previous edition
|
||||
func (store *Store) RollbackEdition(edition portainer.SoftwareEdition) error {
|
||||
// TODO
|
||||
backupLog.NotImplemented("RollbackEdition")
|
||||
// Change Edition
|
||||
// Migrate Services
|
||||
// Restore Latest
|
||||
return nil
|
||||
}
|
||||
99
api/bolt/migrate_data_test.go
Normal file
99
api/bolt/migrate_data_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"testing"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
// New Database should be EE and DBVersion
|
||||
//
|
||||
|
||||
func TestMigrateData(t *testing.T) {
|
||||
var store *Store
|
||||
|
||||
t.Run("MigrateData for New Store", func(t *testing.T) {
|
||||
fileService, err := filesystem.NewService(dataStorePath, "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.Open()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
store.MigrateData(false)
|
||||
|
||||
testVersion(store, portainer.DBVersionEE, t)
|
||||
testEdition(store, portainer.PortainerEE, t)
|
||||
|
||||
store.Close()
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
edition portainer.SoftwareEdition
|
||||
version int
|
||||
expectedVersion int
|
||||
}{
|
||||
{edition: portainer.PortainerCE, version: 5, expectedVersion: portainer.DBVersionEE},
|
||||
{edition: portainer.PortainerCE, version: 21, expectedVersion: portainer.DBVersionEE},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
store = NewTestStore(tc.edition, tc.version, true)
|
||||
t.Run(fmt.Sprintf("MigrateData for %s version %d", tc.edition.GetEditionLabel(), tc.version), func(t *testing.T) {
|
||||
store.MigrateData(false)
|
||||
testVersion(store, tc.expectedVersion, t)
|
||||
testEdition(store, portainer.PortainerEE, t)
|
||||
})
|
||||
|
||||
t.Run(fmt.Sprintf("Restoring DB after migrateData for %s version %d", tc.edition.GetEditionLabel(), tc.version), func(t *testing.T) {
|
||||
store.RollbackToCE()
|
||||
testVersion(store, tc.version, t)
|
||||
testEdition(store, tc.edition, t)
|
||||
})
|
||||
|
||||
store.Close()
|
||||
}
|
||||
|
||||
t.Run("Error in MigrateData should restore backup before MigrateData", func(t *testing.T) {
|
||||
version := 21
|
||||
store = NewTestStore(portainer.PortainerCE, version, true)
|
||||
|
||||
deleteBucket(store.connection.DB, "settings")
|
||||
store.MigrateData(false)
|
||||
|
||||
testVersion(store, version, t)
|
||||
testEdition(store, portainer.PortainerCE, t)
|
||||
|
||||
store.Close()
|
||||
})
|
||||
|
||||
teardown()
|
||||
}
|
||||
|
||||
func deleteBucket(db *bolt.DB, bucketName string) {
|
||||
db.Update(func(tx *bolt.Tx) error {
|
||||
log.Printf("Delete bucket %s\n", bucketName)
|
||||
err := tx.DeleteBucket([]byte(bucketName))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
15
api/bolt/migrator/downgrade_dbversion25.go
Normal file
15
api/bolt/migrator/downgrade_dbversion25.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package migrator
|
||||
|
||||
// DowngradeSettingsFrom25 downgrade template settings for portainer v1.2
|
||||
func (migrator *Migrator) DowngradeSettingsFrom25() error {
|
||||
legacySettings, err := migrator.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
legacySettings.TemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-1.20.0.json"
|
||||
|
||||
err = migrator.settingsService.UpdateSettings(legacySettings)
|
||||
|
||||
return err
|
||||
}
|
||||
308
api/bolt/migrator/migrate_ce.go
Normal file
308
api/bolt/migrator/migrate_ce.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// MigrateCE checks the database version and migrate the existing data to the most recent data model.
|
||||
func (m *Migrator) MigrateCE() error {
|
||||
// Portainer < 1.12
|
||||
if m.currentDBVersion < 1 {
|
||||
err := m.updateAdminUserToDBVersion1()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.12.x
|
||||
if m.currentDBVersion < 2 {
|
||||
err := m.updateResourceControlsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.updateEndpointsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.13.x
|
||||
if m.currentDBVersion < 3 {
|
||||
err := m.updateSettingsToDBVersion3()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.14.0
|
||||
if m.currentDBVersion < 4 {
|
||||
err := m.updateEndpointsToDBVersion4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1235
|
||||
if m.currentDBVersion < 5 {
|
||||
err := m.updateSettingsToVersion5()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1236
|
||||
if m.currentDBVersion < 6 {
|
||||
err := m.updateSettingsToVersion6()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1449
|
||||
if m.currentDBVersion < 7 {
|
||||
err := m.updateSettingsToVersion7()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 8 {
|
||||
err := m.updateEndpointsToVersion8()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https: //github.com/portainer/portainer/issues/1396
|
||||
if m.currentDBVersion < 9 {
|
||||
err := m.updateEndpointsToVersion9()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/461
|
||||
if m.currentDBVersion < 10 {
|
||||
err := m.updateEndpointsToVersion10()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1906
|
||||
if m.currentDBVersion < 11 {
|
||||
err := m.updateEndpointsToVersion11()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.18.0
|
||||
if m.currentDBVersion < 12 {
|
||||
err := m.updateEndpointsToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateStacksToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.0
|
||||
if m.currentDBVersion < 13 {
|
||||
err := m.updateSettingsToVersion13()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.2
|
||||
if m.currentDBVersion < 14 {
|
||||
err := m.updateResourceControlsToDBVersion14()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.0
|
||||
if m.currentDBVersion < 15 {
|
||||
err := m.updateSettingsToDBVersion15()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateTemplatesToVersion15()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 16 {
|
||||
err := m.updateSettingsToDBVersion16()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.1
|
||||
if m.currentDBVersion < 17 {
|
||||
err := m.updateExtensionsToDBVersion17()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.21.0
|
||||
if m.currentDBVersion < 18 {
|
||||
err := m.updateUsersToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointsToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateRegistriesToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.0
|
||||
if m.currentDBVersion < 19 {
|
||||
err := m.updateSettingsToDBVersion19()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.1
|
||||
if m.currentDBVersion < 20 {
|
||||
err := m.updateUsersToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateSettingsToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateSchedulesToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.23.0
|
||||
// DBVersion 21 is missing as it was shipped as via hotfix 1.22.2
|
||||
if m.currentDBVersion < 22 {
|
||||
err := m.updateResourceControlsToDBVersion22()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateUsersAndRolesToDBVersion22()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.0
|
||||
if m.currentDBVersion < 23 {
|
||||
err := m.updateTagsToDBVersion23()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointsAndEndpointGroupsToDBVersion23()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.1
|
||||
if m.currentDBVersion < 24 {
|
||||
err := m.updateSettingsToDB24()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.0.0
|
||||
if m.currentDBVersion < 25 {
|
||||
err := m.updateSettingsToDB25()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateStacksToDB24()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.1.0
|
||||
if m.currentDBVersion < 26 {
|
||||
err := m.updateEndpointSettingsToDB26()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateRbacRolesToDB26()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.2.0
|
||||
if m.currentDBVersion < 27 {
|
||||
err := m.updateStackResourceControlToDB27()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer EE-2.4.0
|
||||
if m.currentDBVersion < 28 {
|
||||
err := m.updateUsersAndRolesToDBVersion28()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer EE-2.4.0
|
||||
if m.currentDBVersion < 29 {
|
||||
err := m.updateRbacRolesToDB29()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer EE-2.7.0
|
||||
if m.currentDBVersion < 31 {
|
||||
err := m.updateSettingsToDB31()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Update DB version to ", portainer.DBVersion)
|
||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||
}
|
||||
@@ -29,11 +29,6 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range legacyUsers {
|
||||
user.PortainerAuthorizations = authorization.DefaultPortainerAuthorizations()
|
||||
err = m.userService.UpdateUser(user.ID, &user)
|
||||
@@ -56,7 +51,7 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
|
||||
return err
|
||||
}
|
||||
helpDeskRole.Priority = 2
|
||||
helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers)
|
||||
helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole()
|
||||
|
||||
err = m.roleService.UpdateRole(helpDeskRole.ID, helpDeskRole)
|
||||
|
||||
@@ -65,7 +60,7 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
|
||||
return err
|
||||
}
|
||||
standardUserRole.Priority = 3
|
||||
standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers)
|
||||
standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole()
|
||||
|
||||
err = m.roleService.UpdateRole(standardUserRole.ID, standardUserRole)
|
||||
|
||||
@@ -74,7 +69,7 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
|
||||
return err
|
||||
}
|
||||
readOnlyUserRole.Priority = 4
|
||||
readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers)
|
||||
readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole()
|
||||
|
||||
err = m.roleService.UpdateRole(readOnlyUserRole.ID, readOnlyUserRole)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,9 +2,10 @@ package migrator
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
func (m *Migrator) updateEndpointSettingsToDB25() error {
|
||||
func (m *Migrator) updateEndpointSettingsToDB26() error {
|
||||
settings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -49,3 +50,27 @@ func (m *Migrator) updateEndpointSettingsToDB25() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateRbacRolesToDB26() error {
|
||||
defaultAuthorizationsOfRoles := map[portainer.RoleID]portainer.Authorizations{
|
||||
portainer.RoleIDEndpointAdmin: authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole(),
|
||||
portainer.RoleIDHelpdesk: authorization.DefaultEndpointAuthorizationsForHelpDeskRole(),
|
||||
portainer.RoleIDStandardUser: authorization.DefaultEndpointAuthorizationsForStandardUserRole(),
|
||||
portainer.RoleIDReadonly: authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(),
|
||||
}
|
||||
|
||||
for roleID, defaultAuthorizations := range defaultAuthorizationsOfRoles {
|
||||
role, err := m.roleService.Role(roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role.Authorizations = defaultAuthorizations
|
||||
|
||||
err = m.roleService.UpdateRole(role.ID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.authorizationService.UpdateUsersAuthorizations()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
func (m *Migrator) updateStackResourceControlToDB27() error {
|
||||
@@ -18,6 +18,9 @@ func (m *Migrator) updateStackResourceControlToDB27() error {
|
||||
}
|
||||
|
||||
stackName := resource.ResourceID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stack, err := m.stackService.StackByName(stackName)
|
||||
if err != nil {
|
||||
@@ -28,7 +31,7 @@ func (m *Migrator) updateStackResourceControlToDB27() error {
|
||||
return err
|
||||
}
|
||||
|
||||
resource.ResourceID = stackutils.ResourceControlID(stack.EndpointID, stack.Name)
|
||||
resource.ResourceID = fmt.Sprintf("%d_%s", stack.EndpointID, stack.Name)
|
||||
|
||||
err = m.resourceControlService.UpdateResourceControl(resource.ID, &resource)
|
||||
if err != nil {
|
||||
|
||||
10
api/bolt/migrator/migrate_dbversion27.go
Normal file
10
api/bolt/migrator/migrate_dbversion27.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package migrator
|
||||
|
||||
func (m *Migrator) updateUsersAndRolesToDBVersion28() error {
|
||||
err := m.roleService.CreateOrUpdatePredefinedRoles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.authorizationService.UpdateUsersAuthorizations()
|
||||
}
|
||||
31
api/bolt/migrator/migrate_dbversion28.go
Normal file
31
api/bolt/migrator/migrate_dbversion28.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
func (m *Migrator) updateRbacRolesToDB29() error {
|
||||
defaultAuthorizationsOfRoles := map[portainer.RoleID]portainer.Authorizations{
|
||||
portainer.RoleIDEndpointAdmin: authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole(),
|
||||
portainer.RoleIDHelpdesk: authorization.DefaultEndpointAuthorizationsForHelpDeskRole(),
|
||||
portainer.RoleIDOperator: authorization.DefaultEndpointAuthorizationsForOperatorRole(),
|
||||
portainer.RoleIDStandardUser: authorization.DefaultEndpointAuthorizationsForStandardUserRole(),
|
||||
portainer.RoleIDReadonly: authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(),
|
||||
}
|
||||
|
||||
for roleID, defaultAuthorizations := range defaultAuthorizationsOfRoles {
|
||||
role, err := m.roleService.Role(roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role.Authorizations = defaultAuthorizations
|
||||
|
||||
err = m.roleService.UpdateRole(role.ID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.authorizationService.UpdateUsersAuthorizations()
|
||||
}
|
||||
12
api/bolt/migrator/migrate_dbversion30.go
Normal file
12
api/bolt/migrator/migrate_dbversion30.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package migrator
|
||||
|
||||
func (m *Migrator) updateSettingsToDB31() error {
|
||||
legacySettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacySettings.OAuthSettings.SSO = false
|
||||
legacySettings.OAuthSettings.HideInternalAuth = false
|
||||
legacySettings.OAuthSettings.LogoutURI = ""
|
||||
return m.settingsService.UpdateSettings(legacySettings)
|
||||
}
|
||||
67
api/bolt/migrator/migrate_dbversion30_test.go
Normal file
67
api/bolt/migrator/migrate_dbversion30_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
"github.com/portainer/portainer/api/bolt/settings"
|
||||
)
|
||||
|
||||
var (
|
||||
testingDBStorePath string
|
||||
testingDBFileName string
|
||||
dummyLogoURL string
|
||||
dbConn *bolt.DB
|
||||
settingsService *settings.Service
|
||||
)
|
||||
|
||||
func setup() error {
|
||||
testingDBStorePath, _ = os.Getwd()
|
||||
testingDBFileName = "portainer-ee-mig-30.db"
|
||||
dummyLogoURL = "example.com"
|
||||
var err error
|
||||
dbConn, err = initTestingDBConn(testingDBStorePath, testingDBFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dummySettingsObj := map[string]interface{}{
|
||||
"LogoURL": dummyLogoURL,
|
||||
}
|
||||
settingsService, err = initTestingSettingsService(dbConn, dummySettingsObj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdateSettingsToDB31(t *testing.T) {
|
||||
if err := setup(); err != nil {
|
||||
t.Errorf("failed to complete testing setups, err: %v", err)
|
||||
}
|
||||
defer dbConn.Close()
|
||||
defer os.Remove(testingDBFileName)
|
||||
m := &Migrator{
|
||||
db: dbConn,
|
||||
settingsService: settingsService,
|
||||
}
|
||||
if err := m.updateSettingsToDB31(); err != nil {
|
||||
t.Errorf("failed to update settings: %v", err)
|
||||
}
|
||||
updatedSettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve the updated settings: %v", err)
|
||||
}
|
||||
if updatedSettings.LogoURL != dummyLogoURL {
|
||||
t.Errorf("unexpected value changes in the updated settings, want LogoURL value: %s, got LogoURL value: %s", dummyLogoURL, updatedSettings.LogoURL)
|
||||
}
|
||||
if updatedSettings.OAuthSettings.SSO != false {
|
||||
t.Errorf("unexpected default OAuth SSO setting, want: false, got: %t", updatedSettings.OAuthSettings.SSO)
|
||||
}
|
||||
if updatedSettings.OAuthSettings.HideInternalAuth != false {
|
||||
t.Errorf("unexpected default OAuth HideInternalAuth setting, want: false, got: %t", updatedSettings.OAuthSettings.HideInternalAuth)
|
||||
}
|
||||
if updatedSettings.OAuthSettings.LogoutURI != "" {
|
||||
t.Errorf("unexpected default OAuth HideInternalAuth setting, want:, got: %s", updatedSettings.OAuthSettings.LogoutURI)
|
||||
}
|
||||
}
|
||||
38
api/bolt/migrator/migrate_test_helper.go
Normal file
38
api/bolt/migrator/migrate_test_helper.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
"github.com/portainer/portainer/api/bolt/settings"
|
||||
)
|
||||
|
||||
// initTestingDBConn creates a raw bolt DB connection
|
||||
// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg
|
||||
func initTestingDBConn(storePath, fileName string) (*bolt.DB, error) {
|
||||
databasePath := path.Join(storePath, fileName)
|
||||
dbConn, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbConn, nil
|
||||
}
|
||||
|
||||
// initTestingDBConn creates a settings service with raw bolt DB connection
|
||||
// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg
|
||||
func initTestingSettingsService(dbConn *bolt.DB, preSetObj map[string]interface{}) (*settings.Service, error) {
|
||||
internalDBConn := &internal.DbConnection{
|
||||
DB: dbConn,
|
||||
}
|
||||
settingsService, err := settings.NewService(internalDBConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//insert a obj
|
||||
if err := internal.UpdateObject(internalDBConn, "settings", []byte("SETTINGS"), preSetObj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settingsService, nil
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/endpoint"
|
||||
"github.com/portainer/portainer/api/bolt/endpointgroup"
|
||||
"github.com/portainer/portainer/api/bolt/endpointrelation"
|
||||
"github.com/portainer/portainer/api/bolt/extension"
|
||||
plog "github.com/portainer/portainer/api/bolt/log"
|
||||
"github.com/portainer/portainer/api/bolt/registry"
|
||||
"github.com/portainer/portainer/api/bolt/resourcecontrol"
|
||||
"github.com/portainer/portainer/api/bolt/role"
|
||||
@@ -20,11 +23,15 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
var migrateLog = plog.NewScopedLog("bolt, migrate")
|
||||
|
||||
type (
|
||||
// Migrator defines a service to migrate data after a Portainer version update.
|
||||
Migrator struct {
|
||||
currentDBVersion int
|
||||
db *bolt.DB
|
||||
db *bolt.DB
|
||||
currentDBVersion int
|
||||
currentEdition portainer.SoftwareEdition
|
||||
|
||||
endpointGroupService *endpointgroup.Service
|
||||
endpointService *endpoint.Service
|
||||
endpointRelationService *endpointrelation.Service
|
||||
@@ -45,8 +52,10 @@ type (
|
||||
|
||||
// Parameters represents the required parameters to create a new Migrator instance.
|
||||
Parameters struct {
|
||||
DB *bolt.DB
|
||||
DatabaseVersion int
|
||||
DB *bolt.DB
|
||||
DatabaseVersion int
|
||||
CurrentEdition portainer.SoftwareEdition
|
||||
|
||||
EndpointGroupService *endpointgroup.Service
|
||||
EndpointService *endpoint.Service
|
||||
EndpointRelationService *endpointrelation.Service
|
||||
@@ -71,6 +80,7 @@ func NewMigrator(parameters *Parameters) *Migrator {
|
||||
return &Migrator{
|
||||
db: parameters.DB,
|
||||
currentDBVersion: parameters.DatabaseVersion,
|
||||
currentEdition: parameters.CurrentEdition,
|
||||
endpointGroupService: parameters.EndpointGroupService,
|
||||
endpointService: parameters.EndpointService,
|
||||
endpointRelationService: parameters.EndpointRelationService,
|
||||
@@ -90,273 +100,43 @@ func NewMigrator(parameters *Parameters) *Migrator {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate checks the database version and migrate the existing data to the most recent data model.
|
||||
func (m *Migrator) Migrate() error {
|
||||
// Portainer < 1.12
|
||||
if m.currentDBVersion < 1 {
|
||||
err := m.updateAdminUserToDBVersion1()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.12.x
|
||||
if m.currentDBVersion < 2 {
|
||||
err := m.updateResourceControlsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.updateEndpointsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.13.x
|
||||
if m.currentDBVersion < 3 {
|
||||
err := m.updateSettingsToDBVersion3()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.14.0
|
||||
if m.currentDBVersion < 4 {
|
||||
err := m.updateEndpointsToDBVersion4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1235
|
||||
if m.currentDBVersion < 5 {
|
||||
err := m.updateSettingsToVersion5()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1236
|
||||
if m.currentDBVersion < 6 {
|
||||
err := m.updateSettingsToVersion6()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1449
|
||||
if m.currentDBVersion < 7 {
|
||||
err := m.updateSettingsToVersion7()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 8 {
|
||||
err := m.updateEndpointsToVersion8()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https: //github.com/portainer/portainer/issues/1396
|
||||
if m.currentDBVersion < 9 {
|
||||
err := m.updateEndpointsToVersion9()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/461
|
||||
if m.currentDBVersion < 10 {
|
||||
err := m.updateEndpointsToVersion10()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1906
|
||||
if m.currentDBVersion < 11 {
|
||||
err := m.updateEndpointsToVersion11()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.18.0
|
||||
if m.currentDBVersion < 12 {
|
||||
err := m.updateEndpointsToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateStacksToVersion12()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.0
|
||||
if m.currentDBVersion < 13 {
|
||||
err := m.updateSettingsToVersion13()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.19.2
|
||||
if m.currentDBVersion < 14 {
|
||||
err := m.updateResourceControlsToDBVersion14()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.0
|
||||
if m.currentDBVersion < 15 {
|
||||
err := m.updateSettingsToDBVersion15()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateTemplatesToVersion15()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 16 {
|
||||
err := m.updateSettingsToDBVersion16()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.20.1
|
||||
if m.currentDBVersion < 17 {
|
||||
err := m.updateExtensionsToDBVersion17()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.21.0
|
||||
if m.currentDBVersion < 18 {
|
||||
err := m.updateUsersToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointsToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointGroupsToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateRegistriesToDBVersion18()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.0
|
||||
if m.currentDBVersion < 19 {
|
||||
err := m.updateSettingsToDBVersion19()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.22.1
|
||||
if m.currentDBVersion < 20 {
|
||||
err := m.updateUsersToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateSettingsToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateSchedulesToDBVersion20()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.23.0
|
||||
// DBVersion 21 is missing as it was shipped as via hotfix 1.22.2
|
||||
if m.currentDBVersion < 22 {
|
||||
err := m.updateResourceControlsToDBVersion22()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateUsersAndRolesToDBVersion22()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.0
|
||||
if m.currentDBVersion < 23 {
|
||||
err := m.updateTagsToDBVersion23()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateEndpointsAndEndpointGroupsToDBVersion23()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.1
|
||||
if m.currentDBVersion < 24 {
|
||||
err := m.updateSettingsToDB24()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.0.0
|
||||
if m.currentDBVersion < 25 {
|
||||
err := m.updateSettingsToDB25()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateStacksToDB24()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.1.0
|
||||
if m.currentDBVersion < 26 {
|
||||
err := m.updateEndpointSettingsToDB25()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.2.0
|
||||
if m.currentDBVersion < 27 {
|
||||
err := m.updateStackResourceControlToDB27()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||
// Version exposes version of database
|
||||
func (migrator *Migrator) Version() int {
|
||||
return migrator.currentDBVersion
|
||||
}
|
||||
|
||||
// Edition exposes edition of portainer
|
||||
func (migrator *Migrator) Edition() portainer.SoftwareEdition {
|
||||
return migrator.currentEdition
|
||||
}
|
||||
|
||||
// Migrate helper to upgrade DB
|
||||
func (migrator *Migrator) Migrate(version int) error {
|
||||
|
||||
migrateLog.Info(fmt.Sprintf("Migrating %s database from version %d to %d.", migrator.Edition().GetEditionLabel(), migrator.currentDBVersion, version))
|
||||
// TODO : run backup before migration and restore if failed
|
||||
err := migrator.MigrateCE() //CE
|
||||
if err != nil {
|
||||
migrateLog.Error("An error occurred during database migration", err)
|
||||
return err
|
||||
}
|
||||
|
||||
migrator.versionService.StoreDBVersion(version)
|
||||
migrator.currentDBVersion = version
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackVersion rolls back the db to version
|
||||
func (migrator *Migrator) RollbackVersion(version int) error {
|
||||
|
||||
err := migrator.versionService.StoreDBVersion(version) // portainer.DBVersion
|
||||
return err
|
||||
}
|
||||
|
||||
// RollbackEdition rolls back the db to portainer CE
|
||||
func (migrator *Migrator) RollbackEdition(edition portainer.SoftwareEdition) error {
|
||||
|
||||
err := migrator.versionService.StoreEdition(portainer.PortainerCE)
|
||||
return err
|
||||
}
|
||||
|
||||
4
api/bolt/migrator/migrator_test.go
Normal file
4
api/bolt/migrator/migrator_test.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package migrator
|
||||
|
||||
// test CE version is always upgraded to latest version of CE
|
||||
// test EE version is always upgraded to latest version of EE
|
||||
228
api/bolt/migrator/upgrade_ee.go
Normal file
228
api/bolt/migrator/upgrade_ee.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
// UpgradeToEE will migrate the db from latest ce version to latest ee version
|
||||
// Latest version is v25 on 06/11/2020
|
||||
func (m *Migrator) UpgradeToEE() error {
|
||||
|
||||
migrateLog.Info(fmt.Sprintf("Migrating CE database version %d to EE database version %d.", m.Version(), portainer.DBVersion))
|
||||
|
||||
migrateLog.Info("Updating LDAP settings to EE")
|
||||
err := m.updateSettingsToEE()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrateLog.Info("Updating user roles to EE")
|
||||
err = m.updateUserRolesToEE()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrateLog.Info("Updating role authorizations to EE")
|
||||
err = m.updateRoleAuthorizationsToEE()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrateLog.Info("Updating user authorizations")
|
||||
err = m.authorizationService.UpdateUsersAuthorizations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrateLog.Info(fmt.Sprintf("Setting db version to %d", portainer.DBVersionEE))
|
||||
err = m.versionService.StoreDBVersion(portainer.DBVersionEE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrateLog.Info(fmt.Sprintf("Setting edition to %s", portainer.PortainerEE.GetEditionLabel()))
|
||||
err = m.versionService.StoreEdition(portainer.PortainerEE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.currentDBVersion = portainer.DBVersionEE
|
||||
m.currentEdition = portainer.PortainerEE
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateSettingsToEE() error {
|
||||
legacySettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
legacySettings.LDAPSettings.URLs = []string{}
|
||||
url := legacySettings.LDAPSettings.URL
|
||||
if url != "" {
|
||||
legacySettings.LDAPSettings.URLs = append(legacySettings.LDAPSettings.URLs, url)
|
||||
}
|
||||
|
||||
legacySettings.LDAPSettings.ServerType = portainer.LDAPServerCustom
|
||||
|
||||
return m.settingsService.UpdateSettings(legacySettings)
|
||||
}
|
||||
|
||||
// Updating role authorizations because of the new policies in Kube RBAC
|
||||
func (m *Migrator) updateRoleAuthorizationsToEE() error {
|
||||
migrateLog.Debug("Retriving settings")
|
||||
|
||||
migrateLog.Debug("Updating Endpoint Admin Role")
|
||||
endpointAdministratorRole, err := m.roleService.Role(portainer.RoleID(1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpointAdministratorRole.Priority = 1
|
||||
endpointAdministratorRole.Authorizations = authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole()
|
||||
|
||||
err = m.roleService.UpdateRole(endpointAdministratorRole.ID, endpointAdministratorRole)
|
||||
|
||||
migrateLog.Debug("Updating Help Desk Role")
|
||||
helpDeskRole, err := m.roleService.Role(portainer.RoleID(2))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helpDeskRole.Priority = 2
|
||||
helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole()
|
||||
|
||||
err = m.roleService.UpdateRole(helpDeskRole.ID, helpDeskRole)
|
||||
|
||||
migrateLog.Debug("Updating Standard User Role")
|
||||
standardUserRole, err := m.roleService.Role(portainer.RoleID(3))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
standardUserRole.Priority = 3
|
||||
standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole()
|
||||
|
||||
err = m.roleService.UpdateRole(standardUserRole.ID, standardUserRole)
|
||||
|
||||
migrateLog.Debug("Updating Read Only User Role")
|
||||
readOnlyUserRole, err := m.roleService.Role(portainer.RoleID(4))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readOnlyUserRole.Priority = 4
|
||||
readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole()
|
||||
|
||||
err = m.roleService.UpdateRole(readOnlyUserRole.ID, readOnlyUserRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// If RBAC extension wasn't installed before, update all users in endpoints and
|
||||
// endpoint groups to have read only access.
|
||||
func (m *Migrator) updateUserRolesToEE() error {
|
||||
err := m.updateUserAuthorizationToEE()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrateLog.Debug("Retriving extension info")
|
||||
extensions, err := m.extensionService.Extensions()
|
||||
for _, extension := range extensions {
|
||||
if extension.ID == 3 && extension.Enabled {
|
||||
migrateLog.Info("RBAC extensions were enabled before; Skip updating User Roles")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
migrateLog.Debug("Retriving endpoint groups")
|
||||
endpointGroups, err := m.endpointGroupService.EndpointGroups()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpointGroup := range endpointGroups {
|
||||
migrateLog.Debug(fmt.Sprintf("Updating user policies for endpoint group %v", endpointGroup.ID))
|
||||
for key := range endpointGroup.UserAccessPolicies {
|
||||
updateUserAccessPolicyToReadOnlyRole(endpointGroup.UserAccessPolicies, key)
|
||||
}
|
||||
|
||||
for key := range endpointGroup.TeamAccessPolicies {
|
||||
updateTeamAccessPolicyToReadOnlyRole(endpointGroup.TeamAccessPolicies, key)
|
||||
}
|
||||
|
||||
err := m.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
migrateLog.Debug("Retriving endpoints")
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
migrateLog.Debug(fmt.Sprintf("Updating user policies for endpoint %v", endpoint.ID))
|
||||
for key := range endpoint.UserAccessPolicies {
|
||||
updateUserAccessPolicyToReadOnlyRole(endpoint.UserAccessPolicies, key)
|
||||
}
|
||||
|
||||
for key := range endpoint.TeamAccessPolicies {
|
||||
updateTeamAccessPolicyToReadOnlyRole(endpoint.TeamAccessPolicies, key)
|
||||
}
|
||||
|
||||
err := m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateUserAuthorizationToEE() error {
|
||||
legacyUsers, err := m.userService.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range legacyUsers {
|
||||
user.PortainerAuthorizations = authorization.DefaultPortainerAuthorizations()
|
||||
|
||||
err = m.userService.UpdateUser(user.ID, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateUserAccessPolicyToNoRole(policies portainer.UserAccessPolicies, key portainer.UserID) {
|
||||
tmp := policies[key]
|
||||
tmp.RoleID = 0
|
||||
policies[key] = tmp
|
||||
}
|
||||
|
||||
func updateTeamAccessPolicyToNoRole(policies portainer.TeamAccessPolicies, key portainer.TeamID) {
|
||||
tmp := policies[key]
|
||||
tmp.RoleID = 0
|
||||
policies[key] = tmp
|
||||
}
|
||||
|
||||
func updateUserAccessPolicyToReadOnlyRole(policies portainer.UserAccessPolicies, key portainer.UserID) {
|
||||
tmp := policies[key]
|
||||
tmp.RoleID = 4
|
||||
policies[key] = tmp
|
||||
}
|
||||
|
||||
func updateTeamAccessPolicyToReadOnlyRole(policies portainer.TeamAccessPolicies, key portainer.TeamID) {
|
||||
tmp := policies[key]
|
||||
tmp.RoleID = 4
|
||||
policies[key] = tmp
|
||||
}
|
||||
68
api/bolt/role/predefined_roles.go
Normal file
68
api/bolt/role/predefined_roles.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package role
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
// CreateOrUpdatePredefinedRoles update the predefined roles. Create one if it does not exist yet.
|
||||
func (service *Service) CreateOrUpdatePredefinedRoles() error {
|
||||
predefinedRoles := map[portainer.RoleID]*portainer.Role{
|
||||
portainer.RoleIDEndpointAdmin: &portainer.Role{
|
||||
Name: "Endpoint administrator",
|
||||
Description: "Full control of all resources in an endpoint",
|
||||
ID: portainer.RoleIDEndpointAdmin,
|
||||
Priority: 1,
|
||||
Authorizations: authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole(),
|
||||
},
|
||||
portainer.RoleIDOperator: &portainer.Role{
|
||||
Name: "Operator",
|
||||
Description: "Operational control of all existing resources in an endpoint",
|
||||
ID: portainer.RoleIDOperator,
|
||||
Priority: 2,
|
||||
Authorizations: authorization.DefaultEndpointAuthorizationsForOperatorRole(),
|
||||
},
|
||||
portainer.RoleIDHelpdesk: &portainer.Role{
|
||||
Name: "Helpdesk",
|
||||
Description: "Read-only access of all resources in an endpoint",
|
||||
ID: portainer.RoleIDHelpdesk,
|
||||
Priority: 3,
|
||||
Authorizations: authorization.DefaultEndpointAuthorizationsForHelpDeskRole(),
|
||||
},
|
||||
portainer.RoleIDStandardUser: &portainer.Role{
|
||||
Name: "Standard user",
|
||||
Description: "Full control of assigned resources in an endpoint",
|
||||
ID: portainer.RoleIDStandardUser,
|
||||
Priority: 4,
|
||||
Authorizations: authorization.DefaultEndpointAuthorizationsForStandardUserRole(),
|
||||
},
|
||||
portainer.RoleIDReadonly: &portainer.Role{
|
||||
Name: "Read-only user",
|
||||
Description: "Read-only access of assigned resources in an endpoint",
|
||||
ID: portainer.RoleIDReadonly,
|
||||
Priority: 5,
|
||||
Authorizations: authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(),
|
||||
},
|
||||
}
|
||||
|
||||
for roleID, predefinedRole := range predefinedRoles {
|
||||
_, err := service.Role(roleID)
|
||||
|
||||
if err == errors.ErrObjectNotFound {
|
||||
err := service.CreateRole(predefinedRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else {
|
||||
err = service.UpdateRole(predefinedRole.ID, predefinedRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -71,7 +71,9 @@ func (service *Service) CreateRole(role *portainer.Role) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
id, _ := bucket.NextSequence()
|
||||
role.ID = portainer.RoleID(id)
|
||||
if role.ID == 0 {
|
||||
role.ID = portainer.RoleID(id)
|
||||
}
|
||||
|
||||
data, err := internal.MarshalObject(role)
|
||||
if err != nil {
|
||||
|
||||
66
api/bolt/s3backup/s3backup.go
Normal file
66
api/bolt/s3backup/s3backup.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package s3backup
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
)
|
||||
|
||||
const (
|
||||
bucketName = "s3backup"
|
||||
statusKey = "lastRunStatus"
|
||||
settingsKey = "settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
connection *internal.DbConnection
|
||||
}
|
||||
|
||||
// NewService creates a new service and ensures corresponding bucket exist
|
||||
func NewService(connection *internal.DbConnection) (*Service, error) {
|
||||
err := internal.CreateBucket(connection, bucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
connection: connection,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetStatus returns the status of the last scheduled backup run
|
||||
func (s *Service) GetStatus() (portainer.S3BackupStatus, error) {
|
||||
var status portainer.S3BackupStatus
|
||||
err := internal.GetObject(s.connection, bucketName, []byte(statusKey), &status)
|
||||
if err == errors.ErrObjectNotFound {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
return status, err
|
||||
}
|
||||
|
||||
// DropStatus deletes the status of the last sheduled backup run
|
||||
func (s *Service) DropStatus() error {
|
||||
return internal.DeleteObject(s.connection, bucketName, []byte(statusKey))
|
||||
}
|
||||
|
||||
// UpdateStatus upserts a status of the last scheduled backup run
|
||||
func (s *Service) UpdateStatus(status portainer.S3BackupStatus) error {
|
||||
return internal.UpdateObject(s.connection, bucketName, []byte(statusKey), status)
|
||||
}
|
||||
|
||||
// UpdateSettings updates stored s3 backup settings
|
||||
func (s *Service) UpdateSettings(settings portainer.S3BackupSettings) error {
|
||||
return internal.UpdateObject(s.connection, bucketName, []byte(settingsKey), settings)
|
||||
}
|
||||
|
||||
// GetSettings returns stored s3 backup settings
|
||||
func (s *Service) GetSettings() (portainer.S3BackupSettings, error) {
|
||||
var settings portainer.S3BackupSettings
|
||||
err := internal.GetObject(s.connection, bucketName, []byte(settingsKey), &settings)
|
||||
if err == errors.ErrObjectNotFound {
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
return settings, err
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"github.com/portainer/portainer/api/bolt/endpointgroup"
|
||||
"github.com/portainer/portainer/api/bolt/endpointrelation"
|
||||
"github.com/portainer/portainer/api/bolt/extension"
|
||||
"github.com/portainer/portainer/api/bolt/license"
|
||||
"github.com/portainer/portainer/api/bolt/registry"
|
||||
"github.com/portainer/portainer/api/bolt/resourcecontrol"
|
||||
"github.com/portainer/portainer/api/bolt/role"
|
||||
"github.com/portainer/portainer/api/bolt/s3backup"
|
||||
"github.com/portainer/portainer/api/bolt/schedule"
|
||||
"github.com/portainer/portainer/api/bolt/settings"
|
||||
"github.com/portainer/portainer/api/bolt/stack"
|
||||
@@ -87,6 +89,12 @@ func (store *Store) initServices() error {
|
||||
}
|
||||
store.ExtensionService = extensionService
|
||||
|
||||
licenseService, err := license.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.LicenseService = licenseService
|
||||
|
||||
registryService, err := registry.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -99,6 +107,12 @@ func (store *Store) initServices() error {
|
||||
}
|
||||
store.ResourceControlService = resourcecontrolService
|
||||
|
||||
s3backupService, err := s3backup.NewService(store.connection)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
store.S3BackupService = s3backupService
|
||||
|
||||
settingsService, err := settings.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -202,6 +216,11 @@ func (store *Store) EndpointRelation() portainer.EndpointRelationService {
|
||||
return store.EndpointRelationService
|
||||
}
|
||||
|
||||
// License provides access to the License data management layer
|
||||
func (store *Store) License() portainer.LicenseRepository {
|
||||
return store.LicenseService
|
||||
}
|
||||
|
||||
// Registry gives access to the Registry data management layer
|
||||
func (store *Store) Registry() portainer.RegistryService {
|
||||
return store.RegistryService
|
||||
@@ -217,6 +236,11 @@ func (store *Store) Role() portainer.RoleService {
|
||||
return store.RoleService
|
||||
}
|
||||
|
||||
// S3Backup gives access to S3 backup settings and status
|
||||
func (store *Store) S3Backup() portainer.S3BackupService {
|
||||
return store.S3BackupService
|
||||
}
|
||||
|
||||
// Settings gives access to the Settings data management layer
|
||||
func (store *Store) Settings() portainer.SettingsService {
|
||||
return store.SettingsService
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
|
||||
const (
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
BucketName = "version"
|
||||
versionKey = "DB_VERSION"
|
||||
instanceKey = "INSTANCE_ID"
|
||||
editionKey = "EDITION"
|
||||
BucketName = "version"
|
||||
versionKey = "DB_VERSION"
|
||||
previousVersionKey = "PREVIOUS_DB_VERSION"
|
||||
instanceKey = "INSTANCE_ID"
|
||||
editionKey = "EDITION"
|
||||
)
|
||||
|
||||
// Service represents a service to manage stored versions.
|
||||
@@ -34,30 +35,6 @@ func NewService(connection *internal.DbConnection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DBVersion retrieves the stored database version.
|
||||
func (service *Service) DBVersion() (int, error) {
|
||||
var data []byte
|
||||
|
||||
err := service.connection.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
value := bucket.Get([]byte(versionKey))
|
||||
if value == nil {
|
||||
return errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return strconv.Atoi(string(data))
|
||||
}
|
||||
|
||||
// Edition retrieves the stored portainer edition.
|
||||
func (service *Service) Edition() (portainer.SoftwareEdition, error) {
|
||||
editionData, err := service.getKey(editionKey)
|
||||
@@ -73,48 +50,54 @@ func (service *Service) Edition() (portainer.SoftwareEdition, error) {
|
||||
return portainer.SoftwareEdition(edition), nil
|
||||
}
|
||||
|
||||
// StoreEdition store the portainer edition.
|
||||
func (service *Service) StoreEdition(edition portainer.SoftwareEdition) error {
|
||||
return service.setKey(editionKey, strconv.Itoa(int(edition)))
|
||||
}
|
||||
|
||||
// PreviousDBVersion retrieves the stored database version.
|
||||
func (service *Service) PreviousDBVersion() (int, error) {
|
||||
version, err := service.getKey(previousVersionKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return strconv.Atoi(string(version))
|
||||
}
|
||||
|
||||
// DBVersion retrieves the stored database version.
|
||||
func (service *Service) DBVersion() (int, error) {
|
||||
version, err := service.getKey(versionKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return strconv.Atoi(string(version))
|
||||
}
|
||||
|
||||
// StorePreviousDBVersion store the database version.
|
||||
func (service *Service) StorePreviousDBVersion(version int) error {
|
||||
return service.setKey(previousVersionKey, strconv.Itoa(version))
|
||||
}
|
||||
|
||||
// StoreDBVersion store the database version.
|
||||
func (service *Service) StoreDBVersion(version int) error {
|
||||
return service.connection.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
data := []byte(strconv.Itoa(version))
|
||||
return bucket.Put([]byte(versionKey), data)
|
||||
})
|
||||
return service.setKey(versionKey, strconv.Itoa(version))
|
||||
}
|
||||
|
||||
// InstanceID retrieves the stored instance ID.
|
||||
func (service *Service) InstanceID() (string, error) {
|
||||
var data []byte
|
||||
|
||||
err := service.connection.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
value := bucket.Get([]byte(instanceKey))
|
||||
if value == nil {
|
||||
return errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
|
||||
return nil
|
||||
})
|
||||
instanceID, err := service.getKey(instanceKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
return string(instanceID), nil
|
||||
}
|
||||
|
||||
// StoreInstanceID store the instance ID.
|
||||
func (service *Service) StoreInstanceID(ID string) error {
|
||||
return service.connection.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
data := []byte(ID)
|
||||
return bucket.Put([]byte(instanceKey), data)
|
||||
})
|
||||
return service.setKey(instanceKey, ID)
|
||||
}
|
||||
|
||||
func (service *Service) getKey(key string) ([]byte, error) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,6 +42,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
||||
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
||||
RollbackToCE: kingpin.Flag("rollback-to-ce", "Rollback the database store to CE").Bool(),
|
||||
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(),
|
||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
|
||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
|
||||
|
||||
24
api/cli/confirm.go
Normal file
24
api/cli/confirm.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Confirm starts a rollback db cli application
|
||||
func Confirm(message string) (bool, error) {
|
||||
log.Printf("%s [y/N]", message)
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
answer = strings.Replace(answer, "\n", "", -1)
|
||||
answer = strings.ToLower(answer)
|
||||
|
||||
return answer == "y" || answer == "yes", nil
|
||||
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
@@ -27,69 +28,92 @@ import (
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/ldap"
|
||||
"github.com/portainer/portainer/api/libcompose"
|
||||
"github.com/portainer/portainer/api/license"
|
||||
"github.com/portainer/portainer/api/oauth"
|
||||
"github.com/portainer/portainer/api/useractivity"
|
||||
)
|
||||
|
||||
func initCLI() *portainer.CLIFlags {
|
||||
var cliService portainer.CLIService = &cli.Service{}
|
||||
flags, err := cliService.ParseFlags(portainer.APIVersion)
|
||||
if err != nil {
|
||||
log.Fatalf("failed parsing flags: %v", err)
|
||||
log.Fatalf("failed parsing flags: %s", err)
|
||||
}
|
||||
|
||||
err = cliService.ValidateFlags(flags)
|
||||
if err != nil {
|
||||
log.Fatalf("failed validating flags:%v", err)
|
||||
log.Fatalf("failed validating flags:%s", err)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func initUserActivityStore(dataStorePath string) portainer.UserActivityStore {
|
||||
store, err := useractivity.NewUserActivityStore(dataStorePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed initalizing user activity store: %s", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func initFileService(dataStorePath string) portainer.FileService {
|
||||
fileService, err := filesystem.NewService(dataStorePath, "")
|
||||
if err != nil {
|
||||
log.Fatalf("failed creating file service: %v", err)
|
||||
log.Fatalf("failed creating file service: %s", err)
|
||||
}
|
||||
return fileService
|
||||
}
|
||||
|
||||
func initDataStore(dataStorePath string, fileService portainer.FileService) portainer.DataStore {
|
||||
func initDataStore(dataStorePath string, rollback bool, fileService portainer.FileService) portainer.DataStore {
|
||||
store, err := bolt.NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed creating data store: %v", err)
|
||||
log.Fatalf("failed creating data store: %s", err)
|
||||
}
|
||||
|
||||
err = store.Open()
|
||||
if err != nil {
|
||||
log.Fatalf("failed opening store: %v", err)
|
||||
log.Fatalf("failed opening store: %s", err)
|
||||
}
|
||||
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing data store: %v", err)
|
||||
log.Fatalf("failed initializing data store: %s", err)
|
||||
}
|
||||
|
||||
if rollback {
|
||||
err := store.RollbackToCE()
|
||||
if err != nil {
|
||||
log.Fatalf("failed rolling back to CE: %s", err)
|
||||
}
|
||||
|
||||
log.Println("Exiting rollback")
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = store.MigrateData(false)
|
||||
if err != nil {
|
||||
log.Fatalf("failed migration: %v", err)
|
||||
log.Fatalf("failed migration: %s", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper := exec.NewComposeWrapper(assetsPath, proxyManager)
|
||||
if composeWrapper != nil {
|
||||
return composeWrapper
|
||||
composeWrapper, err := exec.NewComposeStackManager(assetsPath, dataStorePath, proxyManager)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [main,compose] [message: falling-back to libcompose] [error: %s]", err)
|
||||
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
}
|
||||
|
||||
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
return composeWrapper
|
||||
}
|
||||
|
||||
func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) {
|
||||
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(assetsPath)
|
||||
func initKubernetesDeployer(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(dataStore, reverseTunnelService, signatureService, assetsPath)
|
||||
}
|
||||
|
||||
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
|
||||
@@ -98,11 +122,11 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if settings.UserSessionTimeout == "" {
|
||||
settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
|
||||
dataStore.Settings().UpdateSettings(settings)
|
||||
userSessionTimeout := settings.UserSessionTimeout
|
||||
if userSessionTimeout == "" {
|
||||
userSessionTimeout = portainer.DefaultUserSessionTimeout
|
||||
}
|
||||
jwtService, err := jwt.NewService(settings.UserSessionTimeout)
|
||||
jwtService, err := jwt.NewService(userSessionTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -165,6 +189,8 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI
|
||||
settings.SnapshotInterval = *flags.SnapshotInterval
|
||||
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
|
||||
settings.EnableTelemetry = true
|
||||
settings.OAuthSettings.SSO = true
|
||||
settings.OAuthSettings.HideInternalAuth = true
|
||||
|
||||
if *flags.Templates != "" {
|
||||
settings.TemplatesURL = *flags.Templates
|
||||
@@ -197,7 +223,7 @@ func generateAndStoreKeyPair(fileService portainer.FileService, signatureService
|
||||
func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
|
||||
existingKeyPair, err := fileService.KeyPairFilesExist()
|
||||
if err != nil {
|
||||
log.Fatalf("failed checking for existing key pair: %v", err)
|
||||
log.Fatalf("failed checking for existing key pair: %s", err)
|
||||
}
|
||||
|
||||
if existingKeyPair {
|
||||
@@ -240,6 +266,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore portainer.Dat
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
@@ -301,6 +328,7 @@ func createUnsecuredEndpoint(endpointURL string, dataStore portainer.DataStore,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
@@ -344,15 +372,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
fileService := initFileService(*flags.Data)
|
||||
|
||||
dataStore := initDataStore(*flags.Data, fileService)
|
||||
|
||||
if err := dataStore.CheckCurrentEdition(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
dataStore := initDataStore(*flags.Data, *flags.RollbackToCE, fileService)
|
||||
|
||||
jwtService, err := initJWTService(dataStore)
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing JWT service: %v", err)
|
||||
log.Fatalf("failed initializing JWT service: %s", err)
|
||||
}
|
||||
|
||||
licenseService := license.NewService(dataStore.License(), shutdownCtx)
|
||||
if err = licenseService.Init(); err != nil {
|
||||
log.Fatalf("failed initializing license service: %s", err)
|
||||
}
|
||||
|
||||
ldapService := initLDAPService()
|
||||
@@ -367,14 +396,14 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
err = initKeyPair(fileService, digitalSignatureService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing key pai: %v", err)
|
||||
log.Fatalf("failed initializing key pair: %s", err)
|
||||
}
|
||||
|
||||
reverseTunnelService := chisel.NewService(dataStore, shutdownCtx)
|
||||
|
||||
instanceID, err := dataStore.Version().InstanceID()
|
||||
if err != nil {
|
||||
log.Fatalf("failed getting instance id: %v", err)
|
||||
log.Fatalf("failed to get datastore version: %s", err)
|
||||
}
|
||||
|
||||
dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService)
|
||||
@@ -382,49 +411,55 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx)
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing snapshot service: %v", err)
|
||||
log.Fatalf("failed initializing snapshot service: %s", err)
|
||||
}
|
||||
snapshotService.Start()
|
||||
|
||||
authorizationService := authorization.NewService(dataStore)
|
||||
authorizationService.K8sClientFactory = kubernetesClientFactory
|
||||
|
||||
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing swarm stack manager: %v", err)
|
||||
log.Fatalf("failed initializing swarm stack manager: %s", err)
|
||||
}
|
||||
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
|
||||
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager)
|
||||
|
||||
userActivityStore := initUserActivityStore(*flags.Data)
|
||||
|
||||
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, authorizationService, userActivityStore)
|
||||
|
||||
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(*flags.Assets)
|
||||
kubernetesDeployer := initKubernetesDeployer(dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
|
||||
|
||||
if dataStore.IsNew() {
|
||||
err = updateSettingsFromFlags(dataStore, flags)
|
||||
if err != nil {
|
||||
log.Fatalf("failed updating settings from flags: %v", err)
|
||||
log.Fatalf("failed updating settings from flags: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = edge.LoadEdgeJobs(dataStore, reverseTunnelService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed loading edge jobs from database: %v", err)
|
||||
log.Fatalf("failed loading edge jobs from database: %s", err)
|
||||
}
|
||||
|
||||
applicationStatus := initStatus(flags)
|
||||
|
||||
err = initEndpoint(flags, dataStore, snapshotService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing endpoint: %v", err)
|
||||
log.Fatalf("failed initializing endpoint: %s", err)
|
||||
}
|
||||
|
||||
adminPasswordHash := ""
|
||||
if *flags.AdminPasswordFile != "" {
|
||||
content, err := fileService.GetFileContent(*flags.AdminPasswordFile)
|
||||
if err != nil {
|
||||
log.Fatalf("failed getting admin password file: %v", err)
|
||||
log.Fatalf("failed getting admin password file: %s", err)
|
||||
}
|
||||
adminPasswordHash, err = cryptoService.Hash(strings.TrimSuffix(string(content), "\n"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed hashing admin password: %v", err)
|
||||
log.Fatalf("failed hashing admin password: %s", err)
|
||||
}
|
||||
} else if *flags.AdminPassword != "" {
|
||||
adminPasswordHash = *flags.AdminPassword
|
||||
@@ -433,19 +468,20 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
if adminPasswordHash != "" {
|
||||
users, err := dataStore.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
log.Fatalf("failed getting admin user: %v", err)
|
||||
log.Fatalf("failed getting admin user: %s", err)
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
log.Println("Created admin user with the given password.")
|
||||
user := &portainer.User{
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
Password: adminPasswordHash,
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
Password: adminPasswordHash,
|
||||
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
||||
}
|
||||
err := dataStore.User().CreateUser(user)
|
||||
if err != nil {
|
||||
log.Fatalf("failed creating admin user: %v", err)
|
||||
log.Fatalf("failed creating admin user: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("Instance already has an administrator user defined. Skipping admin password related flags.")
|
||||
@@ -453,16 +489,23 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
|
||||
err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed starting tunnel server: %s", err)
|
||||
}
|
||||
|
||||
err = licenseService.Start()
|
||||
if err != nil {
|
||||
log.Fatalf("failed starting license service: %s", err)
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
AuthorizationService: authorizationService,
|
||||
ReverseTunnelService: reverseTunnelService,
|
||||
Status: applicationStatus,
|
||||
BindAddress: *flags.Addr,
|
||||
AssetsPath: *flags.Assets,
|
||||
DataStore: dataStore,
|
||||
LicenseService: licenseService,
|
||||
SwarmStackManager: swarmStackManager,
|
||||
ComposeStackManager: composeStackManager,
|
||||
KubernetesDeployer: kubernetesDeployer,
|
||||
@@ -480,6 +523,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
SSLCert: *flags.SSLCert,
|
||||
SSLKey: *flags.SSLKey,
|
||||
DockerClientFactory: dockerClientFactory,
|
||||
UserActivityStore: userActivityStore,
|
||||
KubernetesClientFactory: kubernetesClientFactory,
|
||||
ShutdownCtx: shutdownCtx,
|
||||
ShutdownTrigger: shutdownTrigger,
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "encrypt")
|
||||
tmpdir, _ := ioutil.TempDir("", "encrypt")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
var (
|
||||
@@ -52,7 +51,7 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "encrypt")
|
||||
tmpdir, _ := ioutil.TempDir("", "encrypt")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
var (
|
||||
@@ -92,7 +91,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "encrypt")
|
||||
tmpdir, _ := ioutil.TempDir("", "encrypt")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
var (
|
||||
|
||||
3
api/dev.sh
Executable file
3
api/dev.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#! /bin/sh
|
||||
|
||||
go run -v -ldflags="-X github.com/portainer/liblicense.LicenseServerBaseURL=http://localhost:8080" cmd/portainer/main.go --data=./tmp/data
|
||||
120
api/exec/compose_stack.go
Normal file
120
api/exec/compose_stack.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
wrapper "github.com/portainer/docker-compose-wrapper"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
)
|
||||
|
||||
// ComposeStackManager is a wrapper for docker-compose binary
|
||||
type ComposeStackManager struct {
|
||||
wrapper *wrapper.ComposeWrapper
|
||||
dataPath string
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
|
||||
func NewComposeStackManager(binaryPath string, dataPath string, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
|
||||
wrap, err := wrapper.NewComposeWrapper(binaryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ComposeStackManager{
|
||||
wrapper: wrap,
|
||||
proxyManager: proxyManager,
|
||||
dataPath: dataPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
|
||||
func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string {
|
||||
return portainer.ComposeSyntaxMaxVersion
|
||||
}
|
||||
|
||||
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
|
||||
func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := w.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
envFilePath, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filePath := stackFilePath(stack)
|
||||
|
||||
_, err = w.wrapper.Up(filePath, url, stack.Name, envFilePath, w.dataPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command
|
||||
func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := w.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
filePath := stackFilePath(stack)
|
||||
|
||||
_, err = w.wrapper.Down(filePath, url, stack.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
// NormalizeStackName returns the passed stack name, for interface implementation only
|
||||
func (w *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func stackFilePath(stack *portainer.Stack) string {
|
||||
return path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
}
|
||||
|
||||
func (w *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
|
||||
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", proxy.Port), proxy, nil
|
||||
}
|
||||
|
||||
func createEnvFile(stack *portainer.Stack) (string, error) {
|
||||
if stack.Env == nil || len(stack.Env) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
return envFilePath, nil
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// +build integration
|
||||
|
||||
package exec
|
||||
|
||||
import (
|
||||
@@ -33,7 +32,9 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
|
||||
Name: "project-name",
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{}
|
||||
endpoint := &portainer.Endpoint{
|
||||
URL: "unix://",
|
||||
}
|
||||
|
||||
return stack, endpoint
|
||||
}
|
||||
@@ -42,14 +43,17 @@ func Test_UpAndDown(t *testing.T) {
|
||||
|
||||
stack, endpoint := setup(t)
|
||||
|
||||
w := NewComposeWrapper("", nil)
|
||||
w, err := NewComposeStackManager("", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed creating manager: %s", err)
|
||||
}
|
||||
|
||||
err := w.Up(stack, endpoint)
|
||||
err = w.Up(stack, endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("Error calling docker-compose up: %s", err)
|
||||
}
|
||||
|
||||
if containerExists(composedContainerName) == false {
|
||||
if !containerExists(composedContainerName) {
|
||||
t.Fatal("container should exist")
|
||||
}
|
||||
|
||||
@@ -63,13 +67,13 @@ func Test_UpAndDown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func containerExists(contaierName string) bool {
|
||||
cmd := exec.Command(osProgram("docker"), "ps", "-a", "-f", fmt.Sprintf("name=%s", contaierName))
|
||||
func containerExists(containerName string) bool {
|
||||
cmd := exec.Command("docker", "ps", "-a", "-f", fmt.Sprintf("name=%s", containerName))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to list containers: %s", err)
|
||||
}
|
||||
|
||||
return strings.Contains(string(out), contaierName)
|
||||
return strings.Contains(string(out), containerName)
|
||||
}
|
||||
112
api/exec/compose_stack_test.go
Normal file
112
api/exec/compose_stack_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_stackFilePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected string
|
||||
}{
|
||||
// {
|
||||
// name: "should return empty result if stack is missing",
|
||||
// stack: nil,
|
||||
// expected: "",
|
||||
// },
|
||||
// {
|
||||
// name: "should return empty result if stack don't have entrypoint",
|
||||
// stack: &portainer.Stack{},
|
||||
// expected: "",
|
||||
// },
|
||||
{
|
||||
name: "should allow file name and dir",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: "dir",
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: path.Join("dir", "file"),
|
||||
},
|
||||
{
|
||||
name: "should allow file name only",
|
||||
stack: &portainer.Stack{
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: "file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := stackFilePath(tt.stack)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createEnvFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected string
|
||||
expectedFile bool
|
||||
}{
|
||||
// {
|
||||
// name: "should not add env file option if stack is missing",
|
||||
// stack: nil,
|
||||
// expected: "",
|
||||
// },
|
||||
{
|
||||
name: "should not add env file option if stack doesn't have env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack's env variables are empty",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "should add env file option if stack has env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{
|
||||
{Name: "var1", Value: "value1"},
|
||||
{Name: "var2", Value: "value2"},
|
||||
},
|
||||
},
|
||||
expected: "var1=value1\nvar2=value2\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, _ := createEnvFile(tt.stack)
|
||||
|
||||
if tt.expected != "" {
|
||||
assert.Equal(t, path.Join(tt.stack.ProjectPath, "stack.env"), result)
|
||||
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
assert.Equal(t, tt.expected, string(content))
|
||||
} else {
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
)
|
||||
|
||||
// ComposeWrapper is a wrapper for docker-compose binary
|
||||
type ComposeWrapper struct {
|
||||
binaryPath string
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewComposeWrapper returns a docker-compose wrapper if corresponding binary present, otherwise nil
|
||||
func NewComposeWrapper(binaryPath string, proxyManager *proxy.Manager) *ComposeWrapper {
|
||||
if !IsBinaryPresent(programPath(binaryPath, "docker-compose")) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ComposeWrapper{
|
||||
binaryPath: binaryPath,
|
||||
proxyManager: proxyManager,
|
||||
}
|
||||
}
|
||||
|
||||
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
|
||||
func (w *ComposeWrapper) ComposeSyntaxMaxVersion() string {
|
||||
return portainer.ComposeSyntaxMaxVersion
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (w *ComposeWrapper) NormalizeStackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
|
||||
func (w *ComposeWrapper) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
_, err := w.command([]string{"up", "-d"}, stack, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command
|
||||
func (w *ComposeWrapper) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
_, err := w.command([]string{"down", "--remove-orphans"}, stack, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *ComposeWrapper) command(command []string, stack *portainer.Stack, endpoint *portainer.Endpoint) ([]byte, error) {
|
||||
if endpoint == nil {
|
||||
return nil, errors.New("cannot call a compose command on an empty endpoint")
|
||||
}
|
||||
|
||||
program := programPath(w.binaryPath, "docker-compose")
|
||||
|
||||
options := setComposeFile(stack)
|
||||
|
||||
options = addProjectNameOption(options, stack)
|
||||
options, err := addEnvFileOption(options, stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !(endpoint.URL == "" || strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://")) {
|
||||
|
||||
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer proxy.Close()
|
||||
|
||||
options = append(options, "-H", fmt.Sprintf("http://127.0.0.1:%d", proxy.Port))
|
||||
}
|
||||
|
||||
args := append(options, command...)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(program, args...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return out, errors.New(stderr.String())
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setComposeFile(stack *portainer.Stack) []string {
|
||||
options := make([]string, 0)
|
||||
|
||||
if stack == nil || stack.EntryPoint == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
options = append(options, "-f", composeFilePath)
|
||||
return options
|
||||
}
|
||||
|
||||
func addProjectNameOption(options []string, stack *portainer.Stack) []string {
|
||||
if stack == nil || stack.Name == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
options = append(options, "-p", stack.Name)
|
||||
return options
|
||||
}
|
||||
|
||||
func addEnvFileOption(options []string, stack *portainer.Stack) ([]string, error) {
|
||||
if stack == nil || stack.Env == nil || len(stack.Env) == 0 {
|
||||
return options, nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
options = append(options, "--env-file", envFilePath)
|
||||
return options, nil
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_setComposeFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "should return empty result if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should return empty result if stack don't have entrypoint",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should allow file name and dir",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: "dir",
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: []string{"-f", path.Join("dir", "file")},
|
||||
},
|
||||
{
|
||||
name: "should allow file name only",
|
||||
stack: &portainer.Stack{
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: []string{"-f", "file"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := setComposeFile(tt.stack)
|
||||
assert.ElementsMatch(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_addProjectNameOption(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "should not add project option if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add project option if stack doesn't have name",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should add project name option if stack has a name",
|
||||
stack: &portainer.Stack{
|
||||
Name: "project-name",
|
||||
},
|
||||
expected: []string{"-p", "project-name"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := []string{"-a", "b"}
|
||||
result := addProjectNameOption(options, tt.stack)
|
||||
assert.ElementsMatch(t, append(options, tt.expected...), result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_addEnvFileOption(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
expectedContent string
|
||||
}{
|
||||
{
|
||||
name: "should not add env file option if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack doesn't have env variables",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack's env variables are empty",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{},
|
||||
},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should add env file option if stack has env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{
|
||||
{Name: "var1", Value: "value1"},
|
||||
{Name: "var2", Value: "value2"},
|
||||
},
|
||||
},
|
||||
expected: []string{"--env-file", path.Join(dir, "stack.env")},
|
||||
expectedContent: "var1=value1\nvar2=value2\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := []string{"-a", "b"}
|
||||
result, _ := addEnvFileOption(options, tt.stack)
|
||||
assert.ElementsMatch(t, append(options, tt.expected...), result)
|
||||
|
||||
if tt.expectedContent != "" {
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
assert.Equal(t, tt.expectedContent, string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,71 +2,188 @@ package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
)
|
||||
|
||||
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment.
|
||||
type KubernetesDeployer struct {
|
||||
binaryPath string
|
||||
binaryPath string
|
||||
dataStore portainer.DataStore
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
signatureService portainer.DigitalSignatureService
|
||||
}
|
||||
|
||||
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
|
||||
func NewKubernetesDeployer(binaryPath string) *KubernetesDeployer {
|
||||
func NewKubernetesDeployer(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
|
||||
return &KubernetesDeployer{
|
||||
binaryPath: binaryPath,
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
signatureService: signatureService,
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint.
|
||||
// If composeFormat is set to true, it will leverage the kompose binary to deploy a compose compliant manifest.
|
||||
// Otherwise it will use kubectl to deploy the manifest.
|
||||
func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) {
|
||||
if composeFormat {
|
||||
convertedData, err := deployer.convertComposeData(data)
|
||||
func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment {
|
||||
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
data = string(convertedData)
|
||||
|
||||
command := path.Join(deployer.binaryPath, "kubectl")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--server", endpoint.URL)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
args = append(args, "--token", string(token))
|
||||
args = append(args, "--namespace", namespace)
|
||||
args = append(args, "apply", "-f", "-")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = strings.NewReader(stackConfig)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", errors.New(stderr.String())
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
// agent
|
||||
|
||||
endpointURL := endpoint.URL
|
||||
if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
tunnel := deployer.reverseTunnelService.GetTunnelDetails(endpoint.ID)
|
||||
if tunnel.Status == portainer.EdgeAgentIdle {
|
||||
|
||||
err := deployer.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
settings, err := deployer.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
waitForAgentToConnect := time.Duration(settings.EdgeAgentCheckinInterval) * time.Second
|
||||
time.Sleep(waitForAgentToConnect * 2)
|
||||
}
|
||||
|
||||
endpointURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
transport.TLSClientConfig = tlsConfig
|
||||
}
|
||||
|
||||
httpCli := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(endpointURL, "http") {
|
||||
endpointURL = fmt.Sprintf("https://%s", endpointURL)
|
||||
}
|
||||
|
||||
url, err := url.Parse(fmt.Sprintf("%s/v2/kubernetes/stack", endpointURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
command := path.Join(deployer.binaryPath, "kubectl")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--server", endpoint.URL)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
args = append(args, "--token", string(token))
|
||||
args = append(args, "--namespace", namespace)
|
||||
args = append(args, "apply", "-f", "-")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = strings.NewReader(data)
|
||||
|
||||
output, err := cmd.Output()
|
||||
reqPayload, err := json.Marshal(
|
||||
struct {
|
||||
StackConfig string
|
||||
Namespace string
|
||||
}{
|
||||
StackConfig: stackConfig,
|
||||
Namespace: namespace,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New(stderr.String())
|
||||
return "", err
|
||||
}
|
||||
|
||||
return output, nil
|
||||
req, err := http.NewRequest(http.MethodPost, url.String(), bytes.NewReader(reqPayload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signature, err := deployer.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
|
||||
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
|
||||
resp, err := httpCli.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errorResponseData struct {
|
||||
Message string
|
||||
Details string
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&errorResponseData)
|
||||
if err != nil {
|
||||
output, parseStringErr := ioutil.ReadAll(resp.Body)
|
||||
if parseStringErr != nil {
|
||||
return "", parseStringErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Failed parsing, body: %s, error: %w", output, err)
|
||||
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Deployment to agent failed: %s", errorResponseData.Details)
|
||||
}
|
||||
|
||||
var responseData struct{ Output string }
|
||||
err = json.NewDecoder(resp.Body).Decode(&responseData)
|
||||
if err != nil {
|
||||
parsedOutput, parseStringErr := ioutil.ReadAll(resp.Body)
|
||||
if parseStringErr != nil {
|
||||
return "", parseStringErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Failed decoding, body: %s, err: %w", parsedOutput, err)
|
||||
}
|
||||
|
||||
return responseData.Output, nil
|
||||
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) convertComposeData(data string) ([]byte, error) {
|
||||
// ConvertCompose leverages the kompose binary to deploy a compose compliant manifest.
|
||||
func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) {
|
||||
command := path.Join(deployer.binaryPath, "kompose")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kompose.exe")
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path"
|
||||
"runtime"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// SwarmStackManager represents a service for managing stacks.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func osProgram(program string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
program += ".exe"
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
func programPath(rootPath, program string) string {
|
||||
return filepath.Join(rootPath, osProgram(program))
|
||||
}
|
||||
|
||||
// IsBinaryPresent returns true if corresponding program exists on PATH
|
||||
func IsBinaryPresent(program string) bool {
|
||||
_, err := exec.LookPath(program)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_isBinaryPresent(t *testing.T) {
|
||||
|
||||
if !IsBinaryPresent("docker") {
|
||||
t.Error("expect docker binary to exist on the path")
|
||||
}
|
||||
|
||||
if IsBinaryPresent("executable-with-this-name-should-not-exist") {
|
||||
t.Error("expect binary with a random name to be missing on the path")
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,66 @@ func (service *Service) GetStackProjectPath(stackIdentifier string) string {
|
||||
return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier)
|
||||
}
|
||||
|
||||
// Copy copies the file on fromFilePath to toFilePath
|
||||
// if toFilePath exists func will fail unless deleteIfExists is true
|
||||
func (service *Service) Copy(fromFilePath string, toFilePath string, deleteIfExists bool) error {
|
||||
exists, err := service.FileExists(fromFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return errors.New("File doesn't exist")
|
||||
}
|
||||
|
||||
finput, err := os.Open(fromFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer finput.Close()
|
||||
|
||||
exists, err = service.FileExists(toFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
if !deleteIfExists {
|
||||
return errors.New("Destination file exists")
|
||||
}
|
||||
|
||||
err := os.Remove(toFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
foutput, err := os.Create(toFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer foutput.Close()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := finput.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if _, err := foutput.Write(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreStackFileFromBytes creates a subfolder in the ComposeStorePath and stores a new file from bytes.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) {
|
||||
|
||||
219
api/git/azure.go
Normal file
219
api/git/azure.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
azureDevOpsHost = "dev.azure.com"
|
||||
visualStudioHostSuffix = ".visualstudio.com"
|
||||
)
|
||||
|
||||
func isAzureUrl(s string) bool {
|
||||
return strings.Contains(s, azureDevOpsHost) ||
|
||||
strings.Contains(s, visualStudioHostSuffix)
|
||||
}
|
||||
|
||||
type azureOptions struct {
|
||||
organisation, project, repository string
|
||||
// a user may pass credentials in a repository URL,
|
||||
// for example https://<username>:<password>@<domain>/<path>
|
||||
username, password string
|
||||
}
|
||||
|
||||
type azureDownloader struct {
|
||||
client *http.Client
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
func NewAzureDownloader(client *http.Client) *azureDownloader {
|
||||
return &azureDownloader{
|
||||
client: client,
|
||||
baseUrl: "https://dev.azure.com",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *azureDownloader) download(ctx context.Context, destination string, options cloneOptions) error {
|
||||
zipFilepath, err := a.downloadZipFromAzureDevOps(ctx, options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to download a zip file from Azure DevOps")
|
||||
}
|
||||
defer os.Remove(zipFilepath)
|
||||
|
||||
err = archive.UnzipFile(zipFilepath, destination)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to unzip file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *azureDownloader) downloadZipFromAzureDevOps(ctx context.Context, options cloneOptions) (string, error) {
|
||||
config, err := parseUrl(options.repositoryUrl)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to parse url")
|
||||
}
|
||||
downloadUrl, err := a.buildDownloadUrl(config, options.referenceName)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to build download url")
|
||||
}
|
||||
zipFile, err := ioutil.TempFile("", "azure-git-repo-*.zip")
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to create temp file")
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadUrl, nil)
|
||||
if options.username != "" || options.password != "" {
|
||||
req.SetBasicAuth(options.username, options.password)
|
||||
} else if config.username != "" || config.password != "" {
|
||||
req.SetBasicAuth(config.username, config.password)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to create a new HTTP request")
|
||||
}
|
||||
|
||||
res, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to make an HTTP request")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to download zip with a status \"%v\"", res.Status)
|
||||
}
|
||||
|
||||
_, err = io.Copy(zipFile, res.Body)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to save HTTP response to a file")
|
||||
}
|
||||
return zipFile.Name(), nil
|
||||
}
|
||||
|
||||
func parseUrl(rawUrl string) (*azureOptions, error) {
|
||||
if strings.HasPrefix(rawUrl, "https://") || strings.HasPrefix(rawUrl, "http://") {
|
||||
return parseHttpUrl(rawUrl)
|
||||
}
|
||||
if strings.HasPrefix(rawUrl, "git@ssh") {
|
||||
return parseSshUrl(rawUrl)
|
||||
}
|
||||
if strings.HasPrefix(rawUrl, "ssh://") {
|
||||
r := []rune(rawUrl)
|
||||
return parseSshUrl(string(r[6:])) // remove the prefix
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("supported url schemes are https and ssh; recevied URL %s rawUrl", rawUrl)
|
||||
}
|
||||
|
||||
var expectedSshUrl = "git@ssh.dev.azure.com:v3/Organisation/Project/Repository"
|
||||
|
||||
func parseSshUrl(rawUrl string) (*azureOptions, error) {
|
||||
path := strings.Split(rawUrl, "/")
|
||||
|
||||
unexpectedUrlErr := errors.Errorf("want url %s, got %s", expectedSshUrl, rawUrl)
|
||||
if len(path) != 4 {
|
||||
return nil, unexpectedUrlErr
|
||||
}
|
||||
return &azureOptions{
|
||||
organisation: path[1],
|
||||
project: path[2],
|
||||
repository: path[3],
|
||||
}, nil
|
||||
}
|
||||
|
||||
const expectedAzureDevOpsHttpUrl = "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository"
|
||||
const expectedVisualStudioHttpUrl = "https://organisation.visualstudio.com/project/_git/repository"
|
||||
|
||||
func parseHttpUrl(rawUrl string) (*azureOptions, error) {
|
||||
u, err := url.Parse(rawUrl)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse HTTP url")
|
||||
}
|
||||
|
||||
opt := azureOptions{}
|
||||
switch {
|
||||
case u.Host == azureDevOpsHost:
|
||||
path := strings.Split(u.Path, "/")
|
||||
if len(path) != 5 {
|
||||
return nil, errors.Errorf("want url %s, got %s", expectedAzureDevOpsHttpUrl, u)
|
||||
}
|
||||
opt.organisation = path[1]
|
||||
opt.project = path[2]
|
||||
opt.repository = path[4]
|
||||
case strings.HasSuffix(u.Host, visualStudioHostSuffix):
|
||||
path := strings.Split(u.Path, "/")
|
||||
if len(path) != 4 {
|
||||
return nil, errors.Errorf("want url %s, got %s", expectedVisualStudioHttpUrl, u)
|
||||
}
|
||||
opt.organisation = strings.TrimSuffix(u.Host, visualStudioHostSuffix)
|
||||
opt.project = path[1]
|
||||
opt.repository = path[3]
|
||||
default:
|
||||
return nil, errors.Errorf("unknown azure host in url \"%s\"", rawUrl)
|
||||
}
|
||||
|
||||
opt.username = u.User.Username()
|
||||
opt.password, _ = u.User.Password()
|
||||
|
||||
return &opt, nil
|
||||
}
|
||||
|
||||
func (a *azureDownloader) buildDownloadUrl(config *azureOptions, referenceName string) (string, error) {
|
||||
rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/items",
|
||||
a.baseUrl,
|
||||
url.PathEscape(config.organisation),
|
||||
url.PathEscape(config.project),
|
||||
url.PathEscape(config.repository))
|
||||
u, err := url.Parse(rawUrl)
|
||||
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to parse download url path %s", rawUrl)
|
||||
}
|
||||
q := u.Query()
|
||||
// scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0
|
||||
q.Set("scopePath", "/")
|
||||
q.Set("download", "true")
|
||||
q.Set("versionDescriptor.versionType", getVersionType(referenceName))
|
||||
q.Set("versionDescriptor.version", formatReferenceName(referenceName))
|
||||
q.Set("$format", "zip")
|
||||
q.Set("recursionLevel", "full")
|
||||
q.Set("api-version", "6.0")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
const (
|
||||
branchPrefix = "refs/heads/"
|
||||
tagPrefix = "refs/tags/"
|
||||
)
|
||||
|
||||
func formatReferenceName(name string) string {
|
||||
if strings.HasPrefix(name, branchPrefix) {
|
||||
return strings.TrimPrefix(name, branchPrefix)
|
||||
}
|
||||
if strings.HasPrefix(name, tagPrefix) {
|
||||
return strings.TrimPrefix(name, tagPrefix)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func getVersionType(name string) string {
|
||||
if strings.HasPrefix(name, branchPrefix) {
|
||||
return "branch"
|
||||
}
|
||||
if strings.HasPrefix(name, tagPrefix) {
|
||||
return "tag"
|
||||
}
|
||||
return "commit"
|
||||
}
|
||||
92
api/git/azure_integration_test.go
Normal file
92
api/git/azure_integration_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService()
|
||||
|
||||
type args struct {
|
||||
repositoryURLFormat string
|
||||
referenceName string
|
||||
username string
|
||||
password string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Clone Azure DevOps repo branch",
|
||||
args: args{
|
||||
repositoryURLFormat: "https://:%s@portainer.visualstudio.com/Playground/_git/dev_integration",
|
||||
referenceName: "refs/heads/main",
|
||||
username: "",
|
||||
password: pat,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Clone Azure DevOps repo tag",
|
||||
args: args{
|
||||
repositoryURLFormat: "https://:%s@portainer.visualstudio.com/Playground/_git/dev_integration",
|
||||
referenceName: "refs/tags/v1.1",
|
||||
username: "",
|
||||
password: pat,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dst, err := ioutils.TempDir("", "clone")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dst)
|
||||
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
|
||||
err = service.ClonePublicRepository(repositoryUrl, tt.args.referenceName, dst)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService()
|
||||
|
||||
dst, err := ioutils.TempDir("", "clone")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration"
|
||||
err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, "", pat)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
func getRequiredValue(t *testing.T, name string) string {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
t.Fatalf("can't find required env var \"%s\"", name)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func ensureIntegrationTest(t *testing.T) {
|
||||
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
|
||||
t.Skip("skip an integration test")
|
||||
}
|
||||
}
|
||||
250
api/git/azure_test.go
Normal file
250
api/git/azure_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_buildDownloadUrl(t *testing.T) {
|
||||
a := NewAzureDownloader(nil)
|
||||
u, err := a.buildDownloadUrl(&azureOptions{
|
||||
organisation: "organisation",
|
||||
project: "project",
|
||||
repository: "repository",
|
||||
}, "refs/heads/main")
|
||||
|
||||
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0&versionDescriptor.versionType=branch")
|
||||
actualUrl, _ := url.Parse(u)
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
|
||||
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
|
||||
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
|
||||
assert.Equal(t, expectedUrl.Query(), actualUrl.Query())
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseAzureUrl(t *testing.T) {
|
||||
type args struct {
|
||||
url string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *azureOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Expected SSH URL format starting with ssh://",
|
||||
args: args{
|
||||
url: "ssh://git@ssh.dev.azure.com:v3/Organisation/Project/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Expected SSH URL format starting with git@ssh",
|
||||
args: args{
|
||||
url: "git@ssh.dev.azure.com:v3/Organisation/Project/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Unexpected SSH URL format",
|
||||
args: args{
|
||||
url: "git@ssh.dev.azure.com:v3/Organisation/Repository",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Expected HTTPS URL format",
|
||||
args: args{
|
||||
url: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
username: "Organisation",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with credentials",
|
||||
args: args{
|
||||
url: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with password",
|
||||
args: args{
|
||||
url: "https://:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
password: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Visual Studio HTTPS URL with credentials",
|
||||
args: args{
|
||||
url: "https://username:password@organisation.visualstudio.com/project/_git/repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "organisation",
|
||||
project: "project",
|
||||
repository: "repository",
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Unexpected HTTPS URL format",
|
||||
args: args{
|
||||
url: "https://Organisation@dev.azure.com/Project/_git/Repository",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseUrl(tt.args.url)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseUrl() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isAzureUrl(t *testing.T) {
|
||||
type args struct {
|
||||
s string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Is Azure url",
|
||||
args: args{
|
||||
s: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Is Azure url",
|
||||
args: args{
|
||||
s: "https://portainer.visualstudio.com/project/_git/repository",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Is NOT Azure url",
|
||||
args: args{
|
||||
s: "https://github.com/Organisation/Repository",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, isAzureUrl(tt.args.s))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
type args struct {
|
||||
options cloneOptions
|
||||
}
|
||||
type basicAuth struct {
|
||||
username, password string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *basicAuth
|
||||
}{
|
||||
{
|
||||
name: "username, password embedded",
|
||||
args: args{
|
||||
options: cloneOptions{
|
||||
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
},
|
||||
want: &basicAuth{
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "username, password embedded, clone options take precedence",
|
||||
args: args{
|
||||
options: cloneOptions{
|
||||
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
username: "u",
|
||||
password: "p",
|
||||
},
|
||||
},
|
||||
want: &basicAuth{
|
||||
username: "u",
|
||||
password: "p",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no credentials",
|
||||
args: args{
|
||||
options: cloneOptions{
|
||||
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var zipRequestAuth *basicAuth
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if username, password, ok := r.BasicAuth(); ok {
|
||||
zipRequestAuth = &basicAuth{username, password}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound) // this makes function under test to return an error
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
a := &azureDownloader{
|
||||
client: server.Client(),
|
||||
baseUrl: server.URL,
|
||||
}
|
||||
_, err := a.downloadZipFromAzureDevOps(context.Background(), tt.args.options)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.want, zipRequestAuth)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,71 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"github.com/pkg/errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/src-d/go-git.v4"
|
||||
"gopkg.in/src-d/go-git.v4/plumbing"
|
||||
"gopkg.in/src-d/go-git.v4/plumbing/transport/client"
|
||||
githttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
)
|
||||
|
||||
type cloneOptions struct {
|
||||
repositoryUrl string
|
||||
username string
|
||||
password string
|
||||
referenceName string
|
||||
depth int
|
||||
}
|
||||
|
||||
type downloader interface {
|
||||
download(ctx context.Context, dst string, opt cloneOptions) error
|
||||
}
|
||||
|
||||
type gitClient struct{
|
||||
preserveGitDirectory bool
|
||||
}
|
||||
|
||||
func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) error {
|
||||
gitOptions := git.CloneOptions{
|
||||
URL: opt.repositoryUrl,
|
||||
Depth: opt.depth,
|
||||
}
|
||||
|
||||
if opt.password != "" || opt.username != "" {
|
||||
gitOptions.Auth = &githttp.BasicAuth{
|
||||
Username: opt.username,
|
||||
Password: opt.password,
|
||||
}
|
||||
}
|
||||
|
||||
if opt.referenceName != "" {
|
||||
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
|
||||
}
|
||||
|
||||
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to clone git repository")
|
||||
}
|
||||
|
||||
if !c.preserveGitDirectory {
|
||||
os.RemoveAll(filepath.Join(dst, ".git"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service represents a service for managing Git.
|
||||
type Service struct {
|
||||
httpsCli *http.Client
|
||||
azure downloader
|
||||
git downloader
|
||||
}
|
||||
|
||||
// NewService initializes a new service.
|
||||
@@ -31,32 +81,37 @@ func NewService() *Service {
|
||||
|
||||
return &Service{
|
||||
httpsCli: httpsCli,
|
||||
azure: NewAzureDownloader(httpsCli),
|
||||
git: gitClient{},
|
||||
}
|
||||
}
|
||||
|
||||
// ClonePublicRepository clones a public git repository using the specified URL in the specified
|
||||
// destination folder.
|
||||
func (service *Service) ClonePublicRepository(repositoryURL, referenceName string, destination string) error {
|
||||
return cloneRepository(repositoryURL, referenceName, destination)
|
||||
func (service *Service) ClonePublicRepository(repositoryURL, referenceName, destination string) error {
|
||||
return service.cloneRepository(destination, cloneOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
referenceName: referenceName,
|
||||
depth: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
|
||||
// destination folder. It will use the specified username and password for basic HTTP authentication.
|
||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
|
||||
credentials := username + ":" + url.PathEscape(password)
|
||||
repositoryURL = strings.Replace(repositoryURL, "://", "://"+credentials+"@", 1)
|
||||
return cloneRepository(repositoryURL, referenceName, destination)
|
||||
// destination folder. It will use the specified Username and Password for basic HTTP authentication.
|
||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName, destination, username, password string) error {
|
||||
return service.cloneRepository(destination, cloneOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
username: username,
|
||||
password: password,
|
||||
referenceName: referenceName,
|
||||
depth: 1,
|
||||
})
|
||||
}
|
||||
|
||||
func cloneRepository(repositoryURL, referenceName, destination string) error {
|
||||
options := &git.CloneOptions{
|
||||
URL: repositoryURL,
|
||||
func (service *Service) cloneRepository(destination string, options cloneOptions) error {
|
||||
if isAzureUrl(options.repositoryUrl) {
|
||||
return service.azure.download(context.TODO(), destination, options)
|
||||
}
|
||||
|
||||
if referenceName != "" {
|
||||
options.ReferenceName = plumbing.ReferenceName(referenceName)
|
||||
}
|
||||
|
||||
_, err := git.PlainClone(destination, false, options)
|
||||
return err
|
||||
return service.git.download(context.TODO(), destination, options)
|
||||
}
|
||||
|
||||
26
api/git/git_integration_test.go
Normal file
26
api/git/git_integration_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := NewService()
|
||||
|
||||
dst, err := ioutils.TempDir("", "clone")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
|
||||
err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, username, pat)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
173
api/git/git_test.go
Normal file
173
api/git/git_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var bareRepoDir string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if err := testMain(m); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// testMain does extra setup/teardown before/after testing.
|
||||
// The function is separated from TestMain due to necessity to call os.Exit/log.Fatal in the latter.
|
||||
func testMain(m *testing.M) error {
|
||||
dir, err := ioutil.TempDir("", "git-repo-")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create a temp dir")
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
bareRepoDir = filepath.Join(dir, "test-clone.git")
|
||||
|
||||
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to open an archive")
|
||||
}
|
||||
err = archive.ExtractTarGz(file, dir)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to extract file from the archive to a folder %s\n", dir)
|
||||
}
|
||||
|
||||
m.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_ClonePublicRepository_Shallow(t *testing.T) {
|
||||
service := Service{git: gitClient{preserveGitDirectory: true}} // no need for http client since the test access the repo via file system.
|
||||
repositoryURL := bareRepoDir
|
||||
referenceName := "refs/heads/main"
|
||||
destination := "shallow"
|
||||
|
||||
dir, err := ioutil.TempDir("", destination)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create a temp dir")
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err = service.ClonePublicRepository(repositoryURL, referenceName, dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||
service := Service{git: gitClient{preserveGitDirectory: false}} // no need for http client since the test access the repo via file system.
|
||||
repositoryURL := bareRepoDir
|
||||
referenceName := "refs/heads/main"
|
||||
destination := "shallow"
|
||||
|
||||
dir, err := ioutil.TempDir("", destination)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create a temp dir")
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err = service.ClonePublicRepository(repositoryURL, referenceName, dir)
|
||||
assert.NoError(t, err)
|
||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
||||
}
|
||||
|
||||
func Test_cloneRepository(t *testing.T) {
|
||||
service := Service{git: gitClient{preserveGitDirectory: true}} // no need for http client since the test access the repo via file system.
|
||||
|
||||
repositoryURL := bareRepoDir
|
||||
referenceName := "refs/heads/main"
|
||||
destination := "shallow"
|
||||
|
||||
dir, err := ioutil.TempDir("", destination)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create a temp dir")
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
t.Logf("Cloning into %s", dir)
|
||||
|
||||
err = service.cloneRepository(dir, cloneOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
referenceName: referenceName,
|
||||
depth: 10,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
func getCommitHistoryLength(t *testing.T, err error, dir string) int {
|
||||
repo, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("can't open a git repo at %s with error %v", dir, err)
|
||||
}
|
||||
iter, err := repo.Log(&git.LogOptions{All: true})
|
||||
if err != nil {
|
||||
t.Fatalf("can't get a commit history iterator with error %v", err)
|
||||
}
|
||||
count := 0
|
||||
err = iter.ForEach(func(_ *object.Commit) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("can't iterate over the commit history with error %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type testDownloader struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *testDownloader) download(_ context.Context, _ string, _ cloneOptions) error {
|
||||
t.called = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_cloneRepository_azure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
called bool
|
||||
}{
|
||||
{
|
||||
name: "Azure HTTP URL",
|
||||
url: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
called: true,
|
||||
},
|
||||
{
|
||||
name: "Azure SSH URL",
|
||||
url: "git@ssh.dev.azure.com:v3/Organisation/Project/Repository",
|
||||
called: true,
|
||||
},
|
||||
{
|
||||
name: "Something else",
|
||||
url: "https://example.com",
|
||||
called: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
azure := &testDownloader{}
|
||||
git := &testDownloader{}
|
||||
|
||||
s := &Service{azure: azure, git: git}
|
||||
s.cloneRepository("", cloneOptions{repositoryUrl: tt.url, depth: 1})
|
||||
|
||||
// if azure API is called, git isn't and vice versa
|
||||
assert.Equal(t, tt.called, azure.called)
|
||||
assert.Equal(t, tt.called, !git.called)
|
||||
})
|
||||
}
|
||||
}
|
||||
BIN
api/git/testdata/azure-repo.zip
vendored
Normal file
BIN
api/git/testdata/azure-repo.zip
vendored
Normal file
Binary file not shown.
BIN
api/git/testdata/test-clone-git-repo.tar.gz
vendored
Normal file
BIN
api/git/testdata/test-clone-git-repo.tar.gz
vendored
Normal file
Binary file not shown.
23
api/go.mod
23
api/go.mod
@@ -3,41 +3,48 @@ module github.com/portainer/portainer/api
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.14
|
||||
github.com/Microsoft/go-winio v0.4.16
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/aws/aws-sdk-go v1.38.3
|
||||
github.com/boltdb/bolt v1.3.1
|
||||
github.com/containerd/containerd v1.3.1 // indirect
|
||||
github.com/coreos/go-semver v0.3.0
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/docker/cli v0.0.0-20191126203649-54d085b857e9
|
||||
github.com/docker/docker v0.0.0-00010101000000-000000000000
|
||||
github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||
github.com/go-git/go-git/v5 v5.3.0
|
||||
github.com/go-ldap/ldap/v3 v3.1.8
|
||||
github.com/gofrs/uuid v3.2.0+incompatible
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/websocket v1.4.1
|
||||
github.com/imdario/mergo v0.3.8 // indirect
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/jpillora/chisel v0.0.0-20190724232113-f3a8df20e389
|
||||
github.com/json-iterator/go v1.1.8
|
||||
github.com/json-iterator/go v1.1.10
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
|
||||
github.com/mattn/go-shellwords v1.0.6 // indirect
|
||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210415235931-d457f9aba1cc
|
||||
github.com/portainer/libcompose v0.5.3
|
||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1
|
||||
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect
|
||||
github.com/portainer/liblicense v0.0.0-20210409011001-c758dd044fbb
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
go.etcd.io/bbolt v1.3.5 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/src-d/go-git.v4 v4.13.1
|
||||
k8s.io/api v0.17.2
|
||||
k8s.io/apimachinery v0.17.2
|
||||
k8s.io/client-go v0.17.2
|
||||
)
|
||||
|
||||
replace github.com/docker/docker => github.com/docker/engine v1.4.2-0.20200204220554-5f6d6f3f2203
|
||||
|
||||
replace golang.org/x/sys => golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456
|
||||
|
||||
142
api/go.sum
142
api/go.sum
@@ -11,15 +11,19 @@ github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxB
|
||||
github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
|
||||
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Microsoft/go-winio v0.3.8 h1:dvxbxtpTIjdAbx2OtL26p4eq0iEvys/U5yrsTJb3NZI=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.3.8/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
|
||||
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
|
||||
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
|
||||
github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
|
||||
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
|
||||
github.com/Microsoft/hcsshim v0.8.6 h1:ZfF0+zZeYdzMIVMZHKtDKJvLHj76XCuVae/jNkjj0IA=
|
||||
github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
|
||||
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
|
||||
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
|
||||
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
|
||||
@@ -34,6 +38,10 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
||||
github.com/aws/aws-sdk-go v1.38.3 h1:QCL/le04oAz2jELMRSuJVjGT7H+4hhoQc66eMPCfU/k=
|
||||
github.com/aws/aws-sdk-go v1.38.3/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -47,7 +55,7 @@ github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc h1:TP+534wVl
|
||||
github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
|
||||
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -80,6 +88,7 @@ github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkg
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6vRfwrJatElLBEf0I=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
@@ -92,6 +101,15 @@ github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
|
||||
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck=
|
||||
github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
|
||||
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
|
||||
github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
|
||||
github.com/go-git/go-billy/v5 v5.1.0 h1:4pl5BV4o7ZG/lterP4S6WzJ6xr49Ba5ET9ygheTYahk=
|
||||
github.com/go-git/go-billy/v5 v5.1.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12 h1:PbKy9zOy4aAKrJ5pibIRpVO2BXnK1Tlcg+caKI7Ox5M=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw=
|
||||
github.com/go-git/go-git/v5 v5.3.0 h1:8WKMtJR2j8RntEXR/uvTKagfEt4GYlwQ7mntE4+0GWc=
|
||||
github.com/go-git/go-git/v5 v5.3.0/go.mod h1:xdX4bWJ48aOrdhnl2XqHYstHbbp6+LFS4r4X+lNVprw=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-ldap/ldap/v3 v3.1.8 h1:5vU/2jOh9HqprwXp8aF915s9p6Z8wmbSEVF7/gdTFhM=
|
||||
github.com/go-ldap/ldap/v3 v3.1.8/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q=
|
||||
@@ -105,7 +123,6 @@ github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dp
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1 h1:72R+M5VuhED/KujmZVcIquuo8mBgX4oVda//DQb3PXo=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I=
|
||||
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
@@ -118,6 +135,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -145,13 +164,20 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ=
|
||||
github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
||||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jpillora/ansi v0.0.0-20170202005112-f496b27cd669 h1:l5rH/CnVVu+HPxjtxjM90nHrm4nov3j3RF9/62UjgLs=
|
||||
github.com/jpillora/ansi v0.0.0-20170202005112-f496b27cd669/go.mod h1:kOeLNvjNBGSV3uYtFjvb72+fnZCMFJF1XDvRIjdom0g=
|
||||
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
|
||||
@@ -164,12 +190,13 @@ github.com/jpillora/sizestr v0.0.0-20160130011556-e2ea2fa42fb9/go.mod h1:1ffp+CR
|
||||
github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok=
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw=
|
||||
@@ -177,13 +204,14 @@ github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v0.0.0-20150511174710-5cf931ef8f76/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mattn/go-shellwords v1.0.6 h1:9Jok5pILi5S1MnDirGVTufYGtksUs/V2BWUP3ZkeUUI=
|
||||
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
|
||||
@@ -205,10 +233,13 @@ github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5
|
||||
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420 h1:Yu3681ykYHDfLoI6XVjL4JWmkE+3TX9yfIWwRCh1kFM=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
|
||||
@@ -218,22 +249,24 @@ github.com/opencontainers/runc v0.0.0-20161109192122-51371867a01c h1:iOMba/KmaXg
|
||||
github.com/opencontainers/runc v0.0.0-20161109192122-51371867a01c/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 h1:lNCW6THrCKBiJBpz8kbVGjC7MgdCGKwuvBgc7LoD6sw=
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI=
|
||||
github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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 v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/docker-compose-wrapper v0.0.0-20210415235931-d457f9aba1cc h1:MvSEkOvhW3m2D3L0/Ymrjgg0t3CpHlHwpZfpgpIGNiw=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210415235931-d457f9aba1cc/go.mod h1:No8p8iZt9N2HOtDS9aWkh1ILxmQVoOTZZjHGlOij/ec=
|
||||
github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXsI9uI8=
|
||||
github.com/portainer/libcompose v0.5.3/go.mod h1:7SKd/ho69rRKHDFSDUwkbMcol2TMKU5OslDsajr8Ro8=
|
||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yHr4rtnirg0W0Cjvv6/DzxBIZk5sV59208=
|
||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2/go.mod h1:/wIeGwJOMYc1JplE/OvYMO5korce39HddIfI8VKGyAM=
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II=
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0=
|
||||
github.com/portainer/liblicense v0.0.0-20210409011001-c758dd044fbb h1:H1UHoRATMJWQOzvvxYDBcPxVMctpsSzsKdHwPTCFyEU=
|
||||
github.com/portainer/liblicense v0.0.0-20210409011001-c758dd044fbb/go.mod h1:6OSugUz07QF1WwrMmRNIMhgiq+drVMIZ98UTu5LVZP8=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.1.0 h1:BQ53HtBmfOitExawJ6LokA4x8ov/z0SYYb0+HxJfRI8=
|
||||
@@ -248,9 +281,10 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.3 h1:CTwfnzjQ+8dS6MhHHu4YswVAD99sL2wjPqP+VkURmKE=
|
||||
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
@@ -258,40 +292,40 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd
|
||||
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4=
|
||||
github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
|
||||
github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ=
|
||||
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
|
||||
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
|
||||
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.1.0 h1:ngVtJC9TY/lg0AA/1k48FYhBrhRoFlEmWzsehpNAaZg=
|
||||
github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
|
||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1 h1:anGSYQpPhQwXlwsu5wmfq0nWkCNaMEMUwAv13Y92hd8=
|
||||
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -307,13 +341,14 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 h1:e6HwijUxhDe+hPNjZQQn9bA5PW3vNmnN64U2ZW759Lk=
|
||||
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs=
|
||||
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
|
||||
@@ -324,27 +359,16 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181019160139-8e24a49d80f8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 h1:4y9KwBHBgBNwDbtu44R5o1fdOCQUEXhbk/P4A9WmJq0=
|
||||
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -356,13 +380,12 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7 h1:ZUjXAXmrAyrmmCPHgCA/vChHcpsX27MZ3yBonD/z1KE=
|
||||
@@ -373,8 +396,12 @@ google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
@@ -384,14 +411,16 @@ gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOA
|
||||
gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g=
|
||||
gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE=
|
||||
gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
@@ -410,6 +439,7 @@ k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUc
|
||||
k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a h1:UcxjrRMyNx/i/y8G7kPvLyy7rfbeuf1PYyBf973pgyU=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f h1:GiPwtSzdP43eI1hpPCbROQCCIgCuiMMNF8YUVLF3vJo=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
|
||||
|
||||
@@ -5,6 +5,8 @@ import "errors"
|
||||
var (
|
||||
// ErrEndpointAccessDenied Access denied to endpoint error
|
||||
ErrEndpointAccessDenied = errors.New("Access denied to endpoint")
|
||||
// ErrNoValidLicense Unauthorized error
|
||||
ErrNoValidLicense = errors.New("No valid Portainer License found")
|
||||
// ErrUnauthorized Unauthorized error
|
||||
ErrUnauthorized = errors.New("Unauthorized")
|
||||
// ErrResourceAccessDenied Access denied to resource error
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
type authenticatePayload struct {
|
||||
@@ -49,40 +51,75 @@ func (payload *authenticatePayload) Validate(r *http.Request) error {
|
||||
// @failure 422 "Invalid Credentials"
|
||||
// @failure 500 "Server error"
|
||||
// @router /auth [post]
|
||||
func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
resp := &authMiddlewareResponse{
|
||||
Method: portainer.AuthenticationInternal,
|
||||
}
|
||||
|
||||
var payload authenticatePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
resp.Username = payload.Username
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve settings from the database",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
u, err := handler.DataStore.User().UserByUsername(payload.Username)
|
||||
if err != nil && err != bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve a user with the specified username from the database",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if err == bolterrors.ErrObjectNotFound && (settings.AuthenticationMethod == portainer.AuthenticationInternal || settings.AuthenticationMethod == portainer.AuthenticationOAuth) {
|
||||
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Message: "Invalid credentials",
|
||||
Err: httperrors.ErrUnauthorized,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod == portainer.AuthenticationLDAP {
|
||||
if u == nil && settings.LDAPSettings.AutoCreateUsers {
|
||||
return handler.authenticateLDAPAndCreateUser(w, payload.Username, payload.Password, &settings.LDAPSettings)
|
||||
return handler.authenticateLDAPAndCreateUser(rw, payload.Username, payload.Password, &settings.LDAPSettings)
|
||||
} else if u == nil && !settings.LDAPSettings.AutoCreateUsers {
|
||||
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Message: "Invalid credentials",
|
||||
Err: httperrors.ErrUnauthorized,
|
||||
}
|
||||
}
|
||||
return handler.authenticateLDAP(w, u, payload.Password, &settings.LDAPSettings)
|
||||
return handler.authenticateLDAP(rw, u, payload.Password, &settings.LDAPSettings)
|
||||
}
|
||||
|
||||
return handler.authenticateInternal(w, u, payload.Password)
|
||||
return handler.authenticateInternal(rw, u, payload.Password)
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
|
||||
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, password string, ldapSettings *portainer.LDAPSettings) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
resp := &authMiddlewareResponse{
|
||||
Method: portainer.AuthenticationLDAP,
|
||||
Username: user.Username,
|
||||
}
|
||||
|
||||
err := handler.LDAPService.AuthenticateUser(user.Username, password, ldapSettings)
|
||||
if err != nil {
|
||||
return handler.authenticateInternal(w, user, password)
|
||||
@@ -93,32 +130,96 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
|
||||
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user)
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError {
|
||||
err := handler.CryptoService.CompareHashAndData(user.Password, password)
|
||||
err = handler.AuthorizationService.UpdateUsersAuthorizations()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to update user authorizations",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user)
|
||||
info := handler.LicenseService.Info()
|
||||
|
||||
if user.Role != portainer.AdministratorRole && !info.Valid {
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Message: "License is not valid",
|
||||
Err: httperrors.ErrNoValidLicense,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user, resp.Method)
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
|
||||
func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
resp := &authMiddlewareResponse{
|
||||
Method: portainer.AuthenticationInternal,
|
||||
Username: user.Username,
|
||||
}
|
||||
|
||||
err := handler.CryptoService.CompareHashAndData(user.Password, password)
|
||||
if err != nil {
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Message: "Invalid credentials",
|
||||
Err: httperrors.ErrUnauthorized,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
info := handler.LicenseService.Info()
|
||||
|
||||
if user.Role != portainer.AdministratorRole && !info.Valid {
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Message: "License is not valid",
|
||||
Err: httperrors.ErrNoValidLicense,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user, resp.Method)
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, username, password string, ldapSettings *portainer.LDAPSettings) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
resp := &authMiddlewareResponse{
|
||||
Method: portainer.AuthenticationLDAP,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", err}
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Message: "Invalid credentials",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
user := &portainer.User{
|
||||
Username: username,
|
||||
Role: portainer.StandardUserRole,
|
||||
Username: username,
|
||||
Role: portainer.StandardUserRole,
|
||||
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
||||
}
|
||||
|
||||
err = handler.DataStore.User().CreateUser(user)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to persist user inside the database",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
err = handler.addUserIntoTeams(user, ldapSettings)
|
||||
@@ -126,26 +227,78 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
|
||||
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user)
|
||||
}
|
||||
|
||||
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||
tokenData := &portainer.TokenData{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
|
||||
return handler.persistAndWriteToken(w, tokenData)
|
||||
}
|
||||
|
||||
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
|
||||
token, err := handler.JWTService.GenerateToken(tokenData)
|
||||
err = handler.AuthorizationService.UpdateUsersAuthorizations()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err}
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to update user authorizations",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return response.JSON(w, &authenticateResponse{JWT: token})
|
||||
info := handler.LicenseService.Info()
|
||||
|
||||
if !info.Valid {
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Message: "License is not valid",
|
||||
Err: httperrors.ErrNoValidLicense,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user, resp.Method)
|
||||
}
|
||||
|
||||
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User, method portainer.AuthenticationMethod) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
tokenData := composeTokenData(user)
|
||||
|
||||
return handler.persistAndWriteToken(w, tokenData, nil, method)
|
||||
}
|
||||
|
||||
func (handler *Handler) writeTokenForOAuth(w http.ResponseWriter, user *portainer.User, expiryTime *time.Time, method portainer.AuthenticationMethod) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
tokenData := composeTokenData(user)
|
||||
|
||||
return handler.persistAndWriteToken(w, tokenData, expiryTime, method)
|
||||
}
|
||||
|
||||
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData, expiryTime *time.Time, method portainer.AuthenticationMethod) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
resp := &authMiddlewareResponse{
|
||||
Username: tokenData.Username,
|
||||
Method: method,
|
||||
}
|
||||
|
||||
var token string
|
||||
var err error
|
||||
|
||||
if method == portainer.AuthenticationOAuth {
|
||||
token, err = handler.JWTService.GenerateTokenForOAuth(tokenData, expiryTime)
|
||||
if err != nil {
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to generate JWT token for OAuth",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
token, err = handler.JWTService.GenerateToken(tokenData)
|
||||
if err != nil {
|
||||
return resp,
|
||||
&httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to generate JWT token",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return resp, response.JSON(w, &authenticateResponse{JWT: token})
|
||||
|
||||
}
|
||||
|
||||
func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portainer.LDAPSettings) error {
|
||||
@@ -179,6 +332,7 @@ func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portain
|
||||
|
||||
err := handler.DataStore.TeamMembership().CreateTeamMembership(membership)
|
||||
if err != nil {
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -204,3 +358,11 @@ func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamM
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func composeTokenData(user *portainer.User) *portainer.TokenData {
|
||||
return &portainer.TokenData{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
type oauthPayload struct {
|
||||
@@ -25,7 +26,24 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id AuthenticateOauth
|
||||
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (*portainer.OAuthInfo, error) {
|
||||
if code == "" {
|
||||
return nil, errors.New("Invalid OAuth authorization code")
|
||||
}
|
||||
|
||||
if settings == nil {
|
||||
return nil, errors.New("Invalid OAuth configuration")
|
||||
}
|
||||
|
||||
authInfo, err := handler.OAuthService.Authenticate(code, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authInfo, nil
|
||||
}
|
||||
|
||||
// @id ValidateOAuth
|
||||
// @summary Authenticate with OAuth
|
||||
// @tags auth
|
||||
// @accept json
|
||||
@@ -36,63 +54,81 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||
// @failure 422 "Invalid Credentials"
|
||||
// @failure 500 "Server error"
|
||||
// @router /auth/oauth/validate [post]
|
||||
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("Invalid OAuth authorization code")
|
||||
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
resp := &authMiddlewareResponse{
|
||||
Method: portainer.AuthenticationOAuth,
|
||||
}
|
||||
|
||||
if settings == nil {
|
||||
return "", errors.New("Invalid OAuth configuration")
|
||||
}
|
||||
|
||||
username, err := handler.OAuthService.Authenticate(code, settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload oauthPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve settings from the database",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod != 3 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Message: "OAuth authentication is not enabled",
|
||||
Err: errors.New("OAuth authentication is not enabled"),
|
||||
}
|
||||
}
|
||||
|
||||
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
|
||||
authInfo, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] - OAuth authentication error: %s", err)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to authenticate through OAuth",
|
||||
Err: httperrors.ErrUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().UserByUsername(username)
|
||||
resp.Username = authInfo.Username
|
||||
|
||||
user, err := handler.DataStore.User().UserByUsername(authInfo.Username)
|
||||
if err != nil && err != bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve a user with the specified username from the database",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", httperrors.ErrUnauthorized}
|
||||
if user == nil && !settings.OAuthSettings.OAuthAutoMapTeamMemberships && !settings.OAuthSettings.OAuthAutoCreateUsers {
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Message: "Account not created beforehand in Portainer and automatic user provisioning not enabled",
|
||||
Err: httperrors.ErrUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
user = &portainer.User{
|
||||
Username: username,
|
||||
Role: portainer.StandardUserRole,
|
||||
Username: authInfo.Username,
|
||||
Role: portainer.StandardUserRole,
|
||||
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
||||
}
|
||||
|
||||
err = handler.DataStore.User().CreateUser(user)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to persist user inside the database",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if settings.OAuthSettings.DefaultTeamID != 0 {
|
||||
@@ -104,11 +140,54 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
err = handler.DataStore.TeamMembership().CreateTeamMembership(membership)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err}
|
||||
return &authMiddlewareResponse{
|
||||
Method: portainer.AuthenticationOAuth,
|
||||
}, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to persist team membership inside the database",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.AuthorizationService.UpdateUsersAuthorizations()
|
||||
if err != nil {
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to update user authorizations",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user)
|
||||
if settings.OAuthSettings.OAuthAutoMapTeamMemberships {
|
||||
if settings.OAuthSettings.TeamMemberships.OAuthClaimName == "" {
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to process user oauth team memberships",
|
||||
Err: errors.New("empty value set for oauth team membership Claim name"),
|
||||
}
|
||||
}
|
||||
|
||||
err = updateOAuthTeamMemberships(handler.DataStore, settings.OAuthSettings.TeamMemberships.OAuthClaimMappings, *user, authInfo.Teams)
|
||||
if err != nil {
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to update user oauth team memberships",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info := handler.LicenseService.Info()
|
||||
|
||||
if user.Role != portainer.AdministratorRole && !info.Valid {
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Message: "License is not valid",
|
||||
Err: httperrors.ErrNoValidLicense,
|
||||
}
|
||||
}
|
||||
|
||||
return handler.writeTokenForOAuth(w, user, authInfo.ExpiryTime, portainer.AuthenticationOAuth)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle authentication operations.
|
||||
@@ -18,9 +21,12 @@ type Handler struct {
|
||||
CryptoService portainer.CryptoService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
LicenseService portainer.LicenseService
|
||||
OAuthService portainer.OAuthService
|
||||
ProxyManager *proxy.Manager
|
||||
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
AuthorizationService *authorization.Service
|
||||
UserActivityStore portainer.UserActivityStore
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage authentication operations.
|
||||
@@ -30,11 +36,53 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi
|
||||
}
|
||||
|
||||
h.Handle("/auth/oauth/validate",
|
||||
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost)
|
||||
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authActivityMiddleware(h.validateOAuth, portainer.AuthenticationActivitySuccess))))).Methods(http.MethodPost)
|
||||
h.Handle("/auth",
|
||||
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)
|
||||
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authActivityMiddleware(h.authenticate, portainer.AuthenticationActivitySuccess))))).Methods(http.MethodPost)
|
||||
h.Handle("/auth/logout",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.authActivityMiddleware(h.logout, portainer.AuthenticationActivityLogOut)))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type authMiddlewareHandler func(http.ResponseWriter, *http.Request) (*authMiddlewareResponse, *httperror.HandlerError)
|
||||
|
||||
type authMiddlewareResponse struct {
|
||||
Username string
|
||||
Method portainer.AuthenticationMethod
|
||||
}
|
||||
|
||||
func (handler *Handler) authActivityMiddleware(prev authMiddlewareHandler, defaultActivityType portainer.AuthenticationActivityType) httperror.LoggerHandler {
|
||||
return func(rw http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
resp, respErr := prev(rw, r)
|
||||
|
||||
method := resp.Method
|
||||
if int(method) == 0 {
|
||||
method = portainer.AuthenticationInternal
|
||||
}
|
||||
|
||||
activityType := defaultActivityType
|
||||
if respErr != nil && activityType == portainer.AuthenticationActivitySuccess {
|
||||
activityType = portainer.AuthenticationActivityFailure
|
||||
}
|
||||
|
||||
origin := getOrigin(r.RemoteAddr)
|
||||
|
||||
_, err := handler.UserActivityStore.LogAuthActivity(resp.Username, origin, method, activityType)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [msg: Failed logging auth activity] [error: %s]", err)
|
||||
}
|
||||
|
||||
return respErr
|
||||
}
|
||||
}
|
||||
|
||||
func getOrigin(addr string) string {
|
||||
ipRegex := regexp.MustCompile(`:\d+$`)
|
||||
ipSplit := ipRegex.Split(addr, -1)
|
||||
if len(ipSplit) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ipSplit[0]
|
||||
}
|
||||
|
||||
@@ -15,13 +15,23 @@ import (
|
||||
// @success 204 "Success"
|
||||
// @failure 500 "Server error"
|
||||
// @router /auth/logout [post]
|
||||
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) (*authMiddlewareResponse, *httperror.HandlerError) {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
resp := &authMiddlewareResponse{
|
||||
Username: tokenData.Username,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user details from authentication token", err}
|
||||
return resp, &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve user details from authentication token",
|
||||
Err: err,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
handler.KubernetesTokenCacheManager.RemoveUserFromCache(int(tokenData.ID))
|
||||
|
||||
return response.Empty(w)
|
||||
return resp, response.Empty(w)
|
||||
|
||||
}
|
||||
|
||||
165
api/http/handler/auth/oauth_team_memberships.go
Normal file
165
api/http/handler/auth/oauth_team_memberships.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// removeMemberships removes a user's team membership(s) if user does not belong to it/them anymore
|
||||
func removeMemberships(tms portainer.TeamMembershipService, user portainer.User, teams []portainer.Team) error {
|
||||
log.Println("[DEBUG] [internal,oauth] [message: removing user team memberships which no longer exist]")
|
||||
memberships, err := tms.TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, membership := range memberships {
|
||||
teamsContainsTeamID := false
|
||||
for _, team := range teams {
|
||||
if team.ID == membership.TeamID {
|
||||
teamsContainsTeamID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !teamsContainsTeamID {
|
||||
err := tms.DeleteTeamMembership(membership.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createOrUpdateMembership creates a membership if it does not exist or updates a memberships role (if already existent)
|
||||
func createOrUpdateMembership(tms portainer.TeamMembershipService, user portainer.User, team portainer.Team) error {
|
||||
memberships, err := tms.TeamMembershipsByTeamID(team.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[DEBUG] [internal,oauth] [message: memberships: %v]", memberships)
|
||||
|
||||
var membership *portainer.TeamMembership
|
||||
for _, m := range memberships {
|
||||
if m.UserID == user.ID {
|
||||
membership = &m
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if membership == nil {
|
||||
membership = &portainer.TeamMembership{
|
||||
UserID: user.ID,
|
||||
TeamID: team.ID,
|
||||
Role: portainer.MembershipRole(user.Role),
|
||||
}
|
||||
log.Printf("[DEBUG] [internal,oauth] [message: creating oauth user team membership: %v]", membership)
|
||||
err = tms.CreateTeamMembership(membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Printf("[DEBUG] [internal,oauth] [message: membership found %v]", membership)
|
||||
if updatedRole := portainer.MembershipRole(user.Role); membership.Role != updatedRole {
|
||||
log.Printf("[DEBUG] [internal,oauth] [message: updating membership role %d]", updatedRole)
|
||||
membership.Role = updatedRole
|
||||
err = tms.UpdateTeamMembership(membership.ID, membership)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapAllClaimValuesToTeams maps claim values to teams if no explicit mapping exists.
|
||||
// Mapping oauth teams (claim values) to portainer teams by case-insensitive team name
|
||||
func mapAllClaimValuesToTeams(ts portainer.TeamService, user portainer.User, oAuthTeams []string) ([]portainer.Team, error) {
|
||||
teams := make([]portainer.Team, 0)
|
||||
|
||||
log.Println("[DEBUG] [internal,oauth] [message: mapping oauth claim values automatically to existing portainer teams]")
|
||||
dsTeams, err := ts.Teams()
|
||||
if err != nil {
|
||||
return []portainer.Team{}, err
|
||||
}
|
||||
|
||||
for _, oAuthTeam := range oAuthTeams {
|
||||
for _, team := range dsTeams {
|
||||
if strings.EqualFold(team.Name, oAuthTeam) {
|
||||
teams = append(teams, team)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// mapClaimValRegexToTeams maps oauth ClaimValRegex values (stored in settings) to oauth provider teams.
|
||||
// The `ClaimValRegex` is a regexp string that is matched against the oauth team value(s) extracted from oauth user response.
|
||||
// A successful match entails extraction of the respective portainer team (for the mapping).
|
||||
func mapClaimValRegexToTeams(ts portainer.TeamService, claimMappings []portainer.OAuthClaimMappings, oAuthTeams []string) ([]portainer.Team, error) {
|
||||
teams := make([]portainer.Team, 0)
|
||||
|
||||
log.Println("[DEBUG] [internal,oauth] [message: using oauth claim mappings to map groups to portainer teams]")
|
||||
for _, oAuthTeam := range oAuthTeams {
|
||||
for _, mapping := range claimMappings {
|
||||
match, err := regexp.MatchString(mapping.ClaimValRegex, oAuthTeam)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if match {
|
||||
log.Printf("[DEBUG] [internal,oauth] [message: oauth mapping claim matched; claim: %s, team: %s]\n", mapping.ClaimValRegex, oAuthTeam)
|
||||
|
||||
team, err := ts.Team(portainer.TeamID(mapping.Team))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
teams = append(teams, *team)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// updateOAuthTeamMemberships will create, update and delete an oauth user's team memberships.
|
||||
// The mappings of oauth groups to portainer teams is based on the length of `OAuthClaimMappings`; use them if they exist (len > 0),
|
||||
// otherwise map the **values** of the oauth `Claim name` (`OAuthClaimName`) to already existent portainer teams (case-insensitive).
|
||||
func updateOAuthTeamMemberships(dataStore portainer.DataStore, oAuthClaimMappings []portainer.OAuthClaimMappings, user portainer.User, oAuthTeams []string) error {
|
||||
var teams []portainer.Team
|
||||
var err error
|
||||
|
||||
if len(oAuthClaimMappings) > 0 {
|
||||
teams, err = mapClaimValRegexToTeams(dataStore.Team(), oAuthClaimMappings, oAuthTeams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to map claim value regex(s) to teams, mappings: %v, err: %w", oAuthClaimMappings, err)
|
||||
}
|
||||
} else {
|
||||
teams, err = mapAllClaimValuesToTeams(dataStore.Team(), user, oAuthTeams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to map claim value(s) to portainer teams, err: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
err := createOrUpdateMembership(dataStore.TeamMembership(), user, team)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create or update oauth memberships, user: %v, team: %v, err: %w", user, team, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = removeMemberships(dataStore.TeamMembership(), user, teams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove oauth memberships, user: %v, err: %w", user, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
314
api/http/handler/auth/oauth_team_memberships_test.go
Normal file
314
api/http/handler/auth/oauth_team_memberships_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/bolttest"
|
||||
)
|
||||
|
||||
func Test_mapClaimValRegexToTeams(t *testing.T) {
|
||||
store, teardown, err := bolttest.NewTestStore(false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to initialise test store")
|
||||
}
|
||||
defer teardown()
|
||||
|
||||
t.Run("returns no portainer teams if no oauth teams are present", func(t *testing.T) {
|
||||
mappings := []portainer.OAuthClaimMappings{}
|
||||
oAuthTeams := []string{}
|
||||
teams, _ := mapClaimValRegexToTeams(store.TeamService, mappings, oAuthTeams)
|
||||
if len(teams) > 0 {
|
||||
t.Errorf("mapClaimValRegexToTeams return no teams; teams returned %d", len(teams))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns no portainer teams if no regex match occurs", func(t *testing.T) {
|
||||
store.TeamService.CreateTeam(&portainer.Team{ID: 1, Name: "testing"})
|
||||
|
||||
mappings := []portainer.OAuthClaimMappings{
|
||||
{ClaimValRegex: "@", Team: 1},
|
||||
}
|
||||
oAuthTeams := []string{"portainer"}
|
||||
|
||||
teams, _ := mapClaimValRegexToTeams(store.TeamService, mappings, oAuthTeams)
|
||||
if len(teams) > 0 {
|
||||
t.Errorf("mapClaimValRegexToTeams return no teams upon no regex match; teams returned %d", len(teams))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns team upon regex match", func(t *testing.T) {
|
||||
store.TeamService.CreateTeam(&portainer.Team{ID: 1, Name: "testing"})
|
||||
|
||||
mappings := []portainer.OAuthClaimMappings{
|
||||
{ClaimValRegex: "@", Team: 1},
|
||||
}
|
||||
oAuthTeams := []string{"@portainer"}
|
||||
|
||||
got, _ := mapClaimValRegexToTeams(store.TeamService, mappings, oAuthTeams)
|
||||
want := []portainer.Team{{ID: 1, Name: "testing"}}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mapClaimValRegexToTeams failed to return team; got=%v, want=%v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("succcessfully fails to return non-existent team upon regex match", func(t *testing.T) {
|
||||
mappings := []portainer.OAuthClaimMappings{
|
||||
{ClaimValRegex: "@", Team: 1337},
|
||||
}
|
||||
oAuthTeams := []string{"@portainer"}
|
||||
_, err := mapClaimValRegexToTeams(store.TeamService, mappings, oAuthTeams)
|
||||
if err == nil {
|
||||
t.Errorf("mapClaimValRegexToTeams should fail to return non-existent team")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func Test_mapAllClaimValuesToTeams(t *testing.T) {
|
||||
store, teardown, err := bolttest.NewTestStore(false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to initialise test store")
|
||||
}
|
||||
defer teardown()
|
||||
|
||||
store.TeamService.CreateTeam(&portainer.Team{ID: 1, Name: "team-x"})
|
||||
|
||||
t.Run("returns no portainer teams if no oauth teams are present", func(t *testing.T) {
|
||||
oAuthTeams := []string{}
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
|
||||
teams, _ := mapAllClaimValuesToTeams(store.TeamService, user, oAuthTeams)
|
||||
if len(teams) > 0 {
|
||||
t.Errorf("mapAllClaimValuesToTeams return no teams; teams returned %d", len(teams))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns no portainer teams if no regex match occurs", func(t *testing.T) {
|
||||
oAuthTeams := []string{"team-1"}
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
|
||||
teams, _ := mapAllClaimValuesToTeams(store.TeamService, user, oAuthTeams)
|
||||
if len(teams) > 0 {
|
||||
t.Errorf("mapAllClaimValuesToTeams return no teams upon no regex match; teams returned %d", len(teams))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns team upon regex match", func(t *testing.T) {
|
||||
oAuthTeams := []string{"team-x"}
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
|
||||
got, _ := mapAllClaimValuesToTeams(store.TeamService, user, oAuthTeams)
|
||||
want := []portainer.Team{{ID: 1, Name: "team-x"}}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mapAllClaimValuesToTeams failed to return team; got=%v, want=%v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func Test_createOrUpdateMembership(t *testing.T) {
|
||||
store, teardown, err := bolttest.NewTestStore(false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to initialise test store")
|
||||
}
|
||||
defer teardown()
|
||||
|
||||
t.Run("creates membership for new user-team", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
team := portainer.Team{ID: 1, Name: "team-1"}
|
||||
|
||||
err := createOrUpdateMembership(store.TeamMembershipService, user, team)
|
||||
if err != nil {
|
||||
t.Errorf("createOrUpdateMembership should not throw error when creating new team membership")
|
||||
}
|
||||
|
||||
got, _ := store.TeamMembershipService.TeamMemberships()
|
||||
want := []portainer.TeamMembership{{ID: 1, UserID: 1, TeamID: 1, Role: 1}}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("createOrUpdateMembership should succeed in creating new team membership; got=%v, want=%v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("updates membership for existing user-team", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1, Role: 3}
|
||||
team := portainer.Team{ID: 2, Name: "team-2"}
|
||||
store.TeamMembershipService.CreateTeamMembership(&portainer.TeamMembership{ID: 1, UserID: user.ID, TeamID: team.ID, Role: 1})
|
||||
|
||||
err := createOrUpdateMembership(store.TeamMembershipService, user, team)
|
||||
if err != nil {
|
||||
t.Errorf("createOrUpdateMembership should not throw error when updating existing team membership")
|
||||
}
|
||||
|
||||
got, _ := store.TeamMembershipService.TeamMembership(2)
|
||||
want := &portainer.TeamMembership{ID: 2, UserID: 1, TeamID: 2, Role: 3}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("createOrUpdateMembership should succeed in creating new team membership; got=%v, want=%v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func Test_removeMemberships(t *testing.T) {
|
||||
store, teardown, err := bolttest.NewTestStore(false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to initialise test store")
|
||||
}
|
||||
defer teardown()
|
||||
|
||||
t.Run("removes nothing if no user team memberships exist", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
teams := []portainer.Team{{ID: 1, Name: "team-remove"}}
|
||||
|
||||
before, _ := store.TeamMembershipService.TeamMemberships()
|
||||
|
||||
removeMemberships(store.TeamMembershipService, user, teams)
|
||||
|
||||
after, _ := store.TeamMembershipService.TeamMemberships()
|
||||
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("removeMemberships should not have removed any memberships; before=%v, after=%v", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not remove user team membership if it does belong to team whitelist", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
teams := []portainer.Team{{ID: 1, Name: "team-remove"}}
|
||||
store.TeamMembershipService.CreateTeamMembership(&portainer.TeamMembership{ID: 1, UserID: user.ID, TeamID: teams[0].ID, Role: 1})
|
||||
|
||||
before, _ := store.TeamMembershipService.TeamMembership(1)
|
||||
|
||||
removeMemberships(store.TeamMembershipService, user, teams)
|
||||
|
||||
after, _ := store.TeamMembershipService.TeamMembership(1)
|
||||
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("removeMemberships should not have removed any memberships; before=%v, after=%v", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("removes memberships if user team membership does not belong to team whitelist", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
teams := []portainer.Team{{ID: 1, Name: "team-xyz"}}
|
||||
store.TeamMembershipService.CreateTeamMembership(&portainer.TeamMembership{ID: 2, UserID: user.ID, TeamID: 100, Role: 1})
|
||||
store.TeamMembershipService.CreateTeamMembership(&portainer.TeamMembership{ID: 3, UserID: user.ID, TeamID: 50, Role: 1})
|
||||
|
||||
removeMemberships(store.TeamMembershipService, user, teams)
|
||||
|
||||
memberships, _ := store.TeamMembershipService.TeamMembershipsByTeamID(100)
|
||||
if len(memberships) > 0 {
|
||||
t.Errorf("removeMemberships should have successfully removed team membership; team-membership=%v", memberships)
|
||||
}
|
||||
|
||||
memberships, _ = store.TeamMembershipService.TeamMembershipsByTeamID(50)
|
||||
if len(memberships) > 0 {
|
||||
t.Errorf("removeMemberships should have successfully removed team membership; team-membership=%v", memberships)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func Test_updateOAuthTeamMemberships(t *testing.T) {
|
||||
store, teardown, err := bolttest.NewTestStore(false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to initialise test store")
|
||||
}
|
||||
defer teardown()
|
||||
|
||||
t.Run("creates new team memberships based on claim val regex", func(t *testing.T) {
|
||||
store.Team().CreateTeam(&portainer.Team{ID: 1, Name: "testing"})
|
||||
|
||||
claimMappings := []portainer.OAuthClaimMappings{
|
||||
{ClaimValRegex: "@portainer", Team: 1},
|
||||
}
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
oAuthTeams := []string{"@portainer"}
|
||||
|
||||
before, _ := store.TeamMembershipService.TeamMembershipsByTeamID(1)
|
||||
if len(before) > 0 {
|
||||
t.Errorf("updateOAuthTeamMemberships should not have a team membership with team id 1")
|
||||
}
|
||||
|
||||
updateOAuthTeamMemberships(store, claimMappings, user, oAuthTeams)
|
||||
|
||||
after, _ := store.TeamMembershipService.TeamMembershipsByTeamID(1)
|
||||
|
||||
if reflect.DeepEqual(before, after) {
|
||||
t.Errorf("updateOAuthTeamMemberships should have created new team membership based on claim value regex")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallsback to creating team memberships by mapping oauth teams directly to portainer teams", func(t *testing.T) {
|
||||
store.Team().CreateTeam(&portainer.Team{ID: 2, Name: "testing"})
|
||||
|
||||
claimMappings := []portainer.OAuthClaimMappings{}
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
oAuthTeams := []string{"testing"}
|
||||
|
||||
before, _ := store.TeamMembershipService.TeamMembershipsByTeamID(2)
|
||||
if len(before) > 0 {
|
||||
t.Errorf("updateOAuthTeamMemberships should not have a team membership with team id 2")
|
||||
}
|
||||
|
||||
updateOAuthTeamMemberships(store, claimMappings, user, oAuthTeams)
|
||||
|
||||
after, _ := store.TeamMembershipService.TeamMembershipsByTeamID(2)
|
||||
|
||||
if reflect.DeepEqual(before, after) {
|
||||
t.Errorf("updateOAuthTeamMemberships should have created new team membership based on existing portainer teams matching oauth team")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("updates existing team membership based on claim val regex", func(t *testing.T) {
|
||||
store.Team().CreateTeam(&portainer.Team{ID: 1, Name: "testing"})
|
||||
|
||||
claimMappings := []portainer.OAuthClaimMappings{
|
||||
{ClaimValRegex: "@portainer", Team: 1},
|
||||
}
|
||||
user := portainer.User{ID: 1, Role: 2}
|
||||
oAuthTeams := []string{"@portainer"}
|
||||
|
||||
got, _ := store.TeamMembershipService.TeamMembershipsByTeamID(1)
|
||||
want := []portainer.TeamMembership{{ID: 1, UserID: 1, TeamID: 1, Role: 1}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("updateOAuthTeamMemberships should have initial role of 1, got=%v, want=%v", got, want)
|
||||
}
|
||||
|
||||
updateOAuthTeamMemberships(store, claimMappings, user, oAuthTeams)
|
||||
|
||||
got, _ = store.TeamMembershipService.TeamMembershipsByTeamID(1)
|
||||
want = []portainer.TeamMembership{{ID: 1, UserID: 1, TeamID: 1, Role: 2}}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("updateOAuthTeamMemberships should have updated existing team membership role, got=%v, want=%v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("removes an outdated oauth team membership", func(t *testing.T) {
|
||||
store.TeamMembershipService.CreateTeamMembership(&portainer.TeamMembership{
|
||||
ID: 1, UserID: 1, TeamID: 1, Role: 1,
|
||||
})
|
||||
|
||||
claimMappings := []portainer.OAuthClaimMappings{}
|
||||
user := portainer.User{ID: 1, Role: 1}
|
||||
oAuthTeams := []string{}
|
||||
|
||||
got, _ := store.TeamMembershipService.TeamMembershipsByTeamID(1)
|
||||
if len(got) == 0 {
|
||||
t.Errorf("updateOAuthTeamMemberships should have initial team membership")
|
||||
}
|
||||
|
||||
updateOAuthTeamMemberships(store, claimMappings, user, oAuthTeams)
|
||||
|
||||
got, _ = store.TeamMembershipService.TeamMembershipsByTeamID(1)
|
||||
if len(got) > 0 {
|
||||
t.Errorf("updateOAuthTeamMemberships should have removed existing, non-mapped team membership")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
operations "github.com/portainer/portainer/api/backup"
|
||||
)
|
||||
|
||||
@@ -15,12 +17,30 @@ type (
|
||||
backupPayload struct {
|
||||
Password string
|
||||
}
|
||||
s3BackupPayload struct {
|
||||
portainer.S3BackupSettings
|
||||
}
|
||||
)
|
||||
|
||||
func (p *backupPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (payload *s3BackupPayload) Validate(r *http.Request) error {
|
||||
switch {
|
||||
case payload.AccessKeyID == "":
|
||||
return errors.New("missing AccessKeyID")
|
||||
case payload.SecretAccessKey == "":
|
||||
return errors.New("missing SecretAccessKey")
|
||||
case payload.Region == "":
|
||||
return errors.New("missing Region")
|
||||
case payload.BucketName == "":
|
||||
return errors.New("missing BucketName")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// @id Backup
|
||||
// @summary Creates an archive with a system data snapshot that could be used to restore the system.
|
||||
// @description Creates an archive with a system data snapshot that could be used to restore the system.
|
||||
@@ -51,3 +71,26 @@ func (h *Handler) backup(w http.ResponseWriter, r *http.Request) *httperror.Hand
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id BackupToS3
|
||||
// @summary Execute backup to AWS S3 Bucket
|
||||
// @description Creates an archive with a system data snapshot and upload it to the target S3 bucket
|
||||
// @description **Access policy**: admin
|
||||
// @tags backup
|
||||
// @security jwt
|
||||
// @param body body s3BackupPayload true "S3 backup settings"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /backup/s3/execute [post]
|
||||
func (h *Handler) backupToS3(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload s3BackupPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
if err := operations.BackupToS3(payload.S3BackupSettings, h.gate, h.dataStore, h.filestorePath); err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to execute S3 backup", Err: err}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
26
api/http/handler/backup/backup_settings_fetch.go
Normal file
26
api/http/handler/backup/backup_settings_fetch.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
// @id BackupSettingsFetch
|
||||
// @summary Fetch s3 backup settings/configurations
|
||||
// @description **Access policy**: admin
|
||||
// @tags backup
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {object} portainer.S3BackupSettings "Success"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 500 "Server error"
|
||||
// @router /backup/s3/settings [get]
|
||||
func (h *Handler) backupSettingsFetch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
settings, err := h.dataStore.S3Backup().GetSettings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve backup settings from the database", Err: err}
|
||||
}
|
||||
return response.JSON(w, settings)
|
||||
}
|
||||
30
api/http/handler/backup/backup_status_fetch.go
Normal file
30
api/http/handler/backup/backup_status_fetch.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
type backupStatus struct {
|
||||
Failed bool
|
||||
TimestampUTC string
|
||||
}
|
||||
|
||||
// @id BackupStatusFetch
|
||||
// @summary Fetch the status of the last scheduled backup run
|
||||
// @description **Access policy**: public
|
||||
// @tags backup
|
||||
// @produce json
|
||||
// @success 200 {object} backupStatus "Success"
|
||||
// @failure 500 "Server error"
|
||||
// @router /backup/s3/status [get]
|
||||
func (h *Handler) backupStatusFetch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
status, err := h.dataStore.S3Backup().GetStatus()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve last backup run status from the database", Err: err}
|
||||
}
|
||||
return response.JSON(w, backupStatus{Failed: status.Failed, TimestampUTC: status.Timestamp.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/portainer/portainer/api/adminmonitor"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
@@ -49,13 +48,13 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T)
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
adminMonitor := adminmonitor.New(time.Hour, nil, context.Background())
|
||||
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", nil, func() {}, adminMonitor).backup(w, r)
|
||||
assert.Nil(t, handlerErr, "Handler should not fail")
|
||||
|
||||
response := w.Result()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
archivePath := filepath.Join(tmpdir, "archive.tar.gz")
|
||||
@@ -86,13 +85,13 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
adminMonitor := adminmonitor.New(time.Hour, nil, nil)
|
||||
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
|
||||
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", nil, func() {}, adminMonitor).backup(w, r)
|
||||
assert.Nil(t, handlerErr, "Handler should not fail")
|
||||
|
||||
response := w.Result()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
dr, err := crypto.AesDecrypt(bytes.NewReader(body), []byte("secret"))
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/adminmonitor"
|
||||
operations "github.com/portainer/portainer/api/backup"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
// Handler is an http handler responsible for backup and restore portainer state
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
backupScheduler *operations.BackupScheduler
|
||||
bouncer *security.RequestBouncer
|
||||
dataStore portainer.DataStore
|
||||
gate *offlinegate.OfflineGate
|
||||
@@ -24,10 +26,11 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// NewHandler creates an new instance of backup handler
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore, gate *offlinegate.OfflineGate, filestorePath string, shutdownTrigger context.CancelFunc, adminMonitor *adminmonitor.Monitor) *Handler {
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore, gate *offlinegate.OfflineGate, filestorePath string, backupScheduler *operations.BackupScheduler, shutdownTrigger context.CancelFunc, adminMonitor *adminmonitor.Monitor) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
bouncer: bouncer,
|
||||
backupScheduler: backupScheduler,
|
||||
dataStore: dataStore,
|
||||
gate: gate,
|
||||
filestorePath: filestorePath,
|
||||
@@ -35,6 +38,11 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore,
|
||||
adminMonitor: adminMonitor,
|
||||
}
|
||||
|
||||
h.Handle("/backup/s3/settings", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backupSettingsFetch)))).Methods(http.MethodGet)
|
||||
h.Handle("/backup/s3/settings", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.updateSettings)))).Methods(http.MethodPost)
|
||||
h.Handle("/backup/s3/status", bouncer.PublicAccess(httperror.LoggerHandler(h.backupStatusFetch))).Methods(http.MethodGet)
|
||||
h.Handle("/backup/s3/execute", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backupToS3)))).Methods(http.MethodPost)
|
||||
h.Handle("/backup/s3/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restoreFromS3))).Methods(http.MethodPost)
|
||||
h.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost)
|
||||
h.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost)
|
||||
|
||||
@@ -55,11 +63,3 @@ func adminAccess(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func systemWasInitialized(dataStore portainer.DataStore) (bool, error) {
|
||||
users, err := dataStore.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(users) > 0, nil
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type restorePayload struct {
|
||||
// @param FileContent body []byte true "Content of the backup"
|
||||
// @param FileName body string true "File name"
|
||||
// @param Password body string false "Password to decrypt the backup with"
|
||||
// @success 200 "Success"
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /restore [post]
|
||||
|
||||
116
api/http/handler/backup/restore_from_s3.go
Normal file
116
api/http/handler/backup/restore_from_s3.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
operations "github.com/portainer/portainer/api/backup"
|
||||
s3client "github.com/portainer/portainer/api/s3"
|
||||
)
|
||||
|
||||
type restoreS3Settings struct {
|
||||
portainer.S3Location
|
||||
Password string
|
||||
}
|
||||
|
||||
func (p *restoreS3Settings) Validate(r *http.Request) error {
|
||||
if p.AccessKeyID == "" {
|
||||
return errors.New("missing AccessKeyID field")
|
||||
}
|
||||
if p.SecretAccessKey == "" {
|
||||
return errors.New("missing SecretAccessKe field")
|
||||
}
|
||||
if p.Region == "" {
|
||||
return errors.New("missing Region field")
|
||||
}
|
||||
if p.BucketName == "" {
|
||||
return errors.New("missing BucketName field")
|
||||
}
|
||||
if p.Filename == "" {
|
||||
return errors.New("missing Filename field")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id RestoreFromS3
|
||||
// @summary Triggers a system restore using details of s3 backup
|
||||
// @description Triggers a system restore using details of s3 backup
|
||||
// @description **Access policy**: public
|
||||
// @tags backup
|
||||
// @param AccessKeyID body string false "AWS access key id"
|
||||
// @param SecretAccessKey body string false "AWS secret access key"
|
||||
// @param Region body string false "AWS S3 region"
|
||||
// @param BucketName body string false "AWS S3 bucket name"
|
||||
// @param Filename body string false "AWS S3 filename in the bucket"
|
||||
// @param Password body string false "Password to decrypt the backup with"
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /backup/s3/restore [post]
|
||||
func (h *Handler) restoreFromS3(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
initialized, err := h.adminMonitor.WasInitialized()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to check system initialization", Err: err}
|
||||
}
|
||||
if initialized {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Cannot restore already initialized instance", Err: fmt.Errorf("system already initialized")}
|
||||
}
|
||||
|
||||
h.adminMonitor.Stop()
|
||||
defer h.adminMonitor.Start()
|
||||
|
||||
var payload restoreS3Settings
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
backupFile, err := createTmpBackupLocation(h.filestorePath)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to restore", Err: err}
|
||||
}
|
||||
defer func() {
|
||||
backupFile.Close()
|
||||
os.RemoveAll(filepath.Dir(backupFile.Name()))
|
||||
}()
|
||||
|
||||
s3session, err := s3client.NewSession(payload.Region, payload.AccessKeyID, payload.SecretAccessKey)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] %s \n", err)
|
||||
}
|
||||
if err = s3client.Download(s3session, backupFile, payload.S3Location); err != nil {
|
||||
log.Printf("[ERROR] %s \n", errors.Wrap(err, "failed downloading file from S3"))
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to download file from S3", Err: err}
|
||||
}
|
||||
|
||||
if err = operations.RestoreArchive(backupFile, payload.Password, h.filestorePath, h.gate, h.dataStore, h.shutdownTrigger); err != nil {
|
||||
log.Printf("[ERROR] %s", errors.Wrap(err, "faild to restore system from backup"))
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to restore backup", Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createTmpBackupLocation(filestorePath string) (*os.File, error) {
|
||||
restoreDir, err := ioutil.TempDir(filestorePath, fmt.Sprintf("restore_%s", time.Now().Format("2006-01-02_15-04-05")))
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to create tmp download dir")
|
||||
}
|
||||
|
||||
f, err := os.Create(filepath.Join(restoreDir, "backup_file"))
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to create tmp download file")
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) {
|
||||
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}), i.WithEdgeJobs([]portainer.EdgeJob{}))
|
||||
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
|
||||
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", nil, func() {}, adminMonitor)
|
||||
|
||||
//backup
|
||||
archive := backup(t, h, test.backupPassword)
|
||||
@@ -74,7 +74,7 @@ func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) {
|
||||
datastore := i.NewDatastore(i.WithUsers([]portainer.User{admin}), i.WithEdgeJobs([]portainer.EdgeJob{}))
|
||||
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
|
||||
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
|
||||
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", nil, func() {}, adminMonitor)
|
||||
|
||||
//backup
|
||||
archive := backup(t, h, "password")
|
||||
|
||||
69
api/http/handler/backup/update_s3_settings.go
Normal file
69
api/http/handler/backup/update_s3_settings.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type backupSettings struct {
|
||||
portainer.S3BackupSettings
|
||||
}
|
||||
|
||||
func (p *backupSettings) Validate(r *http.Request) error {
|
||||
if p.CronRule == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := cron.ParseStandard(p.CronRule); err != nil {
|
||||
return errors.New("invalid cron rule")
|
||||
}
|
||||
if p.AccessKeyID == "" {
|
||||
return errors.New("missing AccessKeyID")
|
||||
}
|
||||
if p.SecretAccessKey == "" {
|
||||
return errors.New("missing SecretAccessKey")
|
||||
}
|
||||
if p.Region == "" {
|
||||
return errors.New("missing Region")
|
||||
}
|
||||
if p.BucketName == "" {
|
||||
return errors.New("missing BucketName")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id UpdateS3Settings
|
||||
// @summary Updates stored s3 backup settings and updates running cron jobs as needed
|
||||
// @description Updates stored s3 backup settings and updates running cron jobs as needed
|
||||
// @description **Access policy**: admin
|
||||
// @tags backup
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param CronRule body string false "Crontab rule to make periodical backups"
|
||||
// @param AccessKeyID body string false "AWS access key id"
|
||||
// @param SecretAccessKey body string false "AWS secret access key"
|
||||
// @param Region body string false "AWS S3 region"
|
||||
// @param BucketName body string false "AWS S3 bucket name"
|
||||
// @param Password body string false "Password to encrypt the backup with"
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /backup/s3/settings [post]
|
||||
func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload backupSettings
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
if err := h.backupScheduler.Update(payload.S3BackupSettings); err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Couldn't update backup settings", Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
50
api/http/handler/backup/update_s3_settings_test.go
Normal file
50
api/http/handler/backup/update_s3_settings_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_ValidateCronRules(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rule string
|
||||
isErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty cron rule",
|
||||
rule: "",
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "incorrect cron rule",
|
||||
rule: "* wrong *",
|
||||
isErr: true,
|
||||
},
|
||||
{
|
||||
name: "standart cron rule",
|
||||
rule: "* * * * 1",
|
||||
isErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
emtpyRequest := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
s := &backupSettings{
|
||||
S3BackupSettings: portainer.S3BackupSettings{
|
||||
CronRule: test.rule,
|
||||
},
|
||||
}
|
||||
|
||||
err := s.Validate(emtpyRequest)
|
||||
assert.Equal(t, err != nil, test.isErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/http/useractivity"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
@@ -44,7 +45,7 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user details from authentication token", err}
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "unable to retrieve user details from authentication token", err}
|
||||
}
|
||||
|
||||
customTemplate, err := handler.createCustomTemplate(method, r)
|
||||
@@ -157,6 +158,8 @@ func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*p
|
||||
}
|
||||
customTemplate.ProjectPath = projectPath
|
||||
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload)
|
||||
|
||||
return customTemplate, nil
|
||||
}
|
||||
|
||||
@@ -250,6 +253,8 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload)
|
||||
|
||||
return customTemplate, nil
|
||||
}
|
||||
|
||||
@@ -329,5 +334,7 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
|
||||
}
|
||||
customTemplate.ProjectPath = projectPath
|
||||
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload)
|
||||
|
||||
return customTemplate, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/http/useractivity"
|
||||
)
|
||||
|
||||
// @id CustomTemplateDelete
|
||||
@@ -71,6 +72,8 @@ func (handler *Handler) customTemplateDelete(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, nil)
|
||||
|
||||
return response.Empty(w)
|
||||
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/http/useractivity"
|
||||
)
|
||||
|
||||
type customTemplateUpdatePayload struct {
|
||||
@@ -127,5 +128,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist custom template changes inside the database", err}
|
||||
}
|
||||
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, handlerActivityContext, r, payload)
|
||||
|
||||
return response.JSON(w, customTemplate)
|
||||
}
|
||||
|
||||
@@ -5,17 +5,22 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
const (
|
||||
handlerActivityContext = "Portainer"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle endpoint group operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore portainer.DataStore
|
||||
FileService portainer.FileService
|
||||
GitService portainer.GitService
|
||||
DataStore portainer.DataStore
|
||||
FileService portainer.FileService
|
||||
GitService portainer.GitService
|
||||
UserActivityStore portainer.UserActivityStore
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage endpoint group operations.
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/useractivity"
|
||||
consts "github.com/portainer/portainer/api/useractivity"
|
||||
)
|
||||
|
||||
type dockerhubUpdatePayload struct {
|
||||
@@ -64,5 +66,8 @@ func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err}
|
||||
}
|
||||
|
||||
payload.Password = consts.RedactedValue
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ func hideFields(dockerHub *portainer.DockerHub) {
|
||||
// Handler is the HTTP handler used to handle DockerHub operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore portainer.DataStore
|
||||
DataStore portainer.DataStore
|
||||
UserActivityStore portainer.UserActivityStore
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage Dockerhub operations.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/useractivity"
|
||||
)
|
||||
|
||||
type edgeGroupCreatePayload struct {
|
||||
@@ -92,5 +93,7 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Edge group inside the database", err}
|
||||
}
|
||||
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, payload)
|
||||
|
||||
return response.JSON(w, edgeGroup)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/useractivity"
|
||||
)
|
||||
|
||||
// @id EdgeGroupDelete
|
||||
@@ -54,6 +55,8 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge group from the database", err}
|
||||
}
|
||||
|
||||
useractivity.LogHttpActivity(handler.UserActivityStore, "Portainer", r, nil)
|
||||
|
||||
return response.Empty(w)
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user