Feat(stacks): orphaned stacks #4397 (#4834)

* feat(stack): add the ability for an administrator user to manage orphaned stacks (#4397)

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

Co-authored-by: Simon Meng <simon.meng@portainer.io>
pull/4444/merge
cong meng 2021-06-10 14:52:33 +12:00 committed by GitHub
parent eae2f5c9fc
commit 26ead28d7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 428 additions and 117 deletions

View File

@ -52,6 +52,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet)
h.Handle("/stacks/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete)
h.Handle("/stacks/{id}/associate",
bouncer.AdminAccess(httperror.LoggerHandler(h.stackAssociate))).Methods(http.MethodPut)
h.Handle("/stacks/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut)
h.Handle("/stacks/{id}/file",

View File

@ -0,0 +1,91 @@
package stacks
import (
"fmt"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils"
"net/http"
"time"
)
// PUT request on /api/stacks/:id/associate?endpointId=<endpointId>&swarmId=<swarmId>&orphanedRunning=<orphanedRunning>
func (handler *Handler) stackAssociate(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}
}
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
}
swarmId, err := request.RetrieveQueryParameter(r, "swarmId", true)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: swarmId", err}
}
orphanedRunning, err := request.RetrieveBooleanQueryParameter(r, "orphanedRunning", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: orphanedRunning", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
}
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.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}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
if resourceControl != nil {
resourceControl.ResourceID = fmt.Sprintf("%d_%s", endpointID, stack.Name)
err = handler.DataStore.ResourceControl().UpdateResourceControl(resourceControl.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err}
}
}
stack.EndpointID = portainer.EndpointID(endpointID)
stack.SwarmID = swarmId
if orphanedRunning {
stack.Status = portainer.StackStatusActive
} else {
stack.Status = portainer.StackStatusInactive
}
stack.CreationDate = time.Now().Unix()
stack.CreatedBy = user.Username
stack.UpdateDate = 0
stack.UpdatedBy = ""
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
}
stack.ResourceControl = resourceControl
return response.JSON(w, stack)
}

View File

@ -58,42 +58,42 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API endpoint
// can use the optional EndpointID query parameter to set a valid endpoint identifier to be
// used in the context of this request.
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
}
endpointIdentifier := stack.EndpointID
if endpointID != 0 {
endpointIdentifier = portainer.EndpointID(endpointID)
isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID
if isOrphaned && !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointIdentifier)
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
if !isOrphaned {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
}
}
err = handler.deleteStack(stack, endpoint)

View File

@ -46,34 +46,38 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
if endpoint != nil {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
}
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint))

View File

@ -1,6 +1,7 @@
package stacks
import (
"github.com/portainer/portainer/api/http/errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
@ -8,7 +9,6 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils"
)
@ -40,38 +40,42 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
if !securityContext.IsAdmin {
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.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
if endpoint != nil {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
if resourceControl != nil {
stack.ResourceControl = resourceControl
if resourceControl != nil {
stack.ResourceControl = resourceControl
}
}
return response.JSON(w, stack)

View File

@ -1,6 +1,7 @@
package stacks
import (
httperrors "github.com/portainer/portainer/api/http/errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
@ -12,8 +13,9 @@ import (
)
type stackListOperationFilters struct {
SwarmID string `json:"SwarmID"`
EndpointID int `json:"EndpointID"`
SwarmID string `json:"SwarmID"`
EndpointID int `json:"EndpointID"`
IncludeOrphanedStacks bool `json:"IncludeOrphanedStacks"`
}
// @id StackList
@ -37,11 +39,16 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err}
}
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err}
}
stacks, err := handler.DataStore.Stack().Stacks()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err}
}
stacks = filterStacks(stacks, &filters)
stacks = filterStacks(stacks, &filters, endpoints)
resourceControls, err := handler.DataStore.ResourceControl().ResourceControls()
if err != nil {
@ -56,6 +63,10 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
stacks = authorization.DecorateStacks(stacks, resourceControls)
if !securityContext.IsAdmin {
if filters.IncludeOrphanedStacks {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access orphaned stacks", httperrors.ErrUnauthorized}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err}
@ -72,13 +83,20 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
return response.JSON(w, stacks)
}
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack {
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack {
if filters.EndpointID == 0 && filters.SwarmID == "" {
return stacks
}
filteredStacks := make([]portainer.Stack, 0, len(stacks))
for _, stack := range stacks {
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
filteredStacks = append(filteredStacks, stack)
}
continue
}
if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) {
filteredStacks = append(filteredStacks, stack)
}
@ -89,3 +107,13 @@ func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters)
return filteredStacks
}
func isOrphanedStack(stack portainer.Stack, endpoints []portainer.Endpoint) bool {
for _, endpoint := range endpoints {
if stack.EndpointID == endpoint.ID {
return false
}
}
return true
}

View File

@ -456,7 +456,7 @@ angular.module('portainer.docker', ['portainer.app']).config([
var stack = {
name: 'docker.stacks.stack',
url: '/:name?id&type&external',
url: '/:name?id&type&regular&external&orphaned&orphanedRunning',
views: {
'content@': {
templateUrl: '~Portainer/views/stacks/edit/stack.html',

View File

@ -8,5 +8,6 @@ angular.module('portainer.app').component('porAccessControlForm', {
// Optional. An existing resource control object that will be used to set
// the default values of the component.
resourceControl: '<',
hideTitle: '<',
},
});

View File

@ -1,5 +1,5 @@
<div>
<div class="col-sm-12 form-section-title">
<div ng-if="!$ctrl.hideTitle" class="col-sm-12 form-section-title">
Access control
</div>
<!-- access-control-switch -->

View File

@ -14,6 +14,10 @@
</div>
<div class="menuContent">
<div>
<div class="md-checkbox" ng-if="$ctrl.isAdmin">
<input id="setting_all_orphaned_stacks" type="checkbox" ng-model="$ctrl.settings.allOrphanedStacks" ng-change="$ctrl.onSettingsAllOrphanedStacksChange()" />
<label for="setting_all_orphaned_stacks">Show all orphaned stacks</label>
</div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
<label for="setting_auto_refresh">Auto refresh</label>
@ -151,12 +155,26 @@
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="!$ctrl.allowSelection(item)" />
<label for="select_{{ $index }}"></label>
</span>
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.stacks.stack({ name: item.Name, id: item.Id, type: item.Type, external: item.External })">{{ item.Name }}</a>
<a
ng-if="!$ctrl.offlineMode"
ui-sref="docker.stacks.stack({ name: item.Name, id: item.Id, type: item.Type, regular: item.Regular, external: item.External, orphaned: item.Orphaned, orphanedRunning: item.OrphanedRunning })"
>{{ item.Name }}</a
>
<span ng-if="$ctrl.offlineMode">{{ item.Name }}</span>
<span ng-if="item.Status == 2" style="margin-left: 10px;" class="label label-warning image-tag space-left">Inactive</span>
<span ng-if="item.Regular && item.Status == 2" style="margin-left: 10px;" class="label label-warning image-tag space-left">Inactive</span>
</td>
<td>{{ item.Type === 1 ? 'Swarm' : 'Compose' }}</td>
<td>
<span
ng-if="item.Orphaned"
class="interactive"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="This stack was created inside an endpoint that is no longer registered inside Portainer."
>
Orphaned <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-left: 2px;"></i>
</span>
<span
ng-if="item.External"
class="interactive"
@ -167,7 +185,7 @@
>
Limited <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-left: 2px;"></i>
</span>
<span ng-if="!item.External">Total</span>
<span ng-if="item.Regular">Total</span>
</td>
<td>
<span ng-if="item.CreationDate">{{ item.CreationDate | getisodatefromtimestamp }} {{ item.CreatedBy ? 'by ' + item.CreatedBy : '' }}</span>

View File

@ -47,7 +47,11 @@ angular.module('portainer.app').controller('StacksDatatableController', [
this.applyFilters = applyFilters.bind(this);
function applyFilters(stack) {
const { showActiveStacks, showUnactiveStacks } = this.filters.state;
return (stack.Status === 1 && showActiveStacks) || (stack.Status === 2 && showUnactiveStacks) || stack.External;
if (stack.Orphaned) {
return stack.OrphanedRunning || this.settings.allOrphanedStacks;
} else {
return (stack.Status === 1 && showActiveStacks) || (stack.Status === 2 && showUnactiveStacks) || stack.External;
}
}
this.onFilterChange = onFilterChange.bind(this);
@ -57,6 +61,10 @@ angular.module('portainer.app').controller('StacksDatatableController', [
DatatableService.setDataTableFilters(this.tableKey, this.filters);
}
this.onSettingsAllOrphanedStacksChange = function () {
DatatableService.setDataTableSettings(this.tableKey, this.settings);
};
this.$onInit = function () {
this.isAdmin = Authentication.isAdmin();
this.setDefaults();
@ -87,6 +95,7 @@ angular.module('portainer.app').controller('StacksDatatableController', [
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
this.settings.allOrphanedStacks = this.settings.allOrphanedStacks && this.isAdmin;
}
this.onSettingsRepeaterChange();

View File

@ -4,25 +4,54 @@ export function StackViewModel(data) {
this.Id = data.Id;
this.Type = data.Type;
this.Name = data.Name;
this.Checked = false;
this.EndpointId = data.EndpointId;
this.SwarmId = data.SwarmId;
this.Env = data.Env ? data.Env : [];
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
}
this.External = false;
this.Status = data.Status;
this.CreationDate = data.CreationDate;
this.CreatedBy = data.CreatedBy;
this.UpdateDate = data.UpdateDate;
this.UpdatedBy = data.UpdatedBy;
this.Regular = true;
this.External = false;
this.Orphaned = false;
this.Checked = false;
}
export function ExternalStackViewModel(name, type, creationDate) {
this.Name = name;
this.Type = type;
this.External = true;
this.Checked = false;
this.CreationDate = creationDate;
this.Regular = false;
this.External = true;
this.Orphaned = false;
this.Checked = false;
}
export function OrphanedStackViewModel(data) {
this.Id = data.Id;
this.Type = data.Type;
this.Name = data.Name;
this.EndpointId = data.EndpointId;
this.SwarmId = data.SwarmId;
this.Env = data.Env ? data.Env : [];
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
}
this.Status = data.Status;
this.CreationDate = data.CreationDate;
this.CreatedBy = data.CreatedBy;
this.UpdateDate = data.UpdateDate;
this.UpdatedBy = data.UpdatedBy;
this.Regular = false;
this.External = false;
this.Orphaned = true;
this.OrphanedRunning = false;
this.Checked = false;
}

View File

@ -12,6 +12,7 @@ angular.module('portainer.app').factory('Stack', [
query: { method: 'GET', isArray: true },
create: { method: 'POST', ignoreLoadingBar: true },
update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true },
associate: { method: 'PUT', params: { id: '@id', swarmId: '@swarmId', endpointId: '@endpointId', orphanedRunning: '@orphanedRunning', action: 'associate' } },
remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } },
getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } },
migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true },

View File

@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { StackViewModel } from '../../models/stack';
import { StackViewModel, OrphanedStackViewModel } from '../../models/stack';
angular.module('portainer.app').factory('StackService', [
'$q',
@ -88,15 +88,15 @@ angular.module('portainer.app').factory('StackService', [
return deferred.promise;
};
service.stacks = function (compose, swarm, endpointId) {
service.stacks = function (compose, swarm, endpointId, includeOrphanedStacks = false) {
var deferred = $q.defer();
var queries = [];
if (compose) {
queries.push(service.composeStacks(true, { EndpointID: endpointId }));
queries.push(service.composeStacks(endpointId, true, { EndpointID: endpointId, IncludeOrphanedStacks: includeOrphanedStacks }));
}
if (swarm) {
queries.push(service.swarmStacks(true));
queries.push(service.swarmStacks(endpointId, true, { IncludeOrphanedStacks: includeOrphanedStacks }));
}
$q.all(queries)
@ -145,7 +145,22 @@ angular.module('portainer.app').factory('StackService', [
return deferred.promise;
};
service.composeStacks = function (includeExternalStacks, filters) {
service.unionStacks = function (stacks, externalStacks) {
stacks.forEach((stack) => {
externalStacks.forEach((externalStack) => {
if (stack.Orphaned && stack.Name == externalStack.Name) {
stack.OrphanedRunning = true;
}
});
});
const result = _.unionWith(stacks, externalStacks, function (a, b) {
return a.Name === b.Name;
});
return result;
};
service.composeStacks = function (endpointId, includeExternalStacks, filters) {
var deferred = $q.defer();
$q.all({
@ -154,14 +169,15 @@ angular.module('portainer.app').factory('StackService', [
})
.then(function success(data) {
var stacks = data.stacks.map(function (item) {
item.External = false;
return new StackViewModel(item);
if (item.EndpointId == endpointId) {
return new StackViewModel(item);
} else {
return new OrphanedStackViewModel(item);
}
});
var externalStacks = data.externalStacks;
var result = _.unionWith(stacks, externalStacks, function (a, b) {
return a.Name === b.Name;
});
var externalStacks = data.externalStacks;
const result = service.unionStacks(stacks, externalStacks);
deferred.resolve(result);
})
.catch(function error(err) {
@ -171,13 +187,13 @@ angular.module('portainer.app').factory('StackService', [
return deferred.promise;
};
service.swarmStacks = function (includeExternalStacks) {
service.swarmStacks = function (endpointId, includeExternalStacks, filters = {}) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
var filters = { SwarmID: swarm.Id };
filters = { SwarmID: swarm.Id, ...filters };
return $q.all({
stacks: Stack.query({ filters: filters }).$promise,
@ -186,14 +202,15 @@ angular.module('portainer.app').factory('StackService', [
})
.then(function success(data) {
var stacks = data.stacks.map(function (item) {
item.External = false;
return new StackViewModel(item);
if (item.EndpointId == endpointId) {
return new StackViewModel(item);
} else {
return new OrphanedStackViewModel(item);
}
});
var externalStacks = data.externalStacks;
var result = _.unionWith(stacks, externalStacks, function (a, b) {
return a.Name === b.Name;
});
var externalStacks = data.externalStacks;
const result = service.unionStacks(stacks, externalStacks);
deferred.resolve(result);
})
.catch(function error(err) {
@ -217,6 +234,34 @@ angular.module('portainer.app').factory('StackService', [
return deferred.promise;
};
service.associate = function (stack, endpointId, orphanedRunning) {
var deferred = $q.defer();
if (stack.Type == 1) {
SwarmService.swarm()
.then(function success(data) {
const swarm = data;
return Stack.associate({ id: stack.Id, endpointId: endpointId, swarmId: swarm.Id, orphanedRunning }).$promise;
})
.then(function success(data) {
deferred.resolve(data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to associate the stack', err: err });
});
} else {
Stack.associate({ id: stack.Id, endpointId: endpointId, orphanedRunning })
.$promise.then(function success(data) {
deferred.resolve(data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to associate the stack', err: err });
});
}
return deferred.promise;
};
service.updateStack = function (stack, stackFile, env, prune) {
return Stack.update({ endpointId: stack.EndpointId }, { id: stack.Id, StackFileContent: stackFile, Env: env, Prune: prune }).$promise;
};

View File

@ -17,7 +17,7 @@
<uib-tab-heading> <i class="fa fa-th-list" aria-hidden="true"></i> Stack </uib-tab-heading>
<div style="margin-top: 10px;">
<!-- stack-information -->
<div ng-if="state.externalStack">
<div ng-if="external || orphaned">
<div class="col-sm-12 form-section-title">
Information
</div>
@ -25,7 +25,8 @@
<span class="small">
<p class="text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This stack was created outside of Portainer. Control over this stack is limited.
<span ng-if="external">This stack was created outside of Portainer. Control over this stack is limited.</span>
<span ng-if="orphaned">This stack is orphaned. You can reassociate it with the current environment using the "Associate to this endpoint" feature.</span>
</p>
</span>
</div>
@ -41,7 +42,7 @@
<button
authorization="PortainerStackUpdate"
ng-if="!state.externalStack && stack.Status === 2"
ng-if="regular && stack.Status === 2"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-success"
ng-click="startStack()"
@ -51,7 +52,7 @@
</button>
<button
ng-if="!state.externalStack && stack.Status === 1"
ng-if="regular && stack.Status === 1"
authorization="PortainerStackUpdate"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-danger"
@ -61,13 +62,13 @@
Stop this stack
</button>
<button authorization="PortainerStackDelete" class="btn btn-xs btn-danger" ng-click="removeStack()" ng-if="!state.externalStack || stack.Type === 1">
<button authorization="PortainerStackDelete" class="btn btn-xs btn-danger" ng-click="removeStack()" ng-if="!external || stack.Type == 1">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>
Delete this stack
</button>
<button
ng-if="!state.externalStack && stackFileContent"
ng-if="regular && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="docker.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
@ -77,8 +78,40 @@
</div>
</div>
<!-- !stack-details -->
<!-- associate -->
<div ng-if="orphaned">
<div class="col-sm-12 form-section-title">
Associate to this endpoint
</div>
<p class="small text-muted">
This feature allows you to reassociate this stack to the current endpoint.
</p>
<form class="form-horizontal">
<por-access-control-form form-data="formValues.AccessControlData" hide-title="true"></por-access-control-form>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress"
ng-click="associateStack()"
button-spinner="state.actionInProgress"
style="margin-left: -5px;"
>
<i class="fa fa-sync" aria-hidden="true" style="margin-right: 3px;"></i>
<span ng-hide="state.actionInProgress">Associate</span>
<span ng-show="state.actionInProgress">Association in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
</form>
</div>
<!-- !associate -->
<stack-duplication-form
ng-if="!state.externalStack && endpoints.length > 0"
ng-if="regular && endpoints.length > 0"
endpoints="endpoints"
groups="groups"
current-endpoint-id="currentEndpointId"
@ -91,7 +124,7 @@
</uib-tab>
<!-- !tab-info -->
<!-- tab-file -->
<uib-tab index="1" select="showEditor()" ng-if="!state.externalStack">
<uib-tab index="1" select="showEditor()" ng-if="!external">
<uib-tab-heading> <i class="fa fa-pencil-alt space-right" aria-hidden="true"></i> Editor </uib-tab-heading>
<form class="form-horizontal" ng-if="state.showEditorTab" style="margin-top: 10px;">
<div class="form-group">
@ -108,6 +141,7 @@
<div class="form-group">
<div class="col-sm-12">
<code-editor
read-only="orphaned"
identifier="stack-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
@ -173,7 +207,7 @@
<button
type="button"
class="btn btn-sm btn-primary"
ng-disabled="state.actionInProgress || stack.Status === 2 || !stackFileContent"
ng-disabled="state.actionInProgress || stack.Status === 2 || !stackFileContent || orphaned"
ng-click="deployStack()"
button-spinner="state.actionInProgress"
>
@ -192,7 +226,7 @@
</div>
</div>
<div class="row" ng-if="containers">
<div class="row" ng-if="containers && (!orphaned || orphanedRunning)">
<div class="col-sm-12">
<containers-datatable
title-text="Containers"
@ -206,7 +240,7 @@
</div>
</div>
<div class="row" ng-if="services">
<div class="row" ng-if="services && (!orphaned || orphanedRunning)">
<div class="col-sm-12">
<services-datatable
title-text="Services"
@ -226,6 +260,6 @@
</div>
<!-- access-control-panel -->
<por-access-control-panel ng-if="stack" resource-id="stack.EndpointId + '_' + stack.Name" resource-control="stack.ResourceControl" resource-type="'stack'">
<por-access-control-panel ng-if="stack && !orphaned" resource-id="stack.EndpointId + '_' + stack.Name" resource-control="stack.ResourceControl" resource-type="'stack'">
</por-access-control-panel>
<!-- !access-control-panel -->

View File

@ -1,3 +1,5 @@
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
angular.module('portainer.app').controller('StackController', [
'$async',
'$q',
@ -19,6 +21,8 @@ angular.module('portainer.app').controller('StackController', [
'GroupService',
'ModalService',
'StackHelper',
'ResourceControlService',
'Authentication',
'ContainerHelper',
function (
$async,
@ -41,12 +45,13 @@ angular.module('portainer.app').controller('StackController', [
GroupService,
ModalService,
StackHelper,
ResourceControlService,
Authentication,
ContainerHelper
) {
$scope.state = {
actionInProgress: false,
migrationInProgress: false,
externalStack: false,
showEditorTab: false,
yamlError: false,
isEditorDirty: false,
@ -55,6 +60,7 @@ angular.module('portainer.app').controller('StackController', [
$scope.formValues = {
Prune: false,
Endpoint: null,
AccessControlData: new AccessControlFormData(),
};
$window.onbeforeunload = () => {
@ -162,6 +168,31 @@ angular.module('portainer.app').controller('StackController', [
});
}
$scope.associateStack = function () {
var endpointId = +$state.params.endpointId;
var stack = $scope.stack;
var accessControlData = $scope.formValues.AccessControlData;
$scope.state.actionInProgress = true;
StackService.associate(stack, endpointId, $scope.orphanedRunning)
.then(function success(data) {
const resourceControl = data.ResourceControl;
const userDetails = Authentication.getUserDetails();
const userId = userDetails.ID;
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
})
.then(function success() {
Notifications.success('Stack successfully associated', stack.Name);
$state.go('docker.stacks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to associate stack ' + stack.Name);
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
$scope.deployStack = function () {
var stackFile = $scope.stackFileContent;
var env = FormHelper.removeInvalidEnvVars($scope.stack.Env);
@ -391,14 +422,27 @@ angular.module('portainer.app').controller('StackController', [
async function initView() {
var stackName = $transition$.params().name;
$scope.stackName = stackName;
var external = $transition$.params().external;
$scope.currentEndpointId = EndpointProvider.endpointID();
if (external === 'true') {
$scope.state.externalStack = true;
const regular = $transition$.params().regular == 'true';
$scope.regular = regular;
var external = $transition$.params().external == 'true';
$scope.external = external;
const orphaned = $transition$.params().orphaned == 'true';
$scope.orphaned = orphaned;
const orphanedRunning = $transition$.params().orphanedRunning == 'true';
$scope.orphanedRunning = orphanedRunning;
if (external || (orphaned && orphanedRunning)) {
loadExternalStack(stackName);
} else {
var stackId = $transition$.params().id;
}
if (regular || orphaned) {
const stackId = $transition$.params().id;
loadStack(stackId);
}

View File

@ -42,7 +42,8 @@ function StacksController($scope, $state, Notifications, StackService, ModalServ
var endpointMode = $scope.applicationState.endpoint.mode;
var endpointId = EndpointProvider.endpointID();
StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpointId)
const includeOrphanedStacks = Authentication.isAdmin();
StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpointId, includeOrphanedStacks)
.then(function success(data) {
var stacks = data;
$scope.stacks = stacks;