From fcdd6b45101e6d602528fea7a14e0565c5f08771 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 15 Jun 2026 18:49:26 +0300 Subject: [PATCH] feat(stacks): use source id to create git stacks [BE-13043] (#2870) Co-authored-by: Claude Sonnet 4.6 --- api/gitops/workflows/source_artifact.go | 25 +- api/gitops/workflows/source_artifact_test.go | 132 ++++ api/http/handler/gitops/sources/get.go | 2 +- api/http/handler/gitops/sources/handler.go | 2 +- .../handler/stacks/create_compose_stack.go | 38 +- api/http/handler/stacks/create_swarm_stack.go | 38 +- api/http/handler/stacks/response.go | 42 +- api/http/handler/stacks/source_auth.go | 27 + api/http/handler/stacks/source_auth_test.go | 105 ++++ api/http/handler/stacks/stack_inspect.go | 7 +- api/http/handler/stacks/stack_update_git.go | 108 ++-- .../stacks/stack_update_git_redeploy.go | 3 +- .../handler/stacks/update_kubernetes_stack.go | 2 +- api/portainer.go | 2 + api/stacks/deployments/deploy.go | 1 + api/stacks/stackbuilders/stack_git_builder.go | 71 ++- .../stackbuilders/stack_git_builder_test.go | 133 ++++ api/stacks/stackbuilders/stack_payload.go | 5 +- app/portainer/react/components/git-form.ts | 2 + .../EditGitSettings/EditGitSettingsModal.tsx | 5 +- .../stacks/EditGitSettings/InnerForm.tsx | 3 + .../EditGitSettings/useUpdateGitStack.ts | 8 +- .../stacks/EditGitSettings/validation.ts | 8 +- .../createStandaloneStackFromGit.ts | 2 + .../useCreateStack/createSwarmStackFromGit.ts | 2 + .../queries/useCreateStack/useCreateStack.ts | 2 + .../common/stacks/queries/useStackFile.ts | 2 +- app/react/common/stacks/types.ts | 5 + .../CreateStackForm/CreateStackForm.test.tsx | 33 +- .../GitSection/GitSection.test.tsx | 25 +- .../CreateStackForm/GitSection/GitSection.tsx | 1 + .../GitSection/validation.test.ts | 1 + .../CreateStackForm/GitSection/validation.ts | 2 +- .../ItemView/StackInfoTab/StackInfoTab.tsx | 1 + .../generated-api/portainer/sdk.gen.ts | 4 +- .../generated-api/portainer/types.gen.ts | 595 +++++++++++------- .../generated-api/portainer/zod.gen.ts | 154 +++-- .../gitops/AuthFieldset/AuthFieldset.tsx | 20 +- .../gitops/ComposePathField/PathSelector.tsx | 6 +- app/react/portainer/gitops/GitForm.tsx | 147 +++-- .../portainer/gitops/GitReferenceCard.tsx | 39 ++ .../portainer/gitops/RefField/RefSelector.tsx | 3 +- app/react/portainer/gitops/RefField/types.ts | 1 + .../portainer/gitops/queries/useGitRefs.ts | 1 + .../portainer/gitops/queries/useSearch.ts | 1 + .../queries/useUpdateGitStackSettings.ts | 1 + .../gitops/sources/GitSourceSelector.tsx | 40 ++ .../gitops/sources/queries/useSources.ts | 6 +- app/react/portainer/gitops/types.ts | 4 +- app/setup-tests/setup-handlers/gitops.ts | 20 + 50 files changed, 1383 insertions(+), 504 deletions(-) create mode 100644 api/http/handler/stacks/source_auth.go create mode 100644 api/http/handler/stacks/source_auth_test.go create mode 100644 api/stacks/stackbuilders/stack_git_builder_test.go create mode 100644 app/react/portainer/gitops/sources/GitSourceSelector.tsx diff --git a/api/gitops/workflows/source_artifact.go b/api/gitops/workflows/source_artifact.go index f3a5b2ded2..d2a4a1c952 100644 --- a/api/gitops/workflows/source_artifact.go +++ b/api/gitops/workflows/source_artifact.go @@ -238,6 +238,19 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m } } + return SaveWorkflowArtifact(tx, workflowID, matchArtifact, oldSourceID, portainer.ArtifactFile{ + SourceID: newSourceID, + Ref: cfg.ReferenceName, + Path: cfg.ConfigFilePath, + Hash: cfg.ConfigHash, + }) +} + +// SaveWorkflowArtifact replaces the ArtifactFile referencing oldSourceID on the Artifact matched by +// matchArtifact with update (its SourceID may repoint the Artifact to a different Source). It does not +// modify any Source's git config — the caller is responsible for ensuring update.SourceID +// references a valid existing Source. +func SaveWorkflowArtifact(tx gitSourceStore, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, update portainer.ArtifactFile) error { wf, err := tx.Workflow().Read(workflowID) if err != nil { return fmt.Errorf("failed to read workflow: %w", err) @@ -253,13 +266,11 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m continue } - wf.Artifacts[i].Files[j].Ref = cfg.ReferenceName - wf.Artifacts[i].Files[j].Path = cfg.ConfigFilePath - wf.Artifacts[i].Files[j].Hash = cfg.ConfigHash - - if newSourceID != oldSourceID { - wf.Artifacts[i].Files[j].SourceID = newSourceID - } + f := &wf.Artifacts[i].Files[j] + f.SourceID = update.SourceID + f.Ref = update.Ref + f.Path = update.Path + f.Hash = update.Hash break } diff --git a/api/gitops/workflows/source_artifact_test.go b/api/gitops/workflows/source_artifact_test.go index 44406a4ccf..230ddda512 100644 --- a/api/gitops/workflows/source_artifact_test.go +++ b/api/gitops/workflows/source_artifact_test.go @@ -790,6 +790,84 @@ func TestUpdateArtifactFileForStack_MultipleArtifactsOnlyMatchingUpdated(t *test require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash) } +func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var oldSourceID, newSourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + // Two distinct sources sharing the same URL: the case where URL-based + // resolution would fail to switch. + old := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(old) + require.NoError(t, err) + oldSourceID = old.ID + + selected := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + Authentication: &gittypes.GitAuthentication{ + Username: "selected-user", + Password: "selected-pass", + }, + }, + } + err = tx.Source().Create(selected) + require.NoError(t, err) + newSourceID = selected.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{ + SourceID: oldSourceID, + Ref: "refs/heads/main", + Path: "docker-compose.yml", + Hash: "old-hash", + }}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowArtifact(tx, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, oldSourceID, portainer.ArtifactFile{ + SourceID: newSourceID, + Ref: "refs/heads/dev", + Path: "compose.yml", + Hash: "new-hash", + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, newSourceID, wf.Artifacts[0].Files[0].SourceID) + require.Equal(t, "refs/heads/dev", wf.Artifacts[0].Files[0].Ref) + require.Equal(t, "compose.yml", wf.Artifacts[0].Files[0].Path) + require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) + + // The selected source's git config must be left untouched. + selected, err := store.Source().Read(newSourceID) + require.NoError(t, err) + require.Equal(t, "https://github.com/example/repo", selected.Git.URL) + require.Equal(t, "selected-user", selected.Git.Authentication.Username) + require.Equal(t, "selected-pass", selected.Git.Authentication.Password) +} + func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) @@ -829,6 +907,60 @@ func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t * require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash) } +func TestSaveWorkflowArtifact_SameSourceUpdatesArtifactOnly(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var sourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(src) + require.NoError(t, err) + sourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{ + SourceID: sourceID, + Ref: "refs/heads/main", + }}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowArtifact(tx, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, sourceID, portainer.ArtifactFile{ + SourceID: sourceID, + Ref: "refs/heads/dev", + Path: "compose.yml", + Hash: "new-hash", + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Len(t, wf.Artifacts[0].Files, 1) + require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID) + require.Equal(t, "refs/heads/dev", wf.Artifacts[0].Files[0].Ref) + require.Equal(t, "compose.yml", wf.Artifacts[0].Files[0].Path) + require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) +} + func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) diff --git a/api/http/handler/gitops/sources/get.go b/api/http/handler/gitops/sources/get.go index 51f83ac067..eb5258147c 100644 --- a/api/http/handler/gitops/sources/get.go +++ b/api/http/handler/gitops/sources/get.go @@ -38,7 +38,7 @@ type SourceDetail struct { // @id GitOpsSourceGet // @summary Get a GitOps source by ID // @description Returns a single GitOps source with its connection settings and linked workflows. -// @description **Access policy**: admin +// @description **Access policy**: authenticated // @tags gitops // @security ApiKeyAuth // @security jwt diff --git a/api/http/handler/gitops/sources/handler.go b/api/http/handler/gitops/sources/handler.go index 960322c2ea..7b6d8632f4 100644 --- a/api/http/handler/gitops/sources/handler.go +++ b/api/http/handler/gitops/sources/handler.go @@ -41,12 +41,12 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor authenticatedRouter.Use(bouncer.AuthenticatedAccess) authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) authenticatedRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet) + authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet) adminRouter := h.PathPrefix("/gitops/sources").Subrouter() adminRouter.Use(bouncer.AdminAccess) adminRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost) adminRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost) - adminRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet) adminRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut) adminRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete) adminRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 23cbb3dad5..67aae66248 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -171,15 +171,18 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, type composeStackFromGitRepositoryPayload struct { // Name of the stack Name string `example:"myStack" 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 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. RepositoryUsername string `example:"myGitUsername"` - // Password used in basic authentication. Required when RepositoryAuthentication is true. + // Deprecated: use SourceID instead. Password used in basic authentication. RepositoryPassword string `example:"myGitPassword"` // Path to the Stack file inside the Git repository ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` @@ -191,14 +194,15 @@ type composeStackFromGitRepositoryPayload struct { Env []portainer.Pair // Whether the stack is from a app template FromAppTemplate bool `example:"false"` - // 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"` } -func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool) stackbuilders.StackPayload { +func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload { return stackbuilders.StackPayload{ Name: name, RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + SourceID: sourceID, URL: repoUrl, ReferenceName: repoReference, Authentication: repoAuthentication, @@ -218,11 +222,14 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e if len(payload.Name) == 0 { return errors.New("Invalid stack name") } - 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") + } } return update.ValidateAutoUpdateSettings(payload.AutoUpdate) @@ -271,6 +278,12 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } } + if payload.SourceID != 0 { + if _, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID); httpErr != nil { + return httpErr + } + } + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { return httperror.InternalServerError("Unable to retrieve info from request context", err) @@ -288,6 +301,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite payload.Env, payload.FromAppTemplate, payload.TLSSkipVerify, + payload.SourceID, ) composeStackBuilder := stackbuilders.CreateComposeStackGitBuilder(securityContext, diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 23c205809d..3f2d0af1bd 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -112,15 +112,18 @@ type swarmStackFromGitRepositoryPayload struct { // A list of environment variables used during stack deployment Env []portainer.Pair - // 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 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. RepositoryUsername string `example:"myGitUsername"` - // Password used in basic authentication. Required when RepositoryAuthentication is true. + // Deprecated: use SourceID instead. Password used in basic authentication. RepositoryPassword string `example:"myGitPassword"` // Whether the stack is from a app template FromAppTemplate bool `example:"false"` @@ -130,7 +133,7 @@ type swarmStackFromGitRepositoryPayload struct { AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` // Optional GitOps update configuration AutoUpdate *portainer.AutoUpdateSettings - // 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"` } @@ -141,21 +144,25 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err if len(payload.SwarmID) == 0 { return errors.New("Invalid Swarm ID") } - if len(payload.RepositoryURL) == 0 || !valid.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 || !valid.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") + } } return update.ValidateAutoUpdateSettings(payload.AutoUpdate) } -func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool) stackbuilders.StackPayload { +func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload { return stackbuilders.StackPayload{ Name: name, SwarmID: swarmID, RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + SourceID: sourceID, URL: repoUrl, ReferenceName: repoReference, Authentication: repoAuthentication, @@ -210,6 +217,12 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, } } + if payload.SourceID != 0 { + if _, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID); httpErr != nil { + return httpErr + } + } + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { return httperror.InternalServerError("Unable to retrieve info from request context", err) @@ -228,6 +241,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, payload.Env, payload.FromAppTemplate, payload.TLSSkipVerify, + payload.SourceID, ) swarmStackBuilder := stackbuilders.CreateSwarmStackGitBuilder(securityContext, diff --git a/api/http/handler/stacks/response.go b/api/http/handler/stacks/response.go index e0eafc5e39..e5cdbe9816 100644 --- a/api/http/handler/stacks/response.go +++ b/api/http/handler/stacks/response.go @@ -7,6 +7,12 @@ import ( "github.com/portainer/portainer/api/gitops/workflows" ) +// stackResponse extends a Stack response with the git source identifier. +type stackResponse struct { + portainer.Stack + GitSourceId portainer.SourceID `json:"GitSourceId,omitempty"` +} + // loadGitConfigForStack reads the merged GitConfig (Source URL/auth/TLS + Artifact ref/path/hash) // and the SourceID for the given stack. func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) { @@ -18,10 +24,40 @@ func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.Wor return workflows.MergeSourceAndFile(src, file), src.ID, nil } -func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { - return workflows.SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { +// saveStackGitConfig persists the stack's git settings. When newSourceID is non-zero the stack's +// artifact is repointed to that existing Source (selected by the caller) without modifying any +// Source's git config; otherwise the target Source is derived from cfg.URL. +func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { + matchArtifact := func(a portainer.Artifact) bool { return a.StackID == stackID - }, oldSourceID, cfg) + } + + if newSourceID != 0 { + return workflows.SaveWorkflowArtifact(tx, workflowID, matchArtifact, oldSourceID, portainer.ArtifactFile{ + SourceID: newSourceID, + Ref: cfg.ReferenceName, + Path: cfg.ConfigFilePath, + Hash: cfg.ConfigHash, + }) + } + + return workflows.SaveWorkflowGitConfig(tx, workflowID, matchArtifact, oldSourceID, cfg) +} + +// newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId. +func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*stackResponse, error) { + if stack.WorkflowID == 0 { + return &stackResponse{Stack: *stack}, nil + } + + gitConfig, gitSourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID) + if err != nil { + return nil, err + } + + stack.GitConfig = gittypes.SanitizeRepoConfig(gitConfig) + + return &stackResponse{Stack: *stack, GitSourceId: gitSourceID}, nil } // fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses. diff --git a/api/http/handler/stacks/source_auth.go b/api/http/handler/stacks/source_auth.go new file mode 100644 index 0000000000..995aca6d27 --- /dev/null +++ b/api/http/handler/stacks/source_auth.go @@ -0,0 +1,27 @@ +package stacks + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// validateSourceForStack 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) { + src, err := tx.Source().Read(sourceID) + if err != nil { + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Source not found", err) + } + return nil, httperror.InternalServerError("Unable to read source", err) + } + + if src.Type != portainer.SourceTypeGit { + return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil) + } + + return src, nil +} diff --git a/api/http/handler/stacks/source_auth_test.go b/api/http/handler/stacks/source_auth_test.go new file mode 100644 index 0000000000..3d46317e16 --- /dev/null +++ b/api/http/handler/stacks/source_auth_test.go @@ -0,0 +1,105 @@ +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_inspect.go b/api/http/handler/stacks/stack_inspect.go index 4b33b3d381..372354cc7f 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -23,7 +23,7 @@ import ( // @security jwt // @produce json // @param id path int true "Stack identifier" -// @success 200 {object} portainer.Stack "Success" +// @success 200 {object} stackResponse "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied" // @failure 404 "Stack not found" @@ -91,9 +91,10 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht } } - if err := fillStackGitConfig(handler.DataStore, stack); err != nil { + resp, err := newStackResponse(handler.DataStore, stack) + if err != nil { return httperror.InternalServerError("Unable to load git config for stack", err) } - return response.JSON(w, stack) + return response.JSON(w, resp) } diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index db762cc209..51fdb8349b 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -22,17 +22,25 @@ import ( ) type stackGitUpdatePayload struct { - AutoUpdate *portainer.AutoUpdateSettings - Env []portainer.Pair - Prune bool - RepositoryURL string - ConfigFilePath string - AdditionalFiles []string - RepositoryReferenceName string + AutoUpdate *portainer.AutoUpdateSettings + Env []portainer.Pair + Prune bool + ConfigFilePath string + AdditionalFiles []string + RepositoryReferenceName string + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID + // Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string + // Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - TLSSkipVerify bool + // Deprecated: use SourceID instead. Username used in basic authentication. + RepositoryUsername string + // Deprecated: use SourceID instead. Password used in basic authentication. + RepositoryPassword string + // Deprecated: use SourceID instead. Skip TLS verification when cloning the Git repository. + TLSSkipVerify bool } func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { @@ -41,7 +49,7 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { // @id StackUpdateGit // @summary Update a stack's Git configs -// @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate +// @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate. When SourceID is set, URL/auth/TLS are taken from the referenced Source. // @description **Access policy**: authenticated // @tags stacks // @security ApiKeyAuth @@ -51,7 +59,7 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { // @param id path int true "Stack identifier" // @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." // @param body body stackGitUpdatePayload true "Git configs for pull and redeploy a stack" -// @success 200 {object} portainer.Stack "Success" +// @success 200 {object} stackResponse "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied" // @failure 404 "Not found" @@ -152,6 +160,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) } + // Record the current git config as the deployment baseline if it was never set (legacy stacks). if stack.CurrentDeploymentInfo == nil { stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{ RepositoryURL: gitConfig.URL, @@ -159,15 +168,12 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * ConfigFilePath: gitConfig.ConfigFilePath, AdditionalFiles: stack.AdditionalFiles, ConfigHash: gitConfig.ConfigHash, + SourceID: sourceID, } } // Update gitConfig based on payload; the updated config is saved to Source (not stack.GitConfig). gitConfig.ReferenceName = payload.RepositoryReferenceName - gitConfig.TLSSkipVerify = payload.TLSSkipVerify - if payload.RepositoryURL != "" { - gitConfig.URL = payload.RepositoryURL - } if payload.ConfigFilePath != "" { gitConfig.ConfigFilePath = payload.ConfigFilePath } @@ -186,32 +192,48 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * stack.Option = &portainer.StackOption{Prune: payload.Prune} } - if payload.RepositoryAuthentication { - password := payload.RepositoryPassword - - // When the existing stack is using the custom username/password and the password is not updated, - // the stack should keep using the saved username/password - if password == "" && gitConfig.Authentication != nil { - password = gitConfig.Authentication.Password + if payload.SourceID != 0 { + src, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID) + if httpErr != nil { + return httpErr } - gitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: password, - } - - if _, err := handler.GitService.LatestCommitID( - context.TODO(), - gitConfig.URL, - gitConfig.ReferenceName, - gitConfig.Authentication.Username, - gitConfig.Authentication.Password, - gitConfig.TLSSkipVerify, - ); err != nil { - return httperror.InternalServerError("Unable to fetch git repository", err) + if src.Git == nil { + return httperror.BadRequest("Source has no git configuration", errors.New("source has no git config")) } } else { - gitConfig.Authentication = nil + gitConfig.TLSSkipVerify = payload.TLSSkipVerify + if payload.RepositoryURL != "" { + gitConfig.URL = payload.RepositoryURL + } + + if payload.RepositoryAuthentication { + password := payload.RepositoryPassword + + // When the existing stack is using the custom username/password and the password is not updated, + // the stack should keep using the saved username/password + if password == "" && gitConfig.Authentication != nil { + password = gitConfig.Authentication.Password + } + + gitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: password, + } + + if _, err := handler.GitService.LatestCommitID( + context.TODO(), + gitConfig.URL, + gitConfig.ReferenceName, + gitConfig.Authentication.Username, + gitConfig.Authentication.Password, + gitConfig.TLSSkipVerify, + ); err != nil { + return httperror.InternalServerError("Unable to fetch git repository", err) + } + } else { + gitConfig.Authentication = nil + } } if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { @@ -222,18 +244,20 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * } } - // Save the updated stack and git config to DB. + var resp *stackResponse if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { if err := tx.Stack().Update(stack.ID, stack); err != nil { return err } - if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, gitConfig); err != nil { + if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil { return err } - return fillStackGitConfig(tx, stack) + var err error + resp, err = newStackResponse(tx, stack) + return err }); err != nil { return httperror.InternalServerError("Unable to persist the stack changes inside the database", err) } - return response.JSON(w, stack) + return response.JSON(w, resp) } diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index 65304bdbdf..183e5bfa7b 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -211,6 +211,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) ConfigFilePath: gitConfig.ConfigFilePath, AdditionalFiles: stack.AdditionalFiles, ConfigHash: newHash, + SourceID: sourceID, } stack.UpdatedBy = user.Username @@ -253,7 +254,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) if err := tx.Stack().Update(stack.ID, stack); err != nil { return err } - if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, gitConfig); err != nil { + if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { return err } diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index 7b144c058c..0a543b9749 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -111,7 +111,7 @@ func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *ht stack.AutoUpdate.JobID = jobID } - if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, gitConfig); err != nil { + if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { return httperror.InternalServerError("Unable to update source git config", err) } diff --git a/api/portainer.go b/api/portainer.go index 0a4aa8977b..1bca4c04ea 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -320,6 +320,8 @@ type ( ReferenceName string `json:"ReferenceName,omitempty"` // AdditionalFiles are the additional files used for deploying the stack AdditionalFiles []string `json:"AdditionalFiles,omitempty"` + // SourceID is the Source used for deploying the stack + SourceID SourceID `json:"SourceID,omitempty"` } // EdgeStack represents an edge stack diff --git a/api/stacks/deployments/deploy.go b/api/stacks/deployments/deploy.go index 76a0b7ed75..0f8b91d846 100644 --- a/api/stacks/deployments/deploy.go +++ b/api/stacks/deployments/deploy.go @@ -169,6 +169,7 @@ func redeployWhenChangedSecondStage( ConfigFilePath: gitConfig.ConfigFilePath, AdditionalFiles: stack.AdditionalFiles, ConfigHash: gitConfig.ConfigHash, + SourceID: gitSrc.ID, } registries, err := getUserRegistries(datastore, user, endpoint.ID) diff --git a/api/stacks/stackbuilders/stack_git_builder.go b/api/stacks/stackbuilders/stack_git_builder.go index 5733040121..66b9ddd77e 100644 --- a/api/stacks/stackbuilders/stack_git_builder.go +++ b/api/stacks/stackbuilders/stack_git_builder.go @@ -30,16 +30,34 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo } var repoConfig gittypes.RepoConfig - if payload.Authentication { - repoConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.Username, - Password: payload.Password, - } - } + var sourceID portainer.SourceID - repoConfig.URL = payload.URL - repoConfig.ReferenceName = payload.ReferenceName - repoConfig.TLSSkipVerify = payload.TLSSkipVerify + if payload.SourceID != 0 { + src, err := b.dataStore.Source().Read(payload.SourceID) + if err != nil { + return fmt.Errorf("failed to read source: %w", err) + } + if src.Git == nil { + return fmt.Errorf("source %d has no git configuration", payload.SourceID) + } + + repoConfig.URL = src.Git.URL + repoConfig.Authentication = src.Git.Authentication + repoConfig.TLSSkipVerify = src.Git.TLSSkipVerify + repoConfig.ReferenceName = payload.ReferenceName + sourceID = src.ID + } else { + if payload.Authentication { + repoConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.Username, + Password: payload.Password, + } + } + + repoConfig.URL = payload.URL + repoConfig.ReferenceName = payload.ReferenceName + repoConfig.TLSSkipVerify = payload.TLSSkipVerify + } repoConfig.ConfigFilePath = payload.ComposeFile if payload.ComposeFile == "" { @@ -71,27 +89,34 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo var workflowID portainer.WorkflowID if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { - repoConfig.URL = gittypes.SanitizeURL(repoConfig.URL) + file := portainer.ArtifactFile{ + Path: repoConfig.ConfigFilePath, + Ref: repoConfig.ReferenceName, + Hash: repoConfig.ConfigHash, + } - src, err := workflows.FindOrCreateGitSource(tx, &portainer.Source{ - Name: gittypes.RepoName(repoConfig.URL), - Type: portainer.SourceTypeGit, - Git: &repoConfig, - }) - if err != nil { - return fmt.Errorf("failed to find or create source: %w", err) + if sourceID != 0 { + file.SourceID = sourceID + } else { + repoConfig.URL = gittypes.SanitizeURL(repoConfig.URL) + + src, err := workflows.FindOrCreateGitSource(tx, &portainer.Source{ + Name: gittypes.RepoName(repoConfig.URL), + Type: portainer.SourceTypeGit, + Git: &repoConfig, + }) + if err != nil { + return fmt.Errorf("failed to find or create source: %w", err) + } + + file.SourceID = src.ID } wf := &portainer.Workflow{ Name: b.stack.Name, Artifacts: []portainer.Artifact{{ StackID: b.stack.ID, - Files: []portainer.ArtifactFile{{ - SourceID: src.ID, - Path: repoConfig.ConfigFilePath, - Ref: repoConfig.ReferenceName, - Hash: repoConfig.ConfigHash, - }}, + Files: []portainer.ArtifactFile{file}, }}, } if err := tx.Workflow().Create(wf); err != nil { diff --git a/api/stacks/stackbuilders/stack_git_builder_test.go b/api/stacks/stackbuilders/stack_git_builder_test.go new file mode 100644 index 0000000000..240f8eead7 --- /dev/null +++ b/api/stacks/stackbuilders/stack_git_builder_test.go @@ -0,0 +1,133 @@ +package stackbuilders + +import ( + "context" + "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/gitops/workflows" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubFileService satisfies portainer.FileService for git builder tests. +type stubFileService struct { + portainer.FileService +} + +func (s *stubFileService) GetStackProjectPath(stackIdentifier string) string { + return "/data/compose/" + stackIdentifier +} + +func newGitMethodBuilder(t *testing.T, commitHash string) *GitMethodStackBuilder { + t.Helper() + _, store := datastore.MustNewTestStore(t, false, false) + require.NoError(t, store.User().Create(&portainer.User{ID: 1, Username: "testuser"})) + return &GitMethodStackBuilder{ + StackBuilder: StackBuilder{ + stack: &portainer.Stack{}, + fileService: &stubFileService{}, + dataStore: store, + }, + gitService: testhelpers.NewGitService(nil, commitHash), + } +} + +func TestGitMethodStackBuilder_WithSourceID_ReferencesExistingSource(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "abc123") + builder.stack.ID = 1 + + src := &portainer.Source{ + Name: "my-repo", + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{ + URL: "https://github.com/org/private-repo", + Authentication: &gittypes.GitAuthentication{ + Username: "git-user", + Password: "git-token", + }, + }, + } + require.NoError(t, builder.dataStore.Source().Create(src)) + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + SourceID: src.ID, + ReferenceName: "refs/heads/main", + }, + } + + err := builder.prepare(context.Background(), payload, portainer.UserID(1)) + require.NoError(t, err) + + // Workflow Artifact must reference the existing Source — not a new one. + referencedSourceID := builderWorkflowSourceID(t, builder) + assert.Equal(t, src.ID, referencedSourceID) + + // Only one Source exists — no duplicate was created. + allSources, err := builder.dataStore.Source().ReadAll() + require.NoError(t, err) + assert.Len(t, allSources, 1) + + // The merged git config picks up the Source URL/auth. + readSrc, artifact, err := workflows.GitSourceAndArtifactForStack(builder.dataStore, builder.stack.WorkflowID, builder.stack.ID) + require.NoError(t, err) + merged := workflows.MergeSourceAndFile(readSrc, artifact) + assert.Equal(t, "https://github.com/org/private-repo", merged.URL) + assert.Equal(t, "refs/heads/main", merged.ReferenceName) + require.NotNil(t, merged.Authentication) + assert.Equal(t, "git-user", merged.Authentication.Username) +} + +func TestGitMethodStackBuilder_WithMissingSourceID_ReturnsError(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "abc123") + builder.stack.ID = 2 + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + SourceID: portainer.SourceID(999), // does not exist + }, + } + + err := builder.prepare(context.Background(), payload, portainer.UserID(1)) + require.Error(t, err) +} + +func TestGitMethodStackBuilder_WithoutSourceID_InlinePathStillWorks(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "feedcafe") + builder.stack.ID = 4 + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + URL: "https://github.com/org/public-repo", + ReferenceName: "refs/heads/main", + }, + } + + err := builder.prepare(context.Background(), payload, portainer.UserID(1)) + require.NoError(t, err) + + // A Source was created via the inline path. + allSources, err := builder.dataStore.Source().ReadAll() + require.NoError(t, err) + assert.Len(t, allSources, 1) + assert.Equal(t, "https://github.com/org/public-repo", allSources[0].Git.URL) +} + +// builderWorkflowSourceID returns the first SourceID referenced by the Workflow Artifact for this stack. +func builderWorkflowSourceID(t *testing.T, builder *GitMethodStackBuilder) portainer.SourceID { + t.Helper() + require.NotZero(t, builder.stack.WorkflowID) + + wf, err := builder.dataStore.Workflow().Read(builder.stack.WorkflowID) + require.NoError(t, err) + require.Len(t, wf.Artifacts, 1) + require.Len(t, wf.Artifacts[0].Files, 1) + return wf.Artifacts[0].Files[0].SourceID +} diff --git a/api/stacks/stackbuilders/stack_payload.go b/api/stacks/stackbuilders/stack_payload.go index 1a9a9bacdd..8ef005926e 100644 --- a/api/stacks/stackbuilders/stack_payload.go +++ b/api/stacks/stackbuilders/stack_payload.go @@ -36,8 +36,11 @@ type StackPayload struct { } type RepositoryConfigPayload struct { + // SourceID references an existing Source. + // When non-zero, only ReferenceName is still applied. + SourceID portainer.SourceID // URL of a Git repository hosting the Stack file - URL string `example:"https://github.com/openfaas/faas" validate:"required"` + URL string `example:"https://github.com/openfaas/faas"` // Reference name of a Git repository hosting the Stack file ReferenceName string `example:"refs/heads/master"` // Use basic authentication to clone the Git repository diff --git a/app/portainer/react/components/git-form.ts b/app/portainer/react/components/git-form.ts index d8c1b66879..d798df2636 100644 --- a/app/portainer/react/components/git-form.ts +++ b/app/portainer/react/components/git-form.ts @@ -30,6 +30,7 @@ export const gitFormModule = angular 'webhooksDocs', 'createdFromCustomTemplateId', 'isAutoUpdateVisible', + 'isSourceSelectionVisible', ]) ) @@ -41,6 +42,7 @@ export const gitFormModule = angular 'gitConfig', 'autoUpdate', 'currentDeploymentInfo', + 'sourceId', ]) ) .component( diff --git a/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx b/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx index e555656302..b1f47d00e8 100644 --- a/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx +++ b/app/react/common/stacks/EditGitSettings/EditGitSettingsModal.tsx @@ -19,7 +19,7 @@ interface Props { } export function EditGitSettingsModal({ stack, onClose }: Props) { - const validationSchema = useValidationSchema(stack.Type); + const validationSchema = useValidationSchema(stack.Type, !!stack.GitSourceId); const [webhookId] = useState( () => stack.AutoUpdate?.Webhook || createWebhookId() ); @@ -34,6 +34,8 @@ export function EditGitSettingsModal({ stack, onClose }: Props) { git: { ...gitModel, AdditionalFiles: stack.AdditionalFiles || [], + SourceId: stack.GitSourceId, + RepositoryURLValid: !!gitModel.RepositoryURL, }, env: stack.Env || [], prune: stack.Option?.Prune || false, @@ -51,6 +53,7 @@ export function EditGitSettingsModal({ stack, onClose }: Props) { void; isSubmitting: boolean; webhookId: string; @@ -105,6 +107,7 @@ export function InnerForm({ stackType === StackType.Kubernetes ? 'manifest' : 'compose' } isDockerStandalone={isDockerStandalone} + isSourceSelectionVisible={!!gitSourceId} /> { - queryClient.invalidateQueries({ + queryClient.removeQueries({ + queryKey: queryKeys.stackFile(stack.Id, { + commitHash: stack?.GitConfig?.ConfigHash, + }), + }); + return queryClient.invalidateQueries({ queryKey: queryKeys.stack(stack.Id), }); }, diff --git a/app/react/common/stacks/EditGitSettings/validation.ts b/app/react/common/stacks/EditGitSettings/validation.ts index 949d353d94..d730d8e20e 100644 --- a/app/react/common/stacks/EditGitSettings/validation.ts +++ b/app/react/common/stacks/EditGitSettings/validation.ts @@ -9,7 +9,8 @@ import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldse import { FormValues } from './types'; export function useValidationSchema( - stackType: StackType + stackType: StackType, + isSourceSelection: boolean ): SchemaOf { const isKubernetes = stackType === StackType.Kubernetes; @@ -24,13 +25,14 @@ export function useValidationSchema( git: buildGitValidationSchema( false, isKubernetes ? 'manifest' : 'compose', - true + true, + isSourceSelection ), env: envVarValidation(), prune: boolean().default(false), redeployNow: boolean().default(false), }), - [isKubernetes] + [isKubernetes, isSourceSelection] ); } diff --git a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts index 660f648566..f46d5e95fd 100644 --- a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts @@ -40,6 +40,8 @@ export type StandaloneGitRepositoryPayload = { filesystemPath?: string; /** TLSSkipVerify skips SSL verification when cloning the Git repository */ tlsSkipVerify?: boolean; + /** ID of an existing Source. When set, repositoryUrl and authentication fields are ignored. */ + sourceId?: number; environmentId: EnvironmentId; registries?: Array; }; diff --git a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts index 53ac5f95c8..6593fbb56f 100644 --- a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts @@ -42,6 +42,8 @@ export type SwarmGitRepositoryPayload = { filesystemPath?: string; /** TLSSkipVerify skips SSL verification when cloning the Git repository */ tlsSkipVerify?: boolean; + /** ID of an existing Source. When set, repositoryUrl and authentication fields are ignored. */ + sourceId?: number; environmentId: EnvironmentId; registries?: Array; }; diff --git a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts index 8b83274875..d8d2ef2b08 100644 --- a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts +++ b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts @@ -202,6 +202,7 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) { filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, tlsSkipVerify: payload.git.TLSSkipVerify, + sourceId: payload.git.SourceId, autoUpdate: transformAutoUpdateViewModel( payload.git.AutoUpdate, payload.webhook @@ -252,6 +253,7 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) { filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, tlsSkipVerify: payload.git.TLSSkipVerify, + sourceId: payload.git.SourceId, autoUpdate: transformAutoUpdateViewModel( payload.git.AutoUpdate, payload.webhook diff --git a/app/react/common/stacks/queries/useStackFile.ts b/app/react/common/stacks/queries/useStackFile.ts index 05b32c106d..901a0cb399 100644 --- a/app/react/common/stacks/queries/useStackFile.ts +++ b/app/react/common/stacks/queries/useStackFile.ts @@ -10,7 +10,7 @@ import { queryKeys } from './query-keys'; export function useStackFile( stackId?: StackId, { version, commitHash }: { version?: number; commitHash?: string } = {}, - { enabled = true }: { enabled?: boolean } = {} + { enabled }: { enabled?: boolean } = {} ) { return useQuery({ queryKey: queryKeys.stackFile(stackId, { version, commitHash }), diff --git a/app/react/common/stacks/types.ts b/app/react/common/stacks/types.ts index 2c135aa6a7..33c8d20cdb 100644 --- a/app/react/common/stacks/types.ts +++ b/app/react/common/stacks/types.ts @@ -57,6 +57,10 @@ export interface StackDeploymentInfo { ReferenceName?: string; ConfigFilePath?: string; AdditionalFiles?: string[]; + /** + * Source used for deploying the stack + */ + SourceID?: number; } export interface Stack { @@ -82,6 +86,7 @@ export interface Stack { Force: boolean; }; GitConfig?: RepoConfigResponse; + GitSourceId?: number; FromAppTemplate: boolean; Namespace?: string; IsComposeFormat: boolean; diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx index 56f6047379..cabdb4d24b 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx @@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { vi } from 'vitest'; +import { SourcesSource } from '@api/types.gen'; + import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; @@ -90,7 +92,7 @@ describe('CreateStackForm', () => { expect(element).toBeChecked(); expect( - await screen.findByRole('textbox', { name: /repository url/i }) + await screen.findByRole('combobox', { name: /source/i }) ).toBeVisible(); }); @@ -242,6 +244,25 @@ describe('CreateStackForm', () => { it('should submit git form successfully', async () => { let requestBody: unknown; server.use( + http.get('/api/gitops/sources', () => + HttpResponse.json( + [ + { + id: 1, + name: 'my-source', + type: 'git', + url: 'https://github.com/test/repo', + status: 'healthy', + }, + ], + { + headers: { + 'x-total-count': '1', + 'x-total-available': '1', + }, + } + ) + ), http.post( '/api/stacks/create/standalone/repository', async ({ request }) => { @@ -268,17 +289,17 @@ describe('CreateStackForm', () => { // Switch to git await user.click(await screen.findByRole('radio', { name: /repository/i })); - // Fill in form // using paste to reduce test validation time and test time const nameInput = screen.getByRole('textbox', { name: /name/i }); await user.clear(nameInput); await user.paste('test-stack'); - const urlField = await screen.findByRole('textbox', { - name: /repository url/i, + // Select a source (sets URL and SourceId) + const sourceInput = await screen.findByRole('combobox', { + name: /source/i, }); - await user.clear(urlField); - await user.paste('https://github.com/test/repo'); + await user.click(sourceInput); + await user.click(await screen.findByRole('option', { name: 'my-source' })); const refsField = screen.getByLabelText(/reference/i); await user.clear(refsField); diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx index 4eb2959d31..7da40bb371 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx @@ -17,27 +17,10 @@ describe('GitSection', () => { expect(screen.getByText('Git repository')).toBeInTheDocument(); }); - it('should render authentication toggle', () => { + it('should render the source selector', () => { renderComponent(); - expect(screen.getByText('Authentication')).toBeInTheDocument(); - }); - - it('should render TLS skip verification toggle', () => { - renderComponent(); - - expect(screen.getByText('Skip TLS Verification')).toBeInTheDocument(); - }); - - it('should render with git authentication enabled', () => { - renderComponent({ - initialValues: { - RepositoryAuthentication: true, - RepositoryUsername: 'testuser', - }, - }); - - expect(screen.getByText('Authentication')).toBeInTheDocument(); + expect(screen.getByText('Source')).toBeInTheDocument(); }); it('should render with auto update enabled', () => { @@ -79,13 +62,9 @@ function renderComponent({ RepositoryURL: '', RepositoryReferenceName: 'refs/heads/main', ComposeFilePathInRepository: 'docker-compose.yml', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', TLSSkipVerify: false, AdditionalFiles: [], AutoUpdate: undefined, - RepositoryAuthorizationType: undefined, SupportRelativePath: false, FilesystemPath: '', ...initialValues, diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx index ea648f08cb..f2214cfa77 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx @@ -35,6 +35,7 @@ export function GitSection({ webhookId, isDockerStandalone = false }: Props) { isAdditionalFilesFieldVisible isAuthExplanationVisible isForcePullVisible + isSourceSelectionVisible errors={errors.git} baseWebhookUrl={baseStackWebhookUrl()} webhookId={webhookId} 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 16534e0bc7..0e96400137 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.test.ts @@ -18,6 +18,7 @@ describe('Git validation', () => { RepositoryAuthorizationType: undefined, SupportRelativePath: false, FilesystemPath: '', + SourceId: 1, }; await expect(schema.validate(validData)).resolves.toBeDefined(); diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts index b9c47e95b7..c973c2d3d7 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/validation.ts @@ -5,7 +5,7 @@ import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm'; import { GitFormValues } from './types'; export function getGitValidationSchema(): SchemaOf { - return buildGitValidationSchema(false, 'compose').concat( + return buildGitValidationSchema(false, 'compose', false, true).concat( object({ SupportRelativePath: boolean().default(false), FilesystemPath: string() diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx index 9dd2074f60..3d71be7f5b 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx @@ -101,6 +101,7 @@ export function StackInfoTab({ autoUpdate={stack.AutoUpdate} currentDeploymentInfo={stack.CurrentDeploymentInfo} stackType="docker" + sourceId={stack.GitSourceId} /> )} diff --git a/app/react/portainer/generated-api/portainer/sdk.gen.ts b/app/react/portainer/generated-api/portainer/sdk.gen.ts index ed05449d16..e8dcc606b7 100644 --- a/app/react/portainer/generated-api/portainer/sdk.gen.ts +++ b/app/react/portainer/generated-api/portainer/sdk.gen.ts @@ -3916,7 +3916,7 @@ export const gitOpsSourcesDelete = ( * Get a GitOps source by ID * * Returns a single GitOps source with its connection settings and linked workflows. - * **Access policy**: admin + * **Access policy**: authenticated */ export const gitOpsSourceGet = ( options: Options @@ -8001,7 +8001,7 @@ export const stackFileInspect = ( /** * Update a stack's Git configs * - * Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate + * Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate. When SourceID is set, URL/auth/TLS are taken from the referenced Source. * **Access policy**: authenticated */ export const stackUpdateGit = ( diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index 3fd4e9e9aa..21b7bc769f 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -3979,11 +3979,11 @@ export type StacksSwarmStackFromGitRepositoryPayload = { */ Name: string; /** - * 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. */ RepositoryPassword?: string; /** @@ -3991,19 +3991,24 @@ export type StacksSwarmStackFromGitRepositoryPayload = { */ 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. */ RepositoryUsername?: string; + /** + * SourceID references an existing Source for git credentials/URL. + * When set, the inline URL and authentication fields are ignored. + */ + SourceID?: number; /** * Swarm cluster identifier */ SwarmID: 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; }; @@ -4031,6 +4036,329 @@ export type StacksSwarmStackFromFileContentPayload = { SwarmID: string; }; +export type StacksStackResponse = { + /** + * Only applies when deploying stack with multiple files + */ + AdditionalFiles?: Array; + /** + * The GitOps update settings of a git stack + */ + AutoUpdate?: PortainerAutoUpdateSettings; + /** + * The username which created this stack + */ + CreatedBy?: string; + /** + * The date in unix time when stack was created + */ + CreationDate?: number; + /** + * CurrentDeploymentInfo records the git repository state at the time of the last actual deployment. + */ + CurrentDeploymentInfo?: PortainerStackDeploymentInfo; + /** + * DeploymentStartStatus is the stack status captured when the current + * deployment starts. It is used by deployment logic during the current + * deployment attempt and is cleared/replaced when a new deployment begins. + */ + DeploymentStartStatus?: PortainerStackStatus; + /** + * DeploymentStatus records the status progression of the current deployment. + * Cleared when a new deployment starts. + */ + DeploymentStatus?: Array; + /** + * Environment(Endpoint) identifier. Reference the environment(endpoint) that will be used for deployment + */ + EndpointId?: number; + /** + * EntryPoint is the path to the config file relative to the project root. + * NOTE: For git stacks this mirrors GitConfig.ConfigFilePath and the two are kept in sync + * by stackUpdateGit. The deploy command builder (compose_unpacker_cmd_builder) uses this + * field directly; Kubernetes deploy and git clone operations use GitConfig.ConfigFilePath. + */ + EntryPoint?: string; + /** + * A list of environment(endpoint) variables used during stack deployment + */ + Env?: Array; + /** + * Whether the stack is from a app template + */ + FromAppTemplate?: boolean; + /** + * GitConfig is the git repository configuration for git-backed stacks. + * Deprecated: loaded from Source via WorkflowID; kept for DB backwards-compatibility only. + * Non-migration code must not read or write this field; use Source records instead. + */ + GitConfig?: GittypesRepoConfig; + GitSourceId?: number; + /** + * Stack Identifier + */ + Id?: number; + /** + * Stack name + */ + Name?: string; + /** + * Kubernetes namespace if stack is a kube application + */ + Namespace?: string; + /** + * The stack deployment option + */ + Option?: PortainerStackOption; + /** + * Path on disk to the repository hosting the Stack file + */ + ProjectPath?: string; + ResourceControl?: PortainerResourceControl; + /** + * Stack status (1 - active, 2 - inactive, 3 - deploying, 4 - error) + */ + Status?: PortainerStackStatus; + /** + * Cluster identifier of the Swarm cluster where the stack is deployed + */ + SwarmId?: string; + /** + * Stack type. 1 for a Swarm stack, 2 for a Compose stack + */ + Type?: PortainerStackType; + /** + * The date in unix time when stack was last updated + */ + UpdateDate?: number; + /** + * The username which last updated this stack + */ + UpdatedBy?: string; + /** + * WorkflowID is the ID of the Workflow that owns the Source for this stack. + */ + WorkflowID?: number; +}; + +export const PortainerStackType = { + /** + * _ + */ + '': 0, + /** + * DockerSwarmStack + */ + DOCKER_SWARM_STACK: 1, + /** + * DockerComposeStack + */ + DOCKER_COMPOSE_STACK: 2, + /** + * KubernetesStack + */ + KUBERNETES_STACK: 3, +} as const; + +export type PortainerStackType = + (typeof PortainerStackType)[keyof typeof PortainerStackType]; + +export type PortainerUserResourceAccess = { + AccessLevel?: PortainerResourceAccessLevel; + UserId?: number; +}; + +export const PortainerResourceAccessLevel = { + /** + * _ + */ + '': 0 /** + * ReadWriteAccessLevel + */, + READ_WRITE_ACCESS_LEVEL: 1, +} as const; + +export type PortainerResourceAccessLevel = + (typeof PortainerResourceAccessLevel)[keyof typeof PortainerResourceAccessLevel]; + +export const PortainerResourceControlType = { + /** + * _ + */ + '': 0, + /** + * ContainerResourceControl + */ + CONTAINER_RESOURCE_CONTROL: 1, + /** + * ServiceResourceControl + */ + SERVICE_RESOURCE_CONTROL: 2, + /** + * VolumeResourceControl + */ + VOLUME_RESOURCE_CONTROL: 3, + /** + * NetworkResourceControl + */ + NETWORK_RESOURCE_CONTROL: 4, + /** + * SecretResourceControl + */ + SECRET_RESOURCE_CONTROL: 5, + /** + * StackResourceControl + */ + STACK_RESOURCE_CONTROL: 6, + /** + * ConfigResourceControl + */ + CONFIG_RESOURCE_CONTROL: 7, + /** + * CustomTemplateResourceControl + */ + CUSTOM_TEMPLATE_RESOURCE_CONTROL: 8, + /** + * ContainerGroupResourceControl + */ + CONTAINER_GROUP_RESOURCE_CONTROL: 9, +} as const; + +export type PortainerResourceControlType = + (typeof PortainerResourceControlType)[keyof typeof PortainerResourceControlType]; + +export type PortainerTeamResourceAccess = { + AccessLevel?: PortainerResourceAccessLevel; + TeamId?: number; +}; + +export type PortainerResourceControl = { + AccessLevel?: PortainerResourceAccessLevel; + /** + * Permit access to resource only to admins + */ + AdministratorsOnly?: boolean; + /** + * ResourceControl Identifier + */ + Id?: number; + /** + * Deprecated fields + * Deprecated in DBVersion == 2 + */ + OwnerId?: number; + /** + * Permit access to the associated resource to any user + */ + Public?: boolean; + /** + * Docker resource identifier on which access control will be applied.\ + * In the case of a resource control applied to a stack, use the stack name as identifier + */ + ResourceId?: string; + /** + * List of Docker resources that will inherit this access control + */ + SubResourceIds?: Array; + System?: boolean; + TeamAccesses?: Array; + /** + * Type of Docker resource. Valid values are: 1- container, 2 -service + * 3 - volume, 4 - secret, 5 - stack, 6 - config or 7 - custom template + */ + Type?: PortainerResourceControlType; + UserAccesses?: Array; +}; + +export type PortainerStackOption = { + /** + * Enable atomic rollback on failure (Helm --atomic flag for Kubernetes Helm stacks) + */ + HelmAtomic?: boolean; + /** + * Prune services that are no longer referenced + */ + Prune?: boolean; +}; + +export type PortainerStackDeploymentStatus = { + /** + * populated on Error entries + */ + Message?: string; + Status?: PortainerStackStatus; + Time?: number; +}; + +export const PortainerStackStatus = { + /** + * _ + */ + '': 0, + /** + * StackStatusActive + * + * 1 - deployed and running + */ + STACK_STATUS_ACTIVE: 1, + /** + * StackStatusInactive + * + * 2 - intentionally stopped + */ + STACK_STATUS_INACTIVE: 2, + /** + * StackStatusDeploying + * + * 3 - deployment in progress + */ + STACK_STATUS_DEPLOYING: 3, + /** + * StackStatusError + * + * 4 - deployment failed + */ + STACK_STATUS_ERROR: 4, +} as const; + +export type PortainerStackStatus = + (typeof PortainerStackStatus)[keyof typeof PortainerStackStatus]; + +export type PortainerStackDeploymentInfo = { + /** + * AdditionalFiles are the additional files used for deploying the stack + */ + AdditionalFiles?: Array; + /** + * ConfigFilePath is the path to the config file in the git repository used for deploying the stack + */ + ConfigFilePath?: string; + /** + * ConfigHash is the commit hash of the git repository used for deploying the stack + */ + ConfigHash?: string; + /** + * FileVersion is the version of the stack file, used to detect changes + */ + FileVersion?: number; + /** + * ReferenceName is the git reference (branch/tag) used for deploying the stack + */ + ReferenceName?: string; + /** + * RepositoryURL is the git repository URL used for deploying the stack + */ + RepositoryURL?: string; + /** + * SourceID is the Source used for deploying the stack + */ + SourceID?: number; + /** + * Version is the version of the stack and also is the deployed version in edge agent + */ + Version?: number; +}; + export type StacksStackMigratePayload = { /** * Environment(Endpoint) identifier of the target environment(endpoint) where the stack will be relocated @@ -4052,11 +4380,31 @@ export type StacksStackGitUpdatePayload = { ConfigFilePath?: string; Env?: Array; Prune?: boolean; + /** + * 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; 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; + /** + * Deprecated: use SourceID instead. Skip TLS verification when cloning the Git repository. + */ TLSSkipVerify?: boolean; }; @@ -4148,11 +4496,11 @@ export type StacksComposeStackFromGitRepositoryPayload = { */ Name: string; /** - * 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. */ RepositoryPassword?: string; /** @@ -4160,15 +4508,20 @@ export type StacksComposeStackFromGitRepositoryPayload = { */ 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. */ 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; }; @@ -4626,52 +4979,6 @@ export type ResourcecontrolsResourceControlCreatePayload = { Users?: Array; }; -export const PortainerResourceControlType = { - /** - * _ - */ - '': 0, - /** - * ContainerResourceControl - */ - CONTAINER_RESOURCE_CONTROL: 1, - /** - * ServiceResourceControl - */ - SERVICE_RESOURCE_CONTROL: 2, - /** - * VolumeResourceControl - */ - VOLUME_RESOURCE_CONTROL: 3, - /** - * NetworkResourceControl - */ - NETWORK_RESOURCE_CONTROL: 4, - /** - * SecretResourceControl - */ - SECRET_RESOURCE_CONTROL: 5, - /** - * StackResourceControl - */ - STACK_RESOURCE_CONTROL: 6, - /** - * ConfigResourceControl - */ - CONFIG_RESOURCE_CONTROL: 7, - /** - * CustomTemplateResourceControl - */ - CUSTOM_TEMPLATE_RESOURCE_CONTROL: 8, - /** - * ContainerGroupResourceControl - */ - CONTAINER_GROUP_RESOURCE_CONTROL: 9, -} as const; - -export type PortainerResourceControlType = - (typeof PortainerResourceControlType)[keyof typeof PortainerResourceControlType]; - export type ReleaseValues = { computedValues?: string; userSuppliedValues?: string; @@ -5320,24 +5627,6 @@ export const PortainerUserRole = { export type PortainerUserRole = (typeof PortainerUserRole)[keyof typeof PortainerUserRole]; -export type PortainerUserResourceAccess = { - AccessLevel?: PortainerResourceAccessLevel; - UserId?: number; -}; - -export const PortainerResourceAccessLevel = { - /** - * _ - */ - '': 0 /** - * ReadWriteAccessLevel - */, - READ_WRITE_ACCESS_LEVEL: 1, -} as const; - -export type PortainerResourceAccessLevel = - (typeof PortainerResourceAccessLevel)[keyof typeof PortainerResourceAccessLevel]; - export type PortainerUser = { /** * User Identifier @@ -5353,11 +5642,6 @@ export type PortainerUser = { Username: string; }; -export type PortainerTeamResourceAccess = { - AccessLevel?: PortainerResourceAccessLevel; - TeamId?: number; -}; - export type PortainerTeamMembership = { /** * Membership Identifier @@ -5429,113 +5713,6 @@ export type PortainerTag = { Name?: string; }; -export const PortainerStackType = { - /** - * _ - */ - '': 0, - /** - * DockerSwarmStack - */ - DOCKER_SWARM_STACK: 1, - /** - * DockerComposeStack - */ - DOCKER_COMPOSE_STACK: 2, - /** - * KubernetesStack - */ - KUBERNETES_STACK: 3, -} as const; - -export type PortainerStackType = - (typeof PortainerStackType)[keyof typeof PortainerStackType]; - -export const PortainerStackStatus = { - /** - * _ - */ - '': 0, - /** - * StackStatusActive - * - * 1 - deployed and running - */ - STACK_STATUS_ACTIVE: 1, - /** - * StackStatusInactive - * - * 2 - intentionally stopped - */ - STACK_STATUS_INACTIVE: 2, - /** - * StackStatusDeploying - * - * 3 - deployment in progress - */ - STACK_STATUS_DEPLOYING: 3, - /** - * StackStatusError - * - * 4 - deployment failed - */ - STACK_STATUS_ERROR: 4, -} as const; - -export type PortainerStackStatus = - (typeof PortainerStackStatus)[keyof typeof PortainerStackStatus]; - -export type PortainerStackOption = { - /** - * Enable atomic rollback on failure (Helm --atomic flag for Kubernetes Helm stacks) - */ - HelmAtomic?: boolean; - /** - * Prune services that are no longer referenced - */ - Prune?: boolean; -}; - -export type PortainerStackDeploymentStatus = { - /** - * populated on Error entries - */ - Message?: string; - Status?: PortainerStackStatus; - Time?: number; -}; - -export type PortainerStackDeploymentInfo = { - /** - * AdditionalFiles are the additional files used for deploying the stack - */ - AdditionalFiles?: Array; - /** - * ConfigFilePath is the path to the config file in the git repository used for deploying the stack - */ - ConfigFilePath?: string; - /** - * ConfigHash is the commit hash of the git repository used for deploying the stack - */ - ConfigHash?: string; - /** - * FileVersion is the version of the stack file, used to detect changes - */ - FileVersion?: number; - /** - * ReferenceName is the git reference (branch/tag) used for deploying the stack - */ - ReferenceName?: string; - /** - * RepositoryURL is the git repository URL used for deploying the stack - */ - RepositoryURL?: string; - /** - * Version is the version of the stack and also is the deployed version in edge agent - */ - Version?: number; -}; - export type PortainerStack = { /** * Only applies when deploying stack with multiple files @@ -5640,44 +5817,6 @@ export type PortainerStack = { WorkflowID?: number; }; -export type PortainerResourceControl = { - AccessLevel?: PortainerResourceAccessLevel; - /** - * Permit access to resource only to admins - */ - AdministratorsOnly?: boolean; - /** - * ResourceControl Identifier - */ - Id?: number; - /** - * Deprecated fields - * Deprecated in DBVersion == 2 - */ - OwnerId?: number; - /** - * Permit access to the associated resource to any user - */ - Public?: boolean; - /** - * Docker resource identifier on which access control will be applied.\ - * In the case of a resource control applied to a stack, use the stack name as identifier - */ - ResourceId?: string; - /** - * List of Docker resources that will inherit this access control - */ - SubResourceIds?: Array; - System?: boolean; - TeamAccesses?: Array; - /** - * Type of Docker resource. Valid values are: 1- container, 2 -service - * 3 - volume, 4 - secret, 5 - stack, 6 - config or 7 - custom template - */ - Type?: PortainerResourceControlType; - UserAccesses?: Array; -}; - export const PortainerSourceType = { /** * _ @@ -16177,7 +16316,7 @@ export type StackInspectResponses = { /** * Success */ - 200: PortainerStack; + 200: StacksStackResponse; }; export type StackInspectResponse = @@ -16378,7 +16517,7 @@ export type StackUpdateGitResponses = { /** * Success */ - 200: PortainerStack; + 200: StacksStackResponse; }; export type StackUpdateGitResponse = diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index 63fb65a5df..35269257e3 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -1008,8 +1008,9 @@ export const zStacksSwarmStackFromGitRepositoryPayload = 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(), SwarmID: z.string(), TLSSkipVerify: z.boolean().optional(), }); @@ -1022,6 +1023,92 @@ export const zStacksSwarmStackFromFileContentPayload = z.object({ SwarmID: z.string(), }); +export const zPortainerStackType = z.enum(PortainerStackType); + +export const zPortainerResourceAccessLevel = z.enum( + PortainerResourceAccessLevel +); + +export const zPortainerUserResourceAccess = z.object({ + AccessLevel: zPortainerResourceAccessLevel.optional(), + UserId: z.int().optional(), +}); + +export const zPortainerResourceControlType = z.enum( + PortainerResourceControlType +); + +export const zPortainerTeamResourceAccess = z.object({ + AccessLevel: zPortainerResourceAccessLevel.optional(), + TeamId: z.int().optional(), +}); + +export const zPortainerResourceControl = z.object({ + AccessLevel: zPortainerResourceAccessLevel.optional(), + AdministratorsOnly: z.boolean().optional(), + Id: z.int().optional(), + OwnerId: z.int().optional(), + Public: z.boolean().optional(), + ResourceId: z.string().optional(), + SubResourceIds: z.array(z.string()).optional(), + System: z.boolean().optional(), + TeamAccesses: z.array(zPortainerTeamResourceAccess).optional(), + Type: zPortainerResourceControlType.optional(), + UserAccesses: z.array(zPortainerUserResourceAccess).optional(), +}); + +export const zPortainerStackOption = z.object({ + HelmAtomic: z.boolean().optional(), + Prune: z.boolean().optional(), +}); + +export const zPortainerStackStatus = z.enum(PortainerStackStatus); + +export const zPortainerStackDeploymentStatus = z.object({ + Message: z.string().optional(), + Status: zPortainerStackStatus.optional(), + Time: z.int().optional(), +}); + +export const zPortainerStackDeploymentInfo = z.object({ + AdditionalFiles: z.array(z.string()).optional(), + ConfigFilePath: z.string().optional(), + ConfigHash: z.string().optional(), + FileVersion: z.int().optional(), + ReferenceName: z.string().optional(), + RepositoryURL: z.string().optional(), + SourceID: z.int().optional(), + Version: z.int().optional(), +}); + +export const zStacksStackResponse = z.object({ + AdditionalFiles: z.array(z.string()).optional(), + AutoUpdate: zPortainerAutoUpdateSettings.optional(), + CreatedBy: z.string().optional(), + CreationDate: z.int().optional(), + CurrentDeploymentInfo: zPortainerStackDeploymentInfo.optional(), + DeploymentStartStatus: zPortainerStackStatus.optional(), + DeploymentStatus: z.array(zPortainerStackDeploymentStatus).optional(), + EndpointId: z.int().optional(), + EntryPoint: z.string().optional(), + Env: z.array(zPortainerPair).optional(), + FromAppTemplate: z.boolean().optional(), + GitConfig: zGittypesRepoConfig.optional(), + GitSourceId: z.int().optional(), + Id: z.int().optional(), + Name: z.string().optional(), + Namespace: z.string().optional(), + Option: zPortainerStackOption.optional(), + ProjectPath: z.string().optional(), + ResourceControl: zPortainerResourceControl.optional(), + Status: zPortainerStackStatus.optional(), + SwarmId: z.string().optional(), + Type: zPortainerStackType.optional(), + UpdateDate: z.int().optional(), + UpdatedBy: z.string().optional(), + WorkflowID: z.int().optional(), +}); + export const zStacksStackMigratePayload = z.object({ EndpointID: z.int(), Name: z.string().optional(), @@ -1039,6 +1126,7 @@ export const zStacksStackGitUpdatePayload = z.object({ RepositoryReferenceName: z.string().optional(), RepositoryURL: z.string().optional(), RepositoryUsername: z.string().optional(), + SourceID: z.int().optional(), TLSSkipVerify: z.boolean().optional(), }); @@ -1098,8 +1186,9 @@ export const zStacksComposeStackFromGitRepositoryPayload = 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(), }); @@ -1327,10 +1416,6 @@ export const zResourcecontrolsResourceControlCreatePayload = z.object({ Users: z.array(z.int()).optional(), }); -export const zPortainerResourceControlType = z.enum( - PortainerResourceControlType -); - export const zReleaseValues = z.object({ computedValues: z.string().optional(), userSuppliedValues: z.string().optional(), @@ -1610,15 +1695,6 @@ export const zPortainerUserThemeSettings = z.object({ export const zPortainerUserRole = z.enum(PortainerUserRole); -export const zPortainerResourceAccessLevel = z.enum( - PortainerResourceAccessLevel -); - -export const zPortainerUserResourceAccess = z.object({ - AccessLevel: zPortainerResourceAccessLevel.optional(), - UserId: z.int().optional(), -}); - export const zPortainerUser = z.object({ Id: z.int(), Role: zPortainerUserRole, @@ -1628,11 +1704,6 @@ export const zPortainerUser = z.object({ Username: z.string(), }); -export const zPortainerTeamResourceAccess = z.object({ - AccessLevel: zPortainerResourceAccessLevel.optional(), - TeamId: z.int().optional(), -}); - export const zPortainerMembershipRole = z.enum(PortainerMembershipRole); export const zPortainerTeamMembership = z.object({ @@ -1654,45 +1725,6 @@ export const zPortainerTag = z.object({ Name: z.string().optional(), }); -export const zPortainerStackType = z.enum(PortainerStackType); - -export const zPortainerStackStatus = z.enum(PortainerStackStatus); - -export const zPortainerStackOption = z.object({ - HelmAtomic: z.boolean().optional(), - Prune: z.boolean().optional(), -}); - -export const zPortainerStackDeploymentStatus = z.object({ - Message: z.string().optional(), - Status: zPortainerStackStatus.optional(), - Time: z.int().optional(), -}); - -export const zPortainerStackDeploymentInfo = z.object({ - AdditionalFiles: z.array(z.string()).optional(), - ConfigFilePath: z.string().optional(), - ConfigHash: z.string().optional(), - FileVersion: z.int().optional(), - ReferenceName: z.string().optional(), - RepositoryURL: z.string().optional(), - Version: z.int().optional(), -}); - -export const zPortainerResourceControl = z.object({ - AccessLevel: zPortainerResourceAccessLevel.optional(), - AdministratorsOnly: z.boolean().optional(), - Id: z.int().optional(), - OwnerId: z.int().optional(), - Public: z.boolean().optional(), - ResourceId: z.string().optional(), - SubResourceIds: z.array(z.string()).optional(), - System: z.boolean().optional(), - TeamAccesses: z.array(zPortainerTeamResourceAccess).optional(), - Type: zPortainerResourceControlType.optional(), - UserAccesses: z.array(zPortainerUserResourceAccess).optional(), -}); - export const zPortainerStack = z.object({ AdditionalFiles: z.array(z.string()).optional(), AutoUpdate: zPortainerAutoUpdateSettings.optional(), @@ -5351,7 +5383,7 @@ export const zStackInspectPath = z.object({ /** * Success */ -export const zStackInspectResponse = zPortainerStack; +export const zStackInspectResponse = zStacksStackResponse; /** * Stack details @@ -5411,7 +5443,7 @@ export const zStackUpdateGitQuery = z.object({ /** * Success */ -export const zStackUpdateGitResponse = zPortainerStack; +export const zStackUpdateGitResponse = zStacksStackResponse; /** * Git configs for pull and redeploy of a stack. **StackName** may only be populated for Kuberenetes stacks, and if specified with a blank string, it will be set to blank diff --git a/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx b/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx index 7f53dc87b5..d6de2d2647 100644 --- a/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx +++ b/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx @@ -76,23 +76,27 @@ export function gitAuthValidation( return object({ RepositoryAuthentication: boolean().default(false), RepositoryUsername: string() - .when(['RepositoryAuthentication'], { - is: (auth: boolean) => auth, + .when(['RepositoryAuthentication', 'SourceId'], { + is: (auth: boolean, sourceId?: number) => auth && !sourceId, then: string().required('Username is required'), }) .default(''), RepositoryPassword: string() - .when(['RepositoryAuthentication'], { - is: (auth: boolean) => - auth && !isAuthEdit && !isCreatedFromCustomTemplate, + .when(['RepositoryAuthentication', 'SourceId'], { + is: (auth: boolean, sourceId?: number) => + auth && !sourceId && !isAuthEdit && !isCreatedFromCustomTemplate, then: string().required('Personal Access Token is required'), }) .default(''), RepositoryAuthorizationType: mixed() .oneOf(Object.values(AuthTypeOption)) - .when(['RepositoryAuthentication'], { - is: (auth: boolean) => - isBE && auth && !isAuthEdit && !isCreatedFromCustomTemplate, + .when(['RepositoryAuthentication', 'SourceId'], { + is: (auth: boolean, sourceId?: number) => + isBE && + auth && + !sourceId && + !isAuthEdit && + !isCreatedFromCustomTemplate, then: mixed().required('Authorization type is required'), }) .default(AuthTypeOption.Basic), diff --git a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx index 6790fa4e55..cc91528578 100644 --- a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx +++ b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx @@ -15,6 +15,7 @@ export type PathSelectorGitModel = Pick< | 'RepositoryReferenceName' | 'TLSSkipVerify' | 'RepositoryURLValid' + | 'SourceId' >; export function PathSelector({ @@ -44,10 +45,13 @@ export function PathSelector({ tlsSkipVerify: model.TLSSkipVerify, dirOnly, createdFromCustomTemplateId, + sourceId: model.SourceId, ...creds, }; + const enabled = Boolean( - model.RepositoryURL && model.RepositoryURLValid && value + ((model.RepositoryURL && model.RepositoryURLValid) || model.SourceId) && + value ); const { data: searchResults } = useSearch(payload, enabled); diff --git a/app/react/portainer/gitops/GitForm.tsx b/app/react/portainer/gitops/GitForm.tsx index be02d5ee03..67157cabae 100644 --- a/app/react/portainer/gitops/GitForm.tsx +++ b/app/react/portainer/gitops/GitForm.tsx @@ -1,12 +1,13 @@ -import { array, boolean, object, SchemaOf, string } from 'yup'; -import { FormikErrors } from 'formik'; import { useState } from 'react'; +import { array, boolean, number, object, SchemaOf, string } from 'yup'; +import { FormikErrors } from 'formik'; import { ComposePathField } from '@/react/portainer/gitops/ComposePathField'; import { RefField } from '@/react/portainer/gitops/RefField'; import { GitFormUrlField } from '@/react/portainer/gitops/GitFormUrlField'; import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types'; import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay'; +import { GitSourceSelector } from '@/react/portainer/gitops/sources/GitSourceSelector'; import { FormSection } from '@@/form-components/FormSection'; import { validateForm } from '@@/form-components/validate-form'; @@ -33,6 +34,8 @@ interface Props { webhooksDocs?: string; createdFromCustomTemplateId?: number; isAutoUpdateVisible?: boolean; + /** When true, shows a SourceSelector instead of the manual git fields. The manual git fields are deprecated and will be removed (BE-13047). */ + isSourceSelectionVisible?: boolean; } export function GitForm({ @@ -50,52 +53,72 @@ export function GitForm({ webhooksDocs, createdFromCustomTemplateId, isAutoUpdateVisible = true, + isSourceSelectionVisible = false, }: Props) { const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs return ( - - - { - handleChange({ - RepositoryURL: value, - RepositoryReferenceName: initialValue.RepositoryReferenceName, - ComposeFilePathInRepository: - initialValue.ComposeFilePathInRepository, - RepositoryURLValid: false, - }); - }} - onChangeRepositoryValid={(isValid) => - handleChange({ - RepositoryURLValid: isValid, - }) - } - model={value} - createdFromCustomTemplateId={createdFromCustomTemplateId} - errors={errors.RepositoryURL} - /> - -
-
- handleChange({ TLSSkipVerify: value })} - name="TLSSkipVerify" - tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate." - labelClass="col-sm-3 col-lg-2" + {isSourceSelectionVisible ? ( + + handleChange({ + SourceId: source?.id, + RepositoryURL: source?.url ?? '', + RepositoryReferenceName: initialValue.RepositoryReferenceName, + ComposeFilePathInRepository: + initialValue.ComposeFilePathInRepository, + RepositoryURLValid: !!source, + }) + } + error={errors.SourceId as string | undefined} + /> + ) : ( + <> + -
-
+ + { + handleChange({ + RepositoryURL: value, + RepositoryReferenceName: initialValue.RepositoryReferenceName, + ComposeFilePathInRepository: + initialValue.ComposeFilePathInRepository, + RepositoryURLValid: false, + }); + }} + onChangeRepositoryValid={(isValid) => + handleChange({ + RepositoryURLValid: isValid, + }) + } + model={value} + createdFromCustomTemplateId={createdFromCustomTemplateId} + errors={errors.RepositoryURL} + /> + +
+
+ handleChange({ TLSSkipVerify: value })} + name="TLSSkipVerify" + tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate." + labelClass="col-sm-3 col-lg-2" + /> +
+
+ + )} { return object({ - RepositoryURL: string() - .test('valid URL', 'The URL must be a valid URL', (value) => { - if (!value) { - return true; - } - - try { - const url = new URL(value); - return !!url.hostname; - } catch { - return false; - } - }) - .required('Repository URL is required'), + // In source-selection mode the repository URL is derived from the selected + // source (not user-editable), so the user provides a SourceId instead and + // the URL itself needs no validation. + RepositoryURL: isSourceSelection + ? string() + : string() + .test('valid URL', 'The URL must be a valid URL', isValidGitUrl) + .required('Repository URL is required'), RepositoryReferenceName: refFieldValidation(), ComposeFilePathInRepository: string().required( deployMethod === 'compose' @@ -190,7 +208,22 @@ export function buildGitValidationSchema( RepositoryURLValid: boolean().default(false), AutoUpdate: autoUpdateValidation().nullable(), TLSSkipVerify: boolean().default(false), + SourceId: isSourceSelection + ? number().min(1, 'Source is required').required('Source is required') + : number().optional().nullable(), }).concat( gitAuthValidation(isEdit, isCreatedFromCustomTemplate) ) as SchemaOf; } + +function isValidGitUrl(value?: string) { + if (!value) { + return true; + } + + try { + return !!new URL(value).hostname; + } catch { + return false; + } +} diff --git a/app/react/portainer/gitops/GitReferenceCard.tsx b/app/react/portainer/gitops/GitReferenceCard.tsx index a0ca9c2465..f899e9d241 100644 --- a/app/react/portainer/gitops/GitReferenceCard.tsx +++ b/app/react/portainer/gitops/GitReferenceCard.tsx @@ -13,11 +13,13 @@ import { RepoConfigResponse, } from '@/react/portainer/gitops/types'; import { StackDeploymentInfo } from '@/react/common/stacks/types'; +import { useSource } from '@/react/portainer/gitops/sources/queries/useSource'; import { CopyButton } from '@@/buttons'; import { Card } from '@@/Card'; import { Icon } from '@@/Icon'; import { Alert } from '@@/Alert'; +import { Link } from '@@/Link'; import { getGitValidityError } from './hooks/useGitRepoValidity'; @@ -27,12 +29,14 @@ export function GitReferenceCard({ gitConfig, autoUpdate, currentDeploymentInfo, + sourceId, }: { stackId: number; stackType: 'docker' | 'helm' | 'edge' | 'edge-helm' | 'kubernetes'; gitConfig: RepoConfigResponse; autoUpdate?: AutoUpdateResponse | null; currentDeploymentInfo?: StackDeploymentInfo | null; + sourceId?: number; }) { const hasDivergence = isGitConfigDiverged(gitConfig, currentDeploymentInfo); @@ -41,6 +45,7 @@ export function GitReferenceCard({ const configFilePath = deployed?.ConfigFilePath ?? gitConfig.ConfigFilePath; const reference = deployed?.ReferenceName ?? gitConfig.ReferenceName; const commitId = deployed?.ConfigHash ?? gitConfig.ConfigHash; + const sourceIdToShow = deployed?.SourceID ?? sourceId; const fromEdgeStack = stackType === 'edge' || stackType === 'edge-helm'; @@ -49,6 +54,7 @@ export function GitReferenceCard({ repository: url || '', stackId, fromEdgeStack, + sourceId: sourceIdToShow, }, { enabled: !!url, suppressError: true } ); @@ -75,6 +81,7 @@ export function GitReferenceCard({ stackId, fromEdgeStack, reference, + sourceId: sourceIdToShow, }, enableFileCheck && !!url && @@ -179,6 +186,7 @@ export function GitReferenceCard({ data-cy="git-file-path" /> )} + {!!sourceIdToShow && } {!!commitId && ( + {sourceName} + + ) : sourceQuery.isLoading ? ( + '' + ) : ( + 'not found' + ) + } + title={sourceName ?? ''} + isLoading={sourceQuery.isLoading} + isError={sourceQuery.isError || (!sourceQuery.isLoading && !sourceName)} + isValid={!!sourceName} + data-cy="git-source" + /> + ); +} + function DivergenceAlert({ gitConfig, currentDeploymentInfo, diff --git a/app/react/portainer/gitops/RefField/RefSelector.tsx b/app/react/portainer/gitops/RefField/RefSelector.tsx index d7998fb5d9..f9bc899e8a 100644 --- a/app/react/portainer/gitops/RefField/RefSelector.tsx +++ b/app/react/portainer/gitops/RefField/RefSelector.tsx @@ -30,13 +30,14 @@ export function RefSelector({ stackId, createdFromCustomTemplateId, tlsSkipVerify: model.TLSSkipVerify, + sourceId: model.SourceId, ...creds, }; const { data: refs } = useGitRefs>( payload, { - enabled: !!(model.RepositoryURL && isUrlValid), + enabled: !!((model.RepositoryURL && isUrlValid) || model.SourceId), select: (refs) => { if (refs.length === 0) { return [{ value: 'refs/heads/main', label: 'refs/heads/main' }]; diff --git a/app/react/portainer/gitops/RefField/types.ts b/app/react/portainer/gitops/RefField/types.ts index e0b35f4af8..dd4b918dd8 100644 --- a/app/react/portainer/gitops/RefField/types.ts +++ b/app/react/portainer/gitops/RefField/types.ts @@ -3,4 +3,5 @@ import { GitAuthModel } from '../types'; export interface RefFieldModel extends GitAuthModel { RepositoryURL: string; TLSSkipVerify?: boolean; + SourceId?: number; } diff --git a/app/react/portainer/gitops/queries/useGitRefs.ts b/app/react/portainer/gitops/queries/useGitRefs.ts index 9c71c34218..4f3f40122b 100644 --- a/app/react/portainer/gitops/queries/useGitRefs.ts +++ b/app/react/portainer/gitops/queries/useGitRefs.ts @@ -17,6 +17,7 @@ interface RefsPayload { createdFromCustomTemplateID?: number; tlsSkipVerify?: boolean; force?: boolean; + sourceId?: number; } export function useGitRefs( diff --git a/app/react/portainer/gitops/queries/useSearch.ts b/app/react/portainer/gitops/queries/useSearch.ts index c2aa0cda8f..63a73fe397 100644 --- a/app/react/portainer/gitops/queries/useSearch.ts +++ b/app/react/portainer/gitops/queries/useSearch.ts @@ -18,6 +18,7 @@ interface SearchPayload { createdFromCustomTemplateId?: number; stackId?: number; fromEdgeStack?: boolean; + sourceId?: number; } export function useSearch(payload: SearchPayload, enabled: boolean) { diff --git a/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts b/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts index 48f6ff8e59..82056f93e7 100644 --- a/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts +++ b/app/react/portainer/gitops/queries/useUpdateGitStackSettings.ts @@ -27,6 +27,7 @@ export interface GitStackPayload { HelmChartPath?: string; HelmValuesFiles?: string[]; Atomic?: boolean; + SourceID?: number; } export async function updateGitStackSettings( diff --git a/app/react/portainer/gitops/sources/GitSourceSelector.tsx b/app/react/portainer/gitops/sources/GitSourceSelector.tsx new file mode 100644 index 0000000000..4e87da9f25 --- /dev/null +++ b/app/react/portainer/gitops/sources/GitSourceSelector.tsx @@ -0,0 +1,40 @@ +import { FormControl } from '@@/form-components/FormControl'; +import { Select } from '@@/form-components/ReactSelect'; + +import { useSources } from './queries/useSources'; +import { Source } from './types'; + +export function GitSourceSelector({ + value, + onChange, + error, +}: { + value?: Source['id']; + onChange(source?: Source | null): void; + error?: string; +}) { + const sourcesQuery = useSources({ type: 'git' }); + const sources = sourcesQuery.data?.data ?? []; + + return ( +
+
+ +