mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user