diff --git a/api/datastore/migrate_data.go b/api/datastore/migrate_data.go
index 74a033334..4a2e9de7e 100644
--- a/api/datastore/migrate_data.go
+++ b/api/datastore/migrate_data.go
@@ -88,6 +88,7 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai
EdgeGroupService: store.EdgeGroupService,
TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService,
+ CustomTemplateService: store.CustomTemplateService,
SourceService: store.SourceService,
WorkflowService: store.WorkflowService,
}
diff --git a/api/datastore/migrator/migrate_2_43_0.go b/api/datastore/migrator/migrate_2_43_0.go
index 54a2f38a5..40df6c85e 100644
--- a/api/datastore/migrator/migrate_2_43_0.go
+++ b/api/datastore/migrator/migrate_2_43_0.go
@@ -180,3 +180,86 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
return nil
}
+
+func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
+ log.Info().Msg("migrating git-backed custom templates to Source records")
+
+ templates, err := m.customTemplateService.ReadAll()
+ if err != nil {
+ return err
+ }
+
+ existingSources, err := m.sourceService.ReadAll()
+ if err != nil {
+ return err
+ }
+
+ sourcesByKey := make(map[sourceDedupeKey]portainer.SourceID, len(existingSources))
+ for _, src := range existingSources {
+ if src.GitConfig != nil {
+ sourcesByKey[gitSourceKey(src.GitConfig)] = src.ID
+ }
+ }
+
+ for i := range templates {
+ t := &templates[i]
+ if t.GitConfig == nil || t.ArtifactSources != nil {
+ continue
+ }
+
+ cfg := &gittypes.RepoConfig{
+ URL: gittypes.SanitizeURL(t.GitConfig.URL),
+ Authentication: t.GitConfig.Authentication,
+ TLSSkipVerify: t.GitConfig.TLSSkipVerify,
+ }
+
+ if cfg.Authentication != nil && cfg.Authentication.GitCredentialID != 0 {
+ log.Warn().
+ Int("git_credential_id", cfg.Authentication.GitCredentialID).
+ Msg("custom template has a GitCredentialID reference which is not supported in CE; credential reference will be dropped during migration")
+
+ cfg.Authentication.GitCredentialID = 0
+ }
+
+ key := gitSourceKey(cfg)
+
+ var newSrcID portainer.SourceID
+
+ if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
+ srcID, exists := sourcesByKey[key]
+
+ if !exists {
+ src := &portainer.Source{
+ Name: gittypes.RepoName(cfg.URL),
+ Type: portainer.SourceTypeGit,
+ GitConfig: cfg,
+ }
+ if err := m.sourceService.Tx(tx).Create(src); err != nil {
+ return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err)
+ }
+ srcID = src.ID
+ newSrcID = src.ID
+ }
+
+ t.ArtifactSources = &portainer.ArtifactSources{
+ Artifact: portainer.Artifact{
+ ReferenceName: t.GitConfig.ReferenceName,
+ ConfigFilePath: t.GitConfig.ConfigFilePath,
+ ConfigHash: t.GitConfig.ConfigHash,
+ },
+ SourceIDs: []portainer.SourceID{srcID},
+ }
+ t.GitConfig = nil
+
+ return m.customTemplateService.Tx(tx).Update(t.ID, t)
+ }); err != nil {
+ return fmt.Errorf("failed to migrate custom template %d: %w", t.ID, err)
+ }
+
+ if newSrcID != 0 {
+ sourcesByKey[key] = newSrcID
+ }
+ }
+
+ return nil
+}
diff --git a/api/datastore/migrator/migrate_2_43_0_test.go b/api/datastore/migrator/migrate_2_43_0_test.go
index 9b6c72224..12fcc819f 100644
--- a/api/datastore/migrator/migrate_2_43_0_test.go
+++ b/api/datastore/migrator/migrate_2_43_0_test.go
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
+ "github.com/portainer/portainer/api/dataservices/customtemplate"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/dataservices/stack"
"github.com/portainer/portainer/api/dataservices/workflow"
@@ -221,3 +222,241 @@ func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
require.NoError(t, err)
require.Len(t, workflows, 1)
}
+
+func TestMigrateCustomTemplateGitConfigToSources_2_43_0_GitTemplateMigrated(t *testing.T) {
+ t.Parallel()
+
+ conn := &boltdb.DbConnection{Path: t.TempDir()}
+ err := conn.Open()
+ require.NoError(t, err)
+ defer logs.CloseAndLogErr(conn)
+
+ stackSvc, err := stack.NewService(conn)
+ require.NoError(t, err)
+ sourceSvc, err := source.NewService(conn)
+ require.NoError(t, err)
+ customTemplateSvc, err := customtemplate.NewService(conn)
+ require.NoError(t, err)
+
+ m := NewMigrator(&MigratorParameters{
+ StackService: stackSvc,
+ SourceService: sourceSvc,
+ CustomTemplateService: customTemplateSvc,
+ })
+
+ tmpl := &portainer.CustomTemplate{
+ ID: 1,
+ GitConfig: &gittypes.RepoConfig{
+ URL: "https://github.com/example/repo",
+ ReferenceName: "refs/heads/main",
+ ConfigFilePath: "docker-compose.yml",
+ ConfigHash: "abc123",
+ },
+ }
+ err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
+ require.NoError(t, err)
+
+ err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
+ require.NoError(t, err)
+
+ migrated, err := customTemplateSvc.Read(tmpl.ID)
+ require.NoError(t, err)
+ require.NotNil(t, migrated.ArtifactSources)
+ require.Nil(t, migrated.GitConfig)
+ require.Len(t, migrated.ArtifactSources.SourceIDs, 1)
+ require.Equal(t, "refs/heads/main", migrated.ArtifactSources.Artifact.ReferenceName)
+ require.Equal(t, "docker-compose.yml", migrated.ArtifactSources.Artifact.ConfigFilePath)
+ require.Equal(t, "abc123", migrated.ArtifactSources.Artifact.ConfigHash)
+
+ src, err := sourceSvc.Read(migrated.ArtifactSources.SourceIDs[0])
+ require.NoError(t, err)
+ require.Equal(t, portainer.SourceTypeGit, src.Type)
+ require.Equal(t, "https://github.com/example/repo", src.GitConfig.URL)
+}
+
+func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched(t *testing.T) {
+ t.Parallel()
+
+ conn := &boltdb.DbConnection{Path: t.TempDir()}
+ err := conn.Open()
+ require.NoError(t, err)
+ defer logs.CloseAndLogErr(conn)
+
+ stackSvc, err := stack.NewService(conn)
+ require.NoError(t, err)
+ sourceSvc, err := source.NewService(conn)
+ require.NoError(t, err)
+ customTemplateSvc, err := customtemplate.NewService(conn)
+ require.NoError(t, err)
+
+ m := NewMigrator(&MigratorParameters{
+ StackService: stackSvc,
+ SourceService: sourceSvc,
+ CustomTemplateService: customTemplateSvc,
+ })
+
+ tmpl := &portainer.CustomTemplate{ID: 1, Title: "plain-template"}
+ err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
+ require.NoError(t, err)
+
+ err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
+ require.NoError(t, err)
+
+ result, err := customTemplateSvc.Read(tmpl.ID)
+ require.NoError(t, err)
+ require.Nil(t, result.ArtifactSources)
+ require.Nil(t, result.GitConfig)
+
+ sources, err := sourceSvc.ReadAll()
+ require.NoError(t, err)
+ require.Empty(t, sources)
+}
+
+func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t *testing.T) {
+ t.Parallel()
+
+ conn := &boltdb.DbConnection{Path: t.TempDir()}
+ err := conn.Open()
+ require.NoError(t, err)
+ defer logs.CloseAndLogErr(conn)
+
+ stackSvc, err := stack.NewService(conn)
+ require.NoError(t, err)
+ sourceSvc, err := source.NewService(conn)
+ require.NoError(t, err)
+ customTemplateSvc, err := customtemplate.NewService(conn)
+ require.NoError(t, err)
+
+ m := NewMigrator(&MigratorParameters{
+ StackService: stackSvc,
+ SourceService: sourceSvc,
+ CustomTemplateService: customTemplateSvc,
+ })
+
+ // Template already has ArtifactSources set (already migrated)
+ srcID := portainer.SourceID(99)
+ tmpl := &portainer.CustomTemplate{
+ ID: 1,
+ GitConfig: &gittypes.RepoConfig{
+ URL: "https://github.com/example/repo",
+ },
+ ArtifactSources: &portainer.ArtifactSources{
+ SourceIDs: []portainer.SourceID{srcID},
+ },
+ }
+ err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
+ require.NoError(t, err)
+
+ err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
+ require.NoError(t, err)
+
+ sources, err := sourceSvc.ReadAll()
+ require.NoError(t, err)
+ require.Empty(t, sources, "no new sources should be created for already-migrated templates")
+}
+
+func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) {
+ t.Parallel()
+
+ conn := &boltdb.DbConnection{Path: t.TempDir()}
+ err := conn.Open()
+ require.NoError(t, err)
+ defer logs.CloseAndLogErr(conn)
+
+ stackSvc, err := stack.NewService(conn)
+ require.NoError(t, err)
+ sourceSvc, err := source.NewService(conn)
+ require.NoError(t, err)
+ customTemplateSvc, err := customtemplate.NewService(conn)
+ require.NoError(t, err)
+
+ m := NewMigrator(&MigratorParameters{
+ StackService: stackSvc,
+ SourceService: sourceSvc,
+ CustomTemplateService: customTemplateSvc,
+ })
+
+ sharedURL := "https://github.com/example/shared-repo"
+
+ tmpl1 := &portainer.CustomTemplate{
+ ID: 1,
+ Title: "template-a",
+ GitConfig: &gittypes.RepoConfig{
+ URL: sharedURL,
+ ReferenceName: "refs/heads/main",
+ },
+ }
+ tmpl2 := &portainer.CustomTemplate{
+ ID: 2,
+ Title: "template-b",
+ GitConfig: &gittypes.RepoConfig{
+ URL: sharedURL,
+ ReferenceName: "refs/heads/develop",
+ },
+ }
+ err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl1.ID), tmpl1)
+ require.NoError(t, err)
+ err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl2.ID), tmpl2)
+ require.NoError(t, err)
+
+ err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
+ require.NoError(t, err)
+
+ sources, err := sourceSvc.ReadAll()
+ require.NoError(t, err)
+ require.Len(t, sources, 1, "two templates with the same URL must share one Source")
+
+ sharedSrcID := sources[0].ID
+
+ migrated1, err := customTemplateSvc.Read(tmpl1.ID)
+ require.NoError(t, err)
+ require.NotNil(t, migrated1.ArtifactSources)
+ require.Equal(t, sharedSrcID, migrated1.ArtifactSources.SourceIDs[0])
+
+ migrated2, err := customTemplateSvc.Read(tmpl2.ID)
+ require.NoError(t, err)
+ require.NotNil(t, migrated2.ArtifactSources)
+ require.Equal(t, sharedSrcID, migrated2.ArtifactSources.SourceIDs[0])
+}
+
+func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
+ t.Parallel()
+
+ conn := &boltdb.DbConnection{Path: t.TempDir()}
+ err := conn.Open()
+ require.NoError(t, err)
+ defer logs.CloseAndLogErr(conn)
+
+ stackSvc, err := stack.NewService(conn)
+ require.NoError(t, err)
+ sourceSvc, err := source.NewService(conn)
+ require.NoError(t, err)
+ customTemplateSvc, err := customtemplate.NewService(conn)
+ require.NoError(t, err)
+
+ m := NewMigrator(&MigratorParameters{
+ StackService: stackSvc,
+ SourceService: sourceSvc,
+ CustomTemplateService: customTemplateSvc,
+ })
+
+ tmpl := &portainer.CustomTemplate{
+ ID: 1,
+ GitConfig: &gittypes.RepoConfig{
+ URL: "https://github.com/example/repo",
+ },
+ }
+ err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
+ require.NoError(t, err)
+
+ err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
+ require.NoError(t, err)
+
+ // Second run must not create duplicate Source records
+ err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
+ require.NoError(t, err)
+
+ sources, err := sourceSvc.ReadAll()
+ require.NoError(t, err)
+ require.Len(t, sources, 1)
+}
diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go
index 3a4bfa91c..f7fd38fdc 100644
--- a/api/datastore/migrator/migrator.go
+++ b/api/datastore/migrator/migrator.go
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
+ "github.com/portainer/portainer/api/dataservices/customtemplate"
"github.com/portainer/portainer/api/dataservices/dockerhub"
"github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/dataservices/edgejob"
@@ -66,6 +67,7 @@ type (
edgeGroupService *edgegroup.Service
TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service
+ customTemplateService *customtemplate.Service
sourceService *source.Service
workflowService *workflow.Service
}
@@ -98,6 +100,7 @@ type (
EdgeGroupService *edgegroup.Service
TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service
+ CustomTemplateService *customtemplate.Service
SourceService *source.Service
WorkflowService *workflow.Service
}
@@ -132,6 +135,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
edgeGroupService: parameters.EdgeGroupService,
TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService,
+ customTemplateService: parameters.CustomTemplateService,
sourceService: parameters.SourceService,
workflowService: parameters.WorkflowService,
}
@@ -268,7 +272,10 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.40.0", m.migrateRegistryAccessSASecrets_2_40_0)
- m.addMigrations("2.43.0", m.migrateGitConfigToSources_2_43_0)
+ m.addMigrations("2.43.0",
+ m.migrateGitConfigToSources_2_43_0,
+ m.migrateCustomTemplateGitConfigToSources_2_43_0,
+ )
// WARNING: do not change migrations that have already been released!
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index ca4cb9c2c..309a41095 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -920,7 +920,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null,
"workflows": null
diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go
index 88ad28b93..5ee3d7ac8 100644
--- a/api/http/handler/customtemplates/customtemplate_create.go
+++ b/api/http/handler/customtemplates/customtemplate_create.go
@@ -5,12 +5,13 @@ import (
"errors"
"net/http"
"os"
- "regexp"
"strconv"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/stacks/stackutils"
@@ -41,30 +42,39 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ
customTemplate.CreatedByUserID = tokenData.ID
- customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
+ err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return createCustomTemplateTx(tx, customTemplate, tokenData.ID)
+ })
+
+ return response.TxResponse(w, customTemplate, err)
+}
+
+func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, userID portainer.UserID) error {
+ existingTemplates, err := tx.CustomTemplate().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
}
- for _, existingTemplate := range customTemplates {
- if existingTemplate.Title == customTemplate.Title {
+ for _, existing := range existingTemplates {
+ if existing.Title == customTemplate.Title {
return httperror.InternalServerError("Template name must be unique", errors.New("Template name must be unique"))
}
}
- if err := handler.DataStore.CustomTemplate().Create(customTemplate); err != nil {
+ if err := tx.CustomTemplate().Create(customTemplate); err != nil {
return httperror.InternalServerError("Unable to create custom template", err)
}
- resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, tokenData.ID)
+ resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, userID)
- if err := handler.DataStore.ResourceControl().Create(resourceControl); err != nil {
+ if err := tx.ResourceControl().Create(resourceControl); err != nil {
return httperror.InternalServerError("Unable to persist resource control inside the database", err)
}
customTemplate.ResourceControl = resourceControl
+ populateGitConfig(tx, customTemplate)
- return response.JSON(w, customTemplate)
+ return nil
}
func (handler *Handler) createCustomTemplate(method string, r *http.Request) (*portainer.CustomTemplate, error) {
@@ -122,19 +132,11 @@ func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) e
if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack {
return errors.New("Invalid custom template type")
}
- if !isValidNote(payload.Note) {
+ if !IsValidNote(payload.Note) {
return errors.New("Invalid note.
tag is not supported")
}
- return validateVariablesDefinitions(payload.Variables)
-}
-
-func isValidNote(note string) bool {
- if len(note) == 0 {
- return true
- }
- match, _ := regexp.MatchString("
tag is not supported")
}
- return validateVariablesDefinitions(payload.Variables)
+ return ValidateVariablesDefinitions(payload.Variables)
}
// @id CustomTemplateCreateRepository
@@ -312,9 +314,27 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
return nil, err
}
- gitConfig.ConfigHash = commitHash
- customTemplate.GitConfig = gitConfig
+ src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
+ Name: gittypes.RepoName(gitConfig.URL),
+ Type: portainer.SourceTypeGit,
+ GitConfig: &gittypes.RepoConfig{
+ URL: gitConfig.URL,
+ Authentication: gitConfig.Authentication,
+ TLSSkipVerify: gitConfig.TLSSkipVerify,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ customTemplate.ArtifactSources = &portainer.ArtifactSources{
+ Artifact: portainer.Artifact{
+ ReferenceName: gitConfig.ReferenceName,
+ ConfigFilePath: gitConfig.ConfigFilePath,
+ ConfigHash: commitHash,
+ },
+ SourceIDs: []portainer.SourceID{src.ID},
+ }
isValidProject := true
defer func() {
if !isValidProject {
@@ -390,7 +410,7 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
payload.Logo = logo
note, _ := request.RetrieveMultiPartFormValue(r, "Note", true)
- if !isValidNote(note) {
+ if !IsValidNote(note) {
return errors.New("Invalid note.
tag is not supported")
}
payload.Note = note
@@ -422,7 +442,7 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
if err := json.Unmarshal([]byte(varsString), &payload.Variables); err != nil {
return errors.New("Invalid variables. Ensure that the variables are valid JSON")
}
- if err := validateVariablesDefinitions(payload.Variables); err != nil {
+ if err := ValidateVariablesDefinitions(payload.Variables); err != nil {
return err
}
}
diff --git a/api/http/handler/customtemplates/customtemplate_create_test.go b/api/http/handler/customtemplates/customtemplate_create_test.go
new file mode 100644
index 000000000..2e2c7b189
--- /dev/null
+++ b/api/http/handler/customtemplates/customtemplate_create_test.go
@@ -0,0 +1,1037 @@
+package customtemplates
+
+import (
+ "bytes"
+ "context"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
+ "github.com/portainer/portainer/api/filesystem"
+ "github.com/portainer/portainer/api/http/security"
+
+ "github.com/gorilla/mux"
+ "github.com/segmentio/encoding/json"
+ "github.com/stretchr/testify/require"
+)
+
+func createTemplateRequest(t *testing.T, method string, payload any, userID portainer.UserID, role portainer.UserRole) *http.Request {
+ t.Helper()
+
+ body, err := json.Marshal(payload)
+ require.NoError(t, err)
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/"+method, bytes.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ r = mux.SetURLVars(r, map[string]string{"method": method})
+
+ return r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role}))
+}
+
+func TestCustomTemplateCreate_FromFileContent_Success(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ Description: "A test template",
+ FileContent: "version: '3'\nservices:\n web:\n image: nginx",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Equal(t, "My Template", tmpl.Title)
+ require.Equal(t, "A test template", tmpl.Description)
+ require.Equal(t, portainer.UserID(1), tmpl.CreatedByUserID)
+ require.NotNil(t, tmpl.ResourceControl)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ templates, err := tx.CustomTemplate().ReadAll()
+ require.NoError(t, err)
+ require.Len(t, templates, 1)
+ require.Equal(t, "My Template", templates[0].Title)
+
+ rcs, err := tx.ResourceControl().ReadAll()
+ require.NoError(t, err)
+ require.Len(t, rcs, 1)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromFileContent_DuplicateTitle(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "Existing Template",
+ })
+ }))
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "Existing Template",
+ Description: "Another template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_MissingTitle(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Description: "A test template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_MissingDescription(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_MissingFileContent(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ Description: "A test template",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_InvalidPlatform(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ Description: "A test template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: 0,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_NoteWithImage(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ Description: "A test template",
+ FileContent: "version: '3'",
+ Note: `Some note with
`,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_KubernetesTypeIgnoresPlatform(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "K8s Template",
+ Description: "A kubernetes template",
+ FileContent: "apiVersion: v1\nkind: Pod",
+ Type: portainer.KubernetesStack,
+ Platform: 0,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Equal(t, "K8s Template", tmpl.Title)
+ require.Equal(t, portainer.KubernetesStack, tmpl.Type)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ templates, err := tx.CustomTemplate().ReadAll()
+ require.NoError(t, err)
+ require.Len(t, templates, 1)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_Success(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "Uploaded Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "Uploaded from file")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'\nservices:\n web:\n image: nginx"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Equal(t, "Uploaded Template", tmpl.Title)
+ require.Equal(t, filesystem.ComposeFileDefaultName, tmpl.EntryPoint)
+
+ err = ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ templates, err := tx.CustomTemplate().ReadAll()
+ require.NoError(t, err)
+ require.Len(t, templates, 1)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_InvalidMethod(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ Description: "A test template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "invalid", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_InvalidType(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ Description: "A test template",
+ FileContent: "version: '3'",
+ Type: 0,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_Variables(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "Template With Variables",
+ Description: "A test template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ Variables: []portainer.CustomTemplateVariableDefinition{
+ {Name: "IMAGE", Label: "Docker image"},
+ },
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Len(t, tmpl.Variables, 1)
+ require.Equal(t, "IMAGE", tmpl.Variables[0].Name)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(tmpl.ID)
+ require.NoError(t, err)
+ require.Len(t, stored.Variables, 1)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromFileContent_InvalidVariables(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "My Template",
+ Description: "A test template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ Variables: []portainer.CustomTemplateVariableDefinition{
+ {Label: "Missing name"},
+ },
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileContent_EdgeTemplate(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ payload := customTemplateFromFileContentPayload{
+ Title: "Edge Template",
+ Description: "For edge stacks",
+ FileContent: "version: '3'\nservices:\n web:\n image: nginx",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ EdgeTemplate: true,
+ }
+
+ r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.True(t, tmpl.EdgeTemplate)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(tmpl.ID)
+ require.NoError(t, err)
+ require.True(t, stored.EdgeTemplate)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_MissingTitle(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Description", "A description")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_MissingDescription(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "My Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_MissingFile(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "My Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "A description")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_InvalidType(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "My Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "A description")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "0")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_InvalidPlatform(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "My Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "A description")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "0")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_NoteWithImage(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "My Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "A description")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Note", `Some note
`)
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_KubernetesIgnoresPlatform(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "K8s Upload Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "A kubernetes template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "3")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "0")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "deployment.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("apiVersion: v1\nkind: Pod"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Equal(t, "K8s Upload Template", tmpl.Title)
+ require.Equal(t, filesystem.ComposeFileDefaultName, tmpl.EntryPoint)
+
+ err = ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ templates, err := tx.CustomTemplate().ReadAll()
+ require.NoError(t, err)
+ require.Len(t, templates, 1)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_Variables(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ vars, err := json.Marshal([]portainer.CustomTemplateVariableDefinition{{Name: "IMAGE", Label: "Docker image"}})
+ require.NoError(t, err)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err = writer.WriteField("Title", "Template With Variables")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "A description")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Variables", string(vars))
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Len(t, tmpl.Variables, 1)
+ require.Equal(t, "IMAGE", tmpl.Variables[0].Name)
+
+ err = ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(tmpl.ID)
+ require.NoError(t, err)
+ require.Len(t, stored.Variables, 1)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromFileUpload_InvalidVariables(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ err := writer.WriteField("Title", "My Template")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Description", "A description")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Type", "2")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Platform", "1")
+ require.NoError(t, err)
+
+ err = writer.WriteField("Variables", "not-valid-json")
+ require.NoError(t, err)
+
+ part, err := writer.CreateFormFile("File", "docker-compose.yml")
+ require.NoError(t, err)
+
+ _, err = part.Write([]byte("version: '3'"))
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
+ r.Header.Set("Content-Type", writer.FormDataContentType())
+ r = mux.SetURLVars(r, map[string]string{"method": "file"})
+ r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
+
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+type gitServiceCreatingFile struct {
+ portainer.GitService
+}
+
+func (g *gitServiceCreatingFile) CloneRepository(_ context.Context, destination, _, _, _, _ string, _ bool) error {
+ if err := os.MkdirAll(destination, 0700); err != nil {
+ return err
+ }
+
+ f, err := os.Create(filesystem.JoinPaths(destination, filesystem.ComposeFileDefaultName))
+ if err != nil {
+ return err
+ }
+
+ return f.Close()
+}
+
+func (g *gitServiceCreatingFile) LatestCommitID(_ context.Context, _, _, _, _ string, _ bool) (string, error) {
+ return "deadbeef123", nil
+}
+
+func TestCustomTemplateCreate_FromRepository_Success(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+ handler.GitService = &gitServiceCreatingFile{}
+
+ payload := customTemplateFromGitRepositoryPayload{
+ Title: "Git Template",
+ Description: "Created from git",
+ RepositoryURL: "https://github.com/example/repo",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Equal(t, "Git Template", tmpl.Title)
+ require.NotNil(t, tmpl.ArtifactSources)
+ require.Len(t, tmpl.ArtifactSources.SourceIDs, 1)
+ require.Equal(t, "deadbeef123", tmpl.ArtifactSources.Artifact.ConfigHash)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(tmpl.ID)
+ require.NoError(t, err)
+ require.NotNil(t, stored.ArtifactSources)
+
+ src, err := tx.Source().Read(stored.ArtifactSources.SourceIDs[0])
+ require.NoError(t, err)
+ require.Equal(t, portainer.SourceTypeGit, src.Type)
+ require.Equal(t, "https://github.com/example/repo", src.GitConfig.URL)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromRepository_DeduplicatesSource(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+ handler.GitService = &gitServiceCreatingFile{}
+
+ makePayload := func(title string) customTemplateFromGitRepositoryPayload {
+ return customTemplateFromGitRepositoryPayload{
+ Title: title,
+ Description: "Created from git",
+ RepositoryURL: "https://github.com/example/repo",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+ }
+
+ r1 := createTemplateRequest(t, "repository", makePayload("Template One"), 1, portainer.AdministratorRole)
+ rr1 := httptest.NewRecorder()
+ herr := handler.customTemplateCreate(rr1, r1)
+ require.Nil(t, herr)
+
+ r2 := createTemplateRequest(t, "repository", makePayload("Template Two"), 1, portainer.AdministratorRole)
+ rr2 := httptest.NewRecorder()
+ herr = handler.customTemplateCreate(rr2, r2)
+ require.Nil(t, herr)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ sources, err := tx.Source().ReadAll()
+ require.NoError(t, err)
+ require.Len(t, sources, 1, "two templates with the same URL must share one Source")
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateCreate_FromRepository_Validation_MissingTitle(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromGitRepositoryPayload{
+ Description: "A description",
+ RepositoryURL: "https://github.com/example/repo",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromRepository_Validation_MissingDescription(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromGitRepositoryPayload{
+ Title: "My Template",
+ RepositoryURL: "https://github.com/example/repo",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromRepository_Validation_InvalidURL(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromGitRepositoryPayload{
+ Title: "My Template",
+ Description: "A description",
+ RepositoryURL: "http://",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromRepository_Validation_AuthWithoutCredentials(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromGitRepositoryPayload{
+ Title: "My Template",
+ Description: "A description",
+ RepositoryURL: "https://github.com/example/repo",
+ RepositoryAuthentication: true,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromRepository_Validation_InvalidPlatform(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromGitRepositoryPayload{
+ Title: "My Template",
+ Description: "A description",
+ RepositoryURL: "https://github.com/example/repo",
+ Type: portainer.DockerComposeStack,
+ Platform: 0,
+ }
+
+ r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateCreate_FromRepository_Validation_InvalidType(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateFromGitRepositoryPayload{
+ Title: "My Template",
+ Description: "A description",
+ RepositoryURL: "https://github.com/example/repo",
+ Type: 0,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateCreate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
diff --git a/api/http/handler/customtemplates/customtemplate_file.go b/api/http/handler/customtemplates/customtemplate_file.go
index 286da475d..2438aeab5 100644
--- a/api/http/handler/customtemplates/customtemplate_file.go
+++ b/api/http/handler/customtemplates/customtemplate_file.go
@@ -82,8 +82,8 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
}
entryPath := customTemplate.EntryPoint
- if customTemplate.GitConfig != nil {
- entryPath = customTemplate.GitConfig.ConfigFilePath
+ if customTemplate.ArtifactSources != nil {
+ entryPath = customTemplate.ArtifactSources.Artifact.ConfigFilePath
}
fileContent, err := handler.FileService.GetFileContent(customTemplate.ProjectPath, entryPath)
if err != nil {
diff --git a/api/http/handler/customtemplates/customtemplate_file_test.go b/api/http/handler/customtemplates/customtemplate_file_test.go
index cc1f7fa50..d3627cb48 100644
--- a/api/http/handler/customtemplates/customtemplate_file_test.go
+++ b/api/http/handler/customtemplates/customtemplate_file_test.go
@@ -5,11 +5,13 @@ import (
"net/http/httptest"
"testing"
- "github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
+ gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
+
+ "github.com/gorilla/mux"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
@@ -33,7 +35,8 @@ func TestCustomTemplateFile(t *testing.T) {
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
- require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
+ require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{
+ ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
}))
@@ -59,6 +62,7 @@ func TestCustomTemplateFile(t *testing.T) {
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
@@ -74,6 +78,7 @@ func TestCustomTemplateFile(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
@@ -83,6 +88,7 @@ func TestCustomTemplateFile(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
@@ -94,3 +100,47 @@ func TestCustomTemplateFile(t *testing.T) {
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
}
+
+func TestCustomTemplateFile_GitTemplate(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, fs := newTestHandler(t)
+
+ templateContent := "git template content"
+ configFilePath := "docker-compose.yml"
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ src := &portainer.Source{
+ Type: portainer.SourceTypeGit,
+ GitConfig: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
+ }
+ err := tx.Source().Create(src)
+ require.NoError(t, err)
+
+ path, err := fs.StoreCustomTemplateFileFromBytes("10", configFilePath, []byte(templateContent))
+ require.NoError(t, err)
+
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 10,
+ EntryPoint: "should-not-be-used.yml",
+ ProjectPath: path,
+ ArtifactSources: &portainer.ArtifactSources{
+ Artifact: portainer.Artifact{ConfigFilePath: configFilePath},
+ SourceIDs: []portainer.SourceID{src.ID},
+ },
+ })
+ }))
+
+ r := httptest.NewRequest(http.MethodGet, "/custom_templates/10/file", nil)
+ r = mux.SetURLVars(r, map[string]string{"id": "10"})
+ ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ r = r.WithContext(ctx)
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateFile(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
+ var res struct{ FileContent string }
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
+ require.Equal(t, templateContent, res.FileContent)
+}
diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch.go b/api/http/handler/customtemplates/customtemplate_git_fetch.go
index ec7f25376..12db97127 100644
--- a/api/http/handler/customtemplates/customtemplate_git_fetch.go
+++ b/api/http/handler/customtemplates/customtemplate_git_fetch.go
@@ -7,6 +7,7 @@ import (
"sync"
portainer "github.com/portainer/portainer/api"
+ gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -42,8 +43,26 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
- if customTemplate.GitConfig == nil {
- return httperror.BadRequest("Git configuration does not exist in this custom template", err)
+ if customTemplate.ArtifactSources == nil || len(customTemplate.ArtifactSources.SourceIDs) == 0 {
+ return httperror.BadRequest("Git configuration does not exist in this custom template", nil)
+ }
+
+ src, err := handler.DataStore.Source().Read(customTemplate.ArtifactSources.SourceIDs[0])
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve git source for custom template", err)
+ }
+
+ if src.GitConfig == nil {
+ return httperror.InternalServerError("Source has no git configuration", nil)
+ }
+
+ gitConfig := &gittypes.RepoConfig{
+ URL: src.GitConfig.URL,
+ Authentication: src.GitConfig.Authentication,
+ TLSSkipVerify: src.GitConfig.TLSSkipVerify,
+ ReferenceName: customTemplate.ArtifactSources.Artifact.ReferenceName,
+ ConfigFilePath: customTemplate.ArtifactSources.Artifact.ConfigFilePath,
+ ConfigHash: customTemplate.ArtifactSources.Artifact.ConfigHash,
}
// If multiple users are trying to fetch the same custom template simultaneously, a lock needs to be added
@@ -68,7 +87,7 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
}
}()
- commitHash, err := stackutils.DownloadGitRepository(context.TODO(), *customTemplate.GitConfig, handler.GitService, func() string {
+ commitHash, err := stackutils.DownloadGitRepository(context.TODO(), *gitConfig, handler.GitService, func() string {
return customTemplate.ProjectPath
})
if err != nil {
@@ -81,15 +100,15 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
return httperror.InternalServerError("Failed to download git repository", err)
}
- if customTemplate.GitConfig.ConfigHash != commitHash {
- customTemplate.GitConfig.ConfigHash = commitHash
+ if customTemplate.ArtifactSources.Artifact.ConfigHash != commitHash {
+ customTemplate.ArtifactSources.Artifact.ConfigHash = commitHash
if err := handler.DataStore.CustomTemplate().Update(customTemplate.ID, customTemplate); err != nil {
return httperror.InternalServerError("Unable to persist custom template changes inside the database", err)
}
}
- fileContent, err := handler.FileService.GetFileContent(customTemplate.ProjectPath, customTemplate.GitConfig.ConfigFilePath)
+ fileContent, err := handler.FileService.GetFileContent(customTemplate.ProjectPath, gitConfig.ConfigFilePath)
if err != nil {
return httperror.InternalServerError("Unable to retrieve custom template file from disk", err)
}
diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go
index 7f72cca8a..a59ef3a5f 100644
--- a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go
+++ b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go
@@ -156,6 +156,7 @@ func singleAPIRequest(h *Handler, jwt string, expect string) error {
func Test_customTemplateGitFetch(t *testing.T) {
t.Parallel()
+
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -172,12 +173,32 @@ func Test_customTemplateGitFetch(t *testing.T) {
dir, err := os.Getwd()
require.NoError(t, err, "error to get working directory")
- template1 := &portainer.CustomTemplate{ID: 1, Title: "custom-template-1", ProjectPath: filesystem.JoinPaths(dir, "fixtures/custom_template_1"), GitConfig: &gittypes.RepoConfig{ConfigFilePath: "test-config-path.txt"}}
+ src := &portainer.Source{
+ ID: 1,
+ Type: portainer.SourceTypeGit,
+ GitConfig: &gittypes.RepoConfig{
+ URL: "https://github.com/example/repo",
+ },
+ }
+ err = store.Source().Create(src)
+ require.NoError(t, err, "error creating source")
+
+ const configFilePath = "test-config-path.txt"
+
+ template1 := &portainer.CustomTemplate{
+ ID: 1,
+ Title: "custom-template-1",
+ ProjectPath: filesystem.JoinPaths(dir, "fixtures/custom_template_1"),
+ ArtifactSources: &portainer.ArtifactSources{
+ Artifact: portainer.Artifact{ConfigFilePath: configFilePath},
+ SourceIDs: []portainer.SourceID{src.ID},
+ },
+ }
err = store.CustomTemplateService.Create(template1)
require.NoError(t, err, "error creating custom template 1")
// prepare testing folder
- err = prepareTestFolder(template1.ProjectPath, template1.GitConfig.ConfigFilePath)
+ err = prepareTestFolder(template1.ProjectPath, configFilePath)
require.NoError(t, err, "error creating testing folder")
defer func() {
@@ -192,7 +213,7 @@ func Test_customTemplateGitFetch(t *testing.T) {
requestBouncer := security.NewRequestBouncer(t.Context(), store, jwtService, nil)
gitService := &TestGitService{
- targetFilePath: filesystem.JoinPaths(template1.ProjectPath, template1.GitConfig.ConfigFilePath),
+ targetFilePath: filesystem.JoinPaths(template1.ProjectPath, configFilePath),
}
fileService := &TestFileService{}
@@ -252,7 +273,7 @@ func Test_customTemplateGitFetch(t *testing.T) {
t.Run("restore git repository if it is failed to download the new git repository", func(t *testing.T) {
invalidGitService := &InvalidTestGitService{
- targetFilePath: filesystem.JoinPaths(template1.ProjectPath, template1.GitConfig.ConfigFilePath),
+ targetFilePath: filesystem.JoinPaths(template1.ProjectPath, configFilePath),
}
h := NewHandler(requestBouncer, store, fileService, invalidGitService)
@@ -274,3 +295,73 @@ func Test_customTemplateGitFetch(t *testing.T) {
assert.Equal(t, "gfedcba", string(fileContent))
})
}
+
+func TestCustomTemplateGitFetch_NilArtifactSourcesReturnsBadRequest(t *testing.T) {
+ t.Parallel()
+
+ _, store := datastore.MustNewTestStore(t, false, true)
+
+ template := &portainer.CustomTemplate{ID: 1, Title: "no-git-template"}
+ err := store.CustomTemplateService.Create(template)
+ require.NoError(t, err)
+
+ h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
+
+ req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ require.Equal(t, http.StatusBadRequest, rr.Code)
+}
+
+func TestCustomTemplateGitFetch_EmptySourceIDsReturnsBadRequest(t *testing.T) {
+ t.Parallel()
+
+ _, store := datastore.MustNewTestStore(t, false, true)
+
+ template := &portainer.CustomTemplate{
+ ID: 1,
+ Title: "empty-source-ids",
+ ArtifactSources: &portainer.ArtifactSources{
+ SourceIDs: []portainer.SourceID{},
+ },
+ }
+ err := store.CustomTemplateService.Create(template)
+ require.NoError(t, err)
+
+ h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
+
+ req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ require.Equal(t, http.StatusBadRequest, rr.Code)
+}
+
+func TestCustomTemplateGitFetch_SourceWithNilGitConfigReturnsInternalError(t *testing.T) {
+ t.Parallel()
+
+ _, store := datastore.MustNewTestStore(t, false, true)
+
+ src := &portainer.Source{Type: portainer.SourceTypeGit}
+ err := store.Source().Create(src)
+ require.NoError(t, err)
+
+ template := &portainer.CustomTemplate{
+ ID: 1,
+ Title: "nil-git-config",
+ ArtifactSources: &portainer.ArtifactSources{
+ SourceIDs: []portainer.SourceID{src.ID},
+ },
+ }
+ err = store.CustomTemplateService.Create(template)
+ require.NoError(t, err)
+
+ h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
+
+ req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ require.Equal(t, http.StatusInternalServerError, rr.Code)
+}
diff --git a/api/http/handler/customtemplates/customtemplate_inspect.go b/api/http/handler/customtemplates/customtemplate_inspect.go
index 716bc8395..018a492a0 100644
--- a/api/http/handler/customtemplates/customtemplate_inspect.go
+++ b/api/http/handler/customtemplates/customtemplate_inspect.go
@@ -68,11 +68,13 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
}
- if canEdit || hasAccess {
- return nil
+ if !canEdit && !hasAccess {
+ return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
- return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
+ populateGitConfig(tx, customTemplate)
+
+ return nil
})
return response.TxResponse(w, customTemplate, err)
diff --git a/api/http/handler/customtemplates/customtemplate_inspect_test.go b/api/http/handler/customtemplates/customtemplate_inspect_test.go
index 40ec7b916..e0aecbb6c 100644
--- a/api/http/handler/customtemplates/customtemplate_inspect_test.go
+++ b/api/http/handler/customtemplates/customtemplate_inspect_test.go
@@ -5,14 +5,16 @@ import (
"net/http/httptest"
"testing"
- "github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
+ gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
+
+ "github.com/gorilla/mux"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
@@ -33,11 +35,13 @@ func newTestHandler(t *testing.T) (*Handler, dataservices.DataStore, portainer.F
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
- require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
+ require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{
+ ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
2: portainer.AccessPolicy{RoleID: 0},
3: portainer.AccessPolicy{RoleID: 0},
- }}))
+ },
+ }))
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
return nil
@@ -56,7 +60,8 @@ func TestInspectHandler(t *testing.T) {
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}))
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2}))
- require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
+ require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{
+ ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
}))
@@ -82,6 +87,7 @@ func TestInspectHandler(t *testing.T) {
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
var template portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&template))
require.Equal(t, portainer.CustomTemplateID(1), template.ID)
@@ -97,6 +103,7 @@ func TestInspectHandler(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
var template portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&template))
require.Equal(t, portainer.CustomTemplateID(2), template.ID)
@@ -106,6 +113,7 @@ func TestInspectHandler(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
var template portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&template))
require.Equal(t, portainer.CustomTemplateID(2), template.ID)
@@ -117,3 +125,53 @@ func TestInspectHandler(t *testing.T) {
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
}
+
+func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ var srcID portainer.SourceID
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ src := &portainer.Source{
+ Type: portainer.SourceTypeGit,
+ GitConfig: &gittypes.RepoConfig{
+ URL: "https://github.com/example/repo",
+ TLSSkipVerify: true,
+ },
+ }
+ err := tx.Source().Create(src)
+ require.NoError(t, err)
+ srcID = src.ID
+
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 10,
+ ArtifactSources: &portainer.ArtifactSources{
+ Artifact: portainer.Artifact{
+ ReferenceName: "refs/heads/main",
+ ConfigFilePath: "docker-compose.yml",
+ ConfigHash: "abc123",
+ },
+ SourceIDs: []portainer.SourceID{srcID},
+ },
+ })
+ }))
+
+ r := httptest.NewRequest(http.MethodGet, "/custom_templates/10", nil)
+ r = mux.SetURLVars(r, map[string]string{"id": "10"})
+ ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ r = r.WithContext(ctx)
+ rr := httptest.NewRecorder()
+ herr := handler.customTemplateInspect(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Result().StatusCode)
+
+ var template portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&template))
+ require.NotNil(t, template.GitConfig)
+ require.Equal(t, "https://github.com/example/repo", template.GitConfig.URL)
+ require.True(t, template.GitConfig.TLSSkipVerify)
+ require.Equal(t, "refs/heads/main", template.GitConfig.ReferenceName)
+ require.Equal(t, "docker-compose.yml", template.GitConfig.ConfigFilePath)
+ require.Equal(t, "abc123", template.GitConfig.ConfigHash)
+}
diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go
index 4c69cbb89..36bcbff9d 100644
--- a/api/http/handler/customtemplates/customtemplate_list.go
+++ b/api/http/handler/customtemplates/customtemplate_list.go
@@ -74,10 +74,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
}
for i := range customTemplates {
- customTemplate := &customTemplates[i]
- if customTemplate.GitConfig != nil && customTemplate.GitConfig.Authentication != nil {
- customTemplate.GitConfig.Authentication.Password = ""
- }
+ populateGitConfig(handler.DataStore, &customTemplates[i])
}
return response.JSON(w, customTemplates)
diff --git a/api/http/handler/customtemplates/customtemplate_list_test.go b/api/http/handler/customtemplates/customtemplate_list_test.go
new file mode 100644
index 000000000..b3698c1df
--- /dev/null
+++ b/api/http/handler/customtemplates/customtemplate_list_test.go
@@ -0,0 +1,127 @@
+package customtemplates
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
+ gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/portainer/portainer/api/http/security"
+
+ "github.com/segmentio/encoding/json"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ var srcID portainer.SourceID
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ src := &portainer.Source{
+ Type: portainer.SourceTypeGit,
+ GitConfig: &gittypes.RepoConfig{
+ URL: "https://github.com/example/repo",
+ TLSSkipVerify: true,
+ },
+ }
+ err := tx.Source().Create(src)
+ require.NoError(t, err)
+ srcID = src.ID
+ require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ ArtifactSources: &portainer.ArtifactSources{
+ Artifact: portainer.Artifact{
+ ReferenceName: "refs/heads/main",
+ ConfigFilePath: "docker-compose.yml",
+ ConfigHash: "abc123",
+ },
+ SourceIDs: []portainer.SourceID{srcID},
+ },
+ }))
+ require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: "docker-compose.yml"}))
+
+ return nil
+ }))
+
+ r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil)
+ r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}))
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, r)
+
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var templates []portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&templates))
+
+ var gitTemplate portainer.CustomTemplate
+ for _, tpl := range templates {
+ if tpl.ID == 1 {
+ gitTemplate = tpl
+ }
+ }
+
+ require.NotNil(t, gitTemplate.GitConfig)
+ require.Equal(t, "https://github.com/example/repo", gitTemplate.GitConfig.URL)
+ require.True(t, gitTemplate.GitConfig.TLSSkipVerify)
+ require.Equal(t, "refs/heads/main", gitTemplate.GitConfig.ReferenceName)
+ require.Equal(t, "docker-compose.yml", gitTemplate.GitConfig.ConfigFilePath)
+ require.Equal(t, "abc123", gitTemplate.GitConfig.ConfigHash)
+
+ var plainTemplate portainer.CustomTemplate
+ for _, tpl := range templates {
+ if tpl.ID == 2 {
+ plainTemplate = tpl
+ }
+ }
+ require.Nil(t, plainTemplate.GitConfig)
+}
+
+func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ var srcID portainer.SourceID
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ src := &portainer.Source{
+ Type: portainer.SourceTypeGit,
+ GitConfig: &gittypes.RepoConfig{
+ URL: "https://github.com/example/repo",
+ Authentication: &gittypes.GitAuthentication{
+ Username: "user",
+ Password: "topsecret",
+ },
+ },
+ }
+ err := tx.Source().Create(src)
+ require.NoError(t, err)
+ srcID = src.ID
+ require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ ArtifactSources: &portainer.ArtifactSources{
+ SourceIDs: []portainer.SourceID{srcID},
+ },
+ }))
+
+ return nil
+ }))
+
+ r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil)
+ r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}))
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, r)
+
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var templates []portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&templates))
+ require.Len(t, templates, 1)
+ require.NotNil(t, templates[0].GitConfig)
+ require.NotNil(t, templates[0].GitConfig.Authentication)
+ require.Equal(t, "user", templates[0].GitConfig.Authentication.Username)
+ require.Empty(t, templates[0].GitConfig.Authentication.Password)
+}
diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go
index 794d06a6c..aff6c1f34 100644
--- a/api/http/handler/customtemplates/customtemplate_update.go
+++ b/api/http/handler/customtemplates/customtemplate_update.go
@@ -8,9 +8,11 @@ import (
"strconv"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/portainer/portainer/api/gitops/workflows"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -84,7 +86,7 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid custom template description")
}
- if !isValidNote(payload.Note) {
+ if !IsValidNote(payload.Note) {
return errors.New("Invalid note.
tag is not supported")
}
@@ -96,7 +98,7 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
}
- if err := validateVariablesDefinitions(payload.Variables); err != nil {
+ if err := ValidateVariablesDefinitions(payload.Variables); err != nil {
return err
}
@@ -218,8 +220,28 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err))
}
- gitConfig.ConfigHash = commitHash
- customTemplate.GitConfig = gitConfig
+ src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
+ Name: gittypes.RepoName(gitConfig.URL),
+ Type: portainer.SourceTypeGit,
+ GitConfig: &gittypes.RepoConfig{
+ URL: gitConfig.URL,
+ Authentication: gitConfig.Authentication,
+ TLSSkipVerify: gitConfig.TLSSkipVerify,
+ },
+ })
+ if err != nil {
+ return httperror.InternalServerError("Unable to find or create git source", err)
+ }
+
+ customTemplate.ArtifactSources = &portainer.ArtifactSources{
+ Artifact: portainer.Artifact{
+ ReferenceName: gitConfig.ReferenceName,
+ ConfigFilePath: gitConfig.ConfigFilePath,
+ ConfigHash: commitHash,
+ },
+ SourceIDs: []portainer.SourceID{src.ID},
+ }
+
} else {
templateFolder := strconv.Itoa(customTemplateID)
projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
@@ -228,11 +250,18 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
}
customTemplate.ProjectPath = projectPath
+ customTemplate.ArtifactSources = nil
}
- if err := handler.DataStore.CustomTemplate().Update(customTemplate.ID, customTemplate); err != nil {
- return httperror.InternalServerError("Unable to persist custom template changes inside the database", err)
- }
+ err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ if err := tx.CustomTemplate().Update(customTemplate.ID, customTemplate); err != nil {
+ return httperror.InternalServerError("Unable to persist custom template changes inside the database", err)
+ }
- return response.JSON(w, customTemplate)
+ populateGitConfig(tx, customTemplate)
+
+ return nil
+ })
+
+ return response.TxResponse(w, customTemplate, err)
}
diff --git a/api/http/handler/customtemplates/customtemplate_update_test.go b/api/http/handler/customtemplates/customtemplate_update_test.go
new file mode 100644
index 000000000..314cc22d9
--- /dev/null
+++ b/api/http/handler/customtemplates/customtemplate_update_test.go
@@ -0,0 +1,500 @@
+package customtemplates
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
+ "github.com/portainer/portainer/api/filesystem"
+ "github.com/portainer/portainer/api/http/security"
+
+ "github.com/gorilla/mux"
+ "github.com/segmentio/encoding/json"
+ "github.com/stretchr/testify/require"
+)
+
+func updateTemplateRequest(t *testing.T, templateID string, payload any, ctx *security.RestrictedRequestContext) *http.Request {
+ t.Helper()
+
+ body, err := json.Marshal(payload)
+ require.NoError(t, err)
+
+ r := httptest.NewRequest(http.MethodPut, "/custom_templates/"+templateID, bytes.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ r = mux.SetURLVars(r, map[string]string{"id": templateID})
+
+ return r.WithContext(security.StoreRestrictedRequestContext(r, ctx))
+}
+
+func TestCustomTemplateUpdate_NotFound(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateUpdatePayload{
+ Title: "New Title",
+ Description: "New Description",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusNotFound, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Forbidden(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "Original Title",
+ EntryPoint: filesystem.ComposeFileDefaultName,
+ CreatedByUserID: 1,
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ Title: "New Title",
+ Description: "New Description",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ // User 2 did not create this template and is not an admin
+ r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 2})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusForbidden, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_DuplicateTitle(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "Template One",
+ }))
+
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 2,
+ Title: "Template Two",
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ Title: "Template One",
+ Description: "Renamed",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "2", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Success_FileContent(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "Original Title",
+ Description: "Original Description",
+ EntryPoint: filesystem.ComposeFileDefaultName,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ CreatedByUserID: 1,
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ Title: "Updated Title",
+ Description: "Updated Description",
+ FileContent: "version: '3'\nservices:\n app:\n image: alpine",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Equal(t, "Updated Title", tmpl.Title)
+ require.Equal(t, "Updated Description", tmpl.Description)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(1)
+ require.NoError(t, err)
+ require.Equal(t, "Updated Title", stored.Title)
+ require.Equal(t, "Updated Description", stored.Description)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateUpdate_OwnerCanUpdate(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "User Template",
+ EntryPoint: filesystem.ComposeFileDefaultName,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ CreatedByUserID: 2,
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ Title: "User Template Updated",
+ Description: "Updated by owner",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ // User 2 is the creator, not an admin
+ r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 2})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Equal(t, "User Template Updated", tmpl.Title)
+}
+
+func TestCustomTemplateUpdate_SameTitleAllowed(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "My Template",
+ EntryPoint: filesystem.ComposeFileDefaultName,
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ Title: "My Template",
+ Description: "Updated description",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(1)
+ require.NoError(t, err)
+ require.Equal(t, "My Template", stored.Title)
+ require.Equal(t, "Updated description", stored.Description)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateUpdate_InvalidPayload(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "My Template",
+ EntryPoint: filesystem.ComposeFileDefaultName,
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ // Title is empty - invalid
+ Description: "A description",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusBadRequest, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Validation_MissingDescription(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateUpdatePayload{
+ Title: "My Template",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusBadRequest, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Validation_BothContentAndRepoMissing(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateUpdatePayload{
+ Title: "My Template",
+ Description: "A description",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusBadRequest, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Validation_InvalidPlatform(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateUpdatePayload{
+ Title: "My Template",
+ Description: "A description",
+ FileContent: "version: '3'",
+ Type: portainer.DockerComposeStack,
+ Platform: 0,
+ }
+
+ r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusBadRequest, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Validation_InvalidType(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateUpdatePayload{
+ Title: "My Template",
+ Description: "A description",
+ FileContent: "version: '3'",
+ Type: 0,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusBadRequest, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Validation_NoteWithImage(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateUpdatePayload{
+ Title: "My Template",
+ Description: "A description",
+ FileContent: "version: '3'",
+ Note: `Some note
`,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusBadRequest, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_Validation_AuthWithoutCredentials(t *testing.T) {
+ t.Parallel()
+
+ handler, _, _ := newTestHandler(t)
+
+ payload := customTemplateUpdatePayload{
+ Title: "My Template",
+ Description: "A description",
+ RepositoryURL: "https://github.com/example/repo",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ RepositoryAuthentication: true,
+ }
+
+ r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.NotNil(t, herr)
+ require.Equal(t, http.StatusBadRequest, herr.StatusCode)
+}
+
+func TestCustomTemplateUpdate_ClearsArtifactSources(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "Git Template",
+ EntryPoint: filesystem.ComposeFileDefaultName,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ CreatedByUserID: 1,
+ ArtifactSources: &portainer.ArtifactSources{
+ SourceIDs: []portainer.SourceID{},
+ },
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ Title: "Git Template",
+ Description: "Updated with file content",
+ FileContent: "version: '3'\nservices:\n app:\n image: alpine",
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.Nil(t, tmpl.ArtifactSources)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(1)
+ require.NoError(t, err)
+ require.Nil(t, stored.ArtifactSources)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func TestCustomTemplateUpdate_GitRepository_Success(t *testing.T) {
+ t.Parallel()
+
+ handler, ds, _ := newTestHandler(t)
+ handler.GitService = &gitServiceCreatingFile{}
+
+ projectDir := t.TempDir()
+
+ require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ return tx.CustomTemplate().Create(&portainer.CustomTemplate{
+ ID: 1,
+ Title: "Git Template",
+ EntryPoint: filesystem.ComposeFileDefaultName,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ CreatedByUserID: 1,
+ ProjectPath: projectDir,
+ })
+ }))
+
+ payload := customTemplateUpdatePayload{
+ Title: "Git Template",
+ Description: "Updated via git",
+ RepositoryURL: "https://github.com/example/repo",
+ RepositoryReferenceName: "refs/heads/main",
+ ComposeFilePathInRepository: filesystem.ComposeFileDefaultName,
+ Type: portainer.DockerComposeStack,
+ Platform: portainer.CustomTemplatePlatformLinux,
+ }
+
+ r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
+ rr := httptest.NewRecorder()
+
+ herr := handler.customTemplateUpdate(rr, r)
+ require.Nil(t, herr)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ var tmpl portainer.CustomTemplate
+ require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
+ require.NotNil(t, tmpl.ArtifactSources)
+ require.Len(t, tmpl.ArtifactSources.SourceIDs, 1)
+ require.Equal(t, "deadbeef123", tmpl.ArtifactSources.Artifact.ConfigHash)
+
+ err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
+ stored, err := tx.CustomTemplate().Read(1)
+ require.NoError(t, err)
+ require.NotNil(t, stored.ArtifactSources)
+
+ src, err := tx.Source().Read(stored.ArtifactSources.SourceIDs[0])
+ require.NoError(t, err)
+ require.Equal(t, portainer.SourceTypeGit, src.Type)
+ require.Equal(t, "https://github.com/example/repo", src.GitConfig.URL)
+
+ return nil
+ })
+ require.NoError(t, err)
+}
diff --git a/api/http/handler/customtemplates/utils.go b/api/http/handler/customtemplates/utils.go
index 7ae060d49..e99945925 100644
--- a/api/http/handler/customtemplates/utils.go
+++ b/api/http/handler/customtemplates/utils.go
@@ -2,11 +2,47 @@ package customtemplates
import (
"errors"
+ "regexp"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
)
-func validateVariablesDefinitions(variables []portainer.CustomTemplateVariableDefinition) error {
+func populateGitConfig(tx dataservices.DataStoreTx, template *portainer.CustomTemplate) {
+ if template.ArtifactSources == nil || len(template.ArtifactSources.SourceIDs) == 0 {
+ return
+ }
+
+ src, err := tx.Source().Read(template.ArtifactSources.SourceIDs[0])
+ if err != nil || src.GitConfig == nil {
+ return
+ }
+
+ cfg := *src.GitConfig
+ cfg.ReferenceName = template.ArtifactSources.Artifact.ReferenceName
+ cfg.ConfigFilePath = template.ArtifactSources.Artifact.ConfigFilePath
+ cfg.ConfigHash = template.ArtifactSources.Artifact.ConfigHash
+
+ if cfg.Authentication != nil {
+ sanitized := *cfg.Authentication
+ sanitized.Password = ""
+ cfg.Authentication = &sanitized
+ }
+
+ template.GitConfig = &cfg
+}
+
+// IsValidNote reports whether note is safe to display. Notes containing
tags are rejected.
+func IsValidNote(note string) bool {
+ if len(note) == 0 {
+ return true
+ }
+ match, _ := regexp.MatchString("
0 {
+ return ErrSourceInUse
+ }
+
return tx.Source().Delete(portainer.SourceID(sourceID))
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, ErrSourceInUse) {
- return httperror.Conflict("Source is used by one or more workflows", err)
+ return httperror.Conflict("Source is used by one or more workflows or custom templates", err)
} else if err != nil {
return httperror.InternalServerError("Unable to delete source", err)
}
diff --git a/api/http/handler/gitops/sources/delete_test.go b/api/http/handler/gitops/sources/delete_test.go
index f148e962e..7ae04ffc0 100644
--- a/api/http/handler/gitops/sources/delete_test.go
+++ b/api/http/handler/gitops/sources/delete_test.go
@@ -14,6 +14,7 @@ import (
func TestSourceDelete_Success(t *testing.T) {
t.Parallel()
+
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
@@ -35,6 +36,7 @@ func TestSourceDelete_Success(t *testing.T) {
func TestSourceDelete_NotFound(t *testing.T) {
t.Parallel()
+
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
@@ -50,6 +52,7 @@ func TestSourceDelete_NotFound(t *testing.T) {
func TestSourceDelete_InUse(t *testing.T) {
t.Parallel()
+
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
@@ -75,6 +78,7 @@ func TestSourceDelete_InUse(t *testing.T) {
func TestSourceDelete_NonNumericID(t *testing.T) {
t.Parallel()
+
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
@@ -87,3 +91,34 @@ func TestSourceDelete_NonNumericID(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rr.Code)
}
+
+func TestSourceDelete_InUseByCustomTemplate(t *testing.T) {
+ t.Parallel()
+
+ _, store := datastore.MustNewTestStore(t, false, true)
+
+ var srcID portainer.SourceID
+ require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
+ src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit}
+ err := tx.Source().Create(src)
+ require.NoError(t, err)
+ srcID = src.ID
+
+ ct := &portainer.CustomTemplate{
+ ID: 1,
+ ArtifactSources: &portainer.ArtifactSources{
+ SourceIDs: []portainer.SourceID{src.ID},
+ },
+ }
+ err = tx.CustomTemplate().Create(ct)
+ require.NoError(t, err)
+
+ return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
+ }))
+
+ h := newTestHandler(t, store)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, buildDeleteReq(t, 1, int(srcID)))
+
+ require.Equal(t, http.StatusConflict, rr.Code)
+}
diff --git a/api/portainer.go b/api/portainer.go
index 120006636..457c24c4e 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -152,6 +152,7 @@ type (
ResourceControl *ResourceControl `json:"ResourceControl"`
Variables []CustomTemplateVariableDefinition
GitConfig *gittypes.RepoConfig `json:"GitConfig"`
+ ArtifactSources *ArtifactSources `json:"ArtifactSources,omitempty"`
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
IsComposeFormat bool `example:"false"`
// EdgeTemplate indicates if this template purpose for Edge Stack