mirror of https://github.com/portainer/portainer
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 pathpull/4015/head
parent
42aa8ceb00
commit
53b37ab8c8
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)})
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"):
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -6,5 +6,7 @@ angular.module('portainer.docker').component('dockerSidebarContent', {
|
|||
standaloneManagement: '<',
|
||||
adminAccess: '<',
|
||||
offlineMode: '<',
|
||||
toggle: '<',
|
||||
currentRouteName: '<',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -3,6 +3,10 @@
|
|||
</li>
|
||||
<li class="sidebar-list" ng-if="!$ctrl.offlineMode" authorization="DockerContainerCreate, PortainerStackCreate">
|
||||
<a ui-sref="portainer.templates" ui-sref-active="active">App Templates <span class="menu-icon fa fa-rocket fa-fw"></span></a>
|
||||
|
||||
<div class="sidebar-sublist" ng-if="$ctrl.toggle && $ctrl.currentRouteName.includes('portainer.templates')">
|
||||
<a ui-sref="portainer.templates.custom" ui-sref-active="active">Custom Templates</a></div
|
||||
>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="portainer.stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list fa-fw"></span></a>
|
||||
|
|
|
@ -514,6 +514,43 @@ angular.module('portainer.app', []).config([
|
|||
},
|
||||
};
|
||||
|
||||
const customTemplates = {
|
||||
name: 'portainer.templates.custom',
|
||||
url: '/custom',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'customTemplatesView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const customTemplatesNew = {
|
||||
name: 'portainer.templates.custom.new',
|
||||
url: '/new?fileContent&type',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createCustomTemplateView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
fileContent: '',
|
||||
type: '',
|
||||
},
|
||||
};
|
||||
|
||||
const customTemplatesEdit = {
|
||||
name: 'portainer.templates.custom.edit',
|
||||
url: '/:id',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'editCustomTemplateView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(root);
|
||||
$stateRegistryProvider.register(portainer);
|
||||
$stateRegistryProvider.register(about);
|
||||
|
@ -552,5 +589,8 @@ angular.module('portainer.app', []).config([
|
|||
$stateRegistryProvider.register(teams);
|
||||
$stateRegistryProvider.register(team);
|
||||
$stateRegistryProvider.register(templates);
|
||||
$stateRegistryProvider.register(customTemplates);
|
||||
$stateRegistryProvider.register(customTemplatesNew);
|
||||
$stateRegistryProvider.register(customTemplatesEdit);
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -22,6 +22,10 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
|
|||
} else {
|
||||
ctrl.formData.Ownership = resourceControl.Ownership;
|
||||
}
|
||||
|
||||
if (ctrl.formData.Ownership === RCO.PUBLIC) {
|
||||
ctrl.formData.AccessControlEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setAuthorizedUsersAndTeams(authorizedUsers, authorizedTeams) {
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
<ng-form name="commonCustomTemplateForm">
|
||||
<!-- title-input -->
|
||||
<div class="form-group">
|
||||
<label for="template_title" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Title
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.Title" id="template_title" name="template_title" placeholder="e.g. mytemplate" auto-focus required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="commonCustomTemplateForm.template_title.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="commonCustomTemplateForm.template_title.$error">
|
||||
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Title is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !title-input -->
|
||||
|
||||
<!-- description-input -->
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-3 col-lg-2 control-label text-left">Description</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="description" ng-model="$ctrl.formValues.Description" name="description" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="commonCustomTemplateForm.description.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="commonCustomTemplateForm.description.$error">
|
||||
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Description is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !description-input -->
|
||||
|
||||
<!-- note-input -->
|
||||
<div class="form-group">
|
||||
<label for="note" class="col-sm-3 col-lg-2 control-label text-left">Note</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="note" ng-model="$ctrl.formValues.Note" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !note-input -->
|
||||
|
||||
<!-- icon-url-input -->
|
||||
<div class="form-group">
|
||||
<label for="icon-url" class="col-sm-3 col-lg-2 control-label text-left">Icon URL</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="icon-url" ng-model="$ctrl.formValues.Logo" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !icon-url-input -->
|
||||
|
||||
<!-- platform-input -->
|
||||
<div class="form-group">
|
||||
<label for="platform" class="col-sm-3 col-lg-2 control-label text-left">Platform</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<select class="form-control" ng-model="$ctrl.formValues.Platform" ng-options="+(opt.value) as opt.label for opt in $ctrl.platformTypes"> </select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !platform-input -->
|
||||
|
||||
<!-- platform-input -->
|
||||
<div class="form-group">
|
||||
<label for="platform" class="col-sm-3 col-lg-2 control-label text-left">Type</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<select class="form-control" ng-model="$ctrl.formValues.Type" ng-options="+(opt.value) as opt.label for opt in $ctrl.templateTypes"> </select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !platform-input -->
|
||||
</ng-form>
|
|
@ -0,0 +1,16 @@
|
|||
class CustomTemplateCommonFieldsController {
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
this.platformTypes = [
|
||||
{ label: 'Linux', value: 1 },
|
||||
{ label: 'Windows', value: 2 },
|
||||
];
|
||||
|
||||
this.templateTypes = [
|
||||
{ label: 'Swarm', value: 1 },
|
||||
{ label: 'Standalone', value: 2 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export default CustomTemplateCommonFieldsController;
|
|
@ -0,0 +1,9 @@
|
|||
import CustomTemplateCommonFieldsController from './customTemplateCommonFieldsController.js';
|
||||
|
||||
angular.module('portainer.app').component('customTemplateCommonFields', {
|
||||
templateUrl: './customTemplateCommonFields.html',
|
||||
controller: CustomTemplateCommonFieldsController,
|
||||
bindings: {
|
||||
formValues: '=',
|
||||
},
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
|
||||
</div>
|
||||
<div class="actionBar">
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.templates.custom.new">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add Custom Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="searchBar" style="border-top: 2px solid #f6f6f6;">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="blocklist">
|
||||
<template-item
|
||||
ng-repeat="template in $ctrl.templates | filter:$ctrl.state.textFilter"
|
||||
model="template"
|
||||
type-label="{{ template.Type === 1 ? 'swarm' : 'standalone' }}"
|
||||
on-select="($ctrl.onSelectClick)"
|
||||
>
|
||||
<template-item-actions>
|
||||
<div ng-if="$ctrl.isEditAllowed(template)" style="display: flex;">
|
||||
<a ui-sref="portainer.templates.custom.edit({id: template.Id})" ng-click="$event.stopPropagation();" class="btn btn-primary btn-xs" style="margin-right: 10px;">
|
||||
Edit
|
||||
</a>
|
||||
<button class="btn btn-danger btn-xs" ng-click="$ctrl.onDeleteClick(template.Id); $event.stopPropagation();">Delete</button>
|
||||
</div>
|
||||
</template-item-actions>
|
||||
</template-item>
|
||||
<div ng-if="!$ctrl.templates" class="text-center text-muted">
|
||||
Loading...
|
||||
</div>
|
||||
<div ng-if="($ctrl.templates | filter: $ctrl.state.textFilter).length === 0" class="text-center text-muted">
|
||||
No templates available.
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
|
@ -0,0 +1,15 @@
|
|||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.app').component('customTemplatesList', {
|
||||
templateUrl: './customTemplatesList.html',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
templates: '<',
|
||||
tableKey: '@',
|
||||
onSelectClick: '<',
|
||||
showSwarmStacks: '<',
|
||||
onDeleteClick: '<',
|
||||
isEditAllowed: '<',
|
||||
},
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.app').component('stackFromTemplateForm', {
|
||||
templateUrl: './stackFromTemplateForm.html',
|
||||
bindings: {
|
||||
template: '=',
|
||||
formValues: '=',
|
||||
state: '=',
|
||||
createTemplate: '<',
|
||||
unselectTemplate: '<',
|
||||
},
|
||||
transclude: {
|
||||
advanced: '?advancedForm',
|
||||
},
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-custom-header icon="$ctrl.template.Logo" title-text="$ctrl.template.Title"></rd-widget-custom-header>
|
||||
<rd-widget-body classes="padding">
|
||||
<form class="form-horizontal">
|
||||
<!-- description -->
|
||||
<div ng-if="$ctrl.template.Note">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Information
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="template-note" ng-bind-html="$ctrl.template.Note"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !description -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Configuration
|
||||
</div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" name="container_name" class="form-control" ng-model="$ctrl.formValues.name" placeholder="e.g. myStack" required />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- env -->
|
||||
<div ng-repeat="var in $ctrl.template.Env" ng-if="!var.preset || var.select" class="form-group">
|
||||
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
|
||||
{{ var.label }}
|
||||
<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" />
|
||||
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
|
||||
<option selected disabled hidden value="">Select value</option>
|
||||
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !env -->
|
||||
<ng-transclude ng-transclude-slot="advanced"></ng-transclude>
|
||||
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name"
|
||||
ng-click="$ctrl.createTemplate()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.unselectTemplate($ctrl.template)">Hide</button>
|
||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">{{ $ctrl.state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
|
@ -0,0 +1,9 @@
|
|||
.template-item-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-item-details .template-item-details-sub {
|
||||
width: 100%;
|
||||
}
|
|
@ -1,7 +1,15 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import './template-item.css';
|
||||
|
||||
angular.module('portainer.app').component('templateItem', {
|
||||
templateUrl: './templateItem.html',
|
||||
bindings: {
|
||||
model: '=',
|
||||
model: '<',
|
||||
typeLabel: '@',
|
||||
onSelect: '<',
|
||||
},
|
||||
transclude: {
|
||||
actions: '?templateItemActions',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<!-- template -->
|
||||
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item" ng-click="$ctrl.onSelect($ctrl.model)">
|
||||
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item" ng-click="$ctrl.onSelect($ctrl.model)">
|
||||
<div class="blocklist-item-box">
|
||||
<!-- template-image -->
|
||||
<span ng-if="$ctrl.model.Logo">
|
||||
|
@ -10,7 +10,7 @@
|
|||
</span>
|
||||
<!-- !template-image -->
|
||||
<!-- template-details -->
|
||||
<span class="col-sm-12">
|
||||
<div class="col-sm-12 template-item-details">
|
||||
<!-- blocklist-item-line1 -->
|
||||
<div class="blocklist-item-line">
|
||||
<span>
|
||||
|
@ -19,19 +19,20 @@
|
|||
</span>
|
||||
<span class="space-left blocklist-item-subtitle">
|
||||
<span>
|
||||
<i class="fab fa-linux" aria-hidden="true" ng-if="$ctrl.model.Platform === 'linux' || !$ctrl.model.Platform"></i>
|
||||
<i class="fab fa-linux" aria-hidden="true" ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform"></i>
|
||||
<span ng-if="!$ctrl.model.Platform"> & </span>
|
||||
<i class="fab fa-windows" aria-hidden="true" ng-if="$ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"></i>
|
||||
<i class="fab fa-windows" aria-hidden="true" ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"></i>
|
||||
</span>
|
||||
<span>
|
||||
{{ $ctrl.model.Type === 1 ? 'container' : 'stack' }}
|
||||
{{ $ctrl.typeLabel }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- !blocklist-item-line1 -->
|
||||
<span class="blocklist-item-actions" ng-transclude="actions"></span>
|
||||
<!-- blocklist-item-line2 -->
|
||||
<div class="blocklist-item-line">
|
||||
<div class="blocklist-item-line template-item-details-sub">
|
||||
<span class="blocklist-item-desc">
|
||||
{{ $ctrl.model.Description }}
|
||||
</span>
|
||||
|
@ -40,7 +41,7 @@
|
|||
</span>
|
||||
</div>
|
||||
<!-- !blocklist-item-line2 -->
|
||||
</span>
|
||||
</div>
|
||||
<!-- !template-details -->
|
||||
</div>
|
||||
<!-- !template -->
|
||||
|
|
|
@ -1,59 +1,80 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
angular.module('portainer.app').controller('TemplateListController', [
|
||||
'DatatableService',
|
||||
function TemplateListController(DatatableService) {
|
||||
var ctrl = this;
|
||||
angular.module('portainer.app').controller('TemplateListController', TemplateListController);
|
||||
|
||||
this.state = {
|
||||
textFilter: '',
|
||||
selectedCategory: '',
|
||||
categories: [],
|
||||
showContainerTemplates: true,
|
||||
};
|
||||
function TemplateListController($async, $state, DatatableService, Notifications, TemplateService) {
|
||||
var ctrl = this;
|
||||
|
||||
this.onTextFilterChange = function () {
|
||||
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
|
||||
};
|
||||
this.state = {
|
||||
textFilter: '',
|
||||
selectedCategory: '',
|
||||
categories: [],
|
||||
showContainerTemplates: true,
|
||||
};
|
||||
|
||||
this.updateCategories = function () {
|
||||
var availableCategories = [];
|
||||
this.onTextFilterChange = function () {
|
||||
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
|
||||
};
|
||||
|
||||
for (var i = 0; i < ctrl.templates.length; i++) {
|
||||
var template = ctrl.templates[i];
|
||||
if ((template.Type === 1 && ctrl.state.showContainerTemplates) || (template.Type === 2 && ctrl.showSwarmStacks) || (template.Type === 3 && !ctrl.showSwarmStacks)) {
|
||||
availableCategories = availableCategories.concat(template.Categories);
|
||||
}
|
||||
this.updateCategories = function () {
|
||||
var availableCategories = [];
|
||||
|
||||
for (var i = 0; i < ctrl.templates.length; i++) {
|
||||
var template = ctrl.templates[i];
|
||||
if ((template.Type === 1 && ctrl.state.showContainerTemplates) || (template.Type === 2 && ctrl.showSwarmStacks) || (template.Type === 3 && !ctrl.showSwarmStacks)) {
|
||||
availableCategories = availableCategories.concat(template.Categories);
|
||||
}
|
||||
}
|
||||
|
||||
this.state.categories = _.sortBy(_.uniq(availableCategories));
|
||||
};
|
||||
this.state.categories = _.sortBy(_.uniq(availableCategories));
|
||||
};
|
||||
|
||||
this.filterByCategory = function (item) {
|
||||
if (!ctrl.state.selectedCategory) {
|
||||
return true;
|
||||
this.filterByCategory = function (item) {
|
||||
if (!ctrl.state.selectedCategory) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return _.includes(item.Categories, ctrl.state.selectedCategory);
|
||||
};
|
||||
|
||||
this.filterByType = function (item) {
|
||||
if ((item.Type === 1 && ctrl.state.showContainerTemplates) || (item.Type === 2 && ctrl.showSwarmStacks) || (item.Type === 3 && !ctrl.showSwarmStacks)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
this.duplicateTemplate = duplicateTemplate.bind(this);
|
||||
this.duplicateTemplateAsync = duplicateTemplateAsync.bind(this);
|
||||
function duplicateTemplate(template) {
|
||||
return $async(this.duplicateTemplateAsync, template);
|
||||
}
|
||||
|
||||
async function duplicateTemplateAsync(template) {
|
||||
try {
|
||||
const { FileContent: fileContent } = await TemplateService.templateFile(template.Repository.url, template.Repository.stackfile);
|
||||
let type = 0;
|
||||
if (template.Type === 2) {
|
||||
type = 1;
|
||||
}
|
||||
|
||||
return _.includes(item.Categories, ctrl.state.selectedCategory);
|
||||
};
|
||||
|
||||
this.filterByType = function (item) {
|
||||
if ((item.Type === 1 && ctrl.state.showContainerTemplates) || (item.Type === 2 && ctrl.showSwarmStacks) || (item.Type === 3 && !ctrl.showSwarmStacks)) {
|
||||
return true;
|
||||
if (template.Type === 3) {
|
||||
type = 2;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
$state.go('portainer.templates.custom.new', { fileContent, type });
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Failed to duplicate template');
|
||||
}
|
||||
}
|
||||
|
||||
this.$onInit = function () {
|
||||
if (this.showSwarmStacks) {
|
||||
this.state.showContainerTemplates = false;
|
||||
}
|
||||
this.updateCategories();
|
||||
this.$onInit = function () {
|
||||
if (this.showSwarmStacks) {
|
||||
this.state.showContainerTemplates = false;
|
||||
}
|
||||
this.updateCategories();
|
||||
|
||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
||||
if (textFilter !== null) {
|
||||
this.state.textFilter = textFilter;
|
||||
}
|
||||
};
|
||||
},
|
||||
]);
|
||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
||||
if (textFilter !== null) {
|
||||
this.state.textFilter = textFilter;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -49,8 +49,15 @@
|
|||
<template-item
|
||||
ng-repeat="template in $ctrl.templates | filter: $ctrl.filterByType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter"
|
||||
model="template"
|
||||
type-label="{{ template.Type === 1 ? 'container' : 'stack' }}"
|
||||
on-select="($ctrl.selectAction)"
|
||||
></template-item>
|
||||
>
|
||||
<template-item-actions ng-if="template.Type === 2 || template.Type === 3">
|
||||
<button ng-click="$event.stopPropagation(); $ctrl.duplicateTemplate(template)" class="btn btn-primary btn-xs">
|
||||
Copy as Custom
|
||||
</button>
|
||||
</template-item-actions>
|
||||
</template-item>
|
||||
<div ng-if="!$ctrl.templates" class="text-center text-muted">
|
||||
Loading...
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from './resourceControlOwnership';
|
||||
|
||||
export function ResourceControlViewModel(data) {
|
||||
this.Id = data.Id;
|
||||
|
|
|
@ -6,6 +6,7 @@ export const ResourceControlTypeString = Object.freeze({
|
|||
SERVICE: 'service',
|
||||
STACK: 'stack',
|
||||
VOLUME: 'volume',
|
||||
CUSTOM_TEMPLATE: 'custom-template',
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -19,4 +20,5 @@ export const ResourceControlTypeInt = Object.freeze({
|
|||
SECRET: 5,
|
||||
STACK: 6,
|
||||
CONFIG: 7,
|
||||
CUSTOM_TEMPLATE: 8,
|
||||
});
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.app').factory('CustomTemplates', CustomTemplatesFactory);
|
||||
|
||||
function CustomTemplatesFactory($resource, API_ENDPOINT_CUSTOM_TEMPLATES) {
|
||||
return $resource(
|
||||
API_ENDPOINT_CUSTOM_TEMPLATES + '/:id/:action',
|
||||
{},
|
||||
{
|
||||
create: { method: 'POST', ignoreLoadingBar: true },
|
||||
query: { method: 'GET', isArray: true },
|
||||
get: { method: 'GET', params: { id: '@id' } },
|
||||
update: { method: 'PUT', params: { id: '@id' } },
|
||||
remove: { method: 'DELETE', params: { id: '@id' } },
|
||||
file: { method: 'GET', params: { id: '@id', action: 'file' } },
|
||||
}
|
||||
);
|
||||
}
|
|
@ -3,10 +3,11 @@ angular.module('portainer.app').factory('Templates', [
|
|||
'API_ENDPOINT_TEMPLATES',
|
||||
function TemplatesFactory($resource, API_ENDPOINT_TEMPLATES) {
|
||||
return $resource(
|
||||
API_ENDPOINT_TEMPLATES + '/:id',
|
||||
API_ENDPOINT_TEMPLATES + '/:action',
|
||||
{},
|
||||
{
|
||||
query: { method: 'GET' },
|
||||
file: { method: 'POST', params: { action: 'file' } },
|
||||
}
|
||||
);
|
||||
},
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.app').factory('CustomTemplateService', CustomTemplateServiceFactory);
|
||||
|
||||
/* @ngInject */
|
||||
function CustomTemplateServiceFactory(CustomTemplates, FileUploadService) {
|
||||
var service = {};
|
||||
|
||||
service.customTemplate = function customTemplate(id) {
|
||||
return CustomTemplates.get({ id }).$promise;
|
||||
};
|
||||
|
||||
service.customTemplates = function customTemplates(type) {
|
||||
return CustomTemplates.query({ type }).$promise;
|
||||
};
|
||||
|
||||
service.remove = function remove(id) {
|
||||
return CustomTemplates.remove({ id }).$promise;
|
||||
};
|
||||
|
||||
service.customTemplateFile = async function customTemplateFile(id) {
|
||||
try {
|
||||
const { FileContent } = await CustomTemplates.file({ id }).$promise;
|
||||
return FileContent;
|
||||
} catch (err) {
|
||||
throw { msg: 'Unable to retrieve customTemplate content', err };
|
||||
}
|
||||
};
|
||||
|
||||
service.updateCustomTemplate = async function updateCustomTemplate(id, customTemplate) {
|
||||
return CustomTemplates.update({ id }, customTemplate).$promise;
|
||||
};
|
||||
|
||||
service.createCustomTemplateFromFileContent = async function createCustomTemplateFromFileContent(payload) {
|
||||
try {
|
||||
return await CustomTemplates.create({ method: 'string' }, payload).$promise;
|
||||
} catch (err) {
|
||||
throw { msg: 'Unable to create the customTemplate', err };
|
||||
}
|
||||
};
|
||||
|
||||
service.createCustomTemplateFromFileUpload = async function createCustomTemplateFromFileUpload(payload) {
|
||||
try {
|
||||
return await FileUploadService.createCustomTemplate(payload);
|
||||
} catch (err) {
|
||||
throw { msg: 'Unable to create the customTemplate', err };
|
||||
}
|
||||
};
|
||||
|
||||
service.createCustomTemplateFromGitRepository = async function createCustomTemplateFromGitRepository(payload) {
|
||||
try {
|
||||
return await CustomTemplates.create({ method: 'repository' }, payload).$promise;
|
||||
} catch (err) {
|
||||
throw { msg: 'Unable to create the customTemplate', err };
|
||||
}
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
|
@ -43,6 +43,11 @@ angular.module('portainer.app').factory('TemplateService', [
|
|||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.templateFile = templateFile;
|
||||
function templateFile(repositoryUrl, composeFilePathInRepository) {
|
||||
return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise;
|
||||
}
|
||||
|
||||
service.createTemplateConfiguration = function (template, containerName, network) {
|
||||
var imageConfiguration = ImageHelper.createImageConfigForContainer(template.RegistryModel);
|
||||
var containerConfiguration = createContainerConfiguration(template, containerName, network);
|
||||
|
|
|
@ -97,6 +97,14 @@ angular.module('portainer.app').factory('FileUploadService', [
|
|||
});
|
||||
};
|
||||
|
||||
service.createCustomTemplate = function createCustomTemplate(data) {
|
||||
return Upload.upload({
|
||||
url: 'api/custom_templates?method=file',
|
||||
data,
|
||||
ignoreLoadingBar: true,
|
||||
});
|
||||
};
|
||||
|
||||
service.configureRegistry = function (registryId, registryManagementConfigurationModel) {
|
||||
return Upload.upload({
|
||||
url: 'api/registries/' + registryId + '/configure',
|
||||
|
|
|
@ -143,6 +143,12 @@ angular.module('portainer.app').factory('ModalService', [
|
|||
});
|
||||
};
|
||||
|
||||
service.confirmDeletionAsync = function confirmDeletionAsync(message) {
|
||||
return new Promise((resolve) => {
|
||||
service.confirmDeletion(message, (confirmed) => resolve(confirmed));
|
||||
});
|
||||
};
|
||||
|
||||
service.confirmContainerDeletion = function (title, callback) {
|
||||
title = $sanitize(title);
|
||||
prompt({
|
||||
|
|
|
@ -0,0 +1,217 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Create Custom template"></rd-header-title>
|
||||
<rd-header-content> <a ui-sref="portainer.templates.custom">Custom Templates</a> > Create Custom template </rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="!$ctrl.state.loading">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="customTemplateForm">
|
||||
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
|
||||
|
||||
<!-- build-method -->
|
||||
<div ng-if="!$ctrl.state.fromStack">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Build method
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="$ctrl.state.Method" value="editor" ng-change="$ctrl.onChangeMethod()" />
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_upload" ng-model="$ctrl.state.Method" value="upload" ng-change="$ctrl.onChangeMethod()" />
|
||||
<label for="method_upload">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Upload
|
||||
</div>
|
||||
<p>Upload from your computer</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_repository" ng-model="$ctrl.state.Method" value="repository" ng-change="$ctrl.onChangeMethod()" />
|
||||
<label for="method_repository">
|
||||
<div class="boxselector_header">
|
||||
<i class="fab fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Repository
|
||||
</div>
|
||||
<p>Use a git repository</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !build-method -->
|
||||
<!-- web-editor -->
|
||||
<div ng-show="$ctrl.state.Method === 'editor'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Web editor
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can get more information about Compose file format in the
|
||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<code-editor
|
||||
identifier="custom-template-creation-editor"
|
||||
placeholder="# Define or paste the content of your docker-compose file here"
|
||||
yml="true"
|
||||
value="$ctrl.formValues.FileContent"
|
||||
on-change="($ctrl.editorUpdate)"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
<!-- upload -->
|
||||
<div ng-show="$ctrl.state.Method === 'upload'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Upload
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can upload a Compose file from your computer.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.File">
|
||||
Select file
|
||||
</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ $ctrl.formValues.File.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.File" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !upload -->
|
||||
<!-- repository -->
|
||||
<div ng-show="$ctrl.state.Method === 'repository'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Git repository
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can use the URL of a git repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="template_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.RepositoryURL"
|
||||
id="template_repository_url"
|
||||
placeholder="https://github.com/portainer/portainer-compose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Specify a reference of the repository using the following syntax: branches with
|
||||
<code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not specified, will use the default <code>HEAD</code> reference normally the
|
||||
<code>master</code> branch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="template_repository_url" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.RepositoryReferenceName"
|
||||
id="template_repository_reference_name"
|
||||
placeholder="refs/heads/master"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the Compose file from the root of your repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="template_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.ComposeFilePathInRepository" id="template_repository_path" placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Authentication
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.formValues.RepositoryAuthentication" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
If your git account has 2FA enabled, you may receive an
|
||||
<code>authentication required</code> error when creating your template. In this case, you will need to provide a personal-access token instead of your password.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
|
||||
</div>
|
||||
<label for="repository_password" class="col-sm-1 control-label text-left">
|
||||
Password
|
||||
</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="password" class="form-control" ng-model="$ctrl.formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !repository -->
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="$ctrl.state.actionInProgress || customTemplateForm.$invalid
|
||||
|| ($ctrl.state.Method === 'editor' && !$ctrl.formValues.FileContent)
|
||||
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.File)
|
||||
|| ($ctrl.state.Method === 'repository' && ((!$ctrl.formValues.RepositoryURL || !$ctrl.formValues.ComposeFilePathInRepository) || ($ctrl.formValues.RepositoryAuthentication && (!$ctrl.formValues.RepositoryUsername || !$ctrl.formValues.RepositoryPassword))))
|
||||
|| !$ctrl.formValues.Title"
|
||||
ng-click="$ctrl.createCustomTemplate()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Create custom template</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
||||
{{ $ctrl.state.formValidationError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,152 @@
|
|||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
|
||||
class CreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) {
|
||||
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager });
|
||||
|
||||
this.formValues = {
|
||||
Title: '',
|
||||
FileContent: '',
|
||||
File: null,
|
||||
RepositoryURL: '',
|
||||
RepositoryReferenceName: '',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
Description: '',
|
||||
Note: '',
|
||||
Platform: 1,
|
||||
Type: 1,
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
};
|
||||
|
||||
this.state = {
|
||||
Method: 'editor',
|
||||
formValidationError: '',
|
||||
actionInProgress: false,
|
||||
fromStack: false,
|
||||
loading: true,
|
||||
};
|
||||
|
||||
this.createCustomTemplate = this.createCustomTemplate.bind(this);
|
||||
this.createCustomTemplateAsync = this.createCustomTemplateAsync.bind(this);
|
||||
this.validateForm = this.validateForm.bind(this);
|
||||
this.createCustomTemplateByMethod = this.createCustomTemplateByMethod.bind(this);
|
||||
this.createCustomTemplateFromFileContent = this.createCustomTemplateFromFileContent.bind(this);
|
||||
this.createCustomTemplateFromFileUpload = this.createCustomTemplateFromFileUpload.bind(this);
|
||||
this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this);
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
}
|
||||
|
||||
createCustomTemplate() {
|
||||
return this.$async(this.createCustomTemplateAsync);
|
||||
}
|
||||
|
||||
onChangeMethod() {
|
||||
this.formValues.FileContent = '';
|
||||
this.selectedTemplate = null;
|
||||
}
|
||||
|
||||
async createCustomTemplateAsync() {
|
||||
let method = this.state.Method;
|
||||
|
||||
if (method === 'template') {
|
||||
method = 'editor';
|
||||
}
|
||||
|
||||
if (!this.validateForm(method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
const { ResourceControl } = await this.createCustomTemplateByMethod(method);
|
||||
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const userDetails = this.Authentication.getUserDetails();
|
||||
const userId = userDetails.ID;
|
||||
await this.ResourceControlService.applyResourceControl(userId, accessControlData, ResourceControl);
|
||||
|
||||
this.Notifications.success('Custom template successfully created');
|
||||
this.$state.go('portainer.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Deployment error', err, 'Unable to create custom template');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
validateForm(method) {
|
||||
this.state.formValidationError = '';
|
||||
|
||||
if (method === 'editor' && this.formValues.FileContent === '') {
|
||||
this.state.formValidationError = 'Template file content must not be empty';
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdmin = this.Authentication.isAdmin();
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createCustomTemplateByMethod(method) {
|
||||
switch (method) {
|
||||
case 'editor':
|
||||
return this.createCustomTemplateFromFileContent();
|
||||
case 'upload':
|
||||
return this.createCustomTemplateFromFileUpload();
|
||||
case 'repository':
|
||||
return this.createCustomTemplateFromGitRepository();
|
||||
}
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileContent() {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileContent(this.formValues);
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileUpload() {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileUpload(this.formValues);
|
||||
}
|
||||
|
||||
createCustomTemplateFromGitRepository() {
|
||||
return this.CustomTemplateService.createCustomTemplateFromGitRepository(this.formValues);
|
||||
}
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.FileContent = cm.getValue();
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
const applicationState = this.StateManager.getState();
|
||||
|
||||
this.state.endpointMode = applicationState.endpoint.mode;
|
||||
let stackType = 0;
|
||||
if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') {
|
||||
stackType = 2;
|
||||
} else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') {
|
||||
stackType = 1;
|
||||
}
|
||||
this.formValues.Type = stackType;
|
||||
|
||||
const { fileContent, type } = this.$state.params;
|
||||
|
||||
this.formValues.FileContent = fileContent;
|
||||
if (type) {
|
||||
this.formValues.Type = +type;
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default CreateCustomTemplateViewController;
|
|
@ -0,0 +1,6 @@
|
|||
import CreateCustomTemplateViewController from './createCustomTemplateViewController.js';
|
||||
|
||||
angular.module('portainer.app').component('createCustomTemplateView', {
|
||||
templateUrl: './createCustomTemplateView.html',
|
||||
controller: CreateCustomTemplateViewController,
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
<rd-header id="view-top">
|
||||
<rd-header-title title-text="Custom Templates">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.templates.custom" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Custom Templates</rd-header-content>
|
||||
</rd-header>
|
||||
<div class="row">
|
||||
<stack-from-template-form
|
||||
ng-if="$ctrl.state.selectedTemplate"
|
||||
template="$ctrl.state.selectedTemplate"
|
||||
form-values="$ctrl.formValues"
|
||||
state="$ctrl.state"
|
||||
create-template="$ctrl.createStack"
|
||||
unselect-template="$ctrl.unselectTemplate"
|
||||
>
|
||||
<advanced-form>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<a class="small interactive" ng-show="!$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = true;">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i> Customize stack
|
||||
</a>
|
||||
<a class="small interactive" ng-show="$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = false;">
|
||||
<i class="fa fa-minus space-right" aria-hidden="true"></i> Hide custom stack
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="$ctrl.state.showAdvancedOptions">
|
||||
<!-- web-editor -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Web editor
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can get more information about Compose file format in the
|
||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<code-editor
|
||||
identifier="custom-template-creation-editor"
|
||||
placeholder="# Define or paste the content of your docker-compose file here"
|
||||
yml="true"
|
||||
value="$ctrl.formValues.fileContent"
|
||||
on-change="($ctrl.editorUpdate)"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
</div>
|
||||
</advanced-form>
|
||||
</stack-from-template-form>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<custom-templates-list
|
||||
ng-if="$ctrl.templates"
|
||||
title-text="Templates"
|
||||
title-icon="fa-rocket"
|
||||
templates="$ctrl.templates"
|
||||
table-key="templates"
|
||||
is-edit-allowed="$ctrl.isEditAllowed"
|
||||
on-select-click="($ctrl.selectTemplate)"
|
||||
on-delete-click="($ctrl.confirmDelete)"
|
||||
></custom-templates-list>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,243 @@
|
|||
import _ from 'lodash-es';
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
|
||||
class CustomTemplatesViewController {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$anchorScroll,
|
||||
$async,
|
||||
$rootScope,
|
||||
$state,
|
||||
Authentication,
|
||||
CustomTemplateService,
|
||||
EndpointProvider,
|
||||
FormValidator,
|
||||
ModalService,
|
||||
NetworkService,
|
||||
Notifications,
|
||||
ResourceControlService,
|
||||
StackService,
|
||||
StateManager
|
||||
) {
|
||||
this.$anchorScroll = $anchorScroll;
|
||||
this.$async = $async;
|
||||
this.$rootScope = $rootScope;
|
||||
this.$state = $state;
|
||||
this.Authentication = Authentication;
|
||||
this.CustomTemplateService = CustomTemplateService;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.FormValidator = FormValidator;
|
||||
this.ModalService = ModalService;
|
||||
this.NetworkService = NetworkService;
|
||||
this.Notifications = Notifications;
|
||||
this.ResourceControlService = ResourceControlService;
|
||||
this.StateManager = StateManager;
|
||||
this.StackService = StackService;
|
||||
|
||||
this.state = {
|
||||
selectedTemplate: null,
|
||||
showAdvancedOptions: false,
|
||||
formValidationError: '',
|
||||
actionInProgress: false,
|
||||
isEditorVisible: false,
|
||||
};
|
||||
|
||||
this.currentUser = {
|
||||
isAdmin: false,
|
||||
id: null,
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
network: '',
|
||||
name: '',
|
||||
fileContent: '',
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
};
|
||||
|
||||
this.getTemplates = this.getTemplates.bind(this);
|
||||
this.getTemplatesAsync = this.getTemplatesAsync.bind(this);
|
||||
this.removeTemplates = this.removeTemplates.bind(this);
|
||||
this.removeTemplatesAsync = this.removeTemplatesAsync.bind(this);
|
||||
this.validateForm = this.validateForm.bind(this);
|
||||
this.createStack = this.createStack.bind(this);
|
||||
this.createStackAsync = this.createStackAsync.bind(this);
|
||||
this.selectTemplate = this.selectTemplate.bind(this);
|
||||
this.selectTemplateAsync = this.selectTemplateAsync.bind(this);
|
||||
this.unselectTemplate = this.unselectTemplate.bind(this);
|
||||
this.unselectTemplateAsync = this.unselectTemplateAsync.bind(this);
|
||||
this.getNetworks = this.getNetworks.bind(this);
|
||||
this.getNetworksAsync = this.getNetworksAsync.bind(this);
|
||||
this.confirmDelete = this.confirmDelete.bind(this);
|
||||
this.confirmDeleteAsync = this.confirmDeleteAsync.bind(this);
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.isEditAllowed = this.isEditAllowed.bind(this);
|
||||
}
|
||||
|
||||
isEditAllowed(template) {
|
||||
return this.currentUser.isAdmin || this.currentUser.id === template.CreatedByUserId;
|
||||
}
|
||||
|
||||
getTemplates(endpointMode) {
|
||||
return this.$async(this.getTemplatesAsync, endpointMode);
|
||||
}
|
||||
async getTemplatesAsync({ provider, role }) {
|
||||
try {
|
||||
let stackType = 2;
|
||||
if (provider === 'DOCKER_SWARM_MODE' && role === 'MANAGER') {
|
||||
stackType = 1;
|
||||
}
|
||||
this.templates = await this.CustomTemplateService.customTemplates(stackType);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
|
||||
}
|
||||
}
|
||||
|
||||
removeTemplates(templates) {
|
||||
return this.$async(this.removeTemplatesAsync, templates);
|
||||
}
|
||||
async removeTemplatesAsync(templates) {
|
||||
for (let template of templates) {
|
||||
try {
|
||||
await this.CustomTemplateService.remove(template.id);
|
||||
this.Notifications.success('Removed template successfully');
|
||||
_.remove(this.templates, template);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failed removing template', err, 'Unable to remove custom template');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateForm(accessControlData, isAdmin) {
|
||||
this.state.formValidationError = '';
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
createStack() {
|
||||
return this.$async(this.createStackAsync);
|
||||
}
|
||||
async createStackAsync() {
|
||||
const userId = this.currentUser.id;
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
|
||||
if (!this.validateForm(accessControlData, this.currentUser.isAdmin)) {
|
||||
return;
|
||||
}
|
||||
const stackName = this.formValues.name;
|
||||
|
||||
const endpointId = this.EndpointProvider.endpointID();
|
||||
|
||||
this.state.actionInProgress = true;
|
||||
|
||||
try {
|
||||
const file = this.formValues.fileContent;
|
||||
const createAction = this.state.selectedTemplate.Type === 1 ? this.StackService.createSwarmStackFromFileContent : this.StackService.createComposeStackFromFileContent;
|
||||
const { ResourceControl: resourceControl } = await createAction(stackName, file, [], endpointId);
|
||||
await this.ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
|
||||
this.Notifications.success('Stack successfully deployed');
|
||||
this.$state.go('portainer.stacks');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Deployment error', err, 'Failed to deploy stack');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
unselectTemplate(template) {
|
||||
// wrapping unselect with async to make a digest cycle run between unselect to select
|
||||
return this.$async(this.unselectTemplateAsync, template);
|
||||
}
|
||||
async unselectTemplateAsync(template) {
|
||||
template.Selected = false;
|
||||
this.state.selectedTemplate = null;
|
||||
|
||||
this.formValues = {
|
||||
network: '',
|
||||
name: '',
|
||||
fileContent: '',
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
};
|
||||
}
|
||||
|
||||
selectTemplate(template) {
|
||||
return this.$async(this.selectTemplateAsync, template);
|
||||
}
|
||||
async selectTemplateAsync(template) {
|
||||
if (this.state.selectedTemplate) {
|
||||
await this.unselectTemplate(this.state.selectedTemplate);
|
||||
}
|
||||
|
||||
template.Selected = true;
|
||||
|
||||
this.formValues.network = _.find(this.availableNetworks, function (o) {
|
||||
return o.Name === 'bridge';
|
||||
});
|
||||
|
||||
this.formValues.name = template.Title ? template.Title : '';
|
||||
this.state.selectedTemplate = template;
|
||||
this.$anchorScroll('view-top');
|
||||
|
||||
const file = await this.CustomTemplateService.customTemplateFile(template.Id);
|
||||
this.formValues.fileContent = file;
|
||||
}
|
||||
|
||||
getNetworks(provider, apiVersion) {
|
||||
return this.$async(this.getNetworksAsync, provider, apiVersion);
|
||||
}
|
||||
async getNetworksAsync(provider, apiVersion) {
|
||||
try {
|
||||
const networks = await this.NetworkService.networks(
|
||||
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
|
||||
false,
|
||||
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
|
||||
);
|
||||
this.availableNetworks = networks;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Failed to load networks.');
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(templateId) {
|
||||
return this.$async(this.confirmDeleteAsync, templateId);
|
||||
}
|
||||
async confirmDeleteAsync(templateId) {
|
||||
const confirmed = await this.ModalService.confirmDeletionAsync('Are you sure that you want to delete this template?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.CustomTemplateService.remove(templateId);
|
||||
_.remove(this.templates, { Id: templateId });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Failed to delete template');
|
||||
}
|
||||
}
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.fileContent = cm.getValue();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
const applicationState = this.StateManager.getState();
|
||||
|
||||
const {
|
||||
endpoint: { mode: endpointMode },
|
||||
apiVersion,
|
||||
} = applicationState;
|
||||
|
||||
this.getTemplates(endpointMode);
|
||||
this.getNetworks(endpointMode.provider, apiVersion);
|
||||
|
||||
this.currentUser.isAdmin = this.Authentication.isAdmin();
|
||||
const user = this.Authentication.getUserDetails();
|
||||
this.currentUser.id = user.ID;
|
||||
}
|
||||
}
|
||||
|
||||
export default CustomTemplatesViewController;
|
|
@ -0,0 +1,6 @@
|
|||
import CustomTemplatesViewController from './customTemplatesViewController.js';
|
||||
|
||||
angular.module('portainer.app').component('customTemplatesView', {
|
||||
templateUrl: './customTemplatesView.html',
|
||||
controller: CustomTemplatesViewController,
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Edit Custom Template">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.templates.custom.edit({id:$ctrl.formValues.Id})" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content> <a ui-sref="portainer.templates.custom">Custom templates</a> > {{ $ctrl.formValues.Title }} </rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="$ctrl.formValues">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="customTemplateForm">
|
||||
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
|
||||
|
||||
<!-- web-editor -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Web editor
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can get more information about Compose file format in the
|
||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<code-editor
|
||||
identifier="custom-template-creation-editor"
|
||||
placeholder="# Define or paste the content of your docker-compose file here"
|
||||
yml="true"
|
||||
value="$ctrl.formValues.FileContent"
|
||||
on-change="($ctrl.editorUpdate)"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form>
|
||||
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="$ctrl.actionInProgress || customTemplateForm.$invalid
|
||||
|| !$ctrl.formValues.Title
|
||||
|| !$ctrl.formValues.FileContent"
|
||||
ng-click="$ctrl.submitAction()"
|
||||
button-spinner="$ctrl.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.actionInProgress">Update the template</span>
|
||||
<span ng-show="$ctrl.actionInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
||||
{{ $ctrl.state.formValidationError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,93 @@
|
|||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
|
||||
class EditCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
|
||||
|
||||
this.formValues = null;
|
||||
this.state = {
|
||||
formValidationError: '',
|
||||
};
|
||||
|
||||
this.getTemplate = this.getTemplate.bind(this);
|
||||
this.getTemplateAsync = this.getTemplateAsync.bind(this);
|
||||
this.submitAction = this.submitAction.bind(this);
|
||||
this.submitActionAsync = this.submitActionAsync.bind(this);
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
}
|
||||
|
||||
getTemplate() {
|
||||
return this.$async(this.getTemplateAsync);
|
||||
}
|
||||
async getTemplateAsync() {
|
||||
try {
|
||||
const [template, file] = await Promise.all([
|
||||
this.CustomTemplateService.customTemplate(this.$state.params.id),
|
||||
this.CustomTemplateService.customTemplateFile(this.$state.params.id),
|
||||
]);
|
||||
template.FileContent = file;
|
||||
this.formValues = template;
|
||||
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
|
||||
this.formValues.AccessControlData = new AccessControlFormData();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve custom template data');
|
||||
}
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
this.state.formValidationError = '';
|
||||
|
||||
if (!this.formValues.FileContent) {
|
||||
this.state.formValidationError = 'Template file content must not be empty';
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdmin = this.Authentication.isAdmin();
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
submitAction() {
|
||||
return this.$async(this.submitActionAsync);
|
||||
}
|
||||
async submitActionAsync() {
|
||||
if (!this.validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionInProgress = true;
|
||||
try {
|
||||
await this.CustomTemplateService.updateCustomTemplate(this.formValues.Id, this.formValues);
|
||||
|
||||
const userDetails = this.Authentication.getUserDetails();
|
||||
const userId = userDetails.ID;
|
||||
await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl);
|
||||
|
||||
this.Notifications.success('Custom template successfully updated');
|
||||
this.$state.go('portainer.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to update custom template');
|
||||
} finally {
|
||||
this.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.fileContent = cm.getValue();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.getTemplate();
|
||||
}
|
||||
}
|
||||
|
||||
export default EditCustomTemplateViewController;
|
|
@ -0,0 +1,6 @@
|
|||
import EditCustomTemplateViewController from './editCustomTemplateViewController.js';
|
||||
|
||||
angular.module('portainer.app').component('editCustomTemplateView', {
|
||||
templateUrl: './editCustomTemplateView.html',
|
||||
controller: EditCustomTemplateViewController,
|
||||
});
|
|
@ -18,6 +18,8 @@
|
|||
<azure-sidebar-content ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider === 'AZURE'"> </azure-sidebar-content>
|
||||
<docker-sidebar-content
|
||||
ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider !== 'AZURE' && applicationState.endpoint.mode.provider !== 'KUBERNETES'"
|
||||
current-route-name="$state.current.name"
|
||||
toggle="toggle"
|
||||
endpoint-api-version="applicationState.endpoint.apiVersion"
|
||||
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
|
||||
standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel';
|
||||
|
||||
angular.module('portainer.app').controller('CreateStackController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
'StackService',
|
||||
'Authentication',
|
||||
'Notifications',
|
||||
'FormValidator',
|
||||
'ResourceControlService',
|
||||
'FormHelper',
|
||||
'EndpointProvider',
|
||||
function ($scope, $state, StackService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper, EndpointProvider) {
|
||||
angular
|
||||
.module('portainer.app')
|
||||
.controller('CreateStackController', function (
|
||||
$scope,
|
||||
$state,
|
||||
StackService,
|
||||
Authentication,
|
||||
Notifications,
|
||||
FormValidator,
|
||||
ResourceControlService,
|
||||
FormHelper,
|
||||
EndpointProvider,
|
||||
CustomTemplateService
|
||||
) {
|
||||
$scope.formValues = {
|
||||
Name: '',
|
||||
StackFileContent: '',
|
||||
|
@ -56,13 +62,17 @@ angular.module('portainer.app').controller('CreateStackController', [
|
|||
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
|
||||
if (method === 'editor') {
|
||||
if (method === 'template' || method === 'editor') {
|
||||
var stackFileContent = $scope.formValues.StackFileContent;
|
||||
return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId);
|
||||
} else if (method === 'upload') {
|
||||
}
|
||||
|
||||
if (method === 'upload') {
|
||||
var stackFile = $scope.formValues.StackFile;
|
||||
return StackService.createSwarmStackFromFileUpload(name, stackFile, env, endpointId);
|
||||
} else if (method === 'repository') {
|
||||
}
|
||||
|
||||
if (method === 'repository') {
|
||||
var repositoryOptions = {
|
||||
RepositoryURL: $scope.formValues.RepositoryURL,
|
||||
RepositoryReferenceName: $scope.formValues.RepositoryReferenceName,
|
||||
|
@ -79,7 +89,7 @@ angular.module('portainer.app').controller('CreateStackController', [
|
|||
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
|
||||
if (method === 'editor') {
|
||||
if (method === 'editor' || method === 'template') {
|
||||
var stackFileContent = $scope.formValues.StackFileContent;
|
||||
return StackService.createComposeStackFromFileContent(name, stackFileContent, env, endpointId);
|
||||
} else if (method === 'upload') {
|
||||
|
@ -146,14 +156,29 @@ angular.module('portainer.app').controller('CreateStackController', [
|
|||
$scope.formValues.StackFileContent = cm.getValue();
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$scope.onChangeTemplate = async function onChangeTemplate(template) {
|
||||
try {
|
||||
$scope.selectedTemplate = template;
|
||||
$scope.formValues.StackFileContent = await CustomTemplateService.customTemplateFile(template.Id);
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve Custom Template file');
|
||||
}
|
||||
};
|
||||
|
||||
async function initView() {
|
||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
||||
$scope.state.StackType = 2;
|
||||
if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER') {
|
||||
$scope.state.StackType = 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const templates = await CustomTemplateService.customTemplates($scope.state.StackType);
|
||||
$scope.templates = _.map(templates, (template) => ({ ...template, label: `${template.Title} - ${template.Description}` }));
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve Custom Templates');
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -63,6 +63,16 @@
|
|||
<p>Use a git repository</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_template" ng-model="state.Method" value="template" />
|
||||
<label for="method_template">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-rocket" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Custom template
|
||||
</div>
|
||||
<p>Use a custom template</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !build-method -->
|
||||
|
@ -181,6 +191,59 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !repository -->
|
||||
<!-- custom-template -->
|
||||
<div ng-show="state.Method === 'template'">
|
||||
<div class="form-group">
|
||||
<label for="stack_template" class="col-sm-1 control-label text-left">
|
||||
Template
|
||||
</label>
|
||||
<div class="col-sm-11">
|
||||
<select
|
||||
ng-if="templates.length"
|
||||
class="form-control"
|
||||
ng-model="selectedTemplate"
|
||||
ng-options="template as template.label for template in templates"
|
||||
ng-change="onChangeTemplate(selectedTemplate)"
|
||||
>
|
||||
<option value="" label="Select a Custom template" disabled selected="selected"> </option>
|
||||
</select>
|
||||
<span ng-if="!templates.length">
|
||||
No custom template are available. Head over the <a ui-sref="portainer.templates.custom.new">custom template view</a> to create one.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- description -->
|
||||
<div ng-if="selectedTemplate.note">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Information
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="template-note" ng-bind-html="selectedTemplate.note"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !description -->
|
||||
<!-- editor -->
|
||||
<div ng-if="selectedTemplate && formValues.StackFileContent">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Web editor
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<code-editor
|
||||
identifier="template-content-editor"
|
||||
placeholder="# Define or paste the content of your docker-compose file here"
|
||||
yml="true"
|
||||
value="formValues.StackFileContent"
|
||||
on-change="(editorUpdate)"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !custom-template -->
|
||||
<!-- environment-variables -->
|
||||
<div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
|
@ -213,7 +276,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- !environment-variables -->
|
||||
<!-- !repository -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
|
|
|
@ -38,9 +38,19 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
{{ stackName }}
|
||||
<button authorization="PortainerStackDelete" class="btn btn-xs btn-danger" ng-click="removeStack()" ng-if="!state.externalStack || stack.Type === 1"
|
||||
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this stack</button
|
||||
|
||||
<a
|
||||
ng-if="!state.externalStack && stackFileContent"
|
||||
class="btn btn-primary btn-xs"
|
||||
ui-sref="portainer.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
|
||||
>
|
||||
Create template from stack
|
||||
</a>
|
||||
|
||||
<button authorization="PortainerStackDelete" class="btn btn-xs btn-danger" ng-click="removeStack()" ng-if="!state.externalStack || stack.Type === 1">
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>
|
||||
Delete this stack
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !stack-details -->
|
||||
|
|
|
@ -9,77 +9,15 @@
|
|||
|
||||
<div class="row">
|
||||
<!-- stack-form -->
|
||||
<div class="col-sm-12" ng-if="state.selectedTemplate && (state.selectedTemplate.Type === 2 || state.selectedTemplate.Type === 3)">
|
||||
<rd-widget>
|
||||
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title-text="state.selectedTemplate.Title"></rd-widget-custom-header>
|
||||
<rd-widget-body classes="padding">
|
||||
<form class="form-horizontal">
|
||||
<!-- description -->
|
||||
<div ng-if="state.selectedTemplate.Note">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Information
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="template-note" ng-if="state.selectedTemplate.Note" ng-bind-html="state.selectedTemplate.Note"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !description -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Configuration
|
||||
</div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" name="container_name" class="form-control" ng-model="formValues.name" placeholder="e.g. myStack" required />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- env -->
|
||||
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="!var.preset || var.select" class="form-group">
|
||||
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
|
||||
{{ var.label }}
|
||||
<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" />
|
||||
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
|
||||
<option selected disabled hidden value="">Select value</option>
|
||||
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !env -->
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="state.actionInProgress || !formValues.name"
|
||||
ng-click="createTemplate()"
|
||||
button-spinner="state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="state.actionInProgress">Deploy the stack</span>
|
||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-default" ng-click="unselectTemplate(state.selectedTemplate)">Hide</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<stack-from-template-form
|
||||
ng-if="state.selectedTemplate && (state.selectedTemplate.Type === 2 || state.selectedTemplate.Type === 3)"
|
||||
template="state.selectedTemplate"
|
||||
form-values="formValues"
|
||||
state="state"
|
||||
create-template="createTemplate"
|
||||
unselect-template="unselectTemplate"
|
||||
>
|
||||
</stack-from-template-form>
|
||||
<!-- !stack-form -->
|
||||
<!-- container-form -->
|
||||
<div class="col-sm-12" ng-if="state.selectedTemplate && state.selectedTemplate.Type === 1">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {{properCase name}}Controller from './{{camelCase name}}Controller.js'
|
||||
import {{properCase name}}Controller from './{{dashCase name}}/{{camelCase name}}Controller.js'
|
||||
|
||||
angular.module('portainer.{{module}}').component('{{camelCase name}}', {
|
||||
templateUrl: './{{camelCase name}}.html',
|
||||
controller: {{properCase name}}Controller,
|
||||
templateUrl: './{{camelCase name}}.html',
|
||||
controller: {{properCase name}}Controller,
|
||||
});
|
Loading…
Reference in New Issue