From d9673e33ec856096d6800fb8848d4cb9fb65e816 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 15 Jun 2026 22:01:31 +0300 Subject: [PATCH] feat(helm): reuse existing git sources in Kubernetes Helm-from-git install [BE-13046] (#2900) Co-authored-by: Claude --- .../sources/source_access.go} | 17 ++- api/gitops/sources/source_access_test.go | 49 ++++++++ .../handler/gitops/git_repo_file_preview.go | 59 ++++++++-- .../handler/stacks/create_compose_stack.go | 3 +- .../stacks/create_compose_stack_test.go | 31 ++++++ .../handler/stacks/create_kubernetes_stack.go | 54 ++++++--- .../stacks/create_kubernetes_stack_test.go | 67 +++++++++++ api/http/handler/stacks/create_swarm_stack.go | 3 +- .../handler/stacks/create_swarm_stack_test.go | 31 ++++++ api/http/handler/stacks/source_auth_test.go | 105 ------------------ api/http/handler/stacks/stack_update_git.go | 3 +- .../createKubernetesStackFromGit.ts | 3 + .../queries/useCreateStack/useCreateStack.ts | 1 + .../GitSection/validation.test.ts | 2 +- .../generated-api/portainer/types.gen.ts | 50 ++++++++- .../generated-api/portainer/zod.gen.ts | 6 +- .../gitops/hooks/useGitRepoValidity.ts | 8 +- .../gitops/queries/useGitFilePreview.ts | 7 +- 18 files changed, 348 insertions(+), 151 deletions(-) rename api/{http/handler/stacks/source_auth.go => gitops/sources/source_access.go} (50%) create mode 100644 api/gitops/sources/source_access_test.go create mode 100644 api/http/handler/stacks/create_compose_stack_test.go create mode 100644 api/http/handler/stacks/create_kubernetes_stack_test.go create mode 100644 api/http/handler/stacks/create_swarm_stack_test.go delete mode 100644 api/http/handler/stacks/source_auth_test.go diff --git a/api/http/handler/stacks/source_auth.go b/api/gitops/sources/source_access.go similarity index 50% rename from api/http/handler/stacks/source_auth.go rename to api/gitops/sources/source_access.go index 995aca6d27..682c69425d 100644 --- a/api/http/handler/stacks/source_auth.go +++ b/api/gitops/sources/source_access.go @@ -1,4 +1,4 @@ -package stacks +package sources import ( "fmt" @@ -8,9 +8,16 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" ) -// validateSourceForStack checks that the given Source exists and is a git Source, and returns it. +// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need. +// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types. +type gitSourceStore interface { + Source() dataservices.SourceService + IsErrObjectNotFound(err error) bool +} + +// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it. // TODO(BE-12905): enforce per-user access policies once Source ownership is introduced. -func validateSourceForStack(tx dataservices.DataStoreTx, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) { +func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) { src, err := tx.Source().Read(sourceID) if err != nil { if tx.IsErrObjectNotFound(err) { @@ -23,5 +30,9 @@ func validateSourceForStack(tx dataservices.DataStoreTx, sourceID portainer.Sour return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil) } + if src.Git == nil { + return nil, httperror.BadRequest("Source has no git configuration", nil) + } + return src, nil } diff --git a/api/gitops/sources/source_access_test.go b/api/gitops/sources/source_access_test.go new file mode 100644 index 0000000000..55e541c306 --- /dev/null +++ b/api/gitops/sources/source_access_test.go @@ -0,0 +1,49 @@ +package sources + +import ( + "net/http" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"}, + } + require.NoError(t, store.Source().Create(src)) + + _, httpErr := ValidateGitSourceAccess(store, src.ID) + assert.Nil(t, httpErr) +} + +func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + _, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999)) + require.NotNil(t, httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} + +func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + src := &portainer.Source{ + Type: portainer.SourceType(99), // not a git source + } + require.NoError(t, store.Source().Create(src)) + + _, httpErr := ValidateGitSourceAccess(store, src.ID) + require.NotNil(t, httpErr) + assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode) +} diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go index 24165334c7..e4aa405598 100644 --- a/api/http/handler/gitops/git_repo_file_preview.go +++ b/api/http/handler/gitops/git_repo_file_preview.go @@ -6,7 +6,9 @@ import ( "fmt" "net/http" + portainer "github.com/portainer/portainer/api" gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -19,19 +21,32 @@ type fileResponse struct { } type repositoryFilePreviewPayload struct { - Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"` - Reference string `json:"reference" example:"refs/heads/master"` - Username string `json:"username" example:"myGitUsername"` - Password string `json:"password" example:"myGitPassword"` + // SourceID resolves URL and auth from the stored Source record. + // When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored. + SourceID portainer.SourceID `json:"sourceID" example:"1"` + Reference string `json:"reference" example:"refs/heads/master"` // Path to file whose content will be read TargetFile string `json:"targetFile" example:"docker-compose.yml"` - // TLSSkipVerify skips SSL verification when cloning the Git repository - TLSSkipVerify bool `example:"false"` + + // URL of a Git repository to preview. + // Deprecated: use SourceID instead + Repository string `json:"repository" example:"https://github.com/openfaas/faas"` + // Username for git authentication. + // Deprecated: use SourceID instead + Username string `json:"username" example:"myGitUsername"` + // Password for git authentication. + // Deprecated: use SourceID instead + Password string `json:"password" example:"myGitPassword"` + // TLSSkipVerify skips SSL verification when cloning the Git repository. + // Deprecated: use SourceID instead + TLSSkipVerify bool `json:"tlsSkipVerify" example:"false"` } func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error { - if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) { - return errors.New("invalid repository URL. Must correspond to a valid URL format") + if payload.SourceID == 0 { + if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) { + return errors.New("invalid repository URL. Must correspond to a valid URL format") + } } if len(payload.Reference) == 0 { @@ -56,6 +71,7 @@ func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error { // @param body body repositoryFilePreviewPayload true "Template details" // @success 200 {object} fileResponse "Success" // @failure 400 "Invalid request" +// @failure 404 "Source not found" // @failure 500 "Server error" // @router /gitops/repo/file/preview [post] func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -65,6 +81,25 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht return httperror.BadRequest("Invalid request payload", err) } + repoURL := payload.Repository + username := payload.Username + password := payload.Password + tlsSkipVerify := payload.TLSSkipVerify + + if payload.SourceID != 0 { + src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, payload.SourceID) + if httpErr != nil { + return httpErr + } + + repoURL = src.Git.URL + if src.Git.Authentication != nil { + username = src.Git.Authentication.Username + password = src.Git.Authentication.Password + } + tlsSkipVerify = src.Git.TLSSkipVerify + } + projectPath, err := handler.fileService.GetTemporaryPath() if err != nil { return httperror.InternalServerError("Unable to create temporary folder", err) @@ -73,11 +108,11 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht err = handler.gitService.CloneRepository( context.TODO(), projectPath, - payload.Repository, + repoURL, payload.Reference, - payload.Username, - payload.Password, - payload.TLSSkipVerify, + username, + password, + tlsSkipVerify, ) if err != nil { if errors.Is(err, gittypes.ErrAuthenticationFailure) { diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 67aae66248..4c896ba6ca 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/stackbuilders" @@ -279,7 +280,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } if payload.SourceID != 0 { - if _, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID); httpErr != nil { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil { return httpErr } } diff --git a/api/http/handler/stacks/create_compose_stack_test.go b/api/http/handler/stacks/create_compose_stack_test.go new file mode 100644 index 0000000000..89b6591975 --- /dev/null +++ b/api/http/handler/stacks/create_compose_stack_test.go @@ -0,0 +1,31 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestComposeGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) { + t.Parallel() + payload := &composeStackFromGitRepositoryPayload{ + Name: "mystack", + SourceID: portainer.SourceID(1), + // RepositoryURL intentionally omitted + } + + err := payload.Validate(nil) + assert.NoError(t, err) +} + +func TestComposeGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) { + t.Parallel() + payload := &composeStackFromGitRepositoryPayload{ + Name: "mystack", + // SourceID and RepositoryURL both omitted + } + + err := payload.Validate(nil) + assert.Error(t, err) +} diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 915ea53e64..b179641526 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -6,6 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/registryutils" "github.com/portainer/portainer/api/stacks/stackbuilders" @@ -37,25 +38,34 @@ func createStackPayloadFromK8sFileContentPayload(name, namespace, fileContent st } type kubernetesGitDeploymentPayload struct { - StackName string - ComposeFormat bool - Namespace string - RepositoryURL string - RepositoryReferenceName string + StackName string + ComposeFormat bool + Namespace string + // 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 + // Deprecated: use SourceID instead. Reference name of a Git repository hosting the Stack file. + RepositoryReferenceName string + // Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - ManifestFile string - AdditionalFiles []string - AutoUpdate *portainer.AutoUpdateSettings - // TLSSkipVerify skips SSL verification when cloning the Git repository + // Deprecated: use SourceID instead. Username used in basic authentication. + RepositoryUsername string + // Deprecated: use SourceID instead. Password used in basic authentication. + RepositoryPassword string + ManifestFile string + AdditionalFiles []string + AutoUpdate *portainer.AutoUpdateSettings + // Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. TLSSkipVerify bool `example:"false"` } -func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, repoSkipSSLVerify bool) stackbuilders.StackPayload { +func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload { return stackbuilders.StackPayload{ StackName: name, RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + SourceID: sourceID, URL: repoUrl, ReferenceName: repoReference, Authentication: repoAuthentication, @@ -94,12 +104,13 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro } func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error { - 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.RepositoryPassword) == 0 { - return errors.New("Invalid repository credentials. 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.RepositoryPassword) == 0 { + return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") + } } if len(payload.ManifestFile) == 0 { @@ -218,6 +229,12 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr } } + if payload.SourceID != 0 { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil { + return httpErr + } + } + stackPayload := createStackPayloadFromK8sGitPayload(payload.StackName, payload.RepositoryURL, payload.RepositoryReferenceName, @@ -230,6 +247,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr payload.AdditionalFiles, payload.AutoUpdate, payload.TLSSkipVerify, + payload.SourceID, ) k8sStackBuilder := stackbuilders.CreateKubernetesStackGitBuilder(handler.DataStore, diff --git a/api/http/handler/stacks/create_kubernetes_stack_test.go b/api/http/handler/stacks/create_kubernetes_stack_test.go new file mode 100644 index 0000000000..82f472985e --- /dev/null +++ b/api/http/handler/stacks/create_kubernetes_stack_test.go @@ -0,0 +1,67 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_URLNotRequired(t *testing.T) { + t.Parallel() + + p := kubernetesGitDeploymentPayload{ + SourceID: portainer.SourceID(1), + ManifestFile: "manifest.yaml", + } + err := p.Validate(nil) + require.NoError(t, err) +} + +func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_AuthNotRequired(t *testing.T) { + t.Parallel() + + p := kubernetesGitDeploymentPayload{ + SourceID: portainer.SourceID(1), + RepositoryAuthentication: true, + // Password intentionally omitted — should not fail when SourceID is set + ManifestFile: "manifest.yaml", + } + err := p.Validate(nil) + require.NoError(t, err) +} + +func TestKubernetesGitDeploymentPayloadValidate_WithoutSourceID_URLRequired(t *testing.T) { + t.Parallel() + + p := kubernetesGitDeploymentPayload{ + ManifestFile: "manifest.yaml", + // SourceID and RepositoryURL both omitted + } + err := p.Validate(nil) + require.Error(t, err) +} + +func TestCreateStackPayloadFromK8sGitPayload_WithSourceID(t *testing.T) { + t.Parallel() + + p := createStackPayloadFromK8sGitPayload( + "k8s-stack", + "", + "", + "", + "", + false, + false, + "default", + "manifest.yaml", + nil, + nil, + false, + portainer.SourceID(7), + ) + + require.Equal(t, portainer.SourceID(7), p.SourceID) + require.Equal(t, "manifest.yaml", p.ManifestFile) +} diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 3f2d0af1bd..2d08cffb76 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -6,6 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/stackbuilders" "github.com/portainer/portainer/api/stacks/stackutils" @@ -218,7 +219,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, } if payload.SourceID != 0 { - if _, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID); httpErr != nil { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil { return httpErr } } diff --git a/api/http/handler/stacks/create_swarm_stack_test.go b/api/http/handler/stacks/create_swarm_stack_test.go new file mode 100644 index 0000000000..7efb8322ef --- /dev/null +++ b/api/http/handler/stacks/create_swarm_stack_test.go @@ -0,0 +1,31 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestSwarmGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) { + t.Parallel() + payload := &swarmStackFromGitRepositoryPayload{ + Name: "myswarm", + SwarmID: "swarm-abc", + SourceID: portainer.SourceID(1), + } + + err := payload.Validate(nil) + assert.NoError(t, err) +} + +func TestSwarmGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) { + t.Parallel() + payload := &swarmStackFromGitRepositoryPayload{ + Name: "myswarm", + SwarmID: "swarm-abc", + } + + err := payload.Validate(nil) + assert.Error(t, err) +} diff --git a/api/http/handler/stacks/source_auth_test.go b/api/http/handler/stacks/source_auth_test.go deleted file mode 100644 index 3d46317e16..0000000000 --- a/api/http/handler/stacks/source_auth_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package stacks - -import ( - "net/http" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - gittypes "github.com/portainer/portainer/api/git/types" - "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, false) - - src := &portainer.Source{ - Type: portainer.SourceTypeGit, - Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"}, - } - require.NoError(t, store.Source().Create(src)) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - _, httpErr := validateSourceForStack(store, src.ID) - assert.Nil(t, httpErr) -} - -func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, false) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - _, httpErr := validateSourceForStack(store, portainer.SourceID(999)) - require.NotNil(t, httpErr) - assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) -} - -func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, false) - - src := &portainer.Source{ - Type: portainer.SourceType(99), // not a git source - } - require.NoError(t, store.Source().Create(src)) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - _, httpErr := validateSourceForStack(store, src.ID) - require.NotNil(t, httpErr) - assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode) -} - -func TestComposeGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) { - t.Parallel() - payload := &composeStackFromGitRepositoryPayload{ - Name: "mystack", - SourceID: portainer.SourceID(1), - // RepositoryURL intentionally omitted - } - - err := payload.Validate(nil) - assert.NoError(t, err) -} - -func TestComposeGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) { - t.Parallel() - payload := &composeStackFromGitRepositoryPayload{ - Name: "mystack", - // SourceID and RepositoryURL both omitted - } - - err := payload.Validate(nil) - assert.Error(t, err) -} - -func TestSwarmGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) { - t.Parallel() - payload := &swarmStackFromGitRepositoryPayload{ - Name: "myswarm", - SwarmID: "swarm-abc", - SourceID: portainer.SourceID(1), - } - - err := payload.Validate(nil) - assert.NoError(t, err) -} - -func TestSwarmGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) { - t.Parallel() - payload := &swarmStackFromGitRepositoryPayload{ - Name: "myswarm", - SwarmID: "swarm-abc", - } - - err := payload.Validate(nil) - assert.Error(t, err) -} diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index 51fdb8349b..35b52b38ae 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -10,6 +10,7 @@ import ( "github.com/portainer/portainer/api/dataservices" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/deployments" @@ -193,7 +194,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * } if payload.SourceID != 0 { - src, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID) + src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID) if httpErr != nil { return httpErr } diff --git a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts index 8adf63ade9..2e8a10f10b 100644 --- a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts @@ -12,6 +12,9 @@ export type KubernetesGitRepositoryPayload = { composeFormat: boolean; namespace: string; + /** When set, URL and auth are resolved from the stored Source record */ + sourceId?: number; + /** URL of a Git repository hosting the Stack file */ repositoryUrl: string; /** Reference name of a Git repository hosting the Stack file */ diff --git a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts index d8d2ef2b08..8004f71be5 100644 --- a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts +++ b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts @@ -293,6 +293,7 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) { return createKubernetesStackFromGit({ stackName: payload.name, + sourceId: payload.git.SourceId, repositoryUrl: payload.git.RepositoryURL, repositoryReferenceName: payload.git.RepositoryReferenceName, manifestFile: payload.git.ComposeFilePathInRepository, diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts index 0e96400137..64f950b678 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts @@ -6,6 +6,7 @@ describe('Git validation', () => { const schema = getGitValidationSchema(); const validData: GitFormValues = { + SourceId: 1, RepositoryURL: 'https://github.com/user/repo', RepositoryReferenceName: 'refs/heads/main', ComposeFilePathInRepository: 'docker-compose.yml', @@ -18,7 +19,6 @@ describe('Git validation', () => { RepositoryAuthorizationType: undefined, SupportRelativePath: false, FilesystemPath: '', - SourceId: 1, }; await expect(schema.validate(validData)).resolves.toBeDefined(); diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index 21b7bc769f..a27ee7dcd6 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -4458,14 +4458,34 @@ export type StacksKubernetesGitDeploymentPayload = { ComposeFormat?: boolean; ManifestFile?: string; Namespace?: string; + /** + * Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. + */ RepositoryAuthentication?: boolean; + /** + * Deprecated: use SourceID instead. Password used in basic authentication. + */ RepositoryPassword?: string; + /** + * Deprecated: use SourceID instead. Reference name of a Git repository hosting the Stack file. + */ RepositoryReferenceName?: string; + /** + * Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + */ RepositoryURL?: string; + /** + * Deprecated: use SourceID instead. Username used in basic authentication. + */ RepositoryUsername?: string; + /** + * SourceID references an existing Source for git credentials/URL. + * When set, the inline URL and authentication fields are ignored. + */ + SourceID?: number; StackName?: string; /** - * TLSSkipVerify skips SSL verification when cloning the Git repository + * Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. */ TLSSkipVerify?: boolean; }; @@ -7449,16 +7469,34 @@ export type HelmInstallChartPayload = { export type GitopsRepositoryFilePreviewPayload = { /** - * TLSSkipVerify skips SSL verification when cloning the Git repository + * Password for git authentication. + * Deprecated: use SourceID instead */ - TLSSkipVerify?: boolean; password?: string; reference?: string; - repository: string; + /** + * URL of a Git repository to preview. + * Deprecated: use SourceID instead + */ + repository?: string; + /** + * SourceID resolves URL and auth from the stored Source record. + * When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored. + */ + sourceID?: number; /** * Path to file whose content will be read */ targetFile?: string; + /** + * TLSSkipVerify skips SSL verification when cloning the Git repository. + * Deprecated: use SourceID instead + */ + tlsSkipVerify?: boolean; + /** + * Username for git authentication. + * Deprecated: use SourceID instead + */ username?: string; }; @@ -11217,6 +11255,10 @@ export type GitOperationRepoFilePreviewErrors = { * Invalid request */ 400: unknown; + /** + * Source not found + */ + 404: unknown; /** * Server error */ diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index 35269257e3..79f4a185c3 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -1172,6 +1172,7 @@ export const zStacksKubernetesGitDeploymentPayload = z.object({ RepositoryReferenceName: z.string().optional(), RepositoryURL: z.string().optional(), RepositoryUsername: z.string().optional(), + SourceID: z.int().optional(), StackName: z.string().optional(), TLSSkipVerify: z.boolean().optional(), }); @@ -2711,11 +2712,12 @@ export const zHelmInstallChartPayload = z.object({ }); export const zGitopsRepositoryFilePreviewPayload = z.object({ - TLSSkipVerify: z.boolean().optional(), password: z.string().optional(), reference: z.string().optional(), - repository: z.string(), + repository: z.string().optional(), + sourceID: z.int().optional(), targetFile: z.string().optional(), + tlsSkipVerify: z.boolean().optional(), username: z.string().optional(), }); diff --git a/app/react/portainer/gitops/hooks/useGitRepoValidity.ts b/app/react/portainer/gitops/hooks/useGitRepoValidity.ts index 4feb4ece36..a4f0ab1505 100644 --- a/app/react/portainer/gitops/hooks/useGitRepoValidity.ts +++ b/app/react/portainer/gitops/hooks/useGitRepoValidity.ts @@ -18,6 +18,8 @@ interface Params { createdFromCustomTemplateId?: number; fromEdgeStack?: boolean; stackId?: number; + /** When set, the refs check will use credentials from the stored Source record */ + sourceId?: number; enabled?: boolean; onSettled?(isValid?: boolean): void; // run after onSettled, useful for clearing local flags like force @@ -32,6 +34,7 @@ export function useGitRepoValidity({ fromEdgeStack, createdFromCustomTemplateId, stackId, + sourceId, enabled, onSettled, onAfterSettle, @@ -45,9 +48,10 @@ export function useGitRepoValidity({ stackId, force, fromEdgeStack, + sourceId, }, { - enabled: !!url && enabled, + enabled: (!!url || !!sourceId) && enabled, select: () => true, suppressError: true, onSettled(isValid) { @@ -61,7 +65,7 @@ export function useGitRepoValidity({ } ); - const hasCreds = !!(creds?.username && creds?.password); + const hasCreds = !!(creds?.username && creds?.password) || !!sourceId; const errorMessage = getGitValidityError(query.error, hasCreds); diff --git a/app/react/portainer/gitops/queries/useGitFilePreview.ts b/app/react/portainer/gitops/queries/useGitFilePreview.ts index ad2b3e146d..9f6c342f73 100644 --- a/app/react/portainer/gitops/queries/useGitFilePreview.ts +++ b/app/react/portainer/gitops/queries/useGitFilePreview.ts @@ -13,6 +13,8 @@ export interface GitFilePreviewParams { password?: string; authorizationType?: AuthTypeOption; tlsSkipVerify?: boolean; + /** When set, resolves URL and auth from the stored Source record */ + sourceId?: number; } async function getFilePreview(params: GitFilePreviewParams): Promise { @@ -35,7 +37,10 @@ export function useGitFilePreview( return useQuery({ queryKey: ['gitops', 'file-preview', omitPassword(params)], queryFn: () => getFilePreview(params), - enabled: enabled && !!params.repository && !!params.targetFile, + enabled: + enabled && + (!!params.repository || !!params.sourceId) && + !!params.targetFile, select, retry: false, });