From 8eac1d222130dbcea6745168cc186f49e34de68c Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 14 May 2020 05:14:28 +0300 Subject: [PATCH] feat(edge-compute): add support for Edge stacks (#3827) * feat(api): introduce Edge group API (#3639) * feat(edge-groups): add object definition and service definition * feat(edge-groups): implement bolt layer * feat(edge-groups): bind service to server * feat(edge-group): add edge-group create http handler * feat(edge-groups): add list method to edge group handler * feat(edge-group): add inspect http handler * feat(edge-groups): add delete edge-group handler * feat(edge-groups): add update group handler * style(db): order by alphabetical order * fix(edge-groups): rewrite http error messages Co-Authored-By: Anthony Lapenna * fix(main): order by alphabetical order * refactor(edge-group): relocate fetch group * fix(edge-group): reset tagids/endpoints if dynamic * refactor(server): order by alphabetical order * refactor(server): order by alphabetical order Co-authored-by: Anthony Lapenna * Introduce a new setting to enable Edge compute features (#3654) * feat(edge-compute): add edge compute setting * feat(edge-compute): add edge compute group to sidebar * fix(settings): rename settings form group * fix(settings): align form control * Edge group associated endpoints (#3659) * chore(version): bump version number * chore(version): bump version number * feat(endpoints): filter by endpoint type (#3646) * refactor(tags): migrate tags to have association objects * refactor(tags): refactor tag management (#3628) * refactor(tags): replace tags with tag ids * refactor(tags): revert tags to be strings and add tagids * refactor(tags): enable search by tag in home view * refactor(tags): show endpoint tags * refactor(endpoints): expect tagIds on create payload * refactor(endpoints): expect tagIds on update payload * refactor(endpoints): replace TagIds to TagIDs * refactor(endpoints): set endpoint group to get TagIDs * refactor(endpoints): refactor tag-selector to receive tag-ids * refactor(endpoints): show tags in multi-endpoint-selector * chore(tags): revert reformat * refactor(endpoints): remove unneeded bind * refactor(endpoints): change param tags to tagids in endpoint create * refactor(endpoints): remove console.log * refactor(tags): remove deleted tag from endpoint and endpoint group * fix(endpoints): show loading label while loading tags * chore(go): remove obsolete import labels * chore(db): add db version comment * fix(db): add tag service to migrator * refactor(db): add error checks in migrator * style(db): sort props in alphabetical order * style(tags): fix typo Co-Authored-By: Anthony Lapenna * refactor(endpoints): replace tagsMap with tag string representation * refactor(tags): rewrite tag delete to be more readable * refactor(home): rearange code to match former style * refactor(tags): guard against missing model in tag-selector * refactor(tags): rename vars in tag_delete * refactor(tags): allow any authenticated user to fetch tag list * refactor(endpoints): replace controller function with class * refactor(endpoints): replace function with helper * refactor(endpoints): replace controller with class * refactor(tags): revert tags-selector to use 1 way bindings * refactor(endpoints): load empty tag array instead of nil * refactor(endpoints): revert default tag ids * refactor(endpoints): use function in place * refactor(tags): use lodash * style(tags): use parens in arrow functions * fix(tags): remove tag from tag model Co-authored-by: Anthony Lapenna * refactor(tags): create tag association when creating tag * refactor(tags): delete tag association when deleting tag * refactor(db): handle error in tag association create * feat(endpoint-group): update tag assoc when creating endpoint group * feat(endpoint-group): update tag association when updating group * feat(endpoint-groups): remove group from tag associations * feat(endpoints): associate endpoint with tag on create * feat(endpoints): edit tag association when updating endpoint * fix(tags): fix merge problems * refactor(tags): remove tag association resource * fix(db): use regular tags map * style(tags): reorder props and imports * refactor(endpoint-groups): replace tag-association with tag * feat(edge-group): get associated endpoints when fetching * refactor(tags): refactor algo to update endpoint and group tags * refactor(edge-group): rename variable * refactor(tags): move calc of tags to remove to global function * fix(tags): update tag after adding association Co-authored-by: Anthony Lapenna Co-authored-by: Anthony Lapenna * fix(edge-groups): associate groups only with edge endpoints (#3667) * fix(edge-groups): check endpoint type when adding to edge-group * fix(edge-groups): return only edge endpoints for dynamic groups * fix(edge-compute): load edge compute setting on public setting (#3665) * Edge group list (#3644) * feat(edge-groups): add edge module * feat(edge-groups): add edge-group service * feat(edge-group): add groups list view * feat(edge-groups): add link to groups in the sidebar * feat(edge-group): show endpoints count and group type * feat(edge-group): enable removal of edge groups * refactor(edge-groups): replace datatable controller with class * refactor(edge-groups): replace function with class * fix(edge-groups): sort items by endpoints count and group type * refactor(edge-groups): use generic datatable-header component * feat(app): add trace for ui router * fix(edge-compute): add ng injection to onEnter guard * fix(edge-compute): add ng injection to onEnter guard * style(edge-compute): remove space * refactor(edge-compute): import angular * fix(app): remove ui router trace * refactor(product): revert app.js * fix(edge-compute): remove admin guard from edge routes * fix(edge-groups): change label of empty datatable Co-Authored-By: Anthony Lapenna * refactor(edge-groups): rename service * fix(edge-groups): replace icon in sidebar Co-Authored-By: Anthony Lapenna * refactor(edge-groups): remove datatable controller * refactor(edge-groups): move datatable icon to binding * refactor(edge-groups): use vanilla datatable header * refactor(datatable): remove datatable header Co-authored-by: Anthony Lapenna * refactor(edge): rename edge group to Edge group * feat(edge-groups): edge group creation view (#3671) * feat(edge-groups): add create group view * feat(edge-groups): allow to choose group type * feat(edge-groups): implement create service handler * feat(edge-group): filter by edge endpoints * refactor(edge-groups): rename to camel case * refactor(edge-groups): replace controller with class * feat(endpoints): filter endpoints by type * refactor(edge-groups): remove comments and unneccesary async keyword * refactor(edge-group): use $async service * fix(edge-groups): replace view title Co-Authored-By: Anthony Lapenna * fix(edge-groups): change icon Co-Authored-By: Anthony Lapenna * fix(edge-groups): change icon Co-Authored-By: Anthony Lapenna * refactor(edge-groups): remove obsolete function * feat(edge-groups): add empty list messages * feat(edge-group): add description to group types * refactor(edge-groups): add finally block * feat(endpoints): search server in multi-endpoint-selector Co-authored-by: Anthony Lapenna * feat(edge-group) edit view (#3672) * feat(edge-groups): add edit group view * refactor(edge-group): replace edit controller with class * refactor(edge-groups): remove async keyword * refactor(edge-groups): use $async service * refactor(edge-group): remove unnecessary functions * fix(endpoints): group by groups in endpoint-selector * feat(edge-groups): minor UI update * fix(edge-groups): provide defaults for edge group (#3682) * feat(edge-stacks): add basic views and sidebar link (#3689) * feat(edge-stacks): add mock routes * feat(edge-stacks): add link to stacks on sidebar * feat(edge-stacks): add edge stacks view * feat(edge-stacks): add create view * feat(edge-stacks): add edit view * fix(edge-stacks): use class in controller * feat(edge-stacks): add edge-stacks api (#3688) * feat(edge-stack): add edge stack types * feat(edge-stacks): add edge stack service interface * feat(edge-stacks): implement store * feat(edge-stacks): bind service to datastore * feat(edge-stacks): bind service to server * feat(edge-stack): create basic api * feat(edge-stack): create stack api * feat(edge-stacks): update api * refacotor(edge-stack): rename files * feat(edge-stack): update endpoint status * style(edge-stacks): remove comments * feat(edge-stacks): use edge stacks folder for files * fix(edge-stacks): replace bucket name Co-Authored-By: Anthony Lapenna * fix(edge-stacks): replace unmarshal function Co-Authored-By: Anthony Lapenna * fix(edge-stacks): replace edge stacks path Co-Authored-By: Anthony Lapenna Co-authored-by: Anthony Lapenna * chore(git): merge develop to edge compute (#3692) * feat(support): make support type dynamic (#3621) * chore(version): bump version number * chore(version): bump version number * feat(endpoints): filter by endpoint type (#3646) * chore(assets): double UI image resolutions for HiDPI displays (#3648) Fixes #3069 Prevents users seeing blurry logos and other images when using a hidpi display (like scaled 4k, or a Retina display). These images have been recreated manually with 2x the original resolution but should resemble the originals as much as possible. They have also been run through pngcrush for compression. * fix(services): enforce minimum replica count of 0 (#3653) * fix(services): enforce minimum replica count of 0 Fixes #3652 Prevents replica count from being set below zero and causing an error. * fix(services): enforce replica count is an integer Prevents users entering decimals in the replica count * refactor(tags): refactor tag management (#3628) * refactor(tags): replace tags with tag ids * refactor(tags): revert tags to be strings and add tagids * refactor(tags): enable search by tag in home view * refactor(tags): show endpoint tags * refactor(endpoints): expect tagIds on create payload * refactor(endpoints): expect tagIds on update payload * refactor(endpoints): replace TagIds to TagIDs * refactor(endpoints): set endpoint group to get TagIDs * refactor(endpoints): refactor tag-selector to receive tag-ids * refactor(endpoints): show tags in multi-endpoint-selector * chore(tags): revert reformat * refactor(endpoints): remove unneeded bind * refactor(endpoints): change param tags to tagids in endpoint create * refactor(endpoints): remove console.log * refactor(tags): remove deleted tag from endpoint and endpoint group * fix(endpoints): show loading label while loading tags * chore(go): remove obsolete import labels * chore(db): add db version comment * fix(db): add tag service to migrator * refactor(db): add error checks in migrator * style(db): sort props in alphabetical order * style(tags): fix typo Co-Authored-By: Anthony Lapenna * refactor(endpoints): replace tagsMap with tag string representation * refactor(tags): rewrite tag delete to be more readable * refactor(home): rearange code to match former style * refactor(tags): guard against missing model in tag-selector * refactor(tags): rename vars in tag_delete * refactor(tags): allow any authenticated user to fetch tag list * refactor(endpoints): replace controller function with class * refactor(endpoints): replace function with helper * refactor(endpoints): replace controller with class * refactor(tags): revert tags-selector to use 1 way bindings * refactor(endpoints): load empty tag array instead of nil * refactor(endpoints): revert default tag ids * refactor(endpoints): use function in place * refactor(tags): use lodash * style(tags): use parens in arrow functions * fix(tags): remove tag from tag model Co-authored-by: Anthony Lapenna * chore(yarn): change start:client to start webpack dev server (#3595) * chore(yarn): change start:client to start webpack dev server * Update package.json Co-authored-by: Anthony Lapenna * create tag from tag selector (#3640) * feat(tags): add button to save tag when doesn't exist * feat(endpoints): allow the creating of tags in endpoint edit * feat(groups): allow user to create tags in create group * feat(groups): allow user to create tags in edit group * feat(endpoint): allow user to create tags from endpoint create * feat(tags): allow the creation of a new tag from dropdown * feat(tag): replace "add" with "create" * feat(tags): show tags input when not tags * feat(tags): hide create message when not allowed * refactor(tags): replace component controller with class * refactor(tags): replace native methods with lodash * refactor(tags): remove unused onChangeTags function * refactor(tags): remove on-change binding * style(tags): remove white space * refactor(endpoint-groups): move controller to separate file * fix(groups): allow admin to create tag in group form * refactor(endpoints): wrap async function with try catch and $async * style(tags): wrap arrow function args with parenthesis * refactor(endpoints): return $async functions * refactor(tags): throw error in the format Notification expects * chore(yarn): add start:client script back (#3691) * feat(endpoints): filter by ids and/or tag ids (#3690) * feat(endpoints): add filter by tagIds * refactor(endpoints): change endpoints service to query by tagIds * fix(endpoints): filter by tags * feat(endpoints): filter by endpoint groups tags * feat(endpoints): filter by ids Co-authored-by: itsconquest Co-authored-by: Anthony Lapenna Co-authored-by: Ben Brooks Co-authored-by: Anthony Lapenna * Chore merge develop to edge compute (#3702) * feat(support): make support type dynamic (#3621) * chore(version): bump version number * chore(version): bump version number * feat(endpoints): filter by endpoint type (#3646) * chore(assets): double UI image resolutions for HiDPI displays (#3648) Fixes #3069 Prevents users seeing blurry logos and other images when using a hidpi display (like scaled 4k, or a Retina display). These images have been recreated manually with 2x the original resolution but should resemble the originals as much as possible. They have also been run through pngcrush for compression. * fix(services): enforce minimum replica count of 0 (#3653) * fix(services): enforce minimum replica count of 0 Fixes #3652 Prevents replica count from being set below zero and causing an error. * fix(services): enforce replica count is an integer Prevents users entering decimals in the replica count * refactor(tags): refactor tag management (#3628) * refactor(tags): replace tags with tag ids * refactor(tags): revert tags to be strings and add tagids * refactor(tags): enable search by tag in home view * refactor(tags): show endpoint tags * refactor(endpoints): expect tagIds on create payload * refactor(endpoints): expect tagIds on update payload * refactor(endpoints): replace TagIds to TagIDs * refactor(endpoints): set endpoint group to get TagIDs * refactor(endpoints): refactor tag-selector to receive tag-ids * refactor(endpoints): show tags in multi-endpoint-selector * chore(tags): revert reformat * refactor(endpoints): remove unneeded bind * refactor(endpoints): change param tags to tagids in endpoint create * refactor(endpoints): remove console.log * refactor(tags): remove deleted tag from endpoint and endpoint group * fix(endpoints): show loading label while loading tags * chore(go): remove obsolete import labels * chore(db): add db version comment * fix(db): add tag service to migrator * refactor(db): add error checks in migrator * style(db): sort props in alphabetical order * style(tags): fix typo Co-Authored-By: Anthony Lapenna * refactor(endpoints): replace tagsMap with tag string representation * refactor(tags): rewrite tag delete to be more readable * refactor(home): rearange code to match former style * refactor(tags): guard against missing model in tag-selector * refactor(tags): rename vars in tag_delete * refactor(tags): allow any authenticated user to fetch tag list * refactor(endpoints): replace controller function with class * refactor(endpoints): replace function with helper * refactor(endpoints): replace controller with class * refactor(tags): revert tags-selector to use 1 way bindings * refactor(endpoints): load empty tag array instead of nil * refactor(endpoints): revert default tag ids * refactor(endpoints): use function in place * refactor(tags): use lodash * style(tags): use parens in arrow functions * fix(tags): remove tag from tag model Co-authored-by: Anthony Lapenna * chore(yarn): change start:client to start webpack dev server (#3595) * chore(yarn): change start:client to start webpack dev server * Update package.json Co-authored-by: Anthony Lapenna * create tag from tag selector (#3640) * feat(tags): add button to save tag when doesn't exist * feat(endpoints): allow the creating of tags in endpoint edit * feat(groups): allow user to create tags in create group * feat(groups): allow user to create tags in edit group * feat(endpoint): allow user to create tags from endpoint create * feat(tags): allow the creation of a new tag from dropdown * feat(tag): replace "add" with "create" * feat(tags): show tags input when not tags * feat(tags): hide create message when not allowed * refactor(tags): replace component controller with class * refactor(tags): replace native methods with lodash * refactor(tags): remove unused onChangeTags function * refactor(tags): remove on-change binding * style(tags): remove white space * refactor(endpoint-groups): move controller to separate file * fix(groups): allow admin to create tag in group form * refactor(endpoints): wrap async function with try catch and $async * style(tags): wrap arrow function args with parenthesis * refactor(endpoints): return $async functions * refactor(tags): throw error in the format Notification expects * chore(yarn): add start:client script back (#3691) * feat(endpoints): filter by ids and/or tag ids (#3690) * feat(endpoints): add filter by tagIds * refactor(endpoints): change endpoints service to query by tagIds * fix(endpoints): filter by tags * feat(endpoints): filter by endpoint groups tags * feat(endpoints): filter by ids * refactor(project): sort portainer types and interface definitions (#3694) * refactor(portainer): sort types * style(portainer): add comment about role service * refactor(portainer): sort interface types * refactor(portainer): sort enums * Update README.md * Update README.md * Update README.md * chore(project): add prettier for code format (#3645) * chore(project): install prettier and lint-staged * chore(project): apply prettier to html too * chore(project): git ignore eslintcache * chore(project): add a comment about format script * chore(prettier): update printWidth * chore(prettier): remove useTabs option * chore(prettier): add HTML validation * refactor(prettier): fix closing tags * feat(prettier): define angular parser for html templates * style(prettier): run prettier on codebase Co-authored-by: Anthony Lapenna * chore(prettier): run format on client codebase Co-authored-by: itsconquest Co-authored-by: Anthony Lapenna Co-authored-by: Ben Brooks Co-authored-by: Anthony Lapenna Co-authored-by: Neil Cresswell * feat(edge-stacks): create basic edge stack service (#3704) Co-authored-by: Anthony Lapenna * feat(edge-groups): Provide a switch to use AND or OR for tags (#3695) * feat(edge-groups): add switch to form * feat(project): add property to EdgeGroup * feat(edge-groups): save mustHaveAllTags * feat(edge-groups): fetch associated endpoints (AND and OR) * feat(edge-groups): add AND selector * feat(edge-groups): default to AND * fix(edge-groups): rewrite selector options Co-Authored-By: Anthony Lapenna * refactor(endpoints): move margin to schedule form * fix(edge-groups): move the selector to top of group * refactor(edge-groups): replace partialMatch property Co-authored-by: Anthony Lapenna * feat(edge-stacks): add Edge stack creation view (#3705) * feat(edge-stacks): basic creation view * feat(edge-stacks): add group selector * feat(edge-stack): create edge stack * fix(code-editor): apply digest cycle after editor is changed * style(project): reformat constants file * feat(edge-stacks): add a note about missing edge groups * fix(edge-stacks): add groups when creating stack from file * feat(edge-groups): add associated endpoints table (#3710) * feat(edge-groups): load associated endpoints * feat(endpoints): add option to filter endpoint by partial match tags * feat(edge-groups): query endpoints by PartialMatch * feat(edge-groups): reload endpoints when form changes * feat(edge-groups): remove columns * feat(edge-group): remove url column * refactor(edge-group): remove props * feat(edge-stacks): add list view (#3713) * feat(edge-stacks): basic datatable * feat(edge-stacks): remove stack * refactor(edge-stacks): convert to class * refactor(edge-stacks): replace id with stackId * feat(edge-stacks) edit edge stack view (#3716) * feat(edge-stack): load file content * feat(edge-stack): edit view * feat(edge-stack): enable update stack * refactor(edge-stacks): move form to component * feat(edge-stacks): add endpoints status * feat(edge-stacks): minor UI update Co-authored-by: Anthony Lapenna * feat(edge-groups) prevent deletion of edge group used by an edge stack (#3722) * feat(edge-groups): show if group belonges to edge stack * feat(edge-group): protect deletion of used edge group * feat(edge-groups): diable selection of used group * feat(edge-groups): add inuse tag (#3739) * feat(edge-groups): add inuse tag * Update app/edge/components/groups-datatable/groupsDatatable.html Co-authored-by: Anthony Lapenna * feat(edge-stack): update stack version when stack file is changed (#3746) * feat(edge-stack): update version when stack file is changed * refactor(edge-stacks): move update of version to clientside * feat(edge-groups): replace Edge group endpoint selector (#3738) * feat(edge-groups): replace selector * feat(edge-group): add selector in edit form * feat(edge-groups): show tags in endpoint selector * feat(edge-groups): show the endpoint group name * fix(edge-group): remove element from associated endpoints * feat(edge-groups): add group column * feat(edge-groups): move endpoints to other column * fix(groups): disable sort * refactor(endpoints): toggle backend pagination as a property * fix(endpoints): show group name in group-association-table * feat(endpoints): truncate table columns * fix(endpoints): update group association table colspan * fix(endpoint-groups): show dash when no tags Co-authored-by: Anthony Lapenna * feat(edge-stacks): add api for edge to query stack config (#3748) * refactor(http): move edge validation to bouncer * feat(edge-stacks): add api for edge to query stack config * style(edge-stack): remove parentheses * Update api/http/security/bouncer.go * refactor(edge-stacks): move config inspect to endpoints handler * refactor(endpoints): move stack inspect to edge handler * style(security): fix typo Co-Authored-By: Anthony Lapenna * refactor(endpoints): rename file Co-authored-by: Anthony Lapenna * feat(edge-groups): add dynamic group endpoints table (#3780) * fix(edge-stacks): update version when updating stack files (#3778) * feat(edgestacks): change status permission to edge enpoints * feat(edge-compute): add stack info to edge status inspect (#3764) * feat(edge-compute): create helper functions * feat(endpoints): add relation object and service * feat(db): create endpoint relation migration * feat(endpoints): create relation when creating endpoint * feat(endpoints): update relation when updating endpoint * feat(endpoints): delete relation when deleting endpoint * feat(endpoint): add stack status to endpoint_status * feat(edge-stacks): connect new edge stack to endpoint * refactor(edgestack): return errors.New * refactor(edgestacks): return error * refactor(edgegroup): endpoint can be related only if edge endpoint * feat(endpoints): update relation only when tags or groups were changd * refactor(tags): change tags functions to set functions * refactor(edgestack): return a list of endpoints for a list of edgegroups * feat(edgestacks): update relation when updating stack * feat(edgestacks): remove relations when deleting edge stack * feat(edgegroup): update related endpoints * feat(endpoint-group): update endpoints relations on create * feat(endpointgroup): add relatd stacks to endpoint when added to group * feat(endpoint-groups): update relation when group is changed * feat(endpointgroup): when deleting group, update its endpoints relations * feat(tags): update related endpoints when deleting tag * refactor(edge-compute): use pointers * refactor(endpointgroup): handle unassociated endpoint * fix(edgestack): show correct stack status * fix(endpoint): remove deleted endpoint from related tags * feat(edge-stacks): change acknowledged status color to blue (#3810) * feat(edge-compute): provide stack name to edge endpoint (#3809) * feat(edge-groups): when no tags selected show empty list of endpoints (#3811) * feat(edge-groups): when no tags selected show empty list of endpoints * fix(edge-group): change empty associated endpoint text * fix(edge-compute): add missing relations updates (#3817) * fix(endpoint): remove deleted endpoint from edge group * fix(tags): remove deleted tag from edge group * fix(endpoint): remove deleted endpoint from edge stack * fix(edge-groups): remove clearing of edgeGroup fields * fix(edge-groups): show dynamic edge groups without tags * fix(edge-compute): use sequential delete in resources (#3818) * fix(endpoints): delete endpoints on by one * fix(tags): remove tags one by one * fix(groups): remove endpoint groups one by one * fix(edge-stacks): remove stack one by one * fix(edge-groups): remove edge group one by one * fix(edge-stacks): add link to root in breadcrumbs * style(edge): add empty line after errors * refactor(tags): remove old function * refactor(endpoints): revert changes to multi-endpoint-selector * feat(edge-stacks): support Edge stack templates (#3812) * feat(edge-compute): fetch templates from url * feat(edge-stacks): fetch edge templates * feat(edge-stacks): choose template and save * feat(edge-stacks): add placeholder to templates select * feat(edge-templates): show info * fix(edge-stacks): fix typo * feat(edge-templates): replace template url * feat(edge-compute): use custom url if available * fix(edge-stacks): show error message when failing * feat(edge-compute): show description in template * feat(edge-templates): change access to route * style(edge-compute): change EdgeTemplatesURL description Co-authored-by: Anthony Lapenna Co-authored-by: Anthony Lapenna Co-authored-by: Anthony Lapenna Co-authored-by: Anthony Lapenna Co-authored-by: itsconquest Co-authored-by: Ben Brooks Co-authored-by: Neil Cresswell --- api/bolt/datastore.go | 103 ++++--- api/bolt/edgegroup/edgegroup.go | 94 ++++++ api/bolt/edgestack/edgestack.go | 101 ++++++ api/bolt/endpointrelation/endpointrelation.go | 68 ++++ api/bolt/migrator/migrate_dbversion22.go | 49 ++- api/bolt/migrator/migrator.go | 113 +++---- api/bolt/tag/tag.go | 6 + api/cmd/portainer/main.go | 79 ++--- api/edgegroup.go | 54 ++++ api/edgestack.go | 27 ++ api/endpoint.go | 25 ++ api/filesystem/filesystem.go | 28 ++ .../edgegroups/associated_endpoints.go | 104 +++++++ .../handler/edgegroups/edgegroup_create.go | 83 +++++ .../handler/edgegroups/edgegroup_delete.go | 45 +++ .../handler/edgegroups/edgegroup_inspect.go | 35 +++ api/http/handler/edgegroups/edgegroup_list.go | 55 ++++ .../handler/edgegroups/edgegroup_update.go | 154 +++++++++ api/http/handler/edgegroups/handler.go | 39 +++ .../handler/edgestacks/edgestack_create.go | 289 +++++++++++++++++ .../handler/edgestacks/edgestack_delete.go | 62 ++++ api/http/handler/edgestacks/edgestack_file.go | 37 +++ .../handler/edgestacks/edgestack_inspect.go | 26 ++ api/http/handler/edgestacks/edgestack_list.go | 17 + .../edgestacks/edgestack_status_update.go | 76 +++++ .../handler/edgestacks/edgestack_update.go | 156 ++++++++++ api/http/handler/edgestacks/git.go | 17 + api/http/handler/edgestacks/handler.go | 46 +++ .../edgetemplates/edgetemplate_list.go | 49 +++ api/http/handler/edgetemplates/handler.go | 31 ++ .../endpoint_edgestack_inspect.go | 60 ++++ api/http/handler/endpointedge/handler.go | 33 ++ .../endpointgroups/endpointgroup_create.go | 19 ++ .../endpointgroups/endpointgroup_delete.go | 21 +- .../endpointgroup_endpoint_add.go | 5 + .../endpointgroup_endpoint_delete.go | 5 + .../endpointgroups/endpointgroup_update.go | 55 +++- api/http/handler/endpointgroups/endpoints.go | 42 +++ api/http/handler/endpointgroups/handler.go | 10 +- api/http/handler/endpoints/endpoint_create.go | 46 +++ api/http/handler/endpoints/endpoint_delete.go | 70 +++++ api/http/handler/endpoints/endpoint_list.go | 61 +++- .../endpoints/endpoint_status_inspect.go | 45 ++- api/http/handler/endpoints/endpoint_update.go | 80 ++++- api/http/handler/endpoints/handler.go | 12 +- api/http/handler/handler.go | 16 + api/http/handler/settings/settings_public.go | 2 + api/http/handler/settings/settings_update.go | 5 + api/http/handler/tags/handler.go | 9 +- api/http/handler/tags/tag_create.go | 4 +- api/http/handler/tags/tag_delete.go | 88 +++++- api/http/security/bouncer.go | 20 ++ api/http/server.go | 137 ++++++--- api/portainer.go | 99 +++++- api/tag.go | 71 +++++ app/__module.js | 2 + app/constants.js | 3 + app/edge/__module.js | 80 +++++ .../associatedEndpointsDatatable.html | 82 +++++ .../associatedEndpointsDatatable.js | 13 + .../associatedEndpointsDatatableController.js | 103 +++++++ .../edge-groups-selector.html | 18 ++ .../edge-groups-selector.js | 7 + .../edgeStackEndpointsDatatable.html | 87 ++++++ .../edgeStackEndpointsDatatable.js | 12 + .../edgeStackEndpointsDatatableController.js | 111 +++++++ .../edge-stack-status/edgeStackStatus.css | 25 ++ .../edge-stack-status/edgeStackStatus.html | 3 + .../edge-stack-status/edgeStackStatus.js | 11 + .../edgeStackStatusController.js | 25 ++ .../edgeStacksDatatable.html | 145 +++++++++ .../edgeStacksDatatable.js | 14 + .../editEdgeStackForm.html | 74 +++++ .../edit-edge-stack-form/editEdgeStackForm.js | 10 + .../editEdgeStackFormController.js | 14 + app/edge/components/group-form/groupForm.html | 189 ++++++++++++ app/edge/components/group-form/groupForm.js | 14 + .../group-form/groupFormController.js | 94 ++++++ .../groups-datatable/groupsDatatable.html | 102 ++++++ .../groups-datatable/groupsDatatable.js | 15 + .../groupsDatatableController.js | 19 ++ app/edge/rest/edge-groups.js | 15 + app/edge/rest/edge-stacks.js | 16 + app/edge/rest/edge-templates.js | 11 + app/edge/services/edge-group.js | 27 ++ app/edge/services/edge-stack.js | 75 +++++ app/edge/services/edge-template.js | 23 ++ .../create/createEdgeStackView.html | 291 ++++++++++++++++++ .../edge-stacks/create/createEdgeStackView.js | 4 + .../create/createEdgeStackViewController.js | 156 ++++++++++ .../views/edge-stacks/edgeStacksView.html | 21 ++ app/edge/views/edge-stacks/edgeStacksView.js | 4 + .../edge-stacks/edgeStacksViewController.js | 52 ++++ .../edge-stacks/edit/editEdgeStackView.html | 43 +++ .../edge-stacks/edit/editEdgeStackView.js | 4 + .../edit/editEdgeStackViewController.js | 94 ++++++ .../groups/create/createEdgeGroupView.html | 23 ++ .../groups/create/createEdgeGroupView.js | 4 + .../create/createEdgeGroupViewController.js | 56 ++++ app/edge/views/groups/edgeGroupsView.html | 14 + app/edge/views/groups/edgeGroupsView.js | 6 + .../views/groups/edgeGroupsViewController.js | 40 +++ .../views/groups/edit/editEdgeGroupView.html | 23 ++ .../views/groups/edit/editEdgeGroupView.js | 4 + .../edit/editEdgeGroupViewController.js | 56 ++++ .../code-editor/codeEditorController.js | 34 +- .../datatables/genericDatatableController.js | 5 +- .../forms/group-form/groupForm.html | 1 + .../forms/schedule-form/scheduleForm.html | 1 + .../group-association-table.js | 20 +- .../groupAssociationTable.html | 41 ++- .../multiEndpointSelector.html | 12 +- .../multiEndpointSelectorController.js | 12 +- app/portainer/models/settings.js | 2 + app/portainer/services/api/endpointService.js | 4 +- app/portainer/services/fileUpload.js | 13 + app/portainer/services/stateManager.js | 6 + .../views/endpoints/endpointsController.js | 88 +++--- .../views/groups/groupsController.js | 76 +++-- app/portainer/views/settings/settings.html | 32 +- .../views/settings/settingsController.js | 4 + app/portainer/views/sidebar/sidebar.html | 9 + app/portainer/views/tags/tagsController.js | 127 ++++---- 123 files changed, 5463 insertions(+), 441 deletions(-) create mode 100644 api/bolt/edgegroup/edgegroup.go create mode 100644 api/bolt/edgestack/edgestack.go create mode 100644 api/bolt/endpointrelation/endpointrelation.go create mode 100644 api/edgegroup.go create mode 100644 api/edgestack.go create mode 100644 api/endpoint.go create mode 100644 api/http/handler/edgegroups/associated_endpoints.go create mode 100644 api/http/handler/edgegroups/edgegroup_create.go create mode 100644 api/http/handler/edgegroups/edgegroup_delete.go create mode 100644 api/http/handler/edgegroups/edgegroup_inspect.go create mode 100644 api/http/handler/edgegroups/edgegroup_list.go create mode 100644 api/http/handler/edgegroups/edgegroup_update.go create mode 100644 api/http/handler/edgegroups/handler.go create mode 100644 api/http/handler/edgestacks/edgestack_create.go create mode 100644 api/http/handler/edgestacks/edgestack_delete.go create mode 100644 api/http/handler/edgestacks/edgestack_file.go create mode 100644 api/http/handler/edgestacks/edgestack_inspect.go create mode 100644 api/http/handler/edgestacks/edgestack_list.go create mode 100644 api/http/handler/edgestacks/edgestack_status_update.go create mode 100644 api/http/handler/edgestacks/edgestack_update.go create mode 100644 api/http/handler/edgestacks/git.go create mode 100644 api/http/handler/edgestacks/handler.go create mode 100644 api/http/handler/edgetemplates/edgetemplate_list.go create mode 100644 api/http/handler/edgetemplates/handler.go create mode 100644 api/http/handler/endpointedge/endpoint_edgestack_inspect.go create mode 100644 api/http/handler/endpointedge/handler.go create mode 100644 api/http/handler/endpointgroups/endpoints.go create mode 100644 api/tag.go create mode 100644 app/edge/__module.js create mode 100644 app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html create mode 100644 app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.js create mode 100644 app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatableController.js create mode 100644 app/edge/components/edge-groups-selector/edge-groups-selector.html create mode 100644 app/edge/components/edge-groups-selector/edge-groups-selector.js create mode 100644 app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html create mode 100644 app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.js create mode 100644 app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js create mode 100644 app/edge/components/edge-stack-status/edgeStackStatus.css create mode 100644 app/edge/components/edge-stack-status/edgeStackStatus.html create mode 100644 app/edge/components/edge-stack-status/edgeStackStatus.js create mode 100644 app/edge/components/edge-stack-status/edgeStackStatusController.js create mode 100644 app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html create mode 100644 app/edge/components/edge-stacks-datatable/edgeStacksDatatable.js create mode 100644 app/edge/components/edit-edge-stack-form/editEdgeStackForm.html create mode 100644 app/edge/components/edit-edge-stack-form/editEdgeStackForm.js create mode 100644 app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js create mode 100644 app/edge/components/group-form/groupForm.html create mode 100644 app/edge/components/group-form/groupForm.js create mode 100644 app/edge/components/group-form/groupFormController.js create mode 100644 app/edge/components/groups-datatable/groupsDatatable.html create mode 100644 app/edge/components/groups-datatable/groupsDatatable.js create mode 100644 app/edge/components/groups-datatable/groupsDatatableController.js create mode 100644 app/edge/rest/edge-groups.js create mode 100644 app/edge/rest/edge-stacks.js create mode 100644 app/edge/rest/edge-templates.js create mode 100644 app/edge/services/edge-group.js create mode 100644 app/edge/services/edge-stack.js create mode 100644 app/edge/services/edge-template.js create mode 100644 app/edge/views/edge-stacks/create/createEdgeStackView.html create mode 100644 app/edge/views/edge-stacks/create/createEdgeStackView.js create mode 100644 app/edge/views/edge-stacks/create/createEdgeStackViewController.js create mode 100644 app/edge/views/edge-stacks/edgeStacksView.html create mode 100644 app/edge/views/edge-stacks/edgeStacksView.js create mode 100644 app/edge/views/edge-stacks/edgeStacksViewController.js create mode 100644 app/edge/views/edge-stacks/edit/editEdgeStackView.html create mode 100644 app/edge/views/edge-stacks/edit/editEdgeStackView.js create mode 100644 app/edge/views/edge-stacks/edit/editEdgeStackViewController.js create mode 100644 app/edge/views/groups/create/createEdgeGroupView.html create mode 100644 app/edge/views/groups/create/createEdgeGroupView.js create mode 100644 app/edge/views/groups/create/createEdgeGroupViewController.js create mode 100644 app/edge/views/groups/edgeGroupsView.html create mode 100644 app/edge/views/groups/edgeGroupsView.js create mode 100644 app/edge/views/groups/edgeGroupsViewController.js create mode 100644 app/edge/views/groups/edit/editEdgeGroupView.html create mode 100644 app/edge/views/groups/edit/editEdgeGroupView.js create mode 100644 app/edge/views/groups/edit/editEdgeGroupViewController.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index c6ce7db61..f80fd5921 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -5,6 +5,9 @@ import ( "path" "time" + "github.com/portainer/portainer/api/bolt/edgegroup" + "github.com/portainer/portainer/api/bolt/edgestack" + "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/tunnelserver" "github.com/boltdb/bolt" @@ -36,28 +39,31 @@ const ( // Store defines the implementation of portainer.DataStore using // BoltDB as the storage system. type Store struct { - path string - db *bolt.DB - checkForDataMigration bool - fileService portainer.FileService - RoleService *role.Service - DockerHubService *dockerhub.Service - EndpointGroupService *endpointgroup.Service - EndpointService *endpoint.Service - ExtensionService *extension.Service - RegistryService *registry.Service - ResourceControlService *resourcecontrol.Service - SettingsService *settings.Service - StackService *stack.Service - TagService *tag.Service - TeamMembershipService *teammembership.Service - TeamService *team.Service - TemplateService *template.Service - TunnelServerService *tunnelserver.Service - UserService *user.Service - VersionService *version.Service - WebhookService *webhook.Service - ScheduleService *schedule.Service + path string + db *bolt.DB + checkForDataMigration bool + fileService portainer.FileService + RoleService *role.Service + DockerHubService *dockerhub.Service + EdgeGroupService *edgegroup.Service + EdgeStackService *edgestack.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + SettingsService *settings.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TeamService *team.Service + TemplateService *template.Service + TunnelServerService *tunnelserver.Service + UserService *user.Service + VersionService *version.Service + WebhookService *webhook.Service + ScheduleService *schedule.Service } // NewStore initializes a new Store and the associated services @@ -117,23 +123,24 @@ func (store *Store) MigrateData() error { if version < portainer.DBVersion { migratorParams := &migrator.Parameters{ - DB: store.db, - DatabaseVersion: version, - EndpointGroupService: store.EndpointGroupService, - EndpointService: store.EndpointService, - ExtensionService: store.ExtensionService, - RegistryService: store.RegistryService, - ResourceControlService: store.ResourceControlService, - RoleService: store.RoleService, - ScheduleService: store.ScheduleService, - SettingsService: store.SettingsService, - StackService: store.StackService, - TagService: store.TagService, - TeamMembershipService: store.TeamMembershipService, - TemplateService: store.TemplateService, - UserService: store.UserService, - VersionService: store.VersionService, - FileService: store.fileService, + DB: store.db, + DatabaseVersion: version, + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + EndpointRelationService: store.EndpointRelationService, + ExtensionService: store.ExtensionService, + RegistryService: store.RegistryService, + ResourceControlService: store.ResourceControlService, + RoleService: store.RoleService, + ScheduleService: store.ScheduleService, + SettingsService: store.SettingsService, + StackService: store.StackService, + TagService: store.TagService, + TeamMembershipService: store.TeamMembershipService, + TemplateService: store.TemplateService, + UserService: store.UserService, + VersionService: store.VersionService, + FileService: store.fileService, } migrator := migrator.NewMigrator(migratorParams) @@ -161,6 +168,18 @@ func (store *Store) initServices() error { } store.DockerHubService = dockerhubService + edgeStackService, err := edgestack.NewService(store.db) + if err != nil { + return err + } + store.EdgeStackService = edgeStackService + + edgeGroupService, err := edgegroup.NewService(store.db) + if err != nil { + return err + } + store.EdgeGroupService = edgeGroupService + endpointgroupService, err := endpointgroup.NewService(store.db) if err != nil { return err @@ -173,6 +192,12 @@ func (store *Store) initServices() error { } store.EndpointService = endpointService + endpointRelationService, err := endpointrelation.NewService(store.db) + if err != nil { + return err + } + store.EndpointRelationService = endpointRelationService + extensionService, err := extension.NewService(store.db) if err != nil { return err diff --git a/api/bolt/edgegroup/edgegroup.go b/api/bolt/edgegroup/edgegroup.go new file mode 100644 index 000000000..41909b437 --- /dev/null +++ b/api/bolt/edgegroup/edgegroup.go @@ -0,0 +1,94 @@ +package edgegroup + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "edgegroups" +) + +// Service represents a service for managing Edge group 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 +} + +// EdgeGroups return an array containing all the Edge groups. +func (service *Service) EdgeGroups() ([]portainer.EdgeGroup, error) { + var groups = make([]portainer.EdgeGroup, 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 group portainer.EdgeGroup + err := internal.UnmarshalObjectWithJsoniter(v, &group) + if err != nil { + return err + } + groups = append(groups, group) + } + + return nil + }) + + return groups, err +} + +// EdgeGroup returns an Edge group by ID. +func (service *Service) EdgeGroup(ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) { + var group portainer.EdgeGroup + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &group) + if err != nil { + return nil, err + } + + return &group, nil +} + +// UpdateEdgeGroup updates an Edge group. +func (service *Service) UpdateEdgeGroup(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, group) +} + +// DeleteEdgeGroup deletes an Edge group. +func (service *Service) DeleteEdgeGroup(ID portainer.EdgeGroupID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// CreateEdgeGroup assign an ID to a new Edge group and saves it. +func (service *Service) CreateEdgeGroup(group *portainer.EdgeGroup) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + group.ID = portainer.EdgeGroupID(id) + + data, err := internal.MarshalObject(group) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(group.ID)), data) + }) +} diff --git a/api/bolt/edgestack/edgestack.go b/api/bolt/edgestack/edgestack.go new file mode 100644 index 000000000..337bb6892 --- /dev/null +++ b/api/bolt/edgestack/edgestack.go @@ -0,0 +1,101 @@ +package edgestack + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "edge_stack" +) + +// Service represents a service for managing Edge stack 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 +} + +// EdgeStacks returns an array containing all edge stacks +func (service *Service) EdgeStacks() ([]portainer.EdgeStack, error) { + var stacks = make([]portainer.EdgeStack, 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 stack portainer.EdgeStack + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + stacks = append(stacks, stack) + } + + return nil + }) + + return stacks, err +} + +// EdgeStack returns an Edge stack by ID. +func (service *Service) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error) { + var stack portainer.EdgeStack + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &stack) + if err != nil { + return nil, err + } + + return &stack, nil +} + +// CreateEdgeStack assign an ID to a new Edge stack and saves it. +func (service *Service) CreateEdgeStack(edgeStack *portainer.EdgeStack) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + if edgeStack.ID == 0 { + id, _ := bucket.NextSequence() + edgeStack.ID = portainer.EdgeStackID(id) + } + + data, err := internal.MarshalObject(edgeStack) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(edgeStack.ID)), data) + }) +} + +// UpdateEdgeStack updates an Edge stack. +func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, edgeStack) +} + +// DeleteEdgeStack deletes an Edge stack. +func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// GetNextIdentifier returns the next identifier for an endpoint. +func (service *Service) GetNextIdentifier() int { + return internal.GetNextIdentifier(service.db, BucketName) +} diff --git a/api/bolt/endpointrelation/endpointrelation.go b/api/bolt/endpointrelation/endpointrelation.go new file mode 100644 index 000000000..00dab3f4a --- /dev/null +++ b/api/bolt/endpointrelation/endpointrelation.go @@ -0,0 +1,68 @@ +package endpointrelation + +import ( + "github.com/boltdb/bolt" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "endpoint_relations" +) + +// Service represents a service for managing endpoint relation 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 +} + +// EndpointRelation returns a Endpoint relation object by EndpointID +func (service *Service) EndpointRelation(endpointID portainer.EndpointID) (*portainer.EndpointRelation, error) { + var endpointRelation portainer.EndpointRelation + identifier := internal.Itob(int(endpointID)) + + err := internal.GetObject(service.db, BucketName, identifier, &endpointRelation) + if err != nil { + return nil, err + } + + return &endpointRelation, nil +} + +// CreateEndpointRelation saves endpointRelation +func (service *Service) CreateEndpointRelation(endpointRelation *portainer.EndpointRelation) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data, err := internal.MarshalObject(endpointRelation) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(endpointRelation.EndpointID)), data) + }) +} + +// UpdateEndpointRelation updates an Endpoint relation object +func (service *Service) UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error { + identifier := internal.Itob(int(EndpointID)) + return internal.UpdateObject(service.db, BucketName, identifier, endpointRelation) +} + +// DeleteEndpointRelation deletes an Endpoint relation object +func (service *Service) DeleteEndpointRelation(EndpointID portainer.EndpointID) error { + identifier := internal.Itob(int(EndpointID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/migrator/migrate_dbversion22.go b/api/bolt/migrator/migrate_dbversion22.go index f2e3c2b6c..4a132c348 100644 --- a/api/bolt/migrator/migrate_dbversion22.go +++ b/api/bolt/migrator/migrate_dbversion22.go @@ -2,15 +2,32 @@ package migrator import "github.com/portainer/portainer/api" -func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { +func (m *Migrator) updateTagsToDBVersion23() error { tags, err := m.tagService.Tags() if err != nil { return err } - tagsNameMap := make(map[string]portainer.TagID) for _, tag := range tags { - tagsNameMap[tag.Name] = tag.ID + tag.EndpointGroups = make(map[portainer.EndpointGroupID]bool) + tag.Endpoints = make(map[portainer.EndpointID]bool) + err = m.tagService.UpdateTag(tag.ID, &tag) + if err != nil { + return err + } + } + return nil +} + +func (m *Migrator) updateEndpointsAndEndpointGroupsToDBVersion23() error { + tags, err := m.tagService.Tags() + if err != nil { + return err + } + + tagsNameMap := make(map[string]portainer.Tag) + for _, tag := range tags { + tagsNameMap[tag.Name] = tag } endpoints, err := m.endpointService.Endpoints() @@ -21,9 +38,10 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { for _, endpoint := range endpoints { endpointTags := make([]portainer.TagID, 0) for _, tagName := range endpoint.Tags { - tagID, ok := tagsNameMap[tagName] + tag, ok := tagsNameMap[tagName] if ok { - endpointTags = append(endpointTags, tagID) + endpointTags = append(endpointTags, tag.ID) + tag.Endpoints[endpoint.ID] = true } } endpoint.TagIDs = endpointTags @@ -31,6 +49,16 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { if err != nil { return err } + + relation := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + err = m.endpointRelationService.CreateEndpointRelation(relation) + if err != nil { + return err + } } endpointGroups, err := m.endpointGroupService.EndpointGroups() @@ -41,9 +69,10 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { for _, endpointGroup := range endpointGroups { endpointGroupTags := make([]portainer.TagID, 0) for _, tagName := range endpointGroup.Tags { - tagID, ok := tagsNameMap[tagName] + tag, ok := tagsNameMap[tagName] if ok { - endpointGroupTags = append(endpointGroupTags, tagID) + endpointGroupTags = append(endpointGroupTags, tag.ID) + tag.EndpointGroups[endpointGroup.ID] = true } } endpointGroup.TagIDs = endpointGroupTags @@ -53,5 +82,11 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { } } + for _, tag := range tagsNameMap { + err = m.tagService.UpdateTag(tag.ID, &tag) + if err != nil { + return err + } + } return nil } diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 8f4aee085..055ffcdda 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -5,6 +5,7 @@ import ( "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/endpoint" "github.com/portainer/portainer/api/bolt/endpointgroup" + "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/extension" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" @@ -22,67 +23,70 @@ import ( type ( // Migrator defines a service to migrate data after a Portainer version update. Migrator struct { - currentDBVersion int - db *bolt.DB - endpointGroupService *endpointgroup.Service - endpointService *endpoint.Service - extensionService *extension.Service - registryService *registry.Service - resourceControlService *resourcecontrol.Service - roleService *role.Service - scheduleService *schedule.Service - settingsService *settings.Service - stackService *stack.Service - tagService *tag.Service - teamMembershipService *teammembership.Service - templateService *template.Service - userService *user.Service - versionService *version.Service - fileService portainer.FileService + currentDBVersion int + db *bolt.DB + endpointGroupService *endpointgroup.Service + endpointService *endpoint.Service + endpointRelationService *endpointrelation.Service + extensionService *extension.Service + registryService *registry.Service + resourceControlService *resourcecontrol.Service + roleService *role.Service + scheduleService *schedule.Service + settingsService *settings.Service + stackService *stack.Service + tagService *tag.Service + teamMembershipService *teammembership.Service + templateService *template.Service + userService *user.Service + versionService *version.Service + fileService portainer.FileService } // Parameters represents the required parameters to create a new Migrator instance. Parameters struct { - DB *bolt.DB - DatabaseVersion int - EndpointGroupService *endpointgroup.Service - EndpointService *endpoint.Service - ExtensionService *extension.Service - RegistryService *registry.Service - ResourceControlService *resourcecontrol.Service - RoleService *role.Service - ScheduleService *schedule.Service - SettingsService *settings.Service - StackService *stack.Service - TagService *tag.Service - TeamMembershipService *teammembership.Service - TemplateService *template.Service - UserService *user.Service - VersionService *version.Service - FileService portainer.FileService + DB *bolt.DB + DatabaseVersion int + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + RoleService *role.Service + ScheduleService *schedule.Service + SettingsService *settings.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TemplateService *template.Service + UserService *user.Service + VersionService *version.Service + FileService portainer.FileService } ) // NewMigrator creates a new Migrator. func NewMigrator(parameters *Parameters) *Migrator { return &Migrator{ - db: parameters.DB, - currentDBVersion: parameters.DatabaseVersion, - endpointGroupService: parameters.EndpointGroupService, - endpointService: parameters.EndpointService, - extensionService: parameters.ExtensionService, - registryService: parameters.RegistryService, - resourceControlService: parameters.ResourceControlService, - roleService: parameters.RoleService, - scheduleService: parameters.ScheduleService, - settingsService: parameters.SettingsService, - tagService: parameters.TagService, - teamMembershipService: parameters.TeamMembershipService, - templateService: parameters.TemplateService, - stackService: parameters.StackService, - userService: parameters.UserService, - versionService: parameters.VersionService, - fileService: parameters.FileService, + db: parameters.DB, + currentDBVersion: parameters.DatabaseVersion, + endpointGroupService: parameters.EndpointGroupService, + endpointService: parameters.EndpointService, + endpointRelationService: parameters.EndpointRelationService, + extensionService: parameters.ExtensionService, + registryService: parameters.RegistryService, + resourceControlService: parameters.ResourceControlService, + roleService: parameters.RoleService, + scheduleService: parameters.ScheduleService, + settingsService: parameters.SettingsService, + tagService: parameters.TagService, + teamMembershipService: parameters.TeamMembershipService, + templateService: parameters.TemplateService, + stackService: parameters.StackService, + userService: parameters.UserService, + versionService: parameters.VersionService, + fileService: parameters.FileService, } } @@ -307,7 +311,12 @@ func (m *Migrator) Migrate() error { // Portainer 1.24.0-dev if m.currentDBVersion < 23 { - err := m.updateEndointsAndEndpointsGroupsToDBVersion23() + err := m.updateTagsToDBVersion23() + if err != nil { + return err + } + + err = m.updateEndpointsAndEndpointGroupsToDBVersion23() if err != nil { return err } diff --git a/api/bolt/tag/tag.go b/api/bolt/tag/tag.go index d4a5dc9de..ba0f44ba9 100644 --- a/api/bolt/tag/tag.go +++ b/api/bolt/tag/tag.go @@ -82,6 +82,12 @@ func (service *Service) CreateTag(tag *portainer.Tag) error { }) } +// UpdateTag updates a tag. +func (service *Service) UpdateTag(ID portainer.TagID, tag *portainer.Tag) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, tag) +} + // DeleteTag deletes a tag. func (service *Service) DeleteTag(ID portainer.TagID) error { identifier := internal.Itob(int(ID)) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 46c5bb5ca..a606fefd4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -651,44 +651,47 @@ func main() { } var server portainer.Server = &http.Server{ - ReverseTunnelService: reverseTunnelService, - Status: applicationStatus, - BindAddress: *flags.Addr, - AssetsPath: *flags.Assets, - AuthDisabled: *flags.NoAuth, - EndpointManagement: endpointManagement, - RoleService: store.RoleService, - UserService: store.UserService, - TeamService: store.TeamService, - 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, - LDAPService: ldapService, - GitService: gitService, - SignatureService: digitalSignatureService, - JobScheduler: jobScheduler, - Snapshotter: snapshotter, - SSL: *flags.SSL, - SSLCert: *flags.SSLCert, - SSLKey: *flags.SSLKey, - DockerClientFactory: clientFactory, - JobService: jobService, + ReverseTunnelService: reverseTunnelService, + Status: applicationStatus, + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + AuthDisabled: *flags.NoAuth, + EndpointManagement: endpointManagement, + RoleService: store.RoleService, + UserService: store.UserService, + TeamService: store.TeamService, + TeamMembershipService: store.TeamMembershipService, + EdgeGroupService: store.EdgeGroupService, + EdgeStackService: store.EdgeStackService, + EndpointService: store.EndpointService, + EndpointGroupService: store.EndpointGroupService, + EndpointRelationService: store.EndpointRelationService, + 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, + LDAPService: ldapService, + GitService: gitService, + SignatureService: digitalSignatureService, + JobScheduler: jobScheduler, + Snapshotter: snapshotter, + SSL: *flags.SSL, + SSLCert: *flags.SSLCert, + SSLKey: *flags.SSLKey, + DockerClientFactory: clientFactory, + JobService: jobService, } log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) diff --git a/api/edgegroup.go b/api/edgegroup.go new file mode 100644 index 000000000..d68ee7a9b --- /dev/null +++ b/api/edgegroup.go @@ -0,0 +1,54 @@ +package portainer + +// EdgeGroupRelatedEndpoints returns a list of endpoints related to this Edge group +func EdgeGroupRelatedEndpoints(edgeGroup *EdgeGroup, endpoints []Endpoint, endpointGroups []EndpointGroup) []EndpointID { + if !edgeGroup.Dynamic { + return edgeGroup.Endpoints + } + + endpointIDs := []EndpointID{} + for _, endpoint := range endpoints { + if endpoint.Type != EdgeAgentEnvironment { + continue + } + + var endpointGroup EndpointGroup + for _, group := range endpointGroups { + if endpoint.GroupID == group.ID { + endpointGroup = group + break + } + } + + if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, &endpointGroup) { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + + return endpointIDs +} + +// edgeGroupRelatedToEndpoint returns true is edgeGroup is associated with endpoint +func edgeGroupRelatedToEndpoint(edgeGroup *EdgeGroup, endpoint *Endpoint, endpointGroup *EndpointGroup) bool { + if !edgeGroup.Dynamic { + for _, endpointID := range edgeGroup.Endpoints { + if endpoint.ID == endpointID { + return true + } + } + return false + } + + endpointTags := TagSet(endpoint.TagIDs) + if endpointGroup.TagIDs != nil { + endpointTags = TagUnion(endpointTags, TagSet(endpointGroup.TagIDs)) + } + edgeGroupTags := TagSet(edgeGroup.TagIDs) + + if edgeGroup.PartialMatch { + intersection := TagIntersection(endpointTags, edgeGroupTags) + return len(intersection) != 0 + } + + return TagContains(edgeGroupTags, endpointTags) +} diff --git a/api/edgestack.go b/api/edgestack.go new file mode 100644 index 000000000..7a3019c5d --- /dev/null +++ b/api/edgestack.go @@ -0,0 +1,27 @@ +package portainer + +import "errors" + +// EdgeStackRelatedEndpoints returns a list of endpoints related to this Edge stack +func EdgeStackRelatedEndpoints(edgeGroupIDs []EdgeGroupID, endpoints []Endpoint, endpointGroups []EndpointGroup, edgeGroups []EdgeGroup) ([]EndpointID, error) { + edgeStackEndpoints := []EndpointID{} + + for _, edgeGroupID := range edgeGroupIDs { + var edgeGroup *EdgeGroup + + for _, group := range edgeGroups { + if group.ID == edgeGroupID { + edgeGroup = &group + break + } + } + + if edgeGroup == nil { + return nil, errors.New("Edge group was not found") + } + + edgeStackEndpoints = append(edgeStackEndpoints, EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)...) + } + + return edgeStackEndpoints, nil +} diff --git a/api/endpoint.go b/api/endpoint.go new file mode 100644 index 000000000..661818da3 --- /dev/null +++ b/api/endpoint.go @@ -0,0 +1,25 @@ +package portainer + +// EndpointRelatedEdgeStacks returns a list of Edge stacks related to this Endpoint +func EndpointRelatedEdgeStacks(endpoint *Endpoint, endpointGroup *EndpointGroup, edgeGroups []EdgeGroup, edgeStacks []EdgeStack) []EdgeStackID { + relatedEdgeGroupsSet := map[EdgeGroupID]bool{} + + for _, edgeGroup := range edgeGroups { + if edgeGroupRelatedToEndpoint(&edgeGroup, endpoint, endpointGroup) { + relatedEdgeGroupsSet[edgeGroup.ID] = true + } + } + + relatedEdgeStacks := []EdgeStackID{} + for _, edgeStack := range edgeStacks { + for _, edgeGroupID := range edgeStack.EdgeGroups { + if relatedEdgeGroupsSet[edgeGroupID] { + relatedEdgeStacks = append(relatedEdgeStacks, edgeStack.ID) + break + } + } + } + + return relatedEdgeStacks + +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index c2ee9be3a..d65ad4f8d 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -29,6 +29,8 @@ const ( ComposeStorePath = "compose" // ComposeFileDefaultName represents the default name of a compose file. ComposeFileDefaultName = "docker-compose.yml" + // EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder. + EdgeStackStorePath = "edge_stacks" // PrivateKeyFile represents the name on disk of the file containing the private key. PrivateKeyFile = "portainer.key" // PublicKeyFile represents the name on disk of the file containing the public key. @@ -121,6 +123,32 @@ func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string return path.Join(service.fileStorePath, stackStorePath), nil } +// GetEdgeStackProjectPath returns the absolute path on the FS for a edge stack based +// on its identifier. +func (service *Service) GetEdgeStackProjectPath(edgeStackIdentifier string) string { + return path.Join(service.fileStorePath, EdgeStackStorePath, edgeStackIdentifier) +} + +// StoreEdgeStackFileFromBytes creates a subfolder in the EdgeStackStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreEdgeStackFileFromBytes(edgeStackIdentifier, fileName string, data []byte) (string, error) { + stackStorePath := path.Join(EdgeStackStorePath, edgeStackIdentifier) + err := service.createDirectoryInStore(stackStorePath) + if err != nil { + return "", err + } + + composeFilePath := path.Join(stackStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + 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. diff --git a/api/http/handler/edgegroups/associated_endpoints.go b/api/http/handler/edgegroups/associated_endpoints.go new file mode 100644 index 000000000..8ff2f7693 --- /dev/null +++ b/api/http/handler/edgegroups/associated_endpoints.go @@ -0,0 +1,104 @@ +package edgegroups + +import ( + "github.com/portainer/portainer/api" +) + +type endpointSetType map[portainer.EndpointID]bool + +func (handler *Handler) getEndpointsByTags(tagIDs []portainer.TagID, partialMatch bool) ([]portainer.EndpointID, error) { + if len(tagIDs) == 0 { + return []portainer.EndpointID{}, nil + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return nil, err + } + + groupEndpoints := mapEndpointGroupToEndpoints(endpoints) + + tags := []portainer.Tag{} + for _, tagID := range tagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return nil, err + } + tags = append(tags, *tag) + } + + setsOfEndpoints := mapTagsToEndpoints(tags, groupEndpoints) + + var endpointSet endpointSetType + if partialMatch { + endpointSet = setsUnion(setsOfEndpoints) + } else { + endpointSet = setsIntersection(setsOfEndpoints) + } + + results := []portainer.EndpointID{} + for _, endpoint := range endpoints { + if _, ok := endpointSet[endpoint.ID]; ok && endpoint.Type == portainer.EdgeAgentEnvironment { + results = append(results, endpoint.ID) + } + } + + return results, nil +} + +func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType { + groupEndpoints := map[portainer.EndpointGroupID]endpointSetType{} + for _, endpoint := range endpoints { + groupID := endpoint.GroupID + if groupEndpoints[groupID] == nil { + groupEndpoints[groupID] = endpointSetType{} + } + groupEndpoints[groupID][endpoint.ID] = true + } + return groupEndpoints +} + +func mapTagsToEndpoints(tags []portainer.Tag, groupEndpoints map[portainer.EndpointGroupID]endpointSetType) []endpointSetType { + sets := []endpointSetType{} + for _, tag := range tags { + set := tag.Endpoints + for groupID := range tag.EndpointGroups { + for endpointID := range groupEndpoints[groupID] { + set[endpointID] = true + } + } + sets = append(sets, set) + } + + return sets +} + +func setsIntersection(sets []endpointSetType) endpointSetType { + if len(sets) == 0 { + return endpointSetType{} + } + + intersectionSet := sets[0] + + for _, set := range sets { + for endpointID := range intersectionSet { + if !set[endpointID] { + delete(intersectionSet, endpointID) + } + } + } + + return intersectionSet +} + +func setsUnion(sets []endpointSetType) endpointSetType { + unionSet := endpointSetType{} + + for _, set := range sets { + for endpointID := range set { + unionSet[endpointID] = true + } + } + + return unionSet +} diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go new file mode 100644 index 000000000..26bbd0d90 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -0,0 +1,83 @@ +package edgegroups + +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/api" +) + +type edgeGroupCreatePayload struct { + Name string + Dynamic bool + TagIDs []portainer.TagID + Endpoints []portainer.EndpointID + PartialMatch bool +} + +func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid Edge group name") + } + if payload.Dynamic && (payload.TagIDs == nil || len(payload.TagIDs) == 0) { + return portainer.Error("TagIDs is mandatory for a dynamic Edge group") + } + if !payload.Dynamic && (payload.Endpoints == nil || len(payload.Endpoints) == 0) { + return portainer.Error("Endpoints is mandatory for a static Edge group") + } + return nil +} + +func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload edgeGroupCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge groups from the database", err} + } + + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name { + return &httperror.HandlerError{http.StatusBadRequest, "Edge group name must be unique", portainer.Error("Edge group name must be unique")} + } + } + + edgeGroup := &portainer.EdgeGroup{ + Name: payload.Name, + Dynamic: payload.Dynamic, + TagIDs: []portainer.TagID{}, + Endpoints: []portainer.EndpointID{}, + PartialMatch: payload.PartialMatch, + } + + if edgeGroup.Dynamic { + edgeGroup.TagIDs = payload.TagIDs + } else { + endpointIDs := []portainer.EndpointID{} + for _, endpointID := range payload.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + } + + if endpoint.Type == portainer.EdgeAgentEnvironment { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + edgeGroup.Endpoints = endpointIDs + } + + err = handler.EdgeGroupService.CreateEdgeGroup(edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Edge group inside the database", err} + } + + return response.JSON(w, edgeGroup) +} diff --git a/api/http/handler/edgegroups/edgegroup_delete.go b/api/http/handler/edgegroups/edgegroup_delete.go new file mode 100644 index 000000000..8ad9e949f --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_delete.go @@ -0,0 +1,45 @@ +package edgegroups + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} + } + + _, err = handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge group with the specified identifier inside the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge stacks from the database", err} + } + + for _, edgeStack := range edgeStacks { + for _, groupID := range edgeStack.EdgeGroups { + if groupID == portainer.EdgeGroupID(edgeGroupID) { + return &httperror.HandlerError{http.StatusForbidden, "Edge group is used by an Edge stack", portainer.Error("Edge group is used by an Edge stack")} + } + } + } + + err = handler.EdgeGroupService.DeleteEdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge group from the database", err} + } + + return response.Empty(w) + +} diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go new file mode 100644 index 000000000..5fdadf2ec --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -0,0 +1,35 @@ +package edgegroups + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} + } + + edgeGroup, err := handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge group with the specified identifier inside the database", err} + } + + if edgeGroup.Dynamic { + endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints and endpoint groups for Edge group", err} + } + + edgeGroup.Endpoints = endpoints + } + + return response.JSON(w, edgeGroup) +} diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go new file mode 100644 index 000000000..f859300aa --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -0,0 +1,55 @@ +package edgegroups + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type decoratedEdgeGroup struct { + portainer.EdgeGroup + HasEdgeStack bool `json:"HasEdgeStack"` +} + +func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge stacks from the database", err} + } + + usedEdgeGroups := make(map[portainer.EdgeGroupID]bool) + + for _, stack := range edgeStacks { + for _, groupID := range stack.EdgeGroups { + usedEdgeGroups[groupID] = true + } + } + + decoratedEdgeGroups := []decoratedEdgeGroup{} + for _, orgEdgeGroup := range edgeGroups { + edgeGroup := decoratedEdgeGroup{ + EdgeGroup: orgEdgeGroup, + } + if edgeGroup.Dynamic { + endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints and endpoint groups for Edge group", err} + } + + edgeGroup.Endpoints = endpoints + } + + edgeGroup.HasEdgeStack = usedEdgeGroups[edgeGroup.ID] + + decoratedEdgeGroups = append(decoratedEdgeGroups, edgeGroup) + } + + return response.JSON(w, decoratedEdgeGroups) +} diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go new file mode 100644 index 000000000..d6a72ea6d --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -0,0 +1,154 @@ +package edgegroups + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +type edgeGroupUpdatePayload struct { + Name string + Dynamic bool + TagIDs []portainer.TagID + Endpoints []portainer.EndpointID + PartialMatch *bool +} + +func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid Edge group name") + } + if payload.Dynamic && (payload.TagIDs == nil || len(payload.TagIDs) == 0) { + return portainer.Error("TagIDs is mandatory for a dynamic Edge group") + } + if !payload.Dynamic && (payload.Endpoints == nil || len(payload.Endpoints) == 0) { + return portainer.Error("Endpoints is mandatory for a static Edge group") + } + return nil +} + +func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} + } + + var payload edgeGroupUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeGroup, err := handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge group with the specified identifier inside the database", err} + } + + if payload.Name != "" { + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge groups from the database", err} + } + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) { + return &httperror.HandlerError{http.StatusBadRequest, "Edge group name must be unique", portainer.Error("Edge group name must be unique")} + } + } + + edgeGroup.Name = payload.Name + } + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + oldRelatedEndpoints := portainer.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + + edgeGroup.Dynamic = payload.Dynamic + if edgeGroup.Dynamic { + edgeGroup.TagIDs = payload.TagIDs + } else { + endpointIDs := []portainer.EndpointID{} + for _, endpointID := range payload.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + } + + if endpoint.Type == portainer.EdgeAgentEnvironment { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + edgeGroup.Endpoints = endpointIDs + } + + if payload.PartialMatch != nil { + edgeGroup.PartialMatch = *payload.PartialMatch + } + + err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge group changes inside the database", err} + } + + newRelatedEndpoints := portainer.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...) + + for _, endpointID := range endpointsToUpdate { + err = handler.updateEndpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Endpoint relation changes inside the database", err} + } + } + + return response.JSON(w, edgeGroup) +} + +func (handler *Handler) updateEndpoint(endpointID portainer.EndpointID) error { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return err + } + + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return err + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return err + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return err + } + + edgeStackSet := map[portainer.EdgeStackID]bool{} + + endpointEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, edgeStackID := range endpointEdgeStacks { + edgeStackSet[edgeStackID] = true + } + + relation.EdgeStacks = edgeStackSet + + return handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, relation) +} diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go new file mode 100644 index 000000000..874b13477 --- /dev/null +++ b/api/http/handler/edgegroups/handler.go @@ -0,0 +1,39 @@ +package edgegroups + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + TagService portainer.TagService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/edge_groups", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupCreate))).Methods(http.MethodPost) + h.Handle("/edge_groups", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupList))).Methods(http.MethodGet) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupInspect))).Methods(http.MethodGet) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupUpdate))).Methods(http.MethodPut) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupDelete))).Methods(http.MethodDelete) + return h +} diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go new file mode 100644 index 000000000..e3a315cb9 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -0,0 +1,289 @@ +package edgestacks + +import ( + "errors" + "net/http" + "strconv" + "strings" + "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/api" + "github.com/portainer/portainer/api/filesystem" +) + +// POST request on /api/endpoint_groups +func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} + } + + edgeStack, err := handler.createSwarmStack(method, r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Edge stack", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} + } + + relatedEndpoints, err := portainer.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) + + for _, endpointID := range relatedEndpoints { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + relation.EdgeStacks[edgeStack.ID] = true + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + return response.JSON(w, edgeStack) +} + +func (handler *Handler) createSwarmStack(method string, r *http.Request) (*portainer.EdgeStack, error) { + switch method { + case "string": + return handler.createSwarmStackFromFileContent(r) + case "repository": + return handler.createSwarmStackFromGitRepository(r) + case "file": + return handler.createSwarmStackFromFileUpload(r) + } + return nil, errors.New("Invalid value for query parameter: method. Value must be one of: string, repository or file") +} + +type swarmStackFromFileContentPayload struct { + Name string + StackFileContent string + EdgeGroups []portainer.EdgeGroupID +} + +func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + return nil +} + +func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*portainer.EdgeStack, error) { + var payload swarmStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + err = handler.validateUniqueName(payload.Name) + if err != nil { + return nil, err + } + + stackID := handler.EdgeStackService.GetNextIdentifier() + stack := &portainer.EdgeStack{ + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + EntryPoint: filesystem.ComposeFileDefaultName, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + stack.ProjectPath = projectPath + + err = handler.EdgeStackService.CreateEdgeStack(stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +type swarmStackFromGitRepositoryPayload struct { + Name string + RepositoryURL string + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string + EdgeGroups []portainer.EdgeGroupID +} + +func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return portainer.Error("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + return nil +} + +func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*portainer.EdgeStack, error) { + var payload swarmStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + err = handler.validateUniqueName(payload.Name) + if err != nil { + return nil, err + } + + stackID := handler.EdgeStackService.GetNextIdentifier() + stack := &portainer.EdgeStack{ + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + EntryPoint: payload.ComposeFilePathInRepository, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID))) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + referenceName: payload.RepositoryReferenceName, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return nil, err + } + + err = handler.EdgeStackService.CreateEdgeStack(stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +type swarmStackFromFileUploadPayload struct { + Name string + StackFileContent []byte + EdgeGroups []portainer.EdgeGroupID +} + +func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + var edgeGroups []portainer.EdgeGroupID + err = request.RetrieveMultiPartFormJSONValue(r, "EdgeGroups", &edgeGroups, false) + if err != nil || len(edgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + payload.EdgeGroups = edgeGroups + return nil +} + +func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portainer.EdgeStack, error) { + payload := &swarmStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return nil, err + } + + err = handler.validateUniqueName(payload.Name) + if err != nil { + return nil, err + } + + stackID := handler.EdgeStackService.GetNextIdentifier() + stack := &portainer.EdgeStack{ + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + EntryPoint: filesystem.ComposeFileDefaultName, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + stack.ProjectPath = projectPath + + err = handler.EdgeStackService.CreateEdgeStack(stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +func (handler *Handler) validateUniqueName(name string) error { + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return err + } + + for _, stack := range edgeStacks { + if strings.EqualFold(stack.Name, name) { + return portainer.Error("Edge stack name must be unique") + } + } + return nil +} diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go new file mode 100644 index 000000000..ae5d1b476 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -0,0 +1,62 @@ +package edgestacks + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + err = handler.EdgeStackService.DeleteEdgeStack(portainer.EdgeStackID(edgeStackID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the edge stack from the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} + } + + relatedEndpoints, err := portainer.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) + + for _, endpointID := range relatedEndpoints { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + delete(relation.EdgeStacks, edgeStack.ID) + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/edgestacks/edgestack_file.go b/api/http/handler/edgestacks/edgestack_file.go new file mode 100644 index 000000000..c82348b8d --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_file.go @@ -0,0 +1,37 @@ +package edgestacks + +import ( + "net/http" + "path" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type stackFileResponse struct { + StackFileContent string `json:"StackFileContent"` +} + +// GET request on /api/edge_stacks/:id/file +func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + stack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} + } + + return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)}) +} diff --git a/api/http/handler/edgestacks/edgestack_inspect.go b/api/http/handler/edgestacks/edgestack_inspect.go new file mode 100644 index 000000000..66a591633 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_inspect.go @@ -0,0 +1,26 @@ +package edgestacks + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + return response.JSON(w, edgeStack) +} diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go new file mode 100644 index 000000000..dd15e58c1 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -0,0 +1,17 @@ +package edgestacks + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" +) + +func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + return response.JSON(w, edgeStacks) +} diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go new file mode 100644 index 000000000..bcf0a639b --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -0,0 +1,76 @@ +package edgestacks + +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/api" +) + +type updateStatusPayload struct { + Error string + Status *portainer.EdgeStackStatusType + EndpointID *portainer.EndpointID +} + +func (payload *updateStatusPayload) Validate(r *http.Request) error { + if payload.Status == nil { + return portainer.Error("Invalid status") + } + if payload.EndpointID == nil { + return portainer.Error("Invalid EndpointID") + } + if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) { + return portainer.Error("Error message is mandatory when status is error") + } + return nil +} + +func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + var payload updateStatusPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(*payload.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.AuthorizedEdgeEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{ + Type: *payload.Status, + Error: payload.Error, + EndpointID: *payload.EndpointID, + } + + err = handler.EdgeStackService.UpdateEdgeStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) + +} diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go new file mode 100644 index 000000000..404ff1d92 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -0,0 +1,156 @@ +package edgestacks + +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/api" +) + +type updateEdgeStackPayload struct { + StackFileContent string + Version *int + Prune *bool + EdgeGroups []portainer.EdgeGroupID +} + +func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + if payload.EdgeGroups != nil && len(payload.EdgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + return nil +} + +func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + var payload updateEdgeStackPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if payload.EdgeGroups != nil { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} + } + + oldRelated, err := portainer.EdgeStackRelatedEndpoints(stack.EdgeGroups, endpoints, endpointGroups, edgeGroups) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related endpoints from database", err} + } + + newRelated, err := portainer.EdgeStackRelatedEndpoints(payload.EdgeGroups, endpoints, endpointGroups, edgeGroups) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related endpoints from database", err} + } + + oldRelatedSet := EndpointSet(oldRelated) + newRelatedSet := EndpointSet(newRelated) + + endpointsToRemove := map[portainer.EndpointID]bool{} + for endpointID := range oldRelatedSet { + if !newRelatedSet[endpointID] { + endpointsToRemove[endpointID] = true + } + } + + for endpointID := range endpointsToRemove { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + delete(relation.EdgeStacks, stack.ID) + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + endpointsToAdd := map[portainer.EndpointID]bool{} + for endpointID := range newRelatedSet { + if !oldRelatedSet[endpointID] { + endpointsToAdd[endpointID] = true + } + } + + for endpointID := range endpointsToAdd { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + relation.EdgeStacks[stack.ID] = true + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + stack.EdgeGroups = payload.EdgeGroups + + } + + if payload.Prune != nil { + stack.Prune = *payload.Prune + } + + stackFolder := strconv.Itoa(int(stack.ID)) + _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + if payload.Version != nil && *payload.Version != stack.Version { + stack.Version = *payload.Version + stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} + } + + err = handler.EdgeStackService.UpdateEdgeStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) +} + +func EndpointSet(endpointIDs []portainer.EndpointID) map[portainer.EndpointID]bool { + set := map[portainer.EndpointID]bool{} + + for _, endpointID := range endpointIDs { + set[endpointID] = true + } + + return set +} diff --git a/api/http/handler/edgestacks/git.go b/api/http/handler/edgestacks/git.go new file mode 100644 index 000000000..855fa72bc --- /dev/null +++ b/api/http/handler/edgestacks/git.go @@ -0,0 +1,17 @@ +package edgestacks + +type cloneRepositoryParameters struct { + url string + referenceName string + path string + authentication bool + username string + password string +} + +func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { + if parameters.authentication { + return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password) + } + return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path) +} diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go new file mode 100644 index 000000000..45c823e5c --- /dev/null +++ b/api/http/handler/edgestacks/handler.go @@ -0,0 +1,46 @@ +package edgestacks + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + FileService portainer.FileService + GitService portainer.GitService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + h.Handle("/edge_stacks", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackCreate))).Methods(http.MethodPost) + h.Handle("/edge_stacks", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackList))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackInspect))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackUpdate))).Methods(http.MethodPut) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackDelete))).Methods(http.MethodDelete) + h.Handle("/edge_stacks/{id}/file", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackFile))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}/status", + bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut) + return h +} diff --git a/api/http/handler/edgetemplates/edgetemplate_list.go b/api/http/handler/edgetemplates/edgetemplate_list.go new file mode 100644 index 000000000..4b82f19c2 --- /dev/null +++ b/api/http/handler/edgetemplates/edgetemplate_list.go @@ -0,0 +1,49 @@ +package edgetemplates + +import ( + "encoding/json" + "log" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" +) + +// GET request on /api/edgetemplates +func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + url := portainer.EdgeTemplatesURL + if settings.TemplatesURL != "" { + url = settings.TemplatesURL + } + + var templateData []byte + templateData, err = client.Get(url, 0) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve external templates", err} + } + + var templates []portainer.Template + + err = json.Unmarshal(templateData, &templates) + if err != nil { + log.Printf("[DEBUG] [http,edge,templates] [failed parsing edge templates] [body: %s]", templateData) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external templates", err} + } + + filteredTemplates := []portainer.Template{} + + for _, template := range templates { + if template.Type == portainer.EdgeStackTemplate { + filteredTemplates = append(filteredTemplates, template) + } + } + + return response.JSON(w, filteredTemplates) +} diff --git a/api/http/handler/edgetemplates/handler.go b/api/http/handler/edgetemplates/handler.go new file mode 100644 index 000000000..75473c49a --- /dev/null +++ b/api/http/handler/edgetemplates/handler.go @@ -0,0 +1,31 @@ +package edgetemplates + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + + "github.com/gorilla/mux" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle edge endpoint operations. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage endpoint operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + + h.Handle("/edge_templates", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeTemplateList))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go new file mode 100644 index 000000000..3853e3bba --- /dev/null +++ b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go @@ -0,0 +1,60 @@ +package endpointedge + +import ( + "net/http" + "path" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +type configResponse struct { + Prune bool + StackFileContent string + Name string +} + +// GET request on api/endpoints/:id/edge/stacks/:stackId +func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.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.AuthorizedEdgeEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, edgeStack.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} + } + + return response.JSON(w, configResponse{ + Prune: edgeStack.Prune, + StackFileContent: string(stackFileContent), + Name: edgeStack.Name, + }) +} diff --git a/api/http/handler/endpointedge/handler.go b/api/http/handler/endpointedge/handler.go new file mode 100644 index 000000000..e8dfc2995 --- /dev/null +++ b/api/http/handler/endpointedge/handler.go @@ -0,0 +1,33 @@ +package endpointedge + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + + "github.com/gorilla/mux" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle edge endpoint operations. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + EndpointService portainer.EndpointService + EdgeStackService portainer.EdgeStackService + FileService portainer.FileService +} + +// NewHandler creates a handler to manage endpoint operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + + h.Handle("/{id}/edge/stacks/{stackId}", + bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeStackInspect))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index 34bdbe754..f296fee64 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -63,10 +63,29 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } + err = handler.updateEndpointRelations(&endpoint, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + break } } } + for _, tagID := range endpointGroup.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tag from the database", err} + } + + tag.EndpointGroups[endpointGroup.ID] = true + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + return response.JSON(w, endpointGroup) } diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index dbb634eff..76cd4ce86 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -20,7 +20,7 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", portainer.ErrCannotRemoveDefaultGroup} } - _, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { @@ -46,6 +46,11 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } + + err = handler.updateEndpointRelations(&endpoint, nil) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } } } @@ -56,5 +61,19 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } } + for _, tagID := range endpointGroup.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tag from the database", err} + } + + delete(tag.EndpointGroups, endpointGroup.ID) + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go index c67f730e0..f2435ab33 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go @@ -42,5 +42,10 @@ func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + err = handler.updateEndpointRelations(endpoint, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go index 2054b428f..0e4a21611 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go @@ -42,5 +42,10 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + err = handler.updateEndpointRelations(endpoint, nil) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index ef7b4edc4..362b8b697 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -50,8 +50,44 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque endpointGroup.Description = payload.Description } + tagsChanged := false if payload.TagIDs != nil { - endpointGroup.TagIDs = payload.TagIDs + payloadTagSet := portainer.TagSet(payload.TagIDs) + endpointGroupTagSet := portainer.TagSet((endpointGroup.TagIDs)) + union := portainer.TagUnion(payloadTagSet, endpointGroupTagSet) + intersection := portainer.TagIntersection(payloadTagSet, endpointGroupTagSet) + tagsChanged = len(union) > len(intersection) + + if tagsChanged { + removeTags := portainer.TagDifference(endpointGroupTagSet, payloadTagSet) + + for tagID := range removeTags { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + delete(tag.EndpointGroups, endpointGroup.ID) + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + + endpointGroup.TagIDs = payload.TagIDs + for _, tagID := range payload.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + + tag.EndpointGroups[endpointGroup.ID] = true + + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + } } updateAuthorizations := false @@ -77,5 +113,22 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } } + if tagsChanged { + 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.GroupID == endpointGroup.ID { + err = handler.updateEndpointRelations(&endpoint, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + } + } + } + return response.JSON(w, endpointGroup) } diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go new file mode 100644 index 000000000..11e760e15 --- /dev/null +++ b/api/http/handler/endpointgroups/endpoints.go @@ -0,0 +1,42 @@ +package endpointgroups + +import portainer "github.com/portainer/portainer/api" + +func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) error { + if endpoint.Type != portainer.EdgeAgentEnvironment { + return nil + } + + if endpointGroup == nil { + unassignedGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(1)) + if err != nil { + return err + } + + endpointGroup = unassignedGroup + } + + endpointRelation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return err + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return err + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return err + } + + endpointStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + stacksSet := map[portainer.EdgeStackID]bool{} + for _, edgeStackID := range endpointStacks { + stacksSet[edgeStackID] = true + } + endpointRelation.EdgeStacks = stacksSet + + return handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, endpointRelation) +} diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index d4a36d3f5..a738a2dc1 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -12,9 +12,13 @@ import ( // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - AuthorizationService *portainer.AuthorizationService + AuthorizationService *portainer.AuthorizationService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + TagService portainer.TagService } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index dfa030927..b7d08f320 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -146,6 +146,38 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * return endpointCreationError } + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group inside the database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + relationObject := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + if endpoint.Type == portainer.EdgeAgentEnvironment { + relatedEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, stackID := range relatedEdgeStacks { + relationObject.EdgeStacks[stackID] = true + } + } + + err = handler.EndpointRelationService.CreateEndpointRelation(relationObject) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the relation object inside the database", err} + } + return response.JSON(w, endpoint) } @@ -377,6 +409,20 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer. return handler.AuthorizationService.UpdateUsersAuthorizations() } + for _, tagID := range endpoint.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return err + } + + tag.Endpoints[endpoint.ID] = true + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return err + } + } + return nil } diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index acd85e03c..43b20dc78 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -50,5 +50,75 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } } + err = handler.EndpointRelationService.DeleteEndpointRelation(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint relation from the database", err} + } + + for _, tagID := range endpoint.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find tag inside the database", err} + } + + delete(tag.Endpoints, endpoint.ID) + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag relation inside the database", err} + } + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + for idx := range edgeGroups { + edgeGroup := &edgeGroups[idx] + endpointIdx := findEndpointIndex(edgeGroup.Endpoints, endpoint.ID) + if endpointIdx != -1 { + edgeGroup.Endpoints = removeElement(edgeGroup.Endpoints, endpointIdx) + err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update edge group", err} + } + } + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + for idx := range edgeStacks { + edgeStack := &edgeStacks[idx] + if _, ok := edgeStack.Status[endpoint.ID]; ok { + delete(edgeStack.Status, endpoint.ID) + err = handler.EdgeStackService.UpdateEdgeStack(edgeStack.ID, edgeStack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update edge stack", err} + } + } + } + return response.Empty(w) } + +func findEndpointIndex(tags []portainer.EndpointID, searchEndpointID portainer.EndpointID) int { + for idx, tagID := range tags { + if searchEndpointID == tagID { + return idx + } + } + return -1 +} + +func removeElement(arr []portainer.EndpointID, index int) []portainer.EndpointID { + if index < 0 { + return arr + } + lastTagIdx := len(arr) - 1 + arr[index] = arr[lastTagIdx] + return arr[:lastTagIdx] +} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index c68f8d9ec..57ffacbce 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -33,6 +33,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht var tagIDs []portainer.TagID request.RetrieveJSONQueryParameter(r, "tagIds", &tagIDs, true) + tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true) + var endpointIDs []portainer.EndpointID request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true) @@ -62,7 +64,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht } if search != "" { - tags, err := handler.TagsService.Tags() + tags, err := handler.TagService.Tags() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} } @@ -78,7 +80,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht } if tagIDs != nil { - filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups) + filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch) } filteredEndpointCount := len(filteredEndpoints) @@ -202,28 +204,26 @@ func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer. return tags } -func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup) []portainer.Endpoint { +func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint { filteredEndpoints := make([]portainer.Endpoint, 0) for _, endpoint := range endpoints { - missingTags := make(map[portainer.TagID]bool) - for _, tagID := range tagIDs { - missingTags[tagID] = true + endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups) + endpointMatched := false + if partialMatch { + endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs) + } else { + endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs) } - for _, tagID := range endpoint.TagIDs { - if missingTags[tagID] { - delete(missingTags, tagID) - } - } - missingTags = endpointGroupHasTags(endpoint.GroupID, endpointGroups, missingTags) - if len(missingTags) == 0 { + + if endpointMatched { filteredEndpoints = append(filteredEndpoints, endpoint) } } return filteredEndpoints } -func endpointGroupHasTags(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup, missingTags map[portainer.TagID]bool) map[portainer.TagID]bool { +func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup { var endpointGroup portainer.EndpointGroup for _, group := range groups { if group.ID == groupID { @@ -231,12 +231,43 @@ func endpointGroupHasTags(groupID portainer.EndpointGroupID, groups []portainer. break } } + return endpointGroup +} + +func endpointPartialMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool { + tagSet := make(map[portainer.TagID]bool) + for _, tagID := range tagIDs { + tagSet[tagID] = true + } + for _, tagID := range endpoint.TagIDs { + if tagSet[tagID] { + return true + } + } + for _, tagID := range endpointGroup.TagIDs { + if tagSet[tagID] { + return true + } + } + return false +} + +func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool { + missingTags := make(map[portainer.TagID]bool) + for _, tagID := range tagIDs { + missingTags[tagID] = true + } + for _, tagID := range endpoint.TagIDs { + if missingTags[tagID] { + delete(missingTags, tagID) + } + } for _, tagID := range endpointGroup.TagIDs { if missingTags[tagID] { delete(missingTags, tagID) } } - return missingTags + return len(missingTags) == 0 } func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint { diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go index da34a3bfe..13ae819a0 100644 --- a/api/http/handler/endpoints/endpoint_status_inspect.go +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -1,7 +1,6 @@ package endpoints import ( - "errors" "net/http" httperror "github.com/portainer/libhttp/error" @@ -10,12 +9,18 @@ import ( "github.com/portainer/portainer/api" ) +type stackStatusResponse struct { + ID portainer.EdgeStackID + Version int +} + type endpointStatusInspectResponse struct { Status string `json:"status"` Port int `json:"port"` Schedules []portainer.EdgeSchedule `json:"schedules"` CheckinInterval int `json:"checkin"` Credentials string `json:"credentials"` + Stacks []stackStatusResponse `json:"stacks"` } // GET request on /api/endpoints/:id/status @@ -32,20 +37,14 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if endpoint.Type != portainer.EdgeAgentEnvironment { - return &httperror.HandlerError{http.StatusInternalServerError, "Status unavailable for non Edge agent endpoints", errors.New("Status unavailable")} - } - - edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) - if edgeIdentifier == "" { - return &httperror.HandlerError{http.StatusForbidden, "Missing Edge identifier", errors.New("missing Edge identifier")} - } - - if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier { - return &httperror.HandlerError{http.StatusForbidden, "Invalid Edge identifier", errors.New("invalid Edge identifier")} + err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } if endpoint.EdgeID == "" { + edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) + endpoint.EdgeID = edgeIdentifier err := handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) @@ -73,5 +72,27 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID) } + relation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve relation object from the database", err} + } + + edgeStacksStatus := []stackStatusResponse{} + for stackID := range relation.EdgeStacks { + stack, err := handler.EdgeStackService.EdgeStack(stackID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack from the database", err} + } + + stackStatus := stackStatusResponse{ + ID: stack.ID, + Version: stack.Version, + } + + edgeStacksStatus = append(edgeStacksStatus, stackStatus) + } + + statusResponse.Stacks = edgeStacksStatus + return response.JSON(w, statusResponse) } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 7c02f5f67..2cc521a47 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -69,12 +69,52 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.PublicURL = *payload.PublicURL } + groupIDChanged := false if payload.GroupID != nil { - endpoint.GroupID = portainer.EndpointGroupID(*payload.GroupID) + groupID := portainer.EndpointGroupID(*payload.GroupID) + groupIDChanged = groupID != endpoint.GroupID + endpoint.GroupID = groupID } + tagsChanged := false if payload.TagIDs != nil { - endpoint.TagIDs = payload.TagIDs + payloadTagSet := portainer.TagSet(payload.TagIDs) + endpointTagSet := portainer.TagSet((endpoint.TagIDs)) + union := portainer.TagUnion(payloadTagSet, endpointTagSet) + intersection := portainer.TagIntersection(payloadTagSet, endpointTagSet) + tagsChanged = len(union) > len(intersection) + + if tagsChanged { + removeTags := portainer.TagDifference(endpointTagSet, payloadTagSet) + + for tagID := range removeTags { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + + delete(tag.Endpoints, endpoint.ID) + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + + endpoint.TagIDs = payload.TagIDs + for _, tagID := range payload.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + + tag.Endpoints[endpoint.ID] = true + + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + } } updateAuthorizations := false @@ -184,5 +224,41 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } + if endpoint.Type == portainer.EdgeAgentEnvironment && (groupIDChanged || tagsChanged) { + relation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation inside the database", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint group inside the database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + edgeStackSet := map[portainer.EdgeStackID]bool{} + + endpointEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, edgeStackID := range endpointEdgeStacks { + edgeStackSet[edgeStackID] = true + } + + relation.EdgeStacks = edgeStackSet + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation changes inside the database", err} + } + } + return response.JSON(w, endpoint) } diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 6281a4a98..bca5dea75 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -29,16 +29,19 @@ type Handler struct { *mux.Router authorizeEndpointManagement bool requestBouncer *security.RequestBouncer + AuthorizationService *portainer.AuthorizationService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService EndpointService portainer.EndpointService EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService FileService portainer.FileService - ProxyManager *proxy.Manager - Snapshotter portainer.Snapshotter JobService portainer.JobService + ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService SettingsService portainer.SettingsService - TagsService portainer.TagService - AuthorizationService *portainer.AuthorizationService + Snapshotter portainer.Snapshotter + TagService portainer.TagService } // NewHandler creates a handler to manage endpoint operations. @@ -71,6 +74,5 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) - return h } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 7bb72dbb9..8b167b12e 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -4,6 +4,10 @@ import ( "net/http" "strings" + "github.com/portainer/portainer/api/http/handler/edgegroups" + "github.com/portainer/portainer/api/http/handler/edgestacks" + "github.com/portainer/portainer/api/http/handler/edgetemplates" + "github.com/portainer/portainer/api/http/handler/endpointedge" "github.com/portainer/portainer/api/http/handler/support" "github.com/portainer/portainer/api/http/handler/schedules" @@ -37,6 +41,10 @@ import ( type Handler struct { AuthHandler *auth.Handler DockerHubHandler *dockerhub.Handler + EdgeGroupsHandler *edgegroups.Handler + EdgeStacksHandler *edgestacks.Handler + EdgeTemplatesHandler *edgetemplates.Handler + EndpointEdgeHandler *endpointedge.Handler EndpointGroupHandler *endpointgroups.Handler EndpointHandler *endpoints.Handler EndpointProxyHandler *endpointproxy.Handler @@ -68,6 +76,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"): + http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/edge_groups"): + http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/edge_templates"): + http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoints"): @@ -78,6 +92,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/azure/"): http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/edge/"): + http.StripPrefix("/api/endpoints", h.EndpointEdgeHandler).ServeHTTP(w, r) default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index afa8e85dd..e70125afe 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -16,6 +16,7 @@ type publicSettingsResponse struct { AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` OAuthLoginURI string `json:"OAuthLoginURI"` } @@ -34,6 +35,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, + EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures, ExternalTemplates: false, OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", settings.OAuthSettings.AuthorizationURI, diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 82e00861d..cef3bdf0f 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -24,6 +24,7 @@ type settingsUpdatePayload struct { SnapshotInterval *string TemplatesURL *string EdgeAgentCheckinInterval *int + EnableEdgeComputeFeatures *bool } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { @@ -109,6 +110,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures } + if payload.EnableEdgeComputeFeatures != nil { + settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures + } + if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval) if err != nil { diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go index b5dac0274..21ca61acb 100644 --- a/api/http/handler/tags/handler.go +++ b/api/http/handler/tags/handler.go @@ -12,9 +12,12 @@ import ( // Handler is the HTTP handler used to handle tag operations. type Handler struct { *mux.Router - TagService portainer.TagService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService + TagService portainer.TagService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService } // NewHandler creates a handler to manage tag operations. diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go index e639cd39b..846d256ee 100644 --- a/api/http/handler/tags/tag_create.go +++ b/api/http/handler/tags/tag_create.go @@ -41,7 +41,9 @@ func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httpe } tag := &portainer.Tag{ - Name: payload.Name, + Name: payload.Name, + EndpointGroups: map[portainer.EndpointGroupID]bool{}, + Endpoints: map[portainer.EndpointID]bool{}, } err = handler.TagService.CreateTag(tag) diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index 2467a38fd..c2cafe43e 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -17,39 +17,82 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe } tagID := portainer.TagID(id) - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + tag, err := handler.TagService.Tag(tagID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a tag with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag with the specified identifier inside the database", err} } - for _, endpoint := range endpoints { + for endpointID := range tag.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + } + tagIdx := findTagIndex(endpoint.TagIDs, tagID) if tagIdx != -1 { endpoint.TagIDs = removeElement(endpoint.TagIDs, tagIdx) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } } } - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} - } + for endpointGroupID := range tag.EndpointGroups { + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpointGroupID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint group from the database", err} + } - for _, endpointGroup := range endpointGroups { tagIdx := findTagIndex(endpointGroup.TagIDs, tagID) if tagIdx != -1 { endpointGroup.TagIDs = removeElement(endpointGroup.TagIDs, tagIdx) - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} } } } - err = handler.TagService.DeleteTag(portainer.TagID(id)) + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + for _, endpoint := range endpoints { + if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && endpoint.Type == portainer.EdgeAgentEnvironment { + err = handler.updateEndpointRelations(endpoint, edgeGroups, edgeStacks) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint relations in the database", err} + } + } + } + + for idx := range edgeGroups { + edgeGroup := &edgeGroups[idx] + tagIdx := findTagIndex(edgeGroup.TagIDs, tagID) + if tagIdx != -1 { + edgeGroup.TagIDs = removeElement(edgeGroup.TagIDs, tagIdx) + err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} + } + } + } + + err = handler.TagService.DeleteTag(tagID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err} } @@ -57,6 +100,27 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe return response.Empty(w) } +func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { + endpointRelation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return err + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + endpointStacks := portainer.EndpointRelatedEdgeStacks(&endpoint, endpointGroup, edgeGroups, edgeStacks) + stacksSet := map[portainer.EdgeStackID]bool{} + for _, edgeStackID := range endpointStacks { + stacksSet[edgeStackID] = true + } + endpointRelation.EdgeStacks = stacksSet + + return handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, endpointRelation) +} + func findTagIndex(tags []portainer.TagID, searchTagID portainer.TagID) int { for idx, tagID := range tags { if searchTagID == tagID { diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index d52c98562..5bcfe0c72 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -1,6 +1,8 @@ package security import ( + "errors" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" @@ -143,6 +145,24 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp return nil } +// AuthorizedEdgeEndpointOperation verifies that the request was received from a valid Edge endpoint +func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { + if endpoint.Type != portainer.EdgeAgentEnvironment { + return errors.New("Invalid endpoint type") + } + + edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) + if edgeIdentifier == "" { + return errors.New("missing Edge identifier") + } + + if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier { + return errors.New("invalid Edge identifier") + } + + return nil +} + func (bouncer *RequestBouncer) checkEndpointOperationAuthorization(r *http.Request, endpoint *portainer.Endpoint) error { tokenData, err := RetrieveTokenData(r) if err != nil { diff --git a/api/http/server.go b/api/http/server.go index eb4777071..f1c98ee5f 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -3,6 +3,10 @@ package http import ( "time" + "github.com/portainer/portainer/api/http/handler/edgegroups" + "github.com/portainer/portainer/api/http/handler/edgestacks" + "github.com/portainer/portainer/api/http/handler/edgetemplates" + "github.com/portainer/portainer/api/http/handler/endpointedge" "github.com/portainer/portainer/api/http/handler/support" "github.com/portainer/portainer/api/http/handler/roles" @@ -41,45 +45,48 @@ import ( // Server implements the portainer.Server interface type Server struct { - BindAddress string - AssetsPath string - AuthDisabled bool - EndpointManagement bool - Status *portainer.Status - ReverseTunnelService portainer.ReverseTunnelService - ExtensionManager portainer.ExtensionManager - ComposeStackManager portainer.ComposeStackManager - CryptoService portainer.CryptoService - SignatureService portainer.DigitalSignatureService - JobScheduler portainer.JobScheduler - Snapshotter portainer.Snapshotter - RoleService portainer.RoleService - DockerHubService portainer.DockerHubService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - FileService portainer.FileService - 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 - TagService portainer.TagService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - TemplateService portainer.TemplateService - UserService portainer.UserService - WebhookService portainer.WebhookService - Handler *handler.Handler - SSL bool - SSLCert string - SSLKey string - DockerClientFactory *docker.ClientFactory - JobService portainer.JobService + BindAddress string + AssetsPath string + AuthDisabled bool + EndpointManagement bool + Status *portainer.Status + ReverseTunnelService portainer.ReverseTunnelService + ExtensionManager portainer.ExtensionManager + ComposeStackManager portainer.ComposeStackManager + CryptoService portainer.CryptoService + SignatureService portainer.DigitalSignatureService + JobScheduler portainer.JobScheduler + Snapshotter portainer.Snapshotter + RoleService portainer.RoleService + DockerHubService portainer.DockerHubService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + FileService portainer.FileService + 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 + TagService portainer.TagService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + TemplateService portainer.TemplateService + UserService portainer.UserService + WebhookService portainer.WebhookService + Handler *handler.Handler + SSL bool + SSLCert string + SSLKey string + DockerClientFactory *docker.ClientFactory + JobService portainer.JobService } // Start starts the HTTP server @@ -144,21 +151,54 @@ func (server *Server) Start() error { var dockerHubHandler = dockerhub.NewHandler(requestBouncer) dockerHubHandler.DockerHubService = server.DockerHubService + var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer) + edgeGroupsHandler.EdgeGroupService = server.EdgeGroupService + edgeGroupsHandler.EdgeStackService = server.EdgeStackService + edgeGroupsHandler.EndpointService = server.EndpointService + edgeGroupsHandler.EndpointGroupService = server.EndpointGroupService + edgeGroupsHandler.EndpointRelationService = server.EndpointRelationService + edgeGroupsHandler.TagService = server.TagService + + var edgeStacksHandler = edgestacks.NewHandler(requestBouncer) + edgeStacksHandler.EdgeGroupService = server.EdgeGroupService + edgeStacksHandler.EdgeStackService = server.EdgeStackService + edgeStacksHandler.EndpointService = server.EndpointService + edgeStacksHandler.EndpointGroupService = server.EndpointGroupService + edgeStacksHandler.EndpointRelationService = server.EndpointRelationService + edgeStacksHandler.FileService = server.FileService + edgeStacksHandler.GitService = server.GitService + + var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer) + edgeTemplatesHandler.SettingsService = server.SettingsService + var endpointHandler = endpoints.NewHandler(requestBouncer, server.EndpointManagement) + endpointHandler.AuthorizationService = authorizationService + endpointHandler.EdgeGroupService = server.EdgeGroupService + endpointHandler.EdgeStackService = server.EdgeStackService endpointHandler.EndpointService = server.EndpointService endpointHandler.EndpointGroupService = server.EndpointGroupService + endpointHandler.EndpointRelationService = server.EndpointRelationService endpointHandler.FileService = server.FileService - endpointHandler.ProxyManager = proxyManager - endpointHandler.Snapshotter = server.Snapshotter endpointHandler.JobService = server.JobService + endpointHandler.ProxyManager = proxyManager endpointHandler.ReverseTunnelService = server.ReverseTunnelService endpointHandler.SettingsService = server.SettingsService - endpointHandler.AuthorizationService = authorizationService + endpointHandler.Snapshotter = server.Snapshotter + endpointHandler.TagService = server.TagService + + var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer) + endpointEdgeHandler.EdgeStackService = server.EdgeStackService + endpointEdgeHandler.EndpointService = server.EndpointService + endpointEdgeHandler.FileService = server.FileService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) - endpointGroupHandler.EndpointGroupService = server.EndpointGroupService - endpointGroupHandler.EndpointService = server.EndpointService endpointGroupHandler.AuthorizationService = authorizationService + endpointGroupHandler.EdgeGroupService = server.EdgeGroupService + endpointGroupHandler.EdgeStackService = server.EdgeStackService + endpointGroupHandler.EndpointService = server.EndpointService + endpointGroupHandler.EndpointGroupService = server.EndpointGroupService + endpointGroupHandler.EndpointRelationService = server.EndpointRelationService + endpointGroupHandler.TagService = server.TagService var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) endpointProxyHandler.EndpointService = server.EndpointService @@ -221,9 +261,12 @@ func (server *Server) Start() error { stackHandler.ExtensionService = server.ExtensionService var tagHandler = tags.NewHandler(requestBouncer) - tagHandler.TagService = server.TagService + tagHandler.EdgeGroupService = server.EdgeGroupService + tagHandler.EdgeStackService = server.EdgeStackService tagHandler.EndpointService = server.EndpointService tagHandler.EndpointGroupService = server.EndpointGroupService + tagHandler.EndpointRelationService = server.EndpointRelationService + tagHandler.TagService = server.TagService var teamHandler = teams.NewHandler(requestBouncer) teamHandler.TeamService = server.TeamService @@ -268,8 +311,12 @@ func (server *Server) Start() error { RoleHandler: roleHandler, AuthHandler: authHandler, DockerHubHandler: dockerHubHandler, + EdgeGroupsHandler: edgeGroupsHandler, + EdgeStacksHandler: edgeStacksHandler, + EdgeTemplatesHandler: edgeTemplatesHandler, EndpointGroupHandler: endpointGroupHandler, EndpointHandler: endpointHandler, + EndpointEdgeHandler: endpointEdgeHandler, EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, MOTDHandler: motdHandler, diff --git a/api/portainer.go b/api/portainer.go index 5e69a2eb5..c6a469d84 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -84,6 +84,19 @@ type ( Password string `json:"Password,omitempty"` } + // EdgeGroup represents an Edge group + EdgeGroup struct { + ID EdgeGroupID `json:"Id"` + Name string `json:"Name"` + Dynamic bool `json:"Dynamic"` + TagIDs []TagID `json:"TagIds"` + Endpoints []EndpointID `json:"Endpoints"` + PartialMatch bool `json:"PartialMatch"` + } + + // EdgeGroupID represents an Edge group identifier + EdgeGroupID int + // EdgeSchedule represents a scheduled job that can run on Edge environments. EdgeSchedule struct { ID ScheduleID `json:"Id"` @@ -93,6 +106,32 @@ type ( Endpoints []EndpointID `json:"Endpoints"` } + //EdgeStack represents an edge stack + EdgeStack struct { + ID EdgeStackID `json:"Id"` + Name string `json:"Name"` + Status map[EndpointID]EdgeStackStatus `json:"Status"` + CreationDate int64 `json:"CreationDate"` + EdgeGroups []EdgeGroupID `json:"EdgeGroups"` + ProjectPath string `json:"ProjectPath"` + EntryPoint string `json:"EntryPoint"` + Version int `json:"Version"` + Prune bool `json:"Prune"` + } + + //EdgeStackID represents an edge stack id + EdgeStackID int + + //EdgeStackStatus represents an edge stack status + EdgeStackStatus struct { + Type EdgeStackStatusType `json:"Type"` + Error string `json:"Error"` + EndpointID EndpointID `json:"EndpointID"` + } + + //EdgeStackStatusType represents an edge stack status type + EdgeStackStatusType int + // Endpoint represents a Docker endpoint with all the info required // to connect to it Endpoint struct { @@ -176,6 +215,12 @@ type ( // EndpointType represents the type of an endpoint EndpointType int + // EndpointRelation represnts a endpoint relation object + EndpointRelation struct { + EndpointID EndpointID + EdgeStacks map[EdgeStackID]bool + } + // Extension represents a Portainer extension Extension struct { ID ExtensionID `json:"Id"` @@ -387,6 +432,7 @@ type ( TemplatesURL string `json:"TemplatesURL"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"` + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` // Deprecated fields DisplayDonationHeader bool @@ -454,8 +500,10 @@ type ( // Tag represents a tag that can be associated to a resource Tag struct { - ID TagID - Name string `json:"Name"` + ID TagID + Name string `json:"Name"` + Endpoints map[EndpointID]bool `json:"Endpoints"` + EndpointGroups map[EndpointGroupID]bool `json:"EndpointGroups"` } // TagID represents a tag identifier @@ -505,6 +553,9 @@ type ( // Mandatory stack fields Repository TemplateRepository `json:"repository"` + // Mandatory edge stack fields + StackFile string `json:"stackFile"` + // Optional stack/container fields Name string `json:"name,omitempty"` Logo string `json:"logo,omitempty"` @@ -685,6 +736,14 @@ type ( DeleteEndpointGroup(ID EndpointGroupID) error } + // EndpointRelationService represents a service for managing endpoint relations data + EndpointRelationService interface { + EndpointRelation(EndpointID EndpointID) (*EndpointRelation, error) + CreateEndpointRelation(endpointRelation *EndpointRelation) error + UpdateEndpointRelation(EndpointID EndpointID, endpointRelation *EndpointRelation) error + DeleteEndpointRelation(EndpointID EndpointID) error + } + // ExtensionManager represents a service used to manage extensions ExtensionManager interface { FetchExtensionDefinitions() ([]Extension, error) @@ -714,6 +773,8 @@ type ( DeleteTLSFiles(folder string) error GetStackProjectPath(stackIdentifier string) string StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) + GetEdgeStackProjectPath(edgeStackIdentifier string) string + StoreEdgeStackFileFromBytes(edgeStackIdentifier, 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 @@ -855,6 +916,7 @@ type ( Tags() ([]Tag, error) Tag(ID TagID) (*Tag, error) CreateTag(tag *Tag) error + UpdateTag(ID TagID, tag *Tag) error DeleteTag(ID TagID) error } @@ -922,6 +984,25 @@ type ( WebhookByToken(token string) (*Webhook, error) DeleteWebhook(serviceID WebhookID) error } + + // EdgeGroupService represents a service to manage Edge groups + EdgeGroupService interface { + EdgeGroups() ([]EdgeGroup, error) + EdgeGroup(ID EdgeGroupID) (*EdgeGroup, error) + CreateEdgeGroup(group *EdgeGroup) error + UpdateEdgeGroup(ID EdgeGroupID, group *EdgeGroup) error + DeleteEdgeGroup(ID EdgeGroupID) error + } + + // EdgeStackService represents a service to manage Edge stacks + EdgeStackService interface { + EdgeStacks() ([]EdgeStack, error) + EdgeStack(ID EdgeStackID) (*EdgeStack, error) + CreateEdgeStack(edgeStack *EdgeStack) error + UpdateEdgeStack(ID EdgeStackID, edgeStack *EdgeStack) error + DeleteEdgeStack(ID EdgeStackID) error + GetNextIdentifier() int + } ) const ( @@ -958,6 +1039,8 @@ const ( DefaultEdgeAgentCheckinIntervalInSeconds = 5 // LocalExtensionManifestFile represents the name of the local manifest file for extensions LocalExtensionManifestFile = "/extensions.json" + // EdgeTemplatesURL represents the URL used to retrieve Edge templates + EdgeTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-1.20.0.json" ) const ( @@ -970,6 +1053,16 @@ const ( AuthenticationOAuth ) +const ( + _ EdgeStackStatusType = iota + //StatusOk represents a successfully deployed edge stack + StatusOk + //StatusError represents an edge endpoint which failed to deploy its edge stack + StatusError + //StatusAcknowledged represents an acknowledged edge stack + StatusAcknowledged +) + const ( _ EndpointExtensionType = iota // StoridgeEndpointExtension represents the Storidge extension @@ -1078,6 +1171,8 @@ const ( SwarmStackTemplate // ComposeStackTemplate represents a template used to deploy a Compose stack ComposeStackTemplate + // EdgeStackTemplate represents a template used to deploy an Edge stack + EdgeStackTemplate ) const ( diff --git a/api/tag.go b/api/tag.go new file mode 100644 index 000000000..f93c6b547 --- /dev/null +++ b/api/tag.go @@ -0,0 +1,71 @@ +package portainer + +type tagSet map[TagID]bool + +// TagSet converts an array of ids to a set +func TagSet(tagIDs []TagID) tagSet { + set := map[TagID]bool{} + for _, tagID := range tagIDs { + set[tagID] = true + } + return set +} + +// TagIntersection returns a set intersection of the provided sets +func TagIntersection(sets ...tagSet) tagSet { + intersection := tagSet{} + if len(sets) == 0 { + return intersection + } + setA := sets[0] + for tag := range setA { + inAll := true + for _, setB := range sets { + if !setB[tag] { + inAll = false + break + } + } + + if inAll { + intersection[tag] = true + } + } + + return intersection +} + +// TagUnion returns a set union of provided sets +func TagUnion(sets ...tagSet) tagSet { + union := tagSet{} + for _, set := range sets { + for tag := range set { + union[tag] = true + } + } + return union +} + +// TagContains return true if setA contains setB +func TagContains(setA tagSet, setB tagSet) bool { + containedTags := 0 + for tag := range setB { + if setA[tag] { + containedTags++ + } + } + return containedTags == len(setA) +} + +// TagDifference returns the set difference tagsA - tagsB +func TagDifference(setA tagSet, setB tagSet) tagSet { + set := tagSet{} + + for tag := range setA { + if !setB[tag] { + set[tag] = true + } + } + + return set +} diff --git a/app/__module.js b/app/__module.js index e5db84c05..53c901aab 100644 --- a/app/__module.js +++ b/app/__module.js @@ -4,6 +4,7 @@ import angular from 'angular'; import './agent/_module'; import './azure/_module'; import './docker/__module'; +import './edge/__module'; import './portainer/__module'; angular.module('portainer', [ @@ -29,6 +30,7 @@ angular.module('portainer', [ 'portainer.agent', 'portainer.azure', 'portainer.docker', + 'portainer.edge', 'portainer.extensions', 'portainer.integrations', 'rzModule', diff --git a/app/constants.js b/app/constants.js index 052c7d2ed..e615db918 100644 --- a/app/constants.js +++ b/app/constants.js @@ -2,6 +2,9 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') + .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') + .constant('API_ENDPOINT_EDGE_STACKS', 'api/edge_stacks') + .constant('API_ENDPOINT_EDGE_TEMPLATES', 'api/edge_templates') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') diff --git a/app/edge/__module.js b/app/edge/__module.js new file mode 100644 index 000000000..dc813eba2 --- /dev/null +++ b/app/edge/__module.js @@ -0,0 +1,80 @@ +import angular from 'angular'; + +angular.module('portainer.edge', []).config(function config($stateRegistryProvider) { + const edge = { + name: 'edge', + url: '/edge', + parent: 'root', + abstract: true, + }; + + const groups = { + name: 'edge.groups', + url: '/groups', + views: { + 'content@': { + component: 'edgeGroupsView', + }, + }, + }; + + const groupsNew = { + name: 'edge.groups.new', + url: '/new', + views: { + 'content@': { + component: 'createEdgeGroupView', + }, + }, + }; + + const groupsEdit = { + name: 'edge.groups.edit', + url: '/:groupId', + views: { + 'content@': { + component: 'editEdgeGroupView', + }, + }, + }; + + const stacks = { + name: 'edge.stacks', + url: '/stacks', + views: { + 'content@': { + component: 'edgeStacksView', + }, + }, + }; + + const stacksNew = { + name: 'edge.stacks.new', + url: '/new', + views: { + 'content@': { + component: 'createEdgeStackView', + }, + }, + }; + + const stacksEdit = { + name: 'edge.stacks.edit', + url: '/:stackId', + views: { + 'content@': { + component: 'editEdgeStackView', + }, + }, + }; + + $stateRegistryProvider.register(edge); + + $stateRegistryProvider.register(groups); + $stateRegistryProvider.register(groupsNew); + $stateRegistryProvider.register(groupsEdit); + + $stateRegistryProvider.register(stacks); + $stateRegistryProvider.register(stacksNew); + $stateRegistryProvider.register(stacksEdit); +}); diff --git a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html new file mode 100644 index 000000000..d19f4b8bc --- /dev/null +++ b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html @@ -0,0 +1,82 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Group + + + +
+ {{ item.Name }} + {{ item.GroupName }}
Loading...
No endpoint available.
+
+ +
+
+
diff --git a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.js b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.js new file mode 100644 index 000000000..93d4b748f --- /dev/null +++ b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.edge').component('associatedEndpointsDatatable', { + templateUrl: './associatedEndpointsDatatable.html', + controller: 'AssociatedEndpointsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + retrievePage: '<', + updateKey: '<', + }, +}); diff --git a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatableController.js b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatableController.js new file mode 100644 index 000000000..8ba5bdc19 --- /dev/null +++ b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatableController.js @@ -0,0 +1,103 @@ +import angular from 'angular'; + +class AssociatedEndpointsDatatableController { + constructor($scope, $controller, DatatableService, PaginationService) { + this.extendGenericController($controller, $scope); + this.DatatableService = DatatableService; + this.PaginationService = PaginationService; + + this.state = Object.assign(this.state, { + orderBy: this.orderBy, + loading: true, + filteredDataSet: [], + totalFilteredDataset: 0, + pageNumber: 1, + }); + + this.onPageChange = this.onPageChange.bind(this); + this.paginationChanged = this.paginationChanged.bind(this); + } + + extendGenericController($controller, $scope) { + // extending the controller overrides the current controller functions + const $onInit = this.$onInit.bind(this); + const changePaginationLimit = this.changePaginationLimit.bind(this); + const onTextFilterChange = this.onTextFilterChange.bind(this); + angular.extend(this, $controller('GenericDatatableController', { $scope })); + this.$onInit = $onInit; + this.changePaginationLimit = changePaginationLimit; + this.onTextFilterChange = onTextFilterChange; + } + + $onInit() { + this.setDefaults(); + this.prepareTableFromDataset(); + + var storedOrder = this.DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = this.DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } + + var storedFilters = this.DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.filters = storedFilters; + } + if (this.filters && this.filters.state) { + this.filters.state.open = false; + } + + this.paginationChanged(); + } + + $onChanges({ updateKey }) { + if (updateKey.currentValue && !updateKey.isFirstChange()) { + this.paginationChanged(); + } + } + + onPageChange(newPageNumber) { + this.state.pageNumber = newPageNumber; + this.paginationChanged(); + } + + /** + * Overridden + */ + changePaginationLimit() { + this.PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + this.paginationChanged(); + } + + /** + * Overridden + */ + onTextFilterChange() { + var filterValue = this.state.textFilter; + this.DatatableService.setDataTableTextFilters(this.tableKey, filterValue); + this.paginationChanged(); + } + + paginationChanged() { + this.state.loading = true; + this.state.filteredDataSet = []; + const start = (this.state.pageNumber - 1) * this.state.paginatedItemLimit + 1; + this.retrievePage(start, this.state.paginatedItemLimit, this.state.textFilter) + .then((data) => { + this.state.filteredDataSet = data.endpoints; + this.state.totalFilteredDataSet = data.totalCount; + }) + .finally(() => { + this.state.loading = false; + }); + } +} + +angular.module('portainer.edge').controller('AssociatedEndpointsDatatableController', AssociatedEndpointsDatatableController); +export default AssociatedEndpointsDatatableController; diff --git a/app/edge/components/edge-groups-selector/edge-groups-selector.html b/app/edge/components/edge-groups-selector/edge-groups-selector.html new file mode 100644 index 000000000..2badca9a2 --- /dev/null +++ b/app/edge/components/edge-groups-selector/edge-groups-selector.html @@ -0,0 +1,18 @@ + + + + {{ $item.Name }} + + + + + {{ item.Name }} + + + diff --git a/app/edge/components/edge-groups-selector/edge-groups-selector.js b/app/edge/components/edge-groups-selector/edge-groups-selector.js new file mode 100644 index 000000000..3322780b8 --- /dev/null +++ b/app/edge/components/edge-groups-selector/edge-groups-selector.js @@ -0,0 +1,7 @@ +angular.module('portainer.edge').component('edgeGroupsSelector', { + templateUrl: './edge-groups-selector.html', + bindings: { + model: '=', + items: '<' + } +}); diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html new file mode 100644 index 000000000..69bb34b98 --- /dev/null +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html @@ -0,0 +1,87 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Status + + + + + + Error + + + +
{{ item.Name }}{{ $ctrl.statusMap[item.Status.Type] || 'Pending' }}{{ item.Status.Error ? item.Status.Error : '-' }}
Loading...
No endpoint available.
+
+ +
+
+
diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.js b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.js new file mode 100644 index 000000000..85783034d --- /dev/null +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.js @@ -0,0 +1,12 @@ +angular.module('portainer.edge').component('edgeStackEndpointsDatatable', { + templateUrl: './edgeStackEndpointsDatatable.html', + controller: 'EdgeStackEndpointsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + retrievePage: '<', + }, +}); diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js new file mode 100644 index 000000000..dec0c08ed --- /dev/null +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js @@ -0,0 +1,111 @@ +import angular from 'angular'; + +class EdgeStackEndpointsDatatableController { + constructor($async, $scope, $controller, DatatableService, PaginationService, Notifications) { + this.extendGenericController($controller, $scope); + this.DatatableService = DatatableService; + this.PaginationService = PaginationService; + this.Notifications = Notifications; + this.$async = $async; + + this.state = Object.assign(this.state, { + orderBy: this.orderBy, + loading: true, + filteredDataSet: [], + totalFilteredDataset: 0, + pageNumber: 1, + }); + + this.onPageChange = this.onPageChange.bind(this); + this.paginationChanged = this.paginationChanged.bind(this); + this.paginationChangedAsync = this.paginationChangedAsync.bind(this); + + this.statusMap = { + 1: 'OK', + 2: 'Error', + 3: 'Acknowledged', + }; + } + + extendGenericController($controller, $scope) { + // extending the controller overrides the current controller functions + const $onInit = this.$onInit.bind(this); + const changePaginationLimit = this.changePaginationLimit.bind(this); + const onTextFilterChange = this.onTextFilterChange.bind(this); + angular.extend(this, $controller('GenericDatatableController', { $scope })); + this.$onInit = $onInit; + this.changePaginationLimit = changePaginationLimit; + this.onTextFilterChange = onTextFilterChange; + } + + $onInit() { + this.setDefaults(); + this.prepareTableFromDataset(); + + var storedOrder = this.DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = this.DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } + + var storedFilters = this.DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.filters = storedFilters; + } + if (this.filters && this.filters.state) { + this.filters.state.open = false; + } + + this.paginationChanged(); + } + + onPageChange(newPageNumber) { + this.state.pageNumber = newPageNumber; + this.paginationChanged(); + } + + /** + * Overridden + */ + changePaginationLimit() { + this.PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + this.paginationChanged(); + } + + /** + * Overridden + */ + onTextFilterChange() { + var filterValue = this.state.textFilter; + this.DatatableService.setDataTableTextFilters(this.tableKey, filterValue); + this.paginationChanged(); + } + + paginationChanged() { + this.$async(this.paginationChangedAsync); + } + + async paginationChangedAsync() { + this.state.loading = true; + this.state.filteredDataSet = []; + const start = (this.state.pageNumber - 1) * this.state.paginatedItemLimit + 1; + try { + const { endpoints, totalCount } = await this.retrievePage(start, this.state.paginatedItemLimit, this.state.textFilter); + this.state.filteredDataSet = endpoints; + this.state.totalFilteredDataSet = totalCount; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve endpoints'); + } finally { + this.state.loading = false; + } + } +} + +angular.module('portainer.edge').controller('EdgeStackEndpointsDatatableController', EdgeStackEndpointsDatatableController); +export default EdgeStackEndpointsDatatableController; diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.css b/app/edge/components/edge-stack-status/edgeStackStatus.css new file mode 100644 index 000000000..95778ac02 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.css @@ -0,0 +1,25 @@ +.status:not(:last-child) { + margin-right: 1em; +} + +.status .icon { + padding: 0 !important; + margin-right: 1ch; + border-radius: 50%; + background-color: grey; + height: 10px; + width: 10px; + display: inline-block; +} + +.status .error { + background-color: #ae2323; +} + +.status .acknowledged { + background-color: #337ab7; +} + +.status .ok { + background-color: #23ae89; +} diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.html b/app/edge/components/edge-stack-status/edgeStackStatus.html new file mode 100644 index 000000000..5ffda22b5 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.html @@ -0,0 +1,3 @@ +{{ $ctrl.status.acknowledged || 0 }} +{{ $ctrl.status.ok || 0 }} +{{ $ctrl.status.error || 0 }} diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.js b/app/edge/components/edge-stack-status/edgeStackStatus.js new file mode 100644 index 000000000..a0ac9be38 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +import './edgeStackStatus.css'; + +angular.module('portainer.edge').component('edgeStackStatus', { + templateUrl: './edgeStackStatus.html', + controller: 'EdgeStackStatusController', + bindings: { + stackStatus: '<', + }, +}); diff --git a/app/edge/components/edge-stack-status/edgeStackStatusController.js b/app/edge/components/edge-stack-status/edgeStackStatusController.js new file mode 100644 index 000000000..7ce46ebb8 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatusController.js @@ -0,0 +1,25 @@ +import angular from 'angular'; + +const statusMap = { + 1: 'ok', + 2: 'error', + 3: 'acknowledged', +}; + +class EdgeStackStatusController { + $onChanges({ stackStatus }) { + if (!stackStatus || !stackStatus.currentValue) { + return; + } + const aggregateStatus = { ok: 0, error: 0, acknowledged: 0 }; + for (let endpointId in stackStatus.currentValue) { + const endpoint = stackStatus.currentValue[endpointId]; + const endpointStatusKey = statusMap[endpoint.Type]; + aggregateStatus[endpointStatusKey]++; + } + this.status = aggregateStatus; + } +} + +angular.module('portainer.edge').controller('EdgeStackStatusController', EdgeStackStatusController); +export default EdgeStackStatusController; diff --git a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html new file mode 100644 index 000000000..a5e1dbc66 --- /dev/null +++ b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html @@ -0,0 +1,145 @@ +
+ + +
+
+ + Edge Stacks +
+
+ + Settings + + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + Status + + + Creation Date + + + +
+ + + + + + {{ item.Name }} + + {{ item.CreationDate | getisodatefromtimestamp }}
Loading...
+ No stack available. +
+
+ +
+
+
diff --git a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.js b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.js new file mode 100644 index 000000000..6d05005fe --- /dev/null +++ b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.edge').component('edgeStacksDatatable', { + templateUrl: './edgeStacksDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<', + refreshCallback: '<', + }, +}); diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html new file mode 100644 index 000000000..e21b87c0e --- /dev/null +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html @@ -0,0 +1,74 @@ +
+
+ Edge Groups +
+
+
+ +
+
+ + +
+ Web editor +
+
+ + You can get more information about Compose file format in the + + official documentation + + . + +
+
+
+ +
+
+ + +
+ Options +
+
+
+ + +
+
+ + +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.js b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.js new file mode 100644 index 000000000..479a6876e --- /dev/null +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.js @@ -0,0 +1,10 @@ +angular.module('portainer.edge').component('editEdgeStackForm', { + templateUrl: './editEdgeStackForm.html', + controller: 'EditEdgeStackFormController', + bindings: { + model: '<', + actionInProgress: '<', + submitAction: '<', + edgeGroups: '<', + }, +}); diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js new file mode 100644 index 000000000..0c836db04 --- /dev/null +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js @@ -0,0 +1,14 @@ +import angular from 'angular'; + +class EditEdgeStackFormController { + constructor() { + this.editorUpdate = this.editorUpdate.bind(this); + } + + editorUpdate(cm) { + this.model.StackFileContent = cm.getValue(); + } +} + +angular.module('portainer.edge').controller('EditEdgeStackFormController', EditEdgeStackFormController); +export default EditEdgeStackFormController; diff --git a/app/edge/components/group-form/groupForm.html b/app/edge/components/group-form/groupForm.html new file mode 100644 index 000000000..aedf32285 --- /dev/null +++ b/app/edge/components/group-form/groupForm.html @@ -0,0 +1,189 @@ +
+
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+ +
+ Group type +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ Associated endpoints +
+
+
+ You can select which endpoint should be part of this group by moving them to the associated endpoints table. Simply click on any endpoint entry to move it from one table + to the other. +
+
+ +
+
Available endpoints
+
+ +
+
+ + +
+
Associated endpoints
+
+ +
+
+ +
+
+
+
+
No Edge endpoints available. Head over the Endpoints view to add endpoints.
+
+
+ + + +
+
+ Tags +
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+ No tags available. Head over to the Tags view to add tags +
+
+
+ Associated endpoints by tags +
+
+ +
+
+ + + +
+ Actions +
+
+
+ +
+
+
diff --git a/app/edge/components/group-form/groupForm.js b/app/edge/components/group-form/groupForm.js new file mode 100644 index 000000000..2b5f66091 --- /dev/null +++ b/app/edge/components/group-form/groupForm.js @@ -0,0 +1,14 @@ +angular.module('portainer.edge').component('edgeGroupForm', { + templateUrl: './groupForm.html', + controller: 'EdgeGroupFormController', + bindings: { + model: '<', + groups: '<', + tags: '<', + formActionLabel: '@', + formAction: '<', + actionInProgress: '<', + loaded: '<', + pageType: '@', + }, +}); diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js new file mode 100644 index 000000000..413a988e9 --- /dev/null +++ b/app/edge/components/group-form/groupFormController.js @@ -0,0 +1,94 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EdgeGroupFormController { + /* @ngInject */ + constructor(EndpointService, $async, $scope) { + this.EndpointService = EndpointService; + this.$async = $async; + + this.state = { + available: { + limit: '10', + filter: '', + pageNumber: 1, + totalCount: 0, + }, + associated: { + limit: '10', + filter: '', + pageNumber: 1, + totalCount: 0, + }, + }; + + this.endpoints = { + associated: [], + available: null, + }; + + this.associateEndpoint = this.associateEndpoint.bind(this); + this.dissociateEndpoint = this.dissociateEndpoint.bind(this); + this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this); + this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this); + + $scope.$watch( + () => this.model, + () => { + this.getPaginatedEndpoints(this.pageType, 'associated'); + }, + true + ); + } + + associateEndpoint(endpoint) { + if (!_.includes(this.model.Endpoints, endpoint.Id)) { + this.endpoints.associated.push(endpoint); + this.model.Endpoints.push(endpoint.Id); + _.remove(this.endpoints.available, { Id: endpoint.Id }); + } + } + + dissociateEndpoint(endpoint) { + _.remove(this.endpoints.associated, { Id: endpoint.Id }); + _.remove(this.model.Endpoints, (id) => id === endpoint.Id); + this.endpoints.available.push(endpoint); + } + + getPaginatedEndpoints(pageType, tableType) { + return this.$async(this.getPaginatedEndpointsAsync, pageType, tableType); + } + + async getPaginatedEndpointsAsync(pageType, tableType) { + const { pageNumber, limit, search } = this.state[tableType]; + const start = (pageNumber - 1) * limit + 1; + const query = { search, type: 4 }; + if (tableType === 'associated') { + if (this.model.Dynamic) { + query.tagIds = this.model.TagIds; + query.tagsPartialMatch = this.model.PartialMatch; + } else { + query.endpointIds = this.model.Endpoints; + } + } + const response = await this.fetchEndpoints(start, limit, query); + const totalCount = parseInt(response.totalCount, 10); + this.endpoints[tableType] = response.value; + this.state[tableType].totalCount = totalCount; + + if (tableType === 'available') { + this.noEndpoints = totalCount === 0; + this.endpoints[tableType] = _.filter(response.value, (endpoint) => !_.includes(this.model.Endpoints, endpoint.Id)); + } + } + + fetchEndpoints(start, limit, query) { + if (query.tagIds && !query.tagIds.length) { + return { value: [], totalCount: 0 }; + } + return this.EndpointService.endpoints(start, limit, query); + } +} + +angular.module('portainer.edge').controller('EdgeGroupFormController', EdgeGroupFormController); +export default EdgeGroupFormController; diff --git a/app/edge/components/groups-datatable/groupsDatatable.html b/app/edge/components/groups-datatable/groupsDatatable.html new file mode 100644 index 000000000..28115932d --- /dev/null +++ b/app/edge/components/groups-datatable/groupsDatatable.html @@ -0,0 +1,102 @@ +
+ + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + + Endpoints Count + + + + + + Group Type + + + +
+ + + + + {{ item.Name }} + in use + {{ item.Endpoints.length }}{{ item.Dynamic ? 'Dynamic' : 'Static' }}
Loading...
+ No Edge group available. +
+
+ +
+
+
diff --git a/app/edge/components/groups-datatable/groupsDatatable.js b/app/edge/components/groups-datatable/groupsDatatable.js new file mode 100644 index 000000000..3749e8eb6 --- /dev/null +++ b/app/edge/components/groups-datatable/groupsDatatable.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +angular.module('portainer.edge').component('edgeGroupsDatatable', { + templateUrl: './groupsDatatable.html', + controller: 'EdgeGroupsDatatableController', + bindings: { + dataset: '<', + titleIcon: '@', + tableKey: '@', + orderBy: '@', + removeAction: '<', + updateAction: '<', + reverseOrder: '<', + }, +}); diff --git a/app/edge/components/groups-datatable/groupsDatatableController.js b/app/edge/components/groups-datatable/groupsDatatableController.js new file mode 100644 index 000000000..afe9468d7 --- /dev/null +++ b/app/edge/components/groups-datatable/groupsDatatableController.js @@ -0,0 +1,19 @@ +import angular from 'angular'; + +class EdgeGroupsDatatableController { + constructor($scope, $controller) { + const allowSelection = this.allowSelection; + angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); + this.allowSelection = allowSelection.bind(this); + } + + /** + * Override this method to allow/deny selection + */ + allowSelection(item) { + return !item.HasEdgeStack; + } +} + +angular.module('portainer.edge').controller('EdgeGroupsDatatableController', EdgeGroupsDatatableController); +export default EdgeGroupsDatatableController; diff --git a/app/edge/rest/edge-groups.js b/app/edge/rest/edge-groups.js new file mode 100644 index 000000000..676b10281 --- /dev/null +++ b/app/edge/rest/edge-groups.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeGroups', function EdgeGroupsFactory($resource, API_ENDPOINT_EDGE_GROUPS) { + return $resource( + API_ENDPOINT_EDGE_GROUPS + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@Id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); +}); diff --git a/app/edge/rest/edge-stacks.js b/app/edge/rest/edge-stacks.js new file mode 100644 index 000000000..5f5345061 --- /dev/null +++ b/app/edge/rest/edge-stacks.js @@ -0,0 +1,16 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeStacks', function EdgeStacksFactory($resource, API_ENDPOINT_EDGE_STACKS) { + return $resource( + API_ENDPOINT_EDGE_STACKS + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + file: { method: 'GET', params: { id: '@id', action: 'file' } }, + } + ); +}); diff --git a/app/edge/rest/edge-templates.js b/app/edge/rest/edge-templates.js new file mode 100644 index 000000000..e5aa85e2f --- /dev/null +++ b/app/edge/rest/edge-templates.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeTemplates', function EdgeStacksFactory($resource, API_ENDPOINT_EDGE_TEMPLATES) { + return $resource( + API_ENDPOINT_EDGE_TEMPLATES, + {}, + { + query: { method: 'GET', isArray: true }, + } + ); +}); diff --git a/app/edge/services/edge-group.js b/app/edge/services/edge-group.js new file mode 100644 index 000000000..48a27f377 --- /dev/null +++ b/app/edge/services/edge-group.js @@ -0,0 +1,27 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeGroupService', function EdgeGroupServiceFactory(EdgeGroups) { + var service = {}; + + service.group = function group(groupId) { + return EdgeGroups.get({ id: groupId }).$promise; + }; + + service.groups = function groups() { + return EdgeGroups.query({}).$promise; + }; + + service.remove = function remove(groupId) { + return EdgeGroups.remove({ id: groupId }).$promise; + }; + + service.create = function create(group) { + return EdgeGroups.create(group).$promise; + }; + + service.update = function update(group) { + return EdgeGroups.update(group).$promise; + }; + + return service; +}); diff --git a/app/edge/services/edge-stack.js b/app/edge/services/edge-stack.js new file mode 100644 index 000000000..9ec0f20e1 --- /dev/null +++ b/app/edge/services/edge-stack.js @@ -0,0 +1,75 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackServiceFactory(EdgeStacks, FileUploadService) { + var service = {}; + + service.stack = function stack(id) { + return EdgeStacks.get({ id }).$promise; + }; + + service.stacks = function stacks() { + return EdgeStacks.query({}).$promise; + }; + + service.remove = function remove(id) { + return EdgeStacks.remove({ id }).$promise; + }; + + service.stackFile = async function stackFile(id) { + try { + const { StackFileContent } = await EdgeStacks.file({ id }).$promise; + return StackFileContent; + } catch (err) { + throw { msg: 'Unable to retrieve stack content', err }; + } + }; + + service.updateStack = async function updateStack(id, stack) { + return EdgeStacks.update({ id }, stack); + }; + + service.createStackFromFileContent = async function createStackFromFileContent(name, stackFileContent, edgeGroups) { + var payload = { + Name: name, + StackFileContent: stackFileContent, + EdgeGroups: edgeGroups, + }; + try { + return await EdgeStacks.create({ method: 'string' }, payload).$promise; + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.createStackFromFileUpload = async function createStackFromFileUpload(name, stackFile, edgeGroups) { + try { + return await FileUploadService.createEdgeStack(name, stackFile, edgeGroups); + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.createStackFromGitRepository = async function createStackFromGitRepository(name, repositoryOptions, edgeGroups) { + var payload = { + Name: name, + RepositoryURL: repositoryOptions.RepositoryURL, + RepositoryReferenceName: repositoryOptions.RepositoryReferenceName, + ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword, + EdgeGroups: edgeGroups, + }; + try { + return await EdgeStacks.create({ method: 'repository' }, payload).$promise; + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.update = function update(stack) { + return EdgeStacks.update(stack).$promise; + }; + + return service; +}); diff --git a/app/edge/services/edge-template.js b/app/edge/services/edge-template.js new file mode 100644 index 000000000..c8ce4a5ab --- /dev/null +++ b/app/edge/services/edge-template.js @@ -0,0 +1,23 @@ +import angular from 'angular'; + +class EdgeTemplateService { + /* @ngInject */ + constructor(EdgeTemplates) { + this.EdgeTemplates = EdgeTemplates; + } + + edgeTemplates() { + return this.EdgeTemplates.query().$promise; + } + + async edgeTemplate(template) { + const response = await fetch(template.stackFile); + if (!response.ok) { + throw new Error(response.statusText); + } + + return response.text(); + } +} + +angular.module('portainer.edge').service('EdgeTemplateService', EdgeTemplateService); diff --git a/app/edge/views/edge-stacks/create/createEdgeStackView.html b/app/edge/views/edge-stacks/create/createEdgeStackView.html new file mode 100644 index 000000000..d366039ac --- /dev/null +++ b/app/edge/views/edge-stacks/create/createEdgeStackView.html @@ -0,0 +1,291 @@ + + + Edge Stacks > Create Edge stack + + +
+
+ + +
+ +
+ +
+ +
+
+ +
+ + This stack will be deployed using the equivalent of the + docker stack deploy command. + +
+
+ Edge Groups +
+
+
+ +
+
+ No Edge groups are available. Head over the Edge groups view to create one. +
+
+ +
+ Build method +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ Web editor +
+
+ + You can get more information about Compose file format in the + + official documentation + + . + +
+
+
+ +
+
+
+ + +
+
+ Upload +
+
+ + You can upload a Compose file from your computer. + +
+
+
+ + + {{ $ctrl.formValues.StackFile.name }} + + +
+
+
+ + +
+
+ Git repository +
+
+ + You can use the URL of a git repository. + +
+
+ +
+ +
+
+
+ + Specify a reference of the repository using the following syntax: branches with + refs/heads/branch_name or tags with refs/tags/tag_name. If not specified, will use the default HEAD reference normally the + master branch. + +
+
+ +
+ +
+
+
+ + Indicate the path to the Compose file from the root of your repository. + +
+
+ +
+ +
+
+
+
+ + +
+
+
+ + If your git account has 2FA enabled, you may receive an + authentication required error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password. + +
+
+ +
+ +
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+ +
+
+ Information +
+
+
+
+
+
+
+ + +
+
+ Web editor +
+
+
+ +
+
+
+
+ + + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+ +
+
+
+
+
diff --git a/app/edge/views/edge-stacks/create/createEdgeStackView.js b/app/edge/views/edge-stacks/create/createEdgeStackView.js new file mode 100644 index 000000000..5b5403337 --- /dev/null +++ b/app/edge/views/edge-stacks/create/createEdgeStackView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('createEdgeStackView', { + templateUrl: './createEdgeStackView.html', + controller: 'CreateEdgeStackViewController', +}); diff --git a/app/edge/views/edge-stacks/create/createEdgeStackViewController.js b/app/edge/views/edge-stacks/create/createEdgeStackViewController.js new file mode 100644 index 000000000..aff2e517d --- /dev/null +++ b/app/edge/views/edge-stacks/create/createEdgeStackViewController.js @@ -0,0 +1,156 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class CreateEdgeStackViewController { + constructor($state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) { + Object.assign(this, { $state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async }); + + this.formValues = { + Name: '', + StackFileContent: '', + StackFile: null, + RepositoryURL: '', + RepositoryReferenceName: '', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + Env: [], + ComposeFilePathInRepository: 'docker-compose.yml', + Groups: [], + }; + + this.state = { + Method: 'editor', + formValidationError: '', + actionInProgress: false, + StackType: null, + }; + + this.edgeGroups = null; + + this.createStack = this.createStack.bind(this); + this.createStackAsync = this.createStackAsync.bind(this); + this.validateForm = this.validateForm.bind(this); + this.createStackByMethod = this.createStackByMethod.bind(this); + this.createStackFromFileContent = this.createStackFromFileContent.bind(this); + this.createStackFromFileUpload = this.createStackFromFileUpload.bind(this); + this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this); + this.editorUpdate = this.editorUpdate.bind(this); + this.onChangeTemplate = this.onChangeTemplate.bind(this); + this.onChangeTemplateAsync = this.onChangeTemplateAsync.bind(this); + this.onChangeMethod = this.onChangeMethod.bind(this); + } + + async $onInit() { + try { + this.edgeGroups = await this.EdgeGroupService.groups(); + this.noGroups = this.edgeGroups.length === 0; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); + } + + try { + const templates = await this.EdgeTemplateService.edgeTemplates(); + this.templates = _.map(templates, (template) => ({ ...template, label: `${template.title} - ${template.description}` })); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Templates'); + } + } + + createStack() { + return this.$async(this.createStackAsync); + } + + onChangeMethod() { + this.formValues.StackFileContent = ''; + this.selectedTemplate = null; + } + + onChangeTemplate(template) { + return this.$async(this.onChangeTemplateAsync, template); + } + + async onChangeTemplateAsync(template) { + this.formValues.StackFileContent = ''; + try { + const fileContent = await this.EdgeTemplateService.edgeTemplate(template); + this.formValues.StackFileContent = fileContent; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Template'); + } + } + + async createStackAsync() { + const name = this.formValues.Name; + let method = this.state.Method; + + if (method === 'template') { + method = 'editor'; + } + + if (!this.validateForm(method)) { + return; + } + + this.state.actionInProgress = true; + try { + await this.createStackByMethod(name, method); + + this.Notifications.success('Stack successfully deployed'); + this.$state.go('edge.stacks'); + } catch (err) { + this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); + } finally { + this.state.actionInProgress = false; + } + } + + validateForm(method) { + this.state.formValidationError = ''; + + if (method === 'editor' && this.formValues.StackFileContent === '') { + this.state.formValidationError = 'Stack file content must not be empty'; + return; + } + + return true; + } + + createStackByMethod(name, method) { + switch (method) { + case 'editor': + return this.createStackFromFileContent(name); + case 'upload': + return this.createStackFromFileUpload(name); + case 'repository': + return this.createStackFromGitRepository(name); + } + } + + createStackFromFileContent(name) { + return this.EdgeStackService.createStackFromFileContent(name, this.formValues.StackFileContent, this.formValues.Groups); + } + + createStackFromFileUpload(name) { + return this.EdgeStackService.createStackFromFileUpload(name, this.formValues.StackFile, this.formValues.Groups); + } + + createStackFromGitRepository(name) { + const repositoryOptions = { + RepositoryURL: this.formValues.RepositoryURL, + RepositoryReferenceName: this.formValues.RepositoryReferenceName, + ComposeFilePathInRepository: this.formValues.ComposeFilePathInRepository, + RepositoryAuthentication: this.formValues.RepositoryAuthentication, + RepositoryUsername: this.formValues.RepositoryUsername, + RepositoryPassword: this.formValues.RepositoryPassword, + }; + return this.EdgeStackService.createStackFromGitRepository(name, repositoryOptions, this.formValues.Groups); + } + + editorUpdate(cm) { + this.formValues.StackFileContent = cm.getValue(); + } +} + +angular.module('portainer.edge').controller('CreateEdgeStackViewController', CreateEdgeStackViewController); +export default CreateEdgeStackViewController; diff --git a/app/edge/views/edge-stacks/edgeStacksView.html b/app/edge/views/edge-stacks/edgeStacksView.html new file mode 100644 index 000000000..4b712b312 --- /dev/null +++ b/app/edge/views/edge-stacks/edgeStacksView.html @@ -0,0 +1,21 @@ + + + + + + + Edge Stacks + +
+
+ +
+
diff --git a/app/edge/views/edge-stacks/edgeStacksView.js b/app/edge/views/edge-stacks/edgeStacksView.js new file mode 100644 index 000000000..27e58493c --- /dev/null +++ b/app/edge/views/edge-stacks/edgeStacksView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('edgeStacksView', { + templateUrl: './edgeStacksView.html', + controller: 'EdgeStacksViewController', +}); diff --git a/app/edge/views/edge-stacks/edgeStacksViewController.js b/app/edge/views/edge-stacks/edgeStacksViewController.js new file mode 100644 index 000000000..9aae3770d --- /dev/null +++ b/app/edge/views/edge-stacks/edgeStacksViewController.js @@ -0,0 +1,52 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EdgeStacksViewController { + constructor($state, Notifications, EdgeStackService, $scope, $async) { + this.$state = $state; + this.Notifications = Notifications; + this.EdgeStackService = EdgeStackService; + this.$scope = $scope; + this.$async = $async; + + this.stacks = undefined; + + this.getStacks = this.getStacks.bind(this); + this.removeAction = this.removeAction.bind(this); + this.removeActionAsync = this.removeActionAsync.bind(this); + } + + $onInit() { + this.getStacks(); + } + + removeAction(stacks) { + return this.$async(this.removeActionAsync, stacks); + } + + async removeActionAsync(stacks) { + for (let stack of stacks) { + try { + await this.EdgeStackService.remove(stack.Id); + this.Notifications.success('Stack successfully removed', stack.Name); + _.remove(this.stacks, stack); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name); + } + } + + this.$state.reload(); + } + + async getStacks() { + try { + this.stacks = await this.EdgeStackService.stacks(); + } catch (err) { + this.stacks = []; + this.Notifications.error('Failure', err, 'Unable to retrieve stacks'); + } + } +} + +angular.module('portainer.edge').controller('EdgeStacksViewController', EdgeStacksViewController); +export default EdgeStacksViewController; diff --git a/app/edge/views/edge-stacks/edit/editEdgeStackView.html b/app/edge/views/edge-stacks/edit/editEdgeStackView.html new file mode 100644 index 000000000..6a64ae1e9 --- /dev/null +++ b/app/edge/views/edge-stacks/edit/editEdgeStackView.html @@ -0,0 +1,43 @@ + + + Edge Stacks > {{ $ctrl.stack.Name }} + + +
+
+ + + + + + Stack + +
+ +
+
+ + Endpoints + +
+ + +
+
+
+
+
+
+
diff --git a/app/edge/views/edge-stacks/edit/editEdgeStackView.js b/app/edge/views/edge-stacks/edit/editEdgeStackView.js new file mode 100644 index 000000000..9f64573d5 --- /dev/null +++ b/app/edge/views/edge-stacks/edit/editEdgeStackView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('editEdgeStackView', { + templateUrl: './editEdgeStackView.html', + controller: 'EditEdgeStackViewController', +}); diff --git a/app/edge/views/edge-stacks/edit/editEdgeStackViewController.js b/app/edge/views/edge-stacks/edit/editEdgeStackViewController.js new file mode 100644 index 000000000..c691d0541 --- /dev/null +++ b/app/edge/views/edge-stacks/edit/editEdgeStackViewController.js @@ -0,0 +1,94 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EditEdgeStackViewController { + constructor($async, $state, EdgeGroupService, EdgeStackService, EndpointService, Notifications) { + this.$async = $async; + this.$state = $state; + this.EdgeGroupService = EdgeGroupService; + this.EdgeStackService = EdgeStackService; + this.EndpointService = EndpointService; + this.Notifications = Notifications; + + this.stack = null; + this.edgeGroups = null; + + this.state = { + actionInProgress: false, + }; + + this.deployStack = this.deployStack.bind(this); + this.deployStackAsync = this.deployStackAsync.bind(this); + this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this); + this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this); + } + + async $onInit() { + const { stackId } = this.$state.params; + try { + const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]); + this.edgeGroups = edgeGroups; + this.stack = model; + this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups); + this.originalFileContent = file; + this.formValues = { + StackFileContent: file, + EdgeGroups: this.stack.EdgeGroups, + Prune: this.stack.Prune, + }; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve stack data'); + } + } + + filterStackEndpoints(groupIds, groups) { + return _.flatten( + _.map(groupIds, (Id) => { + const group = _.find(groups, { Id }); + return group.Endpoints; + }) + ); + } + + deployStack() { + return this.$async(this.deployStackAsync); + } + + async deployStackAsync() { + this.state.actionInProgress = true; + try { + if (this.originalFileContent != this.formValues.StackFileContent) { + this.formValues.Version = this.stack.Version + 1; + } + await this.EdgeStackService.updateStack(this.stack.Id, this.formValues); + this.Notifications.success('Stack successfully deployed'); + this.$state.go('edge.stacks'); + } catch (err) { + this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); + } finally { + this.state.actionInProgress = false; + } + } + + getPaginatedEndpoints(...args) { + return this.$async(this.getPaginatedEndpointsAsync, ...args); + } + + async getPaginatedEndpointsAsync(lastId, limit, search) { + try { + const query = { search, type: 4, endpointIds: this.stackEndpointIds }; + const { value, totalCount } = await this.EndpointService.endpoints(lastId, limit, query); + const endpoints = _.map(value, (endpoint) => { + const status = this.stack.Status[endpoint.Id]; + endpoint.Status = status; + return endpoint; + }); + return { endpoints, totalCount }; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + } + } +} + +angular.module('portainer.edge').controller('EditEdgeStackViewController', EditEdgeStackViewController); +export default EditEdgeStackViewController; diff --git a/app/edge/views/groups/create/createEdgeGroupView.html b/app/edge/views/groups/create/createEdgeGroupView.html new file mode 100644 index 000000000..2732b58e0 --- /dev/null +++ b/app/edge/views/groups/create/createEdgeGroupView.html @@ -0,0 +1,23 @@ + + + Edge groups > Add edge group + + +
+
+ + + + + +
+
diff --git a/app/edge/views/groups/create/createEdgeGroupView.js b/app/edge/views/groups/create/createEdgeGroupView.js new file mode 100644 index 000000000..e6778e728 --- /dev/null +++ b/app/edge/views/groups/create/createEdgeGroupView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('createEdgeGroupView', { + templateUrl: './createEdgeGroupView.html', + controller: 'CreateEdgeGroupController', +}); diff --git a/app/edge/views/groups/create/createEdgeGroupViewController.js b/app/edge/views/groups/create/createEdgeGroupViewController.js new file mode 100644 index 000000000..401f4c814 --- /dev/null +++ b/app/edge/views/groups/create/createEdgeGroupViewController.js @@ -0,0 +1,56 @@ +import angular from 'angular'; + +class CreateEdgeGroupController { + /* @ngInject */ + constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async) { + this.EdgeGroupService = EdgeGroupService; + this.GroupService = GroupService; + this.TagService = TagService; + this.Notifications = Notifications; + this.$state = $state; + this.$async = $async; + + this.state = { + actionInProgress: false, + loaded: false, + }; + + this.model = { + Name: '', + Endpoints: [], + Dynamic: false, + TagIds: [], + PartialMatch: false, + }; + + this.createGroup = this.createGroup.bind(this); + this.createGroupAsync = this.createGroupAsync.bind(this); + } + + async $onInit() { + const [tags, endpointGroups] = await Promise.all([this.TagService.tags(), this.GroupService.groups()]); + this.tags = tags; + this.endpointGroups = endpointGroups; + this.state.loaded = true; + } + + createGroup() { + return this.$async(this.createGroupAsync); + } + + async createGroupAsync() { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.create(this.model); + this.Notifications.success('Edge group successfully created'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create edge group'); + } finally { + this.state.actionInProgress = false; + } + } +} + +angular.module('portainer.edge').controller('CreateEdgeGroupController', CreateEdgeGroupController); +export default CreateEdgeGroupController; diff --git a/app/edge/views/groups/edgeGroupsView.html b/app/edge/views/groups/edgeGroupsView.html new file mode 100644 index 000000000..4114603fc --- /dev/null +++ b/app/edge/views/groups/edgeGroupsView.html @@ -0,0 +1,14 @@ + + + + + + + Edge Groups + + +
+
+ +
+
diff --git a/app/edge/views/groups/edgeGroupsView.js b/app/edge/views/groups/edgeGroupsView.js new file mode 100644 index 000000000..09f0202af --- /dev/null +++ b/app/edge/views/groups/edgeGroupsView.js @@ -0,0 +1,6 @@ +import angular from 'angular'; + +angular.module('portainer.edge').component('edgeGroupsView', { + templateUrl: './edgeGroupsView.html', + controller: 'EdgeGroupsController', +}); diff --git a/app/edge/views/groups/edgeGroupsViewController.js b/app/edge/views/groups/edgeGroupsViewController.js new file mode 100644 index 000000000..d589f7851 --- /dev/null +++ b/app/edge/views/groups/edgeGroupsViewController.js @@ -0,0 +1,40 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EdgeGroupsController { + /* @ngInject */ + constructor($async, $state, EdgeGroupService, Notifications) { + this.$async = $async; + this.$state = $state; + this.EdgeGroupService = EdgeGroupService; + this.Notifications = Notifications; + + this.removeAction = this.removeAction.bind(this); + this.removeActionAsync = this.removeActionAsync.bind(this); + } + + async $onInit() { + this.items = await this.EdgeGroupService.groups(); + } + + removeAction(selectedItems) { + return this.$async(this.removeActionAsync, selectedItems); + } + + async removeActionAsync(selectedItems) { + for (let item of selectedItems) { + try { + await this.EdgeGroupService.remove(item.Id); + + this.Notifications.success('Edge Group successfully removed', item.Name); + _.remove(this.items, item); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to remove Edge Group'); + } + } + + this.$state.reload(); + } +} + +angular.module('portainer.edge').controller('EdgeGroupsController', EdgeGroupsController); diff --git a/app/edge/views/groups/edit/editEdgeGroupView.html b/app/edge/views/groups/edit/editEdgeGroupView.html new file mode 100644 index 000000000..ba26f203f --- /dev/null +++ b/app/edge/views/groups/edit/editEdgeGroupView.html @@ -0,0 +1,23 @@ + + + Edge Groups > {{ $ctrl.model.Name }} + + +
+
+ + + + + +
+
diff --git a/app/edge/views/groups/edit/editEdgeGroupView.js b/app/edge/views/groups/edit/editEdgeGroupView.js new file mode 100644 index 000000000..7cb21ef3b --- /dev/null +++ b/app/edge/views/groups/edit/editEdgeGroupView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('editEdgeGroupView', { + templateUrl: './editEdgeGroupView.html', + controller: 'EditEdgeGroupController', +}); diff --git a/app/edge/views/groups/edit/editEdgeGroupViewController.js b/app/edge/views/groups/edit/editEdgeGroupViewController.js new file mode 100644 index 000000000..1685fbb50 --- /dev/null +++ b/app/edge/views/groups/edit/editEdgeGroupViewController.js @@ -0,0 +1,56 @@ +import angular from 'angular'; + +class EditEdgeGroupController { + /* @ngInject */ + constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async, EndpointService, EndpointHelper) { + this.EdgeGroupService = EdgeGroupService; + this.GroupService = GroupService; + this.TagService = TagService; + this.Notifications = Notifications; + this.$state = $state; + this.$async = $async; + this.EndpointService = EndpointService; + this.EndpointHelper = EndpointHelper; + + this.state = { + actionInProgress: false, + loaded: false, + }; + + this.updateGroup = this.updateGroup.bind(this); + this.updateGroupAsync = this.updateGroupAsync.bind(this); + } + + async $onInit() { + const [tags, endpointGroups, group] = await Promise.all([this.TagService.tags(), this.GroupService.groups(), this.EdgeGroupService.group(this.$state.params.groupId)]); + + if (!group) { + this.Notifications.error('Failed to find edge group', {}); + this.$state.go('edge.groups'); + } + this.tags = tags; + this.endpointGroups = endpointGroups; + this.model = group; + this.state.loaded = true; + } + + updateGroup() { + return this.$async(this.updateGroupAsync); + } + + async updateGroupAsync() { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.update(this.model); + this.Notifications.success('Edge group successfully updated'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update edge group'); + } finally { + this.state.actionInProgress = false; + } + } +} + +angular.module('portainer.edge').controller('EditEdgeGroupController', EditEdgeGroupController); +export default EditEdgeGroupController; diff --git a/app/portainer/components/code-editor/codeEditorController.js b/app/portainer/components/code-editor/codeEditorController.js index dba976e17..66e24666f 100644 --- a/app/portainer/components/code-editor/codeEditorController.js +++ b/app/portainer/components/code-editor/codeEditorController.js @@ -1,20 +1,16 @@ -angular.module('portainer.app').controller('CodeEditorController', [ - '$document', - 'CodeMirrorService', - function ($document, CodeMirrorService) { - var ctrl = this; +angular.module('portainer.app').controller('CodeEditorController', function CodeEditorController($document, CodeMirrorService, $scope) { + var ctrl = this; - this.$onInit = function () { - $document.ready(function () { - var editorElement = $document[0].getElementById(ctrl.identifier); - ctrl.editor = CodeMirrorService.applyCodeMirrorOnElement(editorElement, ctrl.yml, ctrl.readOnly); - if (ctrl.onChange) { - ctrl.editor.on('change', ctrl.onChange); - } - if (ctrl.value) { - ctrl.editor.setValue(ctrl.value); - } - }); - }; - }, -]); + this.$onInit = function () { + $document.ready(function () { + var editorElement = $document[0].getElementById(ctrl.identifier); + ctrl.editor = CodeMirrorService.applyCodeMirrorOnElement(editorElement, ctrl.yml, ctrl.readOnly); + if (ctrl.onChange) { + ctrl.editor.on('change', (...args) => $scope.$apply(() => ctrl.onChange(...args))); + } + if (ctrl.value) { + ctrl.editor.setValue(ctrl.value); + } + }); + }; +}); diff --git a/app/portainer/components/datatables/genericDatatableController.js b/app/portainer/components/datatables/genericDatatableController.js index 7dfe3b49b..b12bc5bd4 100644 --- a/app/portainer/components/datatables/genericDatatableController.js +++ b/app/portainer/components/datatables/genericDatatableController.js @@ -42,11 +42,12 @@ angular.module('portainer.app').controller('GenericDatatableController', [ DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); }; - this.changeOrderBy = function (orderField) { + this.changeOrderBy = changeOrderBy.bind(this); + function changeOrderBy(orderField) { this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; this.state.orderBy = orderField; DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder); - }; + } this.selectItem = function (item, event) { // Handle range select using shift diff --git a/app/portainer/components/forms/group-form/groupForm.html b/app/portainer/components/forms/group-form/groupForm.html index 404ae02a2..6b0b81cb4 100644 --- a/app/portainer/components/forms/group-form/groupForm.html +++ b/app/portainer/components/forms/group-form/groupForm.html @@ -77,6 +77,7 @@ entry-click="$ctrl.dissociateEndpoint" pagination-state="$ctrl.state.associated" empty-dataset-message="No associated endpoint" + has-backend-pagination="this.pageType !== 'create'" > diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html index f6e6054ba..e08177217 100644 --- a/app/portainer/components/forms/schedule-form/scheduleForm.html +++ b/app/portainer/components/forms/schedule-form/scheduleForm.html @@ -269,6 +269,7 @@ - - Name - - - + Name + + + Group + + + Tags - {{ item.Name }} + + {{ item.Name | truncate: 64 }} + + + {{ $ctrl.groupIdToGroupName(item.GroupId) | truncate: 64 }} + + + {{ $ctrl.tagIdsToTagNames(item.TagIds) | arraytostr | truncate: 64 }} + - {{ item.Name }} + + {{ item.Name | truncate: 64 }} + + + {{ $ctrl.groupIdToGroupName(item.GroupId) | truncate: 64 }} + + + {{ $ctrl.tagIdsToTagNames(item.TagIds) | truncate: 64 }} + + - Loading... + Loading... - {{ $ctrl.emptyDatasetMessage }} + {{ $ctrl.emptyDatasetMessage }} diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html index 394531a4f..adb33e059 100644 --- a/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html @@ -1,14 +1,18 @@ - + {{ $item.Name }} - ({{ $ctrl.tagIdsToTagNames($item.TagIds) | arraytostr }}) + - {{ $ctrl.tagIdsToTagNames($item.TagIds) | arraytostr }} - + {{ endpoint.Name }} - ({{ $ctrl.tagIdsToTagNames(endpoint.TagIds) | arraytostr }}) + - {{ $ctrl.tagIdsToTagNames(endpoint.TagIds) | arraytostr }} diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js index bcf41d7df..608825904 100644 --- a/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js @@ -29,9 +29,19 @@ class MultiEndpointSelectorController { return PortainerEndpointTagHelper.idsToTagNames(this.tags, tagIds); } - $onInit() { + filterEmptyGroups() { this.availableGroups = _.filter(this.groups, (group) => _.some(this.endpoints, (endpoint) => endpoint.GroupId == group.Id)); } + + $onInit() { + this.filterEmptyGroups(); + } + + $onChanges({ endpoints, groups }) { + if (endpoints || groups) { + this.filterEmptyGroups(); + } + } } export default MultiEndpointSelectorController; diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index b49a7a989..7a6324005 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -12,6 +12,7 @@ export function SettingsViewModel(data) { this.ExternalTemplates = data.ExternalTemplates; this.EnableHostManagementFeatures = data.EnableHostManagementFeatures; this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval; + this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures; } export function PublicSettingsViewModel(settings) { @@ -21,6 +22,7 @@ export function PublicSettingsViewModel(settings) { this.AuthenticationMethod = settings.AuthenticationMethod; this.EnableHostManagementFeatures = settings.EnableHostManagementFeatures; this.ExternalTemplates = settings.ExternalTemplates; + this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; this.LogoURL = settings.LogoURL; this.OAuthLoginURI = settings.OAuthLoginURI; } diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 018c2b30d..becb4efbf 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -10,8 +10,8 @@ angular.module('portainer.app').factory('EndpointService', [ return Endpoints.get({ id: endpointID }).$promise; }; - service.endpoints = function (start, limit, { search, type, tagIds, endpointIds } = {}) { - return Endpoints.query({ start, limit, search, type, tagIds, endpointIds }).$promise; + service.endpoints = function (start, limit, { search, type, tagIds, endpointIds, tagsPartialMatch } = {}) { + return Endpoints.query({ start, limit, search, type, tagIds: JSON.stringify(tagIds), endpointIds: JSON.stringify(endpointIds), tagsPartialMatch }).$promise; }; service.snapshotEndpoints = function () { diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index 25aa6b756..6a571e47a 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -85,6 +85,19 @@ angular.module('portainer.app').factory('FileUploadService', [ }); }; + service.createEdgeStack = function createEdgeStack(stackName, file, edgeGroups) { + return Upload.upload({ + url: 'api/edge_stacks?method=file', + data: { + file: file, + Name: stackName, + EdgeGroups: Upload.json(edgeGroups) + }, + ignoreLoadingBar: true + }); + }; + + service.configureRegistry = function (registryId, registryManagementConfigurationModel) { return Upload.upload({ url: 'api/registries/' + registryId + '/configure', diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index e9b4ab908..1fda3ae68 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -71,6 +71,11 @@ angular.module('portainer.app').factory('StateManager', [ LocalStorage.storeApplicationState(state.application); }; + manager.updateEnableEdgeComputeFeatures = function updateEnableEdgeComputeFeatures(enableEdgeComputeFeatures) { + state.application.enableEdgeComputeFeatures = enableEdgeComputeFeatures; + LocalStorage.storeApplicationState(state.application); + }; + function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; @@ -81,6 +86,7 @@ angular.module('portainer.app').factory('StateManager', [ state.application.snapshotInterval = settings.SnapshotInterval; state.application.enableHostManagementFeatures = settings.EnableHostManagementFeatures; state.application.enableVolumeBrowserForNonAdminUsers = settings.AllowVolumeBrowserForRegularUsers; + state.application.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; state.application.validity = moment().unix(); } diff --git a/app/portainer/views/endpoints/endpointsController.js b/app/portainer/views/endpoints/endpointsController.js index 336a86dce..df3c72781 100644 --- a/app/portainer/views/endpoints/endpointsController.js +++ b/app/portainer/views/endpoints/endpointsController.js @@ -1,48 +1,44 @@ -angular.module('portainer.app').controller('EndpointsController', [ - '$q', - '$scope', - '$state', - 'EndpointService', - 'GroupService', - 'EndpointHelper', - 'Notifications', - function ($q, $scope, $state, EndpointService, GroupService, EndpointHelper, Notifications) { - $scope.removeAction = function (selectedItems) { - var actionCount = selectedItems.length; - angular.forEach(selectedItems, function (endpoint) { - EndpointService.deleteEndpoint(endpoint.Id) - .then(function success() { - Notifications.success('Endpoint successfully removed', endpoint.Name); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove endpoint'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - }; +import angular from 'angular'; - $scope.getPaginatedEndpoints = getPaginatedEndpoints; - function getPaginatedEndpoints(lastId, limit, search) { - const deferred = $q.defer(); - $q.all({ - endpoints: EndpointService.endpoints(lastId, limit, { search }), - groups: GroupService.groups(), - }) - .then(function success(data) { - var endpoints = data.endpoints.value; - var groups = data.groups; - EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); - deferred.resolve({ endpoints: endpoints, totalCount: data.endpoints.totalCount }); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); - }); - return deferred.promise; +angular.module('portainer.app').controller('EndpointsController', EndpointsController); + +function EndpointsController($q, $scope, $state, $async, EndpointService, GroupService, EndpointHelper, Notifications) { + $scope.removeAction = removeAction; + + function removeAction(endpoints) { + return $async(removeActionAsync, endpoints); + } + + async function removeActionAsync(endpoints) { + for (let endpoint of endpoints) { + try { + await EndpointService.deleteEndpoint(endpoint.Id); + + Notifications.success('Endpoint successfully removed', endpoint.Name); + } catch (err) { + Notifications.error('Failure', err, 'Unable to remove endpoint'); + } } - }, -]); + + $state.reload(); + } + + $scope.getPaginatedEndpoints = getPaginatedEndpoints; + function getPaginatedEndpoints(lastId, limit, search) { + const deferred = $q.defer(); + $q.all({ + endpoints: EndpointService.endpoints(lastId, limit, { search }), + groups: GroupService.groups(), + }) + .then(function success(data) { + var endpoints = data.endpoints.value; + var groups = data.groups; + EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); + deferred.resolve({ endpoints: endpoints, totalCount: data.endpoints.totalCount }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + }); + return deferred.promise; + } +} diff --git a/app/portainer/views/groups/groupsController.js b/app/portainer/views/groups/groupsController.js index 1b089850e..67fc81af6 100644 --- a/app/portainer/views/groups/groupsController.js +++ b/app/portainer/views/groups/groupsController.js @@ -1,42 +1,40 @@ -angular.module('portainer.app').controller('GroupsController', [ - '$scope', - '$state', - '$filter', - 'GroupService', - 'Notifications', - function ($scope, $state, $filter, GroupService, Notifications) { - $scope.removeAction = function (selectedItems) { - var actionCount = selectedItems.length; - angular.forEach(selectedItems, function (group) { - GroupService.deleteGroup(group.Id) - .then(function success() { - Notifications.success('Endpoint group successfully removed', group.Name); - var index = $scope.groups.indexOf(group); - $scope.groups.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove group'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - }; +import angular from 'angular'; +import _ from 'lodash-es'; - function initView() { - GroupService.groups() - .then(function success(data) { - $scope.groups = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoint groups'); - $scope.groups = []; - }); +angular.module('portainer.app').controller('GroupsController', GroupsController); + +function GroupsController($scope, $state, $async, GroupService, Notifications) { + $scope.removeAction = removeAction; + + function removeAction(selectedItems) { + return $async(removeActionAsync, selectedItems); + } + + async function removeActionAsync(selectedItems) { + for (let group of selectedItems) { + try { + await GroupService.deleteGroup(group.Id); + + Notifications.success('Endpoint group successfully removed', group.Name); + _.remove($scope.groups, group); + } catch (err) { + Notifications.error('Failure', err, 'Unable to remove group'); + } } - initView(); - }, -]); + $state.reload(); + } + + function initView() { + GroupService.groups() + .then(function success(data) { + $scope.groups = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint groups'); + $scope.groups = []; + }); + } + + initView(); +} diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 5d55db506..3f3da4b74 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -123,22 +123,30 @@
- Edge + Edge Compute +
+
+ +
+ +
-
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 16d0e37cb..eba237506 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -32,6 +32,7 @@ angular.module('portainer.app').controller('SettingsController', [ labelValue: '', enableHostManagementFeatures: false, enableVolumeBrowser: false, + enableEdgeComputeFeatures: false, }; $scope.removeFilteredContainerLabel = function (index) { @@ -67,6 +68,7 @@ angular.module('portainer.app').controller('SettingsController', [ settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode; settings.AllowVolumeBrowserForRegularUsers = $scope.formValues.enableVolumeBrowser; settings.EnableHostManagementFeatures = $scope.formValues.enableHostManagementFeatures; + settings.EnableEdgeComputeFeatures = $scope.formValues.enableEdgeComputeFeatures; $scope.state.actionInProgress = true; updateSettings(settings); @@ -80,6 +82,7 @@ angular.module('portainer.app').controller('SettingsController', [ StateManager.updateSnapshotInterval(settings.SnapshotInterval); StateManager.updateEnableHostManagementFeatures(settings.EnableHostManagementFeatures); StateManager.updateEnableVolumeBrowserForNonAdminUsers(settings.AllowVolumeBrowserForRegularUsers); + StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures); $state.reload(); }) .catch(function error(err) { @@ -105,6 +108,7 @@ angular.module('portainer.app').controller('SettingsController', [ $scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers; $scope.formValues.enableVolumeBrowser = settings.AllowVolumeBrowserForRegularUsers; $scope.formValues.enableHostManagementFeatures = settings.EnableHostManagementFeatures; + $scope.formValues.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve application settings'); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index a16482a1e..8e1a3f4d9 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -91,6 +91,15 @@ + + + diff --git a/app/portainer/views/tags/tagsController.js b/app/portainer/views/tags/tagsController.js index ae0477df7..ae86039a9 100644 --- a/app/portainer/views/tags/tagsController.js +++ b/app/portainer/views/tags/tagsController.js @@ -1,72 +1,71 @@ -angular.module('portainer.app').controller('TagsController', [ - '$scope', - '$state', - 'TagService', - 'Notifications', - function ($scope, $state, TagService, Notifications) { - $scope.state = { - actionInProgress: false, - }; +import angular from 'angular'; +import _ from 'lodash-es'; - $scope.formValues = { - Name: '', - }; +angular.module('portainer.app').controller('TagsController', TagsController); - $scope.checkNameValidity = function (form) { - var valid = true; - for (var i = 0; i < $scope.tags.length; i++) { - if ($scope.formValues.Name === $scope.tags[i].Name) { - valid = false; - break; - } +function TagsController($scope, $state, $async, TagService, Notifications) { + $scope.state = { + actionInProgress: false, + }; + + $scope.formValues = { + Name: '', + }; + + $scope.checkNameValidity = function (form) { + var valid = true; + for (var i = 0; i < $scope.tags.length; i++) { + if ($scope.formValues.Name === $scope.tags[i].Name) { + valid = false; + break; } - form.name.$setValidity('validName', valid); - }; + } + form.name.$setValidity('validName', valid); + }; - $scope.removeAction = function (selectedItems) { - var actionCount = selectedItems.length; - angular.forEach(selectedItems, function (tag) { - TagService.deleteTag(tag.Id) - .then(function success() { - Notifications.success('Tag successfully removed', tag.Name); - var index = $scope.tags.indexOf(tag); - $scope.tags.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to tag'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - }; + $scope.removeAction = removeAction; - $scope.createTag = function () { - var tagName = $scope.formValues.Name; - TagService.createTag(tagName) - .then(function success() { - Notifications.success('Tag successfully created', tagName); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to create tag'); - }); - }; + function removeAction(tags) { + return $async(removeActionAsync, tags); + } - function initView() { - TagService.tags() - .then(function success(data) { - $scope.tags = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve tags'); - $scope.tags = []; - }); + async function removeActionAsync(tags) { + for (let tag of tags) { + try { + await TagService.deleteTag(tag.Id); + + Notifications.success('Tag successfully removed', tag.Name); + _.remove($scope.tags, tag); + } catch (err) { + Notifications.error('Failure', err, 'Unable to remove tag'); + } } - initView(); - }, -]); + $state.reload(); + } + + $scope.createTag = function () { + var tagName = $scope.formValues.Name; + TagService.createTag(tagName) + .then(function success() { + Notifications.success('Tag successfully created', tagName); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create tag'); + }); + }; + + function initView() { + TagService.tags() + .then(function success(data) { + $scope.tags = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tags'); + $scope.tags = []; + }); + } + + initView(); +}