From 8daf0bb2a972db37eaccd400ebb4368b9f113eeb Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:51:18 -0300 Subject: [PATCH] feat(customtemplates): use Sources for CustomTemplates BE-12919 (#2759) --- api/datastore/migrate_data.go | 1 + api/datastore/migrator/migrate_2_43_0.go | 83 ++ api/datastore/migrator/migrate_2_43_0_test.go | 239 ++++ api/datastore/migrator/migrator.go | 9 +- .../test_data/output_24_to_latest.json | 2 +- .../customtemplates/customtemplate_create.go | 68 +- .../customtemplate_create_test.go | 1037 +++++++++++++++++ .../customtemplates/customtemplate_file.go | 4 +- .../customtemplate_file_test.go | 54 +- .../customtemplate_git_fetch.go | 31 +- .../customtemplate_git_fetch_test.go | 99 +- .../customtemplates/customtemplate_inspect.go | 8 +- .../customtemplate_inspect_test.go | 66 +- .../customtemplates/customtemplate_list.go | 5 +- .../customtemplate_list_test.go | 127 ++ .../customtemplates/customtemplate_update.go | 45 +- .../customtemplate_update_test.go | 500 ++++++++ api/http/handler/customtemplates/utils.go | 38 +- .../handler/customtemplates/utils_test.go | 173 +++ api/http/handler/gitops/sources/delete.go | 19 +- .../handler/gitops/sources/delete_test.go | 35 + api/portainer.go | 1 + 22 files changed, 2580 insertions(+), 64 deletions(-) create mode 100644 api/http/handler/customtemplates/customtemplate_create_test.go create mode 100644 api/http/handler/customtemplates/customtemplate_list_test.go create mode 100644 api/http/handler/customtemplates/customtemplate_update_test.go create mode 100644 api/http/handler/customtemplates/utils_test.go 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