Compare commits

...

138 Commits

Author SHA1 Message Date
Anthony Lapenna
a649f43c52 chore(build-system): update dev-toolkit (#4887)
* chore(build-system): update dev-toolkit

* chore(build-system): update dev-toolkit

* chore(build-system): update dev-toolkit Dockerfile

* chore(build-system): update gruntfile

* chore(build-system): gruntfile update

* chore(build-system): better support for private git repositories

* Update toolkit.Dockerfile

* merge develop into toolkit-update

* merge develop into toolkit-update
2021-08-28 19:32:57 +12:00
Richard Wei
0ac9d15667 fix kubernetes cluster submenu has no entries (#5502) 2021-08-27 08:19:12 +12:00
Chaim Lev-Ari
1830a80a61 feat(k8s/resource-pool): add the ability to mark/unmark resource pool as system (#5360)
* feat(k8s/resource-pool): add the ability to mark/unmark resource pool as system

fix(kube/ns): check label to see if namespace is system

refactor(k8s/namespaces): rename variables

feat(kubernetes): toggle system state in the server (#5361)

fix(app/resource-pool): UI fixes

feat(app/resource-pool): add confirmation modal when unamrking system namespace

* refactor(app): review changes

* feat(app/namespaces): introduce store to retrieve namespace system status without changing all the kubernetes models

refactor(app/namespaces): remove unused code first introduced for system tagging

fix(app/namespaces): cache namespaces to retrieve system status regardless of namespace reference format

refactor(app): migrate namespace store from helper to a separate singleton

refactor(app): remove KubernetesNamespaceHelper from DI cycle

* refactor(app): normalize usage of KubernetesNamespaceHelper functions

* refactor(app/k8s): change namespace store to functions instead of class

Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2021-08-26 16:00:59 +02:00
Chaim Lev-Ari
5ab98f41f1 fix(endpoints): add more wiggle room for checkin interval (#5456) 2021-08-26 07:28:39 +03:00
testA113
7c02e4b725 Xt 485/give front end elements data cy attributes (#5483)
* kubernetes attributes done, swarm attributes halfway, aci to go

* all attributes for cypress selectors added

* kubernetes attributes done, swarm attributes halfway, aci to go

* all attributes for cypress selectors added

* all attributes for cypress selectors added

* fixed files from rebase, added docker sidebar element attributes

* kubernetes attributes done, swarm attributes halfway, aci to go

* all attributes for cypress selectors added

* all attributes for cypress selectors added

* removed files to match develop

* ammended comments

* removed bindings for switch
2021-08-26 12:05:28 +12:00
cong meng
d6e291db15 fix(kubectl): EE-1342 non-admin users cannot connect to the local kube cluster using kubectl shell (#5475)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-08-26 11:31:22 +12:00
Chaim Lev-Ari
ab30793c48 chore(deps): upgrade eslint and use eslint-plugin (#4989) 2021-08-24 07:34:18 +03:00
Chaim Lev-Ari
5fd92d8a3f feat(kubernetes): fetch config file with endpoint name (#5368) [EE-1159] 2021-08-23 09:24:00 +03:00
Richard Wei
0ff9d49c6f fix kubectl terminal not showing bottom line in some browser (#5444) 2021-08-23 14:23:07 +12:00
itsconquest
80465367a5 fix(stacks): Remove unused functions in create stack controller [EE-1139] (#5401) 2021-08-23 12:05:57 +12:00
zees-dev
db1f182670 removed kubeconfig tls check (#5443) 2021-08-23 10:53:08 +12:00
Chaim Lev-Ari
dcb85ad8fe fix(app/editor): set value from outside only if needed (#5445) 2021-08-22 12:25:31 +03:00
Chaim Lev-Ari
bbbc61dca9 feat(sidebar): add indicator for an openable submenu (#5398) [EE-538] 2021-08-22 12:23:49 +03:00
LP B
d2d885359f feat(app/registries): add indicator about registries accesses relocation (#5374) 2021-08-20 16:47:22 +02:00
cong meng
5fe7526de7 feat(dockerhub): EE-1384 new endpoint prefix for proxying requests to agent (#5428)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-08-20 15:04:28 +12:00
fhanportainer
3b5e15aa42 fix(stack): show success notification when redeploy succeeds (#5441)
* fix(stack): show success notification when redeploy succeeds

* Update app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js

Co-authored-by: itsconquest <william.conquest@portainer.io>
2021-08-20 12:41:50 +12:00
Chaim Lev-Ari
141ee11799 refactor(k8s/deploy): use components (#5417) [EE-141 2021-08-18 14:56:13 +03:00
Chaim Lev-Ari
91653f9c36 refactor(stacks): move custom templates selector to component (#5418)
* feat(app): introduce web-editor form component

* refactor(stacks): move custom templates selector to component

* fix(stacks): validate form for template
2021-08-18 14:40:38 +03:00
cong meng
6b37235eb4 feat(edge) EE-947 provide a way to re-associate an Edge endpoint with a new Edge key (#5413)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-08-18 08:25:34 +12:00
LP B
f763dcb386 refactor(k8s/namespaces): rename Storages sections to Storage (#5375) 2021-08-17 15:20:04 +02:00
Dmitry Salakhov
bcccdfb669 feat(stacks): support automated sync for stacks [EE-248] (#5340) 2021-08-17 13:12:07 +12:00
zees-dev
5fe90db36a fix(metrics): disable metric server api calls if metric server is disabled on k8s endpoint EE-1273 EE-1274 (#5377)
* - metric server api call disabled on cluster view
- metric server api call disabled on node view
- metric server api call disabled on namespace view

* enforcing resource access to function to ensure similarity to ee implementation
2021-08-13 16:46:18 +12:00
Neil Cresswell
7b6a31181e Update README.md 2021-08-13 11:11:54 +12:00
Neil Cresswell
3ae267633e Update README.md 2021-08-13 11:11:14 +12:00
Matt Hook
6ed1856049 fix(git): proxy git requests 2021-08-12 14:37:48 +03:00
Chaim Lev-Ari
f990617a7e fix(docker): pass endpoint to registry field (#5365) 2021-08-12 14:28:25 +03:00
Chaim Lev-Ari
456995353b feat(backup): reload if restore fails (#5404) 2021-08-12 11:10:40 +12:00
itsconquest
8d01b45445 fix(api): increment api version to latest (#5414) 2021-08-12 10:35:27 +12:00
LP B
0954239e19 feat(app/configure): reword metrics features enabling switch and information (#5397) 2021-08-11 15:03:10 +02:00
Chaim Lev-Ari
9be0b89aff feat(analytics): add apis for event tracking (#5298)
* feat(analytics): add apis for event tracking

feat(api): fetch instanceID

feat(state): set instance id and version on matomo

refactor(state): export validation of app state

feat(analytics): update dimensions

refactor(analytics): move matomo to module

feat(analytics): disable analytics on non production

feat(analytics): track event metadata

refactor(analytics): clean push function

refactor(analytics): rename init function

feat(analytics): track user role

feat(analytics): track user global role

fix(stacks): remove event tracking for stack create

* style(analytics): remove TODO

* feat(build): add testing env
2021-08-11 10:45:53 +12:00
Chaim Lev-Ari
11d555bbd6 feat(server): use https by default (#5315) [EE-332] 2021-08-10 07:59:47 +03:00
Richard Wei
3257cb1e28 fix(app):fix additional not save warning EE-799 (#5161)
* fix(app):fix additional not save warning EE-799

* fix additional warning when user leave page

* fix additional warning when user leave page in buildImageController.js

* fix docker build controller additional warning message

* fix changes required from reviews

* - refactored ondestroy hook function to align it closer to (below) oninit
- removed duplicated hook func duplication in configurationController

Co-authored-by: zees-dev <dev.786zshan@gmail.com>
2021-08-10 16:44:33 +12:00
Chaim Lev-Ari
75baf14b38 chore(github): add label conflicts workflow (#5225)
* chore(github): add label conflicts workflow

[DTD-66]

* chore(github): update label on push to release branch

* chore(github): rename branch

* chore(github): remove test branch
2021-08-10 16:15:29 +12:00
cong meng
9af291b67d feat(edge) EE-743 enable signature checking for edge agent (#5355)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-08-09 17:22:41 +12:00
Chaim Lev-Ari
31fe65eade feat(registries): add proget notice (#5345) 2021-08-08 18:01:14 +03:00
Matt Hook
cb3968b92f Fix parsing of content-type field (#5356) 2021-08-06 16:39:26 +12:00
Neil Cresswell
f603cd34be Update README.md 2021-08-06 10:58:21 +12:00
Hui
56f569efe1 fix(oauth): remove expiry time copy logic EE-1085 2021-08-06 00:54:38 +12:00
zees-dev
665bf2c887 feat(kubernetes/shell): kubectl web shell and kubeconfig functionality EE-448 (#5229)
* feat(kubernetes/shell): backport kubectl shell backend functionality EE-849 (#5168)

* backported core backend kubectl shell functionality

* - backported kubectl shell unit tests
- backported k8s cli interface update
- backported k8s client library fake patch

* refactored backend to match EE

* fixed test error typo

* GetServiceAccountName -> GetServiceAccount - making the function reusable in multiple contexts

* feat(kubernetes/shell): backport kubeconfig generation backend functionality EE-1004 (#5213)

* backported core backend kubectl shell functionality

* refactored backend to match EE

* - backported kubernetes backend handler implementation
- backported kubernetes config endpoint
- backported kubeconfig file generation
- backported kubeconfig and yaml unit tests
- backported updates to kubeclient interfaces

* feat(app): kubectl shell ui backport EE-927 (#5221)

* Kubectl UI backport to CE

* fix authentication redirect issue

* comment out redirect function

* fix shell full width & change name of shell

* disable button when terminal connected

* fixed whitespace changes for css

* fixed whitespace changes for html

* linting fixes

Co-authored-by: zees-dev <dev.786zshan@gmail.com>

* feat(kubernetes/shell): backport of kubeconfig export functionality EE-926 (#5228)

* EE backport of kubeconfig UI functionality

* using angularjs constant instead of hardcoded URL

* updated portainer kubectl shell image

* fix kubectl button position issue in ce

* fix pod keep running when switching page

* feat(app): Kubectl shell ui EE-833 EE-1099 (#5271)

* fix kubectl shell css

* fix mini css issue

* fix tech issue for ui changes from review

* delete unuse file

* - refactored variable names
- restored content-wrapper scroll
- created object to store wrapper css

Co-authored-by: zees-dev <dev.786zshan@gmail.com>

* addressing PR issues

* fix required changes from tech reviews (#5319)

* fix required changes from tech reviews

* remove unuse css variable

* component refactor accoridng to PR and style guidelines

Co-authored-by: zees-dev <dev.786zshan@gmail.com>

* removed redundant dockerhub api endpoint variable

* - autoHeight -> terminal-window
- removed redundant try-catch
- saving config.yaml file as config

* fix(kube/shell): show error on failure

* fixed default https bug

* resolved merge conflicts

Co-authored-by: Richard Wei <54336863+WaysonWei@users.noreply.github.com>
Co-authored-by: richard <richard@richards-iMac-Pro.local>
Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
2021-08-05 15:02:06 +12:00
Yi Chen
ec71720ceb Update github banner image (#5388) 2021-08-04 15:01:49 +12:00
Yi Chen
f1e2bb14a9 * update readme as needed (#5387) 2021-08-04 14:50:50 +12:00
dbuduev
ed2c65c1e6 feat(logger): Init logrus [DTD-55] (#5232) 2021-08-04 11:26:22 +12:00
cong meng
51ef2c2aa9 fix(advance deploy): EE-1141 A standard user can escalate to cluster administrator privileges on Kubernetes (#5325)
* fix(advance deploy): EE-1141 A standard user can escalate to cluster administrator privileges on Kubernetes

* fix(advance deploy): EE-1141 reuse existing token cache when do deployment

* fix: EE-1141 use user's SA token to exec pod command

* fix: EE-1141 stop advanced-deploy or pod-exec if user's SA token is empty

* fix: EE-1141 resolve merge conflicts

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-08-04 11:11:24 +12:00
cong meng
5652bac004 feat: EE-424 Provide a way to re-associate an Edge endpoint to a new Edge agent (#5266)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-08-02 18:08:40 +12:00
zees-dev
ce31de5e9e feat(kubernetes/resource-usage): k8s resource usage for cluster, node and namespace EE-3 EE-1112 (#5301)
* backported resource usage functionality from EE

* utilising view bound endpoint object instead of depracated EndpointProvider

* refactor flatmap

* addressed merge conflict issues
2021-07-28 14:26:03 +12:00
Matt Hook
cee7ac26e9 Fix dockerhub pro account rate-limit issue (#5352) 2021-07-27 10:49:28 +12:00
Chaim Lev-Ari
c943ac498f feat(stacks): allow standalone to edit env vars (#5255)
Co-authored-by: Tobias Holler <mail@toubs.de>
2021-07-26 13:48:30 +03:00
Richard Wei
49f25e9c4c fix(docker):Fix image pulled errorDetails not showing EE-936 (#5336)
* fix image pulled errorDetails not showing

* code clean up for errorDetail detection
2021-07-24 08:51:34 +12:00
dbuduev
7d6b1edd48 feat(k8s): Introduce the ability to restrict access to default namespace (EE-745) (#5337) 2021-07-23 17:10:46 +12:00
Richard Wei
c26af1449c fix(app): Fix ports displayed twice when using docker EE-706 (#5239)
* fix duplicate port showing using docker

* fix changes from review by using lodash for filter

* move container filter in filter folder

* change filter name to unique for reuse
2021-07-23 11:29:01 +12:00
Richard Wei
09c5bada3e fix(app): fix create stack with capital letters or space issue EE-908 (#5236)
* fix(app): fix create stack with capital letters or space issue

* replace ComposeWrapper with ComposeStackManager
2021-07-23 09:53:42 +12:00
Chaim Lev-Ari
fe07815fc7 fix(images): ensure latest image (#5274) 2021-07-22 12:19:48 +03:00
Richard Wei
c56c236e3a fix(stack): show warning if endpoint is selected (#5234)
* fix/EE-916/Invalid warning in stack details

* fix typo for isEndpointSelected function

* check yarmlError is valid

* combine yamlError and isEndpointSelected into one linie
2021-07-22 16:21:25 +12:00
Hui
68453482af fix(swagger): add swagger annotation for pull and redeploy stack 2021-07-22 11:40:53 +12:00
Chaim Lev-Ari
7b2269fbba feat(endpoints): filter endpoints by a list of types (#5308)
* feat(endpoints): filter endpoints by a list of types

* docs(endpoints): update api docs for endpoint list
2021-07-21 10:16:22 +03:00
Chaim Lev-Ari
bd47bb8cdc chore(lint): add lint command (#5106) 2021-07-21 17:45:35 +12:00
Chaim Lev-Ari
f9ffb1a712 refactor(stacks): use docker-compose-wrapper library (#4979) 2021-07-21 13:56:28 +12:00
Chaim Lev-Ari
592f7024e1 fix(stacks): prevent stack creation when container_name already exists (#5211) 2021-07-21 13:55:06 +12:00
Richard Wei
00fc629c1c fix charts x label padding (#5327) 2021-07-21 13:54:22 +12:00
Chaim Lev-Ari
6a9b386df8 fix(kube/nodes): show node events (#5246) 2021-07-20 16:49:33 +03:00
Dmitry Salakhov
8aa3bfc59c fix(namespace): update portainer-config when delete a namespace (#5330) 2021-07-20 14:05:31 +12:00
fhanportainer
308f828446 fix(k8s): fixed generating kube auction summary issue (#5331) 2021-07-19 19:45:20 +12:00
Chaim Lev-Ari
89756b2e01 fix(kube/app): show resource allocation (#5317) 2021-07-19 10:44:48 +03:00
Chaim Lev-Ari
db16299aab feat(docker/volumes): change how volume resource id is calculated (#5067)
[EE-494]
2021-07-19 10:43:49 +03:00
Chaim Lev-Ari
72117693fb feat(stacks): update stopped stack (#5215)
[EE-554]
2021-07-19 10:41:50 +03:00
LP B
179df06267 feat(app): rework private registries and support private registries in kubernetes EE-30 (#5131)
* feat(app): rework private registries and support private registries in kubernetes

[EE-30]

feat(api): backport private registries backend changes (#5072)

* feat(api/bolt): backport bolt changes

* feat(api/exec): backport exec changes

* feat(api/http): backport http/handler/dockerhub changes

* feat(api/http): backport http/handler/endpoints changes

* feat(api/http): backport http/handler/registries changes

* feat(api/http): backport http/handler/stacks changes

* feat(api/http): backport http/handler changes

* feat(api/http): backport http/proxy/factory/azure changes

* feat(api/http): backport http/proxy/factory/docker changes

* feat(api/http): backport http/proxy/factory/utils changes

* feat(api/http): backport http/proxy/factory/kubernetes changes

* feat(api/http): backport http/proxy/factory changes

* feat(api/http): backport http/security changes

* feat(api/http): backport http changes

* feat(api/internal): backport internal changes

* feat(api): backport api changes

* feat(api/kubernetes): backport kubernetes changes

* fix(api/http): changes on backend following backport

feat(app): backport private registries frontend changes (#5056)

* feat(app/docker): backport docker/components changes

* feat(app/docker): backport docker/helpers changes

* feat(app/docker): backport docker/views/container changes

* feat(app/docker): backport docker/views/images changes

* feat(app/docker): backport docker/views/registries changes

* feat(app/docker): backport docker/views/services changes

* feat(app/docker): backport docker changes

* feat(app/kubernetes): backport kubernetes/components changes

* feat(app/kubernetes): backport kubernetes/converters changes

* feat(app/kubernetes): backport kubernetes/models changes

* feat(app/kubernetes): backport kubernetes/registries changes

* feat(app/kubernetes): backport kubernetes/services changes

* feat(app/kubernetes): backport kubernetes/views/applications changes

* feat(app/kubernetes): backport kubernetes/views/configurations changes

* feat(app/kubernetes): backport kubernetes/views/configure changes

* feat(app/kubernetes): backport kubernetes/views/resource-pools changes

* feat(app/kubernetes): backport kubernetes/views changes

* feat(app/portainer): backport portainer/components/accessManagement changes

* feat(app/portainer): backport portainer/components/datatables changes

* feat(app/portainer): backport portainer/components/forms changes

* feat(app/portainer): backport portainer/components/registry-details changes

* feat(app/portainer): backport portainer/models changes

* feat(app/portainer): backport portainer/rest changes

* feat(app/portainer): backport portainer/services changes

* feat(app/portainer): backport portainer/views changes

* feat(app/portainer): backport portainer changes

* feat(app): backport app changes

* config(project): gitignore + jsconfig changes

gitignore all files under api/cmd/portainer but main.go and enable Code Editor autocomplete on import ... from '@/...'

fix(app): fix pull rate limit checker

fix(app/registries): sidebar menus and registry accesses users filtering

fix(api): add missing kube client factory

fix(kube): fetch dockerhub pull limits (#5133)

fix(app): pre review fixes (#5142)

* fix(app/registries): remove checkbox for endpointRegistries view

* fix(endpoints): allow access to default namespace

* fix(docker): fetch pull limits

* fix(kube/ns): show selected registries for non admin

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>

chore(webpack): ignore missing sourcemaps

fix(registries): fetch registry config from url

feat(kube/registries): ignore not found when deleting secret

feat(db): move migration to db 31

fix(registries): fix bugs in PR EE-869 (#5169)

* fix(registries): hide role

* fix(endpoints): set empty access policy to edge endpoint

* fix(registry): remove double arguments

* fix(admin): ignore warning

* feat(kube/configurations): tag registry secrets (#5157)

* feat(kube/configurations): tag registry secrets

* feat(kube/secrets): show registry secrets for admins

* fix(registries): move dockerhub to beginning

* refactor(registries): use endpoint scoped registries

feat(registries): filter by namespace if supplied

feat(access-managment): filter users for registry (#5191)

* refactor(access-manage): move users selector to component

* feat(access-managment): filter users for registry

refactor(registries): sync code with CE (#5200)

* refactor(registry): add inspect handler under endpoints

* refactor(endpoint): sync endpoint_registries_list

* refactor(endpoints): sync registry_access

* fix(db): rename migration functions

* fix(registries): show accesses for admin

* fix(kube): set token on transport

* refactor(kube): move secret help to bottom

* fix(kuberentes): remove shouldLog parameter

* style(auth): add description of security.IsAdmin

* feat(security): allow admin access to registry

* feat(edge): connect to edge endpoint when creating client

* style(portainer): change deprecation version

* refactor(sidebar): hide manage

* refactor(containers): revert changes

* style(container): remove whitespace

* fix(endpoint): add handler to registy on endpointService

* refactor(image): use endpointService.registries

* fix(kueb/namespaces): rename resource pool to namespace

* fix(kube/namespace): move selected registries

* fix(api/registries): hide accesses on registry creation

Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>

refactor(api): remove code duplication after rebase

fix(app/registries): replace last registry api usage by endpoint registry api

fix(api/endpoints): update registry access policies on endpoint deletion (#5226)

[EE-1027]

fix(db): update db version

* fix(dockerhub): fetch rate limits

* fix(registry/tests): supply restricred context

* fix(registries): show proget registry only when selected

* fix(registry): create dockerhub registry

* feat(db): move migrations to db 32

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
2021-07-14 21:15:21 +12:00
Dmitry Salakhov
0f5407da40 feat(tech): bump golang to v1.16 EE-515 (#4993)
* bump golang to v1.16

* Update build/linux/toolkit.Dockerfile

Co-authored-by: dbuduev <dbuduev@gmail.com>
2021-07-14 13:10:42 +12:00
Chaim Lev-Ari
2fd95d87eb fix(volumes): fetch resource by docker name (#5216) 2021-07-13 18:09:58 +12:00
cong meng
33b428eb7f EE-1110 Ingress routes and their mapping to a application name are not deleted when the application is deleted (#5291)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-07-09 10:41:04 +12:00
Chaim Lev-Ari
c6b770d697 feat(edgestack): remove deploy message (#5279)
[EE-392]
2021-07-08 11:39:52 +12:00
fhanportainer
d48f6bd02c fix(ingress): fixed hostname field when having multiple ingresses EE-1072 (#5273) 2021-07-05 18:17:20 +12:00
Stéphane Busso
340805f880 fix download logs (#5243) 2021-07-05 11:10:10 +12:00
zees-dev
f6c5c552aa feat(oauth/team-memberships): oauth team memberships teaser EE-341 (#5088)
* EE oauth team memberships feature teaser

* bugfix: deleting a default team should reset default team id to 0

* error wrapping, refactor team deletion code
2021-07-02 18:20:10 +12:00
dbuduev
90a472c08b feat(registry): Add ProGet registry type EE-703 (#5196)
* intermediate commit

* feat(registry): backport ProGet registry to CE (#954)

* backport EE changes

* label updates and remove auth-toggle

Co-authored-by: Dennis Buduev <dennis.buduev@portainer.io>
2021-07-01 14:57:15 +12:00
Richard Wei
8b80eb1731 fix(app):Set resource assignment default to off EE-1043 (#5248)
test passed.
2021-06-30 19:15:28 +12:00
yi-portainer
d2404458ea Merge branch 'release/2.6' into develop 2021-06-25 00:02:42 +12:00
Chaim Lev-Ari
1ddf76dbda fix(git-form): show git form and clear auth values (#5224)
* fix(custom-templates): show git form

fix [EE-1025]

* fix(git-form): empty auth values when auth is off
2021-06-23 12:33:22 +12:00
Chaim Lev-Ari
6a39a5cf44 fix(git-form): show git form and clear auth values (#5224)
* fix(custom-templates): show git form

fix [EE-1025]

* fix(git-form): empty auth values when auth is off
2021-06-22 21:41:50 +12:00
cong meng
a13ad8927f fix(stack) ignore username and password when authentication is disabled EE-161 (#5222)
* fix(stack) ignore username and password when authentication is disabled EE-161

* fix(stack) ignore username and password when authentication is disabled for stack creation EE-161

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-22 19:59:05 +12:00
cong meng
8e3751d0b7 fix(stack) Unable to update and redeploy a stack created from a git repository if it has failed once EE-1012 (#5212)
testing passed
2021-06-22 12:58:54 +12:00
Dmitry Salakhov
89f53458c6 fix(stack): allow standard users use advanced deployment (#5205) 2021-06-21 09:53:48 +12:00
cong meng
5466e68f50 fix(ACI): At least one team or user should be specified when creating a restricted container in Azure ACI EE-578 (#5204)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-18 11:30:18 +12:00
Stéphane Busso
60ef6d0270 Bump version to 2.6.0 2021-06-17 16:55:11 +12:00
Hui
caa6c15032 feat(k8s): advanced deployment from Git repo EE-447 (#5166)
* feat(stack): UI updates in git repo deployment method for k8s EE-640. (#5097)

* feat(stack): UI updates in git repo deployment method for k8s EE-640.

* feat(stack): supports the combination of GIT + COMPOSE.

* feat(stack): rename variable

* feat(stack): add git repo deployment method for k8s EE-638

* cleanup

* update payload validation rules

* make repo ref optional in frond end

Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
2021-06-16 23:47:32 +02:00
cong meng
6b759438b8 fix(k8s) cleaning up namespace access policies when removing users orteams from endpoint or endpoint group EE-718 (#5184)
* fix(k8s) cleaning up namespace access policies when removing users or teams from endpoint or endpoint group EE-718

* fix(k8s) minor code cleanup EE-718

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-16 20:15:29 +12:00
Hui
2170ad49ef fix(DB): downgrade DB version from 31 to 30 EE-955 (#5193)
* downgrade DB version from 31 to 30

* rename unit test func

* refactor migration func for DB 30

* move test helper func

* use struct method
2021-06-16 19:58:30 +12:00
yi-portainer
6a88c2ae36 Merge branch 'release/2.5' into develop 2021-06-16 17:31:00 +12:00
Alice Groux
7f96220a09 feat(k8s/advanced-deployment): allow standard users to see and use advanced deployment feature EE-446 (#5050) 2021-06-16 17:28:44 +12:00
Dmitry Salakhov
0b93714de4 feat(stacks): redeploy git stack [EE-161] (#5139)
* feat(git): save git config when creating stack (#5048)

* feat(git): save git config when creating stack

* chore(fs): test fileExists

* fix(git): fix tests to use CloneRepository

* refactor(git): move options to new object

* feat(stacks): redeploy git stack api (#5112)

* feat(stacks): redeploy git stacks form

[EE-666]

* feat(stack): show loading after confirmation

* fix(stacks): show same size description

* fix(stacks): reload state when deployed

* feat(stacks): set stopped stacks status to activate when updating

* feat(stacks): backup stack folder before cloning

* feat(stacks): don't accept prune and env on update git

Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
2021-06-16 09:11:35 +12:00
cong meng
296ecc5960 fix(k8s) Adding a Kube app does not allow Global to be set after removing persisted folder EE-563 (#5143)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-15 15:53:31 +12:00
Chaim Lev-Ari
d7bc4f9b96 fix(stacks): show missing status stacks (#5047)
Co-authored-by: dbuduev <dbuduev@gmail.com>
2021-06-14 14:40:00 +02:00
itsconquest
a5e8cf62d2 feat(UX): introduce new env variables UI (#4175)
* feat(app): introduce new env vars ui

feat(app): introduce new env vars ui

feat(UX): WIP new env variables UI

feat(UX): update button and placeholder

feat(UX): mention .env file in message

feat(UX): allow add/remove value & load correctly

feat(UX): restrict filesize to 1MB

feat(UX): vertical align error message

feat(UX): fill UI from file & when switching modes

feat(UX): strip un-needed newline character

feat(UX): introduce component to other views

feat(UX): fix title alignment

feat(UX): only populate editor on mode switch when key exists

feat(UX): prevent trimming of whitespace on values

feat(UX): change editor to async

feat(UX): add message describing use

feat(UX): Refactor variable text to editorText

refactor(app): rename env vars controller

refactor(app): move env var explanation to parent

refactor(app): order env var panels

refactor(app): move simple env vars mode to component

refactor(app): parse env vars

refactor(app): move styles to css

refactor(app): rename functions

refactor(container): parse env vars

refactor(env-vars): move utils to helper module

refactor(env-vars): use util function for parse dot env file

fix(env-vars): ignore comments

refactor(services): use env vars utils

refactor(env-vars): rename files

refactor(env-panel): use utils

style(stack): revert EnvContent to Env

style(service): revert EnvContent to Env

style(container): revert EnvContent to Env

refactor(env-vars): support default value

refactor(service): use new env var component

refactor(env-var): use one way data flow

refactor(containers): remove unused function

* fix(env-vars): prevent using non .env files

* refactor(env-vars): move env vars items to a component

* feat(app): fixed env vars form validation in Stack

* feat(services): disable env form submit if invalid

* fix(app): show key pairs correctly

* fix(env-var): use the same validation as with kubernetes

* fix(env-vars): parse env var

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
2021-06-14 18:59:07 +12:00
zees-dev
6e9f472723 feat(container-stats): introduce container block I/O stats (#5017)
* feat(container-stats):introduce container block io stats

* Change charts to 2x2 view

* fix(container-stats): handle missing io stats by detecting stats based on op codes

Co-authored-by: DarkAEther <30438425+DarkAEther@users.noreply.github.com>
2021-06-14 15:57:00 +12:00
Hui
49bd139466 fix swagger param (#5183) 2021-06-14 14:45:57 +12:00
cong meng
dc180d85c5 Feat 4612 real time metrics for kube nodes (#4708)
* feat(k8s/node): display realtime node metrics GH#4612

* feat(k8s): show observation timestamp instead of real timestamp GH#4612

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-14 12:29:41 +12:00
Maxime Bajeux
45ceece1a9 feat(application): Invalid environment variable form validation when creating an application (#5019) 2021-06-14 11:06:54 +12:00
Chaim Lev-Ari
0b85684168 fix(app): parse response with null body (#4654)
* fix(app): parse response with null body

* style(docker): add comment explaining change

* fix(images): show correct error when failing import

* fix(images): use async await
2021-06-11 12:05:54 +12:00
Hui
f674573cdf feat(OAuth): Add SSO support for OAuth EE-390 (#5087)
* add updateSettingsToDB28 func and test

* update DBversion const

* migration func naming modification

* feat(oauth): add sso, hide internal auth teaser and logout options. (#5039)

* cleanup and make helper func for unit testing

* dbversion update

* feat(publicSettings): public settings response modification for OAuth SSO EE-608 (#5062)

* feat(oauth): updated logout logic with logoutUrl. (#5064)

* add exclusive token generation for OAuth

* swagger annotation revision

* add unit test

* updates based on tech review feedback

* feat(oauth): updated oauth settings model

* feat(oauth): added oauth logout url

* feat(oauth): fixed SSO toggle and logout issue.

* set SSO to ON by default

* update migrator unit test

* set SSO to true by default for new instance

* prevent applying the SSO logout url to the initial admin user

Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
2021-06-11 10:09:04 +12:00
Richard Wei
14ac005627 fix(app):fix local k8s endpoint not saved EE-825 (#5162) 2021-06-11 09:36:17 +12:00
cong meng
26ead28d7b Feat(stacks): orphaned stacks #4397 (#4834)
* feat(stack): add the ability for an administrator user to manage orphaned stacks (#4397)

* feat(stack): apply small font size to the information text of associate (#4397)

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-10 14:52:33 +12:00
zees-dev
eae2f5c9fc feat(kubernetes/summary): summary of k8s actions upon deploying/updating resources EE-436 (#5137)
* feat EE-440/EE-436 kubernetes-resources-summary-panel

* bugfix: returning created resources after update

* fixed patch based bugs - displaying accurate updates for k8s resources

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-10 10:38:23 +12:00
fhanportainer
267968e099 fix(aci): fixed aci with persistence or networking issue. (#4996) 2021-06-10 01:34:19 +12:00
cong meng
defd929366 Fix(kube) advanced deployment CE-83 (#4866)
* refactor(http/kube): convert compose format

* feat(kube/deploy): deploy to agent

* feat(kube/deploy): show more details about error

* refactor(kube): return string from deploy

* feat(kube/deploy): revert to use local kubectl

* Revert "feat(kube/deploy): revert to use local kubectl"

This reverts commit 7c4a1c70

* feat(kube/deploy): GH#4321 use the v2 version of agent api instead of v3

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-09 01:55:17 +02:00
testA113
2fb17c9cf9 Merge pull request #4983 from portainer/feat/EE-352/CE-truncate-image-name-in-tables
feat(k8s): truncate image name in tables
2021-06-04 15:20:26 +12:00
dbuduev
c8d78ad15f Merge pull request #5146 from portainer/feat/EE-872/test-scaffolding
feat(bolt): implement bolt db test store EE-872
2021-06-04 13:44:56 +12:00
Dennis Buduev
96a6129d8a feat(bolt): implement boltdb test store EE-872 2021-06-04 13:33:18 +12:00
Alice Groux
b8660ed2a0 feat(k8s/applications): reorder placement policies and select mandatory by default (#5063) 2021-06-03 13:42:44 +02:00
Chaim Lev-Ari
9ec1f2ed6d fix(endpoints): set sysctl setting for new endpoints (#5028) 2021-06-03 11:36:54 +02:00
yi-portainer
8bfa5132cd Merge branch 'release/2.5' into develop 2021-06-03 20:39:54 +12:00
wheresolivia
cafcebe27e Merge pull request #4668 from portainer/feat-4667-custom-portainer-folder
chore(dev-build): custom portainer data folder
2021-06-03 13:28:33 +12:00
wheresolivia
ea6df891c3 Merge pull request #5014 from portainer/feat/EE-445/resourcepool-namespace
feat(k8s): replace resourcepool with namespace EE-445
2021-06-02 11:30:20 +12:00
Chaim Lev-Ari
230f8fddc3 fix(kube): replace remaining resource pool texts 2021-06-01 11:56:47 +03:00
Chaim Lev-Ari
6734f0ab74 feat(k8s): replace resource pool with name space 2021-06-01 11:52:05 +03:00
Chaim Lev-Ari
3e60167aeb feat(k8s/applications): default to isolated application 2021-06-01 11:52:05 +03:00
Chaim Lev-Ari
8a4902f15a feat(k8s/applications): rephrase descriptions 2021-06-01 11:52:05 +03:00
yi-portainer
dde0467b89 Merge branch 'release/2.5' into develop 2021-05-28 10:16:38 +12:00
wheresolivia
a2a197b14b Merge pull request #5033 from portainer/fix/CE-575/type-downgrade-error
fix(portainer): Fix the typo in the downgrade error message
2021-05-27 16:46:48 +12:00
cong meng
3018801fc0 fix(image) Confirmation modal on builder output view EE-816 (#5107)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-05-26 17:11:32 +12:00
dbuduev
3568fe9e52 feat(git) git clone improvements [EE-451] (#5070) 2021-05-24 17:27:07 +12:00
yi-portainer
2270de73ee Merge branch 'release/2.5' into develop 2021-05-24 08:53:10 +12:00
zees-dev
0114766d50 Merge pull request #5086 from portainer/revert-5084-feat/EE-341/EE-777/oauth-memberships-teaser
Revert "feat(oauth/team-memberships): EE oauth team memberships teaser"
2021-05-20 10:21:11 +12:00
Stéphane Busso
2b94aa5aa6 Revert "feat(oauth/team-memberships): EE oauth team memberships teaser" 2021-05-20 10:03:59 +12:00
cong meng
746e738f1d Merge pull request #5084 from portainer/feat/EE-341/EE-777/oauth-memberships-teaser
feat(oauth/team-memberships): EE oauth team memberships teaser
2021-05-20 09:21:10 +12:00
zees-dev
29f5008c5f EE oauth team memberships feature teaser 2021-05-19 16:15:46 +12:00
yi-portainer
af03d91e39 Merge branch 'release/2.5' into develop 2021-05-18 17:02:31 +12:00
yi-portainer
71635834c7 * update portainer version to 2.5.0
(cherry picked from commit 43702c2516)
2021-05-13 18:32:42 +12:00
Chaim Lev-Ari
39e24ec93f fix(docker): set image pulls as valid if failed fetching (#5007) 2021-05-07 15:38:58 +12:00
Maxime Bajeux
ce04944ce6 fix(portainer): Fix the type in the downgrade error message 2021-05-05 11:44:00 +02:00
zees-dev
daabce2b8f Merge pull request #4406 from ricmatsui/feat1654-colorize-logs
feat(log-viewer): add ansi color support for logs
2021-05-03 09:25:24 +12:00
Alice Groux
d99358ea8e feat(k8s/container): realtime metrics (#4416)
* feat(k8s/container): metrics layout

* feat(k8s/container): memory graph

* feat(k8s/container): cpu usage percent

* feat(k8s/metrics): metrics api validation to enable metrics server

* feat(k8s/pods): update error metrics view

* feat(k8s/container): improve stopRepeater function

* feat(k8s/pods): display empty view instead of empty graphs

* feat(k8s/pods): fix CPU usage

* feat(k8s/configure): fix the metrics server test

* feat(k8s/pod): fix cpu issue

* feat(k8s/pod): fix toaster for non register pods in metrics server

* feat(k8s/service): remove options before 30 secondes for refresh rate

* feat(k8s/pod): fix default value for the refresh rate

* feat(k8s/pod): fix rebase
2021-04-29 13:10:14 +12:00
Alice Groux
befccacc27 feat(k8s/ingress): create multiple ingress network per kubernetes namespace (#4464)
* feat(k8s/ingress): introduce multiple hosts per ingress

* feat(k8s/ingress): host selector in app create/edit

* feat(k8s/ingress): save empty hosts

* feat(k8s/ingress): fix empty host

* feat(k8s/ingress): rename inputs + ensure hostnames unicity + fix remove hostname and routes

* feat(k8s/ingress): fix duplicates hostname validation

* feat(k8s/application): fix rebase

* feat(k8s/resource-pool): fix error messages for ingress (wip)

* fix(k8s/resource-pool): ingress duplicates detection
2021-04-28 05:51:13 +12:00
Ricardo Matsui
a7fc7816d1 Merge branch 'develop' into feat1654-colorize-logs 2021-04-15 22:38:43 -07:00
alice groux
872a8262f1 feat(k8s): add full name on hovering over the image name 2021-04-14 14:59:17 +02:00
alice groux
c339afb562 feat(k8s): cut image name to 64 chars with truncate filter in all applications datatables 2021-04-13 16:09:37 +02:00
Chaim Lev-Ari
78661b50ca chore(dev-build): custom portainer data folder 2021-04-12 08:49:07 +03:00
Ricardo Matsui
3f9ff8460f fix(log-viewer): fix copy logs and log status 2020-10-28 23:43:53 -07:00
Ricardo Matsui
ae3809cefd fix(log-viewer): fix formatting last line without newline 2020-10-26 16:36:12 -07:00
Ricardo Matsui
8e246c203c feat(log-viewer): add ansi color support for logs 2020-10-24 01:01:09 -07:00
588 changed files with 19840 additions and 7044 deletions

15
.github/workflows/label-conflcts.yaml vendored Normal file
View File

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

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ api/cmd/portainer/portainer*
__debug_bin
api/docs
.idea
.env

View File

@@ -0,0 +1,4 @@
{
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast", "-E", "exportloopref"]
}

View File

@@ -1,16 +1,14 @@
<p align="center">
<img title="portainer" src='https://github.com/portainer/portainer/blob/develop/app/assets/images/logo_alt.png?raw=true' />
<img title="portainer" src='https://github.com/portainer/portainer/blob/develop/app/assets/images/portainer-github-banner.png?raw=true' />
</p>
[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/)
[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer 'Image size')
[![Build Status](https://portainer.visualstudio.com/Portainer%20CI/_apis/build/status/Portainer%20CI?branchName=develop)](https://portainer.visualstudio.com/Portainer%20CI/_build/latest?definitionId=3&branchName=develop)
[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)
**Portainer CE** is a lightweight universal management GUI that can be used to **easily** manage Docker, Swarm, Kubernetes and ACI environments. It is designed to be as **simple** to deploy as it is to use.
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container, supports other platforms too).
**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more!) It is compatible with the _standalone Docker_ engine and with _Docker Swarm mode_.
Portainer consists of a single container that can run on any cluster. It can be deployed as a Linux container or a Windows native container.
**Portainer** allows you to manage all your orchestrator resources (containers, images, volumes, networks and more) through a super-simple graphical interface.
A fully supported version of Portainer is available for business use. Visit http://www.portainer.io to learn more
## Demo
@@ -18,30 +16,38 @@ You can try out the public demo instance: http://demo.portainer.io/ (login with
Please note that the public demo cluster is **reset every 15min**.
Alternatively, you can deploy a copy of the demo stack inside a [play-with-docker (PWD)](https://labs.play-with-docker.com) playground:
## Latest Version
- Browse [PWD/?stack=portainer-demo/play-with-docker/docker-stack.yml](http://play-with-docker.com/?stack=https://raw.githubusercontent.com/portainer/portainer-demo/master/play-with-docker/docker-stack.yml)
- Sign in with your [Docker ID](https://docs.docker.com/docker-id)
- Follow [these](https://github.com/portainer/portainer-demo/blob/master/play-with-docker/docker-stack.yml#L5-L8) steps.
Portainer CE is updated regularly. We aim to do an update release every couple of months.
Unlike the public demo, the playground sessions are deleted after 4 hours. Apart from that, all the settings are the same, including default credentials.
**The latest version of Portainer is 2.6.x** And you can find the release notes [here.](https://www.portainer.io/blog/new-portainer-ce-2.6.0-release)
Portainer is on version 2, the second number denotes the month of release.
## Getting started
- [Deploy Portainer](https://documentation.portainer.io/quickstart/)
- [Documentation](https://documentation.portainer.io)
- [Building Portainer](https://documentation.portainer.io/contributing/instructions/)
- [Contribute to the project](https://documentation.portainer.io/contributing/instructions/)
## Features & Functions
View [this](https://www.portainer.io/products) table to see all of the Portainer CE functionality and compare to Portainer Business.
- [Portainer CE for Docker / Docker Swarm](https://www.portainer.io/solutions/docker)
- [Portainer CE for Kubernetes](https://www.portainer.io/solutions/kubernetes-ui)
- [Portainer CE for Azure ACI](https://www.portainer.io/solutions/serverless-containers)
## Getting help
For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products/portainer-business
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
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
Learn more about Portainers community support channels [here.](https://www.portainer.io/help_about)
- Issues: https://github.com/portainer/portainer/issues
- FAQ: https://documentation.portainer.io
- Slack (chat): https://portainer.io/slack/
You can join the Portainer Community by visiting community.portainer.io. This will give you advance notice of events, content and other related Portainer content.
## Reporting bugs and contributing
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
@@ -51,6 +57,10 @@ For community support: You can find more information about Portainer's community
- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
## WORK FOR US
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to info@portainer.io with your details and we will be in touch.
## Privacy
**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.**

BIN
api/archive/testdata/sample_archive.zip vendored Normal file

Binary file not shown.

View File

@@ -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
View 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"))
}

View File

@@ -10,6 +10,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
)
@@ -32,7 +33,7 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
}
for _, filename := range filesToBackup {
err := copyPath(filepath.Join(filestorePath, filename), backupDirPath)
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
if err != nil {
return "", errors.Wrap(err, "Failed to create backup file")
}

View File

@@ -1,105 +0,0 @@
package backup
import (
"io/ioutil"
"os"
"path"
"path/filepath"
"testing"
"github.com/docker/docker/pkg/ioutils"
"github.com/stretchr/testify/assert"
)
func listFiles(dir string) []string {
items := make([]string, 0)
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if path == dir {
return nil
}
items = append(items, path)
return nil
})
return items
}
func contains(t *testing.T, list []string, path string) {
assert.Contains(t, list, path)
copyContent, _ := ioutil.ReadFile(path)
assert.Equal(t, "content\n", string(copyContent))
}
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
tmpdir, _ := ioutils.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
err := copyFile("does-not-exist", tmpdir)
assert.NotNil(t, err)
}
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
tmpdir, _ := ioutils.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
content := []byte("content")
ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
assert.Nil(t, err)
copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy"))
assert.Equal(t, content, copyContent)
}
func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
destination, _ := ioutils.TempDir("", "destination")
defer os.RemoveAll(destination)
err := copyDir("./test_assets/copy_test", destination)
assert.Nil(t, err)
createdFiles := listFiles(destination)
contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer"))
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner"))
}
func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) {
tmpdir, _ := ioutils.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
err := copyPath("does-not-exists", tmpdir)
assert.Nil(t, err)
assert.Empty(t, listFiles(tmpdir))
}
func Test_backupPath_shouldCopyFile(t *testing.T) {
tmpdir, _ := ioutils.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
content := []byte("content")
ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600)
os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
err := copyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
assert.Nil(t, err)
copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file"))
assert.Nil(t, err)
assert.Equal(t, content, copyContent)
}
func Test_backupPath_shouldCopyDir(t *testing.T) {
destination, _ := ioutils.TempDir("", "destination")
defer os.RemoveAll(destination)
err := copyPath("./test_assets/copy_test", destination)
assert.Nil(t, err)
createdFiles := listFiles(destination)
contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer"))
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner"))
}

View File

@@ -11,6 +11,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
)
@@ -59,7 +60,7 @@ func extractArchive(r io.Reader, destinationDirPath string) error {
func restoreFiles(srcDir string, destinationDir string) error {
for _, filename := range filesToRestore {
err := copyPath(filepath.Join(srcDir, filename), destinationDir)
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
if err != nil {
return err
}

View 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)
}
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/portainer/portainer/api/bolt/role"
"github.com/portainer/portainer/api/bolt/schedule"
"github.com/portainer/portainer/api/bolt/settings"
"github.com/portainer/portainer/api/bolt/ssl"
"github.com/portainer/portainer/api/bolt/stack"
"github.com/portainer/portainer/api/bolt/tag"
"github.com/portainer/portainer/api/bolt/team"
@@ -61,6 +62,7 @@ type Store struct {
RoleService *role.Service
ScheduleService *schedule.Service
SettingsService *settings.Service
SSLSettingsService *ssl.Service
StackService *stack.Service
TagService *tag.Service
TeamMembershipService *teammembership.Service
@@ -114,6 +116,7 @@ func (store *Store) Open() error {
}
// Close closes the BoltDB database.
// Safe to being called multiple times.
func (store *Store) Close() error {
if store.connection.DB != nil {
return store.connection.Close()
@@ -169,6 +172,7 @@ func (store *Store) MigrateData(force bool) error {
UserService: store.UserService,
VersionService: store.VersionService,
FileService: store.fileService,
DockerhubService: store.DockerHubService,
AuthorizationService: authorization.NewService(store),
}
migrator := migrator.NewMigrator(migratorParams)

View File

@@ -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/")
ErrWrongDBEdition = errors.New("The Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
)

View File

@@ -55,20 +55,20 @@ func (store *Store) Init() error {
return err
}
_, err = store.DockerHubService.DockerHub()
if err == errors.ErrObjectNotFound {
defaultDockerHub := &portainer.DockerHub{
Authentication: false,
Username: "",
Password: "",
_, err = store.SSLSettings().Settings()
if err != nil {
if err != errors.ErrObjectNotFound {
return err
}
err := store.DockerHubService.UpdateDockerHub(defaultDockerHub)
defaultSSLSettings := &portainer.SSLSettings{
HTTPEnabled: true,
}
err = store.SSLSettings().UpdateSettings(defaultSSLSettings)
if err != nil {
return err
}
} else if err != nil {
return err
}
groups, err := store.EndpointGroupService.EndpointGroups()

41
api/bolt/log/log.go Normal file
View 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
View File

@@ -0,0 +1 @@
package log

View File

@@ -0,0 +1,19 @@
package migrator
func (m *Migrator) migrateDBVersionToDB30() error {
if err := m.migrateSettingsToDB30(); err != nil {
return err
}
return nil
}
func (m *Migrator) migrateSettingsToDB30() error {
legacySettings, err := m.settingsService.Settings()
if err != nil {
return err
}
legacySettings.OAuthSettings.SSO = false
legacySettings.OAuthSettings.LogoutURI = ""
return m.settingsService.UpdateSettings(legacySettings)
}

View File

@@ -0,0 +1,95 @@
package migrator
import (
"os"
"path"
"testing"
"time"
"github.com/boltdb/bolt"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/portainer/portainer/api/bolt/settings"
)
var (
testingDBStorePath string
testingDBFileName string
dummyLogoURL string
dbConn *bolt.DB
settingsService *settings.Service
)
// 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
}
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 TestMigrateSettings(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.migrateSettingsToDB30(); 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.LogoutURI != "" {
t.Errorf("unexpected default OAuth HideInternalAuth setting, want:, got: %s", updatedSettings.OAuthSettings.LogoutURI)
}
}

View File

@@ -0,0 +1,213 @@
package migrator
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
snapshotutils "github.com/portainer/portainer/api/internal/snapshot"
)
func (m *Migrator) migrateDBVersionToDB32() error {
err := m.updateRegistriesToDB32()
if err != nil {
return err
}
err = m.updateDockerhubToDB32()
if err != nil {
return err
}
if err := m.updateVolumeResourceControlToDB32(); err != nil {
return err
}
return nil
}
func (m *Migrator) updateRegistriesToDB32() error {
registries, err := m.registryService.Registries()
if err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, registry := range registries {
registry.RegistryAccesses = portainer.RegistryAccesses{}
for _, endpoint := range endpoints {
filteredUserAccessPolicies := portainer.UserAccessPolicies{}
for userId, registryPolicy := range registry.UserAccessPolicies {
if _, found := endpoint.UserAccessPolicies[userId]; found {
filteredUserAccessPolicies[userId] = registryPolicy
}
}
filteredTeamAccessPolicies := portainer.TeamAccessPolicies{}
for teamId, registryPolicy := range registry.TeamAccessPolicies {
if _, found := endpoint.TeamAccessPolicies[teamId]; found {
filteredTeamAccessPolicies[teamId] = registryPolicy
}
}
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{
UserAccessPolicies: filteredUserAccessPolicies,
TeamAccessPolicies: filteredTeamAccessPolicies,
Namespaces: []string{},
}
}
m.registryService.UpdateRegistry(registry.ID, &registry)
}
return nil
}
func (m *Migrator) updateDockerhubToDB32() error {
dockerhub, err := m.dockerhubService.DockerHub()
if err == errors.ErrObjectNotFound {
return nil
} else if err != nil {
return err
}
if !dockerhub.Authentication {
return nil
}
registry := &portainer.Registry{
Type: portainer.DockerHubRegistry,
Name: "Dockerhub (authenticated - migrated)",
URL: "docker.io",
Authentication: true,
Username: dockerhub.Username,
Password: dockerhub.Password,
RegistryAccesses: portainer.RegistryAccesses{},
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpoint.Type != portainer.KubernetesLocalEnvironment &&
endpoint.Type != portainer.AgentOnKubernetesEnvironment &&
endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment {
userAccessPolicies := portainer.UserAccessPolicies{}
for userId := range endpoint.UserAccessPolicies {
if _, found := endpoint.UserAccessPolicies[userId]; found {
userAccessPolicies[userId] = portainer.AccessPolicy{
RoleID: 0,
}
}
}
teamAccessPolicies := portainer.TeamAccessPolicies{}
for teamId := range endpoint.TeamAccessPolicies {
if _, found := endpoint.TeamAccessPolicies[teamId]; found {
teamAccessPolicies[teamId] = portainer.AccessPolicy{
RoleID: 0,
}
}
}
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{
UserAccessPolicies: userAccessPolicies,
TeamAccessPolicies: teamAccessPolicies,
Namespaces: []string{},
}
}
}
return m.registryService.CreateRegistry(registry)
}
func (m *Migrator) updateVolumeResourceControlToDB32() error {
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return fmt.Errorf("failed fetching endpoints: %w", err)
}
resourceControls, err := m.resourceControlService.ResourceControls()
if err != nil {
return fmt.Errorf("failed fetching resource controls: %w", err)
}
toUpdate := map[portainer.ResourceControlID]string{}
volumeResourceControls := map[string]*portainer.ResourceControl{}
for i := range resourceControls {
resourceControl := resourceControls[i]
if resourceControl.Type == portainer.VolumeResourceControl {
volumeResourceControls[resourceControl.ResourceID] = &resourceControl
}
}
for _, endpoint := range endpoints {
if !endpointutils.IsDockerEndpoint(&endpoint) {
continue
}
totalSnapshots := len(endpoint.Snapshots)
if totalSnapshots == 0 {
continue
}
snapshot := endpoint.Snapshots[totalSnapshots-1]
endpointDockerID, err := snapshotutils.FetchDockerID(snapshot)
if err != nil {
return fmt.Errorf("failed fetching endpoint docker id: %w", err)
}
if volumesData, done := snapshot.SnapshotRaw.Volumes.(map[string]interface{}); done {
if volumesData["Volumes"] == nil {
continue
}
findResourcesToUpdateForDB32(endpointDockerID, volumesData, toUpdate, volumeResourceControls)
}
}
for _, resourceControl := range volumeResourceControls {
if newResourceID, ok := toUpdate[resourceControl.ID]; ok {
resourceControl.ResourceID = newResourceID
err := m.resourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl)
if err != nil {
return fmt.Errorf("failed updating resource control %d: %w", resourceControl.ID, err)
}
} else {
err := m.resourceControlService.DeleteResourceControl(resourceControl.ID)
if err != nil {
return fmt.Errorf("failed deleting resource control %d: %w", resourceControl.ID, err)
}
}
}
return nil
}
func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interface{}, toUpdate map[portainer.ResourceControlID]string, volumeResourceControls map[string]*portainer.ResourceControl) {
volumes := volumesData["Volumes"].([]interface{})
for _, volumeMeta := range volumes {
volume := volumeMeta.(map[string]interface{})
volumeName := volume["Name"].(string)
oldResourceID := fmt.Sprintf("%s%s", volumeName, volume["CreatedAt"].(string))
resourceControl, ok := volumeResourceControls[oldResourceID]
if ok {
toUpdate[resourceControl.ID] = fmt.Sprintf("%s_%s", volumeName, dockerID)
}
}
}

View File

@@ -0,0 +1,32 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
)
func (m *Migrator) migrateDBVersionTo33() error {
err := migrateStackEntryPoint(m.stackService)
if err != nil {
return err
}
return nil
}
func migrateStackEntryPoint(stackService portainer.StackService) error {
stacks, err := stackService.Stacks()
if err != nil {
return err
}
for i := range stacks {
stack := &stacks[i]
if stack.GitConfig == nil {
continue
}
stack.GitConfig.ConfigFilePath = stack.EntryPoint
if err := stackService.UpdateStack(stack.ID, stack); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,51 @@
package migrator
import (
"path"
"testing"
"time"
"github.com/boltdb/bolt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/portainer/portainer/api/bolt/stack"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
)
func TestMigrateStackEntryPoint(t *testing.T) {
dbConn, err := bolt.Open(path.Join(t.TempDir(), "portainer-ee-mig-33.db"), 0600, &bolt.Options{Timeout: 1 * time.Second})
assert.NoError(t, err, "failed to init testing DB connection")
defer dbConn.Close()
stackService, err := stack.NewService(&internal.DbConnection{DB: dbConn})
assert.NoError(t, err, "failed to init testing Stack service")
stacks := []*portainer.Stack{
{
ID: 1,
EntryPoint: "dir/sub/compose.yml",
},
{
ID: 2,
EntryPoint: "dir/sub/compose.yml",
GitConfig: &gittypes.RepoConfig{},
},
}
for _, s := range stacks {
err := stackService.CreateStack(s)
assert.NoError(t, err, "failed to create stack")
}
err = migrateStackEntryPoint(stackService)
assert.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath")
s, err := stackService.Stack(1)
assert.NoError(t, err)
assert.Nil(t, s.GitConfig, "first stack should not have git config")
s, err = stackService.Stack(2)
assert.NoError(t, err)
assert.Equal(t, "dir/sub/compose.yml", s.GitConfig.ConfigFilePath, "second stack should have config file path migrated")
}

View File

@@ -3,10 +3,12 @@ package migrator
import (
"github.com/boltdb/bolt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/dockerhub"
"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,6 +22,8 @@ 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 {
@@ -41,6 +45,7 @@ type (
versionService *version.Service
fileService portainer.FileService
authorizationService *authorization.Service
dockerhubService *dockerhub.Service
}
// Parameters represents the required parameters to create a new Migrator instance.
@@ -63,6 +68,7 @@ type (
VersionService *version.Service
FileService portainer.FileService
AuthorizationService *authorization.Service
DockerhubService *dockerhub.Service
}
)
@@ -87,6 +93,7 @@ func NewMigrator(parameters *Parameters) *Migrator {
versionService: parameters.VersionService,
fileService: parameters.FileService,
authorizationService: parameters.AuthorizationService,
dockerhubService: parameters.DockerhubService,
}
}
@@ -358,5 +365,27 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 2.6.0
if m.currentDBVersion < 30 {
err := m.migrateDBVersionToDB30()
if err != nil {
return err
}
}
// Portainer 2.9.0
if m.currentDBVersion < 32 {
err := m.migrateDBVersionToDB32()
if err != nil {
return err
}
}
if m.currentDBVersion < 33 {
if err := m.migrateDBVersionTo33(); err != nil {
return err
}
}
return m.versionService.StoreDBVersion(portainer.DBVersion)
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/portainer/portainer/api/bolt/role"
"github.com/portainer/portainer/api/bolt/schedule"
"github.com/portainer/portainer/api/bolt/settings"
"github.com/portainer/portainer/api/bolt/ssl"
"github.com/portainer/portainer/api/bolt/stack"
"github.com/portainer/portainer/api/bolt/tag"
"github.com/portainer/portainer/api/bolt/team"
@@ -105,6 +106,12 @@ func (store *Store) initServices() error {
}
store.SettingsService = settingsService
sslSettingsService, err := ssl.NewService(store.connection)
if err != nil {
return err
}
store.SSLSettingsService = sslSettingsService
stackService, err := stack.NewService(store.connection)
if err != nil {
return err
@@ -167,11 +174,6 @@ func (store *Store) CustomTemplate() portainer.CustomTemplateService {
return store.CustomTemplateService
}
// DockerHub gives access to the DockerHub data management layer
func (store *Store) DockerHub() portainer.DockerHubService {
return store.DockerHubService
}
// EdgeGroup gives access to the EdgeGroup data management layer
func (store *Store) EdgeGroup() portainer.EdgeGroupService {
return store.EdgeGroupService
@@ -222,6 +224,11 @@ func (store *Store) Settings() portainer.SettingsService {
return store.SettingsService
}
// SSLSettings gives access to the SSL Settings data management layer
func (store *Store) SSLSettings() portainer.SSLSettingsService {
return store.SSLSettingsService
}
// Stack gives access to the Stack data management layer
func (store *Store) Stack() portainer.StackService {
return store.StackService

46
api/bolt/ssl/ssl.go Normal file
View File

@@ -0,0 +1,46 @@
package ssl
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/internal"
)
const (
// BucketName represents the name of the bucket where this service stores data.
BucketName = "ssl"
key = "SSL"
)
// Service represents a service for managing ssl 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
}
// Settings retrieve the ssl settings object.
func (service *Service) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
err := internal.GetObject(service.connection, BucketName, []byte(key), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// UpdateSettings persists a SSLSettings object.
func (service *Service) UpdateSettings(settings *portainer.SSLSettings) error {
return internal.UpdateObject(service.connection, BucketName, []byte(key), settings)
}

View File

@@ -1,11 +1,14 @@
package stack
import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/boltdb/bolt"
pkgerrors "github.com/pkg/errors"
)
const (
@@ -133,3 +136,76 @@ func (service *Service) DeleteStack(ID portainer.StackID) error {
identifier := internal.Itob(int(ID))
return internal.DeleteObject(service.connection, BucketName, identifier)
}
// StackByWebhookID returns a pointer to a stack object by webhook ID.
// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID.
func (service *Service) StackByWebhookID(id string) (*portainer.Stack, error) {
if id == "" {
return nil, pkgerrors.New("webhook ID can't be empty string")
}
var stack portainer.Stack
found := false
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 t struct {
AutoUpdate *struct {
WebhookID string `json:"Webhook"`
} `json:"AutoUpdate"`
}
err := internal.UnmarshalObject(v, &t)
if err != nil {
return err
}
if t.AutoUpdate != nil && strings.EqualFold(t.AutoUpdate.WebhookID, id) {
found = true
err := internal.UnmarshalObject(v, &stack)
if err != nil {
return err
}
break
}
}
return nil
})
if err != nil {
return nil, err
}
if !found {
return nil, errors.ErrObjectNotFound
}
return &stack, nil
}
// RefreshableStacks returns stacks that are configured for a periodic update
func (service *Service) RefreshableStacks() ([]portainer.Stack, error) {
stacks := make([]portainer.Stack, 0)
err := service.connection.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(BucketName))
cursor := bucket.Cursor()
var stack portainer.Stack
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
err := internal.UnmarshalObject(v, &stack)
if err != nil {
return err
}
if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" {
stacks = append(stacks, stack)
}
}
return nil
})
return stacks, err
}

View File

@@ -0,0 +1,111 @@
package tests
import (
"testing"
"time"
"github.com/portainer/portainer/api/bolt"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/bolt/bolttest"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
)
func newGuidString(t *testing.T) string {
uuid, err := uuid.NewV4()
assert.NoError(t, err)
return uuid.String()
}
type stackBuilder struct {
t *testing.T
count int
store *bolt.Store
}
func TestService_StackByWebhookID(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
}
store, teardown := bolttest.MustNewTestStore(true)
defer teardown()
b := stackBuilder{t: t, store: store}
b.createNewStack(newGuidString(t))
for i := 0; i < 10; i++ {
b.createNewStack("")
}
webhookID := newGuidString(t)
stack := b.createNewStack(webhookID)
// can find a stack by webhook ID
got, err := store.StackService.StackByWebhookID(webhookID)
assert.NoError(t, err)
assert.Equal(t, stack, *got)
// returns nil and object not found error if there's no stack associated with the webhook
got, err = store.StackService.StackByWebhookID(newGuidString(t))
assert.Nil(t, got)
assert.ErrorIs(t, err, bolterrors.ErrObjectNotFound)
}
func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack {
b.count++
stack := portainer.Stack{
ID: portainer.StackID(b.count),
Name: "Name",
Type: portainer.DockerComposeStack,
EndpointID: 2,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: []portainer.Pair{{"Name1", "Value1"}},
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
ProjectPath: "/tmp/project",
CreatedBy: "test",
}
if webhookID == "" {
if b.count%2 == 0 {
stack.AutoUpdate = &portainer.StackAutoUpdate{
Interval: "",
Webhook: "",
}
} // else keep AutoUpdate nil
} else {
stack.AutoUpdate = &portainer.StackAutoUpdate{Webhook: webhookID}
}
err := b.store.StackService.CreateStack(&stack)
assert.NoError(b.t, err)
return stack
}
func Test_RefreshableStacks(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
}
store, teardown := bolttest.MustNewTestStore(true)
defer teardown()
staticStack := portainer.Stack{ID: 1}
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.StackAutoUpdate{Webhook: "webhook"}}
refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.StackAutoUpdate{Interval: "1m"}}
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
err := store.Stack().CreateStack(stack)
assert.NoError(t, err)
}
stacks, err := store.Stack().RefreshableStacks()
assert.NoError(t, err)
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
}

View File

@@ -5,7 +5,7 @@ import (
"log"
"time"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"os"
"path/filepath"
@@ -30,6 +30,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags := &portainer.CLIFlags{
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
AddrHTTPS: kingpin.Flag("bind-https", "Address and port to serve Portainer via https").Default(defaultHTTPSBindAddress).String(),
TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(),
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
@@ -42,9 +43,10 @@ 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(),
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(),
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each endpoint snapshot job").Default(defaultSnapshotInterval).String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
@@ -92,6 +94,10 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
if *flags.NoAnalytics {
log.Println("Warning: The --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect.")
}
if *flags.SSL {
log.Println("Warning: SSL is enabled by default and there is no need for the --ssl flag. It has been kept to allow migration of instances running a previous version of Portainer with this flag enabled")
}
}
func validateEndpointURL(endpointURL string) error {

View File

@@ -4,6 +4,7 @@ package cli
const (
defaultBindAddress = ":9000"
defaultHTTPSBindAddress = ":9443"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "/data"
@@ -13,6 +14,7 @@ const (
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultHTTPDisabled = "false"
defaultSSL = "false"
defaultSSLCertPath = "/certs/portainer.crt"
defaultSSLKeyPath = "/certs/portainer.key"

View File

@@ -2,6 +2,7 @@ package cli
const (
defaultBindAddress = ":9000"
defaultHTTPSBindAddress = ":9443"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "C:\\data"
@@ -11,6 +12,7 @@ const (
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
defaultHTTPDisabled = "false"
defaultSSL = "false"
defaultSSLCertPath = "C:\\certs\\portainer.crt"
defaultSSLKeyPath = "C:\\certs\\portainer.key"

19
api/cmd/portainer/log.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"log"
"github.com/sirupsen/logrus"
)
func configureLogger() {
logger := logrus.New() // logger is to implicitly substitute stdlib's log
log.SetOutput(logger.Writer())
formatter := &logrus.TextFormatter{DisableTimestamp: true, DisableLevelTruncation: true}
logger.SetFormatter(formatter)
logrus.SetFormatter(formatter)
logger.SetLevel(logrus.DebugLevel)
logrus.SetLevel(logrus.DebugLevel)
}

View File

@@ -20,14 +20,18 @@ 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/internal/ssl"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
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/oauth"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks"
)
func initCLI() *portainer.CLIFlags {
@@ -52,7 +56,7 @@ func initFileService(dataStorePath string) portainer.FileService {
return fileService
}
func initDataStore(dataStorePath string, fileService portainer.FileService) portainer.DataStore {
func initDataStore(dataStorePath string, fileService portainer.FileService, shutdownCtx context.Context) portainer.DataStore {
store, err := bolt.NewStore(dataStorePath, fileService)
if err != nil {
log.Fatalf("failed creating data store: %v", err)
@@ -72,24 +76,32 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port
if err != nil {
log.Fatalf("failed migration: %v", err)
}
go shutdownDatastore(shutdownCtx, store)
return store
}
func shutdownDatastore(shutdownCtx context.Context, datastore portainer.DataStore) {
<-shutdownCtx.Done()
datastore.Close()
}
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper := exec.NewComposeWrapper(assetsPath, dataStorePath, 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(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath)
}
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
@@ -129,12 +141,29 @@ func initGitService() portainer.GitService {
return git.NewService()
}
func initSSLService(addr, dataPath, certPath, keyPath string, fileService portainer.FileService, dataStore portainer.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) {
slices := strings.Split(addr, ":")
host := slices[0]
if host == "" {
host = "0.0.0.0"
}
sslService := ssl.NewService(fileService, dataStore, shutdownTrigger)
err := sslService.Init(host, certPath, keyPath)
if err != nil {
return nil, err
}
return sslService, nil
}
func initDockerClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory {
return docker.NewClientFactory(signatureService, reverseTunnelService)
}
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *kubecli.ClientFactory {
return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID)
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *kubecli.ClientFactory {
return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID, dataStore)
}
func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory, shutdownCtx context.Context) (portainer.SnapshotService, error) {
@@ -149,9 +178,10 @@ func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore,
return snapshotService, nil
}
func initStatus(flags *portainer.CLIFlags) *portainer.Status {
func initStatus(instanceID string) *portainer.Status {
return &portainer.Status{
Version: portainer.APIVersion,
Version: portainer.APIVersion,
InstanceID: instanceID,
}
}
@@ -165,6 +195,7 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI
settings.SnapshotInterval = *flags.SnapshotInterval
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
settings.EnableTelemetry = true
settings.OAuthSettings.SSO = true
if *flags.Templates != "" {
settings.TemplatesURL = *flags.Templates
@@ -174,7 +205,26 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI
settings.BlackListedLabels = *flags.Labels
}
return dataStore.Settings().UpdateSettings(settings)
err = dataStore.Settings().UpdateSettings(settings)
if err != nil {
return err
}
httpEnabled := !*flags.HTTPDisabled
sslSettings, err := dataStore.SSLSettings().Settings()
if err != nil {
return err
}
sslSettings.HTTPEnabled = httpEnabled
err = dataStore.SSLSettings().UpdateSettings(sslSettings)
if err != nil {
return err
}
return nil
}
func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
@@ -240,6 +290,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore portainer.Dat
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
@@ -301,6 +352,7 @@ func createUnsecuredEndpoint(endpointURL string, dataStore portainer.DataStore,
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
@@ -344,7 +396,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
fileService := initFileService(*flags.Data)
dataStore := initDataStore(*flags.Data, fileService)
dataStore := initDataStore(*flags.Data, fileService, shutdownCtx)
if err := dataStore.CheckCurrentEdition(); err != nil {
log.Fatal(err)
@@ -365,6 +417,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
digitalSignatureService := initDigitalSignatureService()
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.Data, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
if err != nil {
log.Fatal(err)
}
err = initKeyPair(fileService, digitalSignatureService)
if err != nil {
log.Fatalf("failed initializing key pai: %v", err)
@@ -378,7 +435,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService)
kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID)
kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID, dataStore)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx)
if err != nil {
@@ -386,6 +443,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
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)
@@ -395,7 +455,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
kubernetesDeployer := initKubernetesDeployer(*flags.Assets)
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
if dataStore.IsNew() {
err = updateSettingsFromFlags(dataStore, flags)
@@ -409,7 +469,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatalf("failed loading edge jobs from database: %v", err)
}
applicationStatus := initStatus(flags)
applicationStatus := initStatus(instanceID)
err = initEndpoint(flags, dataStore, snapshotService)
if err != nil {
@@ -454,13 +514,25 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService)
if err != nil {
log.Fatalf("failed starting license service: %s", err)
log.Fatalf("failed starting tunnel server: %s", err)
}
sslSettings, err := dataStore.SSLSettings().Settings()
if err != nil {
log.Fatalf("failed to fetch ssl settings from DB")
}
scheduler := scheduler.NewScheduler(shutdownCtx)
stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager)
stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
return &http.Server{
AuthorizationService: authorizationService,
ReverseTunnelService: reverseTunnelService,
Status: applicationStatus,
BindAddress: *flags.Addr,
BindAddressHTTPS: *flags.AddrHTTPS,
HTTPEnabled: sslSettings.HTTPEnabled,
AssetsPath: *flags.Assets,
DataStore: dataStore,
SwarmStackManager: swarmStackManager,
@@ -476,23 +548,25 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
SignatureService: digitalSignatureService,
SnapshotService: snapshotService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
SSLService: sslService,
DockerClientFactory: dockerClientFactory,
KubernetesClientFactory: kubernetesClientFactory,
Scheduler: scheduler,
ShutdownCtx: shutdownCtx,
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
}
}
func main() {
flags := initCLI()
configureLogger()
for {
server := buildServer(flags)
log.Printf("Starting Portainer %s on %s\n", portainer.APIVersion, *flags.Addr)
log.Printf("[INFO] [cmd,main] Starting Portainer version %s\n", portainer.APIVersion)
err := server.Start()
log.Printf("Http server exited: %s\n", err)
log.Printf("[INFO] [cmd,main] Http server exited: %s\n", err)
}
}

View File

@@ -42,7 +42,7 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
return createAgentClient(endpoint, factory.signatureService, nodeName)
} else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
return createEdgeClient(endpoint, factory.reverseTunnelService, nodeName)
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName)
}
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
@@ -71,13 +71,22 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
)
}
func createEdgeClient(endpoint *portainer.Endpoint, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) {
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
if err != nil {
return nil, err
}
headers := map[string]string{}
signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
headers := map[string]string{
portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(),
portainer.PortainerAgentSignatureHeader: signature,
}
if nodeName != "" {
headers[portainer.PortainerAgentTargetHeader] = nodeName
}

117
api/exec/compose_stack.go Normal file
View File

@@ -0,0 +1,117 @@
package exec
import (
"fmt"
"os"
"path"
"regexp"
"strings"
"github.com/pkg/errors"
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
configPath string
proxyManager *proxy.Manager
}
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeStackManager(binaryPath string, configPath string, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
wrap, err := wrapper.NewComposeWrapper(binaryPath)
if err != nil {
return nil, err
}
return &ComposeStackManager{
wrapper: wrap,
proxyManager: proxyManager,
configPath: configPath,
}, 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 errors.Wrap(err, "failed to featch endpoint proxy")
}
if proxy != nil {
defer proxy.Close()
}
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
}
filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
_, err = w.wrapper.Up(filePaths, stack.ProjectPath, url, stack.Name, envFilePath, w.configPath)
return errors.Wrap(err, "failed to deploy a stack")
}
// 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()
}
filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
_, err = w.wrapper.Down(filePaths, stack.ProjectPath, url, stack.Name)
return err
}
// NormalizeStackName returns a new stack name with unsupported characters replaced
func (w *ComposeStackManager) NormalizeStackName(name string) string {
r := regexp.MustCompile("[^a-z0-9]+")
return r.ReplaceAllString(strings.ToLower(name), "")
}
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 "stack.env", nil
}

View File

@@ -1,5 +1,3 @@
// +build integration
package exec
import (
@@ -33,7 +31,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 +42,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 +66,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)
}

View File

@@ -0,0 +1,66 @@
package exec
import (
"io/ioutil"
"os"
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
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 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, "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)
}
})
}
}

View File

@@ -1,141 +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
dataPath string
proxyManager *proxy.Manager
}
// NewComposeWrapper returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeWrapper(binaryPath, dataPath string, proxyManager *proxy.Manager) *ComposeWrapper {
if !IsBinaryPresent(programPath(binaryPath, "docker-compose")) {
return nil
}
return &ComposeWrapper{
binaryPath: binaryPath,
dataPath: dataPath,
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.Env = os.Environ()
cmd.Env = append(cmd.Env, fmt.Sprintf("DOCKER_CONFIG=%s", w.dataPath))
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
}

View File

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

View File

@@ -2,71 +2,234 @@ package exec
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
"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
kubernetesClientFactory *cli.ClientFactory
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
}
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
func NewKubernetesDeployer(binaryPath string) *KubernetesDeployer {
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
return &KubernetesDeployer{
binaryPath: binaryPath,
binaryPath: binaryPath,
dataStore: datastore,
reverseTunnelService: reverseTunnelService,
signatureService: signatureService,
kubernetesClientFactory: kubernetesClientFactory,
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
}
}
func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return "", err
}
kubecli, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return "", err
}
tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
tokenManager, err := kubernetes.NewTokenManager(kubecli, deployer.dataStore, tokenCache, setLocalAdminToken)
if err != nil {
return "", err
}
if tokenData.Role == portainer.AdministratorRole {
return tokenManager.GetAdminServiceAccountToken(), nil
}
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID)
if err != nil {
return "", err
}
if token == "" {
return "", fmt.Errorf("can not get a valid user service account token")
}
return token, nil
}
// 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(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
if endpoint.Type == portainer.KubernetesLocalEnvironment {
token, err := deployer.getToken(request, endpoint, true);
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", 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
}
token, err := deployer.getToken(request, endpoint, false);
if err != nil {
return "", err
}
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
req.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
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")

View File

@@ -8,9 +8,12 @@ import (
"os"
"os/exec"
"path"
"regexp"
"runtime"
"strings"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/stackutils"
)
// SwarmStackManager represents a service for managing stacks.
@@ -42,7 +45,7 @@ func NewSwarmStackManager(binaryPath, dataPath string, signatureService portaine
}
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) {
func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) {
command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
for _, registry := range registries {
if registry.Authentication {
@@ -50,11 +53,6 @@ func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registri
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
}
}
if dockerhub.Authentication {
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "")
}
}
// Logout executes the docker logout command.
@@ -66,22 +64,23 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
// Deploy executes the docker stack deploy command.
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error {
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
filePaths := stackutils.GetStackFilePaths(stack)
command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
if prune {
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
} else {
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
args = append(args, "stack", "deploy", "--with-registry-auth")
}
args = configureFilePaths(args, filePaths)
args = append(args, stack.Name)
env := make([]string, 0)
for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value)
}
stackFolder := path.Dir(stackFilePath)
return runCommandAndCaptureStdErr(command, args, env, stackFolder)
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
}
// Remove executes the docker stack rm command.
@@ -189,3 +188,15 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
return config, nil
}
func (manager *SwarmStackManager) NormalizeStackName(name string) string {
r := regexp.MustCompile("[^a-z0-9]+")
return r.ReplaceAllString(strings.ToLower(name), "")
}
func configureFilePaths(args []string, filePaths []string) []string {
for _, path := range filePaths {
args = append(args, "--compose-file", path)
}
return args
}

View File

@@ -0,0 +1,15 @@
package exec
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigFilePaths(t *testing.T) {
args := []string{"stack", "deploy", "--with-registry-auth"}
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
output := configureFilePaths(args, filePaths)
assert.ElementsMatch(t, expected, output, "wrong output file paths")
}

View File

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

View File

@@ -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")
}
}

View File

@@ -1,4 +1,4 @@
package backup
package filesystem
import (
"errors"
@@ -8,7 +8,8 @@ import (
"strings"
)
func copyPath(path string, toDir string) error {
// CopyPath copies file or directory defined by the path to the toDir path
func CopyPath(path string, toDir string) error {
info, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
// skip copy if file does not exist
@@ -20,17 +21,30 @@ func copyPath(path string, toDir string) error {
return copyFile(path, destination)
}
return copyDir(path, toDir)
return CopyDir(path, toDir, true)
}
func copyDir(fromDir, toDir string) error {
// CopyDir copies contents of fromDir to toDir.
// When keepParent is true, contents will be copied with their immediate parent dir,
// i.e. given /from/dirA and /to/dirB with keepParent == true, result will be /to/dirB/dirA/<children>
func CopyDir(fromDir, toDir string, keepParent bool) error {
cleanedSourcePath := filepath.Clean(fromDir)
parentDirectory := filepath.Dir(cleanedSourcePath)
err := filepath.Walk(cleanedSourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
destination := filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory))
var destination string
if keepParent {
destination = filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory))
} else {
destination = filepath.Join(toDir, strings.TrimPrefix(path, cleanedSourcePath))
}
if destination == "" {
return nil
}
if info.IsDir() {
return nil // skip directory creations
}

View File

@@ -0,0 +1,92 @@
package filesystem
import (
"io/ioutil"
"os"
"path"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
tmpdir, _ := ioutil.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
err := copyFile("does-not-exist", tmpdir)
assert.Error(t, err)
}
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
tmpdir, _ := ioutil.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
content := []byte("content")
ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
assert.NoError(t, err)
copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy"))
assert.Equal(t, content, copyContent)
}
func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
destination, _ := ioutil.TempDir("", "destination")
defer os.RemoveAll(destination)
err := CopyDir("./testdata/copy_test", destination, true)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
}
func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) {
destination, _ := ioutil.TempDir("", "destination")
defer os.RemoveAll(destination)
err := CopyDir("./testdata/copy_test", destination, false)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(destination, "outer"))
assert.FileExists(t, filepath.Join(destination, "dir", ".dotfile"))
assert.FileExists(t, filepath.Join(destination, "dir", "inner"))
}
func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
tmpdir, _ := ioutil.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
err := CopyPath("does-not-exists", tmpdir)
assert.NoError(t, err)
assert.NoFileExists(t, tmpdir)
}
func Test_CopyPath_shouldCopyFile(t *testing.T) {
tmpdir, _ := ioutil.TempDir("", "backup")
defer os.RemoveAll(tmpdir)
content := []byte("content")
ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600)
os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
err := CopyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
assert.NoError(t, err)
copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file"))
assert.NoError(t, err)
assert.Equal(t, content, copyContent)
}
func Test_CopyPath_shouldCopyDir(t *testing.T) {
destination, _ := ioutil.TempDir("", "destination")
defer os.RemoveAll(destination)
err := CopyPath("./testdata/copy_test", destination)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
}

View File

@@ -31,6 +31,8 @@ const (
ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml"
// ManifestFileDefaultName represents the default name of a k8s manifest file.
ManifestFileDefaultName = "k8s-deployment.yml"
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
EdgeStackStorePath = "edge_stacks"
// PrivateKeyFile represents the name on disk of the file containing the private key.
@@ -48,6 +50,12 @@ const (
CustomTemplateStorePath = "custom_templates"
// TempPath represent the subfolder where temporary files are saved
TempPath = "tmp"
// SSLCertPath represents the default ssl certificates path
SSLCertPath = "certs"
// DefaultSSLCertFilename represents the default ssl certificate file name
DefaultSSLCertFilename = "cert.pem"
// DefaultSSLKeyFilename represents the default ssl key file name
DefaultSSLKeyFilename = "key.pem"
)
// ErrUndefinedTLSFileType represents an error returned on undefined TLS file type
@@ -72,6 +80,11 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return nil, err
}
err = service.createDirectoryInStore(SSLCertPath)
if err != nil {
return nil, err
}
err = service.createDirectoryInStore(TLSStorePath)
if err != nil {
return nil, err
@@ -106,6 +119,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) {
@@ -279,13 +352,7 @@ func (service *Service) WriteJSONToFile(path string, content interface{}) error
// FileExists checks for the existence of the specified file.
func (service *Service) FileExists(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
return FileExists(filePath)
}
// KeyPairFilesExist checks for the existence of the key files.
@@ -510,3 +577,83 @@ func (service *Service) GetTemporaryPath() (string, error) {
func (service *Service) GetDatastorePath() string {
return service.dataStorePath
}
func (service *Service) wrapFileStore(filepath string) string {
return path.Join(service.fileStorePath, filepath)
}
func defaultCertPathUnderFileStore() (string, string) {
certPath := path.Join(SSLCertPath, DefaultSSLCertFilename)
keyPath := path.Join(SSLCertPath, DefaultSSLKeyFilename)
return certPath, keyPath
}
// GetDefaultSSLCertsPath returns the ssl certs path
func (service *Service) GetDefaultSSLCertsPath() (string, string) {
certPath, keyPath := defaultCertPathUnderFileStore()
return service.wrapFileStore(certPath), service.wrapFileStore(keyPath)
}
// StoreSSLCertPair stores a ssl certificate pair
func (service *Service) StoreSSLCertPair(cert, key []byte) (string, string, error) {
certPath, keyPath := defaultCertPathUnderFileStore()
r := bytes.NewReader(cert)
err := service.createFileInStore(certPath, r)
if err != nil {
return "", "", err
}
r = bytes.NewReader(key)
err = service.createFileInStore(keyPath, r)
if err != nil {
return "", "", err
}
return service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil
}
// CopySSLCertPair copies a ssl certificate pair
func (service *Service) CopySSLCertPair(certPath, keyPath string) (string, string, error) {
defCertPath, defKeyPath := service.GetDefaultSSLCertsPath()
err := service.Copy(certPath, defCertPath, false)
if err != nil {
return "", "", err
}
err = service.Copy(keyPath, defKeyPath, false)
if err != nil {
return "", "", err
}
return defCertPath, defKeyPath, nil
}
// FileExists checks for the existence of the specified file.
func FileExists(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
func MoveDirectory(originalPath, newPath string) error {
if _, err := os.Stat(originalPath); err != nil {
return err
}
alreadyExists, err := FileExists(newPath)
if err != nil {
return err
}
if alreadyExists {
return errors.New("Target path already exists")
}
return os.Rename(originalPath, newPath)
}

View File

@@ -0,0 +1,55 @@
package filesystem
import (
"fmt"
"math/rand"
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
service := createService(t)
testHelperFileExists_fileExists(t, service.FileExists)
}
func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
service := createService(t)
testHelperFileExists_fileNotExists(t, service.FileExists)
}
func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
testHelperFileExists_fileExists(t, FileExists)
}
func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
testHelperFileExists_fileNotExists(t, FileExists)
}
func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) {
file, err := os.CreateTemp("", t.Name())
assert.NoError(t, err, "CreateTemp should not fail")
t.Cleanup(func() {
os.RemoveAll(file.Name())
})
exists, err := checker(file.Name())
assert.NoError(t, err, "FileExists should not fail")
assert.True(t, exists)
}
func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) {
filePath := path.Join(os.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
err := os.RemoveAll(filePath)
assert.NoError(t, err, "RemoveAll should not fail")
exists, err := checker(filePath)
assert.NoError(t, err, "FileExists should not fail")
assert.False(t, exists)
}

View File

@@ -0,0 +1,49 @@
package filesystem
import (
"fmt"
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
// temporary function until upgrade to 1.16
func tempDir(t *testing.T) string {
tmpDir, err := os.MkdirTemp("", "dir")
assert.NoError(t, err, "MkdirTemp should not fail")
return tmpDir
}
func Test_movePath_shouldFailIfOriginalPathDoesntExist(t *testing.T) {
tmpDir := tempDir(t)
missingPath := path.Join(tmpDir, "missing")
targetPath := path.Join(tmpDir, "target")
defer os.RemoveAll(tmpDir)
err := MoveDirectory(missingPath, targetPath)
assert.Error(t, err, "move directory should fail when target path exists")
}
func Test_movePath_shouldFailIfTargetPathDoesExist(t *testing.T) {
originalPath := tempDir(t)
missingPath := tempDir(t)
defer os.RemoveAll(originalPath)
defer os.RemoveAll(missingPath)
err := MoveDirectory(originalPath, missingPath)
assert.Error(t, err, "move directory should fail when target path exists")
}
func Test_movePath_success(t *testing.T) {
originalPath := tempDir(t)
defer os.RemoveAll(originalPath)
err := MoveDirectory(originalPath, fmt.Sprintf("%s-old", originalPath))
assert.NoError(t, err)
}

View File

@@ -0,0 +1,22 @@
package filesystem
import (
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
func createService(t *testing.T) *Service {
dataStorePath := path.Join(os.TempDir(), t.Name())
service, err := NewService(dataStorePath, "")
assert.NoError(t, err, "NewService should not fail")
t.Cleanup(func() {
os.RemoveAll(dataStorePath)
})
return service
}

293
api/git/azure.go Normal file
View File

@@ -0,0 +1,293 @@
package git
import (
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/archive"
)
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 (a *azureDownloader) latestCommitID(ctx context.Context, options fetchOptions) (string, error) {
config, err := parseUrl(options.repositoryUrl)
if err != nil {
return "", errors.WithMessage(err, "failed to parse url")
}
refsUrl, err := a.buildRefsUrl(config, options.referenceName)
if err != nil {
return "", errors.WithMessage(err, "failed to build azure refs url")
}
req, err := http.NewRequestWithContext(ctx, "GET", refsUrl, 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")
}
resp, err := a.client.Do(req)
if err != nil {
return "", errors.WithMessage(err, "failed to make an HTTP request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get repository refs with a status \"%v\"", resp.Status)
}
var refs struct {
Value []struct {
Name string `json:"name"`
ObjectId string `json:"objectId"`
}
}
if err := json.NewDecoder(resp.Body).Decode(&refs); err != nil {
return "", errors.Wrap(err, "could not parse Azure Refs response")
}
for _, ref := range refs.Value {
if strings.EqualFold(ref.Name, options.referenceName) {
return ref.ObjectId, nil
}
}
return "", errors.Errorf("could not find ref %q in the repository", options.referenceName)
}
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
}
func (a *azureDownloader) buildRefsUrl(config *azureOptions, referenceName string) (string, error) {
rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs",
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 refs url path %s", rawUrl)
}
// filterContains=main&api-version=6.0
q := u.Query()
q.Set("filterContains", formatReferenceName(referenceName))
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"
}

View File

@@ -0,0 +1,105 @@
package git
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/docker/docker/pkg/ioutils"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert"
)
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.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "")
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.CloneRepository(dst, repositoryUrl, "refs/heads/main", "", pat)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
}
func TestService_LatestCommitID_Azure(t *testing.T) {
ensureIntegrationTest(t)
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
service := NewService()
repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration"
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", "", pat)
assert.NoError(t, err)
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
}
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")
}
}

375
api/git/azure_test.go Normal file
View File

@@ -0,0 +1,375 @@
package git
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
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_buildRefsUrl(t *testing.T) {
a := NewAzureDownloader(nil)
u, err := a.buildRefsUrl(&azureOptions{
organisation: "organisation",
project: "project",
repository: "repository",
}, "refs/heads/main")
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/refs?filterContains=main&api-version=6.0")
actualUrl, _ := url.Parse(u)
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)
})
}
}
func Test_azureDownloader_latestCommitID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := `{
"value": [
{
"name": "refs/heads/feature/calcApp",
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2FcalcApp"
},
{
"name": "refs/heads/feature/replacer",
"objectId": "917131a709996c5cfe188c3b57e9a6ad90e8b85c",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2Freplacer"
},
{
"name": "refs/heads/master",
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Fmaster"
}
],
"count": 3
}`
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
}))
defer server.Close()
a := &azureDownloader{
client: server.Client(),
baseUrl: server.URL,
}
tests := []struct {
name string
args fetchOptions
want string
wantErr bool
}{
{
name: "should be able to parse response",
args: fetchOptions{
referenceName: "refs/heads/master",
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
want: "ffe9cba521f00d7f60e322845072238635edb451",
wantErr: false,
},
{
name: "should be able to parse response",
args: fetchOptions{
referenceName: "refs/heads/unknown",
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := a.latestCommitID(context.Background(), tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(t, tt.want, id)
})
}
}

View File

@@ -1,21 +1,115 @@
package git
import (
"context"
"crypto/tls"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"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/pkg/errors"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"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"
"github.com/go-git/go-git/v5/storage/memory"
)
type fetchOptions struct {
repositoryUrl string
username string
password string
referenceName string
}
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
latestCommitID(ctx context.Context, opt fetchOptions) (string, 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,
Auth: getAuth(opt.username, 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
}
func (c gitClient) latestCommitID(ctx context.Context, opt fetchOptions) (string, error) {
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin",
URLs: []string{opt.repositoryUrl},
})
listOptions := &git.ListOptions{
Auth: getAuth(opt.username, opt.password),
}
refs, err := remote.List(listOptions)
if err != nil {
return "", errors.Wrap(err, "failed to list repository refs")
}
for _, ref := range refs {
if strings.EqualFold(ref.Name().String(), opt.referenceName) {
return ref.Hash().String(), nil
}
}
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
}
func getAuth(username, password string) *githttp.BasicAuth {
if password != "" {
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
}
return nil
}
// Service represents a service for managing Git.
type Service struct {
httpsCli *http.Client
azure downloader
git downloader
}
// NewService initializes a new service.
@@ -23,6 +117,7 @@ func NewService() *Service {
httpsCli := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
},
Timeout: 300 * time.Second,
}
@@ -31,32 +126,45 @@ 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
// CloneRepository clones a 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)
}
// 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)
}
func cloneRepository(repositoryURL, referenceName, destination string) error {
options := &git.CloneOptions{
URL: repositoryURL,
func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string) error {
options := cloneOptions{
repositoryUrl: repositoryURL,
username: username,
password: password,
referenceName: referenceName,
depth: 1,
}
if referenceName != "" {
options.ReferenceName = plumbing.ReferenceName(referenceName)
return service.cloneRepository(destination, options)
}
func (service *Service) cloneRepository(destination string, options cloneOptions) error {
if isAzureUrl(options.repositoryUrl) {
return service.azure.download(context.TODO(), destination, options)
}
_, err := git.PlainClone(destination, false, options)
return err
return service.git.download(context.TODO(), destination, options)
}
// LatestCommitID returns SHA1 of the latest commit of the specified reference
func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
options := fetchOptions{
repositoryUrl: repositoryURL,
username: username,
password: password,
referenceName: referenceName,
}
if isAzureUrl(options.repositoryUrl) {
return service.azure.latestCommitID(context.TODO(), options)
}
return service.git.latestCommitID(context.TODO(), options)
}

View File

@@ -0,0 +1,40 @@
package git
import (
"os"
"path/filepath"
"testing"
"github.com/docker/docker/pkg/ioutils"
"github.com/stretchr/testify/assert"
)
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
ensureIntegrationTest(t)
accessToken := 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.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
}
func TestService_LatestCommitID_GitHub(t *testing.T) {
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := NewService()
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken)
assert.NoError(t, err)
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
}

192
api/git/git_test.go Normal file
View File

@@ -0,0 +1,192 @@
package git
import (
"context"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
"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"
)
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.CloneRepository(dir, repositoryURL, referenceName, "", "")
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.CloneRepository(dir, repositoryURL, referenceName, "", "")
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, 4, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
}
func Test_latestCommitID(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"
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "")
assert.NoError(t, err)
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
}
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 (t *testDownloader) latestCommitID(_ context.Context, _ fetchOptions) (string, error) {
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 copy.zip vendored Normal file

Binary file not shown.

BIN
api/git/testdata/azure-repo.zip vendored Normal file

Binary file not shown.

Binary file not shown.

20
api/git/types/types.go Normal file
View File

@@ -0,0 +1,20 @@
package gittypes
// RepoConfig represents a configuration for a repo
type RepoConfig struct {
// The repo url
URL string `example:"https://github.com/portainer/portainer.git"`
// The reference name
ReferenceName string `example:"refs/heads/branch_name"`
// Path to where the config file is in this url/refName
ConfigFilePath string `example:"docker-compose.yml"`
// Git credentials
Authentication *GitAuthentication
// Repository hash
ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"`
}
type GitAuthentication struct {
Username string
Password string
}

View File

@@ -1,9 +1,9 @@
module github.com/portainer/portainer/api
go 1.13
go 1.16
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/boltdb/bolt v1.3.1
github.com/containerd/containerd v1.3.1 // indirect
@@ -13,12 +13,13 @@ require (
github.com/docker/cli v0.0.0-20191126203649-54d085b857e9
github.com/docker/docker v0.0.0-00010101000000-000000000000
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/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
@@ -26,18 +27,22 @@ require (
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-20210810234209-d01bc85eb481
github.com/portainer/libcompose v0.5.3
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
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/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0
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
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
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

View File

@@ -11,10 +11,10 @@ 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/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=
@@ -47,7 +47,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 +80,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 +93,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 +115,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=
@@ -145,13 +154,16 @@ 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/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=
@@ -168,22 +180,22 @@ github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46O
github.com/json-iterator/go v1.1.8/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=
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA=
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 +217,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,20 +233,20 @@ 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-20210810234209-d01bc85eb481 h1:5c8N9Gh21Ja/9EIpfyHFmQvTCKgOjnRhosmo0ZshkFk=
github.com/portainer/docker-compose-wrapper v0.0.0-20210810234209-d01bc85eb481/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
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/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -248,34 +263,31 @@ 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=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
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/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=
@@ -288,10 +300,9 @@ golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnf
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-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=
@@ -308,12 +319,10 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
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-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-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 +333,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 +354,11 @@ 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/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,25 +369,24 @@ 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=
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg=
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.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 +405,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=

View File

@@ -130,19 +130,13 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
}
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)
return handler.persistAndWriteToken(w, composeTokenData(user))
}
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
token, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err}
}
return response.JSON(w, &authenticateResponse{JWT: token})
@@ -204,3 +198,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,
}
}

View File

@@ -25,17 +25,6 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil
}
// @id AuthenticateOauth
// @summary Authenticate with OAuth
// @tags auth
// @accept json
// @produce json
// @param body body oauthPayload true "OAuth Credentials used for authentication"
// @success 200 {object} authenticateResponse "Success"
// @failure 400 "Invalid request"
// @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")
@@ -53,35 +42,46 @@ func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuth
return username, nil
}
// @id ValidateOAuth
// @summary Authenticate with OAuth
// @tags auth
// @accept json
// @produce json
// @param body body oauthPayload true "OAuth Credentials used for authentication"
// @success 200 {object} authenticateResponse "Success"
// @failure 400 "Invalid request"
// @failure 422 "Invalid Credentials"
// @failure 500 "Server error"
// @router /auth/oauth/validate [post]
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 &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 &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")}
if settings.AuthenticationMethod != portainer.AuthenticationOAuth {
return &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)
if err != nil {
log.Printf("[DEBUG] - OAuth authentication error: %s", err)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to authenticate through OAuth", Err: httperrors.ErrUnauthorized}
}
user, err := handler.DataStore.User().UserByUsername(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 &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}
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Account not created beforehand in Portainer and automatic user provisioning not enabled", Err: httperrors.ErrUnauthorized}
}
if user == nil {
@@ -92,7 +92,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
err = handler.DataStore.User().CreateUser(user)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist user inside the database", Err: err}
}
if settings.OAuthSettings.DefaultTeamID != 0 {
@@ -104,7 +104,7 @@ 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 &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist team membership inside the database", Err: err}
}
}

View File

@@ -236,16 +236,14 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID))
customTemplate.ProjectPath = projectPath
gitCloneParams := &cloneRepositoryParameters{
url: payload.RepositoryURL,
referenceName: payload.RepositoryReferenceName,
path: projectPath,
authentication: payload.RepositoryAuthentication,
username: payload.RepositoryUsername,
password: payload.RepositoryPassword,
repositoryUsername := payload.RepositoryUsername
repositoryPassword := payload.RepositoryPassword
if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err = handler.cloneGitRepository(gitCloneParams)
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return nil, err
}

View File

@@ -1,17 +0,0 @@
package customtemplates
type cloneRepositoryParameters struct {
url string
referenceName string
path string
authentication bool
username string
password string
}
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
if parameters.authentication {
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
}
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
}

View File

@@ -1,28 +0,0 @@
package dockerhub
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
)
// @id DockerHubInspect
// @summary Retrieve DockerHub information
// @description Use this endpoint to retrieve the information used to connect to the DockerHub
// @description **Access policy**: authenticated
// @tags dockerhub
// @security jwt
// @produce json
// @success 200 {object} portainer.DockerHub
// @failure 500 "Server error"
// @router /dockerhub [get]
func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
}
hideFields(dockerhub)
return response.JSON(w, dockerhub)
}

View File

@@ -1,68 +0,0 @@
package dockerhub
import (
"errors"
"net/http"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type dockerhubUpdatePayload struct {
// Enable authentication against DockerHub
Authentication bool `validate:"required" example:"false"`
// Username used to authenticate against the DockerHub
Username string `validate:"required" example:"hub_user"`
// Password used to authenticate against the DockerHub
Password string `validate:"required" example:"hub_password"`
}
func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error {
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
}
return nil
}
// @id DockerHubUpdate
// @summary Update DockerHub information
// @description Use this endpoint to update the information used to connect to the DockerHub
// @description **Access policy**: administrator
// @tags dockerhub
// @security jwt
// @accept json
// @produce json
// @param body body dockerhubUpdatePayload true "DockerHub information"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /dockerhub [put]
func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload dockerhubUpdatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
dockerhub := &portainer.DockerHub{
Authentication: false,
Username: "",
Password: "",
}
if payload.Authentication {
dockerhub.Authentication = true
dockerhub.Username = payload.Username
dockerhub.Password = payload.Password
}
err = handler.DataStore.DockerHub().UpdateDockerHub(dockerhub)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err}
}
return response.Empty(w)
}

View File

@@ -1,33 +0,0 @@
package dockerhub
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
)
func hideFields(dockerHub *portainer.DockerHub) {
dockerHub.Password = ""
}
// Handler is the HTTP handler used to handle DockerHub operations.
type Handler struct {
*mux.Router
DataStore portainer.DataStore
}
// NewHandler creates a handler to manage Dockerhub operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/dockerhub",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet)
h.Handle("/dockerhub",
bouncer.AdminAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut)
return h
}

View File

@@ -212,16 +212,14 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
stack.ProjectPath = projectPath
gitCloneParams := &cloneRepositoryParameters{
url: payload.RepositoryURL,
referenceName: payload.RepositoryReferenceName,
path: projectPath,
authentication: payload.RepositoryAuthentication,
username: payload.RepositoryUsername,
password: payload.RepositoryPassword,
repositoryUsername := payload.RepositoryUsername
repositoryPassword := payload.RepositoryPassword
if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err = handler.cloneGitRepository(gitCloneParams)
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return nil, err
}

View File

@@ -1,17 +0,0 @@
package edgestacks
type cloneRepositoryParameters struct {
url string
referenceName string
path string
authentication bool
username string
password string
}
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
if parameters.authentication {
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
}
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
}

View File

@@ -109,12 +109,33 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque
}
}
updateAuthorizations := false
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) {
endpointGroup.UserAccessPolicies = payload.UserAccessPolicies
updateAuthorizations = true
}
if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) {
endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies
updateAuthorizations = true
}
if updateAuthorizations {
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
}
for _, endpoint := range endpoints {
if endpoint.GroupID == endpointGroup.ID {
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err = handler.AuthorizationService.CleanNAPWithOverridePolicies(&endpoint, endpointGroup)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err}
}
}
}
}
}
err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup)

View File

@@ -1,6 +1,7 @@
package endpointgroups
import (
"github.com/portainer/portainer/api/internal/authorization"
"net/http"
"github.com/gorilla/mux"
@@ -12,6 +13,7 @@ import (
// Handler is the HTTP handler used to handle endpoint group operations.
type Handler struct {
*mux.Router
AuthorizationService *authorization.Service
DataStore portainer.DataStore
}

View File

@@ -29,6 +29,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI)))
h.PathPrefix("/{id}/kubernetes").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI)))
h.PathPrefix("/{id}/agent/docker").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI)))
h.PathPrefix("/{id}/agent/kubernetes").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI)))
h.PathPrefix("/{id}/storidge").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI)))
return h

View File

@@ -3,6 +3,7 @@ package endpointproxy
import (
"errors"
"strconv"
"strings"
"time"
httperror "github.com/portainer/libhttp/error"
@@ -65,6 +66,12 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
}
id := strconv.Itoa(endpointID)
http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r)
prefix := "/" + id + "/agent/docker";
if !strings.HasPrefix(r.URL.Path, prefix) {
prefix = "/" + id + "/docker";
}
http.StripPrefix(prefix, proxy).ServeHTTP(w, r)
return nil
}

View File

@@ -65,17 +65,18 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
}
}
// For KubernetesLocalEnvironment
requestPrefix := fmt.Sprintf("/%d/kubernetes", endpointID)
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
if isKubernetesRequest(strings.TrimPrefix(r.URL.String(), requestPrefix)) {
requestPrefix = fmt.Sprintf("/%d", endpointID)
requestPrefix = fmt.Sprintf("/%d", endpointID)
agentPrefix := fmt.Sprintf("/%d/agent/kubernetes", endpointID)
if strings.HasPrefix(r.URL.Path, agentPrefix) {
requestPrefix = agentPrefix
}
}
http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r)
return nil
}
func isKubernetesRequest(requestURL string) bool {
return strings.HasPrefix(requestURL, "/api") || strings.HasPrefix(requestURL, "/healthz")
}
}

View File

@@ -0,0 +1,88 @@
package endpoints
import (
"encoding/base64"
"errors"
"fmt"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"net/http"
"regexp"
"strings"
)
// @id EndpointAssociationDelete
// @summary De-association an edge endpoint
// @description De-association an edge endpoint.
// @description **Access policy**: administrator
// @security jwt
// @tags endpoints
// @produce json
// @param id path int true "Endpoint identifier"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 404 "Endpoint not found"
// @failure 500 "Server error"
// @router /api/endpoints/:id/association [put]
func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint type", errors.New("Invalid endpoint type")}
}
endpoint.EdgeID = ""
endpoint.Snapshots = []portainer.DockerSnapshot{}
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
endpoint.EdgeKey, err = handler.updateEdgeKey(endpoint.EdgeKey)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Invalid EdgeKey", err}
}
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting endpoint in database", err}
}
handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID)
return response.JSON(w, endpoint)
}
func (handler *Handler) updateEdgeKey(edgeKey string) (string, error) {
oldEdgeKeyByte, err := base64.RawStdEncoding.DecodeString(edgeKey)
if err != nil {
return "", err
}
oldEdgeKeyStr := string(oldEdgeKeyByte)
httpPort := getPort(handler.BindAddress)
httpsPort := getPort(handler.BindAddressHTTPS)
// replace "http://" with "https://" and replace ":9000" with ":9443", in the case of default values
// oldEdgeKeyStr example: http://10.116.1.178:9000|10.116.1.178:8000|46:99:4a:8d:a6:de:6a:bd:d8:e2:1c:99:81:60:54:55|52
r := regexp.MustCompile(fmt.Sprintf("^(http://)([^|]+)(:%s)(|.*)", httpPort))
newEdgeKeyStr := r.ReplaceAllString(oldEdgeKeyStr, fmt.Sprintf("https://$2:%s$4", httpsPort))
return base64.RawStdEncoding.EncodeToString([]byte(newEdgeKeyStr)), nil
}
func getPort(url string) string {
items := strings.Split(url, ":")
return items[len(items) - 1]
}

View File

@@ -156,7 +156,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
// @accept multipart/form-data
// @produce json
// @param Name formData string true "Name that will be used to identify this endpoint (example: my-endpoint)"
// @param EndpointType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5)
// @param EndpointCreationType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5)
// @param URL formData string false "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)"
// @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)"
// @param GroupID formData int false "Endpoint group identifier. If not specified will default to 1 (unassigned)."
@@ -322,8 +322,8 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Extensions: []portainer.EndpointExtension{},
TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
@@ -471,6 +471,7 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,

View File

@@ -103,6 +103,22 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for idx := range registries {
registry := &registries[idx]
if _, ok := registry.RegistryAccesses[endpoint.ID]; ok {
delete(registry.RegistryAccesses, endpoint.ID)
err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update registry accesses", Err: err}
}
}
}
return response.Empty(w)
}

View File

@@ -22,7 +22,7 @@ type dockerhubStatusResponse struct {
Limit int `json:"limit"`
}
// GET request on /api/endpoints/{id}/dockerhub/status
// GET request on /api/endpoints/{id}/dockerhub/{registryId}
func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
@@ -40,13 +40,30 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment type", errors.New("Invalid environment type")}
}
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
var registry *portainer.Registry
if registryID == 0 {
registry = &portainer.Registry{}
} else {
registry, err = handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
}
if registry.Type != portainer.DockerHubRegistry {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry type", errors.New("Invalid registry type")}
}
}
httpClient := client.NewHTTPClient()
token, err := getDockerHubToken(httpClient, dockerhub)
token, err := getDockerHubToken(httpClient, registry)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub token from DockerHub", err}
}
@@ -59,7 +76,7 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
return response.JSON(w, resp)
}
func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.DockerHub) (string, error) {
func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Registry) (string, error) {
type dockerhubTokenResponse struct {
Token string `json:"token"`
}
@@ -71,8 +88,8 @@ func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.Docke
return "", err
}
if dockerhub.Authentication {
req.SetBasicAuth(dockerhub.Username, dockerhub.Password)
if registry.Authentication {
req.SetBasicAuth(registry.Username, registry.Password)
}
resp, err := httpClient.Do(req)
@@ -115,8 +132,18 @@ func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhub
return nil, errors.New("failed fetching dockerhub limits")
}
// An error with rateLimit-Limit or RateLimit-Remaining is likely for dockerhub pro accounts where there is no rate limit.
// In that specific case the headers will not be present. Don't bubble up the error as its normal
// See: https://docs.docker.com/docker-hub/download-rate-limit/
rateLimit, err := parseRateLimitHeader(resp.Header, "RateLimit-Limit")
if err != nil {
return nil, nil
}
rateLimitRemaining, err := parseRateLimitHeader(resp.Header, "RateLimit-Remaining")
if err != nil {
return nil, nil
}
return &dockerhubStatusResponse{
Limit: rateLimit,

View File

@@ -26,7 +26,7 @@ import (
// @param search query string false "Search query"
// @param groupId query int false "List endpoints of this group"
// @param limit query int false "Limit results to this value"
// @param type query int false "List endpoints of this type"
// @param types query []int false "List endpoints of this type"
// @param tagIds query []int false "search endpoints with these tags (depends on tagsPartialMatch)"
// @param tagsPartialMatch query bool false "If true, will return endpoint which has one of tagIds, if false (or missing) will return only endpoints that has all the tags"
// @param endpointIds query []int false "will return only these endpoints"
@@ -46,7 +46,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
endpointType, _ := request.RetrieveNumericQueryParameter(r, "type", true)
var endpointTypes []int
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
var tagIDs []portainer.TagID
request.RetrieveJSONQueryParameter(r, "tagIds", &tagIDs, true)
@@ -98,8 +100,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, tagsMap, search)
}
if endpointType != 0 {
filteredEndpoints = filterEndpointsByType(filteredEndpoints, portainer.EndpointType(endpointType))
if endpointTypes != nil {
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, endpointTypes)
}
if tagIDs != nil {
@@ -212,11 +214,16 @@ func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGrou
return false
}
func filterEndpointsByType(endpoints []portainer.Endpoint, endpointType portainer.EndpointType) []portainer.Endpoint {
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
typeSet := map[portainer.EndpointType]bool{}
for _, endpointType := range endpointTypes {
typeSet[portainer.EndpointType(endpointType)] = true
}
for _, endpoint := range endpoints {
if endpoint.Type == endpointType {
if typeSet[endpoint.Type] {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}

View File

@@ -0,0 +1,50 @@
package endpoints
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
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/http/security"
)
// GET request on /endpoints/{id}/registries/{registryId}
func (handler *Handler) endpointRegistryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a registry with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a registry with the specified identifier inside the database", Err: err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user from the database", Err: err}
}
if !security.AuthorizedRegistryAccess(registry, user, securityContext.UserMemberships, portainer.EndpointID(endpointID)) {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
}
hideRegistryFields(registry, !securityContext.IsAdmin)
return response.JSON(w, registry)
}

View File

@@ -0,0 +1,130 @@
package endpoints
import (
"net/http"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"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/security"
"github.com/portainer/portainer/api/internal/endpointutils"
)
// GET request on /endpoints/{id}/registries?namespace
func (handler *Handler) endpointRegistriesList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user from the database", err}
}
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
isAdmin := securityContext.IsAdmin
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
if endpointutils.IsKubernetesEndpoint(endpoint) {
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
if namespace == "" && !isAdmin {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Missing namespace query parameter", Err: errors.New("missing namespace query parameter")}
}
if namespace != "" {
authorized, err := handler.isNamespaceAuthorized(endpoint, namespace, user.ID, securityContext.UserMemberships, isAdmin)
if err != nil {
return &httperror.HandlerError{http.StatusNotFound, "Unable to check for namespace authorization", err}
}
if !authorized {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized to use namespace", Err: errors.New("user is not authorized to use namespace")}
}
registries = filterRegistriesByNamespace(registries, endpoint.ID, namespace)
}
} else if !isAdmin {
registries = security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
}
for idx := range registries {
hideRegistryFields(&registries[idx], !isAdmin)
}
return response.JSON(w, registries)
}
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
if isAdmin || namespace == "" {
return true, nil
}
if namespace == "default" {
return true, nil
}
kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return false, errors.Wrap(err, "unable to retrieve kubernetes client")
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, errors.Wrap(err, "unable to retrieve endpoint's namespaces policies")
}
namespacePolicy, ok := accessPolicies[namespace]
if !ok {
return false, nil
}
return security.AuthorizedAccess(userId, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies), nil
}
func filterRegistriesByNamespace(registries []portainer.Registry, endpointId portainer.EndpointID, namespace string) []portainer.Registry {
if namespace == "" {
return registries
}
filteredRegistries := []portainer.Registry{}
for _, registry := range registries {
for _, authorizedNamespace := range registry.RegistryAccesses[endpointId].Namespaces {
if authorizedNamespace == namespace {
filteredRegistries = append(filteredRegistries, registry)
}
}
}
return filteredRegistries
}
func hideRegistryFields(registry *portainer.Registry, hideAccesses bool) {
registry.Password = ""
registry.ManagementConfiguration = nil
if hideAccesses {
registry.RegistryAccesses = nil
}
}

View File

@@ -0,0 +1,149 @@
package endpoints
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"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/security"
)
type registryAccessPayload struct {
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
Namespaces []string
}
func (payload *registryAccessPayload) Validate(r *http.Request) error {
return nil
}
// PUT request on /endpoints/{id}/registries/{registryId}
func (handler *Handler) endpointRegistryAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized", Err: err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
var payload registryAccessPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
if registry.RegistryAccesses == nil {
registry.RegistryAccesses = portainer.RegistryAccesses{}
}
if _, ok := registry.RegistryAccesses[endpoint.ID]; !ok {
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{}
}
registryAccess := registry.RegistryAccesses[endpoint.ID]
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err := handler.updateKubeAccess(endpoint, registry, registryAccess.Namespaces, payload.Namespaces)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update kube access policies", Err: err}
}
registryAccess.Namespaces = payload.Namespaces
} else {
registryAccess.UserAccessPolicies = payload.UserAccessPolicies
registryAccess.TeamAccessPolicies = payload.TeamAccessPolicies
}
registry.RegistryAccesses[portainer.EndpointID(endpointID)] = registryAccess
handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
return response.Empty(w)
}
func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error {
oldNamespacesSet := toSet(oldNamespaces)
newNamespacesSet := toSet(newNamespaces)
namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet)
namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet)
cli, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return err
}
for namespace := range namespacesToRemove {
err := cli.DeleteRegistrySecret(registry, namespace)
if err != nil {
return err
}
}
for namespace := range namespacesToAdd {
err := cli.CreateRegistrySecret(registry, namespace)
if err != nil {
return err
}
}
return nil
}
type stringSet map[string]bool
func toSet(list []string) stringSet {
set := stringSet{}
for _, el := range list {
set[el] = true
}
return set
}
// setDifference returns the set difference tagsA - tagsB
func setDifference(setA stringSet, setB stringSet) stringSet {
set := stringSet{}
for el := range setA {
if !setB[el] {
set[el] = true
}
}
return set
}

View File

@@ -151,15 +151,24 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
}
}
updateAuthorizations := false
if payload.Kubernetes != nil {
if payload.Kubernetes.Configuration.RestrictDefaultNamespace !=
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
updateAuthorizations = true
}
endpoint.Kubernetes = *payload.Kubernetes
}
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) {
updateAuthorizations = true
endpoint.UserAccessPolicies = payload.UserAccessPolicies
}
if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) {
updateAuthorizations = true
endpoint.TeamAccessPolicies = payload.TeamAccessPolicies
}
@@ -252,6 +261,15 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
}
}
if updateAuthorizations {
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err = handler.AuthorizationService.CleanNAPWithOverridePolicies(endpoint, nil)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err}
}
}
}
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}

View File

@@ -5,6 +5,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/kubernetes/cli"
"net/http"
@@ -27,7 +29,11 @@ type Handler struct {
ProxyManager *proxy.Manager
ReverseTunnelService portainer.ReverseTunnelService
SnapshotService portainer.SnapshotService
K8sClientFactory *cli.ClientFactory
ComposeStackManager portainer.ComposeStackManager
AuthorizationService *authorization.Service
BindAddress string
BindAddressHTTPS string
}
// NewHandler creates a handler to manage endpoint operations.
@@ -41,6 +47,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/settings",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSettingsUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}/association",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointAssociationDelete))).Methods(http.MethodDelete)
h.Handle("/endpoints/snapshot",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost)
h.Handle("/endpoints",
@@ -51,7 +59,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/dockerhub",
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/extensions",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
@@ -61,5 +69,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/registries",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistriesList))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/registries/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryInspect))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/registries/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryAccess))).Methods(http.MethodPut)
return h
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/portainer/portainer/api/http/handler/auth"
"github.com/portainer/portainer/api/http/handler/backup"
"github.com/portainer/portainer/api/http/handler/customtemplates"
"github.com/portainer/portainer/api/http/handler/dockerhub"
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
@@ -17,11 +16,13 @@ import (
"github.com/portainer/portainer/api/http/handler/endpointproxy"
"github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
"github.com/portainer/portainer/api/http/handler/roles"
"github.com/portainer/portainer/api/http/handler/settings"
"github.com/portainer/portainer/api/http/handler/ssl"
"github.com/portainer/portainer/api/http/handler/stacks"
"github.com/portainer/portainer/api/http/handler/status"
"github.com/portainer/portainer/api/http/handler/tags"
@@ -39,7 +40,6 @@ type Handler struct {
AuthHandler *auth.Handler
BackupHandler *backup.Handler
CustomTemplatesHandler *customtemplates.Handler
DockerHubHandler *dockerhub.Handler
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler
@@ -48,12 +48,14 @@ type Handler struct {
EndpointGroupHandler *endpointgroups.Handler
EndpointHandler *endpoints.Handler
EndpointProxyHandler *endpointproxy.Handler
KubernetesHandler *kubernetes.Handler
FileHandler *file.Handler
MOTDHandler *motd.Handler
RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler
RoleHandler *roles.Handler
SettingsHandler *settings.Handler
SSLHandler *ssl.Handler
StackHandler *stacks.Handler
StatusHandler *status.Handler
TagHandler *tags.Handler
@@ -88,8 +90,6 @@ type Handler struct {
// @tag.description Authenticate against Portainer HTTP API
// @tag.name custom_templates
// @tag.description Manage Custom Templates
// @tag.name dockerhub
// @tag.description Manage how Portainer connects to the DockerHub
// @tag.name edge_groups
// @tag.description Manage Edge Groups
// @tag.name edge_jobs
@@ -104,6 +104,8 @@ type Handler struct {
// @tag.description Manage Docker environments
// @tag.name endpoint_groups
// @tag.description Manage endpoint groups
// @tag.name kubernetes
// @tag.description Manage Kubernetes cluster
// @tag.name motd
// @tag.description Fetch the message of the day
// @tag.name registries
@@ -130,6 +132,8 @@ type Handler struct {
// @tag.description Manage App Templates
// @tag.name stacks
// @tag.description Manage stacks
// @tag.name ssl
// @tag.description Manage ssl settings
// @tag.name upload
// @tag.description Upload files
// @tag.name webhooks
@@ -146,8 +150,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/restore"):
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"):
@@ -162,6 +164,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
switch {
case strings.Contains(r.URL.Path, "/docker/"):
@@ -172,6 +176,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
case strings.Contains(r.URL.Path, "/azure/"):
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
case strings.Contains(r.URL.Path, "/agent/"):
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
case strings.Contains(r.URL.Path, "/edge/"):
http.StripPrefix("/api/endpoints", h.EndpointEdgeHandler).ServeHTTP(w, r)
default:
@@ -199,6 +205,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/users"):
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/ssl"):
http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/teams"):
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):

View File

@@ -0,0 +1,68 @@
package kubernetes
import (
"errors"
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
)
// Handler is the HTTP handler which will natively deal with to external endpoints.
type Handler struct {
*mux.Router
dataStore portainer.DataStore
kubernetesClientFactory *cli.ClientFactory
authorizationService *authorization.Service
}
// NewHandler creates a handler to process pre-proxied requests to external APIs.
func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore portainer.DataStore, kubernetesClientFactory *cli.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
dataStore: dataStore,
kubernetesClientFactory: kubernetesClientFactory,
authorizationService: authorizationService,
}
kubeRouter := h.PathPrefix("/kubernetes/{id}").Subrouter()
kubeRouter.Use(bouncer.AuthenticatedAccess)
kubeRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
kubeRouter.Use(kubeOnlyMiddleware)
kubeRouter.PathPrefix("/config").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet)
// namespaces
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
// to keep it simple, we've decided to leave it like this.
namespaceRouter := kubeRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
return h
}
func kubeOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
endpoint, err := middlewares.FetchEndpoint(request)
if err != nil {
httperror.WriteError(rw, http.StatusInternalServerError, "Unable to find an endpoint on request context", err)
return
}
if !endpointutils.IsKubernetesEndpoint(endpoint) {
errMessage := "Endpoint is not a kubernetes endpoint"
httperror.WriteError(rw, http.StatusBadRequest, errMessage, errors.New(errMessage))
return
}
next.ServeHTTP(rw, request)
})
}

View File

@@ -0,0 +1,126 @@
package kubernetes
import (
"errors"
"fmt"
"strings"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
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/http/security"
kcli "github.com/portainer/portainer/api/kubernetes/cli"
"net/http"
)
// @id GetKubernetesConfig
// @summary Generates kubeconfig file enabling client communication with k8s api server
// @description Generates kubeconfig file enabling client communication with k8s api server
// @description **Access policy**: authorized
// @tags kubernetes
// @security jwt
// @accept json
// @produce json
// @param id path int true "Endpoint identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 401 "Unauthorized"
// @failure 403 "Permission denied"
// @failure 404 "Endpoint or ServiceAccount not found"
// @failure 500 "Server error"
// @router /kubernetes/{id}/config [get]
func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
}
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
bearerToken, err := extractBearerToken(r)
if err != nil {
return &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err}
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
}
apiServerURL := getProxyUrl(r, endpointID)
config, err := cli.GetKubeConfig(r.Context(), apiServerURL, bearerToken, tokenData)
if err != nil {
return &httperror.HandlerError{http.StatusNotFound, "Unable to generate Kubeconfig", err}
}
filenameBase := fmt.Sprintf("%s-%s", tokenData.Username, endpoint.Name)
contentAcceptHeader := r.Header.Get("Accept")
if contentAcceptHeader == "text/yaml" {
yaml, err := kcli.GenerateYAML(config)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed to generate Kubeconfig", err}
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.yaml", filenameBase))
return YAML(w, yaml)
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase))
return response.JSON(w, config)
}
// extractBearerToken extracts user's portainer bearer token from request auth header
func extractBearerToken(r *http.Request) (string, error) {
token := ""
tokens := r.Header["Authorization"]
if len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
}
if token == "" {
return "", httperrors.ErrUnauthorized
}
return token, nil
}
// getProxyUrl generates portainer proxy url which acts as proxy to k8s api server
func getProxyUrl(r *http.Request, endpointID int) string {
return fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpointID)
}
// YAML writes yaml response as string to writer. Returns a pointer to a HandlerError if encoding fails.
// This could be moved to a more useful place; but that place is most likely not in this project.
// It should actually go in https://github.com/portainer/libhttp - since that is from where we use response.JSON.
// We use `data interface{}` as parameter - since im trying to keep it as close to (or the same as) response.JSON method signature:
// https://github.com/portainer/libhttp/blob/d20481a3da823c619887c440a22fdf4fa8f318f2/response/response.go#L13
func YAML(rw http.ResponseWriter, data interface{}) *httperror.HandlerError {
rw.Header().Set("Content-Type", "text/yaml")
strData, ok := data.(string)
if !ok {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to write YAML response",
Err: errors.New("failed to convert input to string"),
}
}
fmt.Fprint(rw, strData)
return nil
}

View File

@@ -0,0 +1,65 @@
package kubernetes
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/http/middlewares"
)
type namespacesToggleSystemPayload struct {
// Toggle the system state of this namespace to true or false
System bool `example:"true"`
}
func (payload *namespacesToggleSystemPayload) Validate(r *http.Request) error {
return nil
}
// @id KubernetesNamespacesToggleSystem
// @summary Toggle the system state for a namespace
// @description Toggle the system state for a namespace
// @description **Access policy**: administrator or endpoint admin
// @security jwt
// @tags kubernetes
// @accept json
// @param id path int true "Endpoint identifier"
// @param namespace path string true "Namespace name"
// @param body body namespacesToggleSystemPayload true "Update details"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 404 "Endpoint not found"
// @failure 500 "Server error"
// @router /kubernetes/{id}/namespaces/{namespace}/system [put]
func (handler *Handler) namespacesToggleSystem(rw http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint on request context", err}
}
namespaceName, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid namespace identifier route variable", err}
}
var payload namespacesToggleSystemPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
kubeClient, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create kubernetes client", err}
}
err = kubeClient.ToggleSystemState(namespaceName, payload.System)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to toggle system status", err}
}
return response.Empty(rw)
}

View File

@@ -5,32 +5,46 @@ 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/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
)
func hideFields(registry *portainer.Registry) {
func hideFields(registry *portainer.Registry, hideAccesses bool) {
registry.Password = ""
registry.ManagementConfiguration = nil
if hideAccesses {
registry.RegistryAccesses = nil
}
}
// Handler is the HTTP handler used to handle registry operations.
type Handler struct {
*mux.Router
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
K8sClientFactory *cli.ClientFactory
}
// NewHandler creates a handler to manage registry operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
h := newHandler(bouncer)
h.initRouter(bouncer)
return h
}
func newHandler(bouncer *security.RequestBouncer) *Handler {
return &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
}
func (h *Handler) initRouter(bouncer accessGuard) {
h.Handle("/registries",
bouncer.AdminAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost)
h.Handle("/registries",
@@ -45,5 +59,21 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete)
h.PathPrefix("/registries/proxies/gitlab").Handler(
bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry)))
return h
}
type accessGuard interface {
AdminAccess(h http.Handler) http.Handler
RestrictedAccess(h http.Handler) http.Handler
AuthenticatedAccess(h http.Handler) http.Handler
}
func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Registry) bool {
hasSameUrl := r1.URL == r2.URL
hasSameCredentials := r1.Authentication == r2.Authentication && (!r1.Authentication || (r1.Authentication && r1.Username == r2.Username))
if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry {
return hasSameUrl && hasSameCredentials
}
return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
}

View File

@@ -10,6 +10,8 @@ import (
"github.com/portainer/libhttp/response"
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/http/security"
)
type registryConfigurePayload struct {
@@ -93,9 +95,12 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error {
// @failure 500 "Server error"
// @router /registries/{id}/configure [post]
func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to configure registry", httperrors.ErrResourceAccessDenied}
}
payload := &registryConfigurePayload{}
@@ -104,6 +109,11 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}

View File

@@ -2,6 +2,7 @@ package registries
import (
"errors"
"fmt"
"net/http"
"github.com/asaskevich/govalidator"
@@ -9,15 +10,19 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
type registryCreatePayload struct {
// Name that will be used to identify this registry
Name string `example:"my-registry" validate:"required"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6"`
// URL or IP address of the Docker registry
URL string `example:"registry.mydomain.tld:2375" validate:"required"`
URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"`
// BaseURL required for ProGet registry
BaseURL string `example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
@@ -30,7 +35,7 @@ type registryCreatePayload struct {
Quay portainer.QuayRegistryData
}
func (payload *registryCreatePayload) Validate(r *http.Request) error {
func (payload *registryCreatePayload) Validate(_ *http.Request) error {
if govalidator.IsNull(payload.Name) {
return errors.New("Invalid registry name")
}
@@ -40,9 +45,17 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
}
if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry {
return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)")
switch payload.Type {
case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry:
default:
return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)")
}
if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" {
return fmt.Errorf("BaseURL is required for registry type %d (ProGet)", portainer.ProGetRegistry)
}
return nil
}
@@ -60,23 +73,41 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
// @failure 500 "Server error"
// @router /registries [post]
func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create registry", httperrors.ErrResourceAccessDenied}
}
var payload registryCreatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registry := &portainer.Registry{
Type: portainer.RegistryType(payload.Type),
Name: payload.Name,
URL: payload.URL,
Authentication: payload.Authentication,
Username: payload.Username,
Password: payload.Password,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Gitlab: payload.Gitlab,
Quay: payload.Quay,
Type: portainer.RegistryType(payload.Type),
Name: payload.Name,
URL: payload.URL,
BaseURL: payload.BaseURL,
Authentication: payload.Authentication,
Username: payload.Username,
Password: payload.Password,
RegistryAccesses: portainer.RegistryAccesses{},
Gitlab: payload.Gitlab,
Quay: payload.Quay,
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for _, r := range registries {
if handler.registriesHaveSameURLAndCredentials(&r, registry) {
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")}
}
}
err = handler.DataStore.Registry().CreateRegistry(registry)
@@ -84,6 +115,6 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err}
}
hideFields(registry)
hideFields(registry, true)
return response.JSON(w, registry)
}

View File

@@ -0,0 +1,113 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
)
func Test_registryCreatePayload_Validate(t *testing.T) {
basePayload := registryCreatePayload{Name: "Test registry", URL: "http://example.com"}
t.Run("Can't create a ProGet registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
err := payload.Validate(nil)
assert.Error(t, err)
})
t.Run("Can create a GitLab registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.GitlabRegistry
err := payload.Validate(nil)
assert.NoError(t, err)
})
t.Run("Can create a ProGet registry if BaseURL is not empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
payload.BaseURL = "http://example.com"
err := payload.Validate(nil)
assert.NoError(t, err)
})
}
type testRegistryService struct {
portainer.RegistryService
createRegistry func(r *portainer.Registry) error
updateRegistry func(ID portainer.RegistryID, r *portainer.Registry) error
getRegistry func(ID portainer.RegistryID) (*portainer.Registry, error)
}
type testDataStore struct {
portainer.DataStore
registry *testRegistryService
}
func (t testDataStore) Registry() portainer.RegistryService {
return t.registry
}
func (t testRegistryService) CreateRegistry(r *portainer.Registry) error {
return t.createRegistry(r)
}
func (t testRegistryService) UpdateRegistry(ID portainer.RegistryID, r *portainer.Registry) error {
return t.updateRegistry(ID, r)
}
func (t testRegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) {
return t.getRegistry(ID)
}
func (t testRegistryService) Registries() ([]portainer.Registry, error) {
return nil, nil
}
func TestHandler_registryCreate(t *testing.T) {
payload := registryCreatePayload{
Name: "Test registry",
Type: portainer.ProGetRegistry,
URL: "http://example.com",
BaseURL: "http://example.com",
Authentication: false,
Username: "username",
Password: "password",
Gitlab: portainer.GitlabRegistryData{},
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
restrictedContext := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: portainer.UserID(1),
}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
registry := portainer.Registry{}
handler := Handler{}
handler.DataStore = testDataStore{
registry: &testRegistryService{
createRegistry: func(r *portainer.Registry) error {
registry = *r
return nil
},
},
}
handlerError := handler.registryCreate(w, r)
assert.Nil(t, handlerError)
assert.Equal(t, payload.Name, registry.Name)
assert.Equal(t, payload.Type, registry.Type)
assert.Equal(t, payload.URL, registry.URL)
assert.Equal(t, payload.BaseURL, registry.BaseURL)
assert.Equal(t, payload.Authentication, registry.Authentication)
assert.Equal(t, payload.Username, registry.Username)
assert.Equal(t, payload.Password, registry.Password)
}

View File

@@ -8,6 +8,8 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
// @id RegistryDelete
@@ -23,6 +25,14 @@ import (
// @failure 500 "Server error"
// @router /registries/{id} [delete]
func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete registry", httperrors.ErrResourceAccessDenied}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}

View File

@@ -5,7 +5,6 @@ import (
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -27,6 +26,7 @@ import (
// @failure 500 "Server error"
// @router /registries/{id} [get]
func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
@@ -39,11 +39,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
}
err = handler.requestBouncer.RegistryAccess(r, registry)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", errors.ErrEndpointAccessDenied}
}
hideFields(registry)
hideFields(registry, false)
return response.JSON(w, registry)
}

View File

@@ -5,6 +5,7 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
@@ -21,21 +22,18 @@ import (
// @failure 500 "Server error"
// @router /registries [get]
func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list registries, use /endpoints/:endpointId/registries route instead", httperrors.ErrResourceAccessDenied}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
filteredRegistries := security.FilterRegistries(registries, securityContext)
for idx := range filteredRegistries {
hideFields(&filteredRegistries[idx])
}
return response.JSON(w, filteredRegistries)
return response.JSON(w, registries)
}

View File

@@ -9,6 +9,8 @@ import (
"github.com/portainer/libhttp/response"
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/http/security"
)
type registryUpdatePayload struct {
@@ -16,15 +18,16 @@ type registryUpdatePayload struct {
Name *string `validate:"required" example:"my-registry"`
// URL or IP address of the Docker registry
URL *string `validate:"required" example:"registry.mydomain.tld:2375"`
// BaseURL is used for quay registry
BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication *bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
Username *string `example:"registry_user"`
// Password used to authenticate against this registry. required when Authentication is true
Password *string `example:"registry_password"`
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
Quay *portainer.QuayRegistryData
Password *string `example:"registry_password"`
RegistryAccesses *portainer.RegistryAccesses
Quay *portainer.QuayRegistryData
}
func (payload *registryUpdatePayload) Validate(r *http.Request) error {
@@ -48,17 +51,19 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error {
// @failure 500 "Server error"
// @router /registries/{id} [put]
func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update registry", httperrors.ErrResourceAccessDenied}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
var payload registryUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
@@ -66,27 +71,26 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
}
var payload registryUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
if payload.Name != nil {
registry.Name = *payload.Name
}
if payload.URL != nil {
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for _, r := range registries {
if r.ID != registry.ID && hasSameURL(&r, registry) {
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", errors.New("A registry is already defined for this URL")}
}
}
shouldUpdateSecrets := false
registry.URL = *payload.URL
if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil {
registry.BaseURL = *payload.BaseURL
}
if payload.Authentication != nil {
if *payload.Authentication {
registry.Authentication = true
shouldUpdateSecrets = shouldUpdateSecrets || (payload.Username != nil && *payload.Username != registry.Username) || (payload.Password != nil && *payload.Password != registry.Password)
if payload.Username != nil {
registry.Username = *payload.Username
@@ -103,12 +107,35 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
}
}
if payload.UserAccessPolicies != nil {
registry.UserAccessPolicies = payload.UserAccessPolicies
if payload.URL != nil {
shouldUpdateSecrets = shouldUpdateSecrets || (*payload.URL != registry.URL)
registry.URL = *payload.URL
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for _, r := range registries {
if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) {
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")}
}
}
}
if payload.TeamAccessPolicies != nil {
registry.TeamAccessPolicies = payload.TeamAccessPolicies
if shouldUpdateSecrets {
for endpointID, endpointAccess := range registry.RegistryAccesses {
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err}
}
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err = handler.updateEndpointRegistryAccess(endpoint, registry, endpointAccess)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err}
}
}
}
}
if payload.Quay != nil {
@@ -123,10 +150,24 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
return response.JSON(w, registry)
}
func hasSameURL(r1, r2 *portainer.Registry) bool {
if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry {
return r1.URL == r2.URL
func (handler *Handler) updateEndpointRegistryAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, endpointAccess portainer.RegistryAccessPolicies) error {
cli, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return err
}
return r1.URL == r2.URL && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
for _, namespace := range endpointAccess.Namespaces {
err := cli.DeleteRegistrySecret(registry, namespace)
if err != nil {
return err
}
err = cli.CreateRegistrySecret(registry, namespace)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,88 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
)
func ps(s string) *string {
return &s
}
func pb(b bool) *bool {
return &b
}
type TestBouncer struct{}
func (t TestBouncer) AdminAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) RestrictedAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
return h
}
func TestHandler_registryUpdate(t *testing.T) {
payload := registryUpdatePayload{
Name: ps("Updated test registry"),
URL: ps("http://example.org/feed"),
BaseURL: ps("http://example.org"),
Authentication: pb(true),
Username: ps("username"),
Password: ps("password"),
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
registry := portainer.Registry{Type: portainer.ProGetRegistry, ID: 5}
r := httptest.NewRequest(http.MethodPut, "/registries/5", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
restrictedContext := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: portainer.UserID(1),
}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
updatedRegistry := portainer.Registry{}
handler := newHandler(nil)
handler.initRouter(TestBouncer{})
handler.DataStore = testDataStore{
registry: &testRegistryService{
getRegistry: func(_ portainer.RegistryID) (*portainer.Registry, error) {
return &registry, nil
},
updateRegistry: func(ID portainer.RegistryID, r *portainer.Registry) error {
assert.Equal(t, ID, r.ID)
updatedRegistry = *r
return nil
},
},
}
handler.Router.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
// Registry type should remain intact
assert.Equal(t, registry.Type, updatedRegistry.Type)
assert.Equal(t, *payload.Name, updatedRegistry.Name)
assert.Equal(t, *payload.URL, updatedRegistry.URL)
assert.Equal(t, *payload.BaseURL, updatedRegistry.BaseURL)
assert.Equal(t, *payload.Authentication, updatedRegistry.Authentication)
assert.Equal(t, *payload.Username, updatedRegistry.Username)
assert.Equal(t, *payload.Password, updatedRegistry.Password)
}

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