mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
feat(custom-templates): reuse existing git sources in create/update [BE-13053] (#2925)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"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/sources"
|
||||
"github.com/portainer/portainer/api/gitops/workflows"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
@@ -202,21 +203,24 @@ type customTemplateFromGitRepositoryPayload struct {
|
||||
// * 3 - kubernetes
|
||||
Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
|
||||
|
||||
// URL of a Git repository hosting the Stack file
|
||||
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
|
||||
// SourceID references an existing Source for git credentials/URL.
|
||||
// When set, the inline URL and authentication fields are ignored.
|
||||
SourceID portainer.SourceID `example:"1" validate:"required"`
|
||||
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
|
||||
RepositoryURL string `example:"https://github.com/openfaas/faas"`
|
||||
// Reference name of a Git repository hosting the Stack file
|
||||
RepositoryReferenceName string `example:"refs/heads/master"`
|
||||
// Use basic authentication to clone the Git repository
|
||||
// Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
|
||||
RepositoryAuthentication bool `example:"true"`
|
||||
// Username used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
// Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryUsername string `example:"myGitUsername"`
|
||||
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
// Deprecated: use SourceID instead. Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryPassword string `example:"myGitPassword"`
|
||||
// Path to the Stack file inside the Git repository
|
||||
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
// Definitions of variables in the stack file
|
||||
Variables []portainer.CustomTemplateVariableDefinition
|
||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
TLSSkipVerify bool `example:"false"`
|
||||
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
|
||||
IsComposeFormat bool `example:"false"`
|
||||
@@ -231,11 +235,13 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
|
||||
if len(payload.Description) == 0 {
|
||||
return errors.New("Invalid custom template description")
|
||||
}
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
if payload.SourceID == 0 {
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
}
|
||||
if len(payload.ComposeFilePathInRepository) == 0 {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
@@ -295,41 +301,45 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
|
||||
projectPath := getProjectPath()
|
||||
customTemplate.ProjectPath = projectPath
|
||||
|
||||
gitConfig := &gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.ComposeFilePathInRepository,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
gitConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
}
|
||||
}
|
||||
|
||||
commitHash, err := stackutils.DownloadGitRepository(context.TODO(), *gitConfig, handler.GitService, getProjectPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
|
||||
Name: gittypes.RepoName(gitConfig.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.RepoConfig{
|
||||
URL: gitConfig.URL,
|
||||
Authentication: gitConfig.Authentication,
|
||||
TLSSkipVerify: gitConfig.TLSSkipVerify,
|
||||
},
|
||||
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{
|
||||
SourceID: payload.SourceID,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.ComposeFilePathInRepository,
|
||||
RepositoryURL: payload.RepositoryURL,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
RepositoryAuthentication: payload.RepositoryAuthentication,
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
})
|
||||
if httpErr != nil {
|
||||
return nil, httpErr
|
||||
}
|
||||
|
||||
commitHash, err := stackutils.DownloadGitRepository(context.TODO(), gitConfig, handler.GitService, getProjectPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceID := payload.SourceID
|
||||
if sourceID == 0 {
|
||||
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
|
||||
Name: gittypes.RepoName(gitConfig.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.RepoConfig{
|
||||
URL: gitConfig.URL,
|
||||
Authentication: gitConfig.Authentication,
|
||||
TLSSkipVerify: gitConfig.TLSSkipVerify,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceID = src.ID
|
||||
}
|
||||
|
||||
customTemplate.Artifact = &portainer.Artifact{
|
||||
Files: []portainer.ArtifactFile{{
|
||||
SourceID: src.ID,
|
||||
SourceID: sourceID,
|
||||
Path: gitConfig.ConfigFilePath,
|
||||
Ref: gitConfig.ReferenceName,
|
||||
Hash: commitHash,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
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/http/security"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -1035,3 +1036,69 @@ func TestCustomTemplateCreate_FromRepository_Validation_InvalidType(t *testing.T
|
||||
require.NotNil(t, herr)
|
||||
require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
|
||||
}
|
||||
|
||||
func TestCustomTemplateCreate_FromRepository_WithSourceID_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, ds, _ := newTestHandler(t)
|
||||
handler.GitService = &gitServiceCreatingFile{}
|
||||
|
||||
var srcID portainer.SourceID
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
src := &portainer.Source{
|
||||
Name: "example/repo",
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/example/repo",
|
||||
},
|
||||
}
|
||||
err := tx.Source().Create(src)
|
||||
require.NoError(t, err)
|
||||
srcID = src.ID
|
||||
return nil
|
||||
}))
|
||||
|
||||
payload := customTemplateFromGitRepositoryPayload{
|
||||
Title: "Source Template",
|
||||
Description: "Created from source ID",
|
||||
SourceID: srcID,
|
||||
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.NotNil(t, tmpl.Artifact)
|
||||
require.Len(t, tmpl.Artifact.Files, 1)
|
||||
require.Equal(t, srcID, tmpl.Artifact.Files[0].SourceID)
|
||||
require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash)
|
||||
}
|
||||
|
||||
func TestCustomTemplateCreate_FromRepository_WithSourceID_NonExistentSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, _, _ := newTestHandler(t)
|
||||
handler.GitService = &gitServiceCreatingFile{}
|
||||
|
||||
payload := customTemplateFromGitRepositoryPayload{
|
||||
Title: "Source Template",
|
||||
Description: "Created from non-existent source ID",
|
||||
SourceID: 999,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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/sources"
|
||||
"github.com/portainer/portainer/api/gitops/workflows"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -36,16 +37,18 @@ type customTemplateUpdatePayload struct {
|
||||
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
|
||||
// Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes)
|
||||
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
|
||||
// URL of a Git repository hosting the Stack file
|
||||
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
|
||||
// SourceID references an existing Source for git credentials/URL.
|
||||
// When set, the inline URL and authentication fields are ignored.
|
||||
SourceID portainer.SourceID `example:"1"`
|
||||
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
|
||||
RepositoryURL string `example:"https://github.com/openfaas/faas"`
|
||||
// Reference name of a Git repository hosting the Stack file
|
||||
RepositoryReferenceName string `example:"refs/heads/master"`
|
||||
// Use authentication to clone the Git repository
|
||||
// Deprecated: use SourceID instead. Use authentication to clone the Git repository.
|
||||
RepositoryAuthentication bool `example:"true"`
|
||||
// Username used in basic authentication. Required when RepositoryAuthentication is true
|
||||
// Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryUsername string `example:"myGitUsername"`
|
||||
// Password used in basic authentication or token used in token authentication.
|
||||
// Required when RepositoryAuthentication is true
|
||||
// Deprecated: use SourceID instead. Password used in basic authentication or token used in token authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryPassword string `example:"myGitPassword"`
|
||||
// Path to the Stack file inside the Git repository
|
||||
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
@@ -53,7 +56,7 @@ type customTemplateUpdatePayload struct {
|
||||
FileContent string `validate:"required"`
|
||||
// Definitions of variables in the stack file
|
||||
Variables []portainer.CustomTemplateVariableDefinition
|
||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
TLSSkipVerify bool `example:"false"`
|
||||
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
|
||||
IsComposeFormat bool `example:"false"`
|
||||
@@ -66,10 +69,6 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
|
||||
return errors.New("Invalid custom template title")
|
||||
}
|
||||
|
||||
if len(payload.FileContent) == 0 && len(payload.RepositoryURL) == 0 {
|
||||
return errors.New("Either file content or git repository url need to be provided")
|
||||
}
|
||||
|
||||
if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
|
||||
return errors.New("Invalid custom template platform")
|
||||
}
|
||||
@@ -86,8 +85,19 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
|
||||
return errors.New("Invalid note. <img> tag is not supported")
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
if len(payload.FileContent) == 0 && payload.SourceID == 0 {
|
||||
if len(payload.RepositoryURL) == 0 {
|
||||
return errors.New("Either file content, git repository url, or source ID need to be provided")
|
||||
}
|
||||
|
||||
if !validate.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if len(payload.ComposeFilePathInRepository) == 0 {
|
||||
@@ -172,35 +182,33 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||
customTemplate.IsComposeFormat = payload.IsComposeFormat
|
||||
customTemplate.EdgeTemplate = payload.EdgeTemplate
|
||||
|
||||
if payload.RepositoryURL != "" {
|
||||
if !validate.IsURL(payload.RepositoryURL) {
|
||||
return httperror.BadRequest("Invalid repository URL. Must correspond to a valid URL format", err)
|
||||
if payload.SourceID != 0 || payload.RepositoryURL != "" {
|
||||
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{
|
||||
SourceID: payload.SourceID,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.ComposeFilePathInRepository,
|
||||
RepositoryURL: payload.RepositoryURL,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
RepositoryAuthentication: payload.RepositoryAuthentication,
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
})
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
gitConfig := &gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.ComposeFilePathInRepository,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
}
|
||||
|
||||
repositoryUsername := ""
|
||||
repositoryPassword := ""
|
||||
if payload.RepositoryAuthentication {
|
||||
repositoryUsername = payload.RepositoryUsername
|
||||
repositoryPassword = payload.RepositoryPassword
|
||||
gitConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
}
|
||||
var username, password string
|
||||
if gitConfig.Authentication != nil {
|
||||
username = gitConfig.Authentication.Username
|
||||
password = gitConfig.Authentication.Password
|
||||
}
|
||||
|
||||
cleanBackup, err := git.CloneWithBackup(context.TODO(), handler.GitService, handler.FileService, git.CloneOptions{
|
||||
ProjectPath: customTemplate.ProjectPath,
|
||||
URL: gitConfig.URL,
|
||||
ReferenceName: gitConfig.ReferenceName,
|
||||
Username: repositoryUsername,
|
||||
Password: repositoryPassword,
|
||||
Username: username,
|
||||
Password: password,
|
||||
TLSSkipVerify: gitConfig.TLSSkipVerify,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -213,30 +221,34 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||
context.TODO(),
|
||||
gitConfig.URL,
|
||||
gitConfig.ReferenceName,
|
||||
repositoryUsername,
|
||||
repositoryPassword,
|
||||
username,
|
||||
password,
|
||||
gitConfig.TLSSkipVerify,
|
||||
)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err))
|
||||
}
|
||||
|
||||
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
|
||||
Name: gittypes.RepoName(gitConfig.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &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)
|
||||
sourceID := payload.SourceID
|
||||
if sourceID == 0 {
|
||||
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
|
||||
Name: gittypes.RepoName(gitConfig.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &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)
|
||||
}
|
||||
sourceID = src.ID
|
||||
}
|
||||
|
||||
customTemplate.Artifact = &portainer.Artifact{
|
||||
Files: []portainer.ArtifactFile{{
|
||||
SourceID: src.ID,
|
||||
SourceID: sourceID,
|
||||
Path: gitConfig.ConfigFilePath,
|
||||
Ref: gitConfig.ReferenceName,
|
||||
Hash: commitHash,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
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/http/security"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -448,6 +449,95 @@ func TestCustomTemplateUpdate_CreatorDeniedWhenAdminOnly(t *testing.T) {
|
||||
require.Equal(t, http.StatusForbidden, herr.StatusCode)
|
||||
}
|
||||
|
||||
func TestCustomTemplateUpdate_WithSourceID_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, ds, _ := newTestHandler(t)
|
||||
handler.GitService = &gitServiceCreatingFile{}
|
||||
|
||||
projectDir := t.TempDir()
|
||||
|
||||
var srcID portainer.SourceID
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
|
||||
ID: 1,
|
||||
Title: "Source Template",
|
||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||
Type: portainer.DockerComposeStack,
|
||||
Platform: portainer.CustomTemplatePlatformLinux,
|
||||
CreatedByUserID: 1,
|
||||
ProjectPath: projectDir,
|
||||
}))
|
||||
|
||||
src := &portainer.Source{
|
||||
Name: "example/repo",
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.RepoConfig{
|
||||
URL: "https://github.com/example/repo",
|
||||
},
|
||||
}
|
||||
err := tx.Source().Create(src)
|
||||
require.NoError(t, err)
|
||||
srcID = src.ID
|
||||
return nil
|
||||
}))
|
||||
|
||||
payload := customTemplateUpdatePayload{
|
||||
Title: "Source Template",
|
||||
Description: "Updated via source ID",
|
||||
SourceID: srcID,
|
||||
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.Artifact)
|
||||
require.Len(t, tmpl.Artifact.Files, 1)
|
||||
require.Equal(t, srcID, tmpl.Artifact.Files[0].SourceID)
|
||||
require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash)
|
||||
}
|
||||
|
||||
func TestCustomTemplateUpdate_WithSourceID_NonExistentSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, ds, _ := newTestHandler(t)
|
||||
handler.GitService = &gitServiceCreatingFile{}
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.CustomTemplate().Create(&portainer.CustomTemplate{
|
||||
ID: 1,
|
||||
Title: "Source Template",
|
||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||
Type: portainer.DockerComposeStack,
|
||||
Platform: portainer.CustomTemplatePlatformLinux,
|
||||
CreatedByUserID: 1,
|
||||
})
|
||||
}))
|
||||
|
||||
payload := customTemplateUpdatePayload{
|
||||
Title: "Source Template",
|
||||
Description: "Updated via non-existent source ID",
|
||||
SourceID: 999,
|
||||
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.StatusNotFound, herr.StatusCode)
|
||||
}
|
||||
|
||||
func TestCustomTemplateUpdate_AdminCanUpdateAdminOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -8018,12 +8018,11 @@ export type CustomtemplatesCustomTemplateUpdatePayload = {
|
||||
*/
|
||||
Platform?: 1 | 2;
|
||||
/**
|
||||
* Use authentication to clone the Git repository
|
||||
* Deprecated: use SourceID instead. Use authentication to clone the Git repository.
|
||||
*/
|
||||
RepositoryAuthentication?: boolean;
|
||||
/**
|
||||
* Password used in basic authentication or token used in token authentication.
|
||||
* Required when RepositoryAuthentication is true
|
||||
* Deprecated: use SourceID instead. Password used in basic authentication or token used in token authentication. Required when RepositoryAuthentication is true.
|
||||
*/
|
||||
RepositoryPassword?: string;
|
||||
/**
|
||||
@@ -8031,15 +8030,20 @@ export type CustomtemplatesCustomTemplateUpdatePayload = {
|
||||
*/
|
||||
RepositoryReferenceName?: string;
|
||||
/**
|
||||
* URL of a Git repository hosting the Stack file
|
||||
* Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
|
||||
*/
|
||||
RepositoryURL: string;
|
||||
RepositoryURL?: string;
|
||||
/**
|
||||
* Username used in basic authentication. Required when RepositoryAuthentication is true
|
||||
* Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
*/
|
||||
RepositoryUsername?: string;
|
||||
/**
|
||||
* TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
* SourceID references an existing Source for git credentials/URL.
|
||||
* When set, the inline URL and authentication fields are ignored.
|
||||
*/
|
||||
SourceID?: number;
|
||||
/**
|
||||
* Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
*/
|
||||
TLSSkipVerify?: boolean;
|
||||
/**
|
||||
@@ -8088,11 +8092,11 @@ export type CustomtemplatesCustomTemplateFromGitRepositoryPayload = {
|
||||
*/
|
||||
Platform?: 1 | 2;
|
||||
/**
|
||||
* Use basic authentication to clone the Git repository
|
||||
* Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
|
||||
*/
|
||||
RepositoryAuthentication?: boolean;
|
||||
/**
|
||||
* Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
* Deprecated: use SourceID instead. Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
*/
|
||||
RepositoryPassword?: string;
|
||||
/**
|
||||
@@ -8100,15 +8104,20 @@ export type CustomtemplatesCustomTemplateFromGitRepositoryPayload = {
|
||||
*/
|
||||
RepositoryReferenceName?: string;
|
||||
/**
|
||||
* URL of a Git repository hosting the Stack file
|
||||
* Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
|
||||
*/
|
||||
RepositoryURL: string;
|
||||
RepositoryURL?: string;
|
||||
/**
|
||||
* Username used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
* Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
*/
|
||||
RepositoryUsername?: string;
|
||||
/**
|
||||
* TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
* SourceID references an existing Source for git credentials/URL.
|
||||
* When set, the inline URL and authentication fields are ignored.
|
||||
*/
|
||||
SourceID: number;
|
||||
/**
|
||||
* Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
*/
|
||||
TLSSkipVerify?: boolean;
|
||||
/**
|
||||
|
||||
@@ -3025,8 +3025,9 @@ export const zCustomtemplatesCustomTemplateUpdatePayload = z.object({
|
||||
RepositoryAuthentication: z.boolean().optional(),
|
||||
RepositoryPassword: z.string().optional(),
|
||||
RepositoryReferenceName: z.string().optional(),
|
||||
RepositoryURL: z.string(),
|
||||
RepositoryURL: z.string().optional(),
|
||||
RepositoryUsername: z.string().optional(),
|
||||
SourceID: z.int().optional(),
|
||||
TLSSkipVerify: z.boolean().optional(),
|
||||
Title: z.string(),
|
||||
Type: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
@@ -3047,8 +3048,9 @@ export const zCustomtemplatesCustomTemplateFromGitRepositoryPayload = z.object({
|
||||
RepositoryAuthentication: z.boolean().optional(),
|
||||
RepositoryPassword: z.string().optional(),
|
||||
RepositoryReferenceName: z.string().optional(),
|
||||
RepositoryURL: z.string(),
|
||||
RepositoryURL: z.string().optional(),
|
||||
RepositoryUsername: z.string().optional(),
|
||||
SourceID: z.int(),
|
||||
TLSSkipVerify: z.boolean().optional(),
|
||||
Title: z.string(),
|
||||
Type: z.union([z.literal(1), z.literal(2)]),
|
||||
|
||||
@@ -131,6 +131,7 @@ export function InnerForm({
|
||||
}))
|
||||
}
|
||||
errors={errors.Git}
|
||||
isSourceSelectionVisible
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ export function useValidation({
|
||||
}),
|
||||
Git: mixed().when('Method', {
|
||||
is: git.value,
|
||||
then: () => buildGitValidationSchema(false, deployMethod),
|
||||
then: () =>
|
||||
buildGitValidationSchema(false, deployMethod, false, true),
|
||||
}),
|
||||
Variables: variablesValidation(),
|
||||
EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(),
|
||||
|
||||
@@ -41,6 +41,7 @@ export function EditForm({
|
||||
isGit,
|
||||
templateId: template.Id,
|
||||
deployMethod,
|
||||
isSourceSelection: isGit,
|
||||
});
|
||||
|
||||
const fileContentQuery = useCustomTemplateFile(template.Id);
|
||||
|
||||
@@ -119,6 +119,7 @@ export function InnerForm({
|
||||
values.Type === StackType.Kubernetes ? 'manifest' : 'compose'
|
||||
}
|
||||
errors={typeof errors.Git === 'object' ? errors.Git : undefined}
|
||||
isSourceSelectionVisible={!!values.Git.SourceId}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
|
||||
@@ -33,7 +33,12 @@ export function useInitialValues({
|
||||
Note: template.Note,
|
||||
Logo: template.Logo,
|
||||
Variables: template.Variables,
|
||||
Git: template.GitConfig ? toGitFormModel(template.GitConfig) : undefined,
|
||||
Git: template.GitConfig
|
||||
? {
|
||||
...toGitFormModel(template.GitConfig),
|
||||
SourceId: template.artifact?.files?.[0]?.sourceId,
|
||||
}
|
||||
: undefined,
|
||||
AccessControl:
|
||||
!isEdge && template.ResourceControl
|
||||
? parseAccessControlFormData(
|
||||
|
||||
@@ -18,11 +18,13 @@ export function useValidation({
|
||||
templateId,
|
||||
viewType,
|
||||
deployMethod,
|
||||
isSourceSelection,
|
||||
}: {
|
||||
isGit: boolean;
|
||||
templateId: CustomTemplate['Id'];
|
||||
viewType: TemplateViewType;
|
||||
deployMethod: DeployMethod;
|
||||
isSourceSelection?: boolean;
|
||||
}) {
|
||||
const customTemplatesQuery = useCustomTemplates({
|
||||
params: {
|
||||
@@ -45,7 +47,14 @@ export function useValidation({
|
||||
.default(StackType.DockerCompose),
|
||||
FileContent: string().required('Template is required.'),
|
||||
|
||||
Git: isGit ? buildGitValidationSchema(false, deployMethod) : mixed(),
|
||||
Git: isGit
|
||||
? buildGitValidationSchema(
|
||||
false,
|
||||
deployMethod,
|
||||
false,
|
||||
isSourceSelection ?? false
|
||||
)
|
||||
: mixed(),
|
||||
Variables: variablesValidation(),
|
||||
EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(),
|
||||
}).concat(
|
||||
@@ -54,6 +63,13 @@ export function useValidation({
|
||||
currentTemplateId: templateId,
|
||||
})
|
||||
),
|
||||
[customTemplatesQuery.data, isGit, templateId, viewType, deployMethod]
|
||||
[
|
||||
customTemplatesQuery.data,
|
||||
isGit,
|
||||
isSourceSelection,
|
||||
templateId,
|
||||
viewType,
|
||||
deployMethod,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,8 @@ interface CustomTemplateFromGitRepositoryPayload {
|
||||
Platform: Platform;
|
||||
/** Type of created stack. Required. */
|
||||
Type: StackType;
|
||||
/** References an existing Source for git credentials/URL. When set, inline URL and auth are ignored. */
|
||||
SourceId?: number;
|
||||
/** URL of a Git repository hosting the Stack file. Required. */
|
||||
RepositoryURL: string;
|
||||
/** Reference name of a Git repository hosting the Stack file. */
|
||||
|
||||
@@ -65,6 +65,8 @@ interface CustomTemplateUpdatePayload {
|
||||
* Required
|
||||
*/
|
||||
Type: StackType;
|
||||
/** References an existing Source for git credentials/URL. When set, inline URL and auth are ignored. */
|
||||
SourceId?: number;
|
||||
/** URL of a Git repository hosting the Stack file */
|
||||
RepositoryURL?: string;
|
||||
/** Reference name of a Git repository hosting the Stack file */
|
||||
|
||||
@@ -96,6 +96,11 @@ export type CustomTemplate = {
|
||||
EdgeTemplate: boolean;
|
||||
|
||||
EdgeSettings?: EdgeTemplateSettings;
|
||||
|
||||
/** Artifact stores the git source reference for git-backed templates. */
|
||||
artifact?: {
|
||||
files?: Array<{ sourceId?: number }>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user