diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index e898d4ec2..a6a921acf 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -18,6 +18,7 @@ import ( "github.com/portainer/portainer/bolt/tag" "github.com/portainer/portainer/bolt/team" "github.com/portainer/portainer/bolt/teammembership" + "github.com/portainer/portainer/bolt/template" "github.com/portainer/portainer/bolt/user" "github.com/portainer/portainer/bolt/version" ) @@ -43,6 +44,7 @@ type Store struct { TagService *tag.Service TeamMembershipService *teammembership.Service TeamService *team.Service + TemplateService *template.Service UserService *user.Service VersionService *version.Service } @@ -212,6 +214,12 @@ func (store *Store) initServices() error { } store.TeamService = teamService + templateService, err := template.NewService(store.db) + if err != nil { + return err + } + store.TemplateService = templateService + userService, err := user.NewService(store.db) if err != nil { return err diff --git a/api/bolt/template/template.go b/api/bolt/template/template.go new file mode 100644 index 000000000..d60f64c0e --- /dev/null +++ b/api/bolt/template/template.go @@ -0,0 +1,95 @@ +package template + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "templates" +) + +// Service represents a service for managing endpoint 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 +} + +// Templates return an array containing all the templates. +func (service *Service) Templates() ([]portainer.Template, error) { + var templates = make([]portainer.Template, 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 template portainer.Template + err := internal.UnmarshalObject(v, &template) + if err != nil { + return err + } + templates = append(templates, template) + } + + return nil + }) + + return templates, err +} + +// Template returns a template by ID. +func (service *Service) Template(ID portainer.TemplateID) (*portainer.Template, error) { + var template portainer.Template + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &template) + if err != nil { + return nil, err + } + + return &template, nil +} + +// CreateTemplate creates a new template. +func (service *Service) CreateTemplate(template *portainer.Template) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + template.ID = portainer.TemplateID(id) + + data, err := internal.MarshalObject(template) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(template.ID)), data) + }) +} + +// UpdateTemplate saves a template. +func (service *Service) UpdateTemplate(ID portainer.TemplateID, template *portainer.Template) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, template) +} + +// DeleteTemplate deletes a template. +func (service *Service) DeleteTemplate(ID portainer.TemplateID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/cli/cli.go b/api/cli/cli.go index b9e00ca66..31ae297fe 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -19,6 +19,7 @@ const ( errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") errSocketNotFound = portainer.Error("Unable to locate Unix socket") errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") + errTemplateFileNotFound = portainer.Error("Unable to locate template file on disk") errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file") @@ -50,7 +51,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(), Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), - Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(), + Templates: kingpin.Flag("templates", "URL to the templates definitions.").Short('t').String(), + TemplateFile: kingpin.Flag("template-file", "Path to the templates (app) definitions on the filesystem").Default(defaultTemplateFile).String(), } kingpin.Parse() @@ -73,7 +75,12 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { return errEndpointExcludeExternal } - err := validateEndpointURL(*flags.EndpointURL) + err := validateTemplateFile(*flags.TemplateFile) + if err != nil { + return err + } + + err = validateEndpointURL(*flags.EndpointURL) if err != nil { return err } @@ -130,6 +137,16 @@ func validateExternalEndpoints(externalEndpoints string) error { return nil } +func validateTemplateFile(templateFile string) error { + if _, err := os.Stat(templateFile); err != nil { + if os.IsNotExist(err) { + return errTemplateFileNotFound + } + return err + } + return nil +} + func validateSyncInterval(syncInterval string) error { if syncInterval != defaultSyncInterval { _, err := time.ParseDuration(syncInterval) diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 419e5fd81..1c674e5ec 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -17,4 +17,5 @@ const ( defaultSSLCertPath = "/certs/portainer.crt" defaultSSLKeyPath = "/certs/portainer.key" defaultSyncInterval = "60s" + defaultTemplateFile = "/templates.json" ) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 2bd909c22..fda74265a 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -15,4 +15,5 @@ const ( defaultSSLCertPath = "C:\\certs\\portainer.crt" defaultSSLKeyPath = "C:\\certs\\portainer.key" defaultSyncInterval = "60s" + defaultTemplateFile = "C:\\templates.json" ) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 54b7ebb5b..9809828c4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -1,6 +1,7 @@ package main // import "github.com/portainer/portainer" import ( + "encoding/json" "strings" "github.com/portainer/portainer" @@ -143,9 +144,8 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL _, err := settingsService.Settings() if err == portainer.ErrObjectNotFound { settings := &portainer.Settings{ - LogoURL: *flags.Logo, - DisplayExternalContributors: false, - AuthenticationMethod: portainer.AuthenticationInternal, + LogoURL: *flags.Logo, + AuthenticationMethod: portainer.AuthenticationInternal, LDAPSettings: portainer.LDAPSettings{ TLSConfig: portainer.TLSConfiguration{}, SearchSettings: []portainer.LDAPSearchSettings{ @@ -156,12 +156,6 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL AllowPrivilegedModeForRegularUsers: true, } - if *flags.Templates != "" { - settings.TemplatesURL = *flags.Templates - } else { - settings.TemplatesURL = portainer.DefaultTemplatesURL - } - if *flags.Labels != nil { settings.BlackListedLabels = *flags.Labels } else { @@ -176,6 +170,58 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL return nil } +func initTemplates(templateService portainer.TemplateService, fileService portainer.FileService, templateURL, templateFile string) error { + + existingTemplates, err := templateService.Templates() + if err != nil { + return err + } + + if len(existingTemplates) != 0 { + log.Printf("Templates already registered inside the database. Skipping template import.") + return nil + } + + var templatesJSON []byte + if templateURL == "" { + return loadTemplatesFromFile(fileService, templateService, templateFile) + } + + templatesJSON, err = client.Get(templateURL) + if err != nil { + log.Println("Unable to retrieve templates via HTTP") + return err + } + + return unmarshalAndPersistTemplates(templateService, templatesJSON) +} + +func loadTemplatesFromFile(fileService portainer.FileService, templateService portainer.TemplateService, templateFile string) error { + templatesJSON, err := fileService.GetFileContent(templateFile) + if err != nil { + log.Println("Unable to retrieve template via filesystem") + return err + } + return unmarshalAndPersistTemplates(templateService, templatesJSON) +} + +func unmarshalAndPersistTemplates(templateService portainer.TemplateService, templateData []byte) error { + var templates []portainer.Template + err := json.Unmarshal(templateData, &templates) + if err != nil { + log.Println("Unable to parse templates file. Please review your template definition file.") + return err + } + + for _, template := range templates { + err := templateService.CreateTemplate(&template) + if err != nil { + return err + } + } + return nil +} + func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint { endpoints, err := endpointService.Endpoints() if err != nil { @@ -334,6 +380,11 @@ func main() { composeStackManager := initComposeStackManager(*flags.Data) + err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile) + if err != nil { + log.Fatal(err) + } + err = initSettings(store.SettingsService, flags) if err != nil { log.Fatal(err) @@ -357,7 +408,7 @@ func main() { if err != nil { log.Fatal(err) } - adminPasswordHash, err = cryptoService.Hash(content) + adminPasswordHash, err = cryptoService.Hash(string(content)) if err != nil { log.Fatal(err) } @@ -404,6 +455,7 @@ func main() { DockerHubService: store.DockerHubService, StackService: store.StackService, TagService: store.TagService, + TemplateService: store.TemplateService, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, CryptoService: cryptoService, diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index 1e896971e..aa32bfe54 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -169,7 +169,7 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma return make(map[string]interface{}), nil } - err = json.Unmarshal([]byte(raw), &config) + err = json.Unmarshal(raw, &config) if err != nil { return nil, err } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 5bff04852..1f5ca322e 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -176,14 +176,14 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT return nil } -// GetFileContent returns a string content from file. -func (service *Service) GetFileContent(filePath string) (string, error) { +// GetFileContent returns the content of a file as bytes. +func (service *Service) GetFileContent(filePath string) ([]byte, error) { content, err := ioutil.ReadFile(filePath) if err != nil { - return "", err + return nil, err } - return string(content), nil + return content, nil } // Rename renames a file or directory diff --git a/api/http/client/client.go b/api/http/client/client.go index 338aed15d..29ebb7d88 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "io/ioutil" "net/http" "net/url" "strings" @@ -61,6 +62,27 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain return &token, nil } +// Get executes a simple HTTP GET to the specified URL and returns +// the content of the response body. +func Get(url string) ([]byte, error) { + client := &http.Client{ + Timeout: time.Second * 3, + } + + response, err := client.Get(url) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + return body, nil +} + // ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment // using the specified host and optional TLS configuration. // It uses a new Http.Client for each operation. diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index ef76231f0..c2ee2a616 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -10,7 +10,6 @@ import ( type publicSettingsResponse struct { LogoURL string `json:"LogoURL"` - DisplayExternalContributors bool `json:"DisplayExternalContributors"` AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` @@ -25,7 +24,6 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * publicSettings := &publicSettingsResponse{ LogoURL: settings.LogoURL, - DisplayExternalContributors: settings.DisplayExternalContributors, AuthenticationMethod: settings.AuthenticationMethod, AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 1e854ec7f..51341658d 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -12,10 +12,8 @@ import ( ) type settingsUpdatePayload struct { - TemplatesURL string LogoURL string BlackListedLabels []portainer.Pair - DisplayExternalContributors bool AuthenticationMethod int LDAPSettings portainer.LDAPSettings AllowBindMountsForRegularUsers bool @@ -23,9 +21,6 @@ type settingsUpdatePayload struct { } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.TemplatesURL) || !govalidator.IsURL(payload.TemplatesURL) { - return portainer.Error("Invalid templates URL. Must correspond to a valid URL format") - } if payload.AuthenticationMethod == 0 { return portainer.Error("Invalid authentication method") } @@ -47,10 +42,8 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * } settings := &portainer.Settings{ - TemplatesURL: payload.TemplatesURL, LogoURL: payload.LogoURL, BlackListedLabels: payload.BlackListedLabels, - DisplayExternalContributors: payload.DisplayExternalContributors, LDAPSettings: payload.LDAPSettings, AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers, AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers, diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index a0b644ba0..f8d25af8d 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -54,5 +54,5 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} } - return response.JSON(w, &stackFileResponse{StackFileContent: stackFileContent}) + return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)}) } diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index b1b4fe867..eed7f6410 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -9,7 +9,7 @@ import ( "github.com/portainer/portainer/http/response" ) -// DELETE request on /api/tags/:name +// DELETE request on /api/tags/:id func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { id, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go index f4d0c6fcf..db65830c2 100644 --- a/api/http/handler/templates/handler.go +++ b/api/http/handler/templates/handler.go @@ -9,14 +9,10 @@ import ( "github.com/portainer/portainer/http/security" ) -const ( - containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" -) - // Handler represents an HTTP API handler for managing templates. type Handler struct { *mux.Router - SettingsService portainer.SettingsService + TemplateService portainer.TemplateService } // NewHandler returns a new instance of Handler. @@ -25,6 +21,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/templates", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + h.Handle("/templates", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateCreate))).Methods(http.MethodPost) + h.Handle("/templates/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateInspect))).Methods(http.MethodGet) + h.Handle("/templates/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateUpdate))).Methods(http.MethodPut) + h.Handle("/templates/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateDelete))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/templates/template_create.go b/api/http/handler/templates/template_create.go new file mode 100644 index 000000000..f27cd494a --- /dev/null +++ b/api/http/handler/templates/template_create.go @@ -0,0 +1,122 @@ +package templates + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type templateCreatePayload struct { + // Mandatory + Type int + Title string + Description string + AdministratorOnly bool + + // Opt stack/container + Name string + Logo string + Note string + Platform string + Categories []string + Env []portainer.TemplateEnv + + // Mandatory container + Image string + + // Mandatory stack + Repository portainer.TemplateRepository + + // Opt container + Registry string + Command string + Network string + Volumes []portainer.TemplateVolume + Ports []string + Labels []portainer.Pair + Privileged bool + Interactive bool + RestartPolicy string + Hostname string +} + +func (payload *templateCreatePayload) Validate(r *http.Request) error { + if payload.Type == 0 || (payload.Type != 1 && payload.Type != 2 && payload.Type != 3) { + return portainer.Error("Invalid template type. Valid values are: 1 (container), 2 (Swarm stack template) or 3 (Compose stack template).") + } + if govalidator.IsNull(payload.Title) { + return portainer.Error("Invalid template title") + } + if govalidator.IsNull(payload.Description) { + return portainer.Error("Invalid template description") + } + + if payload.Type == 1 { + if govalidator.IsNull(payload.Image) { + return portainer.Error("Invalid template image") + } + } + + if payload.Type == 2 || payload.Type == 3 { + if govalidator.IsNull(payload.Repository.URL) { + return portainer.Error("Invalid template repository URL") + } + if govalidator.IsNull(payload.Repository.StackFile) { + payload.Repository.StackFile = filesystem.ComposeFileDefaultName + } + } + + return nil +} + +// POST request on /api/templates +func (handler *Handler) templateCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload templateCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + template := &portainer.Template{ + Type: portainer.TemplateType(payload.Type), + Title: payload.Title, + Description: payload.Description, + AdministratorOnly: payload.AdministratorOnly, + Name: payload.Name, + Logo: payload.Logo, + Note: payload.Note, + Platform: payload.Platform, + Categories: payload.Categories, + Env: payload.Env, + } + + if template.Type == portainer.ContainerTemplate { + template.Image = payload.Image + template.Registry = payload.Registry + template.Command = payload.Command + template.Network = payload.Network + template.Volumes = payload.Volumes + template.Ports = payload.Ports + template.Labels = payload.Labels + template.Privileged = payload.Privileged + template.Interactive = payload.Interactive + template.RestartPolicy = payload.RestartPolicy + template.Hostname = payload.Hostname + } + + if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate { + template.Repository = payload.Repository + } + + err = handler.TemplateService.CreateTemplate(template) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the template inside the database", err} + } + + return response.JSON(w, template) +} diff --git a/api/http/handler/templates/template_delete.go b/api/http/handler/templates/template_delete.go new file mode 100644 index 000000000..c23c2d237 --- /dev/null +++ b/api/http/handler/templates/template_delete.go @@ -0,0 +1,25 @@ +package templates + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/templates/:id +func (handler *Handler) templateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err} + } + + err = handler.TemplateService.DeleteTemplate(portainer.TemplateID(id)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the template from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/templates/template_inspect.go b/api/http/handler/templates/template_inspect.go new file mode 100644 index 000000000..6b1b4c6a1 --- /dev/null +++ b/api/http/handler/templates/template_inspect.go @@ -0,0 +1,27 @@ +package templates + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/templates/:id +func (handler *Handler) templateInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + templateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err} + } + + template, err := handler.TemplateService.Template(portainer.TemplateID(templateID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err} + } + + return response.JSON(w, template) +} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go index 188951201..7c31e1c10 100644 --- a/api/http/handler/templates/template_list.go +++ b/api/http/handler/templates/template_list.go @@ -1,50 +1,26 @@ package templates import ( - "io/ioutil" "net/http" httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/request" "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" ) -// GET request on /api/templates?key= +// GET request on /api/templates func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - key, err := request.RetrieveQueryParameter(r, "key", false) + templates, err := handler.TemplateService.Templates() if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: key", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates from the database", err} } - templatesURL, templateErr := handler.retrieveTemplateURLFromKey(key) - if templateErr != nil { - return templateErr - } - - resp, err := http.Get(templatesURL) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", err} - } - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to read template response", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - return response.Bytes(w, body, "application/json") -} - -func (handler *Handler) retrieveTemplateURLFromKey(key string) (string, *httperror.HandlerError) { - switch key { - case "containers": - settings, err := handler.SettingsService.Settings() - if err != nil { - return "", &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} - } - return settings.TemplatesURL, nil - case "linuxserver.io": - return containerTemplatesURLLinuxServerIo, nil - } - return "", &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: key. Value must be one of: containers or linuxserver.io", request.ErrInvalidQueryParameter} + filteredTemplates := security.FilterTemplates(templates, securityContext) + + return response.JSON(w, filteredTemplates) } diff --git a/api/http/handler/templates/template_update.go b/api/http/handler/templates/template_update.go new file mode 100644 index 000000000..2eff9701f --- /dev/null +++ b/api/http/handler/templates/template_update.go @@ -0,0 +1,164 @@ +package templates + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type templateUpdatePayload struct { + Title *string + Description *string + AdministratorOnly *bool + Name *string + Logo *string + Note *string + Platform *string + Categories []string + Env []portainer.TemplateEnv + Image *string + Registry *string + Repository portainer.TemplateRepository + Command *string + Network *string + Volumes []portainer.TemplateVolume + Ports []string + Labels []portainer.Pair + Privileged *bool + Interactive *bool + RestartPolicy *string + Hostname *string +} + +func (payload *templateUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/templates/:id +func (handler *Handler) templateUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + templateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err} + } + + template, err := handler.TemplateService.Template(portainer.TemplateID(templateID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err} + } + + var payload templateUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + updateTemplate(template, &payload) + + err = handler.TemplateService.UpdateTemplate(template.ID, template) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to persist template changes inside the database", err} + } + + return response.JSON(w, template) +} + +func updateContainerProperties(template *portainer.Template, payload *templateUpdatePayload) { + if payload.Image != nil { + template.Image = *payload.Image + } + + if payload.Registry != nil { + template.Registry = *payload.Registry + } + + if payload.Command != nil { + template.Command = *payload.Command + } + + if payload.Network != nil { + template.Network = *payload.Network + } + + if payload.Volumes != nil { + template.Volumes = payload.Volumes + } + + if payload.Ports != nil { + template.Ports = payload.Ports + } + + if payload.Labels != nil { + template.Labels = payload.Labels + } + + if payload.Privileged != nil { + template.Privileged = *payload.Privileged + } + + if payload.Interactive != nil { + template.Interactive = *payload.Interactive + } + + if payload.RestartPolicy != nil { + template.RestartPolicy = *payload.RestartPolicy + } + + if payload.Hostname != nil { + template.Hostname = *payload.Hostname + } +} + +func updateStackProperties(template *portainer.Template, payload *templateUpdatePayload) { + if payload.Repository.URL != "" && payload.Repository.StackFile != "" { + template.Repository = payload.Repository + } +} + +func updateTemplate(template *portainer.Template, payload *templateUpdatePayload) { + if payload.Title != nil { + template.Title = *payload.Title + } + + if payload.Description != nil { + template.Description = *payload.Description + } + + if payload.Name != nil { + template.Name = *payload.Name + } + + if payload.Logo != nil { + template.Logo = *payload.Logo + } + + if payload.Note != nil { + template.Note = *payload.Note + } + + if payload.Platform != nil { + template.Platform = *payload.Platform + } + + if payload.Categories != nil { + template.Categories = payload.Categories + } + + if payload.Env != nil { + template.Env = payload.Env + } + + if payload.AdministratorOnly != nil { + template.AdministratorOnly = *payload.AdministratorOnly + } + + if template.Type == portainer.ContainerTemplate { + updateContainerProperties(template, payload) + } else if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate { + updateStackProperties(template, payload) + } +} diff --git a/api/http/response/response.go b/api/http/response/response.go index 1334d4d7e..2451abd24 100644 --- a/api/http/response/response.go +++ b/api/http/response/response.go @@ -23,10 +23,3 @@ func Empty(rw http.ResponseWriter) *httperror.HandlerError { rw.WriteHeader(http.StatusNoContent) return nil } - -// Bytes write data into rw. It also allows to set the Content-Type header. -func Bytes(rw http.ResponseWriter, data []byte, contentType string) *httperror.HandlerError { - rw.Header().Set("Content-Type", contentType) - rw.Write(data) - return nil -} diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 0e00ab568..878a66689 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -77,6 +77,24 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques return filteredRegistries } +// FilterTemplates filters templates based on the user role. +// Non-administrato template do not have access to templates where the AdministratorOnly flag is set to true. +func FilterTemplates(templates []portainer.Template, context *RestrictedRequestContext) []portainer.Template { + filteredTemplates := templates + + if !context.IsAdmin { + filteredTemplates = make([]portainer.Template, 0) + + for _, template := range templates { + if !template.AdministratorOnly { + filteredTemplates = append(filteredTemplates, template) + } + } + } + + return filteredTemplates +} + // FilterEndpoints filters endpoints based on user role and team memberships. // Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { diff --git a/api/http/server.go b/api/http/server.go index 7569760dd..53f438514 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -55,6 +55,7 @@ type Server struct { TagService portainer.TagService TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService + TemplateService portainer.TemplateService UserService portainer.UserService Handler *handler.Handler SSL bool @@ -143,7 +144,7 @@ func (server *Server) Start() error { var statusHandler = status.NewHandler(requestBouncer, server.Status) var templatesHandler = templates.NewHandler(requestBouncer) - templatesHandler.SettingsService = server.SettingsService + templatesHandler.TemplateService = server.TemplateService var uploadHandler = upload.NewHandler(requestBouncer) uploadHandler.FileService = server.FileService diff --git a/api/portainer.go b/api/portainer.go index e69f90fcd..07172ca89 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -21,6 +21,7 @@ type ( NoAuth *bool NoAnalytics *bool Templates *string + TemplateFile *string TLS *bool TLSSkipVerify *bool TLSCacert *string @@ -68,16 +69,16 @@ type ( // Settings represents the application settings. Settings struct { - TemplatesURL string `json:"TemplatesURL"` LogoURL string `json:"LogoURL"` BlackListedLabels []Pair `json:"BlackListedLabels"` - DisplayExternalContributors bool `json:"DisplayExternalContributors"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` LDAPSettings LDAPSettings `json:"LDAPSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` // Deprecated fields - DisplayDonationHeader bool + DisplayDonationHeader bool + DisplayExternalContributors bool + TemplatesURL string } // User represents a user account. @@ -277,6 +278,79 @@ type ( Name string `json:"Name"` } + // TemplateID represents a template identifier. + TemplateID int + + // TemplateType represents the type of a template. + TemplateType int + + // Template represents an application template. + Template struct { + // Mandatory container/stack fields + ID TemplateID `json:"Id"` + Type TemplateType `json:"type"` + Title string `json:"title"` + Description string `json:"description"` + AdministratorOnly bool `json:"administrator_only"` + + // Mandatory container fields + Image string `json:"image"` + + // Mandatory stack fields + Repository TemplateRepository `json:"repository"` + + // Optional stack/container fields + Name string `json:"name,omitempty"` + Logo string `json:"logo,omitempty"` + Env []TemplateEnv `json:"env,omitempty"` + Note string `json:"note,omitempty"` + Platform string `json:"platform,omitempty"` + Categories []string `json:"categories,omitempty"` + + // Optional container fields + Registry string `json:"registry,omitempty"` + Command string `json:"command,omitempty"` + Network string `json:"network,omitempty"` + Volumes []TemplateVolume `json:"volumes,omitempty"` + Ports []string `json:"ports,omitempty"` + Labels []Pair `json:"labels,omitempty"` + Privileged bool `json:"privileged,omitempty"` + Interactive bool `json:"interactive,omitempty"` + RestartPolicy string `json:"restart_policy,omitempty"` + Hostname string `json:"hostname,omitempty"` + } + + // TemplateEnv represents a template environment variable configuration. + TemplateEnv struct { + Name string `json:"name"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` + Default string `json:"default,omitempty"` + Preset bool `json:"preset,omitempty"` + Select []TemplateEnvSelect `json:"select,omitempty"` + } + + // TemplateVolume represents a template volume configuration. + TemplateVolume struct { + Container string `json:"container"` + Bind string `json:"bind,omitempty"` + ReadOnly bool `json:"readonly,omitempty"` + } + + // TemplateRepository represents the git repository configuration for a template. + TemplateRepository struct { + URL string `json:"url"` + StackFile string `json:"stackfile"` + } + + // TemplateEnvSelect represents text/value pair that will be displayed as a choice for the + // template user. + TemplateEnvSelect struct { + Text string `json:"text"` + Value string `json:"value"` + Default bool `json:"default"` + } + // ResourceAccessLevel represents the level of control associated to a resource. ResourceAccessLevel int @@ -411,6 +485,15 @@ type ( DeleteTag(ID TagID) error } + // TemplateService represents a service for managing template data. + TemplateService interface { + Templates() ([]Template, error) + Template(ID TemplateID) (*Template, error) + CreateTemplate(template *Template) error + UpdateTemplate(ID TemplateID, template *Template) error + DeleteTemplate(ID TemplateID) error + } + // CryptoService represents a service for encrypting/hashing data. CryptoService interface { Hash(data string) (string, error) @@ -434,7 +517,7 @@ type ( // FileService represents a service for managing files. FileService interface { - GetFileContent(filePath string) (string, error) + GetFileContent(filePath string) ([]byte, error) Rename(oldPath, newPath string) error RemoveDirectory(directoryPath string) error StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error) @@ -487,8 +570,6 @@ const ( APIVersion = "1.18.2-dev" // DBVersion is the version number of the Portainer database. DBVersion = 12 - // DefaultTemplatesURL represents the default URL for the templates definitions. - DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentTargetHeader represent the name of the header containing the target node name. @@ -582,3 +663,13 @@ const ( // DockerComposeStack represents a stack managed via docker-compose DockerComposeStack ) + +const ( + _ TemplateType = iota + // ContainerTemplate represents a container template + ContainerTemplate + // SwarmStackTemplate represents a template used to deploy a Swarm stack + SwarmStackTemplate + // ComposeStackTemplate represents a template used to deploy a Compose stack + ComposeStackTemplate +) diff --git a/api/swagger.yaml b/api/swagger.yaml index 97e9015c2..d0cbbe5bd 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -2530,32 +2530,189 @@ paths: get: tags: - "templates" - summary: "Retrieve App templates" + summary: "List available templates" description: | - Retrieve App templates. - You can find more information about the format at http://portainer.readthedocs.io/en/stable/templates.html - **Access policy**: authenticated + List available templates. + Administrator templates will not be listed for non-administrator users. + **Access policy**: restricted operationId: "TemplateList" produces: - "application/json" parameters: - - name: "key" - in: "query" - required: true - description: "Templates key. Valid values are 'container' or 'linuxserver.io'." - type: "string" responses: 200: description: "Success" schema: $ref: "#/definitions/TemplateListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "templates" + summary: "Create a new template" + description: | + Create a new template. + **Access policy**: administrator + operationId: "TemplateCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Template details" + required: true + schema: + $ref: "#/definitions/TemplateCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Template" 400: description: "Invalid request" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Invalid query format" + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /templates/{id}: + get: + tags: + - "templates" + summary: "Inspect a template" + description: | + Retrieve details about a template. + **Access policy**: administrator + operationId: "TemplateInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Template identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Template" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "Template not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Template not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "templates" + summary: "Update a template" + description: | + Update a template. + **Access policy**: administrator + operationId: "TemplateUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Template identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Template details" + required: true + schema: + $ref: "#/definitions/TemplateUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "Template not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Template not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "templates" + summary: "Remove a template" + description: | + Remove a template. + **Access policy**: administrator + operationId: "TemplateDelete" + parameters: + - name: "id" + in: "path" + description: "Template identifier" + required: true + type: "integer" + responses: + 204: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" 500: description: "Server error" schema: @@ -3602,9 +3759,17 @@ definitions: type: "array" items: $ref: "#/definitions/Template" - Template: + TemplateCreateRequest: type: "object" + required: + - "type" + - "title" + - "description" properties: + type: + type: "integer" + example: 1 + description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)" title: type: "string" example: "Nginx" @@ -3613,14 +3778,354 @@ definitions: type: "string" example: "High performance web server" description: "Description of the template" + administrator_only: + type: "boolean" + example: true + description: "Whether the template should be available to administrators only" + image: + type: "string" + example: "nginx:latest" + description: "Image associated to a container template. Mandatory for a container template" + repository: + $ref: "#/definitions/TemplateRepository" + name: + type: "string" + example: "mystackname" + description: "Default name for the stack/container to be used on deployment" logo: type: "string" example: "https://cloudinovasi.id/assets/img/logos/nginx.png" description: "URL of the template's logo" + env: + type: "array" + description: "A list of environment variables used during the template deployment" + items: + $ref: "#/definitions/TemplateEnv" + note: + type: "string" + example: "This is my custom template" + description: "A note that will be displayed in the UI. Supports HTML content" + platform: + type: "string" + example: "linux" + description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform" + categories: + type: "array" + description: "A list of categories associated to the template" + items: + type: "string" + exampe: "database" + registry: + type: "string" + example: "quay.io" + description: "The URL of a registry associated to the image for a container template" + command: + type: "string" + example: "ls -lah" + description: "The command that will be executed in a container template" + network: + type: "string" + example: "mynet" + description: "Name of a network that will be used on container deployment if it exists inside the environment" + volumes: + type: "array" + description: "A list of volumes used during the container template deployment" + items: + $ref: "#/definitions/TemplateVolume" + ports: + type: "array" + description: "A list of ports exposed by the container" + items: + type: "string" + example: "8080:80/tcp" + labels: + type: "array" + description: "Container labels" + items: + $ref: '#/definitions/Pair' + privileged: + type: "boolean" + example: true + description: "Whether the container should be started in privileged mode" + interactive: + type: "boolean" + example: true + description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)" + restart_policy: + type: "string" + example: "on-failure" + description: "Container restart policy" + hostname: + type: "string" + example: "mycontainer" + description: "Container hostname" + TemplateUpdateRequest: + type: "object" + properties: + type: + type: "integer" + example: 1 + description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)" + title: + type: "string" + example: "Nginx" + description: "Title of the template" + description: + type: "string" + example: "High performance web server" + description: "Description of the template" + administrator_only: + type: "boolean" + example: true + description: "Whether the template should be available to administrators only" image: type: "string" example: "nginx:latest" - description: "The Docker image associated to the template" + description: "Image associated to a container template. Mandatory for a container template" + repository: + $ref: "#/definitions/TemplateRepository" + name: + type: "string" + example: "mystackname" + description: "Default name for the stack/container to be used on deployment" + logo: + type: "string" + example: "https://cloudinovasi.id/assets/img/logos/nginx.png" + description: "URL of the template's logo" + env: + type: "array" + description: "A list of environment variables used during the template deployment" + items: + $ref: "#/definitions/TemplateEnv" + note: + type: "string" + example: "This is my custom template" + description: "A note that will be displayed in the UI. Supports HTML content" + platform: + type: "string" + example: "linux" + description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform" + categories: + type: "array" + description: "A list of categories associated to the template" + items: + type: "string" + exampe: "database" + registry: + type: "string" + example: "quay.io" + description: "The URL of a registry associated to the image for a container template" + command: + type: "string" + example: "ls -lah" + description: "The command that will be executed in a container template" + network: + type: "string" + example: "mynet" + description: "Name of a network that will be used on container deployment if it exists inside the environment" + volumes: + type: "array" + description: "A list of volumes used during the container template deployment" + items: + $ref: "#/definitions/TemplateVolume" + ports: + type: "array" + description: "A list of ports exposed by the container" + items: + type: "string" + example: "8080:80/tcp" + labels: + type: "array" + description: "Container labels" + items: + $ref: '#/definitions/Pair' + privileged: + type: "boolean" + example: true + description: "Whether the container should be started in privileged mode" + interactive: + type: "boolean" + example: true + description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)" + restart_policy: + type: "string" + example: "on-failure" + description: "Container restart policy" + hostname: + type: "string" + example: "mycontainer" + description: "Container hostname" + Template: + type: "object" + properties: + id: + type: "integer" + example: 1 + description: "Template identifier" + type: + type: "integer" + example: 1 + description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)" + title: + type: "string" + example: "Nginx" + description: "Title of the template" + description: + type: "string" + example: "High performance web server" + description: "Description of the template" + administrator_only: + type: "boolean" + example: true + description: "Whether the template should be available to administrators only" + image: + type: "string" + example: "nginx:latest" + description: "Image associated to a container template. Mandatory for a container template" + repository: + $ref: "#/definitions/TemplateRepository" + name: + type: "string" + example: "mystackname" + description: "Default name for the stack/container to be used on deployment" + logo: + type: "string" + example: "https://cloudinovasi.id/assets/img/logos/nginx.png" + description: "URL of the template's logo" + env: + type: "array" + description: "A list of environment variables used during the template deployment" + items: + $ref: "#/definitions/TemplateEnv" + note: + type: "string" + example: "This is my custom template" + description: "A note that will be displayed in the UI. Supports HTML content" + platform: + type: "string" + example: "linux" + description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform" + categories: + type: "array" + description: "A list of categories associated to the template" + items: + type: "string" + exampe: "database" + registry: + type: "string" + example: "quay.io" + description: "The URL of a registry associated to the image for a container template" + command: + type: "string" + example: "ls -lah" + description: "The command that will be executed in a container template" + network: + type: "string" + example: "mynet" + description: "Name of a network that will be used on container deployment if it exists inside the environment" + volumes: + type: "array" + description: "A list of volumes used during the container template deployment" + items: + $ref: "#/definitions/TemplateVolume" + ports: + type: "array" + description: "A list of ports exposed by the container" + items: + type: "string" + example: "8080:80/tcp" + labels: + type: "array" + description: "Container labels" + items: + $ref: '#/definitions/Pair' + privileged: + type: "boolean" + example: true + description: "Whether the container should be started in privileged mode" + interactive: + type: "boolean" + example: true + description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)" + restart_policy: + type: "string" + example: "on-failure" + description: "Container restart policy" + hostname: + type: "string" + example: "mycontainer" + description: "Container hostname" + TemplateVolume: + type: "object" + properties: + container: + type: "string" + example: "/data" + description: "Path inside the container" + bind: + type: "string" + example: "/tmp" + description: "Path on the host" + readonly: + type: "boolean" + example: true + description: "Whether the volume used should be readonly" + TemplateEnv: + type: "object" + properties: + name: + type: "string" + example: "MYSQL_ROOT_PASSWORD" + description: "name of the environment variable" + label: + type: "string" + example: "Root password" + description: "Text for the label that will be generated in the UI" + description: + type: "string" + example: "MySQL root account password" + description: "Content of the tooltip that will be generated in the UI" + default: + type: "string" + example: "default_value" + description: "Default value that will be set for the variable" + preset: + type: "boolean" + example: true + description: "If set to true, will not generate any input for this variable in the UI" + select: + type: "array" + description: "A list of name/value that will be used to generate a dropdown in the UI" + items: + $ref: '#/definitions/TemplateEnvSelect' + TemplateEnvSelect: + type: "object" + properties: + text: + type: "string" + example: "text value" + description: "Some text that will displayed as a choice" + value: + type: "string" + example: "value" + description: "A value that will be associated to the choice" + default: + type: "boolean" + example: true + description: "Will set this choice as the default choice" + TemplateRepository: + type: "object" + required: + - "URL" + properties: + URL: + type: "string" + example: "https://github.com/portainer/portainer-compose" + description: "URL of a git repository used to deploy a stack template. Mandatory for a Swarm/Compose stack template" + stackfile: + type: "string" + example: "./subfolder/docker-compose.yml" + description: "Path to the stack file inside the git repository" StackMigrateRequest: type: "object" required: diff --git a/app/docker/__module.js b/app/docker/__module.js index 82041efeb..78c34897a 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -361,36 +361,6 @@ angular.module('portainer.docker', ['portainer.app']) } }; - var templates = { - name: 'docker.templates', - url: '/templates', - views: { - 'content@': { - templateUrl: 'app/docker/views/templates/templates.html', - controller: 'TemplatesController' - } - }, - params: { - key: 'containers', - hide_descriptions: false - } - }; - - var templatesLinuxServer = { - name: 'docker.templates.linuxserver', - url: '/linuxserver', - views: { - 'content@': { - templateUrl: 'app/docker/views/templates/templates.html', - controller: 'TemplatesController' - } - }, - params: { - key: 'linuxserver.io', - hide_descriptions: true - } - }; - var volumes = { name: 'docker.volumes', url: '/volumes', @@ -458,8 +428,6 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(tasks); $stateRegistryProvider.register(task); $stateRegistryProvider.register(taskLogs); - $stateRegistryProvider.register(templates); - $stateRegistryProvider.register(templatesLinuxServer); $stateRegistryProvider.register(volumes); $stateRegistryProvider.register(volume); $stateRegistryProvider.register(volumeCreation); diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index 59da699dc..870c959ed 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -2,10 +2,7 @@ Dashboard