1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

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
This commit is contained in:
Chaim Lev-Ari 2020-07-07 02:18:39 +03:00 committed by GitHub
parent 42aa8ceb00
commit 53b37ab8c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 2513 additions and 154 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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