From 53b37ab8c80099df8801f2effc755e9e53d1ea7b Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 7 Jul 2020 02:18:39 +0300 Subject: [PATCH] feat(custom-templates): introduce custom templates (#3906) * feat(custom-templates): introduce types * feat(custom-templates): introduce data layer service * feat(custom-templates): introduce http handler * feat(custom-templates): create routes and view stubs * feat(custom-templates): add create custom template ui * feat(custom-templates): add json keys * feat(custom-templates): introduce custom templates list page * feat(custom-templates): introduce update page * feat(stack): create template from stack * feat(stacks): create stack from custom template * feat(custom-templates): disable edit/delete of templates * fix(custom-templates): fail update on non admin/owner * fix(custom-templates): add ng-inject decorator * chore(plop): revert template * feat(stacks): remove actions column * feat(stack): add button to create template from stack * feat(stacks): add empty state for templates * feat(custom-templates): show templates in a list * feat(custom-template): replace table with list * feat(custom-templates): move create template button * refactor(custom-templates): introduce more fields * feat(custom-templates): use stack type when creating template * feat(custom-templates): use same type as stack * feat(custom-templates): add edit and delete buttons to template item * feat(custom-templates): customize stack before deploy * feat(stack): show template details * feat(custom-templates): move customize * feat(custom-templates): create description required * fix(template): show platform icon * fix(custom-templates): show spinner when creating stack * feat(custom-templates): prevent user from edit templates * feat(custom-templates): use resource control for custom templates * feat(custom-templates): show created templates * feat(custom-templates): filter templates by stack type * fix(custom-templates): create swarm or standalone stack * feat(stacks): filter templates by type * feat(resource-control): disable resource control on public * feat(custom-template): apply access control on edit * feat(custom-template): add form validation * feat(stack): disable create custom template from external task * refactor(custom-templates): create template from file and type * feat(templates): introduce a file handler that returns template docker file * feat(template): introduce template duplication * feat(custom-template): enforce unique template name * fix(template): rename copy button * fix(custom-template): clear access control selection between templates * fix(custom-templates): show required fields * refactor(filesystem): use a constant for temp path --- api/bolt/customtemplate/customtemplate.go | 96 ++++++ api/bolt/datastore.go | 13 + api/filesystem/filesystem.go | 41 +++ .../customtemplates/customtemplate_create.go | 290 ++++++++++++++++++ .../customtemplates/customtemplate_delete.go | 61 ++++ .../customtemplates/customtemplate_file.go | 37 +++ .../customtemplates/customtemplate_inspect.go | 47 +++ .../customtemplates/customtemplate_list.go | 67 ++++ .../customtemplates/customtemplate_update.go | 92 ++++++ api/http/handler/customtemplates/git.go | 17 + api/http/handler/customtemplates/handler.go | 60 ++++ api/http/handler/handler.go | 6 + api/http/handler/templates/git.go | 17 + api/http/handler/templates/handler.go | 6 +- api/http/handler/templates/template_file.go | 77 +++++ api/http/server.go | 9 + api/internal/authorization/access_control.go | 33 +- api/portainer.go | 45 +++ app/constants.js | 1 + .../docker-sidebar-content.js | 2 + .../dockerSidebarContent.html | 4 + app/portainer/__module.js | 40 +++ .../porAccessControlFormController.js | 4 + .../customTemplateCommonFields.html | 71 +++++ .../customTemplateCommonFieldsController.js | 16 + .../custom-template-common-fields/index.js | 9 + .../customTemplatesList.html | 51 +++ .../components/custom-templates-list/index.js | 15 + .../forms/stack-from-template-form/index.js | 15 + .../stackFromTemplateForm.html | 73 +++++ .../template-item/template-item.css | 9 + .../template-item/template-item.js | 10 +- .../template-item/templateItem.html | 15 +- .../template-list/template-list-controller.js | 113 ++++--- .../template-list/templateList.html | 9 +- .../models/resourceControl/resourceControl.js | 2 +- .../resourceControl/resourceControlTypes.js | 2 + app/portainer/rest/customTemplate.js | 18 ++ app/portainer/rest/template.js | 3 +- app/portainer/services/api/customTemplate.js | 59 ++++ app/portainer/services/api/templateService.js | 5 + app/portainer/services/fileUpload.js | 8 + app/portainer/services/modalService.js | 6 + .../createCustomTemplateView.html | 217 +++++++++++++ .../createCustomTemplateViewController.js | 152 +++++++++ .../create-custom-template-view/index.js | 6 + .../customTemplatesView.html | 72 +++++ .../customTemplatesViewController.js | 243 +++++++++++++++ .../custom-templates-view/index.js | 6 + .../editCustomTemplateView.html | 71 +++++ .../editCustomTemplateViewController.js | 93 ++++++ .../edit-custom-template-view/index.js | 6 + app/portainer/views/sidebar/sidebar.html | 2 + .../stacks/create/createStackController.js | 61 ++-- .../views/stacks/create/createstack.html | 64 +++- app/portainer/views/stacks/edit/stack.html | 14 +- app/portainer/views/templates/templates.html | 80 +---- plop-templates/component.js.hbs | 6 +- 58 files changed, 2513 insertions(+), 154 deletions(-) create mode 100644 api/bolt/customtemplate/customtemplate.go create mode 100644 api/http/handler/customtemplates/customtemplate_create.go create mode 100644 api/http/handler/customtemplates/customtemplate_delete.go create mode 100644 api/http/handler/customtemplates/customtemplate_file.go create mode 100644 api/http/handler/customtemplates/customtemplate_inspect.go create mode 100644 api/http/handler/customtemplates/customtemplate_list.go create mode 100644 api/http/handler/customtemplates/customtemplate_update.go create mode 100644 api/http/handler/customtemplates/git.go create mode 100644 api/http/handler/customtemplates/handler.go create mode 100644 api/http/handler/templates/git.go create mode 100644 api/http/handler/templates/template_file.go create mode 100644 app/portainer/components/custom-template-common-fields/customTemplateCommonFields.html create mode 100644 app/portainer/components/custom-template-common-fields/customTemplateCommonFieldsController.js create mode 100644 app/portainer/components/custom-template-common-fields/index.js create mode 100644 app/portainer/components/custom-templates-list/customTemplatesList.html create mode 100644 app/portainer/components/custom-templates-list/index.js create mode 100644 app/portainer/components/forms/stack-from-template-form/index.js create mode 100644 app/portainer/components/forms/stack-from-template-form/stackFromTemplateForm.html create mode 100644 app/portainer/components/template-list/template-item/template-item.css create mode 100644 app/portainer/rest/customTemplate.js create mode 100644 app/portainer/services/api/customTemplate.js create mode 100644 app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html create mode 100644 app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js create mode 100644 app/portainer/views/custom-templates/create-custom-template-view/index.js create mode 100644 app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html create mode 100644 app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js create mode 100644 app/portainer/views/custom-templates/custom-templates-view/index.js create mode 100644 app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateView.html create mode 100644 app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateViewController.js create mode 100644 app/portainer/views/custom-templates/edit-custom-template-view/index.js diff --git a/api/bolt/customtemplate/customtemplate.go b/api/bolt/customtemplate/customtemplate.go new file mode 100644 index 000000000..316af170e --- /dev/null +++ b/api/bolt/customtemplate/customtemplate.go @@ -0,0 +1,96 @@ +package customtemplate + +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 = "customtemplates" +) + +// Service represents a service for managing custom template 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 +} + +// CustomTemplates return an array containing all the custom templates. +func (service *Service) CustomTemplates() ([]portainer.CustomTemplate, error) { + var customTemplates = make([]portainer.CustomTemplate, 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 customTemplate portainer.CustomTemplate + err := internal.UnmarshalObjectWithJsoniter(v, &customTemplate) + if err != nil { + return err + } + customTemplates = append(customTemplates, customTemplate) + } + + return nil + }) + + return customTemplates, err +} + +// CustomTemplate returns an custom template by ID. +func (service *Service) CustomTemplate(ID portainer.CustomTemplateID) (*portainer.CustomTemplate, error) { + var customTemplate portainer.CustomTemplate + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &customTemplate) + if err != nil { + return nil, err + } + + return &customTemplate, nil +} + +// UpdateCustomTemplate updates an custom template. +func (service *Service) UpdateCustomTemplate(ID portainer.CustomTemplateID, customTemplate *portainer.CustomTemplate) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, customTemplate) +} + +// DeleteCustomTemplate deletes an custom template. +func (service *Service) DeleteCustomTemplate(ID portainer.CustomTemplateID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// CreateCustomTemplate assign an ID to a new custom template and saves it. +func (service *Service) CreateCustomTemplate(customTemplate *portainer.CustomTemplate) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data, err := internal.MarshalObject(customTemplate) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(customTemplate.ID)), data) + }) +} + +// GetNextIdentifier returns the next identifier for a custom template. +func (service *Service) GetNextIdentifier() int { + return internal.GetNextIdentifier(service.db, BucketName) +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 9ace83b9a..e7311a1bd 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -7,6 +7,7 @@ import ( "github.com/boltdb/bolt" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/customtemplate" "github.com/portainer/portainer/api/bolt/dockerhub" "github.com/portainer/portainer/api/bolt/edgegroup" "github.com/portainer/portainer/api/bolt/edgejob" @@ -43,6 +44,7 @@ type Store struct { db *bolt.DB isNew bool fileService portainer.FileService + CustomTemplateService *customtemplate.Service DockerHubService *dockerhub.Service EdgeGroupService *edgegroup.Service EdgeJobService *edgejob.Service @@ -168,6 +170,12 @@ func (store *Store) initServices() error { } store.RoleService = authorizationsetService + customTemplateService, err := customtemplate.NewService(store.db) + if err != nil { + return err + } + store.CustomTemplateService = customTemplateService + dockerhubService, err := dockerhub.NewService(store.db) if err != nil { return err @@ -291,6 +299,11 @@ func (store *Store) initServices() error { return nil } +// CustomTemplate gives access to the CustomTemplate data management layer +func (store *Store) CustomTemplate() portainer.CustomTemplateService { + return store.CustomTemplateService +} + // DockerHub gives access to the DockerHub data management layer func (store *Store) DockerHub() portainer.DockerHubService { return store.DockerHubService diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 7d3747ae4..eb5024a93 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -7,6 +7,7 @@ import ( "fmt" "io/ioutil" + "github.com/gofrs/uuid" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/archive" @@ -43,6 +44,10 @@ const ( // ExtensionRegistryManagementStorePath represents the subfolder where files related to the // registry management extension are stored. ExtensionRegistryManagementStorePath = "extensions" + // CustomTemplateStorePath represents the subfolder where custom template files are stored in the file store folder. + CustomTemplateStorePath = "custom_templates" + // TempPath represent the subfolder where temporary files are saved + TempPath = "tmp" ) // Service represents a service for managing files and directories. @@ -393,6 +398,32 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { return block.Bytes, nil } +// GetCustomTemplateProjectPath returns the absolute path on the FS for a custom template based +// on its identifier. +func (service *Service) GetCustomTemplateProjectPath(identifier string) string { + return path.Join(service.fileStorePath, CustomTemplateStorePath, identifier) +} + +// StoreCustomTemplateFileFromBytes creates a subfolder in the CustomTemplateStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreCustomTemplateFileFromBytes(identifier, fileName string, data []byte) (string, error) { + customTemplateStorePath := path.Join(CustomTemplateStorePath, identifier) + err := service.createDirectoryInStore(customTemplateStorePath) + if err != nil { + return "", err + } + + templateFilePath := path.Join(customTemplateStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(templateFilePath, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, customTemplateStorePath), nil +} + // GetEdgeJobFolder returns the absolute path on the filesystem for an Edge job based // on its identifier. func (service *Service) GetEdgeJobFolder(identifier string) string { @@ -467,3 +498,13 @@ func (service *Service) StoreEdgeJobTaskLogFileFromBytes(edgeJobID, taskID strin func (service *Service) getEdgeJobTaskLogPath(edgeJobID string, taskID string) string { return fmt.Sprintf("%s/logs_%s", service.GetEdgeJobFolder(edgeJobID), taskID) } + +// GetTemporaryPath returns a temp folder +func (service *Service) GetTemporaryPath() (string, error) { + uid, err := uuid.NewV4() + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, TempPath, uid.String()), nil +} diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go new file mode 100644 index 000000000..3bb8fef0b --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -0,0 +1,290 @@ +package customtemplates + +import ( + "errors" + "net/http" + "strconv" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +func (handler *Handler) customTemplateCreate(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} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user details from authentication token", err} + } + + customTemplate, err := handler.createCustomTemplate(method, r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create custom template", err} + } + + customTemplate.CreatedByUserID = tokenData.ID + + customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err} + } + + for _, existingTemplate := range customTemplates { + if existingTemplate.Title == customTemplate.Title { + return &httperror.HandlerError{http.StatusInternalServerError, "Template name must be unique", errors.New("Template name must be unique")} + } + } + + err = handler.DataStore.CustomTemplate().CreateCustomTemplate(customTemplate) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create custom template", err} + } + + resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, tokenData.ID) + + err = handler.DataStore.ResourceControl().CreateResourceControl(resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control inside the database", err} + } + + customTemplate.ResourceControl = resourceControl + + return response.JSON(w, customTemplate) +} + +func (handler *Handler) createCustomTemplate(method string, r *http.Request) (*portainer.CustomTemplate, error) { + switch method { + case "string": + return handler.createCustomTemplateFromFileContent(r) + case "repository": + return handler.createCustomTemplateFromGitRepository(r) + case "file": + return handler.createCustomTemplateFromFileUpload(r) + } + return nil, errors.New("Invalid value for query parameter: method. Value must be one of: string, repository or file") +} + +type customTemplateFromFileContentPayload struct { + Logo string + Title string + FileContent string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType +} + +func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Title) { + return portainer.Error("Invalid custom template title") + } + if govalidator.IsNull(payload.Description) { + return portainer.Error("Invalid custom template description") + } + if govalidator.IsNull(payload.FileContent) { + return portainer.Error("Invalid file content") + } + if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return portainer.Error("Invalid custom template platform") + } + if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + return portainer.Error("Invalid custom template type") + } + return nil +} + +func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*portainer.CustomTemplate, error) { + var payload customTemplateFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() + customTemplate := &portainer.CustomTemplate{ + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + EntryPoint: filesystem.ComposeFileDefaultName, + Description: payload.Description, + Note: payload.Note, + Platform: (payload.Platform), + Type: (payload.Type), + Logo: payload.Logo, + } + + templateFolder := strconv.Itoa(customTemplateID) + projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent)) + if err != nil { + return nil, err + } + customTemplate.ProjectPath = projectPath + + return customTemplate, nil +} + +type customTemplateFromGitRepositoryPayload struct { + Logo string + Title string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType + RepositoryURL string + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Title) { + return portainer.Error("Invalid custom template title") + } + if govalidator.IsNull(payload.Description) { + return portainer.Error("Invalid custom template description") + } + 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.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return portainer.Error("Invalid custom template platform") + } + if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + return portainer.Error("Invalid custom template type") + } + return nil +} + +func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (*portainer.CustomTemplate, error) { + var payload customTemplateFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() + customTemplate := &portainer.CustomTemplate{ + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + EntryPoint: payload.ComposeFilePathInRepository, + Description: payload.Description, + Note: payload.Note, + Platform: payload.Platform, + Type: payload.Type, + Logo: payload.Logo, + } + + projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID)) + customTemplate.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + referenceName: payload.RepositoryReferenceName, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return nil, err + } + + return customTemplate, nil +} + +type customTemplateFromFileUploadPayload struct { + Logo string + Title string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType + FileContent []byte +} + +func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error { + title, err := request.RetrieveMultiPartFormValue(r, "Title", false) + if err != nil { + return portainer.Error("Invalid custom template title") + } + payload.Title = title + + description, err := request.RetrieveMultiPartFormValue(r, "Description", false) + if err != nil { + return portainer.Error("Invalid custom template description") + } + + payload.Description = description + + note, _ := request.RetrieveMultiPartFormValue(r, "Note", true) + payload.Note = note + + platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true) + templatePlatform := portainer.CustomTemplatePlatform(platform) + if templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows { + return portainer.Error("Invalid custom template platform") + } + payload.Platform = templatePlatform + + typeNumeral, _ := request.RetrieveNumericMultiPartFormValue(r, "Type", true) + templateType := portainer.StackType(typeNumeral) + if templateType != portainer.DockerComposeStack && templateType != portainer.DockerSwarmStack { + return portainer.Error("Invalid custom template type") + } + payload.Type = templateType + + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.FileContent = composeFileContent + + return nil +} + +func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*portainer.CustomTemplate, error) { + payload := &customTemplateFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return nil, err + } + + customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() + customTemplate := &portainer.CustomTemplate{ + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + Description: payload.Description, + Note: payload.Note, + Platform: payload.Platform, + Type: payload.Type, + Logo: payload.Logo, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + templateFolder := strconv.Itoa(customTemplateID) + projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent)) + if err != nil { + return nil, err + } + customTemplate.ProjectPath = projectPath + + return customTemplate, nil +} diff --git a/api/http/handler/customtemplates/customtemplate_delete.go b/api/http/handler/customtemplates/customtemplate_delete.go new file mode 100644 index 000000000..f81c41992 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_delete.go @@ -0,0 +1,61 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +func (handler *Handler) customTemplateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template identifier route variable", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template with the specified identifier inside the database", err} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the custom template", err} + } + + access := userCanEditTemplate(customTemplate, securityContext) + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + + err = handler.DataStore.CustomTemplate().DeleteCustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the custom template from the database", err} + } + + err = handler.FileService.RemoveDirectory(customTemplate.ProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove custom template files from disk", err} + } + + if resourceControl != nil { + err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the associated resource control from the database", err} + } + } + + return response.Empty(w) + +} diff --git a/api/http/handler/customtemplates/customtemplate_file.go b/api/http/handler/customtemplates/customtemplate_file.go new file mode 100644 index 000000000..5b3689fa1 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_file.go @@ -0,0 +1,37 @@ +package customtemplates + +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 fileResponse struct { + FileContent string +} + +// GET request on /api/custom_templates/:id/file +func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid custom template identifier route variable", err} + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template with the specified identifier inside the database", err} + } + + fileContent, err := handler.FileService.GetFileContent(path.Join(customTemplate.ProjectPath, customTemplate.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom template file from disk", err} + } + + return response.JSON(w, &fileResponse{FileContent: string(fileContent)}) +} diff --git a/api/http/handler/customtemplates/customtemplate_inspect.go b/api/http/handler/customtemplates/customtemplate_inspect.go new file mode 100644 index 000000000..fd77be2b0 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_inspect.go @@ -0,0 +1,47 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + 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/http/security" +) + +func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template identifier route variable", err} + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template with the specified identifier inside the database", 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(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the custom template", err} + } + + access := userCanEditTemplate(customTemplate, securityContext) + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + + if resourceControl != nil { + customTemplate.ResourceControl = resourceControl + } + + return response.JSON(w, customTemplate) +} diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go new file mode 100644 index 000000000..b33cb8297 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -0,0 +1,67 @@ +package customtemplates + +import ( + "net/http" + + 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/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err} + } + + stackType, _ := request.RetrieveNumericQueryParameter(r, "type", true) + + resourceControls, err := handler.DataStore.ResourceControl().ResourceControls() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + + customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls) + + customTemplates = filterTemplatesByEngineType(customTemplates, portainer.StackType(stackType)) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin { + 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} + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range securityContext.UserMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs) + } + + return response.JSON(w, customTemplates) +} + +func filterTemplatesByEngineType(templates []portainer.CustomTemplate, stackType portainer.StackType) []portainer.CustomTemplate { + if stackType == 0 { + return templates + } + + filteredTemplates := []portainer.CustomTemplate{} + + for _, template := range templates { + if template.Type == stackType { + filteredTemplates = append(filteredTemplates, template) + } + } + + return filteredTemplates +} diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go new file mode 100644 index 000000000..b969b4fe2 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -0,0 +1,92 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + "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" + "github.com/portainer/portainer/api/http/security" +) + +type customTemplateUpdatePayload struct { + Logo string + Title string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType + FileContent string +} + +func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Title) { + return portainer.Error("Invalid custom template title") + } + if govalidator.IsNull(payload.FileContent) { + return portainer.Error("Invalid file content") + } + if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return portainer.Error("Invalid custom template platform") + } + if payload.Type != portainer.DockerComposeStack && payload.Type != portainer.DockerSwarmStack { + return portainer.Error("Invalid custom template type") + } + if govalidator.IsNull(payload.Description) { + return portainer.Error("Invalid custom template description") + } + return nil +} + +func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template identifier route variable", err} + } + + var payload customTemplateUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template 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} + } + + access := userCanEditTemplate(customTemplate, securityContext) + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + + templateFolder := strconv.Itoa(customTemplateID) + _, err = handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated custom template file on disk", err} + } + + customTemplate.Title = payload.Title + customTemplate.Logo = payload.Logo + customTemplate.Description = payload.Description + customTemplate.Note = payload.Note + customTemplate.Platform = payload.Platform + customTemplate.Type = payload.Type + + err = handler.DataStore.CustomTemplate().UpdateCustomTemplate(customTemplate.ID, customTemplate) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist custom template changes inside the database", err} + } + + return response.JSON(w, customTemplate) +} diff --git a/api/http/handler/customtemplates/git.go b/api/http/handler/customtemplates/git.go new file mode 100644 index 000000000..b4f3e3211 --- /dev/null +++ b/api/http/handler/customtemplates/git.go @@ -0,0 +1,17 @@ +package customtemplates + +type cloneRepositoryParameters struct { + url string + referenceName string + path string + authentication bool + username string + password string +} + +func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { + if parameters.authentication { + return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password) + } + return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path) +} diff --git a/api/http/handler/customtemplates/handler.go b/api/http/handler/customtemplates/handler.go new file mode 100644 index 000000000..ff8fce4a5 --- /dev/null +++ b/api/http/handler/customtemplates/handler.go @@ -0,0 +1,60 @@ +package customtemplates + +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" + "github.com/portainer/portainer/api/internal/authorization" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + DataStore portainer.DataStore + FileService portainer.FileService + GitService portainer.GitService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/custom_templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost) + h.Handle("/custom_templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet) + h.Handle("/custom_templates/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateInspect))).Methods(http.MethodGet) + h.Handle("/custom_templates/{id}/file", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateFile))).Methods(http.MethodGet) + h.Handle("/custom_templates/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateUpdate))).Methods(http.MethodPut) + h.Handle("/custom_templates/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateDelete))).Methods(http.MethodDelete) + return h +} + +func userCanEditTemplate(customTemplate *portainer.CustomTemplate, securityContext *security.RestrictedRequestContext) bool { + return securityContext.IsAdmin || customTemplate.CreatedByUserID == securityContext.UserID +} + +func userCanAccessTemplate(customTemplate portainer.CustomTemplate, securityContext *security.RestrictedRequestContext, resourceControl *portainer.ResourceControl) bool { + if securityContext.IsAdmin || customTemplate.CreatedByUserID == securityContext.UserID { + return true + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range securityContext.UserMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + if resourceControl != nil && authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) { + return true + } + + return false +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 113fa60c4..4d7dbf760 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/portainer/portainer/api/http/handler/auth" + "github.com/portainer/portainer/api/http/handler/customtemplates" "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" @@ -37,6 +38,7 @@ import ( // Handler is a collection of all the service handlers. type Handler struct { AuthHandler *auth.Handler + CustomTemplatesHandler *customtemplates.Handler DockerHubHandler *dockerhub.Handler EdgeGroupsHandler *edgegroups.Handler EdgeJobsHandler *edgejobs.Handler @@ -73,6 +75,10 @@ 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/custom_templates"): + http.StripPrefix("/api", h.CustomTemplatesHandler).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_jobs"): diff --git a/api/http/handler/templates/git.go b/api/http/handler/templates/git.go new file mode 100644 index 000000000..cc94668cd --- /dev/null +++ b/api/http/handler/templates/git.go @@ -0,0 +1,17 @@ +package templates + +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/templates/handler.go b/api/http/handler/templates/handler.go index 7360d5252..5c89f350f 100644 --- a/api/http/handler/templates/handler.go +++ b/api/http/handler/templates/handler.go @@ -12,7 +12,9 @@ import ( // Handler represents an HTTP API handler for managing templates. type Handler struct { *mux.Router - DataStore portainer.DataStore + DataStore portainer.DataStore + GitService portainer.GitService + FileService portainer.FileService } // NewHandler returns a new instance of Handler. @@ -23,5 +25,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h.Handle("/templates", bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + h.Handle("/templates/file", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go new file mode 100644 index 000000000..614e3d170 --- /dev/null +++ b/api/http/handler/templates/template_file.go @@ -0,0 +1,77 @@ +package templates + +import ( + "errors" + "log" + "net/http" + "path" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +type filePayload struct { + RepositoryURL string + ComposeFilePathInRepository string +} + +type fileResponse struct { + FileContent string +} + +func (payload *filePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.RepositoryURL) { + return errors.New("Invalid repository url") + } + + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + return errors.New("Invalid file path") + } + + return nil +} + +func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload filePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + projectPath, err := handler.FileService.GetTemporaryPath() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create temporary folder", err} + } + + defer handler.cleanUp(projectPath) + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + } + + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + composeFilePath := path.Join(projectPath, payload.ComposeFilePathInRepository) + + fileContent, err := handler.FileService.GetFileContent(composeFilePath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed loading file content", err} + } + + return response.JSON(w, fileResponse{FileContent: string(fileContent)}) + +} + +func (handler *Handler) cleanUp(projectPath string) error { + err := handler.FileService.RemoveDirectory(projectPath) + if err != nil { + log.Printf("http error: Unable to cleanup stack creation (err=%s)\n", err) + } + return nil +} diff --git a/api/http/server.go b/api/http/server.go index 4693c5334..1b1e24b76 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/handler" "github.com/portainer/portainer/api/http/handler/auth" + "github.com/portainer/portainer/api/http/handler/customtemplates" "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgejobs" @@ -92,6 +93,11 @@ func (server *Server) Start() error { var roleHandler = roles.NewHandler(requestBouncer) roleHandler.DataStore = server.DataStore + var customTemplatesHandler = customtemplates.NewHandler(requestBouncer) + customTemplatesHandler.DataStore = server.DataStore + customTemplatesHandler.FileService = server.FileService + customTemplatesHandler.GitService = server.GitService + var dockerHubHandler = dockerhub.NewHandler(requestBouncer) dockerHubHandler.DataStore = server.DataStore @@ -184,6 +190,8 @@ func (server *Server) Start() error { var templatesHandler = templates.NewHandler(requestBouncer) templatesHandler.DataStore = server.DataStore + templatesHandler.FileService = server.FileService + templatesHandler.GitService = server.GitService var uploadHandler = upload.NewHandler(requestBouncer) uploadHandler.FileService = server.FileService @@ -206,6 +214,7 @@ func (server *Server) Start() error { server.Handler = &handler.Handler{ RoleHandler: roleHandler, AuthHandler: authHandler, + CustomTemplatesHandler: customTemplatesHandler, DockerHubHandler: dockerHubHandler, EdgeGroupsHandler: edgeGroupsHandler, EdgeJobsHandler: edgeJobsHandler, diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go index 9879799ac..cb263b76a 100644 --- a/api/internal/authorization/access_control.go +++ b/api/internal/authorization/access_control.go @@ -1,6 +1,10 @@ package authorization -import "github.com/portainer/portainer/api" +import ( + "strconv" + + "github.com/portainer/portainer/api" +) // NewPrivateResourceControl will create a new private resource control associated to the resource specified by the // identifier and type parameters. It automatically assigns it to the user specified by the userID parameter. @@ -100,6 +104,20 @@ func DecorateStacks(stacks []portainer.Stack, resourceControls []portainer.Resou return stacks } +// DecorateCustomTemplates will iterate through a list of custom templates, check for an associated resource control for each +// template and decorate the template element if a resource control is found. +func DecorateCustomTemplates(templates []portainer.CustomTemplate, resourceControls []portainer.ResourceControl) []portainer.CustomTemplate { + for idx, template := range templates { + + resourceControl := GetResourceControlByResourceIDAndType(strconv.Itoa(int(template.ID)), portainer.CustomTemplateResourceControl, resourceControls) + if resourceControl != nil { + templates[idx].ResourceControl = resourceControl + } + } + + return templates +} + // FilterAuthorizedStacks returns a list of decorated stacks filtered through resource control access checks. func FilterAuthorizedStacks(stacks []portainer.Stack, user *portainer.User, userTeamIDs []portainer.TeamID, rbacEnabled bool) []portainer.Stack { authorizedStacks := make([]portainer.Stack, 0) @@ -119,6 +137,19 @@ func FilterAuthorizedStacks(stacks []portainer.Stack, user *portainer.User, user return authorizedStacks } +// FilterAuthorizedCustomTemplates returns a list of decorated custom templates filtered through resource control access checks. +func FilterAuthorizedCustomTemplates(customTemplates []portainer.CustomTemplate, user *portainer.User, userTeamIDs []portainer.TeamID) []portainer.CustomTemplate { + authorizedTemplates := make([]portainer.CustomTemplate, 0) + + for _, customTemplate := range customTemplates { + if customTemplate.CreatedByUserID == user.ID || (customTemplate.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, customTemplate.ResourceControl)) { + authorizedTemplates = append(authorizedTemplates, customTemplate) + } + } + + return authorizedTemplates +} + // UserCanAccessResource will valide that a user has permissions defined in the specified resource control // based on its identifier and the team(s) he is part of. func UserCanAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { diff --git a/api/portainer.go b/api/portainer.go index 582e7cf84..8e94b7694 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -61,6 +61,27 @@ type ( SnapshotInterval *string } + // CustomTemplate represents a custom template + CustomTemplate struct { + ID CustomTemplateID `json:"Id"` + Title string `json:"Title"` + Description string `json:"Description"` + ProjectPath string `json:"ProjectPath"` + EntryPoint string `json:"EntryPoint"` + CreatedByUserID UserID `json:"CreatedByUserId"` + Note string `json:"Note"` + Platform CustomTemplatePlatform `json:"Platform"` + Logo string `json:"Logo"` + Type StackType `json:"Type"` + ResourceControl *ResourceControl `json:"ResourceControl"` + } + + // CustomTemplateID represents a custom template identifier + CustomTemplateID int + + // CustomTemplatePlatform represents a custom template platform + CustomTemplatePlatform int + // DockerHub represents all the required information to connect and use the // Docker Hub DockerHub struct { @@ -746,6 +767,16 @@ type ( CompareHashAndData(hash string, data string) error } + // CustomTemplateService represents a service to manage custom templates + CustomTemplateService interface { + GetNextIdentifier() int + CustomTemplates() ([]CustomTemplate, error) + CustomTemplate(ID CustomTemplateID) (*CustomTemplate, error) + CreateCustomTemplate(customTemplate *CustomTemplate) error + UpdateCustomTemplate(ID CustomTemplateID, customTemplate *CustomTemplate) error + DeleteCustomTemplate(ID CustomTemplateID) error + } + // DataStore defines the interface to manage the data DataStore interface { Open() error @@ -755,6 +786,7 @@ type ( MigrateData() error DockerHub() DockerHubService + CustomTemplate() CustomTemplateService EdgeGroup() EdgeGroupService EdgeJob() EdgeJobService EdgeStack() EdgeStackService @@ -897,6 +929,9 @@ type ( StoreEdgeJobTaskLogFileFromBytes(edgeJobID, taskID string, data []byte) error ExtractExtensionArchive(data []byte) error GetBinaryFolder() string + StoreCustomTemplateFileFromBytes(identifier, fileName string, data []byte) (string, error) + GetCustomTemplateProjectPath(identifier string) string + GetTemporaryPath() (string, error) } // GitService represents a service for managing Git @@ -1140,6 +1175,14 @@ const ( EdgeJobLogsStatusCollected ) +const ( + _ CustomTemplatePlatform = iota + // CustomTemplatePlatformLinux represents a custom template for linux + CustomTemplatePlatformLinux + // CustomTemplatePlatformWindows represents a custom template for windows + CustomTemplatePlatformWindows +) + const ( _ EdgeStackStatusType = iota //StatusOk represents a successfully deployed edge stack @@ -1240,6 +1283,8 @@ const ( StackResourceControl // ConfigResourceControl represents a resource control associated to a Docker config ConfigResourceControl + // CustomTemplateResourceControl represents a resource control associated to a custom template + CustomTemplateResourceControl ) const ( diff --git a/app/constants.js b/app/constants.js index a727766bb..f27f26557 100644 --- a/app/constants.js +++ b/app/constants.js @@ -2,6 +2,7 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') + .constant('API_ENDPOINT_CUSTOM_TEMPLATES', 'api/custom_templates') .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') .constant('API_ENDPOINT_EDGE_JOBS', 'api/edge_jobs') .constant('API_ENDPOINT_EDGE_STACKS', 'api/edge_stacks') diff --git a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js index b57e4cdcb..400293ee5 100644 --- a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js +++ b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js @@ -6,5 +6,7 @@ angular.module('portainer.docker').component('dockerSidebarContent', { standaloneManagement: '<', adminAccess: '<', offlineMode: '<', + toggle: '<', + currentRouteName: '<', }, }); diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index 50815d41d..118b63fbe 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -3,6 +3,10 @@