feat(stacks): use source id to create git stacks [BE-13043] (#2870)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chaim Lev-Ari
2026-06-15 18:49:26 +03:00
committed by GitHub
parent 04048c3818
commit fcdd6b4510
50 changed files with 1383 additions and 504 deletions
+18 -7
View File
@@ -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
}
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+26 -12
View File
@@ -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,
+26 -12
View File
@@ -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,
+39 -3
View File
@@ -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.
+27
View File
@@ -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
}
+105
View File
@@ -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)
}
+4 -3
View File
@@ -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)
}
+66 -42
View File
@@ -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)
}
@@ -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
}
@@ -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)
}
+2
View File
@@ -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
+1
View File
@@ -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)
+48 -23
View File
@@ -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 {
@@ -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
}
+4 -1
View File
@@ -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
@@ -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(
@@ -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) {
<InnerForm
stackName={stack.Name}
stackType={stack.Type}
gitSourceId={stack.GitSourceId}
webhookId={webhookId}
onDismiss={onClose}
isSubmitting={mutation.isLoading}
@@ -24,12 +24,14 @@ import { FormValues } from './types';
export function InnerForm({
stackName,
stackType,
gitSourceId,
onDismiss,
isSubmitting,
webhookId,
}: {
stackName: string;
stackType: StackType;
gitSourceId?: number;
onDismiss: () => void;
isSubmitting: boolean;
webhookId: string;
@@ -105,6 +107,7 @@ export function InnerForm({
stackType === StackType.Kubernetes ? 'manifest' : 'compose'
}
isDockerStandalone={isDockerStandalone}
isSourceSelectionVisible={!!gitSourceId}
/>
<StackEnvironmentVariablesPanel
@@ -40,6 +40,7 @@ export function useUpdateGitStack(stack: Stack) {
AdditionalFiles: values.git.AdditionalFiles,
env: values.env,
prune: values.prune,
SourceID: values.git.SourceId,
});
if (repullImageAndRedeploy === undefined) {
@@ -66,7 +67,12 @@ export function useUpdateGitStack(stack: Stack) {
}
},
onSuccess: () => {
queryClient.invalidateQueries({
queryClient.removeQueries({
queryKey: queryKeys.stackFile(stack.Id, {
commitHash: stack?.GitConfig?.ConfigHash,
}),
});
return queryClient.invalidateQueries({
queryKey: queryKeys.stack(stack.Id),
});
},
@@ -9,7 +9,8 @@ import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldse
import { FormValues } from './types';
export function useValidationSchema(
stackType: StackType
stackType: StackType,
isSourceSelection: boolean
): SchemaOf<FormValues> {
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]
);
}
@@ -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<RegistryId>;
};
@@ -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<RegistryId>;
};
@@ -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
@@ -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 }),
+5
View File
@@ -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;
@@ -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<SourcesSource[]>(
[
{
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);
@@ -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,
@@ -35,6 +35,7 @@ export function GitSection({ webhookId, isDockerStandalone = false }: Props) {
isAdditionalFilesFieldVisible
isAuthExplanationVisible
isForcePullVisible
isSourceSelectionVisible
errors={errors.git}
baseWebhookUrl={baseStackWebhookUrl()}
webhookId={webhookId}
@@ -18,6 +18,7 @@ describe('Git validation', () => {
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
SourceId: 1,
};
await expect(schema.validate(validData)).resolves.toBeDefined();
@@ -5,7 +5,7 @@ import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { GitFormValues } from './types';
export function getGitValidationSchema(): SchemaOf<GitFormValues> {
return buildGitValidationSchema(false, 'compose').concat(
return buildGitValidationSchema(false, 'compose', false, true).concat(
object({
SupportRelativePath: boolean().default(false),
FilesystemPath: string()
@@ -101,6 +101,7 @@ export function StackInfoTab({
autoUpdate={stack.AutoUpdate}
currentDeploymentInfo={stack.CurrentDeploymentInfo}
stackType="docker"
sourceId={stack.GitSourceId}
/>
)}
@@ -3916,7 +3916,7 @@ export const gitOpsSourcesDelete = <ThrowOnError extends boolean = true>(
* 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 = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourceGetData, ThrowOnError>
@@ -8001,7 +8001,7 @@ export const stackFileInspect = <ThrowOnError extends boolean = true>(
/**
* 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 = <ThrowOnError extends boolean = true>(
@@ -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<string>;
/**
* 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<PortainerStackDeploymentStatus>;
/**
* 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<PortainerPair>;
/**
* 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<string>;
System?: boolean;
TeamAccesses?: Array<PortainerTeamResourceAccess>;
/**
* 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<PortainerUserResourceAccess>;
};
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<string>;
/**
* 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<PortainerPair>;
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<number>;
};
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<string>;
/**
* 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<string>;
System?: boolean;
TeamAccesses?: Array<PortainerTeamResourceAccess>;
/**
* 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<PortainerUserResourceAccess>;
};
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 =
@@ -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
@@ -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),
@@ -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);
+90 -57
View File
@@ -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 (
<FormSection title="Git repository">
<AuthFieldset
value={value}
onChange={handleChange}
isAuthExplanationVisible={isAuthExplanationVisible}
errors={errors}
/>
<GitFormUrlField
value={value.RepositoryURL}
onChange={(value) => {
handleChange({
RepositoryURL: value,
RepositoryReferenceName: initialValue.RepositoryReferenceName,
ComposeFilePathInRepository:
initialValue.ComposeFilePathInRepository,
RepositoryURLValid: false,
});
}}
onChangeRepositoryValid={(isValid) =>
handleChange({
RepositoryURLValid: isValid,
})
}
model={value}
createdFromCustomTemplateId={createdFromCustomTemplateId}
errors={errors.RepositoryURL}
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Skip TLS Verification"
data-cy="gitops-skip-tls-verification-switch"
checked={value.TLSSkipVerify || false}
onChange={(value) => 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 ? (
<GitSourceSelector
value={value.SourceId}
onChange={(source) =>
handleChange({
SourceId: source?.id,
RepositoryURL: source?.url ?? '',
RepositoryReferenceName: initialValue.RepositoryReferenceName,
ComposeFilePathInRepository:
initialValue.ComposeFilePathInRepository,
RepositoryURLValid: !!source,
})
}
error={errors.SourceId as string | undefined}
/>
) : (
<>
<AuthFieldset
value={value}
onChange={handleChange}
isAuthExplanationVisible={isAuthExplanationVisible}
errors={errors}
/>
</div>
</div>
<GitFormUrlField
value={value.RepositoryURL}
onChange={(value) => {
handleChange({
RepositoryURL: value,
RepositoryReferenceName: initialValue.RepositoryReferenceName,
ComposeFilePathInRepository:
initialValue.ComposeFilePathInRepository,
RepositoryURLValid: false,
});
}}
onChangeRepositoryValid={(isValid) =>
handleChange({
RepositoryURLValid: isValid,
})
}
model={value}
createdFromCustomTemplateId={createdFromCustomTemplateId}
errors={errors.RepositoryURL}
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Skip TLS Verification"
data-cy="gitops-skip-tls-verification-switch"
checked={value.TLSSkipVerify || false}
onChange={(value) => 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"
/>
</div>
</div>
</>
)}
<RefField
value={value.RepositoryReferenceName || ''}
@@ -163,23 +186,18 @@ export async function validateGitForm(
export function buildGitValidationSchema(
isCreatedFromCustomTemplate: boolean,
deployMethod: DeployMethod,
isEdit = false
isEdit = false,
isSourceSelection = false
): SchemaOf<GitFormModel> {
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<GitFormModel>;
}
function isValidGitUrl(value?: string) {
if (!value) {
return true;
}
try {
return !!new URL(value).hostname;
} catch {
return false;
}
}
@@ -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 && <SourceLineItem sourceId={sourceIdToShow} />}
{!!commitId && (
<LineItem
label="Commit"
@@ -233,6 +241,37 @@ export function GitReferenceCard({
);
}
function SourceLineItem({ sourceId }: { sourceId: number }) {
const sourceQuery = useSource(sourceId);
const sourceName = sourceQuery.data?.name;
return (
<LineItem
label="Source"
value={
sourceName ? (
<Link
to="portainer.gitops.sources.item"
params={{ sourceId }}
data-cy="git-source-link"
>
{sourceName}
</Link>
) : 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,
@@ -30,13 +30,14 @@ export function RefSelector({
stackId,
createdFromCustomTemplateId,
tlsSkipVerify: model.TLSSkipVerify,
sourceId: model.SourceId,
...creds,
};
const { data: refs } = useGitRefs<Array<{ label: string; value: string }>>(
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' }];
@@ -3,4 +3,5 @@ import { GitAuthModel } from '../types';
export interface RefFieldModel extends GitAuthModel {
RepositoryURL: string;
TLSSkipVerify?: boolean;
SourceId?: number;
}
@@ -17,6 +17,7 @@ interface RefsPayload {
createdFromCustomTemplateID?: number;
tlsSkipVerify?: boolean;
force?: boolean;
sourceId?: number;
}
export function useGitRefs<T = string[]>(
@@ -18,6 +18,7 @@ interface SearchPayload {
createdFromCustomTemplateId?: number;
stackId?: number;
fromEdgeStack?: boolean;
sourceId?: number;
}
export function useSearch(payload: SearchPayload, enabled: boolean) {
@@ -27,6 +27,7 @@ export interface GitStackPayload {
HelmChartPath?: string;
HelmValuesFiles?: string[];
Atomic?: boolean;
SourceID?: number;
}
export async function updateGitStackSettings(
@@ -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 (
<div className="form-group">
<div className="col-sm-12">
<FormControl label="Source" inputId="source-selector" errors={error}>
<Select
placeholder="Select a source"
value={sources.find((s) => s.id === value) ?? null}
options={sources}
getOptionLabel={(s) => s.name}
getOptionValue={(s) => String(s.id)}
onChange={onChange}
isClearable
isLoading={sourcesQuery.isLoading}
noOptionsMessage={() => 'No git sources available'}
inputId="source-selector"
data-cy="source-selector"
/>
</FormControl>
</div>
</div>
);
}
@@ -23,10 +23,14 @@ async function getSources(params: SourcesParams) {
return withPaginationHeaders(response);
}
export function useSources(params: SourcesParams) {
export function useSources(
params: SourcesParams,
{ enabled = true }: { enabled?: boolean } = {}
) {
return useQuery({
queryKey: sourceQueryKeys.list(params),
queryFn: () => getSources(params),
enabled,
...withError('Failed loading sources'),
});
}
+2 -2
View File
@@ -43,15 +43,15 @@ export interface GitFormModel extends GitAuthModel {
ComposeFilePathInRepository?: string;
RepositoryReferenceName?: string;
AdditionalFiles?: string[];
TLSSkipVerify?: boolean;
/**
* Auto update
*
* if undefined, GitForm won't show the AutoUpdate fieldset
*/
AutoUpdate?: AutoUpdateModel;
/** ID of an existing Source. When set, inline URL and credentials are ignored. */
SourceId?: number;
}
export function getDefaultModel(
+20
View File
@@ -1,5 +1,7 @@
import { http, HttpResponse } from 'msw';
import { SourcesSourceDetail } from '@api/types.gen';
export const gitopsHandlers = [
http.post('/api/gitops/repo/refs', () =>
HttpResponse.json(['refs/heads/main', 'refs/heads/develop'])
@@ -7,4 +9,22 @@ export const gitopsHandlers = [
http.post('/api/gitops/repo/files/search', () =>
HttpResponse.json(['docker-compose.yml'])
),
http.get('/api/gitops/sources', () => {
return HttpResponse.json([], {
headers: {
'x-total-count': '0',
'x-total-available': '0',
},
});
}),
http.get('/api/gitops/sources/:id', ({ params: { id } }) => {
return HttpResponse.json<SourcesSourceDetail>({
id: typeof id === 'string' ? parseInt(id, 10) : 0,
name: 'source',
status: 'healthy',
type: 'git',
url: 'https://github.com/portainer/portainer',
connection: {},
});
}),
];