Compare commits

...

194 Commits

Author SHA1 Message Date
Anthony Lapenna
a171e540c5 Merge branch 'release/1.20.2' 2019-03-05 17:34:28 +13:00
Anthony Lapenna
cb858f0412 chore(version): bump version number 2019-03-05 17:34:19 +13:00
Anthony Lapenna
82078a8d8f style(extensions): update extensions information panel 2019-03-05 16:09:03 +13:00
Anthony Lapenna
2b31f489d9 feat(api): add support for an externally fetched title for motd (#2755)
* feat(api): add support for an externally fetched title for motd

* refactor(api): gofmt motd.go

* refactor(api): update go comment
2019-03-05 16:05:15 +13:00
Anthony Lapenna
e2a17480af Merge branch 'develop' of github.com:portainer/portainer into develop 2019-03-04 13:48:01 +13:00
Anthony Lapenna
0670079566 feat(api): update ExtensionDefinitionsURL 2019-03-04 13:46:27 +13:00
Anthony Lapenna
5ca9501540 dep(api): update docker binary version to 18.09.3 (#2749) 2019-03-01 14:45:36 +13:00
Anthony Lapenna
415c1759d1 Merge branch 'oath-poc' into develop 2019-03-01 14:16:04 +13:00
Anthony Lapenna
db0091b46d feat(api): revert extension URLs to correct one 2019-03-01 13:58:55 +13:00
linquize
42529cc5ea feat(volumes): show volume creation date (#2745) 2019-03-01 11:59:11 +13:00
Anthony Lapenna
60fbfeba23 fix(oauth): fix settings displaying issue for custom OAuth configuration 2019-03-01 11:24:47 +13:00
Anthony Lapenna
f5091ce5fb fix(auth): fix invalid condition to display OAuth login button 2019-03-01 10:58:18 +13:00
Anthony Lapenna
58962de20e Merge branch 'develop' into oath-poc 2019-03-01 09:42:38 +13:00
Anthony Lapenna
1eb7e6bacc fix(auth): rollback changes introduced via #2591 (#2747) 2019-02-28 11:38:02 +13:00
Anthony Lapenna
130baddea0 fix(api): fix an issue when removing non local administrators 2019-02-25 18:54:21 +13:00
Tim van den Eijnden
9cbf1f34a7 feat(networks): prevent removal of predefined networks (#2697)
* fix(networks): disable removing predefined networks (#1838)

*  fix(networks): disable select all for predefined networks (#1838)

* fix(networks): do not allow delete in network-details & use constant (#1838)
2019-02-25 14:25:48 +13:00
linquize
c152d3f62e fix(stacks): update web editor to set tab key to insert spaces (#2735) 2019-02-25 14:19:53 +13:00
linquize
da44f14e07 fix(auth): prevent redirect parameter to use state portainer.auth (#2701) 2019-02-25 13:57:11 +13:00
Anthony Lapenna
49516e2c3f style(oauth): update Azure UI elements 2019-02-25 13:38:27 +13:00
Anthony Lapenna
9c4c782a90 style(container-creation): review auto remove element position 2019-02-25 13:09:09 +13:00
baron_l
7aa6a30614 feat(registry-manager): allow regular users to use the registry browse feature (#2664)
* feat(registries): registries accessibility to all authorized people and not only admins

* feat(registry): dockerhub settings for admin only

* feat(registry): remove registry config access for non admin users

* feat(api): use AuthenticatedAccess policy instead of RestrictedAccess for extensionList operation

* refactor(api): minor update to security package

* refactor(api): revert unexporting function changes

* refactor(api): apply gofmt
2019-02-25 13:02:49 +13:00
linquize
99e50370bd feat(container-creation): support auto remove option (docker run --rm) (#2684) 2019-02-25 09:48:31 +13:00
Anthony Lapenna
dc2a8cf1f4 feat(oauth): update OAuth configuration UX 2019-02-21 14:02:25 +13:00
Anthony Lapenna
b9ac3d4286 feat(oauth): fix the double refresh issue 2019-02-21 11:09:57 +13:00
Anthony Lapenna
6711e6c969 feat(oauth): update configuration override UX 2019-02-21 10:30:09 +13:00
Anthony Lapenna
4a5fa211a7 feat(account): display a warning message in the account view 2019-02-20 13:57:13 +13:00
Anthony Lapenna
d510d23408 feat(oauth): improve Azure OAuth support 2019-02-20 13:53:25 +13:00
Anthony Lapenna
ce9e009e22 feat(oauth): update UI/UX 2019-02-19 14:38:42 +13:00
Anthony Lapenna
9918c1260b feat(oauth): update authentication panel with OAuth provider details 2019-02-19 09:54:02 +13:00
Anthony Lapenna
e325ad10dd fix(oauth): fix an UX issue when updating microsoft oauth settings 2019-02-18 16:18:48 +13:00
Anthony Lapenna
73f20b5157 refactor(oauth): remove console log statement 2019-02-18 15:21:34 +13:00
Anthony Lapenna
b6f04c5e0d fix(oauth): fix missing scopes for microsoft provider 2019-02-18 15:21:06 +13:00
Anthony Lapenna
2ef8c0b33e fix(app): rewrite URLHelper to avoid an issue with minification 2019-02-18 15:08:54 +13:00
Anthony Lapenna
7643f8d08c feat(oauth): dev build supporting Oauth extension 2019-02-18 14:46:34 +13:00
Anthony Lapenna
086bad2956 Merge branch 'develop' into oath-poc 2019-02-18 09:58:51 +13:00
Anthony Lapenna
d5dfc889bb docs(README): remove gitter badges 2019-02-18 09:51:20 +13:00
Montana Flynn
ef926dce33 docs(README): update logo src (#2719)
The current logo src is 404: https://portainer.io/images/logo_alt.png

The repo already includes the logo: https://github.com/portainer/portainer/blob/develop/assets/images/logo_alt.png?raw=true
2019-02-18 09:49:34 +13:00
Anthony Lapenna
d768e72a21 feat(oauth): add support for default team 2019-02-17 19:01:42 +13:00
Anthony Lapenna
78e2aaf7d4 feat(oauth): update OAuth UX 2019-02-17 17:01:36 +13:00
Anthony Lapenna
17cf374c30 Merge branch 'develop' into oath-poc 2019-02-17 16:39:23 +13:00
Nathan Baum
165096bef0 refactor(api): fix a typo (#2712)
Just a trivial spelling error.
2019-02-15 09:12:53 +13:00
Anthony Lapenna
de76ba4e67 feat(oauth): update OAuth UX 2019-02-14 15:58:45 +13:00
linquize
b1e048e218 feat(build-system): prefix some dependencies with "semver:" (#2690)
This makes both npm and yarn to work
2019-02-14 12:13:48 +13:00
linquize
8f32d58fae fix(templates): redirect to home if endpoint not yet selected #2709 (#2710) 2019-02-14 12:08:46 +13:00
Anthony Lapenna
16226b1202 Merge branch 'oath-poc' of github.com:portainer/pportainer into oath-poc 2019-02-13 10:01:06 +13:00
baron_l
8f568c8699 style(oauth): oauth loading + oauth config rework 2019-02-08 16:07:16 +01:00
Anthony Lapenna
af34b99cd4 Merge branch 'develop' into oath-poc 2019-02-08 13:32:53 +13:00
baron_l
2755527d28 feat(oauth): default team for user on oauth settings 2019-02-07 19:32:02 +01:00
baron_l
4d8133f696 feat(oauth): spinner on code evaluation after sucessfull oauth 2019-02-07 15:07:10 +01:00
Anthony Lapenna
fdc11dbe3a feat(build-system): update build system (#2682) 2019-02-07 12:00:47 +13:00
Anthony Lapenna
508352f4ea Merge branch 'develop' into oath-poc 2019-02-04 09:19:12 +13:00
Daniel Cardoza
9b6b6e09ae fix(endpoints): correct agent stack download url (#2667)
* 2584 fix(endpoints): correct agent stack download url

The directions for installing the agent stack from the endpoints
view used an old url. Update to the new url.

* Drop the portainer- prefix for the download path and filename

Co-Authored-By: dang3r <danielpcardoza@gmail.com>
2019-02-04 09:06:07 +13:00
Anthony Lapenna
899cd5f279 fix(home): fix an issue when trying to connect to an Azure ACI endpoint (#2671) 2019-02-04 09:04:52 +13:00
Anthony Lapenna
2eec8b75d0 Merge tag '1.20.1' into develop
Release 1.20.1
2019-01-31 13:15:28 +13:00
Anthony Lapenna
048c74a0dc Merge branch 'release/1.20.1' 2019-01-31 13:15:23 +13:00
Anthony Lapenna
6b1c476b63 chore(version): bump version number 2019-01-31 13:15:18 +13:00
Anthony Lapenna
c5b5f80bea docs(README): update build badge 2019-01-31 12:02:12 +13:00
Anthony Lapenna
cea2c60b55 refactor(build-system): fix lint issues 2019-01-31 11:38:27 +13:00
Steven Kang
576f369152 feat(build-system): introduce Azure DevOps support (#2666) 2019-01-31 11:37:16 +13:00
Anthony Lapenna
fca4f619b5 fix(api): re-use previous password when ldap settings update use empty password (#2659) 2019-01-30 14:53:14 +13:00
Chaim Lev Ari
90281fd7f0 feat(oauth): add providers to providers-selector 2019-01-25 10:57:40 +02:00
Chaim Lev Ari
c1939f6070 feature(oauth): add provider selector 2019-01-25 10:46:17 +02:00
Chaim Lev Ari
50c604ee4c fix(auth): use the right function to oauth validate 2019-01-25 10:44:31 +02:00
Chaim Lev Ari
41ded64037 Revert "refactor(auth): extract oauth login mechanism to service"
This reverts commit 0a439b3893.
2019-01-25 10:37:23 +02:00
baron_l
801336336f fix(registry-manager): add repositories pagination support (#2641)
* fix(registry-management): add support for repositories list with multiple requests

* refactor(registry-management): change regex usage to a reusable interceptor function

* refactor(registry-management): change interceptor to transformResponse function
2019-01-24 13:38:36 +13:00
Anthony Lapenna
90a0998502 feat(templates): add sonatype nexus 3 template 2019-01-23 16:05:07 +13:00
Anthony Lapenna
1a4dff536d fix(container-creation): fix an issue with command parsing (#2642)
* fix(container-creation): fix an issue with command parsing

* refactor(container-creation): remove indentation update
2019-01-23 12:25:42 +13:00
Chaim Lev-Ari
f772cd31cb feat(auth): preserve url when redirected to login (#2591)
* feat(auth): preserve url when redirected to login

* feat(auth): add redirect also to unauthenticated flow

* style(app): remove style changes from files

* fix(app): remove reference to otpLogin

* style(auth): remove semicolon
2019-01-23 12:22:56 +13:00
Chaim Lev-Ari
8160fe4717 feat(app): redirect to home if no endpoint is set (#2601)
* refactor(stacks): set newstack state as a child state of stacks

* fix(docker): add check on docker states for endpoint

* refactor(app): remove redirect notification
2019-01-23 12:21:48 +13:00
Anthony Lapenna
86c60807cd feat(endpoint-creation): fix invalid link (#2644) 2019-01-23 12:18:18 +13:00
baron_l
c1f2d90997 fix(container-creation): fix missing capabilities on duplicate (#2635) 2019-01-23 09:28:44 +13:00
Chaim Lev Ari
3699b794eb feat(oauth): add providers selectors 2019-01-18 12:14:12 +02:00
Chaim Lev Ari
69252a8377 refactour(auth): move information body to each setting 2019-01-18 12:08:18 +02:00
Chaim Lev Ari
193e7eb3f8 refactor(oauth): remove separation of strings 2019-01-18 11:53:44 +02:00
Chaim Lev Ari
de5f6086d0 refactor(oauth): return parse content error 2019-01-18 11:51:41 +02:00
Chaim Lev Ari
46e8f10aea refactor(ouath): use oauth2 library to get token 2019-01-18 10:56:16 +02:00
Chaim Lev Ari
60040e90d0 refactor(oauth): move build url logic to service 2019-01-18 10:24:42 +02:00
Chaim Lev Ari
c5c06b307a refactor(oauth): rename authenticate function 2019-01-18 10:15:02 +02:00
Chaim Lev Ari
c28274667d refactor(oauth): use oauth2 to generate login url 2019-01-18 10:13:33 +02:00
Anthony Lapenna
54163e3b92 fix(extensions): fix an issue with extensions with expired licenses (#2628)
* fix(extensions): fix an issue with extensions with expired licenses

* fix(api): fix invalid log call

* fix(api): allow to re-enable an extension
2019-01-18 10:00:18 +13:00
Chaim Lev-Ari
62eb47b3cb fix(container-creation): revert container state if creation failed (#2565)
* fix(container): rename old container only if exist

* fix(container): remove new container only if created

* style(container): fix typo

Co-Authored-By: chiptus <chiptus@users.noreply.github.com>
2019-01-18 08:59:43 +13:00
Anthony Lapenna
808eb7d341 dep(bootstrap): update bootstrap version to 3.4.0 (#2632) 2019-01-18 08:51:12 +13:00
hiyao
a33eca4bbb fix(registry-manager): fix an issue when removing all tags of a repository (#2545)
* fix repository reload got error in remove tags

When I remove all tags, removeTags() will reload and do initView() again, but data.tags response null, that trigger data.tags.length got error.

* Revert "fix repository reload got error in remove tags"

This reverts commit 5d9b1778ef.

* fix(registry-management): change response repository tags type to array by force

* feat(registry-management): redirect to repositories page when no tag in the repository after delete tags
2019-01-18 08:01:47 +13:00
baron_l
50e77d2bf1 fix(network-details): displaying all subnets and gateways on network details (#2629) 2019-01-17 11:39:15 +13:00
DevHugo
50a3b08209 feat(app): add driver name in the volume selector for container/service creation (#2534)
* Feat(containers): add driver name in the volume selector

* Feat(services): add driver name in the volume selector
2019-01-17 11:28:40 +13:00
Chaim Lev Ari
0a439b3893 refactor(auth): extract oauth login mechanism to service 2019-01-16 18:57:15 +02:00
Chaim Lev Ari
0d4e1d00f0 refactor(login): move oauth button to right 2019-01-16 18:00:01 +02:00
Chaim Lev Ari
b09f491f62 style(auth): remove comments and change error 2019-01-16 17:53:10 +02:00
Chaim Lev Ari
dc067b3308 refactor(http): remove old oauth handler 2019-01-16 17:41:56 +02:00
Chaim Lev Ari
b121f975fa refactor(settings): remove duplicate settings 2019-01-16 17:38:07 +02:00
Chaim Lev Ari
3f44925d7e fix(auth): fix typo - missing function 2019-01-16 17:37:50 +02:00
Chaim Lev Ari
80d570861d refactor(auth): move public settings into view model 2019-01-16 17:34:12 +02:00
Chaim Lev Ari
317bd53e43 Merge branch 'oath-poc' of github.com:portainer/pportainer into oath-poc 2019-01-16 17:26:29 +02:00
Chaim Lev Ari
24f066716b refactor(auth): expose only the login url 2019-01-16 17:25:16 +02:00
Chaim Lev Ari
4cbde7bb0d refactor(auth): move oauth handler under auth 2019-01-16 17:24:58 +02:00
Chaim Lev Ari
f6bdc5c2b3 refactor(auth): move oauth handler code to its own file 2019-01-16 17:01:38 +02:00
Anthony Lapenna
c650fe56c2 fix(auth): fix typos
Co-Authored-By: chiptus <chiptus@users.noreply.github.com>
2019-01-16 16:53:24 +02:00
Anthony Lapenna
fc8938e871 fix(auth): change oauth error type
Co-Authored-By: chiptus <chiptus@users.noreply.github.com>
2019-01-16 16:50:19 +02:00
Anthony Lapenna
44b7e0fdca fix(auth): change error type
Co-Authored-By: chiptus <chiptus@users.noreply.github.com>
2019-01-16 16:49:33 +02:00
Chaim Lev-Ari
fe63b4a156 fix(container-creation): populate logger config from existing container (#2602)
* refactor(container): change map function to lodash

* style(container): add semicolon
2019-01-16 13:34:28 +13:00
Chaim Lev-Ari
42365a52b1 feat(container-details): change network identifier to name (#2623) 2019-01-16 08:05:55 +13:00
Mark Stansberry
d6aafceba8 docs(api): update swagger definitions 2019-01-16 08:04:47 +13:00
baron_l
c7983d8993 fix(app): remove endpoint status update on 502/503 http return
* refactor(app): removing unused dep and function
2019-01-16 07:58:35 +13:00
Anthony Lapenna
34667bd3b3 fix(network-creation): force overlay network creation on manager node (#2622)
* fix(network-creation): force overlay network creation on manager node

* fix(app): fix function override

* fix(app): use portainerAgentManagerOperation in interceptor
2019-01-15 12:10:29 +13:00
Anthony Lapenna
3a3577754e fix(home): only display group name if available (#2621) 2019-01-15 08:52:26 +13:00
Anthony Lapenna
bed49c37e4 fix(teams): remove name sanitization when creating a team (#2619) 2019-01-14 17:27:55 +13:00
Anthony Lapenna
dedc02cc8d docs(api): fix invalid example value for AutoCreateUsers property (#2618) 2019-01-14 16:50:53 +13:00
Chaim Lev Ari
17ac3e5ed1 refactor(oauth): move enpoint constant to extension 2019-01-03 13:36:17 +02:00
Chaim Lev Ari
25620c5008 refactor(auth): refactor get url params 2019-01-02 20:49:25 +02:00
Chaim Lev Ari
9bebe9dee7 refactor(auth): move user setter into function 2019-01-02 20:01:23 +02:00
Chaim Lev Ari
81e3ace232 fix(auth): fix oauh enabled function 2019-01-02 20:01:06 +02:00
Chaim Lev Ari
15b6941872 refactor(oauth): move oauth rest service to extension 2019-01-02 20:00:41 +02:00
Chaim Lev Ari
7aaa9e58e9 refactor(auth): move oauth info to component 2019-01-02 16:24:10 +02:00
Chaim Lev Ari
515daf6dba refactor(auth): exprt oauth settings into extension 2019-01-02 16:21:36 +02:00
Chaim Lev Ari
0a1643bbcf style(auth): remove added spaces 2019-01-02 16:01:10 +02:00
Chaim Lev Ari
38f24683a6 refactor(auth): remove empty $q.deffered 2019-01-02 15:59:38 +02:00
Chaim Lev Ari
7494101a4d refactor(auth): refactor auth controller 2019-01-02 15:56:08 +02:00
Chaim Lev Ari
996319d299 feat(auth): don't clear client secret on update 2018-12-30 18:39:16 +02:00
Chaim Lev Ari
2ee6f2780b refactor(oauth): add debug logs 2018-12-30 18:25:30 +02:00
Chaim Lev Ari
241a701eca feat(oauth): merge pr from https://github.com/portainer/portainer/pull/2515 2018-12-30 18:02:22 +02:00
Anthony Lapenna
463b379876 docs(README): remove broken badges and links 2018-12-27 09:03:13 +01:00
Chaim Lev-Ari
f2cd33e831 feat(container-creation): call stopAndRename after pullImage (#2564)
* refactor(container): remove bind of function
2018-12-21 00:37:35 +09:00
Anthony Lapenna
6b05a35881 fix(api): set a default value for potentially empty snapshot interval (#2543) 2018-12-12 21:16:44 +13:00
Anthony Lapenna
6648c0bbe7 Merge tag '1.20.0' into develop
Release 1.20.0
2018-12-12 17:03:36 +13:00
Anthony Lapenna
dbda568481 Merge branch 'release/1.20.0' 2018-12-12 17:03:31 +13:00
Anthony Lapenna
189d131105 chore(version): bump version number 2018-12-12 17:03:25 +13:00
Anthony Lapenna
1384359baf fix(api): fix snapshot hanging 2018-12-12 17:00:15 +13:00
Anthony Lapenna
6c26cf1f39 style(support): update support pricing 2018-12-12 16:03:20 +13:00
Anthony Lapenna
8780b0a901 feat(api): update extension path on Windows arch 2018-12-12 14:19:58 +13:00
Anthony Lapenna
f5ada3085e fix(api): fix an issue with schedule update 2018-12-12 14:11:40 +13:00
Anthony Lapenna
acc5218c16 fix(api): fix snapshot schedule loading 2018-12-12 12:31:55 +13:00
Anthony Lapenna
8a186b4024 feat(api): update DigitalSignatureService (#2539) 2018-12-12 11:19:23 +13:00
Anthony Lapenna
5c2e714e69 style(extensions): minor update to extension UX/UI (#2538)
* style(extensions): update extension icons

* style(extensions): style update

* feat(extensions): update extension UX

* style(extensions): update extension style

* style(extension-details): update screenshot default size

* style(extensions): update overview diagram image

* refactor(support): fix support URLs
2018-12-12 10:28:21 +13:00
Anthony Lapenna
f222b3cb1a feat(templates): update logo URLs 2018-12-12 09:47:28 +13:00
Anthony Lapenna
e440ba53cb feat(api): migrate template data logo URLs (#2537) 2018-12-12 09:46:05 +13:00
Anthony Lapenna
17d85fdc15 fix(registry-creation): fix registry creation request being fired twice on firefox 2018-12-10 21:56:07 +13:00
Anthony Lapenna
42a357f863 fix(support-details): fix a js error 2018-12-09 17:03:26 +13:00
Anthony Lapenna
6fd5ddc802 feat(extensions): introduce extension support (#2527)
* wip

* wip: missing repository & tags removal

* feat(registry): private registry management

* style(plugin-details): update view

* wip

* wip

* wip

* feat(plugins): add license info

* feat(plugins): browse feature preview

* feat(registry-configure): add the ability to configure registry management

* style(app): update text in app

* feat(plugins): add plugin version number

* feat(plugins): wip plugin upgrade process

* feat(plugins): wip plugin upgrade

* feat(plugins): add the ability to update a plugin

* feat(plugins): init plugins at startup time

* feat(plugins): add the ability to remove a plugin

* feat(plugins): update to latest plugin definitions

* feat(plugins): introduce plugin-tooltip component

* refactor(app): relocate plugin files to app/plugins

* feat(plugins): introduce PluginDefinitionsURL constant

* feat(plugins): update the flags used by the plugins

* feat(plugins): wip

* feat(plugins): display a label when a plugin has expired

* wip

* feat(registry-creation): update registry creation logic

* refactor(registry-creation): change name/ids for inputs

* feat(api): pass registry type to management configuration

* feat(api): unstrip /v2 in regsitry proxy

* docs(api): add TODO

* feat(store): mockup-1

* feat(store): mockup 2

* feat(store): mockup 2

* feat(store): update mockup-2

* feat(app): add unauthenticated event check

* update gruntfile

* style(support): update support views

* style(support): update product views

* refactor(extensions): refactor plugins to extensions

* feat(extensions): add a deal property

* feat(extensions): introduce ExtensionManager

* style(extensions): update extension details style

* feat(extensions): display license/company when enabling extension

* feat(extensions): update extensions views

* feat(extensions): use ProductId defined in extension schema

* style(app): remove padding left for form section title elements

* style(support): use per host model

* refactor(extensions): multiple refactors related to extensions mecanism

* feat(extensions): update tls file path for registry extension

* feat(extensions): update registry management configuration

* feat(extensions): send license in header to extension proxy

* fix(proxy): fix invalid default loopback address

* feat(extensions): add header X-RegistryManagement-ForceNew for specific operations

* feat(extensions): add the ability to display screenshots

* feat(extensions): center screenshots

* style(extensions): tune style

* feat(extensions-details): open full screen image on click (#2517)

* feat(extension-details): show magnifying glass on images

* feat(extensions): support extension logo

* feat(extensions): update support logos

* refactor(lint): fix lint issues
2018-12-09 16:49:27 +13:00
Anthony Lapenna
f5dc663879 fix(build-system): revert appveyor integration
* Revert "fix(build-system): fix local build system after appveyor introduction (#2528)"

This reverts commit 79c24ced96.

* Revert "feat(build-system): add support for AppVeyor CI (#2449)"

This reverts commit 65979709e9.
2018-12-09 16:32:12 +13:00
Anthony Lapenna
79c24ced96 fix(build-system): fix local build system after appveyor introduction (#2528) 2018-12-09 16:08:36 +13:00
Steven Kang
65979709e9 feat(build-system): add support for AppVeyor CI (#2449) 2018-12-07 16:19:58 +13:00
Olli Janatuinen
2541f4daea feat(UX): persist search criterias (#2425)
* feat(ui): persist search criteria

* fix(ui): trying make templates search working correctly

* fix(ui): corrected search persistance on home and templates

* fix(ui): corrected javascript errors
2018-12-07 08:54:34 +13:00
baron_l
1a94158f77 * feat(UX): schedule creation UX overhaul (#2485)
* feat(api): add a new Recurring property on Schedule

* feat(schedules): date to cron convert + recurring flag

* feat(schedules): update angularjs-datetime-picker from v1 to v2

* chore(app): use minified dependency for angularjs-datetime-picker

* chore(vendor): rollback version of angularjs-datetime-picker

* * feat(ux): replace datepicker for schedule creation/details

* feat(container-stats): add refresh rate of 1 and 3 seconds (#2493)

* fix(templates): set var to default value if no value selected (#2323)

* fix(templates): set preset to true iff var type is preset

* fix(templates): add env var value when changing type

* feat(security): shutdown instance after 5minutes if no admin account created (#2500)

* feat(security): skip admin check if --no-auth

* fix(security): change error message

* fix(vendor): use datepicker minified version

* feat(schedule-creation): replace angular-datetime-picker

* feat(schedule): parse cron to datetime

* fix(schedule): fix zero based months
2018-12-07 08:53:23 +13:00
Anthony Lapenna
9e1800e2ec style(settings): update host management tooltip 2018-12-06 14:01:49 +13:00
baron_l
a9b107dbb5 feat(app): add the capability to enable/disable host management features (#2472)
* feat(settings): add the capability to enable/disable the host management features

* feat(settings): remove the validation of EnableHostManagementFeatures in frontend

* feat(api): disable schedules API when HostManagementFeatures is false + DB migration

* style(settings): update host management settings tooltip

* refacot(schedules): update DBVersion to 15
2018-12-06 11:36:25 +13:00
Chaim Lev-Ari
101bb41587 feat(security): shutdown instance after 5minutes if no admin account created (#2500)
* feat(security): skip admin check if --no-auth

* fix(security): change error message
2018-12-04 16:50:41 +13:00
Chaim Lev-Ari
acce5e0023 fix(templates): set var to default value if no value selected (#2323)
* fix(templates): set preset to true iff var type is preset

* 

* fix(templates): add env var value when changing type
2018-12-04 09:52:59 +13:00
linquize
5fa4403d20 feat(container-stats): add refresh rate of 1 and 3 seconds (#2493) 2018-12-03 21:49:02 +13:00
Anthony Lapenna
dc9a878f4b chore(docker): update docker binary version to 18.09.0 (#2510) 2018-12-03 12:10:55 +13:00
baron_l
969f70edeb fix(image-upload): uploading a tar with multiple images wont display an error anymore (#2503) 2018-11-29 07:00:58 +13:00
baron_l
c778e79004 fix(container-console): close the console when selected shell does not exist inside the container (#2502) 2018-11-29 06:57:36 +13:00
Chaim Lev-Ari
34b886d690 chore(build-system): add start and start:server scripts (#2495) 2018-11-27 10:05:13 +13:00
Andreas Roussos
b809177147 feat(dashboard): use plural form only when required
* fix(endpoint-item): use plural form only when required

* refactor(endpoint-item): use clearer patterns

* refactor(dashboard): use clearer patterns
2018-11-25 09:46:13 +13:00
baron_l
52788029ed feat(container-details): add visual feedback when creating image from container (#2487) 2018-11-24 11:11:58 +13:00
Anthony Lapenna
d510bbbcfd feat(api): filter LDAP password from settings response (#2488) 2018-11-24 08:40:56 +13:00
Olli Janatuinen
17d63ae3ca chore(dependencies): updated xterm to 3.8.0 version (#2452) 2018-11-23 22:00:30 +13:00
baron_l
5e49f934b9 fix(containers-stats): accessing a down container stats wont display a js error anymore (#2484) 2018-11-23 21:44:34 +13:00
Anthony Lapenna
d03fd5805a feat(api): support AGENT_SECRET environment variable (#2486) 2018-11-23 11:46:51 +13:00
baron_l
fe8dfee69a feat(home): display each endpoint URL (#2471) 2018-11-19 19:07:38 +13:00
baron_l
488dc5f9db fix(network-creation): macvlan availability for standalone endpoints (#2441) 2018-11-16 13:26:56 +13:00
Anthony Lapenna
0ef25a4cbd fix(schedules): add schedule name validation and remove endpoint name prefix (#2470) 2018-11-14 16:10:49 +13:00
Anthony Lapenna
94d3d7bde2 feat(motd): relocate motd file URL and always return 200 (#2466) 2018-11-14 12:20:33 +13:00
Christer Warén
40e0c3879c style(dashboard): change blocklist-item border color (#2465)
Changing blocklist-item border color to more confortable color that makes UI look more consistence
2018-11-14 10:01:36 +13:00
baron_l
d455ab3fc7 feat(endpoints): enhance offline browsing (#2454)
* feat(api): rewrite error response when trying to query a down endpoint

* feat(interceptors): adding custom backend return code on offline fastfail
2018-11-13 16:08:12 +13:00
Anthony Lapenna
0825d05546 feat(endpoints): improve offline banner UX (#2462)
* feat(endpoints): add the last snapshot timestamp in offline banner

* feat(endpoints): add the ability to refresh a snapshot in the offline banner
2018-11-13 16:02:49 +13:00
Anthony Lapenna
cf370f6a4c refactor(endpoints): remove time.Sleep call 2018-11-13 15:19:29 +13:00
Anthony Lapenna
381ab81fdd fix(endpoints): ensure endpoint is up to date after snapshot (#2460)
* feat(snapshots): fix a potential concurrency issue with endpoint snapshots

* fix(endpoints): ensure endpoint is up to date after snapshot
2018-11-13 15:18:38 +13:00
Anthony Lapenna
64c29f7402 feat(schedules): add the ability to list tasks from snapshots (#2458)
* feat(schedules): add the ability to list tasks from snapshots

* feat(schedules): update schedules

* refactor(schedules): fix linting issue
2018-11-13 14:39:26 +13:00
Anthony Lapenna
a2d9f591a7 feat(schedules): add retry policy to script schedules (#2445) 2018-11-09 15:22:08 +13:00
Anthony Lapenna
e7ab057c81 feat(sidebar): add a new Scheduler top entry 2018-11-08 14:09:21 +13:00
Yassir Hannoun
309620545c fix(container-stat): fix cpu/mem charts on Windows containers
* Fixing the CPU and Memory charts on Windows containers

* Fixing the CPU and Memory charts on Windows containers
2018-11-08 13:31:33 +13:00
Dmitriy Larionov
55b50c2a49 feat(container-creation): allow escaped quotes in command field (#2419) 2018-11-08 09:53:19 +13:00
Anthony Lapenna
807c830db0 feat(schedules): add the ability to update a schedule script (#2438) 2018-11-07 17:19:10 +13:00
Anthony Lapenna
695c28d4f8 fix(host): fix a typo in job history clear notification 2018-11-07 16:06:27 +13:00
Anthony Lapenna
4740375ba5 feat(schedules): add schedules UI (#2414)
* feat(schedules): add schedules UI mockups

* feat(schedules): update controller pattern

* feat(schedules): leverages API

* feat(schedules): add the ability create/edit a script execution job schedule

* feat(schedules): add form validation and details about cron expression
2018-11-07 11:59:21 +13:00
Anthony Lapenna
7d32a6619d feat(api): add created property for schedules (#2435) 2018-11-07 09:22:30 +13:00
Anthony Lapenna
110fcc46a6 feat(api): revamp scheduling to introduce system schedules (#2433)
* feat(api): revamp scheduling to introduce system schedules

* fix(api): fix linting issues

* fix(api): fix lint issues

* refactor(api): fix lint issues
2018-11-06 22:49:48 +13:00
Chaim Lev-Ari
dbbea0a20f feat(schedules): add the schedule API
* feat(jobs): add job service interface

* feat(jobs): create job execution api

* style(jobs): remove comment

* feat(jobs): add bindings

* feat(jobs): validate payload different cases

* refactor(jobs): rename endpointJob method

* refactor(jobs): return original error

* feat(jobs): pull image before creating container

* feat(jobs): run jobs with sh

* style(jobs): remove comment

* refactor(jobs): change error names

* feat(jobs): sync pull image

* fix(jobs): close image reader after error check

* style(jobs): remove comment and add docs

* refactor(jobs): inline script command

* fix(jobs): handle pul image error

* refactor(jobs): handle image pull output

* fix(docker): set http client timeout to 100s

* feat(api): create schedule type

* feat(agent): add basic schedule api

* feat(schedules): add schedule service in bolt

* feat(schedule): add schedule service to handler

* feat(schedule): add and list schedules from db

* feat(agent): get schedule from db

* feat(schedule): update schedule in db

* feat(agent): delete schedule

* fix(bolt): remove sync method from scheduleService

* feat(schedules): save/delete script in fs

* feat(schedules): schedules cron service implementation

* feat(schedule): integrate handler with cron

* feat(schedules): schedules API overhaul

* refactor(project): remove .idea folder

* fix(schedules): fix script task execute call

* refactor(schedules): refactor/fix golint issues

* refactor(schedules): update SnapshotTask documentation

* refactor(schedules): validate image name in ScheduleCreate operation
2018-11-06 09:58:15 +13:00
Anthony Lapenna
e94d6ad6b2 docs(swagger): update EndpointCreate operation 2018-11-01 07:32:41 +13:00
Jan Jansen
78bf374548 feat(ux): normalize quick actions buttons (#2389)
* feat(ux): normalize quick actions buttons

Fixes #2013

* fix(ux): fix wrong naming of variable
2018-10-31 15:50:38 +13:00
pc
8df64031e8 feat(log-viewer): change line count default to 100 and add a since parameter (#2377)
* chore(log-viewer): add the ability to use`since` parameter #1942

https://github.com/portainer/portainer/issues/1942#issuecomment-430246378

* chore(log-viewer): change lineCount to 100 #1942

https://github.com/portainer/portainer/issues/1942#issuecomment-430246378

* fix(log-viewer): js syntax typo for `;` and `'`

forget to lint the code, reported by codeclimate

* fix(log-viewer): use mementjs to format timestamp

1. use moment lib instead of define a function in filter.js(not the right place for this function, removed)
2. set sinceTimestamp init value to `24 hours ago`, as we just need to focus on the relative latest logs after the log-viewer loading, not all the logs(to speedup the process)
3. use moment().unix() to convert the `sinceTimestamp`  to local unix timestamp(not utc)

* chore(log-viewer): add the ability to select the datetime for `since`

* chore(log-viewer): add the ability to fetch logs from specific time
2018-10-29 17:49:35 +13:00
baron_l
a61654a35d feat(endpoints): add the ability to browse offline endpoints (#2253)
* feat(back): saved data in snapshot

* feat(endpoints): adding interceptors to retrieve saved data on offline endpoints

* feat(endpoints): offline dashboard working - need tests on offline views

* refactor(endpoints): interceptors cleaning and saving/loading offline endpoints data in/from localstorage

* feat(endpoints): browsing offline endpoints

* feat(endpoints): removing all the link in offline mode - sidebar not working when switching between off and on modes w/ stateManager logic

* feat(endpoints): endpoint status detection in real time

* fix(endpoints): offline swarm endpoint are not accessible anymore

* fix(endpoints): refactor message + disable offline browsing for an endpoint when no snapshot is available for it

* fix(endpoints): adding timeout and enabling loading bar for offline requests

* fix(endpoints): trying to access a down endpoint wont remove sidebar items if it fails

* feat(endpoints): disable checkboxes on offline views for offline mode

* feat(endpoints): updating endpoint status when detecting a change

* refactor(host): moved offline status panel from engine view to new host view

* fix(endpoints): missing endpoint update on ping from home view

* fix(api): rework EndpointUpdate operation

* refactor(offline): moved endpoint status to EndpointProvider and refactor the status-changed detection

* fix(offline): moved status detection to callback on views -> prevent displaying the offline message when endpoint is back online on view change

* fix(offline): offline message is now displayed online when browsing an offline endpoint

* fix(offline): sidebar updates correctly on endpoint status change

* fix(offline): offline panel not displayed and hidden on online mode

* refactor(offline): rework of OfflineMode management

* refactor(offline): extract information-panel for offlineMode into a component

* refactor(offline): remove redundant binding of informationPanel + endpointStatusInterceptor patter as service

* refactor(interceptors): moved interceptors pattern to service pattern

* feat(stacks): prevent inspection of a stack in offline mode

* feat(host): hide devices/disk panels in offline mode

* feat(host): disable browse action in offline mode

* refactor(home): remove comments
2018-10-28 22:27:06 +13:00
baron_l
354fda31f1 feat(jobs): add the ability to run a job on a target endpoint #2374
* feat(jobs): adding the ability to run scripts on endpoints

fix(job): click on containerId in JobsDatatable redirects to container's logs
refactor(job): remove the jobs datatable settings + texts changes on JobCreation view
fix(jobs): jobs payloads are now following API rules and case
feat(jobs): adding the capability to run scripts on hosts

* feat(jobs): adding the ability to purge jobs containers

* refactor(job): apply review changes

* feat(job-creation): store image name in local storage

* feat(host): disable job exec link in non-agent Swarm setup

* feat(host): only display execute job in agent setups or standalone

* feat(job): job execution overhaul

* docs(swagger): update EndpointJob documentation
2018-10-28 19:06:50 +13:00
Mark Stansberry
6ab510e5cb docs(api): update swagger related files to support swagger-codegen (#2404)
* Linting updates to api/swagger.yaml

* Security updates to api/swagger.yml

* Add api/swagger_config.json for swagger-codegen

* Add swagger_config.json packageVersion to match swagger.yml
2018-10-28 16:05:54 +13:00
Damian Czaja
7e6c647e93 feat(container-creation): add the ability to override the logging driver (#2384) 2018-10-28 16:00:56 +13:00
Yassir Hannoun
07c1e1bc3e feat(container-stats): display cache in memory usage chart (#2383) 2018-10-28 15:45:02 +13:00
Ricardo Cardona Ramirez
fe6ca042f3 feat(ux): Alphabetically sort configs and secrets in service details/creation (#2396)
* fix(sorting): Alphabetically sort configs in service details select box
* fix(sorting): Alphabetically sort configs and secrets  for service creation
2018-10-28 15:39:09 +13:00
Chaim Lev-Ari
9813099aa4 feat(app): toggle features based on agent API version (#2378)
* feat(agent): get agent's version from ping

* feat(agent): add version to api url

* feat(agent): query agent with api version

* feat(agent): rename agent api version name on state

* feat(agent): disable feature based on agent's api version

* style(agent): rename ping rest service + remove whitespaces

* style(state): remove whitespace

* style(agent): add whitespace

* fix(agent): remove check for error status 403

* refactor(agent): rename ping file name

* refactor(agent): move old services to v1 folder

* refactor(agent): turn ping service to usual pattern

* refactor(agent): change version to a global variable

* refactor(agent): move ping to version2

* refactor(agent): restore ping to use root ping

* fix(volumes): add volumeID to browse api path

* feat(volume): add upload button to volume browser
2018-10-26 16:16:29 +13:00
Yassir Hannoun
cca378b2e8 docs(README): fix semaphore badge 2018-10-24 08:55:30 +13:00
Anthony Lapenna
b5dfaff292 refactor(app): refactor unauthenticated state management (#2393)
* refactor(app): refactor Authentication service

* refactor(app): refactor unauthenticated state management
2018-10-23 17:28:59 +13:00
Anthony Lapenna
4f9a8180f9 docs(swagger): document the endpoint job execution (#2392) 2018-10-23 11:59:43 +13:00
Anthony Lapenna
14d2bf4ebb refactor(api): fix typo (#2391)
* refactor(api): fix typo

* refactor(api): remove newline
2018-10-23 10:07:39 +13:00
Chaim Lev-Ari
65291c68e9 feat(jobs): add the job execution API
* feat(jobs): add job service interface

* feat(jobs): create job execution api

* style(jobs): remove comment

* feat(jobs): add bindings

* feat(jobs): validate payload different cases

* refactor(jobs): rename endpointJob method

* refactor(jobs): return original error

* feat(jobs): pull image before creating container

* feat(jobs): run jobs with sh

* style(jobs): remove comment

* refactor(jobs): change error names

* feat(jobs): sync pull image

* fix(jobs): close image reader after error check

* style(jobs): remove comment and add docs

* refactor(jobs): inline script command

* fix(jobs): handle pul image error

* refactor(jobs): handle image pull output

* fix(docker): set http client timeout to 100s

* fix(client): remove timeout from http client
2018-10-23 10:03:30 +13:00
Yassir Hannoun
719299d75b fix(container-stat) : exclude cache from the Memory Usage chart to avoid misinterpret… (#2371) 2018-10-18 10:00:45 +13:00
371 changed files with 10810 additions and 1464 deletions

View File

@@ -1,15 +1,13 @@
<p align="center">
<img title="portainer" src='https://portainer.io/images/logo_alt.png' />
<img title="portainer" src='https://github.com/portainer/portainer/blob/develop/assets/images/logo_alt.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")
[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable)
[![Build Status](https://semaphoreci.com/api/v1/portainer/portainer/branches/develop/badge.svg)](https://semaphoreci.com/portainer/portainer)
[![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)
[![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/)
[![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[![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_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
@@ -20,8 +18,6 @@
## Demo
<img src="https://portainer.io/images/screenshots/portainer.gif" width="77%"/>
You can try out the public demo instance: http://demo.portainer.io/ (login with the username **admin** and the password **tryportainer**).
Please note that the public demo cluster is **reset every 15min**.
@@ -44,7 +40,6 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart
* Issues: https://github.com/portainer/portainer/issues
* FAQ: https://portainer.readthedocs.io/en/latest/faq.html
* Slack (chat): https://portainer.io/slack/
* Gitter (chat): https://gitter.im/portainer/Lobby
## Reporting bugs and contributing

View File

@@ -7,13 +7,13 @@ import (
// TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content
// specified in fileContent. Returns the archive as a byte array.
func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) {
func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, error) {
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
header := &tar.Header{
Name: fileName,
Mode: 0600,
Mode: mode,
Size: int64(len(fileContent)),
}

48
api/archive/zip.go Normal file
View File

@@ -0,0 +1,48 @@
package archive
import (
"archive/zip"
"bytes"
"io"
"io/ioutil"
"os"
"path/filepath"
)
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
func UnzipArchive(archiveData []byte, dest string) error {
zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
if err != nil {
return err
}
for _, zipFile := range zipReader.File {
f, err := zipFile.Open()
if err != nil {
return err
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
return err
}
fpath := filepath.Join(dest, zipFile.Name)
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode())
if err != nil {
return err
}
_, err = io.Copy(outFile, bytes.NewReader(data))
if err != nil {
return err
}
outFile.Close()
}
return nil
}

View File

@@ -10,9 +10,11 @@ import (
"github.com/portainer/portainer/bolt/dockerhub"
"github.com/portainer/portainer/bolt/endpoint"
"github.com/portainer/portainer/bolt/endpointgroup"
"github.com/portainer/portainer/bolt/extension"
"github.com/portainer/portainer/bolt/migrator"
"github.com/portainer/portainer/bolt/registry"
"github.com/portainer/portainer/bolt/resourcecontrol"
"github.com/portainer/portainer/bolt/schedule"
"github.com/portainer/portainer/bolt/settings"
"github.com/portainer/portainer/bolt/stack"
"github.com/portainer/portainer/bolt/tag"
@@ -38,6 +40,7 @@ type Store struct {
DockerHubService *dockerhub.Service
EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service
ExtensionService *extension.Service
RegistryService *registry.Service
ResourceControlService *resourcecontrol.Service
SettingsService *settings.Service
@@ -49,6 +52,7 @@ type Store struct {
UserService *user.Service
VersionService *version.Service
WebhookService *webhook.Service
ScheduleService *schedule.Service
}
// NewStore initializes a new Store and the associated services
@@ -135,9 +139,11 @@ func (store *Store) MigrateData() error {
DatabaseVersion: version,
EndpointGroupService: store.EndpointGroupService,
EndpointService: store.EndpointService,
ExtensionService: store.ExtensionService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
StackService: store.StackService,
TemplateService: store.TemplateService,
UserService: store.UserService,
VersionService: store.VersionService,
FileService: store.fileService,
@@ -174,6 +180,12 @@ func (store *Store) initServices() error {
}
store.EndpointService = endpointService
extensionService, err := extension.NewService(store.db)
if err != nil {
return err
}
store.ExtensionService = extensionService
registryService, err := registry.NewService(store.db)
if err != nil {
return err
@@ -240,5 +252,11 @@ func (store *Store) initServices() error {
}
store.WebhookService = webhookService
scheduleService, err := schedule.NewService(store.db)
if err != nil {
return err
}
store.ScheduleService = scheduleService
return nil
}

View File

@@ -0,0 +1,86 @@
package extension
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
const (
// BucketName represents the name of the bucket where this service stores data.
BucketName = "extension"
)
// Service represents a service for managing endpoint data.
type Service struct {
db *bolt.DB
}
// NewService creates a new instance of a service.
func NewService(db *bolt.DB) (*Service, error) {
err := internal.CreateBucket(db, BucketName)
if err != nil {
return nil, err
}
return &Service{
db: db,
}, nil
}
// Extension returns a extension by ID
func (service *Service) Extension(ID portainer.ExtensionID) (*portainer.Extension, error) {
var extension portainer.Extension
identifier := internal.Itob(int(ID))
err := internal.GetObject(service.db, BucketName, identifier, &extension)
if err != nil {
return nil, err
}
return &extension, nil
}
// Extensions return an array containing all the extensions.
func (service *Service) Extensions() ([]portainer.Extension, error) {
var extensions = make([]portainer.Extension, 0)
err := service.db.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 extension portainer.Extension
err := internal.UnmarshalObject(v, &extension)
if err != nil {
return err
}
extensions = append(extensions, extension)
}
return nil
})
return extensions, err
}
// Persist persists a extension inside the database.
func (service *Service) Persist(extension *portainer.Extension) error {
return service.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(BucketName))
data, err := internal.MarshalObject(extension)
if err != nil {
return err
}
return bucket.Put(internal.Itob(int(extension.ID)), data)
})
}
// DeleteExtension deletes a Extension.
func (service *Service) DeleteExtension(ID portainer.ExtensionID) error {
identifier := internal.Itob(int(ID))
return internal.DeleteObject(service.db, BucketName, identifier)
}

View File

@@ -0,0 +1,35 @@
package migrator
import (
"strings"
"github.com/portainer/portainer"
)
func (m *Migrator) updateSettingsToDBVersion15() error {
legacySettings, err := m.settingsService.Settings()
if err != nil {
return err
}
legacySettings.EnableHostManagementFeatures = false
return m.settingsService.UpdateSettings(legacySettings)
}
func (m *Migrator) updateTemplatesToVersion15() error {
legacyTemplates, err := m.templateService.Templates()
if err != nil {
return err
}
for _, template := range legacyTemplates {
template.Logo = strings.Replace(template.Logo, "https://portainer.io/images", portainer.AssetsServerURL, -1)
err = m.templateService.UpdateTemplate(template.ID, &template)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,14 @@
package migrator
func (m *Migrator) updateSettingsToDBVersion16() error {
legacySettings, err := m.settingsService.Settings()
if err != nil {
return err
}
if legacySettings.SnapshotInterval == "" {
legacySettings.SnapshotInterval = "5m"
}
return m.settingsService.UpdateSettings(legacySettings)
}

View File

@@ -0,0 +1,19 @@
package migrator
func (m *Migrator) updateExtensionsToDBVersion17() error {
legacyExtensions, err := m.extensionService.Extensions()
if err != nil {
return err
}
for _, extension := range legacyExtensions {
extension.License.Valid = true
err = m.extensionService.Persist(&extension)
if err != nil {
return err
}
}
return nil
}

View File

@@ -5,9 +5,11 @@ import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/endpoint"
"github.com/portainer/portainer/bolt/endpointgroup"
"github.com/portainer/portainer/bolt/extension"
"github.com/portainer/portainer/bolt/resourcecontrol"
"github.com/portainer/portainer/bolt/settings"
"github.com/portainer/portainer/bolt/stack"
"github.com/portainer/portainer/bolt/template"
"github.com/portainer/portainer/bolt/user"
"github.com/portainer/portainer/bolt/version"
)
@@ -19,9 +21,11 @@ type (
db *bolt.DB
endpointGroupService *endpointgroup.Service
endpointService *endpoint.Service
extensionService *extension.Service
resourceControlService *resourcecontrol.Service
settingsService *settings.Service
stackService *stack.Service
templateService *template.Service
userService *user.Service
versionService *version.Service
fileService portainer.FileService
@@ -33,9 +37,11 @@ type (
DatabaseVersion int
EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service
ExtensionService *extension.Service
ResourceControlService *resourcecontrol.Service
SettingsService *settings.Service
StackService *stack.Service
TemplateService *template.Service
UserService *user.Service
VersionService *version.Service
FileService portainer.FileService
@@ -49,8 +55,10 @@ func NewMigrator(parameters *Parameters) *Migrator {
currentDBVersion: parameters.DatabaseVersion,
endpointGroupService: parameters.EndpointGroupService,
endpointService: parameters.EndpointService,
extensionService: parameters.ExtensionService,
resourceControlService: parameters.ResourceControlService,
settingsService: parameters.SettingsService,
templateService: parameters.TemplateService,
stackService: parameters.StackService,
userService: parameters.UserService,
versionService: parameters.VersionService,
@@ -186,5 +194,33 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 1.20.0
if m.currentDBVersion < 15 {
err := m.updateSettingsToDBVersion15()
if err != nil {
return err
}
err = m.updateTemplatesToVersion15()
if err != nil {
return err
}
}
if m.currentDBVersion < 16 {
err := m.updateSettingsToDBVersion16()
if err != nil {
return err
}
}
// Portainer 1.20.1
if m.currentDBVersion < 17 {
err := m.updateExtensionsToDBVersion17()
if err != nil {
return err
}
}
return m.versionService.StoreDBVersion(portainer.DBVersion)
}

View File

@@ -0,0 +1,129 @@
package schedule
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
const (
// BucketName represents the name of the bucket where this service stores data.
BucketName = "schedules"
)
// Service represents a service for managing schedule data.
type Service struct {
db *bolt.DB
}
// NewService creates a new instance of a service.
func NewService(db *bolt.DB) (*Service, error) {
err := internal.CreateBucket(db, BucketName)
if err != nil {
return nil, err
}
return &Service{
db: db,
}, nil
}
// Schedule returns a schedule by ID.
func (service *Service) Schedule(ID portainer.ScheduleID) (*portainer.Schedule, error) {
var schedule portainer.Schedule
identifier := internal.Itob(int(ID))
err := internal.GetObject(service.db, BucketName, identifier, &schedule)
if err != nil {
return nil, err
}
return &schedule, nil
}
// UpdateSchedule updates a schedule.
func (service *Service) UpdateSchedule(ID portainer.ScheduleID, schedule *portainer.Schedule) error {
identifier := internal.Itob(int(ID))
return internal.UpdateObject(service.db, BucketName, identifier, schedule)
}
// DeleteSchedule deletes a schedule.
func (service *Service) DeleteSchedule(ID portainer.ScheduleID) error {
identifier := internal.Itob(int(ID))
return internal.DeleteObject(service.db, BucketName, identifier)
}
// Schedules return a array containing all the schedules.
func (service *Service) Schedules() ([]portainer.Schedule, error) {
var schedules = make([]portainer.Schedule, 0)
err := service.db.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 schedule portainer.Schedule
err := internal.UnmarshalObject(v, &schedule)
if err != nil {
return err
}
schedules = append(schedules, schedule)
}
return nil
})
return schedules, err
}
// SchedulesByJobType return a array containing all the schedules
// with the specified JobType.
func (service *Service) SchedulesByJobType(jobType portainer.JobType) ([]portainer.Schedule, error) {
var schedules = make([]portainer.Schedule, 0)
err := service.db.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 schedule portainer.Schedule
err := internal.UnmarshalObject(v, &schedule)
if err != nil {
return err
}
if schedule.JobType == jobType {
schedules = append(schedules, schedule)
}
}
return nil
})
return schedules, err
}
// CreateSchedule assign an ID to a new schedule and saves it.
func (service *Service) CreateSchedule(schedule *portainer.Schedule) error {
return service.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(BucketName))
// We manually manage sequences for schedules
err := bucket.SetSequence(uint64(schedule.ID))
if err != nil {
return err
}
data, err := internal.MarshalObject(schedule)
if err != nil {
return err
}
return bucket.Put(internal.Itob(int(schedule.ID)), data)
})
}
// GetNextIdentifier returns the next identifier for a schedule.
func (service *Service) GetNextIdentifier() int {
return internal.GetNextIdentifier(service.db, BucketName)
}

View File

@@ -2,7 +2,9 @@ package main // import "github.com/portainer/portainer"
import (
"encoding/json"
"os"
"strings"
"time"
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt"
@@ -87,7 +89,7 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService {
}
func initDigitalSignatureService() portainer.DigitalSignatureService {
return &crypto.ECDSAService{}
return crypto.NewECDSAService(os.Getenv("AGENT_SECRET"))
}
func initCryptoService() portainer.CryptoService {
@@ -110,25 +112,110 @@ func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter
return docker.NewSnapshotter(clientFactory)
}
func initJobScheduler(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter, flags *portainer.CLIFlags) (portainer.JobScheduler, error) {
jobScheduler := cron.NewJobScheduler(endpointService, snapshotter)
func initJobScheduler() portainer.JobScheduler {
return cron.NewJobScheduler()
}
if *flags.ExternalEndpoints != "" {
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
err := jobScheduler.ScheduleEndpointSyncJob(*flags.ExternalEndpoints, *flags.SyncInterval)
if err != nil {
return nil, err
func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter portainer.Snapshotter, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, settingsService portainer.SettingsService) error {
settings, err := settingsService.Settings()
if err != nil {
return err
}
schedules, err := scheduleService.SchedulesByJobType(portainer.SnapshotJobType)
if err != nil {
return err
}
var snapshotSchedule *portainer.Schedule
if len(schedules) == 0 {
snapshotJob := &portainer.SnapshotJob{}
snapshotSchedule = &portainer.Schedule{
ID: portainer.ScheduleID(scheduleService.GetNextIdentifier()),
Name: "system_snapshot",
CronExpression: "@every " + settings.SnapshotInterval,
Recurring: true,
JobType: portainer.SnapshotJobType,
SnapshotJob: snapshotJob,
Created: time.Now().Unix(),
}
} else {
snapshotSchedule = &schedules[0]
}
snapshotJobContext := cron.NewSnapshotJobContext(endpointService, snapshotter)
snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotSchedule, snapshotJobContext)
err = jobScheduler.ScheduleJob(snapshotJobRunner)
if err != nil {
return err
}
if len(schedules) == 0 {
return scheduleService.CreateSchedule(snapshotSchedule)
}
return nil
}
func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, flags *portainer.CLIFlags) error {
if *flags.ExternalEndpoints == "" {
return nil
}
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
schedules, err := scheduleService.SchedulesByJobType(portainer.EndpointSyncJobType)
if err != nil {
return err
}
if len(schedules) != 0 {
return nil
}
endpointSyncJob := &portainer.EndpointSyncJob{}
endpointSyncSchedule := &portainer.Schedule{
ID: portainer.ScheduleID(scheduleService.GetNextIdentifier()),
Name: "system_endpointsync",
CronExpression: "@every " + *flags.SyncInterval,
Recurring: true,
JobType: portainer.EndpointSyncJobType,
EndpointSyncJob: endpointSyncJob,
Created: time.Now().Unix(),
}
endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints)
endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endpointSyncSchedule, endpointSyncJobContext)
err = jobScheduler.ScheduleJob(endpointSyncJobRunner)
if err != nil {
return err
}
return scheduleService.CreateSchedule(endpointSyncSchedule)
}
func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService) error {
schedules, err := scheduleService.Schedules()
if err != nil {
return err
}
for _, schedule := range schedules {
if schedule.JobType == portainer.ScriptExecutionJobType {
jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService)
jobRunner := cron.NewScriptExecutionJobRunner(&schedule, jobContext)
err = jobScheduler.ScheduleJob(jobRunner)
if err != nil {
return err
}
}
}
if *flags.Snapshot {
err := jobScheduler.ScheduleSnapshotJob(*flags.SnapshotInterval)
if err != nil {
return nil, err
}
}
return jobScheduler, nil
return nil
}
func initStatus(endpointManagement, snapshot bool, flags *portainer.CLIFlags) *portainer.Status {
@@ -173,8 +260,10 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
portainer.LDAPGroupSearchSettings{},
},
},
OAuthSettings: portainer.OAuthSettings{},
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
EnableHostManagementFeatures: false,
SnapshotInterval: *flags.SnapshotInterval,
}
@@ -383,6 +472,46 @@ func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointS
return createUnsecuredEndpoint(*flags.EndpointURL, endpointService, snapshotter)
}
func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobService {
return docker.NewJobService(dockerClientFactory)
}
func initExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) (portainer.ExtensionManager, error) {
extensionManager := exec.NewExtensionManager(fileService, extensionService)
extensions, err := extensionService.Extensions()
if err != nil {
return nil, err
}
for _, extension := range extensions {
err := extensionManager.EnableExtension(&extension, extension.License.LicenseKey)
if err != nil {
log.Printf("Unable to enable extension: %s [extension: %s]", err.Error(), extension.Name)
extension.Enabled = false
extension.License.Valid = false
extensionService.Persist(&extension)
}
}
return extensionManager, nil
}
func terminateIfNoAdminCreated(userService portainer.UserService) {
timer1 := time.NewTimer(5 * time.Minute)
<-timer1.C
users, err := userService.UsersByRole(portainer.AdministratorRole)
if err != nil {
log.Fatal(err)
}
if len(users) == 0 {
log.Fatal("No administrator account was created after 5 min. Shutting down the Portainer instance for security reasons.")
return
}
}
func main() {
flags := initCLI()
@@ -406,16 +535,16 @@ func main() {
log.Fatal(err)
}
clientFactory := initClientFactory(digitalSignatureService)
snapshotter := initSnapshotter(clientFactory)
jobScheduler, err := initJobScheduler(store.EndpointService, snapshotter, flags)
extensionManager, err := initExtensionManager(fileService, store.ExtensionService)
if err != nil {
log.Fatal(err)
}
jobScheduler.Start()
clientFactory := initClientFactory(digitalSignatureService)
jobService := initJobService(clientFactory)
snapshotter := initSnapshotter(clientFactory)
endpointManagement := true
if *flags.ExternalEndpoints != "" {
@@ -439,6 +568,27 @@ func main() {
log.Fatal(err)
}
jobScheduler := initJobScheduler()
err = loadSchedulesFromDatabase(jobScheduler, jobService, store.ScheduleService, store.EndpointService, fileService)
if err != nil {
log.Fatal(err)
}
err = loadEndpointSyncSystemSchedule(jobScheduler, store.ScheduleService, store.EndpointService, flags)
if err != nil {
log.Fatal(err)
}
if *flags.Snapshot {
err = loadSnapshotSystemSchedule(jobScheduler, snapshotter, store.ScheduleService, store.EndpointService, store.SettingsService)
if err != nil {
log.Fatal(err)
}
}
jobScheduler.Start()
err = initDockerHub(store.DockerHubService)
if err != nil {
log.Fatal(err)
@@ -487,6 +637,10 @@ func main() {
}
}
if !*flags.NoAuth {
go terminateIfNoAdminCreated(store.UserService)
}
var server portainer.Server = &http.Server{
Status: applicationStatus,
BindAddress: *flags.Addr,
@@ -498,16 +652,19 @@ func main() {
TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService,
EndpointGroupService: store.EndpointGroupService,
ExtensionService: store.ExtensionService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
RegistryService: store.RegistryService,
DockerHubService: store.DockerHubService,
StackService: store.StackService,
ScheduleService: store.ScheduleService,
TagService: store.TagService,
TemplateService: store.TemplateService,
WebhookService: store.WebhookService,
SwarmStackManager: swarmStackManager,
ComposeStackManager: composeStackManager,
ExtensionManager: extensionManager,
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
@@ -520,6 +677,7 @@ func main() {
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
DockerClientFactory: clientFactory,
JobService: jobService,
}
log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr)

View File

@@ -1,60 +0,0 @@
package cron
import (
"log"
"github.com/portainer/portainer"
)
type (
endpointSnapshotJob struct {
endpointService portainer.EndpointService
snapshotter portainer.Snapshotter
}
)
func newEndpointSnapshotJob(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) endpointSnapshotJob {
return endpointSnapshotJob{
endpointService: endpointService,
snapshotter: snapshotter,
}
}
func (job endpointSnapshotJob) Snapshot() error {
endpoints, err := job.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment {
continue
}
snapshot, err := job.snapshotter.CreateSnapshot(&endpoint)
endpoint.Status = portainer.EndpointStatusUp
if err != nil {
log.Printf("cron error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
endpoint.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
}
err = job.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
func (job endpointSnapshotJob) Run() {
err := job.Snapshot()
if err != nil {
log.Printf("cron error: snapshot job error (err=%s)\n", err)
}
}

View File

@@ -9,44 +9,93 @@ import (
"github.com/portainer/portainer"
)
type (
endpointSyncJob struct {
endpointService portainer.EndpointService
endpointFilePath string
}
// EndpointSyncJobRunner is used to run a EndpointSyncJob
type EndpointSyncJobRunner struct {
schedule *portainer.Schedule
context *EndpointSyncJobContext
}
synchronization struct {
endpointsToCreate []*portainer.Endpoint
endpointsToUpdate []*portainer.Endpoint
endpointsToDelete []*portainer.Endpoint
}
// EndpointSyncJobContext represents the context of execution of a EndpointSyncJob
type EndpointSyncJobContext struct {
endpointService portainer.EndpointService
endpointFilePath string
}
fileEndpoint struct {
Name string `json:"Name"`
URL string `json:"URL"`
TLS bool `json:"TLS,omitempty"`
TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"`
TLSCACert string `json:"TLSCACert,omitempty"`
TLSCert string `json:"TLSCert,omitempty"`
TLSKey string `json:"TLSKey,omitempty"`
}
)
const (
// ErrEmptyEndpointArray is an error raised when the external endpoint source array is empty.
ErrEmptyEndpointArray = portainer.Error("External endpoint source is empty")
)
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
return endpointSyncJob{
// NewEndpointSyncJobContext returns a new context that can be used to execute a EndpointSyncJob
func NewEndpointSyncJobContext(endpointService portainer.EndpointService, endpointFilePath string) *EndpointSyncJobContext {
return &EndpointSyncJobContext{
endpointService: endpointService,
endpointFilePath: endpointFilePath,
}
}
// NewEndpointSyncJobRunner returns a new runner that can be scheduled
func NewEndpointSyncJobRunner(schedule *portainer.Schedule, context *EndpointSyncJobContext) *EndpointSyncJobRunner {
return &EndpointSyncJobRunner{
schedule: schedule,
context: context,
}
}
type synchronization struct {
endpointsToCreate []*portainer.Endpoint
endpointsToUpdate []*portainer.Endpoint
endpointsToDelete []*portainer.Endpoint
}
type fileEndpoint struct {
Name string `json:"Name"`
URL string `json:"URL"`
TLS bool `json:"TLS,omitempty"`
TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"`
TLSCACert string `json:"TLSCACert,omitempty"`
TLSCert string `json:"TLSCert,omitempty"`
TLSKey string `json:"TLSKey,omitempty"`
}
// GetSchedule returns the schedule associated to the runner
func (runner *EndpointSyncJobRunner) GetSchedule() *portainer.Schedule {
return runner.schedule
}
// Run triggers the execution of the endpoint synchronization process.
func (runner *EndpointSyncJobRunner) Run() {
data, err := ioutil.ReadFile(runner.context.endpointFilePath)
if endpointSyncError(err) {
return
}
var fileEndpoints []fileEndpoint
err = json.Unmarshal(data, &fileEndpoints)
if endpointSyncError(err) {
return
}
if len(fileEndpoints) == 0 {
log.Println("background job error (endpoint synchronization). External endpoint source is empty")
return
}
storedEndpoints, err := runner.context.endpointService.Endpoints()
if endpointSyncError(err) {
return
}
convertedFileEndpoints := convertFileEndpoints(fileEndpoints)
sync := prepareSyncData(storedEndpoints, convertedFileEndpoints)
if sync.requireSync() {
err = runner.context.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
if endpointSyncError(err) {
return
}
log.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
}
}
func endpointSyncError(err error) bool {
if err != nil {
log.Printf("cron error: synchronization job error (err=%s)\n", err)
log.Printf("background job error (endpoint synchronization). Unable to synchronize endpoints (err=%s)\n", err)
return true
}
return false
@@ -126,8 +175,7 @@ func (sync synchronization) requireSync() bool {
return false
}
// TMP: endpointSyncJob method to access logger, should be generic
func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization {
func prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization {
endpointsToCreate := make([]*portainer.Endpoint, 0)
endpointsToUpdate := make([]*portainer.Endpoint, 0)
endpointsToDelete := make([]*portainer.Endpoint, 0)
@@ -164,43 +212,3 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
endpointsToDelete: endpointsToDelete,
}
}
func (job endpointSyncJob) Sync() error {
data, err := ioutil.ReadFile(job.endpointFilePath)
if endpointSyncError(err) {
return err
}
var fileEndpoints []fileEndpoint
err = json.Unmarshal(data, &fileEndpoints)
if endpointSyncError(err) {
return err
}
if len(fileEndpoints) == 0 {
return ErrEmptyEndpointArray
}
storedEndpoints, err := job.endpointService.Endpoints()
if endpointSyncError(err) {
return err
}
convertedFileEndpoints := convertFileEndpoints(fileEndpoints)
sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints)
if sync.requireSync() {
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
if endpointSyncError(err) {
return err
}
log.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
}
return nil
}
func (job endpointSyncJob) Run() {
log.Println("cron: synchronization job started")
err := job.Sync()
endpointSyncError(err)
}

View File

@@ -0,0 +1,96 @@
package cron
import (
"log"
"time"
"github.com/portainer/portainer"
)
// ScriptExecutionJobRunner is used to run a ScriptExecutionJob
type ScriptExecutionJobRunner struct {
schedule *portainer.Schedule
context *ScriptExecutionJobContext
executedOnce bool
}
// ScriptExecutionJobContext represents the context of execution of a ScriptExecutionJob
type ScriptExecutionJobContext struct {
jobService portainer.JobService
endpointService portainer.EndpointService
fileService portainer.FileService
}
// NewScriptExecutionJobContext returns a new context that can be used to execute a ScriptExecutionJob
func NewScriptExecutionJobContext(jobService portainer.JobService, endpointService portainer.EndpointService, fileService portainer.FileService) *ScriptExecutionJobContext {
return &ScriptExecutionJobContext{
jobService: jobService,
endpointService: endpointService,
fileService: fileService,
}
}
// NewScriptExecutionJobRunner returns a new runner that can be scheduled
func NewScriptExecutionJobRunner(schedule *portainer.Schedule, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner {
return &ScriptExecutionJobRunner{
schedule: schedule,
context: context,
executedOnce: false,
}
}
// Run triggers the execution of the job.
// It will iterate through all the endpoints specified in the context to
// execute the script associated to the job.
func (runner *ScriptExecutionJobRunner) Run() {
if !runner.schedule.Recurring && runner.executedOnce {
return
}
runner.executedOnce = true
scriptFile, err := runner.context.fileService.GetFileContent(runner.schedule.ScriptExecutionJob.ScriptPath)
if err != nil {
log.Printf("scheduled job error (script execution). Unable to retrieve script file (err=%s)\n", err)
return
}
targets := make([]*portainer.Endpoint, 0)
for _, endpointID := range runner.schedule.ScriptExecutionJob.Endpoints {
endpoint, err := runner.context.endpointService.Endpoint(endpointID)
if err != nil {
log.Printf("scheduled job error (script execution). Unable to retrieve information about endpoint (id=%d) (err=%s)\n", endpointID, err)
return
}
targets = append(targets, endpoint)
}
runner.executeAndRetry(targets, scriptFile, 0)
}
func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.Endpoint, script []byte, retryCount int) {
retryTargets := make([]*portainer.Endpoint, 0)
for _, endpoint := range endpoints {
err := runner.context.jobService.ExecuteScript(endpoint, "", runner.schedule.ScriptExecutionJob.Image, script, runner.schedule)
if err == portainer.ErrUnableToPingEndpoint {
retryTargets = append(retryTargets, endpoint)
} else if err != nil {
log.Printf("scheduled job error (script execution). Unable to execute script (endpoint=%s) (err=%s)\n", endpoint.Name, err)
}
}
retryCount++
if retryCount >= runner.schedule.ScriptExecutionJob.RetryCount {
return
}
time.Sleep(time.Duration(runner.schedule.ScriptExecutionJob.RetryInterval) * time.Second)
runner.executeAndRetry(retryTargets, script, retryCount)
}
// GetSchedule returns the schedule associated to the runner
func (runner *ScriptExecutionJobRunner) GetSchedule() *portainer.Schedule {
return runner.schedule
}

85
api/cron/job_snapshot.go Normal file
View File

@@ -0,0 +1,85 @@
package cron
import (
"log"
"github.com/portainer/portainer"
)
// SnapshotJobRunner is used to run a SnapshotJob
type SnapshotJobRunner struct {
schedule *portainer.Schedule
context *SnapshotJobContext
}
// SnapshotJobContext represents the context of execution of a SnapshotJob
type SnapshotJobContext struct {
endpointService portainer.EndpointService
snapshotter portainer.Snapshotter
}
// NewSnapshotJobContext returns a new context that can be used to execute a SnapshotJob
func NewSnapshotJobContext(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) *SnapshotJobContext {
return &SnapshotJobContext{
endpointService: endpointService,
snapshotter: snapshotter,
}
}
// NewSnapshotJobRunner returns a new runner that can be scheduled
func NewSnapshotJobRunner(schedule *portainer.Schedule, context *SnapshotJobContext) *SnapshotJobRunner {
return &SnapshotJobRunner{
schedule: schedule,
context: context,
}
}
// GetSchedule returns the schedule associated to the runner
func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule {
return runner.schedule
}
// Run triggers the execution of the schedule.
// It will iterate through all the endpoints available in the database to
// create a snapshot of each one of them.
// As a snapshot can be a long process, to avoid any concurrency issue we
// retrieve the latest version of the endpoint right after a snapshot.
func (runner *SnapshotJobRunner) Run() {
go func() {
endpoints, err := runner.context.endpointService.Endpoints()
if err != nil {
log.Printf("background schedule error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err)
return
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment {
continue
}
snapshot, snapshotError := runner.context.snapshotter.CreateSnapshot(&endpoint)
latestEndpointReference, err := runner.context.endpointService.Endpoint(endpoint.ID)
if latestEndpointReference == nil {
log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
continue
}
latestEndpointReference.Status = portainer.EndpointStatusUp
if snapshotError != nil {
log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError)
latestEndpointReference.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
}
err = runner.context.endpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
if err != nil {
log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
return
}
}
}()
}

View File

@@ -1,76 +1,109 @@
package cron
import (
"log"
"github.com/portainer/portainer"
"github.com/robfig/cron"
)
// JobScheduler represents a service for managing crons.
// JobScheduler represents a service for managing crons
type JobScheduler struct {
cron *cron.Cron
endpointService portainer.EndpointService
snapshotter portainer.Snapshotter
endpointFilePath string
endpointSyncInterval string
cron *cron.Cron
}
// NewJobScheduler initializes a new service.
func NewJobScheduler(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) *JobScheduler {
// NewJobScheduler initializes a new service
func NewJobScheduler() *JobScheduler {
return &JobScheduler{
cron: cron.New(),
endpointService: endpointService,
snapshotter: snapshotter,
cron: cron.New(),
}
}
// ScheduleEndpointSyncJob schedules a cron job to synchronize the endpoints from a file
func (scheduler *JobScheduler) ScheduleEndpointSyncJob(endpointFilePath string, interval string) error {
scheduler.endpointFilePath = endpointFilePath
scheduler.endpointSyncInterval = interval
job := newEndpointSyncJob(endpointFilePath, scheduler.endpointService)
err := job.Sync()
if err != nil {
return err
}
return scheduler.cron.AddJob("@every "+interval, job)
// ScheduleJob schedules the execution of a job via a runner
func (scheduler *JobScheduler) ScheduleJob(runner portainer.JobRunner) error {
return scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner)
}
// ScheduleSnapshotJob schedules a cron job to create endpoint snapshots
func (scheduler *JobScheduler) ScheduleSnapshotJob(interval string) error {
job := newEndpointSnapshotJob(scheduler.endpointService, scheduler.snapshotter)
go job.Snapshot()
// UpdateSystemJobSchedule updates the first occurence of the specified
// scheduled job based on the specified job type.
// It does so by re-creating a new cron
// and adding all the existing jobs. It will then re-schedule the new job
// with the update cron expression passed in parameter.
// NOTE: the cron library do not support updating schedules directly
// hence the work-around
func (scheduler *JobScheduler) UpdateSystemJobSchedule(jobType portainer.JobType, newCronExpression string) error {
cronEntries := scheduler.cron.Entries()
newCron := cron.New()
return scheduler.cron.AddJob("@every "+interval, job)
}
// UpdateSnapshotJob will update the schedules to match the new snapshot interval
func (scheduler *JobScheduler) UpdateSnapshotJob(interval string) {
// TODO: the cron library do not support removing/updating schedules.
// As a work-around we need to re-create the cron and reschedule the jobs.
// We should update the library.
jobs := scheduler.cron.Entries()
scheduler.cron.Stop()
scheduler.cron = cron.New()
for _, job := range jobs {
switch job.Job.(type) {
case endpointSnapshotJob:
scheduler.ScheduleSnapshotJob(interval)
case endpointSyncJob:
scheduler.ScheduleEndpointSyncJob(scheduler.endpointFilePath, scheduler.endpointSyncInterval)
default:
log.Println("Unsupported job")
for _, entry := range cronEntries {
if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType {
err := newCron.AddJob(newCronExpression, entry.Job)
if err != nil {
return err
}
continue
}
newCron.Schedule(entry.Schedule, entry.Job)
}
scheduler.cron.Stop()
scheduler.cron = newCron
scheduler.cron.Start()
return nil
}
// UpdateJobSchedule updates a specific scheduled job by re-creating a new cron
// and adding all the existing jobs. It will then re-schedule the new job
// via the specified JobRunner parameter.
// NOTE: the cron library do not support updating schedules directly
// hence the work-around
func (scheduler *JobScheduler) UpdateJobSchedule(runner portainer.JobRunner) error {
cronEntries := scheduler.cron.Entries()
newCron := cron.New()
for _, entry := range cronEntries {
if entry.Job.(portainer.JobRunner).GetSchedule().ID == runner.GetSchedule().ID {
var jobRunner cron.Job = runner
if entry.Job.(portainer.JobRunner).GetSchedule().JobType == portainer.SnapshotJobType {
jobRunner = entry.Job
}
err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner)
if err != nil {
return err
}
continue
}
newCron.Schedule(entry.Schedule, entry.Job)
}
scheduler.cron.Stop()
scheduler.cron = newCron
scheduler.cron.Start()
return nil
}
// UnscheduleJob remove a scheduled job by re-creating a new cron
// and adding all the existing jobs except for the one specified via scheduleID.
// NOTE: the cron library do not support removing schedules directly
// hence the work-around
func (scheduler *JobScheduler) UnscheduleJob(scheduleID portainer.ScheduleID) {
cronEntries := scheduler.cron.Entries()
newCron := cron.New()
for _, entry := range cronEntries {
if entry.Job.(portainer.JobRunner).GetSchedule().ID == scheduleID {
continue
}
newCron.Schedule(entry.Schedule, entry.Job)
}
scheduler.cron.Stop()
scheduler.cron = newCron
scheduler.cron.Start()
}

View File

@@ -26,6 +26,15 @@ type ECDSAService struct {
privateKey *ecdsa.PrivateKey
publicKey *ecdsa.PublicKey
encodedPubKey string
secret string
}
// NewECDSAService returns a pointer to a ECDSAService.
// An optional secret can be specified
func NewECDSAService(secret string) *ECDSAService {
return &ECDSAService{
secret: secret,
}
}
// EncodedPublicKey returns the encoded version of the public that can be used
@@ -91,11 +100,17 @@ func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) {
return private, public, nil
}
// Sign creates a signature from a message.
// It automatically hash the message using MD5 and creates a signature from
// CreateSignature creates a digital signature.
// It automatically hash a specific message using MD5 and creates a signature from
// that hash.
// If a secret is associated to the service, it will be used instead of the specified
// message.
// It then encodes the generated signature in base64.
func (service *ECDSAService) Sign(message string) (string, error) {
func (service *ECDSAService) CreateSignature(message string) (string, error) {
if service.secret != "" {
message = service.secret
}
hash := HashFromBytes([]byte(message))
r := big.NewInt(0)

View File

@@ -27,12 +27,13 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService) *Clien
}
// CreateClient is a generic function to create a Docker client based on
// a specific endpoint configuration
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*client.Client, error) {
// a specific endpoint configuration. The nodeName parameter can be used
// with an agent enabled endpoint to target a specific node in an agent cluster.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) {
if endpoint.Type == portainer.AzureEnvironment {
return nil, unsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
return createAgentClient(endpoint, factory.signatureService)
return createAgentClient(endpoint, factory.signatureService, nodeName)
}
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
@@ -61,13 +62,13 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
)
}
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService) (*client.Client, error) {
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
if err != nil {
return nil, err
}
signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
@@ -77,6 +78,10 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
portainer.PortainerAgentSignatureHeader: signature,
}
if nodeName != "" {
headers[portainer.PortainerAgentTargetHeader] = nodeName
}
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(portainer.SupportedDockerAPIVersion),
@@ -97,7 +102,7 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
}
return &http.Client{
Timeout: time.Second * 10,
Transport: transport,
Timeout: 30 * time.Second,
}, nil
}

115
api/docker/job.go Normal file
View File

@@ -0,0 +1,115 @@
package docker
import (
"bytes"
"context"
"io"
"io/ioutil"
"strconv"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/client"
"github.com/portainer/portainer"
"github.com/portainer/portainer/archive"
)
// JobService represents a service that handles the execution of jobs
type JobService struct {
dockerClientFactory *ClientFactory
}
// NewJobService returns a pointer to a new job service
func NewJobService(dockerClientFactory *ClientFactory) *JobService {
return &JobService{
dockerClientFactory: dockerClientFactory,
}
}
// ExecuteScript will leverage a privileged container to execute a script against the specified endpoint/nodename.
// It will copy the script content specified as a parameter inside a container based on the specified image and execute it.
func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, image string, script []byte, schedule *portainer.Schedule) error {
buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700)
if err != nil {
return err
}
cli, err := service.dockerClientFactory.CreateClient(endpoint, nodeName)
if err != nil {
return err
}
defer cli.Close()
_, err = cli.Ping(context.Background())
if err != nil {
return portainer.ErrUnableToPingEndpoint
}
err = pullImage(cli, image)
if err != nil {
return err
}
containerConfig := &container.Config{
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
WorkingDir: "/tmp",
Image: image,
Labels: map[string]string{
"io.portainer.job.endpoint": strconv.Itoa(int(endpoint.ID)),
},
Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}),
}
if schedule != nil {
containerConfig.Labels["io.portainer.schedule.id"] = strconv.Itoa(int(schedule.ID))
}
hostConfig := &container.HostConfig{
Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"},
NetworkMode: "host",
Privileged: true,
}
networkConfig := &network.NetworkingConfig{}
body, err := cli.ContainerCreate(context.Background(), containerConfig, hostConfig, networkConfig, "")
if err != nil {
return err
}
if schedule != nil {
err = cli.ContainerRename(context.Background(), body.ID, schedule.Name+"_"+body.ID)
if err != nil {
return err
}
}
copyOptions := types.CopyToContainerOptions{}
err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions)
if err != nil {
return err
}
startOptions := types.ContainerStartOptions{}
return cli.ContainerStart(context.Background(), body.ID, startOptions)
}
func pullImage(cli *client.Client, image string) error {
imageReadCloser, err := cli.ImagePull(context.Background(), image, types.ImagePullOptions{})
if err != nil {
return err
}
defer imageReadCloser.Close()
_, err = io.Copy(ioutil.Discard, imageReadCloser)
if err != nil {
return err
}
return nil
}

View File

@@ -52,6 +52,16 @@ func snapshot(cli *client.Client) (*portainer.Snapshot, error) {
return nil, err
}
err = snapshotNetworks(snapshot, cli)
if err != nil {
return nil, err
}
err = snapshotVersion(snapshot, cli)
if err != nil {
return nil, err
}
snapshot.Time = time.Now().Unix()
return snapshot, nil
}
@@ -66,6 +76,7 @@ func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error {
snapshot.DockerVersion = info.ServerVersion
snapshot.TotalCPU = info.NCPU
snapshot.TotalMemory = info.MemTotal
snapshot.SnapshotRaw.Info = info
return nil
}
@@ -132,6 +143,7 @@ func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error
snapshot.RunningContainerCount = runningContainers
snapshot.StoppedContainerCount = stoppedContainers
snapshot.StackCount += len(stacks)
snapshot.SnapshotRaw.Containers = containers
return nil
}
@@ -142,6 +154,7 @@ func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error {
}
snapshot.ImageCount = len(images)
snapshot.SnapshotRaw.Images = images
return nil
}
@@ -152,5 +165,24 @@ func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error {
}
snapshot.VolumeCount = len(volumes.Volumes)
snapshot.SnapshotRaw.Volumes = volumes
return nil
}
func snapshotNetworks(snapshot *portainer.Snapshot, cli *client.Client) error {
networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{})
if err != nil {
return err
}
snapshot.SnapshotRaw.Networks = networks
return nil
}
func snapshotVersion(snapshot *portainer.Snapshot, cli *client.Client) error {
version, err := cli.ServerVersion(context.Background())
if err != nil {
return err
}
snapshot.SnapshotRaw.Version = version
return nil
}

View File

@@ -18,7 +18,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
// CreateSnapshot creates a snapshot of a specific endpoint
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) {
cli, err := snapshotter.clientFactory.CreateClient(endpoint)
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "")
if err != nil {
return nil, err
}

View File

@@ -88,6 +88,21 @@ const (
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
)
// Extension errors.
const (
ErrExtensionAlreadyEnabled = Error("This extension is already enabled")
)
// Docker errors.
const (
ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint")
)
// Schedule errors.
const (
ErrHostManagementFeaturesDisabled = Error("Host management features are disabled")
)
// Error represents an application error.
type Error string

211
api/exec/extension.go Normal file
View File

@@ -0,0 +1,211 @@
package exec
import (
"bytes"
"encoding/json"
"errors"
"os/exec"
"path"
"runtime"
"strconv"
"strings"
"github.com/orcaman/concurrent-map"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/client"
)
var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/"
var extensionBinaryMap = map[portainer.ExtensionID]string{
portainer.RegistryManagementExtension: "extension-registry-management",
portainer.OAuthAuthenticationExtension: "extension-oauth-authentication",
}
// ExtensionManager represents a service used to
// manage extension processes.
type ExtensionManager struct {
processes cmap.ConcurrentMap
fileService portainer.FileService
extensionService portainer.ExtensionService
}
// NewExtensionManager returns a pointer to an ExtensionManager
func NewExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) *ExtensionManager {
return &ExtensionManager{
processes: cmap.New(),
fileService: fileService,
extensionService: extensionService,
}
}
func processKey(ID portainer.ExtensionID) string {
return strconv.Itoa(int(ID))
}
func buildExtensionURL(extension *portainer.Extension) string {
extensionURL := extensionDownloadBaseURL
extensionURL += extensionBinaryMap[extension.ID]
extensionURL += "-" + runtime.GOOS + "-" + runtime.GOARCH
extensionURL += "-" + extension.Version
extensionURL += ".zip"
return extensionURL
}
func buildExtensionPath(binaryPath string, extension *portainer.Extension) string {
extensionFilename := extensionBinaryMap[extension.ID]
extensionFilename += "-" + runtime.GOOS + "-" + runtime.GOARCH
extensionFilename += "-" + extension.Version
if runtime.GOOS == "windows" {
extensionFilename += ".exe"
}
extensionPath := path.Join(
binaryPath,
extensionFilename)
return extensionPath
}
// FetchExtensionDefinitions will fetch the list of available
// extension definitions from the official Portainer assets server
func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) {
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30)
if err != nil {
return nil, err
}
var extensions []portainer.Extension
err = json.Unmarshal(extensionData, &extensions)
if err != nil {
return nil, err
}
return extensions, nil
}
// EnableExtension will check for the existence of the extension binary on the filesystem
// first. If it does not exist, it will download it from the official Portainer assets server.
// After installing the binary on the filesystem, it will execute the binary in license check
// mode to validate the extension license. If the license is valid, it will then start
// the extension process and register it in the processes map.
func (manager *ExtensionManager) EnableExtension(extension *portainer.Extension, licenseKey string) error {
extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension)
extensionBinaryExists, err := manager.fileService.FileExists(extensionBinaryPath)
if err != nil {
return err
}
if !extensionBinaryExists {
err := manager.downloadExtension(extension)
if err != nil {
return err
}
}
licenseDetails, err := validateLicense(extensionBinaryPath, licenseKey)
if err != nil {
return err
}
extension.License = portainer.LicenseInformation{
LicenseKey: licenseKey,
Company: licenseDetails[0],
Expiration: licenseDetails[1],
Valid: true,
}
extension.Version = licenseDetails[2]
return manager.startExtensionProcess(extension, extensionBinaryPath)
}
// DisableExtension will retrieve the process associated to the extension
// from the processes map and kill the process. It will then remove the process
// from the processes map and remove the binary associated to the extension
// from the filesystem
func (manager *ExtensionManager) DisableExtension(extension *portainer.Extension) error {
process, ok := manager.processes.Get(processKey(extension.ID))
if !ok {
return nil
}
err := process.(*exec.Cmd).Process.Kill()
if err != nil {
return err
}
manager.processes.Remove(processKey(extension.ID))
extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension)
return manager.fileService.RemoveDirectory(extensionBinaryPath)
}
// UpdateExtension will download the new extension binary from the official Portainer assets
// server, disable the previous extension via DisableExtension, trigger a license check
// and then start the extension process and add it to the processes map
func (manager *ExtensionManager) UpdateExtension(extension *portainer.Extension, version string) error {
oldVersion := extension.Version
extension.Version = version
err := manager.downloadExtension(extension)
if err != nil {
return err
}
extension.Version = oldVersion
err = manager.DisableExtension(extension)
if err != nil {
return err
}
extension.Version = version
extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension)
licenseDetails, err := validateLicense(extensionBinaryPath, extension.License.LicenseKey)
if err != nil {
return err
}
extension.Version = licenseDetails[2]
return manager.startExtensionProcess(extension, extensionBinaryPath)
}
func (manager *ExtensionManager) downloadExtension(extension *portainer.Extension) error {
extensionURL := buildExtensionURL(extension)
data, err := client.Get(extensionURL, 30)
if err != nil {
return err
}
return manager.fileService.ExtractExtensionArchive(data)
}
func validateLicense(binaryPath, licenseKey string) ([]string, error) {
licenseCheckProcess := exec.Command(binaryPath, "-license", licenseKey, "-check")
cmdOutput := &bytes.Buffer{}
licenseCheckProcess.Stdout = cmdOutput
err := licenseCheckProcess.Run()
if err != nil {
return nil, errors.New("Invalid extension license key")
}
output := string(cmdOutput.Bytes())
return strings.Split(output, "|"), nil
}
func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Extension, binaryPath string) error {
extensionProcess := exec.Command(binaryPath, "-license", extension.License.LicenseKey)
err := extensionProcess.Start()
if err != nil {
return err
}
manager.processes.Set(processKey(extension.ID), extensionProcess)
return nil
}

View File

@@ -140,7 +140,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string)
return err
}
signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}

View File

@@ -7,6 +7,7 @@ import (
"io/ioutil"
"github.com/portainer/portainer"
"github.com/portainer/portainer/archive"
"io"
"os"
@@ -32,6 +33,13 @@ const (
PrivateKeyFile = "portainer.key"
// PublicKeyFile represents the name on disk of the file containing the public key.
PublicKeyFile = "portainer.pub"
// BinaryStorePath represents the subfolder where binaries are stored in the file store folder.
BinaryStorePath = "bin"
// ScheduleStorePath represents the subfolder where schedule files are stored.
ScheduleStorePath = "schedules"
// ExtensionRegistryManagementStorePath represents the subfolder where files related to the
// registry management extension are stored.
ExtensionRegistryManagementStorePath = "extensions"
)
// Service represents a service for managing files and directories.
@@ -63,9 +71,30 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return nil, err
}
err = service.createDirectoryInStore(BinaryStorePath)
if err != nil {
return nil, err
}
return service, nil
}
// GetBinaryFolder returns the full path to the binary store on the filesystem
func (service *Service) GetBinaryFolder() string {
return path.Join(service.fileStorePath, BinaryStorePath)
}
// ExtractExtensionArchive extracts the content of an extension archive
// specified as raw data into the binary store on the filesystem
func (service *Service) ExtractExtensionArchive(data []byte) error {
err := archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath))
if err != nil {
return err
}
return nil
}
// RemoveDirectory removes a directory on the filesystem.
func (service *Service) RemoveDirectory(directoryPath string) error {
return os.RemoveAll(directoryPath)
@@ -97,6 +126,27 @@ func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string
return path.Join(service.fileStorePath, stackStorePath), nil
}
// StoreRegistryManagementFileFromBytes creates a subfolder in the
// ExtensionRegistryManagementStorePath and stores a new file from bytes.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) {
extensionStorePath := path.Join(ExtensionRegistryManagementStorePath, folder)
err := service.createDirectoryInStore(extensionStorePath)
if err != nil {
return "", err
}
file := path.Join(extensionStorePath, fileName)
r := bytes.NewReader(data)
err = service.createFileInStore(file, r)
if err != nil {
return "", err
}
return path.Join(service.fileStorePath, file), nil
}
// StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes.
// It returns the path to the newly created file.
func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) {
@@ -318,3 +368,32 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
block, _ := pem.Decode(fileContent)
return block.Bytes, nil
}
// GetScheduleFolder returns the absolute path on the filesystem for a schedule based
// on its identifier.
func (service *Service) GetScheduleFolder(identifier string) string {
return path.Join(service.fileStorePath, ScheduleStorePath, identifier)
}
// StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) {
scheduleStorePath := path.Join(ScheduleStorePath, identifier)
err := service.createDirectoryInStore(scheduleStorePath)
if err != nil {
return "", err
}
filePath := path.Join(scheduleStorePath, createScheduledJobFileName(identifier))
r := bytes.NewReader(data)
err = service.createFileInStore(filePath, r)
if err != nil {
return "", err
}
return path.Join(service.fileStorePath, filePath), nil
}
func createScheduledJobFileName(identifier string) string {
return "job_" + identifier + ".sh"
}

View File

@@ -0,0 +1,138 @@
package auth
import (
"encoding/json"
"io/ioutil"
"net/http"
"log"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/portainer"
)
type oauthPayload struct {
Code string
}
func (payload *oauthPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Code) {
return portainer.Error("Invalid OAuth authorization code")
}
return nil
}
func (handler *Handler) authenticateThroughExtension(code, licenseKey string, settings *portainer.OAuthSettings) (string, error) {
extensionURL := handler.ProxyManager.GetExtensionURL(portainer.OAuthAuthenticationExtension)
encodedConfiguration, err := json.Marshal(settings)
if err != nil {
return "", nil
}
req, err := http.NewRequest("GET", extensionURL+"/validate", nil)
if err != nil {
return "", err
}
client := &http.Client{}
req.Header.Set("X-OAuth-Config", string(encodedConfiguration))
req.Header.Set("X-OAuth-Code", code)
req.Header.Set("X-PortainerExtension-License", licenseKey)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
type extensionResponse struct {
Username string `json:"Username,omitempty"`
Err string `json:"err,omitempty"`
Details string `json:"details,omitempty"`
}
var extResp extensionResponse
err = json.Unmarshal(body, &extResp)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", portainer.Error(extResp.Err + ":" + extResp.Details)
}
return extResp.Username, nil
}
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload oauthPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
if settings.AuthenticationMethod != 3 {
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", portainer.Error("OAuth authentication is not enabled")}
}
extension, err := handler.ExtensionService.Extension(portainer.OAuthAuthenticationExtension)
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Oauth authentication extension is not enabled", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
}
username, err := handler.authenticateThroughExtension(payload.Code, extension.License.LicenseKey, &settings.OAuthSettings)
if err != nil {
log.Printf("[DEBUG] - OAuth authentication error: %s", err)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", portainer.ErrUnauthorized}
}
user, err := handler.UserService.UserByUsername(username)
if err != nil && err != portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
}
if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers {
return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", portainer.ErrUnauthorized}
}
if user == nil {
user = &portainer.User{
Username: username,
Role: portainer.StandardUserRole,
}
err = handler.UserService.CreateUser(user)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
}
if settings.OAuthSettings.DefaultTeamID != 0 {
membership := &portainer.TeamMembership{
UserID: user.ID,
TeamID: settings.OAuthSettings.DefaultTeamID,
Role: portainer.TeamMember,
}
err = handler.TeamMembershipService.CreateTeamMembership(membership)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err}
}
}
}
return handler.writeToken(w, user)
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
)
@@ -28,6 +29,8 @@ type Handler struct {
SettingsService portainer.SettingsService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
ExtensionService portainer.ExtensionService
ProxyManager *proxy.Manager
}
// NewHandler creates a handler to manage authentication operations.
@@ -36,6 +39,9 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi
Router: mux.NewRouter(),
authDisabled: authDisabled,
}
h.Handle("/auth/oauth/validate",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost)
h.Handle("/auth",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)

View File

@@ -1,6 +1,7 @@
package endpointproxy
import (
"errors"
"strconv"
httperror "github.com/portainer/libhttp/error"
@@ -23,6 +24,10 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
if endpoint.Status == portainer.EndpointStatusDown {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")}
}
err = handler.requestBouncer.EndpointAccess(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied}

View File

@@ -1,5 +1,7 @@
package endpointproxy
// TODO: legacy extension management
import (
"strconv"
@@ -42,9 +44,9 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt
proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension)
var proxy http.Handler
proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey)
proxy = handler.ProxyManager.GetLegacyExtensionProxy(proxyExtensionKey)
if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
proxy, err = handler.ProxyManager.CreateLegacyExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err}
}

View File

@@ -42,7 +42,6 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
}
handler.ProxyManager.DeleteProxy(string(endpointID))
handler.ProxyManager.DeleteExtensionProxies(string(endpointID))
return response.Empty(w)
}

View File

@@ -1,5 +1,7 @@
package endpoints
// TODO: legacy extension management
import (
"net/http"

View File

@@ -1,5 +1,7 @@
package endpoints
// TODO: legacy extension management
import (
"net/http"

View File

@@ -0,0 +1,116 @@
package endpoints
import (
"errors"
"net/http"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type endpointJobFromFilePayload struct {
Image string
File []byte
}
type endpointJobFromFileContentPayload struct {
Image string
FileContent string
}
func (payload *endpointJobFromFilePayload) Validate(r *http.Request) error {
file, _, err := request.RetrieveMultiPartFormFile(r, "File")
if err != nil {
return portainer.Error("Invalid Script file. Ensure that the file is uploaded correctly")
}
payload.File = file
image, err := request.RetrieveMultiPartFormValue(r, "Image", false)
if err != nil {
return portainer.Error("Invalid image name")
}
payload.Image = image
return nil
}
func (payload *endpointJobFromFileContentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.FileContent) {
return portainer.Error("Invalid script file content")
}
if govalidator.IsNull(payload.Image) {
return portainer.Error("Invalid image name")
}
return nil
}
// POST request on /api/endpoints/:id/job?method&nodeName
func (handler *Handler) endpointJob(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}
}
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err}
}
nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true)
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.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}
}
err = handler.requestBouncer.EndpointAccess(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied}
}
switch method {
case "file":
return handler.executeJobFromFile(w, r, endpoint, nodeName)
case "string":
return handler.executeJobFromFileContent(w, r, endpoint, nodeName)
}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string or file", errors.New(request.ErrInvalidQueryParameter)}
}
func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError {
payload := &endpointJobFromFilePayload{}
err := payload.Validate(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, payload.File, nil)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
}
return response.Empty(w)
}
func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError {
var payload endpointJobFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, []byte(payload.FileContent), nil)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
}
return response.Empty(w)
}

View File

@@ -1,41 +1,51 @@
package endpoints
import (
"log"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
// POST request on /api/endpoints/snapshot
// POST request on /api/endpoints/:id/snapshot
func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoints, err := handler.EndpointService.Endpoints()
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment {
continue
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.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}
}
snapshot, err := handler.Snapshotter.CreateSnapshot(&endpoint)
endpoint.Status = portainer.EndpointStatusUp
if err != nil {
log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
endpoint.Status = portainer.EndpointStatusDown
}
if endpoint.Type == portainer.AzureEnvironment {
return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err}
}
if snapshot != nil {
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
}
snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint)
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID)
if latestEndpointReference == nil {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
}
latestEndpointReference.Status = portainer.EndpointStatusUp
if snapshotError != nil {
latestEndpointReference.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
}
err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
return response.Empty(w)

View File

@@ -0,0 +1,49 @@
package endpoints
import (
"log"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
// POST request on /api/endpoints/snapshot
func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment {
continue
}
snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint)
latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID)
if latestEndpointReference == nil {
log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
continue
}
latestEndpointReference.Status = portainer.EndpointStatusUp
if snapshotError != nil {
log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError)
latestEndpointReference.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
}
err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
}
return response.Empty(w)
}

View File

@@ -12,16 +12,17 @@ import (
)
type endpointUpdatePayload struct {
Name string
URL string
PublicURL string
GroupID int
TLS bool
TLSSkipVerify bool
TLSSkipClientVerify bool
AzureApplicationID string
AzureTenantID string
AzureAuthenticationKey string
Name *string
URL *string
PublicURL *string
GroupID *int
TLS *bool
TLSSkipVerify *bool
TLSSkipClientVerify *bool
Status *int
AzureApplicationID *string
AzureTenantID *string
AzureAuthenticationKey *string
Tags []string
}
@@ -53,36 +54,49 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
if payload.Name != "" {
endpoint.Name = payload.Name
if payload.Name != nil {
endpoint.Name = *payload.Name
}
if payload.URL != "" {
endpoint.URL = payload.URL
if payload.URL != nil {
endpoint.URL = *payload.URL
}
if payload.PublicURL != "" {
endpoint.PublicURL = payload.PublicURL
if payload.PublicURL != nil {
endpoint.PublicURL = *payload.PublicURL
}
if payload.GroupID != 0 {
endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID)
if payload.GroupID != nil {
endpoint.GroupID = portainer.EndpointGroupID(*payload.GroupID)
}
if payload.Tags != nil {
endpoint.Tags = payload.Tags
}
if payload.Status != nil {
switch *payload.Status {
case 1:
endpoint.Status = portainer.EndpointStatusUp
break
case 2:
endpoint.Status = portainer.EndpointStatusDown
break
default:
break
}
}
if endpoint.Type == portainer.AzureEnvironment {
credentials := endpoint.AzureCredentials
if payload.AzureApplicationID != "" {
credentials.ApplicationID = payload.AzureApplicationID
if payload.AzureApplicationID != nil {
credentials.ApplicationID = *payload.AzureApplicationID
}
if payload.AzureTenantID != "" {
credentials.TenantID = payload.AzureTenantID
if payload.AzureTenantID != nil {
credentials.TenantID = *payload.AzureTenantID
}
if payload.AzureAuthenticationKey != "" {
credentials.AuthenticationKey = payload.AzureAuthenticationKey
if payload.AzureAuthenticationKey != nil {
credentials.AuthenticationKey = *payload.AzureAuthenticationKey
}
httpClient := client.NewHTTPClient()
@@ -93,44 +107,55 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
endpoint.AzureCredentials = credentials
}
folder := strconv.Itoa(endpointID)
if payload.TLS {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = payload.TLSSkipVerify
if !payload.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
} else {
endpoint.TLSConfig.TLSCACertPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA)
}
if payload.TLS != nil {
folder := strconv.Itoa(endpointID)
if *payload.TLS {
endpoint.TLSConfig.TLS = true
if payload.TLSSkipVerify != nil {
endpoint.TLSConfig.TLSSkipVerify = *payload.TLSSkipVerify
if !*payload.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
} else {
endpoint.TLSConfig.TLSCACertPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA)
}
}
if payload.TLSSkipClientVerify != nil {
if !*payload.TLSSkipClientVerify {
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
} else {
endpoint.TLSConfig.TLSCertPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSKeyPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey)
}
}
if !payload.TLSSkipClientVerify {
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
} else {
endpoint.TLSConfig.TLS = false
endpoint.TLSConfig.TLSSkipVerify = false
endpoint.TLSConfig.TLSCACertPath = ""
endpoint.TLSConfig.TLSCertPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSKeyPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey)
}
} else {
endpoint.TLSConfig.TLS = false
endpoint.TLSConfig.TLSSkipVerify = false
endpoint.TLSConfig.TLSCACertPath = ""
endpoint.TLSConfig.TLSCertPath = ""
endpoint.TLSConfig.TLSKeyPath = ""
err = handler.FileService.DeleteTLSFiles(folder)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err}
err = handler.FileService.DeleteTLSFiles(folder)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err}
}
}
}
_, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err}
if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment {
_, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err}
}
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)

View File

@@ -31,6 +31,7 @@ type Handler struct {
FileService portainer.FileService
ProxyManager *proxy.Manager
Snapshotter portainer.Snapshotter
JobService portainer.JobService
}
// NewHandler creates a handler to manage endpoint operations.
@@ -44,7 +45,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
h.Handle("/endpoints",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost)
h.Handle("/endpoints/snapshot",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost)
h.Handle("/endpoints",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}",
@@ -59,6 +60,9 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/extensions/{extensionType}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/job",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/snapshot",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
return h
}

View File

@@ -0,0 +1,79 @@
package extensions
import (
"net/http"
"strconv"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type extensionCreatePayload struct {
License string
}
func (payload *extensionCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.License) {
return portainer.Error("Invalid license")
}
return nil
}
func (handler *Handler) extensionCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload extensionCreatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
extensionIdentifier, err := strconv.Atoi(string(payload.License[0]))
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid license format", err}
}
extensionID := portainer.ExtensionID(extensionIdentifier)
extensions, err := handler.ExtensionService.Extensions()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions status from the database", err}
}
for _, existingExtension := range extensions {
if existingExtension.ID == extensionID && existingExtension.Enabled {
return &httperror.HandlerError{http.StatusConflict, "Unable to enable extension", portainer.ErrExtensionAlreadyEnabled}
}
}
extension := &portainer.Extension{
ID: extensionID,
}
extensionDefinitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err}
}
for _, def := range extensionDefinitions {
if def.ID == extension.ID {
extension.Version = def.Version
break
}
}
err = handler.ExtensionManager.EnableExtension(extension, payload.License)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to enable extension", err}
}
extension.Enabled = true
err = handler.ExtensionService.Persist(extension)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,38 @@
package extensions
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
// DELETE request on /api/extensions/:id
func (handler *Handler) extensionDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
}
extensionID := portainer.ExtensionID(extensionIdentifier)
extension, err := handler.ExtensionService.Extension(extensionID)
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
}
err = handler.ExtensionManager.DisableExtension(extension)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete extension", err}
}
err = handler.ExtensionService.DeleteExtension(extensionID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the extension from the database", err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,63 @@
package extensions
import (
"encoding/json"
"net/http"
"github.com/coreos/go-semver/semver"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/client"
)
// GET request on /api/extensions/:id
func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
}
extensionID := portainer.ExtensionID(extensionIdentifier)
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err}
}
var extensions []portainer.Extension
err = json.Unmarshal(extensionData, &extensions)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external extension definitions", err}
}
var extension portainer.Extension
for _, p := range extensions {
if p.ID == extensionID {
extension = p
if extension.DescriptionURL != "" {
description, _ := client.Get(extension.DescriptionURL, 10)
extension.Description = string(description)
}
break
}
}
storedExtension, err := handler.ExtensionService.Extension(extensionID)
if err == portainer.ErrObjectNotFound {
return response.JSON(w, extension)
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
}
extension.Enabled = storedExtension.Enabled
extensionVer := semver.New(extension.Version)
pVer := semver.New(storedExtension.Version)
if pVer.LessThan(*extensionVer) {
extension.UpdateAvailable = true
}
return response.JSON(w, extension)
}

View File

@@ -0,0 +1,56 @@
package extensions
import (
"net/http"
"github.com/coreos/go-semver/semver"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
// GET request on /api/extensions?store=<store>
func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
storeDetails, _ := request.RetrieveBooleanQueryParameter(r, "store", true)
extensions, err := handler.ExtensionService.Extensions()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err}
}
if storeDetails {
definitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions", err}
}
for idx := range definitions {
associateExtensionData(&definitions[idx], extensions)
}
extensions = definitions
}
return response.JSON(w, extensions)
}
func associateExtensionData(definition *portainer.Extension, extensions []portainer.Extension) {
for _, extension := range extensions {
if extension.ID == definition.ID {
definition.Enabled = extension.Enabled
definition.License.Company = extension.License.Company
definition.License.Expiration = extension.License.Expiration
definition.License.Valid = extension.License.Valid
definitionVersion := semver.New(definition.Version)
extensionVersion := semver.New(extension.Version)
if extensionVersion.LessThan(*definitionVersion) {
definition.UpdateAvailable = true
}
break
}
}
}

View File

@@ -0,0 +1,56 @@
package extensions
import (
"net/http"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type extensionUpdatePayload struct {
Version string
}
func (payload *extensionUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Version) {
return portainer.Error("Invalid extension version")
}
return nil
}
func (handler *Handler) extensionUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
}
extensionID := portainer.ExtensionID(extensionIdentifier)
var payload extensionUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
extension, err := handler.ExtensionService.Extension(extensionID)
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
}
err = handler.ExtensionManager.UpdateExtension(extension, payload.Version)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update extension", err}
}
err = handler.ExtensionService.Persist(extension)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,37 @@
package extensions
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
)
// Handler is the HTTP handler used to handle extension operations.
type Handler struct {
*mux.Router
ExtensionService portainer.ExtensionService
ExtensionManager portainer.ExtensionManager
}
// NewHandler creates a handler to manage extension operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/extensions",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet)
h.Handle("/extensions",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost)
h.Handle("/extensions/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet)
h.Handle("/extensions/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete)
h.Handle("/extensions/{id}/update",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost)
return h
}

View File

@@ -9,10 +9,12 @@ import (
"github.com/portainer/portainer/http/handler/endpointgroups"
"github.com/portainer/portainer/http/handler/endpointproxy"
"github.com/portainer/portainer/http/handler/endpoints"
"github.com/portainer/portainer/http/handler/extensions"
"github.com/portainer/portainer/http/handler/file"
"github.com/portainer/portainer/http/handler/motd"
"github.com/portainer/portainer/http/handler/registries"
"github.com/portainer/portainer/http/handler/resourcecontrols"
"github.com/portainer/portainer/http/handler/schedules"
"github.com/portainer/portainer/http/handler/settings"
"github.com/portainer/portainer/http/handler/stacks"
"github.com/portainer/portainer/http/handler/status"
@@ -36,6 +38,7 @@ type Handler struct {
EndpointProxyHandler *endpointproxy.Handler
FileHandler *file.Handler
MOTDHandler *motd.Handler
ExtensionHandler *extensions.Handler
RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler
SettingsHandler *settings.Handler
@@ -49,6 +52,7 @@ type Handler struct {
UserHandler *users.Handler
WebSocketHandler *websocket.Handler
WebhookHandler *webhooks.Handler
SchedulesHanlder *schedules.Handler
}
// ServeHTTP delegates a request to the appropriate subhandler.
@@ -73,6 +77,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
case strings.HasPrefix(r.URL.Path, "/api/motd"):
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/extensions"):
http.StripPrefix("/api", h.ExtensionHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/registries"):
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/resource_controls"):
@@ -99,6 +105,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/webhooks"):
http.StripPrefix("/api", h.WebhookHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/schedules"):
http.StripPrefix("/api", h.SchedulesHanlder).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/"):
h.FileHandler.ServeHTTP(w, r)
}

View File

@@ -10,6 +10,7 @@ import (
)
type motdResponse struct {
Title string `json:"Title"`
Message string `json:"Message"`
Hash []byte `json:"Hash"`
}
@@ -18,10 +19,16 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
motd, err := client.Get(portainer.MessageOfTheDayURL, 0)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
response.JSON(w, &motdResponse{Message: ""})
return
}
title, err := client.Get(portainer.MessageOfTheDayTitleURL, 0)
if err != nil {
response.JSON(w, &motdResponse{Message: ""})
return
}
hash := crypto.HashFromBytes(motd)
response.JSON(w, &motdResponse{Message: string(motd), Hash: hash})
response.JSON(w, &motdResponse{Title: string(title), Message: string(motd), Hash: hash})
}

View File

@@ -1,29 +1,35 @@
package registries
import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
)
func hideFields(registry *portainer.Registry) {
registry.Password = ""
registry.ManagementConfiguration = nil
}
// Handler is the HTTP handler used to handle registry operations.
type Handler struct {
*mux.Router
RegistryService portainer.RegistryService
requestBouncer *security.RequestBouncer
RegistryService portainer.RegistryService
ExtensionService portainer.ExtensionService
FileService portainer.FileService
ProxyManager *proxy.Manager
}
// NewHandler creates a handler to manage registry operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
h.Handle("/registries",
@@ -31,13 +37,17 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
h.Handle("/registries",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet)
h.Handle("/registries/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet)
bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet)
h.Handle("/registries/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut)
h.Handle("/registries/{id}/access",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdateAccess))).Methods(http.MethodPut)
h.Handle("/registries/{id}/configure",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost)
h.Handle("/registries/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete)
h.PathPrefix("/registries/{id}/v2").Handler(
bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI)))
return h
}

View File

@@ -0,0 +1,83 @@
package registries
import (
"encoding/json"
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/portainer"
)
// request on /api/registries/:id/v2
func (handler *Handler) proxyRequestsToRegistryAPI(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}
}
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
if err == portainer.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}
}
err = handler.requestBouncer.RegistryAccess(r, registry)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied}
}
extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension)
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
}
var proxy http.Handler
proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension)
if proxy == nil {
proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register registry proxy", err}
}
}
managementConfiguration := registry.ManagementConfiguration
if managementConfiguration == nil {
managementConfiguration = createDefaultManagementConfiguration(registry)
}
encodedConfiguration, err := json.Marshal(managementConfiguration)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err}
}
id := strconv.Itoa(int(registryID))
r.Header.Set("X-RegistryManagement-Key", id)
r.Header.Set("X-RegistryManagement-URI", registry.URL)
r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration))
r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey)
http.StripPrefix("/registries/"+id, proxy).ServeHTTP(w, r)
return nil
}
func createDefaultManagementConfiguration(registry *portainer.Registry) *portainer.RegistryManagementConfiguration {
config := &portainer.RegistryManagementConfiguration{
Type: registry.Type,
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
}
if registry.Authentication {
config.Authentication = true
config.Username = registry.Username
config.Password = registry.Password
}
return config
}

View File

@@ -0,0 +1,137 @@
package registries
import (
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type registryConfigurePayload struct {
Authentication bool
Username string
Password string
TLS bool
TLSSkipVerify bool
TLSCertFile []byte
TLSKeyFile []byte
TLSCACertFile []byte
}
func (payload *registryConfigurePayload) Validate(r *http.Request) error {
useAuthentication, _ := request.RetrieveBooleanMultiPartFormValue(r, "Authentication", true)
payload.Authentication = useAuthentication
if useAuthentication {
username, err := request.RetrieveMultiPartFormValue(r, "Username", false)
if err != nil {
return portainer.Error("Invalid username")
}
payload.Username = username
password, _ := request.RetrieveMultiPartFormValue(r, "Password", true)
payload.Password = password
}
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
payload.TLS = useTLS
skipTLSVerify, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true)
payload.TLSSkipVerify = skipTLSVerify
if useTLS && !skipTLSVerify {
cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile")
if err != nil {
return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly")
}
payload.TLSCertFile = cert
key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile")
if err != nil {
return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly")
}
payload.TLSKeyFile = key
ca, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile")
if err != nil {
return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly")
}
payload.TLSCACertFile = ca
}
return nil
}
// POST request on /api/registries/:id/configure
func (handler *Handler) registryConfigure(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}
}
payload := &registryConfigurePayload{}
err = payload.Validate(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
if err == portainer.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}
}
registry.ManagementConfiguration = &portainer.RegistryManagementConfiguration{
Type: registry.Type,
}
if payload.Authentication {
registry.ManagementConfiguration.Authentication = true
registry.ManagementConfiguration.Username = payload.Username
if payload.Username == registry.Username && payload.Password == "" {
registry.ManagementConfiguration.Password = registry.Password
} else {
registry.ManagementConfiguration.Password = payload.Password
}
}
if payload.TLS {
registry.ManagementConfiguration.TLSConfig = portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: payload.TLSSkipVerify,
}
if !payload.TLSSkipVerify {
folder := strconv.Itoa(int(registry.ID))
certPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "cert.pem", payload.TLSCertFile)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err}
}
registry.ManagementConfiguration.TLSConfig.TLSCertPath = certPath
keyPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "key.pem", payload.TLSKeyFile)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err}
}
registry.ManagementConfiguration.TLSConfig.TLSKeyPath = keyPath
cacertPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "ca.pem", payload.TLSCACertFile)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err}
}
registry.ManagementConfiguration.TLSConfig.TLSCACertPath = cacertPath
}
}
err = handler.RegistryService.UpdateRegistry(registry.ID, registry)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err}
}
return response.Empty(w)
}

View File

@@ -12,6 +12,7 @@ import (
type registryCreatePayload struct {
Name string
Type int
URL string
Authentication bool
Username string
@@ -28,6 +29,9 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled")
}
if payload.Type != 1 && payload.Type != 2 && payload.Type != 3 {
return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)")
}
return nil
}
@@ -49,6 +53,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
}
registry := &portainer.Registry{
Type: portainer.RegistryType(payload.Type),
Name: payload.Name,
URL: payload.URL,
Authentication: payload.Authentication,

View File

@@ -23,6 +23,11 @@ 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", portainer.ErrEndpointAccessDenied}
}
hideFields(registry)
return response.JSON(w, registry)
}

View File

@@ -0,0 +1,44 @@
package schedules
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
)
// Handler is the HTTP handler used to handle schedule operations.
type Handler struct {
*mux.Router
ScheduleService portainer.ScheduleService
EndpointService portainer.EndpointService
SettingsService portainer.SettingsService
FileService portainer.FileService
JobService portainer.JobService
JobScheduler portainer.JobScheduler
}
// NewHandler creates a handler to manage schedule operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/schedules",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet)
h.Handle("/schedules",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost)
h.Handle("/schedules/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet)
h.Handle("/schedules/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut)
h.Handle("/schedules/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete)
h.Handle("/schedules/{id}/file",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet)
h.Handle("/schedules/{id}/tasks",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet)
return h
}

View File

@@ -0,0 +1,238 @@
package schedules
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
"github.com/portainer/portainer/cron"
)
type scheduleCreateFromFilePayload struct {
Name string
Image string
CronExpression string
Recurring bool
Endpoints []portainer.EndpointID
File []byte
RetryCount int
RetryInterval int
}
type scheduleCreateFromFileContentPayload struct {
Name string
CronExpression string
Recurring bool
Image string
Endpoints []portainer.EndpointID
FileContent string
RetryCount int
RetryInterval int
}
func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error {
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
if err != nil {
return errors.New("Invalid schedule name")
}
if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
payload.Name = name
image, err := request.RetrieveMultiPartFormValue(r, "Image", false)
if err != nil {
return errors.New("Invalid schedule image")
}
payload.Image = image
cronExpression, err := request.RetrieveMultiPartFormValue(r, "CronExpression", false)
if err != nil {
return errors.New("Invalid cron expression")
}
payload.CronExpression = cronExpression
var endpoints []portainer.EndpointID
err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false)
if err != nil {
return errors.New("Invalid endpoints")
}
payload.Endpoints = endpoints
file, _, err := request.RetrieveMultiPartFormFile(r, "file")
if err != nil {
return portainer.Error("Invalid script file. Ensure that the file is uploaded correctly")
}
payload.File = file
retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true)
payload.RetryCount = retryCount
retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true)
payload.RetryInterval = retryInterval
return nil
}
func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return portainer.Error("Invalid schedule name")
}
if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
if govalidator.IsNull(payload.Image) {
return portainer.Error("Invalid schedule image")
}
if govalidator.IsNull(payload.CronExpression) {
return portainer.Error("Invalid cron expression")
}
if payload.Endpoints == nil || len(payload.Endpoints) == 0 {
return portainer.Error("Invalid endpoints payload")
}
if govalidator.IsNull(payload.FileContent) {
return portainer.Error("Invalid script file content")
}
if payload.RetryCount != 0 && payload.RetryInterval == 0 {
return portainer.Error("RetryInterval must be set")
}
return nil
}
// POST /api/schedules?method=file/string
func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
}
if !settings.EnableHostManagementFeatures {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
}
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", err}
}
switch method {
case "string":
return handler.createScheduleFromFileContent(w, r)
case "file":
return handler.createScheduleFromFile(w, r)
default:
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", errors.New(request.ErrInvalidQueryParameter)}
}
}
func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload scheduleCreateFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
schedule := handler.createScheduleObjectFromFileContentPayload(&payload)
err = handler.addAndPersistSchedule(schedule, []byte(payload.FileContent))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
}
return response.JSON(w, schedule)
}
func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
payload := &scheduleCreateFromFilePayload{}
err := payload.Validate(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
schedule := handler.createScheduleObjectFromFilePayload(payload)
err = handler.addAndPersistSchedule(schedule, payload.File)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
}
return response.JSON(w, schedule)
}
func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCreateFromFilePayload) *portainer.Schedule {
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
job := &portainer.ScriptExecutionJob{
Endpoints: payload.Endpoints,
Image: payload.Image,
RetryCount: payload.RetryCount,
RetryInterval: payload.RetryInterval,
}
schedule := &portainer.Schedule{
ID: scheduleIdentifier,
Name: payload.Name,
CronExpression: payload.CronExpression,
Recurring: payload.Recurring,
JobType: portainer.ScriptExecutionJobType,
ScriptExecutionJob: job,
Created: time.Now().Unix(),
}
return schedule
}
func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *scheduleCreateFromFileContentPayload) *portainer.Schedule {
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
job := &portainer.ScriptExecutionJob{
Endpoints: payload.Endpoints,
Image: payload.Image,
RetryCount: payload.RetryCount,
RetryInterval: payload.RetryInterval,
}
schedule := &portainer.Schedule{
ID: scheduleIdentifier,
Name: payload.Name,
CronExpression: payload.CronExpression,
Recurring: payload.Recurring,
JobType: portainer.ScriptExecutionJobType,
ScriptExecutionJob: job,
Created: time.Now().Unix(),
}
return schedule
}
func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error {
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file)
if err != nil {
return err
}
schedule.ScriptExecutionJob.ScriptPath = scriptPath
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext)
err = handler.JobScheduler.ScheduleJob(jobRunner)
if err != nil {
return err
}
return handler.ScheduleService.CreateSchedule(schedule)
}

View File

@@ -0,0 +1,53 @@
package schedules
import (
"errors"
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
}
if !settings.EnableHostManagementFeatures {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
}
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
}
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
}
if schedule.JobType == portainer.SnapshotJobType || schedule.JobType == portainer.EndpointSyncJobType {
return &httperror.HandlerError{http.StatusBadRequest, "Cannot remove system schedules", errors.New("Cannot remove system schedule")}
}
scheduleFolder := handler.FileService.GetScheduleFolder(strconv.Itoa(scheduleID))
err = handler.FileService.RemoveDirectory(scheduleFolder)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err}
}
handler.JobScheduler.UnscheduleJob(schedule.ID)
err = handler.ScheduleService.DeleteSchedule(portainer.ScheduleID(scheduleID))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the schedule from the database", err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,49 @@
package schedules
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type scheduleFileResponse struct {
ScheduleFileContent string `json:"ScheduleFileContent"`
}
// GET request on /api/schedules/:id/file
func (handler *Handler) scheduleFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
}
if !settings.EnableHostManagementFeatures {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
}
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
}
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
}
if schedule.JobType != portainer.ScriptExecutionJobType {
return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve script file", errors.New("This type of schedule do not have any associated script file")}
}
scheduleFileContent, err := handler.FileService.GetFileContent(schedule.ScriptExecutionJob.ScriptPath)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedule script file from disk", err}
}
return response.JSON(w, &scheduleFileResponse{ScheduleFileContent: string(scheduleFileContent)})
}

View File

@@ -0,0 +1,35 @@
package schedules
import (
"net/http"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
)
func (handler *Handler) scheduleInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
}
if !settings.EnableHostManagementFeatures {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
}
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
}
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
}
return response.JSON(w, schedule)
}

View File

@@ -0,0 +1,27 @@
package schedules
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
// GET request on /api/schedules
func (handler *Handler) scheduleList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
}
if !settings.EnableHostManagementFeatures {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
}
schedules, err := handler.ScheduleService.Schedules()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedules from the database", err}
}
return response.JSON(w, schedules)
}

View File

@@ -0,0 +1,95 @@
package schedules
import (
"encoding/json"
"errors"
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type taskContainer struct {
ID string `json:"Id"`
EndpointID portainer.EndpointID `json:"EndpointId"`
Status string `json:"Status"`
Created float64 `json:"Created"`
Labels map[string]string `json:"Labels"`
}
// GET request on /api/schedules/:id/tasks
func (handler *Handler) scheduleTasks(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
}
if !settings.EnableHostManagementFeatures {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
}
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
}
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
}
if schedule.JobType != portainer.ScriptExecutionJobType {
return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve schedule tasks", errors.New("This type of schedule do not have any associated tasks")}
}
tasks := make([]taskContainer, 0)
for _, endpointID := range schedule.ScriptExecutionJob.Endpoints {
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrObjectNotFound {
continue
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
endpointTasks, err := extractTasksFromContainerSnasphot(endpoint, schedule.ID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find extract schedule tasks from endpoint snapshot", err}
}
tasks = append(tasks, endpointTasks...)
}
return response.JSON(w, tasks)
}
func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID portainer.ScheduleID) ([]taskContainer, error) {
endpointTasks := make([]taskContainer, 0)
if len(endpoint.Snapshots) == 0 {
return endpointTasks, nil
}
b, err := json.Marshal(endpoint.Snapshots[0].SnapshotRaw.Containers)
if err != nil {
return nil, err
}
var containers []taskContainer
err = json.Unmarshal(b, &containers)
if err != nil {
return nil, err
}
for _, container := range containers {
if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) {
container.EndpointID = endpoint.ID
endpointTasks = append(endpointTasks, container)
}
}
return endpointTasks, nil
}

View File

@@ -0,0 +1,126 @@
package schedules
import (
"errors"
"net/http"
"strconv"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
"github.com/portainer/portainer/cron"
)
type scheduleUpdatePayload struct {
Name *string
Image *string
CronExpression *string
Recurring *bool
Endpoints []portainer.EndpointID
FileContent *string
RetryCount *int
RetryInterval *int
}
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error {
if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
return nil
}
func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
}
if !settings.EnableHostManagementFeatures {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
}
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
}
var payload scheduleUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
}
updateJobSchedule := updateSchedule(schedule, &payload)
if payload.FileContent != nil {
_, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist script file changes on the filesystem", err}
}
updateJobSchedule = true
}
if updateJobSchedule {
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext)
err := handler.JobScheduler.UpdateJobSchedule(jobRunner)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update job scheduler", err}
}
}
err = handler.ScheduleService.UpdateSchedule(portainer.ScheduleID(scheduleID), schedule)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist schedule changes inside the database", err}
}
return response.JSON(w, schedule)
}
func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) bool {
updateJobSchedule := false
if payload.Name != nil {
schedule.Name = *payload.Name
}
if payload.Endpoints != nil {
schedule.ScriptExecutionJob.Endpoints = payload.Endpoints
updateJobSchedule = true
}
if payload.CronExpression != nil {
schedule.CronExpression = *payload.CronExpression
updateJobSchedule = true
}
if payload.Recurring != nil {
schedule.Recurring = *payload.Recurring
updateJobSchedule = true
}
if payload.Image != nil {
schedule.ScriptExecutionJob.Image = *payload.Image
updateJobSchedule = true
}
if payload.RetryCount != nil {
schedule.ScriptExecutionJob.RetryCount = *payload.RetryCount
updateJobSchedule = true
}
if payload.RetryInterval != nil {
schedule.ScriptExecutionJob.RetryInterval = *payload.RetryInterval
updateJobSchedule = true
}
return updateJobSchedule
}

View File

@@ -9,6 +9,11 @@ import (
"github.com/portainer/portainer/http/security"
)
func hideFields(settings *portainer.Settings) {
settings.LDAPSettings.Password = ""
settings.OAuthSettings.ClientSecret = ""
}
// Handler is the HTTP handler used to handle settings operations.
type Handler struct {
*mux.Router
@@ -16,6 +21,7 @@ type Handler struct {
LDAPService portainer.LDAPService
FileService portainer.FileService
JobScheduler portainer.JobScheduler
ScheduleService portainer.ScheduleService
}
// NewHandler creates a handler to manage settings operations.

View File

@@ -14,5 +14,6 @@ func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
}
hideFields(settings)
return response.JSON(w, settings)
}

View File

@@ -1,6 +1,7 @@
package settings
import (
"fmt"
"net/http"
httperror "github.com/portainer/libhttp/error"
@@ -13,7 +14,9 @@ type publicSettingsResponse struct {
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
ExternalTemplates bool `json:"ExternalTemplates"`
OAuthLoginURI string `json:"OAuthLoginURI"`
}
// GET request on /api/settings/public
@@ -28,7 +31,13 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
AuthenticationMethod: settings.AuthenticationMethod,
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
EnableHostManagementFeatures: settings.EnableHostManagementFeatures,
ExternalTemplates: false,
OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login",
settings.OAuthSettings.AuthorizationURI,
settings.OAuthSettings.ClientID,
settings.OAuthSettings.RedirectURI,
settings.OAuthSettings.Scopes),
}
if settings.TemplatesURL != "" {

View File

@@ -16,15 +16,17 @@ type settingsUpdatePayload struct {
BlackListedLabels []portainer.Pair
AuthenticationMethod *int
LDAPSettings *portainer.LDAPSettings
OAuthSettings *portainer.OAuthSettings
AllowBindMountsForRegularUsers *bool
AllowPrivilegedModeForRegularUsers *bool
EnableHostManagementFeatures *bool
SnapshotInterval *string
TemplatesURL *string
}
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 {
return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)")
if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 && *payload.AuthenticationMethod != 3 {
return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)")
}
if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) {
return portainer.Error("Invalid logo URL. Must correspond to a valid URL format")
@@ -65,7 +67,21 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
}
if payload.LDAPSettings != nil {
ldapPassword := settings.LDAPSettings.Password
if payload.LDAPSettings.Password != "" {
ldapPassword = payload.LDAPSettings.Password
}
settings.LDAPSettings = *payload.LDAPSettings
settings.LDAPSettings.Password = ldapPassword
}
if payload.OAuthSettings != nil {
clientSecret := payload.OAuthSettings.ClientSecret
if clientSecret == "" {
clientSecret = settings.OAuthSettings.ClientSecret
}
settings.OAuthSettings = *payload.OAuthSettings
settings.OAuthSettings.ClientSecret = clientSecret
}
if payload.AllowBindMountsForRegularUsers != nil {
@@ -76,9 +92,15 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers
}
if payload.EnableHostManagementFeatures != nil {
settings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
}
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
settings.SnapshotInterval = *payload.SnapshotInterval
handler.JobScheduler.UpdateSnapshotJob(settings.SnapshotInterval)
err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update snapshot interval", err}
}
}
tlsError := handler.updateTLS(settings)
@@ -94,6 +116,32 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
return response.JSON(w, settings)
}
func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error {
settings.SnapshotInterval = snapshotInterval
schedules, err := handler.ScheduleService.SchedulesByJobType(portainer.SnapshotJobType)
if err != nil {
return err
}
if len(schedules) != 0 {
snapshotSchedule := schedules[0]
snapshotSchedule.CronExpression = "@every " + snapshotInterval
err := handler.JobScheduler.UpdateSystemJobSchedule(portainer.SnapshotJobType, snapshotSchedule.CronExpression)
if err != nil {
return err
}
err = handler.ScheduleService.UpdateSchedule(snapshotSchedule.ID, &snapshotSchedule)
if err != nil {
return err
}
}
return nil
}
func (handler *Handler) updateTLS(settings *portainer.Settings) *httperror.HandlerError {
if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)

View File

@@ -41,6 +41,10 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http
}
func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
if user.Password == "" {
return handler.deleteUser(w, user)
}
users, err := handler.UserService.Users()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err}

View File

@@ -49,7 +49,7 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) *
}
func (handler *Handler) executeServiceWebhook(w http.ResponseWriter, endpoint *portainer.Endpoint, resourceID string) *httperror.HandlerError {
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint)
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err}
}

View File

@@ -111,12 +111,13 @@ func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Req
}
}
signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
}

View File

@@ -43,7 +43,7 @@ func buildOperation(request *http.Request) error {
dockerfileContent = []byte(req.Content)
}
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile")
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile", 0600)
if err != nil {
return err
}

View File

@@ -64,7 +64,7 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
request.URL.Path = path
if p.enableSignature {
signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := p.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}

View File

@@ -25,7 +25,7 @@ type proxyFactory struct {
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
u.Scheme = "http"
return newSingleHostReverseProxyWithHostHeader(u)
return httputil.NewSingleHostReverseProxy(u)
}
func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) {

View File

@@ -3,18 +3,26 @@ package proxy
import (
"net/http"
"net/url"
"strings"
"strconv"
"github.com/orcaman/concurrent-map"
"github.com/portainer/portainer"
)
// TODO: contain code related to legacy extension management
var extensionPorts = map[portainer.ExtensionID]string{
portainer.RegistryManagementExtension: "7001",
portainer.OAuthAuthenticationExtension: "7002",
}
type (
// Manager represents a service used to manage Docker proxies.
Manager struct {
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
extensionProxies cmap.ConcurrentMap
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
extensionProxies cmap.ConcurrentMap
legacyExtensionProxies cmap.ConcurrentMap
}
// ManagerParams represents the required parameters to create a new Manager instance.
@@ -31,8 +39,9 @@ type (
// NewManager initializes a new proxy Service
func NewManager(parameters *ManagerParams) *Manager {
return &Manager{
proxies: cmap.New(),
extensionProxies: cmap.New(),
proxies: cmap.New(),
extensionProxies: cmap.New(),
legacyExtensionProxies: cmap.New(),
proxyFactory: &proxyFactory{
ResourceControlService: parameters.ResourceControlService,
TeamMembershipService: parameters.TeamMembershipService,
@@ -44,6 +53,87 @@ func NewManager(parameters *ManagerParams) *Manager {
}
}
// GetProxy returns the proxy associated to a key
func (manager *Manager) GetProxy(key string) http.Handler {
proxy, ok := manager.proxies.Get(key)
if !ok {
return nil
}
return proxy.(http.Handler)
}
// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
proxy, err := manager.createProxy(endpoint)
if err != nil {
return nil, err
}
manager.proxies.Set(string(endpoint.ID), proxy)
return proxy, nil
}
// DeleteProxy deletes the proxy associated to a key
func (manager *Manager) DeleteProxy(key string) {
manager.proxies.Remove(key)
}
// GetExtensionProxy returns an extension proxy associated to an extension identifier
func (manager *Manager) GetExtensionProxy(extensionID portainer.ExtensionID) http.Handler {
proxy, ok := manager.extensionProxies.Get(strconv.Itoa(int(extensionID)))
if !ok {
return nil
}
return proxy.(http.Handler)
}
// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and
// registers it in the extension map associated to the specified extension identifier
func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) {
address := "http://localhost:" + extensionPorts[extensionID]
extensionURL, err := url.Parse(address)
if err != nil {
return nil, err
}
proxy := manager.proxyFactory.newHTTPProxy(extensionURL)
manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), proxy)
return proxy, nil
}
func (manager *Manager) GetExtensionURL(extensionID portainer.ExtensionID) string {
return "http://localhost:" + extensionPorts[extensionID]
}
// DeleteExtensionProxy deletes the extension proxy associated to an extension identifier
func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) {
manager.extensionProxies.Remove(strconv.Itoa(int(extensionID)))
}
// GetLegacyExtensionProxy returns a legacy extension proxy associated to a key
func (manager *Manager) GetLegacyExtensionProxy(key string) http.Handler {
proxy, ok := manager.legacyExtensionProxies.Get(key)
if !ok {
return nil
}
return proxy.(http.Handler)
}
// CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies.
func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
extensionURL, err := url.Parse(extensionAPIURL)
if err != nil {
return nil, err
}
proxy := manager.proxyFactory.newHTTPProxy(extensionURL)
manager.extensionProxies.Set(key, proxy)
return proxy, nil
}
func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) {
if endpointURL.Scheme == "tcp" {
if tlsConfig.TLS || tlsConfig.TLSSkipVerify {
@@ -69,59 +159,3 @@ func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler,
return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig)
}
}
// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
proxy, err := manager.createProxy(endpoint)
if err != nil {
return nil, err
}
manager.proxies.Set(string(endpoint.ID), proxy)
return proxy, nil
}
// GetProxy returns the proxy associated to a key
func (manager *Manager) GetProxy(key string) http.Handler {
proxy, ok := manager.proxies.Get(key)
if !ok {
return nil
}
return proxy.(http.Handler)
}
// DeleteProxy deletes the proxy associated to a key
func (manager *Manager) DeleteProxy(key string) {
manager.proxies.Remove(key)
}
// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies.
func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
extensionURL, err := url.Parse(extensionAPIURL)
if err != nil {
return nil, err
}
proxy := manager.proxyFactory.newHTTPProxy(extensionURL)
manager.extensionProxies.Set(key, proxy)
return proxy, nil
}
// GetExtensionProxy returns the extension proxy associated to a key
func (manager *Manager) GetExtensionProxy(key string) http.Handler {
proxy, ok := manager.extensionProxies.Get(key)
if !ok {
return nil
}
return proxy.(http.Handler)
}
// DeleteExtensionProxies deletes all the extension proxies associated to a key
func (manager *Manager) DeleteExtensionProxies(key string) {
for _, k := range manager.extensionProxies.Keys() {
if strings.Contains(k, key+"_") {
manager.extensionProxies.Remove(k)
}
}
}

View File

@@ -153,10 +153,10 @@ func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *porta
return true
}
// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group.
// authorizedEndpointGroupAccess ensure that the user can access the specified endpoint group.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
func authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
}

View File

@@ -111,6 +111,31 @@ func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portain
return nil
}
// RegistryAccess retrieves the JWT token from the request context and verifies
// that the user can access the specified registry.
// An error is returned when access is denied.
func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portainer.Registry) error {
tokenData, err := RetrieveTokenData(r)
if err != nil {
return err
}
if tokenData.Role == portainer.AdministratorRole {
return nil
}
memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return err
}
if !AuthorizedRegistryAccess(registry, tokenData.ID, memberships) {
return portainer.ErrEndpointAccessDenied
}
return nil
}
// mwSecureHeaders provides secure headers middleware for handlers.
func mwSecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -124,7 +124,7 @@ func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *Res
filteredEndpointGroups = make([]portainer.EndpointGroup, 0)
for _, group := range endpointGroups {
if AuthorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) {
if authorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) {
filteredEndpointGroups = append(filteredEndpointGroups, group)
}
}

View File

@@ -11,10 +11,12 @@ import (
"github.com/portainer/portainer/http/handler/endpointgroups"
"github.com/portainer/portainer/http/handler/endpointproxy"
"github.com/portainer/portainer/http/handler/endpoints"
"github.com/portainer/portainer/http/handler/extensions"
"github.com/portainer/portainer/http/handler/file"
"github.com/portainer/portainer/http/handler/motd"
"github.com/portainer/portainer/http/handler/registries"
"github.com/portainer/portainer/http/handler/resourcecontrols"
"github.com/portainer/portainer/http/handler/schedules"
"github.com/portainer/portainer/http/handler/settings"
"github.com/portainer/portainer/http/handler/stacks"
"github.com/portainer/portainer/http/handler/status"
@@ -40,6 +42,7 @@ type Server struct {
AuthDisabled bool
EndpointManagement bool
Status *portainer.Status
ExtensionManager portainer.ExtensionManager
ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService
SignatureService portainer.DigitalSignatureService
@@ -52,8 +55,10 @@ type Server struct {
GitService portainer.GitService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
ExtensionService portainer.ExtensionService
RegistryService portainer.RegistryService
ResourceControlService portainer.ResourceControlService
ScheduleService portainer.ScheduleService
SettingsService portainer.SettingsService
StackService portainer.StackService
SwarmStackManager portainer.SwarmStackManager
@@ -68,6 +73,7 @@ type Server struct {
SSLCert string
SSLKey string
DockerClientFactory *docker.ClientFactory
JobService portainer.JobService
}
// Start starts the HTTP server
@@ -80,6 +86,7 @@ func (server *Server) Start() error {
AuthDisabled: server.AuthDisabled,
}
requestBouncer := security.NewRequestBouncer(requestBouncerParameters)
proxyManagerParameters := &proxy.ManagerParams{
ResourceControlService: server.ResourceControlService,
TeamMembershipService: server.TeamMembershipService,
@@ -89,6 +96,7 @@ func (server *Server) Start() error {
SignatureService: server.SignatureService,
}
proxyManager := proxy.NewManager(proxyManagerParameters)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, server.AuthDisabled)
@@ -99,6 +107,8 @@ func (server *Server) Start() error {
authHandler.SettingsService = server.SettingsService
authHandler.TeamService = server.TeamService
authHandler.TeamMembershipService = server.TeamMembershipService
authHandler.ExtensionService = server.ExtensionService
authHandler.ProxyManager = proxyManager
var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
dockerHubHandler.DockerHubService = server.DockerHubService
@@ -109,6 +119,7 @@ func (server *Server) Start() error {
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager
endpointHandler.Snapshotter = server.Snapshotter
endpointHandler.JobService = server.JobService
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
@@ -122,17 +133,33 @@ func (server *Server) Start() error {
var motdHandler = motd.NewHandler(requestBouncer)
var extensionHandler = extensions.NewHandler(requestBouncer)
extensionHandler.ExtensionService = server.ExtensionService
extensionHandler.ExtensionManager = server.ExtensionManager
var registryHandler = registries.NewHandler(requestBouncer)
registryHandler.RegistryService = server.RegistryService
registryHandler.ExtensionService = server.ExtensionService
registryHandler.FileService = server.FileService
registryHandler.ProxyManager = proxyManager
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
resourceControlHandler.ResourceControlService = server.ResourceControlService
var schedulesHandler = schedules.NewHandler(requestBouncer)
schedulesHandler.ScheduleService = server.ScheduleService
schedulesHandler.EndpointService = server.EndpointService
schedulesHandler.FileService = server.FileService
schedulesHandler.JobService = server.JobService
schedulesHandler.JobScheduler = server.JobScheduler
schedulesHandler.SettingsService = server.SettingsService
var settingsHandler = settings.NewHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService
settingsHandler.LDAPService = server.LDAPService
settingsHandler.FileService = server.FileService
settingsHandler.JobScheduler = server.JobScheduler
settingsHandler.ScheduleService = server.ScheduleService
var stackHandler = stacks.NewHandler(requestBouncer)
stackHandler.FileService = server.FileService
@@ -188,6 +215,7 @@ func (server *Server) Start() error {
EndpointProxyHandler: endpointProxyHandler,
FileHandler: fileHandler,
MOTDHandler: motdHandler,
ExtensionHandler: extensionHandler,
RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler,
SettingsHandler: settingsHandler,
@@ -201,6 +229,7 @@ func (server *Server) Start() error {
UserHandler: userHandler,
WebSocketHandler: websocketHandler,
WebhookHandler: webhookHandler,
SchedulesHanlder: schedulesHandler,
}
if server.SSL {

View File

@@ -47,7 +47,7 @@ type (
// LDAPSettings represents the settings used to connect to a LDAP server
LDAPSettings struct {
ReaderDN string `json:"ReaderDN"`
Password string `json:"Password"`
Password string `json:"Password,omitempty"`
URL string `json:"URL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
StartTLS bool `json:"StartTLS"`
@@ -56,6 +56,20 @@ type (
AutoCreateUsers bool `json:"AutoCreateUsers"`
}
// OAuthSettings represents the settings used to authorize with an authorization server
OAuthSettings struct {
ClientID string `json:"ClientID"`
ClientSecret string `json:"ClientSecret,omitempty"`
AccessTokenURI string `json:"AccessTokenURI"`
AuthorizationURI string `json:"AuthorizationURI"`
ResourceURI string `json:"ResourceURI"`
RedirectURI string `json:"RedirectURI"`
UserIdentifier string `json:"UserIdentifier"`
Scopes string `json:"Scopes"`
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
DefaultTeamID TeamID `json:"DefaultTeamID"`
}
// TLSConfiguration represents a TLS configuration
TLSConfiguration struct {
TLS bool `json:"TLS"`
@@ -85,10 +99,12 @@ type (
BlackListedLabels []Pair `json:"BlackListedLabels"`
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
LDAPSettings LDAPSettings `json:"LDAPSettings"`
OAuthSettings OAuthSettings `json:"OAuthSettings"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
SnapshotInterval string `json:"SnapshotInterval"`
TemplatesURL string `json:"TemplatesURL"`
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
// Deprecated fields
DisplayDonationHeader bool
@@ -164,17 +180,32 @@ type (
// RegistryID represents a registry identifier
RegistryID int
// RegistryType represents a type of registry
RegistryType int
// Registry represents a Docker registry with all the info required
// to connect to it
Registry struct {
ID RegistryID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
Authentication bool `json:"Authentication"`
Username string `json:"Username"`
Password string `json:"Password,omitempty"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
ID RegistryID `json:"Id"`
Type RegistryType `json:"Type"`
Name string `json:"Name"`
URL string `json:"URL"`
Authentication bool `json:"Authentication"`
Username string `json:"Username"`
Password string `json:"Password,omitempty"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"`
}
// RegistryManagementConfiguration represents a configuration that can be used to query
// the registry API via the registry management extension.
RegistryManagementConfiguration struct {
Type RegistryType `json:"Type"`
Authentication bool `json:"Authentication"`
Username string `json:"Username"`
Password string `json:"Password"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
}
// DockerHub represents all the required information to connect and use the
@@ -220,7 +251,44 @@ type (
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// WebhookID represents an webhook identifier.
// ScheduleID represents a schedule identifier.
ScheduleID int
// JobType represents a job type
JobType int
// ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
ScriptExecutionJob struct {
Endpoints []EndpointID
Image string
ScriptPath string
RetryCount int
RetryInterval int
}
// SnapshotJob represents a scheduled job that can create endpoint snapshots
SnapshotJob struct{}
// EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file
EndpointSyncJob struct{}
// Schedule represents a scheduled job.
// It only contains a pointer to one of the JobRunner implementations
// based on the JobType.
// NOTE: The Recurring option is only used by ScriptExecutionJob at the moment
Schedule struct {
ID ScheduleID `json:"Id"`
Name string
CronExpression string
Recurring bool
Created int64
JobType JobType
ScriptExecutionJob *ScriptExecutionJob
SnapshotJob *SnapshotJob
EndpointSyncJob *EndpointSyncJob
}
// WebhookID represents a webhook identifier.
WebhookID int
// WebhookType represents the type of resource a webhook is related to
@@ -245,17 +313,28 @@ type (
// Snapshot represents a snapshot of a specific endpoint at a specific time
Snapshot struct {
Time int64 `json:"Time"`
DockerVersion string `json:"DockerVersion"`
Swarm bool `json:"Swarm"`
TotalCPU int `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
RunningContainerCount int `json:"RunningContainerCount"`
StoppedContainerCount int `json:"StoppedContainerCount"`
VolumeCount int `json:"VolumeCount"`
ImageCount int `json:"ImageCount"`
ServiceCount int `json:"ServiceCount"`
StackCount int `json:"StackCount"`
Time int64 `json:"Time"`
DockerVersion string `json:"DockerVersion"`
Swarm bool `json:"Swarm"`
TotalCPU int `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
RunningContainerCount int `json:"RunningContainerCount"`
StoppedContainerCount int `json:"StoppedContainerCount"`
VolumeCount int `json:"VolumeCount"`
ImageCount int `json:"ImageCount"`
ServiceCount int `json:"ServiceCount"`
StackCount int `json:"StackCount"`
SnapshotRaw SnapshotRaw `json:"SnapshotRaw"`
}
// SnapshotRaw represents all the information related to a snapshot as returned by the Docker API
SnapshotRaw struct {
Containers interface{} `json:"Containers"`
Volumes interface{} `json:"Volumes"`
Networks interface{} `json:"Networks"`
Images interface{} `json:"Images"`
Info interface{} `json:"Info"`
Version interface{} `json:"Version"`
}
// EndpointGroupID represents an endpoint group identifier
@@ -274,7 +353,8 @@ type (
Labels []Pair `json:"Labels"`
}
// EndpointExtension represents a extension associated to an endpoint
// EndpointExtension represents a deprecated form of Portainer extension
// TODO: legacy extension management
EndpointExtension struct {
Type EndpointExtensionType `json:"Type"`
URL string `json:"URL"`
@@ -410,6 +490,37 @@ type (
// It can be either a TLS CA file, a TLS certificate file or a TLS key file
TLSFileType int
// ExtensionID represents a extension identifier
ExtensionID int
// Extension represents a Portainer extension
Extension struct {
ID ExtensionID `json:"Id"`
Enabled bool `json:"Enabled"`
Name string `json:"Name,omitempty"`
ShortDescription string `json:"ShortDescription,omitempty"`
Description string `json:"Description,omitempty"`
DescriptionURL string `json:"DescriptionURL,omitempty"`
Price string `json:"Price,omitempty"`
PriceDescription string `json:"PriceDescription,omitempty"`
Deal bool `json:"Deal,omitempty"`
Available bool `json:"Available,omitempty"`
License LicenseInformation `json:"License,omitempty"`
Version string `json:"Version"`
UpdateAvailable bool `json:"UpdateAvailable"`
ShopURL string `json:"ShopURL,omitempty"`
Images []string `json:"Images,omitempty"`
Logo string `json:"Logo,omitempty"`
}
// LicenseInformation represents information about an extension license
LicenseInformation struct {
LicenseKey string `json:"LicenseKey,omitempty"`
Company string `json:"Company,omitempty"`
Expiration string `json:"Expiration,omitempty"`
Valid bool `json:"Valid,omitempty"`
}
// CLIService represents a service for managing CLI
CLIService interface {
ParseFlags(version string) (*CLIFlags, error)
@@ -541,6 +652,17 @@ type (
DeleteResourceControl(ID ResourceControlID) error
}
// ScheduleService represents a service for managing schedule data
ScheduleService interface {
Schedule(ID ScheduleID) (*Schedule, error)
Schedules() ([]Schedule, error)
SchedulesByJobType(jobType JobType) ([]Schedule, error)
CreateSchedule(schedule *Schedule) error
UpdateSchedule(ID ScheduleID, schedule *Schedule) error
DeleteSchedule(ID ScheduleID) error
GetNextIdentifier() int
}
// TagService represents a service for managing tag data
TagService interface {
Tags() ([]Tag, error)
@@ -557,6 +679,14 @@ type (
DeleteTemplate(ID TemplateID) error
}
// ExtensionService represents a service for managing extension data
ExtensionService interface {
Extension(ID ExtensionID) (*Extension, error)
Extensions() ([]Extension, error)
Persist(extension *Extension) error
DeleteExtension(ID ExtensionID) error
}
// CryptoService represents a service for encrypting/hashing data
CryptoService interface {
Hash(data string) (string, error)
@@ -569,7 +699,7 @@ type (
GenerateKeyPair() ([]byte, []byte, error)
EncodedPublicKey() string
PEMHeaders() (string, string)
Sign(message string) (string, error)
CreateSignature(message string) (string, error)
}
// JWTService represents a service for managing JWT tokens
@@ -589,11 +719,16 @@ type (
DeleteTLSFiles(folder string) error
GetStackProjectPath(stackIdentifier string) string
StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error)
KeyPairFilesExist() (bool, error)
StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error
LoadKeyPair() ([]byte, []byte, error)
WriteJSONToFile(path string, content interface{}) error
FileExists(path string) (bool, error)
StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
GetScheduleFolder(identifier string) string
ExtractExtensionArchive(data []byte) error
GetBinaryFolder() string
}
// GitService represents a service for managing Git
@@ -604,12 +739,19 @@ type (
// JobScheduler represents a service to run jobs on a periodic basis
JobScheduler interface {
ScheduleEndpointSyncJob(endpointFilePath, interval string) error
ScheduleSnapshotJob(interval string) error
UpdateSnapshotJob(interval string)
ScheduleJob(runner JobRunner) error
UpdateJobSchedule(runner JobRunner) error
UpdateSystemJobSchedule(jobType JobType, newCronExpression string) error
UnscheduleJob(ID ScheduleID)
Start()
}
// JobRunner represents a service that can be used to run a job
JobRunner interface {
Run()
GetSchedule() *Schedule
}
// Snapshotter represents a service used to create endpoint snapshots
Snapshotter interface {
CreateSnapshot(endpoint *Endpoint) (*Snapshot, error)
@@ -635,15 +777,34 @@ type (
Up(stack *Stack, endpoint *Endpoint) error
Down(stack *Stack, endpoint *Endpoint) error
}
// JobService represents a service to manage job execution on hosts
JobService interface {
ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error
}
// ExtensionManager represents a service used to manage extensions
ExtensionManager interface {
FetchExtensionDefinitions() ([]Extension, error)
EnableExtension(extension *Extension, licenseKey string) error
DisableExtension(extension *Extension) error
UpdateExtension(extension *Extension, version string) error
}
)
const (
// APIVersion is the version number of the Portainer API
APIVersion = "1.20-dev"
APIVersion = "1.20.2"
// DBVersion is the version number of the Portainer database
DBVersion = 14
DBVersion = 17
// AssetsServerURL represents the URL of the Portainer asset server
AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com"
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
MessageOfTheDayURL = "https://raw.githubusercontent.com/portainer/motd/master/message.html"
MessageOfTheDayURL = AssetsServerURL + "/motd.html"
// MessageOfTheDayTitleURL represents the URL where Portainer MOTD title can be retrieved
MessageOfTheDayTitleURL = AssetsServerURL + "/motd-title.txt"
// ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved
ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.20.2.json"
// PortainerAgentHeader represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentTargetHeader represent the name of the header containing the target node name
@@ -690,6 +851,8 @@ const (
AuthenticationInternal
// AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
AuthenticationLDAP
//AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server)
AuthenticationOAuth
)
const (
@@ -763,3 +926,33 @@ const (
// ServiceWebhook is a webhook for restarting a docker service
ServiceWebhook
)
const (
_ ExtensionID = iota
// RegistryManagementExtension represents the registry management extension
RegistryManagementExtension
// OAuthAuthenticationExtension represents the OAuth authentication extension
OAuthAuthenticationExtension
)
const (
_ JobType = iota
// ScriptExecutionJobType is a non-system job used to execute a script against a list of
// endpoints via privileged containers
ScriptExecutionJobType
// SnapshotJobType is a system job used to create endpoint snapshots
SnapshotJobType
// EndpointSyncJobType is a system job used to synchronize endpoints from
// an external definition store
EndpointSyncJobType
)
const (
_ RegistryType = iota
// QuayRegistry represents a Quay.io registry
QuayRegistry
// AzureRegistry represents an ACR registry
AzureRegistry
// CustomRegistry represents a custom registry
CustomRegistry
)

View File

@@ -54,7 +54,7 @@ info:
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
version: "1.20-dev"
version: "1.20.2"
title: "Portainer API"
contact:
email: "info@portainer.io"
@@ -153,6 +153,8 @@ paths:
operationId: "DockerHubInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -175,6 +177,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -211,6 +215,8 @@ paths:
operationId: "EndpointList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -233,6 +239,8 @@ paths:
- "multipart/form-data"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "Name"
in: "formData"
@@ -265,7 +273,11 @@ paths:
- name: "TLSSkipVerify"
in: "formData"
type: "string"
description: "Skip server verification when using TLS" (example: false)
description: "Skip server verification when using TLS (example: false)"
- name: "TLSSkipClientVerify"
in: "formData"
type: "string"
description: "Skip client verification when using TLS (example: false)"
- name: "TLSCACertFile"
in: "formData"
type: "file"
@@ -324,6 +336,8 @@ paths:
operationId: "EndpointInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -365,6 +379,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -413,6 +429,8 @@ paths:
Remove an endpoint.
**Access policy**: administrator
operationId: "EndpointDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -460,6 +478,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -495,6 +515,78 @@ paths:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/endpoints/{id}/job:
post:
tags:
- "endpoints"
summary: "Execute a job on the endpoint host"
description: |
Execute a job (script) on the underlying host of the endpoint.
**Access policy**: administrator
operationId: "EndpointJob"
consumes:
- "multipart/form-data"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
- name: "method"
in: "query"
description: "Job execution method. Possible values: file or string."
required: true
type: "string"
- name: "nodeName"
in: "query"
description: "Optional. Hostname of a node when targeting a Portainer agent cluster."
required: true
type: "string"
- in: "body"
name: "body"
description: "Job details. Required when method equals string."
required: true
schema:
$ref: "#/definitions/EndpointJobRequest"
- name: "Image"
in: "formData"
type: "string"
description: "Container image which will be used to execute the job. Required when method equals file."
- name: "file"
in: "formData"
type: "file"
description: "Job script file. Required when method equals file."
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/Endpoint"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
403:
description: "Unauthorized"
schema:
$ref: "#/definitions/GenericError"
404:
description: "Endpoint not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Endpoint not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/endpoint_groups:
get:
tags:
@@ -508,6 +600,8 @@ paths:
operationId: "EndpointGroupList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -530,6 +624,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -564,6 +660,8 @@ paths:
operationId: "EndpointGroupInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -605,6 +703,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -655,6 +755,8 @@ paths:
Remove an endpoint group.
**Access policy**: administrator
operationId: "EndpointGroupDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -702,6 +804,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -750,6 +854,8 @@ paths:
operationId: "RegistryList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -772,6 +878,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -813,6 +921,8 @@ paths:
operationId: "RegistryInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -854,6 +964,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -904,6 +1016,8 @@ paths:
Remove a registry.
**Access policy**: administrator
operationId: "RegistryDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -944,6 +1058,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -992,6 +1108,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -1042,6 +1160,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1092,6 +1212,8 @@ paths:
Remove a resource control.
**Access policy**: restricted
operationId: "ResourceControlDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1137,6 +1259,8 @@ paths:
operationId: "SettingsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -1159,6 +1283,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -1193,6 +1319,8 @@ paths:
operationId: "PublicSettingsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -1216,6 +1344,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -1248,6 +1378,8 @@ paths:
operationId: "StatusInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -1271,6 +1403,8 @@ paths:
operationId: "StackList"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "filters"
in: "query"
@@ -1300,9 +1434,11 @@ paths:
**Access policy**: restricted
operationId: "StackCreate"
consumes:
- "application/json"
- "multipart/form-data"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "type"
in: "query"
@@ -1382,6 +1518,8 @@ paths:
operationId: "StackInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1427,6 +1565,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1479,6 +1619,8 @@ paths:
Remove a stack.
**Access policy**: restricted
operationId: "StackDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1529,6 +1671,8 @@ paths:
operationId: "StackFileInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1574,6 +1718,8 @@ paths:
operationId: "StackMigrate"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1628,6 +1774,8 @@ paths:
operationId: "UserList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -1651,6 +1799,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -1699,6 +1849,8 @@ paths:
operationId: "UserInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1740,6 +1892,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1790,6 +1944,8 @@ paths:
Remove a user.
**Access policy**: administrator
operationId: "UserDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1828,6 +1984,8 @@ paths:
operationId: "UserMembershipsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1871,6 +2029,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -1918,6 +2078,8 @@ paths:
operationId: "UserAdminCheck"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
204:
@@ -1947,6 +2109,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -1991,6 +2155,8 @@ paths:
- multipart/form-data
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "path"
name: "certificate"
@@ -2032,6 +2198,8 @@ paths:
operationId: "TagList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -2054,6 +2222,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -2093,6 +2263,8 @@ paths:
Remove a tag.
**Access policy**: administrator
operationId: "TagDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2125,6 +2297,8 @@ paths:
operationId: "TeamList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -2147,6 +2321,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -2195,6 +2371,8 @@ paths:
operationId: "TeamInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2243,6 +2421,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2284,6 +2464,8 @@ paths:
Remove a team.
**Access policy**: administrator
operationId: "TeamDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2323,6 +2505,8 @@ paths:
operationId: "TeamMembershipsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2364,6 +2548,8 @@ paths:
operationId: "TeamMembershipList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@@ -2393,6 +2579,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -2443,6 +2631,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2493,6 +2683,8 @@ paths:
Remove a team membership. Access is only available to administrators leaders of the associated team.
**Access policy**: restricted
operationId: "TeamMembershipDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2539,7 +2731,9 @@ paths:
operationId: "TemplateList"
produces:
- "application/json"
parameters:
security:
- jwt: []
parameters: []
responses:
200:
description: "Success"
@@ -2561,6 +2755,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@@ -2602,6 +2798,8 @@ paths:
operationId: "TemplateInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2650,6 +2848,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2698,6 +2898,8 @@ paths:
Remove a template.
**Access policy**: administrator
operationId: "TemplateDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@@ -2816,7 +3018,7 @@ definitions:
description: "Is analytics enabled"
Version:
type: "string"
example: "1.20-dev"
example: "1.20.2"
description: "Portainer API version"
PublicSettingsInspectResponse:
type: "object"
@@ -2944,7 +3146,7 @@ definitions:
$ref: "#/definitions/LDAPGroupSearchSettings"
AutoCreateUsers:
type: "boolean"
example: "true"
example: true
description: "Automatically provision users and assign them to matching LDAP group names"
Settings:
@@ -3404,6 +3606,7 @@ definitions:
- "Authentication"
- "Name"
- "Password"
- "Type"
- "URL"
- "Username"
properties:
@@ -3411,6 +3614,10 @@ definitions:
type: "string"
example: "my-registry"
description: "Name that will be used to identify this registry"
Type:
type: "integer"
example: 1
description: "Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)"
URL:
type: "string"
example: "registry.mydomain.tld:2375"
@@ -3783,9 +3990,9 @@ definitions:
TemplateCreateRequest:
type: "object"
required:
- "type"
- "title"
- "description"
- "type"
- "title"
- "description"
properties:
type:
type: "integer"
@@ -3835,7 +4042,7 @@ definitions:
description: "A list of categories associated to the template"
items:
type: "string"
exampe: "database"
example: "database"
registry:
type: "string"
example: "quay.io"
@@ -3931,7 +4138,7 @@ definitions:
description: "A list of categories associated to the template"
items:
type: "string"
exampe: "database"
example: "database"
registry:
type: "string"
example: "quay.io"
@@ -4031,7 +4238,7 @@ definitions:
description: "A list of categories associated to the template"
items:
type: "string"
exampe: "database"
example: "database"
registry:
type: "string"
example: "quay.io"
@@ -4137,7 +4344,7 @@ definitions:
TemplateRepository:
type: "object"
required:
- "URL"
- "URL"
properties:
URL:
type: "string"
@@ -4164,6 +4371,20 @@ definitions:
type: "string"
example: "new-stack"
description: "If provided will rename the migrated stack"
EndpointJobRequest:
type: "object"
required:
- "Image"
- "FileContent"
properties:
Image:
type: "string"
example: "ubuntu:latest"
description: "Container image which will be used to execute the job"
FileContent:
type: "string"
example: "ls -lah /host/tmp"
description: "Content of the job script"
StackCreateRequest:
type: "object"
required:

5
api/swagger_config.json Normal file
View File

@@ -0,0 +1,5 @@
{
"packageName": "portainer",
"packageVersion": "1.20.2",
"projectName": "portainer"
}

View File

@@ -22,5 +22,8 @@ angular.module('portainer', [
'portainer.agent',
'portainer.azure',
'portainer.docker',
'portainer.extensions',
'extension.storidge',
'rzModule']);
'rzModule',
'moment-picker'
]);

View File

@@ -8,7 +8,7 @@
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter"
placeholder="Search..." auto-focus>
ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table">

View File

@@ -3,6 +3,7 @@ angular.module('portainer.agent').component('volumeBrowser', {
controller: 'VolumeBrowserController',
bindings: {
volumeId: '<',
nodeName: '<'
nodeName: '<',
isUploadEnabled: '<'
}
});

View File

@@ -8,4 +8,7 @@
rename="$ctrl.rename(name, newName)"
download="$ctrl.download(name)"
delete="$ctrl.delete(name)"
is-upload-allowed="$ctrl.isUploadEnabled"
on-file-selected-for-upload="$ctrl.onFileSelectedForUpload"
></files-datatable>

View File

@@ -84,6 +84,16 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService
});
}
this.onFileSelectedForUpload = function onFileSelectedForUpload(file) {
VolumeBrowserService.upload(ctrl.state.path, file, ctrl.volumeId)
.then(function onFileUpload() {
onFileUploaded();
})
.catch(function onFileUpload(err) {
Notifications.error('Failure', err, 'Unable to upload file');
});
};
function parentPath(path) {
if (path.lastIndexOf('/') === 0) {
return '/';
@@ -112,4 +122,14 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService
});
};
function onFileUploaded() {
refreshList();
}
function refreshList() {
browse(ctrl.state.path);
}
}]);

View File

@@ -1,8 +1,10 @@
angular.module('portainer.agent')
.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager',
function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', {
endpointId: EndpointProvider.endpointID
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/agents', {
endpointId: EndpointProvider.endpointID,
version: StateManager.getAgentApiVersion
},
{
query: { method: 'GET', isArray: true }

View File

@@ -1,8 +1,10 @@
angular.module('portainer.agent')
.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager',
function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:action', {
endpointId: EndpointProvider.endpointID
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/browse/:action', {
endpointId: EndpointProvider.endpointID,
version: StateManager.getAgentApiVersion
},
{
ls: {

View File

@@ -1,11 +1,12 @@
angular.module('portainer.agent').factory('Host', [
'$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager',
function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) {
'use strict';
return $resource(
API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/host/:action',
API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/host/:action',
{
endpointId: EndpointProvider.endpointID
endpointId: EndpointProvider.endpointID,
version: StateManager.getAgentApiVersion
},
{
info: { method: 'GET', params: { action: 'info' } }

33
app/agent/rest/ping.js Normal file
View File

@@ -0,0 +1,33 @@
angular.module('portainer.agent').factory('AgentPing', [
'$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q',
function AgentPingFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) {
'use strict';
return $resource(
API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/ping',
{
endpointId: EndpointProvider.endpointID
},
{
ping: {
method: 'GET',
interceptor: {
response: function versionInterceptor(response) {
var instance = response.resource;
var version =
response.headers('Portainer-Agent-Api-Version') || 1;
instance.version = Number(version);
return instance;
},
responseError: function versionResponseError(error) {
// 404 - agent is up - set version to 1
if (error.status === 404) {
return { version: 1 };
}
return $q.reject(error);
}
}
}
}
);
}
]);

View File

@@ -0,0 +1,10 @@
angular.module('portainer.agent')
.factory('AgentVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', {
endpointId: EndpointProvider.endpointID
},
{
query: { method: 'GET', isArray: true }
});
}]);

View File

@@ -0,0 +1,22 @@
angular.module('portainer.agent')
.factory('BrowseVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:volumeID/:action', {
endpointId: EndpointProvider.endpointID
},
{
ls: {
method: 'GET', isArray: true, params: { action: 'ls' }
},
get: {
method: 'GET', params: { action: 'get' },
transformResponse: browseGetResponse
},
delete: {
method: 'DELETE', params: { action: 'delete' }
},
rename: {
method: 'PUT', params: { action: 'rename' }
}
});
}]);

View File

@@ -1,12 +1,17 @@
angular.module('portainer.agent').factory('AgentService', [
'$q', 'Agent','HttpRequestHelper', 'Host',
function AgentServiceFactory($q, Agent, HttpRequestHelper, Host) {
'$q', 'Agent', 'AgentVersion1', 'HttpRequestHelper', 'Host', 'StateManager',
function AgentServiceFactory($q, Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) {
'use strict';
var service = {};
service.agents = agents;
service.hostInfo = hostInfo;
function getAgentApiVersion() {
var state = StateManager.getState();
return state.endpoint.agentApiVersion;
}
function hostInfo(nodeName) {
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
return Host.info().$promise;
@@ -15,7 +20,10 @@ angular.module('portainer.agent').factory('AgentService', [
function agents() {
var deferred = $q.defer();
Agent.query({})
var agentVersion = getAgentApiVersion();
var service = agentVersion > 1 ? Agent : AgentVersion1;
service.query({ version: agentVersion })
.$promise.then(function success(data) {
var agents = data.map(function(item) {
return new AgentViewModel(item);

View File

@@ -1,6 +1,6 @@
angular.module('portainer.agent').factory('HostBrowserService', [
'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q',
function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) {
'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', 'StateManager',
function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q, StateManager) {
var service = {};
service.ls = ls;
@@ -31,9 +31,17 @@ angular.module('portainer.agent').factory('HostBrowserService', [
function upload(path, file, onProgress) {
var deferred = $q.defer();
var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/browse/put';
var agentVersion = StateManager.getAgentApiVersion();
var url =
API_ENDPOINT_ENDPOINTS +
'/' +
EndpointProvider.endpointID() +
'/docker' +
(agentVersion > 1 ? '/v' + agentVersion : '') +
'/browse/put';
Upload.upload({
url: url,
url: url,
data: { file: file, Path: path }
}).then(deferred.resolve, deferred.reject, onProgress);
return deferred.promise;

View File

@@ -0,0 +1,14 @@
angular.module('portainer.agent').service('AgentPingService', [
'AgentPing',
function AgentPingService(AgentPing) {
var service = {};
service.ping = ping;
function ping() {
return AgentPing.ping().$promise;
}
return service;
}
]);

View File

@@ -1,27 +1,60 @@
angular.module('portainer.agent').factory('VolumeBrowserService', [
'$q', 'Browse',
function VolumeBrowserServiceFactory($q, Browse) {
'StateManager', 'Browse', 'BrowseVersion1', '$q', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'Upload',
function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, $q, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) {
'use strict';
var service = {};
function getAgentApiVersion() {
var state = StateManager.getState();
return state.endpoint.agentApiVersion;
}
function getBrowseService() {
var agentVersion = getAgentApiVersion();
return agentVersion > 1 ? Browse : BrowseVersion1;
}
service.ls = function(volumeId, path) {
return Browse.ls({ volumeID: volumeId, path: path }).$promise;
return getBrowseService().ls({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise;
};
service.get = function(volumeId, path) {
return Browse.get({ volumeID: volumeId, path: path }).$promise;
return getBrowseService().get({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise;
};
service.delete = function(volumeId, path) {
return Browse.delete({ volumeID: volumeId, path: path }).$promise;
return getBrowseService().delete({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise;
};
service.rename = function(volumeId, path, newPath) {
var payload = {
CurrentFilePath: path,
CurrentFilePath: path,
NewFilePath: newPath
};
return Browse.rename({ volumeID: volumeId }, payload).$promise;
return getBrowseService().rename({ volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise;
};
service.upload = function upload(path, file, volumeId, onProgress) {
var deferred = $q.defer();
var agentVersion = StateManager.getAgentApiVersion();
if (agentVersion <2) {
deferred.reject('upload is not supported on this agent version');
return;
}
var url =
API_ENDPOINT_ENDPOINTS +
'/' +
EndpointProvider.endpointID() +
'/docker' +
'/v' + agentVersion +
'/browse/put?volumeID=' +
volumeId;
Upload.upload({
url: url,
data: { file: file, Path: path }
}).then(deferred.resolve, deferred.reject, onProgress);
return deferred.promise;
};
return service;

View File

@@ -30,17 +30,23 @@ function ($rootScope, $state, Authentication, authManager, StateManager, Endpoin
};
$transitions.onBefore({ to: 'docker.**' }, function() {
HttpRequestHelper.resetAgentTargetQueue();
HttpRequestHelper.resetAgentHeaders();
});
}]);
function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
authManager.redirectWhenUnauthenticated();
Authentication.init();
$rootScope.$on('tokenHasExpired', function() {
$state.go('portainer.auth', {error: 'Your session has expired'});
// The unauthenticated event is broadcasted by the jwtInterceptor when
// hitting a 401. We're using this instead of the usual combination of
// authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector
// to have more controls on which URL should trigger the unauthenticated state.
$rootScope.$on('unauthenticated', function (event, data) {
if (!_.includes(data.config.url, '/v2/')) {
$state.go('portainer.auth', { error: 'Your session has expired' });
}
});
}

View File

@@ -17,7 +17,7 @@
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">

View File

@@ -14,12 +14,10 @@ angular.module('portainer')
jwtOptionsProvider.config({
tokenGetter: ['LocalStorage', function(LocalStorage) {
return LocalStorage.getJWT();
}],
unauthenticatedRedirector: ['$state', function($state) {
$state.go('portainer.auth', {error: 'Your session has expired'});
}]
});
$httpProvider.interceptors.push('jwtInterceptor');
$httpProvider.interceptors.push('EndpointStatusInterceptor');
$httpProvider.defaults.headers.post['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.patch['Content-Type'] = 'application/json';
@@ -29,6 +27,9 @@ angular.module('portainer')
request: function(config) {
if (config.url.indexOf('/docker/') > -1) {
config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader();
if (HttpRequestHelper.portainerAgentManagerOperation()) {
config.headers['X-PortainerAgent-ManagerOperation'] = '1';
}
}
return config;
}
@@ -51,6 +52,7 @@ angular.module('portainer')
cfpLoadingBarProvider.includeSpinner = false;
cfpLoadingBarProvider.parentSelector = '#loadingbar-placeholder';
cfpLoadingBarProvider.latencyThreshold = 600;
$urlRouterProvider.otherwise('/auth');
}]);

View File

@@ -4,8 +4,10 @@ angular.module('portainer')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups')
.constant('API_ENDPOINT_MOTD', 'api/motd')
.constant('API_ENDPOINT_EXTENSIONS', 'api/extensions')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SCHEDULES', 'api/schedules')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_STACKS', 'api/stacks')
.constant('API_ENDPOINT_STATUS', 'api/status')
@@ -18,4 +20,5 @@ angular.module('portainer')
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
.constant('PAGINATION_MAX_ITEMS', 10)
.constant('APPLICATION_CACHE_VALIDITY', 3600)
.constant('CONSOLE_COMMANDS_LABEL_PREFIX', 'io.portainer.commands.');
.constant('CONSOLE_COMMANDS_LABEL_PREFIX', 'io.portainer.commands.')
.constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']);

View File

@@ -5,7 +5,17 @@ angular.module('portainer.docker', ['portainer.app'])
var docker = {
name: 'docker',
parent: 'root',
abstract: true
abstract: true,
resolve: {
endpointID: ['EndpointProvider', '$state',
function (EndpointProvider, $state) {
var id = EndpointProvider.endpointID();
if (!id) {
return $state.go('portainer.home');
}
}
]
}
};
var configs = {
@@ -149,6 +159,16 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
var hostJob = {
name: 'docker.host.job',
url: '/job',
views: {
'content@': {
component: 'hostJobView'
}
}
};
var events = {
name: 'docker.events',
url: '/events',
@@ -263,6 +283,16 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
var nodeJob = {
name: 'docker.nodes.node.job',
url: '/job',
views: {
'content@': {
component: 'nodeJobView'
}
}
};
var secrets = {
name: 'docker.secrets',
url: '/secrets',
@@ -434,7 +464,7 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
$stateRegistryProvider.register(configs);
$stateRegistryProvider.register(config);
@@ -450,6 +480,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(dashboard);
$stateRegistryProvider.register(host);
$stateRegistryProvider.register(hostBrowser);
$stateRegistryProvider.register(hostJob);
$stateRegistryProvider.register(events);
$stateRegistryProvider.register(images);
$stateRegistryProvider.register(image);
@@ -461,6 +492,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(nodes);
$stateRegistryProvider.register(node);
$stateRegistryProvider.register(nodeBrowser);
$stateRegistryProvider.register(nodeJob);
$stateRegistryProvider.register(secrets);
$stateRegistryProvider.register(secret);
$stateRegistryProvider.register(secretCreation);

View File

@@ -0,0 +1,44 @@
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
<a
ng-if="$ctrl.state.showQuickActionLogs && $ctrl.taskId === undefined"
style="margin: 0 2.5px;"
ui-sref="docker.containers.container.logs({id: $ctrl.containerId, nodeName: $ctrl.nodeName})"
title="Logs">
<i class="fa fa-file-alt space-right" aria-hidden="true"></i>
</a>
<a
ng-if="$ctrl.state.showQuickActionLogs && $ctrl.taskId !== undefined"
style="margin: 0 2.5px;"
ui-sref="docker.tasks.task.logs({id: $ctrl.taskId})"
title="Logs">
<i class="fa fa-file-alt space-right" aria-hidden="true"></i>
</a>
<a
ng-if="$ctrl.state.showQuickActionInspect && $ctrl.taskId === undefined"
style="margin: 0 2.5px;"
ui-sref="docker.containers.container.inspect({id: $ctrl.containerId, nodeName: $ctrl.nodeName})"
title="Inspect">
<i class="fa fa-info-circle space-right" aria-hidden="true"></i>
</a>
<a
ng-if="$ctrl.state.showQuickActionInspect && $ctrl.taskId !== undefined"
style="margin: 0 2.5px;"
ui-sref="docker.tasks.task({id: $ctrl.taskId})"
title="Inspect">
<i class="fa fa-info-circle space-right" aria-hidden="true"></i>
</a>
<a
ng-if="$ctrl.state.showQuickActionStats && ['starting', 'running', 'healthy', 'unhealthy'].indexOf($ctrl.status) !== -1"
style="margin: 0 2.5px;"
ui-sref="docker.containers.container.stats({id: $ctrl.containerId, nodeName: $ctrl.nodeName})"
title="Stats">
<i class="fa fa-chart-area space-right" aria-hidden="true"></i>
</a>
<a
ng-if="$ctrl.state.showQuickActionConsole && ['starting', 'running', 'healthy', 'unhealthy'].indexOf($ctrl.status) !== -1"
style="margin: 0 2.5px;"
ui-sref="docker.containers.container.console({id: $ctrl.containerId, nodeName: $ctrl.nodeName})"
title="Console">
<i class="fa fa-terminal space-right" aria-hidden="true"></i>
</a>
</div>

View File

@@ -0,0 +1,10 @@
angular.module('portainer.docker').component('containerQuickActions', {
templateUrl: 'app/docker/components/container-quick-actions/containerQuickActions.html',
bindings: {
containerId: '<',
nodeName: '<',
status: '<',
state: '<',
taskId: '<'
}
});

View File

@@ -17,7 +17,7 @@
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">

View File

@@ -38,12 +38,12 @@
</thead>
<tbody>
<tr dir-paginate="(key, value) in $ctrl.dataset | itemsPerPage: $ctrl.state.paginatedItemLimit" ng-class="{active: item.Checked}">
<td><a ui-sref="docker.networks.network({ id: value.NetworkID, nodeName: $ctrl.nodeName })">{{ key }}</a></td>
<td><a ui-sref="docker.networks.network({ id: key, nodeName: $ctrl.nodeName })">{{ key }}</a></td>
<td>{{ value.IPAddress || '-' }}</td>
<td>{{ value.Gateway || '-' }}</td>
<td>{{ value.MacAddress || '-' }}</td>
<td>
<button type="button" class="btn btn-xs btn-danger" ng-disabled="$ctrl.leaveNetworkActionInProgress" button-spinner="$ctrl.leaveNetworkActionInProgress" ng-click="$ctrl.leaveNetworkAction($ctrl.container, value.NetworkID)">
<button type="button" class="btn btn-xs btn-danger" ng-disabled="$ctrl.leaveNetworkActionInProgress" button-spinner="$ctrl.leaveNetworkActionInProgress" ng-click="$ctrl.leaveNetworkAction($ctrl.container, key)">
<span ng-hide="$ctrl.leaveNetworkActionInProgress"><i class="fa fa-trash-alt space-right" aria-hidden="true"></i> Leave network</span>
<span ng-show="$ctrl.leaveNetworkActionInProgress">Leaving network...</span>
</button>

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