feat(app/sources): source create view (#2680)

Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LP B
2026-06-10 20:34:46 +02:00
committed by GitHub
parent d7a1d34be7
commit 0c2f07988a
61 changed files with 2272 additions and 282 deletions
+8 -3
View File
@@ -16,6 +16,8 @@ import (
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
)
func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
@@ -115,7 +117,8 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict
access, err := resolveKubeAccess(k8sFactory, sc, &ep)
if err != nil {
return nil, err
log.Warn().Err(err).Str("context", "buildEndpointAccessMap").Int("endpoint_id", int(epID)).Msg("Failed to resolve kube access for endpoint, skipping")
continue
}
result[epID] = access
@@ -148,7 +151,8 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
if err != nil {
return nil, err
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube client for endpoint, skipping")
continue
}
access := accessMap[envID]
@@ -157,7 +161,8 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
apps, err := kcl.GetApplications("", "")
if err != nil {
return nil, err
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube applications for endpoint, skipping")
continue
}
for _, s := range stacks {
+15 -3
View File
@@ -348,8 +348,9 @@ func gitAuthMatches(a, b *gittypes.GitAuthentication) bool {
return a.Username == b.Username && a.Password == b.Password && a.GitCredentialID == b.GitCredentialID
}
// ValidateUniqueSourceURL validates there are no other sources with the same URL
func ValidateUniqueSourceURL(tx gitSourceStore, url string, sourceID portainer.SourceID) (bool, error) {
// ValidateUniqueSource validates there are no other sources with the same URL and credentials.
// Pass empty strings for username and password when the source has no authentication.
func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sourceID portainer.SourceID) (bool, error) {
normalizedURL, err := gittypes.NormalizeURL(gittypes.SanitizeURL(url))
if err != nil {
return false, err
@@ -361,8 +362,12 @@ func ValidateUniqueSourceURL(tx gitSourceStore, url string, sourceID portainer.S
}
normalized, err := gittypes.NormalizeURL(gittypes.SanitizeURL(s.Git.URL))
if err != nil || normalized != normalizedURL {
return false
}
return err == nil && normalized == normalizedURL
existingUsername, existingPassword := gitAuthCredentials(s.Git.Authentication)
return existingUsername == username && existingPassword == password
})
if err != nil {
@@ -371,3 +376,10 @@ func ValidateUniqueSourceURL(tx gitSourceStore, url string, sourceID portainer.S
return len(existing) == 0, nil
}
func gitAuthCredentials(auth *gittypes.GitAuthentication) (username, password string) {
if auth == nil {
return "", ""
}
return auth.Username, auth.Password
}
@@ -769,3 +769,97 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "https://github.com/example/repo", src.Git.URL)
}
func newSourceWithAuth(url, username, password string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: url,
Authentication: &gittypes.GitAuthentication{
Username: username,
Password: password,
},
},
}
}
func newAuthlessSource(url string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: url},
}
}
func validateUniqueSourceInStore(t *testing.T, store *datastore.Store, url, username, password string, sourceID portainer.SourceID) bool {
t.Helper()
var isUnique bool
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
isUnique, err = ValidateUniqueSource(tx, url, username, password, sourceID)
return err
}))
return isUnique
}
func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0))
}
func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0))
}
func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_ExcludesSelf(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")
if err := tx.Source().Create(src); err != nil {
return err
}
srcID = src.ID
return nil
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID))
}
+63 -35
View File
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -15,10 +16,8 @@ import (
// GitAuthenticationPayload holds authentication parameters for a git source
type GitAuthenticationPayload struct {
Username string `json:"username"`
Password string `json:"password"`
Provider gittypes.GitProvider `json:"provider"`
AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"`
Username string `json:"username"`
Password string `json:"password"`
}
// GitSourceCreatePayload holds the parameters for creating a git-backed source
@@ -38,38 +37,10 @@ func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error {
return nil
}
// BuildGitSource constructs a portainer.Source from a GitSourceCreatePayload
func BuildGitSource(payload GitSourceCreatePayload) *portainer.Source {
gitConfig := &gittypes.RepoConfig{
URL: payload.URL,
TLSSkipVerify: payload.TLSSkipVerify,
}
if payload.Authentication != nil {
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.Authentication.Username,
Password: payload.Authentication.Password,
Provider: payload.Authentication.Provider,
AuthorizationType: payload.Authentication.AuthorizationType,
}
}
name := payload.Name
if strings.TrimSpace(name) == "" {
name = gittypes.RepoName(payload.URL)
}
return &portainer.Source{
Name: name,
Type: portainer.SourceTypeGit,
Git: gitConfig,
}
}
// @id GitOpsSourcesCreateGit
// @summary Create a Git source
// @description Creates a new GitOps source backed by a Git repository.
// @description **Access policy**: admin
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -79,6 +50,7 @@ func BuildGitSource(payload GitSourceCreatePayload) *portainer.Source {
// @success 201 {object} portainer.Source
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 409 "A source with this URL and credentials already exists"
// @failure 500 "Server error"
// @router /gitops/sources/git [post]
func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -88,11 +60,28 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
return httperror.BadRequest("Invalid request payload", err)
}
src := BuildGitSource(payload)
src, err := BuildGitSource(payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
username, password := "", ""
if payload.Authentication != nil {
username = payload.Authentication.Username
password = payload.Authentication.Password
}
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if isUnique, err := workflows.ValidateUniqueSource(tx, payload.URL, username, password, 0); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSource
}
return tx.Source().Create(src)
}); err != nil {
}); errors.Is(err, ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if err != nil {
return httperror.InternalServerError("Unable to create source", err)
}
@@ -100,3 +89,42 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
return response.JSONWithStatus(w, src, http.StatusCreated)
}
// BuildGitSource constructs a portainer.Source from a GitSourceCreatePayload
func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) {
src := BuildBaseGitSource(payload)
src.Git.Authentication = BuildAuth(payload.Authentication)
return src, nil
}
// BuildBaseGitSource constructs the source skeleton (name, URL, TLS) without
// authentication.
func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source {
name := payload.Name
if strings.TrimSpace(name) == "" {
name = gittypes.RepoName(payload.URL)
}
return &portainer.Source{
Name: name,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: payload.URL,
TLSSkipVerify: payload.TLSSkipVerify,
},
}
}
// BuildAuth constructs basic git authentication from the payload, returning nil
// when no authentication is provided.
func BuildAuth(payload *GitAuthenticationPayload) *gittypes.GitAuthentication {
if payload == nil {
return nil
}
return &gittypes.GitAuthentication{
Username: payload.Username,
Password: payload.Password,
}
}
@@ -16,9 +16,10 @@ import (
func TestBuildGitSource_DerivesNameFromURL(t *testing.T) {
t.Parallel()
src := BuildGitSource(GitSourceCreatePayload{
src, err := BuildGitSource(GitSourceCreatePayload{
URL: "https://github.com/org/my-repo.git",
})
require.NoError(t, err)
require.Equal(t, "my-repo", src.Name)
require.Equal(t, portainer.SourceTypeGit, src.Type)
@@ -28,10 +29,11 @@ func TestBuildGitSource_DerivesNameFromURL(t *testing.T) {
func TestBuildGitSource_UsesExplicitName(t *testing.T) {
t.Parallel()
src := BuildGitSource(GitSourceCreatePayload{
src, err := BuildGitSource(GitSourceCreatePayload{
Name: "custom-name",
URL: "https://github.com/org/repo.git",
})
require.NoError(t, err)
require.Equal(t, "custom-name", src.Name)
}
@@ -39,13 +41,14 @@ func TestBuildGitSource_UsesExplicitName(t *testing.T) {
func TestBuildGitSource_WithAuthentication(t *testing.T) {
t.Parallel()
src := BuildGitSource(GitSourceCreatePayload{
src, err := BuildGitSource(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
require.NotNil(t, src.Git.Authentication)
require.Equal(t, "alice", src.Git.Authentication.Username)
@@ -149,6 +152,128 @@ func TestGitSourceCreate_MissingURL(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestGitSourceCreate_ConflictOnDuplicateURLAndCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
body, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusConflict, rr.Code)
}
func TestGitSourceCreate_AllowsDuplicateURLWithDifferentCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
first, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
second, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "bob",
Password: "other",
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, first))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, second))
require.Equal(t, http.StatusCreated, rr.Code)
}
func TestGitSourceCreate_ConflictOnDuplicateAuthlessSource(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
body, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusConflict, rr.Code)
}
func TestGitSourceCreate_AllowsAuthlessAndAuthenticatedSameURL(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
authless, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
})
require.NoError(t, err)
authenticated, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, authless))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, authenticated))
require.Equal(t, http.StatusCreated, rr.Code)
}
func TestGitSourceCreate_MalformedJSON(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
+8 -10
View File
@@ -13,8 +13,7 @@ import (
)
type gitAuthInfo struct {
Type gittypes.GitCredentialAuthType `json:"type"`
Username string `json:"username"`
Username string `json:"username"`
}
type connectionInfo struct {
@@ -23,7 +22,7 @@ type connectionInfo struct {
Authentication *gitAuthInfo `json:"authentication,omitempty"`
}
type autoUpdateInfo struct {
type AutoUpdateInfo struct {
Mechanism string `json:"mechanism,omitempty"`
FetchInterval string `json:"fetchInterval,omitempty"`
}
@@ -32,7 +31,7 @@ type autoUpdateInfo struct {
type SourceDetail struct {
Source
Connection connectionInfo `json:"connection" validate:"required"`
AutoUpdate *autoUpdateInfo `json:"autoUpdate,omitempty"`
AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []workflows.Workflow `json:"workflows"`
}
@@ -85,9 +84,9 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
}
func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail {
var autoUpdate *autoUpdateInfo
var autoUpdate *AutoUpdateInfo
if len(sourceWfs) > 0 {
autoUpdate = buildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
}
return SourceDetail{
@@ -114,24 +113,23 @@ func buildGitAuthInfo(auth *gittypes.GitAuthentication) *gitAuthInfo {
return nil
}
return &gitAuthInfo{
Type: auth.AuthorizationType,
Username: auth.Username,
}
}
func buildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *autoUpdateInfo {
func BuildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *AutoUpdateInfo {
if autoUpdate == nil {
return nil
}
switch {
case autoUpdate.Interval != "":
return &autoUpdateInfo{
return &AutoUpdateInfo{
Mechanism: "Interval",
FetchInterval: autoUpdate.Interval,
}
case autoUpdate.Webhook != "":
return &autoUpdateInfo{
return &AutoUpdateInfo{
Mechanism: "Webhook",
}
default:
@@ -45,6 +45,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
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)
@@ -14,10 +14,10 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GitOpsSourcesTestGit
// @summary Test a Git source connection
// @id GitOpsSourcesTestById
// @summary Test the connection of a stored source
// @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration.
// @description **Access policy**: admin
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -72,6 +72,40 @@ type ConnectionTestResult struct {
Error string `json:"error,omitempty"`
}
// @id GitOpsSourcesTest
// @summary Test a Git source connection
// @description Tests connectivity for Git connection details that have not been persisted yet.
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param body body GitSourceCreatePayload true "Git connection details"
// @success 200 {object} ConnectionTestResult "Connection test result"
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 500 "Server error"
// @router /gitops/sources/test [post]
func (h *Handler) gitSourceTest(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload GitSourceCreatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
src, err := BuildGitSource(payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
if src.Git == nil {
return httperror.InternalServerError("Source has no git configuration", nil)
}
result := testSourceConnection(r.Context(), h.gitService, src.Git)
return response.JSON(w, result)
}
// testSourceConnection verifies that a git repository is reachable with the given config.
func testSourceConnection(ctx context.Context, gitService portainer.GitService, config *gittypes.RepoConfig) ConnectionTestResult {
var username, password string
+9 -11
View File
@@ -4,22 +4,20 @@ import (
"fmt"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
)
// Source represents a unique git repository used as a GitOps source across one or more workflows.
type Source struct {
ID portainer.SourceID `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type SourceType `json:"type" validate:"required"`
URL string `json:"url" validate:"required"`
Status workflows.Status `json:"status" validate:"required"`
Error string `json:"error,omitempty"`
Provider gittypes.GitProvider `json:"provider,omitempty"`
UsedBy int `json:"usedBy"`
Environments int `json:"environments"`
LastSync int64 `json:"lastSync"`
ID portainer.SourceID `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type SourceType `json:"type" validate:"required"`
URL string `json:"url" validate:"required"`
Status workflows.Status `json:"status" validate:"required"`
Error string `json:"error,omitempty"`
UsedBy int `json:"usedBy"`
Environments int `json:"environments"`
LastSync int64 `json:"lastSync"`
}
type SourceType string
+66 -57
View File
@@ -15,8 +15,8 @@ import (
)
var (
ErrNotGitSource = errors.New("source is not a Git source")
ErrDuplicateSourceURL = errors.New("a source with this URL already exists")
ErrNotGitSource = errors.New("source is not a Git source")
ErrDuplicateSource = errors.New("a source with this URL and credentials already exists")
)
// GitSourceUpdatePayload holds the parameters for creating a git-backed source
@@ -29,10 +29,8 @@ type GitSourceUpdatePayload struct {
}
type GitAuthenticationUpdatePayload struct {
Username *string `json:"username"`
Password *string `json:"password"`
Provider *gittypes.GitProvider `json:"provider" swaggertype:"integer" enums:"0,1,2,3,4,5,6"`
AuthorizationType *gittypes.GitCredentialAuthType `json:"authorizationType" swaggertype:"integer" enums:"0,1"`
Username *string `json:"username"`
Password *string `json:"password"`
}
// Validate implements the portainer.Validatable interface
@@ -43,7 +41,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error {
// @id GitOpsSourcesUpdateGit
// @summary Update a Git source
// @description Updates an existing GitOps source backed by a Git repository.
// @description **Access policy**: admin
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -55,7 +53,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error {
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 404 "Source not found"
// @failure 409 "A source with this URL already exists"
// @failure 409 "A source with this URL and credentials already exists"
// @failure 500 "Server error"
// @router /gitops/sources/{id} [put]
func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -77,14 +75,6 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
if payload.URL != nil {
if isUnique, err := workflows.ValidateUniqueSourceURL(tx, *payload.URL, sourceID); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSourceURL
}
}
if src, err = tx.Source().Read(sourceID); err != nil {
return err
}
@@ -93,13 +83,25 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
return err
}
username, password := "", ""
if src.Git != nil && src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
if isUnique, err := workflows.ValidateUniqueSource(tx, src.Git.URL, username, password, sourceID); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSource
}
return tx.Source().Update(src.ID, src)
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, ErrNotGitSource) {
return httperror.BadRequest("Source is not a Git source", err)
} else if errors.Is(err, ErrDuplicateSourceURL) {
return httperror.Conflict("A source with this URL already exists", err)
} else if errors.Is(err, ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if err != nil {
return httperror.InternalServerError("Unable to update source", err)
}
@@ -111,6 +113,27 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
// ApplyGitSourceChanges applies the payload changes to the source in place
func ApplyGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error {
if err := ApplyBaseGitSourceChanges(src, payload); err != nil {
return err
}
if payload.Authentication == nil {
return nil
}
if *payload.Authentication == (GitAuthenticationUpdatePayload{}) {
src.Git.Authentication = nil
return nil
}
src.Git.Authentication = ApplyAuthChanges(src.Git.Authentication, *payload.Authentication)
return nil
}
// ApplyBaseGitSourceChanges applies the non-authentication field changes (name,
// URL, reference, TLS) to the source in place, ensuring src.Git is set
func ApplyBaseGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error {
if src.Type != portainer.SourceTypeGit {
return ErrNotGitSource
}
@@ -119,55 +142,41 @@ func ApplyGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload
src.Name = *payload.Name
}
gitConfig := src.Git
if gitConfig == nil {
gitConfig = &gittypes.RepoConfig{}
if src.Git == nil {
src.Git = &gittypes.RepoConfig{}
}
if payload.URL != nil {
gitConfig.URL = *payload.URL
src.Git.URL = *payload.URL
}
if payload.ReferenceName != nil {
gitConfig.ReferenceName = *payload.ReferenceName
src.Git.ReferenceName = *payload.ReferenceName
}
if payload.TLSSkipVerify != nil {
gitConfig.TLSSkipVerify = *payload.TLSSkipVerify
src.Git.TLSSkipVerify = *payload.TLSSkipVerify
}
var auth *gittypes.GitAuthentication
if payload.Authentication == nil {
auth = gitConfig.Authentication
} else if *payload.Authentication != (GitAuthenticationUpdatePayload{}) {
existing := gitConfig.Authentication
if existing != nil {
copied := *existing
auth = &copied
} else {
auth = &gittypes.GitAuthentication{}
}
authPayload := *payload.Authentication
if authPayload.AuthorizationType != nil {
auth.AuthorizationType = *authPayload.AuthorizationType
}
if authPayload.Username != nil {
auth.Username = *authPayload.Username
}
if authPayload.Password != nil {
auth.Password = *authPayload.Password
}
if authPayload.Provider != nil {
auth.Provider = *authPayload.Provider
}
}
gitConfig.Authentication = auth
src.Git = gitConfig
return nil
}
// ApplyAuthChanges returns a copy of the existing authentication (or a fresh
// one) with the basic credential changes applied.
func ApplyAuthChanges(existing *gittypes.GitAuthentication, payload GitAuthenticationUpdatePayload) *gittypes.GitAuthentication {
auth := &gittypes.GitAuthentication{}
if existing != nil {
copied := *existing
auth = &copied
}
if payload.Username != nil {
auth.Username = *payload.Username
}
if payload.Password != nil {
auth.Password = *payload.Password
}
return auth
}
@@ -300,6 +300,57 @@ func TestGitSourceUpdate_MalformedJSON(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
existing := &portainer.Source{
Name: "existing",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/org/repo.git",
Authentication: &gittypes.GitAuthentication{
Username: "alice",
Password: "secret",
},
},
}
if err := tx.Source().Create(existing); err != nil {
return err
}
other := &portainer.Source{
Name: "other",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo.git"},
}
if err := tx.Source().Create(other); err != nil {
return err
}
srcID = other.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
alice := "alice"
secret := "secret"
body, err := json.Marshal(GitSourceUpdatePayload{
Authentication: &GitAuthenticationUpdatePayload{
Username: &alice,
Password: &secret,
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body))
require.Equal(t, http.StatusConflict, rr.Code)
}
func TestGitSourceUpdate_NonNumericID(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
-5
View File
@@ -20,12 +20,8 @@ func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats
}
url := ""
var provider gittypes.GitProvider
if src.Git != nil {
url = gittypes.SanitizeURL(src.Git.URL)
if src.Git.Authentication != nil {
provider = src.Git.Authentication.Provider
}
}
return Source{
@@ -35,7 +31,6 @@ func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats
URL: url,
Status: status,
Error: sourceErr,
Provider: provider,
UsedBy: stats.WorkflowCount,
Environments: len(stats.EndpointIDs),
LastSync: stats.LastSync,
@@ -49,15 +49,15 @@ func TestRedactWorkflowCredentials(t *testing.T) {
func TestBuildAutoUpdateInfo(t *testing.T) {
t.Parallel()
assert.Nil(t, buildAutoUpdateInfo(nil))
assert.Nil(t, buildAutoUpdateInfo(&portainer.AutoUpdateSettings{}))
assert.Nil(t, BuildAutoUpdateInfo(nil))
assert.Nil(t, BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{}))
got := buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"})
got := BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"})
require.NotNil(t, got)
assert.Equal(t, "Interval", got.Mechanism)
assert.Equal(t, "5m", got.FetchInterval)
got = buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"})
got = BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"})
require.NotNil(t, got)
assert.Equal(t, "Webhook", got.Mechanism)
assert.Empty(t, got.FetchInterval)
+3 -8
View File
@@ -1,4 +1,7 @@
import _ from 'lodash-es';
import { strToHash } from '@/react/utils/hash';
import { hideShaSum, joinCommand, nodeStatusBadge, taskStatusBadge, trimContainerName, trimSHA, trimVersionTag } from './utils';
function includeString(text, values) {
@@ -7,14 +10,6 @@ function includeString(text, values) {
});
}
function strToHash(str) {
var hash = 0;
for (var i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
}
function hashToHexColor(hash) {
var color = '#';
for (var i = 0; i < 3; ) {
+14
View File
@@ -340,6 +340,9 @@ angular
component: 'sourcesListView',
},
},
data: {
access: AccessHeaders.Admin,
},
};
var gitopsSourceDetail = {
@@ -355,6 +358,16 @@ angular
},
};
const gitopsSourceCreate = {
name: 'portainer.gitops.sources.new',
url: '/new',
views: {
'content@': {
component: 'sourceCreateView',
},
},
};
var init = {
name: 'portainer.init',
abstract: true,
@@ -479,6 +492,7 @@ angular
$stateRegistryProvider.register(workflows);
$stateRegistryProvider.register(gitopsSources);
$stateRegistryProvider.register(gitopsSourceDetail);
$stateRegistryProvider.register(gitopsSourceCreate);
$stateRegistryProvider.register(init);
$stateRegistryProvider.register(initAdmin);
$stateRegistryProvider.register(settings);
+5
View File
@@ -3,6 +3,7 @@ import angular from 'angular';
import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView';
import { ListView as SourcesListView } from '@/react/portainer/gitops/sources/ListView/ListView';
import { ItemView as SourceItemView } from '@/react/portainer/gitops/sources/ItemView/ItemView';
import { CreateView as SourceCreateView } from '@/react/portainer/gitops/sources/CreateView/CreateView';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
@@ -20,4 +21,8 @@ export const gitopsViewsModule = angular
.component(
'sourceItemView',
r2a(withUIRouter(withCurrentUser(SourceItemView)), [])
)
.component(
'sourceCreateView',
r2a(withUIRouter(withCurrentUser(SourceCreateView)), [])
).name;
+2
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/refs */
/* eslint-disable no-console */
import { intersection } from 'lodash';
import { useEffect, useRef } from 'react';
@@ -49,4 +50,5 @@ export function useDebugPropChanges(
}
lastProps.current = newProps;
}
/* eslint-enable react-hooks/refs */
/* eslint-enable no-console */
@@ -61,13 +61,15 @@ export function BoxSelector<T extends Value>({
>
{options
.filter((option) => !option.hide)
.map((option) => (
.map(({ disabled, ...option }) => (
<BoxSelectorItem
key={option.id}
radioName={radioName}
option={option}
onSelect={handleSelect}
disabled={option.disabled && option.disabled()}
disabled={
typeof disabled === 'function' ? disabled() : disabled
}
tooltip={option.tooltip && option.tooltip()}
type={props.isMulti ? 'checkbox' : 'radio'}
isSelected={isSelected}
+1 -1
View File
@@ -11,7 +11,7 @@ export interface BoxSelectorOption<T extends Value> extends IconProps {
readonly label: string;
readonly description?: ReactNode;
readonly value: T;
readonly disabled?: () => boolean;
readonly disabled?: boolean | (() => boolean);
readonly tooltip?: () => string;
readonly feature?: FeatureId;
readonly disabledWhenLimited?: boolean;
@@ -5,13 +5,13 @@ export interface StepConfig {
label: string;
}
interface UseWizardStepsOptions {
steps: Array<StepConfig>;
interface UseWizardStepsOptions<T extends StepConfig = StepConfig> {
steps: Array<T>;
initialStepId?: string;
}
interface WizardStepState {
currentStep: StepConfig;
export interface WizardStepState<T extends StepConfig = StepConfig> {
currentStep: T;
currentStepIndex: number;
isFirstStep: boolean;
isLastStep: boolean;
@@ -23,10 +23,10 @@ interface WizardStepState {
goToStepByIndex: (index: number) => void;
}
export function useWizardSteps({
export function useWizardSteps<T extends StepConfig = StepConfig>({
steps,
initialStepId,
}: UseWizardStepsOptions): WizardStepState {
}: UseWizardStepsOptions<T>): WizardStepState<T> {
const initialIndex = useMemo(() => {
if (!initialStepId) return 0;
const index = steps.findIndex((s) => s.id === initialStepId);
@@ -8,6 +8,7 @@ interface Props {
title: ReactNode;
icon?: ReactNode;
className?: string;
subtitle?: string;
}
export function WidgetTitle({
@@ -15,6 +16,7 @@ export function WidgetTitle({
icon,
className,
children,
subtitle,
}: PropsWithChildren<Props>) {
const { titleId } = useWidgetContext();
@@ -29,6 +31,7 @@ export function WidgetTitle({
</span>
<span className={clsx('flex items-center', className)}>{children}</span>
</div>
{subtitle && <span className="text-muted small">{subtitle}</span>}
</div>
);
}
@@ -27,7 +27,7 @@ export interface GroupOption<TValue> {
options: Option<TValue>[];
}
type Options<TValue> = OptionsOrGroups<
export type Options<TValue> = OptionsOrGroups<
Option<TValue>,
GroupBase<Option<TValue>>
>;
@@ -0,0 +1,49 @@
import {
GroupBase,
OptionProps,
SingleValueProps,
SelectComponentsConfig,
components,
} from 'react-select';
import { ReactNode } from 'react';
import { Option } from './PortainerSelect';
export function CustomComponents<TValue>(
render: (data: Option<TValue>) => ReactNode
): SelectComponentsConfig<Option<TValue>, false, GroupBase<Option<TValue>>> {
return {
Option: CustomOption(render),
SingleValue: CustomSingleValue(render),
};
}
function CustomOption<TValue>(render: (data: Option<TValue>) => ReactNode) {
return function CustomOptionRenderer({
data,
...props
}: OptionProps<Option<TValue>, false, GroupBase<Option<TValue>>>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<components.Option data={data} {...props}>
{render(data)}
</components.Option>
);
};
}
function CustomSingleValue<TValue>(
render: (data: Option<TValue>) => ReactNode
) {
return function CustomOptionRenderer({
data,
...props
}: SingleValueProps<Option<TValue>, false, GroupBase<Option<TValue>>>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<components.SingleValue data={data} {...props}>
{render(data)}
</components.SingleValue>
);
};
}
@@ -451,9 +451,12 @@ import type {
GitOpsSourcesSummaryData,
GitOpsSourcesSummaryErrors,
GitOpsSourcesSummaryResponses,
GitOpsSourcesTestGitData,
GitOpsSourcesTestGitErrors,
GitOpsSourcesTestGitResponses,
GitOpsSourcesTestByIdData,
GitOpsSourcesTestByIdErrors,
GitOpsSourcesTestByIdResponses,
GitOpsSourcesTestData,
GitOpsSourcesTestErrors,
GitOpsSourcesTestResponses,
GitOpsSourcesUpdateGitData,
GitOpsSourcesUpdateGitErrors,
GitOpsSourcesUpdateGitResponses,
@@ -1103,9 +1106,11 @@ import {
zGitOpsSourcesListQuery,
zGitOpsSourcesListResponse,
zGitOpsSourcesSummaryResponse,
zGitOpsSourcesTestGitBody,
zGitOpsSourcesTestGitPath,
zGitOpsSourcesTestGitResponse,
zGitOpsSourcesTestBody,
zGitOpsSourcesTestByIdBody,
zGitOpsSourcesTestByIdPath,
zGitOpsSourcesTestByIdResponse,
zGitOpsSourcesTestResponse,
zGitOpsSourcesUpdateGitBody,
zGitOpsSourcesUpdateGitPath,
zGitOpsSourcesUpdateGitResponse,
@@ -3944,7 +3949,7 @@ export const gitOpsSourceGet = <ThrowOnError extends boolean = true>(
* Update a Git source
*
* Updates an existing GitOps source backed by a Git repository.
* **Access policy**: admin
* **Access policy**: administrator
*/
export const gitOpsSourcesUpdateGit = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesUpdateGitData, ThrowOnError>
@@ -3978,30 +3983,30 @@ export const gitOpsSourcesUpdateGit = <ThrowOnError extends boolean = true>(
});
/**
* Test a Git source connection
* Test the connection of a stored source
*
* Tests connectivity for a GitOps source, applying optional overrides to the stored configuration.
* **Access policy**: admin
* **Access policy**: administrator
*/
export const gitOpsSourcesTestGit = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesTestGitData, ThrowOnError>
export const gitOpsSourcesTestById = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesTestByIdData, ThrowOnError>
) =>
(options.client ?? client).post<
GitOpsSourcesTestGitResponses,
GitOpsSourcesTestGitErrors,
GitOpsSourcesTestByIdResponses,
GitOpsSourcesTestByIdErrors,
ThrowOnError
>({
requestValidator: async (data) =>
await z
.object({
body: zGitOpsSourcesTestGitBody.optional(),
path: zGitOpsSourcesTestGitPath,
body: zGitOpsSourcesTestByIdBody.optional(),
path: zGitOpsSourcesTestByIdPath,
query: z.never().optional(),
})
.parseAsync(data),
responseType: 'json',
responseValidator: async (data) =>
await zGitOpsSourcesTestGitResponse.parseAsync(data),
await zGitOpsSourcesTestByIdResponse.parseAsync(data),
security: [
{ name: 'X-API-KEY', type: 'apiKey' },
{ name: 'Authorization', type: 'apiKey' },
@@ -4018,7 +4023,7 @@ export const gitOpsSourcesTestGit = <ThrowOnError extends boolean = true>(
* Create a Git source
*
* Creates a new GitOps source backed by a Git repository.
* **Access policy**: admin
* **Access policy**: administrator
*/
export const gitOpsSourcesCreateGit = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesCreateGitData, ThrowOnError>
@@ -4084,6 +4089,43 @@ export const gitOpsSourcesSummary = <ThrowOnError extends boolean = true>(
...options,
});
/**
* Test a Git source connection
*
* Tests connectivity for Git connection details that have not been persisted yet.
* **Access policy**: administrator
*/
export const gitOpsSourcesTest = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesTestData, ThrowOnError>
) =>
(options.client ?? client).post<
GitOpsSourcesTestResponses,
GitOpsSourcesTestErrors,
ThrowOnError
>({
requestValidator: async (data) =>
await z
.object({
body: zGitOpsSourcesTestBody,
path: z.never().optional(),
query: z.never().optional(),
})
.parseAsync(data),
responseType: 'json',
responseValidator: async (data) =>
await zGitOpsSourcesTestResponse.parseAsync(data),
security: [
{ name: 'X-API-KEY', type: 'apiKey' },
{ name: 'Authorization', type: 'apiKey' },
],
url: '/gitops/sources/test',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
/**
* List all GitOps workflows
*
@@ -4208,7 +4208,6 @@ export type SslSslUpdatePayload = {
};
export type SourcesGitAuthInfo = {
type?: number;
username?: string;
};
@@ -4218,11 +4217,6 @@ export type SourcesConnectionInfo = {
tlsSkipVerify?: boolean;
};
export type SourcesAutoUpdateInfo = {
fetchInterval?: string;
mechanism?: string;
};
export const SourcesSourceType = {
/**
* SourceTypeGit
@@ -4249,7 +4243,6 @@ export type SourcesSourceDetail = {
id: number;
lastSync?: number;
name: string;
provider?: number;
status: WorkflowsStatus;
type: SourcesSourceType;
url: string;
@@ -4257,13 +4250,17 @@ export type SourcesSourceDetail = {
workflows?: Array<WorkflowsWorkflow>;
};
export type SourcesAutoUpdateInfo = {
fetchInterval?: string;
mechanism?: string;
};
export type SourcesSource = {
environments?: number;
error?: string;
id: number;
lastSync?: number;
name: string;
provider?: number;
status: WorkflowsStatus;
type: SourcesSourceType;
url: string;
@@ -4279,9 +4276,7 @@ export type SourcesGitSourceUpdatePayload = {
};
export type SourcesGitAuthenticationUpdatePayload = {
authorizationType?: 0 | 1;
password?: string;
provider?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
username?: string;
};
@@ -4293,9 +4288,7 @@ export type SourcesGitSourceCreatePayload = {
};
export type SourcesGitAuthenticationPayload = {
authorizationType?: number;
password?: string;
provider?: number;
username?: string;
};
@@ -5714,8 +5707,8 @@ export type PortainerSourceType =
(typeof PortainerSourceType)[keyof typeof PortainerSourceType];
export type PortainerSource = {
gitConfig?: GittypesRepoConfig;
helmConfig?: PortainerHelmConfig;
git?: GittypesRepoConfig;
helm?: PortainerHelmConfig;
id?: number;
lastSync?: number;
name?: string;
@@ -6614,7 +6607,6 @@ export type PortainerCustomTemplatePlatform =
(typeof PortainerCustomTemplatePlatform)[keyof typeof PortainerCustomTemplatePlatform];
export type PortainerCustomTemplate = {
ArtifactSources?: PortainerArtifactSources;
/**
* User identifier who created this template
*/
@@ -6670,21 +6662,25 @@ export type PortainerCustomTemplate = {
*/
Type?: 1 | 2 | 3;
Variables?: Array<PortainerCustomTemplateVariableDefinition>;
artifact?: PortainerArtifact;
};
export type PortainerArtifactFile = {
hash?: string;
path?: string;
ref?: string;
sourceId?: number;
};
export type PortainerArtifact = {
configFilePath?: string;
configHash?: string;
edgeGroups?: Array<number>;
edgeStackId?: number;
referenceName?: string;
envGroups?: Array<number>;
envIds?: Array<number>;
files?: Array<PortainerArtifactFile>;
stackId?: number;
};
export type PortainerArtifactSources = {
artifact?: PortainerArtifact;
sourceIds?: Array<number>;
};
export type MotdMotd = {
ContentLayout?: {
[key: string]: string;
@@ -11285,7 +11281,7 @@ export type GitOpsSourcesUpdateGitErrors = {
*/
404: unknown;
/**
* A source with this URL already exists
* A source with this URL and credentials already exists
*/
409: unknown;
/**
@@ -11304,7 +11300,7 @@ export type GitOpsSourcesUpdateGitResponses = {
export type GitOpsSourcesUpdateGitResponse =
GitOpsSourcesUpdateGitResponses[keyof GitOpsSourcesUpdateGitResponses];
export type GitOpsSourcesTestGitData = {
export type GitOpsSourcesTestByIdData = {
/**
* Optional connection overrides; omitted fields fall back to stored values
*/
@@ -11319,7 +11315,7 @@ export type GitOpsSourcesTestGitData = {
url: '/gitops/sources/{id}/test';
};
export type GitOpsSourcesTestGitErrors = {
export type GitOpsSourcesTestByIdErrors = {
/**
* Invalid request payload
*/
@@ -11338,15 +11334,15 @@ export type GitOpsSourcesTestGitErrors = {
500: unknown;
};
export type GitOpsSourcesTestGitResponses = {
export type GitOpsSourcesTestByIdResponses = {
/**
* Connection test result
*/
200: SourcesConnectionTestResult;
};
export type GitOpsSourcesTestGitResponse =
GitOpsSourcesTestGitResponses[keyof GitOpsSourcesTestGitResponses];
export type GitOpsSourcesTestByIdResponse =
GitOpsSourcesTestByIdResponses[keyof GitOpsSourcesTestByIdResponses];
export type GitOpsSourcesCreateGitData = {
/**
@@ -11367,6 +11363,10 @@ export type GitOpsSourcesCreateGitErrors = {
* Access denied
*/
403: unknown;
/**
* A source with this URL and credentials already exists
*/
409: unknown;
/**
* Server error
*/
@@ -11411,6 +11411,41 @@ export type GitOpsSourcesSummaryResponses = {
export type GitOpsSourcesSummaryResponse =
GitOpsSourcesSummaryResponses[keyof GitOpsSourcesSummaryResponses];
export type GitOpsSourcesTestData = {
/**
* Git connection details
*/
body: SourcesGitSourceCreatePayload;
path?: never;
query?: never;
url: '/gitops/sources/test';
};
export type GitOpsSourcesTestErrors = {
/**
* Invalid request payload
*/
400: unknown;
/**
* Access denied
*/
403: unknown;
/**
* Server error
*/
500: unknown;
};
export type GitOpsSourcesTestResponses = {
/**
* Connection test result
*/
200: SourcesConnectionTestResult;
};
export type GitOpsSourcesTestResponse =
GitOpsSourcesTestResponses[keyof GitOpsSourcesTestResponses];
export type GitOpsWorkflowsListData = {
body?: never;
path?: never;
@@ -1118,7 +1118,6 @@ export const zSslSslUpdatePayload = z.object({
});
export const zSourcesGitAuthInfo = z.object({
type: z.int().optional(),
username: z.string().optional(),
});
@@ -1128,13 +1127,13 @@ export const zSourcesConnectionInfo = z.object({
tlsSkipVerify: z.boolean().optional(),
});
export const zSourcesSourceType = z.enum(SourcesSourceType);
export const zSourcesAutoUpdateInfo = z.object({
fetchInterval: z.string().optional(),
mechanism: z.string().optional(),
});
export const zSourcesSourceType = z.enum(SourcesSourceType);
export const zSourcesSourceDetail = z.object({
autoUpdate: zSourcesAutoUpdateInfo.optional(),
connection: zSourcesConnectionInfo,
@@ -1143,7 +1142,6 @@ export const zSourcesSourceDetail = z.object({
id: z.int(),
lastSync: z.int().optional(),
name: z.string(),
provider: z.int().optional(),
status: zWorkflowsStatus,
type: zSourcesSourceType,
url: z.string(),
@@ -1157,7 +1155,6 @@ export const zSourcesSource = z.object({
id: z.int(),
lastSync: z.int().optional(),
name: z.string(),
provider: z.int().optional(),
status: zWorkflowsStatus,
type: zSourcesSourceType,
url: z.string(),
@@ -1165,19 +1162,7 @@ export const zSourcesSource = z.object({
});
export const zSourcesGitAuthenticationUpdatePayload = z.object({
authorizationType: z.union([z.literal(0), z.literal(1)]).optional(),
password: z.string().optional(),
provider: z
.union([
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
])
.optional(),
username: z.string().optional(),
});
@@ -1190,9 +1175,7 @@ export const zSourcesGitSourceUpdatePayload = z.object({
});
export const zSourcesGitAuthenticationPayload = z.object({
authorizationType: z.int().optional(),
password: z.string().optional(),
provider: z.int().optional(),
username: z.string().optional(),
});
@@ -1802,8 +1785,8 @@ export const zPortainerHelmConfig = z.object({
});
export const zPortainerSource = z.object({
gitConfig: zGittypesRepoConfig.optional(),
helmConfig: zPortainerHelmConfig.optional(),
git: zGittypesRepoConfig.optional(),
helm: zPortainerHelmConfig.optional(),
id: z.int().optional(),
lastSync: z.int().optional(),
name: z.string().optional(),
@@ -2159,21 +2142,23 @@ export const zPortainerCustomTemplatePlatform = z.enum(
PortainerCustomTemplatePlatform
);
export const zPortainerArtifactFile = z.object({
hash: z.string().optional(),
path: z.string().optional(),
ref: z.string().optional(),
sourceId: z.int().optional(),
});
export const zPortainerArtifact = z.object({
configFilePath: z.string().optional(),
configHash: z.string().optional(),
edgeGroups: z.array(z.int()).optional(),
edgeStackId: z.int().optional(),
referenceName: z.string().optional(),
envGroups: z.array(z.int()).optional(),
envIds: z.array(z.int()).optional(),
files: z.array(zPortainerArtifactFile).optional(),
stackId: z.int().optional(),
});
export const zPortainerArtifactSources = z.object({
artifact: zPortainerArtifact.optional(),
sourceIds: z.array(z.int()).optional(),
});
export const zPortainerCustomTemplate = z.object({
ArtifactSources: zPortainerArtifactSources.optional(),
CreatedByUserId: z.int().optional(),
Description: z.string().optional(),
EdgeTemplate: z.boolean().optional(),
@@ -2189,6 +2174,7 @@ export const zPortainerCustomTemplate = z.object({
Title: z.string().optional(),
Type: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
Variables: z.array(zPortainerCustomTemplateVariableDefinition).optional(),
artifact: zPortainerArtifact.optional(),
});
export const zMotdMotd = z.object({
@@ -4066,16 +4052,16 @@ export const zGitOpsSourcesUpdateGitResponse = zPortainerSource;
/**
* Optional connection overrides; omitted fields fall back to stored values
*/
export const zGitOpsSourcesTestGitBody = zSourcesGitSourceUpdatePayload;
export const zGitOpsSourcesTestByIdBody = zSourcesGitSourceUpdatePayload;
export const zGitOpsSourcesTestGitPath = z.object({
export const zGitOpsSourcesTestByIdPath = z.object({
id: z.int(),
});
/**
* Connection test result
*/
export const zGitOpsSourcesTestGitResponse = zSourcesConnectionTestResult;
export const zGitOpsSourcesTestByIdResponse = zSourcesConnectionTestResult;
/**
* Git source details
@@ -4092,6 +4078,16 @@ export const zGitOpsSourcesCreateGitResponse = zPortainerSource;
*/
export const zGitOpsSourcesSummaryResponse = zWorkflowsStatusSummary;
/**
* Git connection details
*/
export const zGitOpsSourcesTestBody = zSourcesGitSourceCreatePayload;
/**
* Connection test result
*/
export const zGitOpsSourcesTestResponse = zSourcesConnectionTestResult;
export const zGitOpsWorkflowsListQuery = z.object({
search: z.string().optional(),
sort: z.string().optional(),
@@ -0,0 +1,70 @@
import { Form, Formik, FormikHelpers } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { FormValues, formValuesToCreatePayload } from './type';
import { useCreateSourceMutation } from './useSourceCreateMutation';
import { WizardStep, useWizardContext } from './WizardContext';
import { WizardHeader } from './WizardHeader';
import { WizardFooter } from './WizardFooter';
const initialFormValues: FormValues = {
name: '',
type: 'git',
git: {
url: '',
authentication: {
authEnabled: true,
},
connectionOk: false,
},
};
type Props = {
steps: WizardStep[];
};
export function CreateForm({ steps }: Props) {
const mutation = useCreateSourceMutation();
const router = useRouter();
const { currentStep, isLastStep, goToNextStep } = useWizardContext();
return (
<Formik
initialValues={initialFormValues}
onSubmit={handleSubmit}
validationSchema={currentStep.validateStep}
>
<Form noValidate>
<WizardHeader steps={steps} />
<Widget>
<currentStep.component />
</Widget>
<WizardFooter />
</Form>
</Formik>
);
function handleSubmit(
formValues: FormValues,
{ setTouched, setSubmitting }: FormikHelpers<FormValues>
) {
if (!isLastStep) {
goToNextStep();
setTouched({});
setSubmitting(false);
return;
}
mutation.mutate(formValuesToCreatePayload(formValues), {
onSuccess: () => {
notifySuccess('Success', 'Source successfully created');
router.stateService.go('.^');
},
onSettled: () => setSubmitting(false),
});
}
}
@@ -0,0 +1,47 @@
import { useWizardSteps } from '@@/Stepper/useWizardSteps';
import { PageHeader } from '@@/PageHeader';
import { TypeSelectStep, validateTypeSelectStep } from './steps/TypeSelectStep';
import { ConfigureStep, validateConfigureStep } from './steps/ConfigureStep';
import { WizardStep, WizardProvider } from './WizardContext';
import { CreateForm } from './CreateForm';
const steps: WizardStep[] = [
{
id: 'type',
label: 'Select source type',
component: TypeSelectStep,
validateStep: validateTypeSelectStep,
},
{
id: 'configure',
label: 'Configure connection',
component: ConfigureStep,
validateStep: validateConfigureStep,
},
];
export function CreateView() {
const context = useWizardSteps<WizardStep>({ steps });
return (
<div className="form-horizontal pb-20">
<PageHeader
title="Create Source"
breadcrumbs={[
{ link: '.^', label: 'GitOps Sources' },
{ label: 'Create Source' },
]}
reload
/>
<div className="row">
<div className="col-sm-12">
<WizardProvider context={context}>
<CreateForm steps={steps} />
</WizardProvider>
</div>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import { ComponentProps, ComponentType } from 'react';
import { Formik } from 'formik';
import { createContext } from '@/react/utils/context';
import { StepConfig, WizardStepState } from '@@/Stepper/useWizardSteps';
export type WizardStep = StepConfig & {
component: ComponentType;
validateStep: () => ComponentProps<typeof Formik>['validationSchema'];
};
const { Provider: WizardProvider, useContext: useWizardContext } =
createContext<WizardStepState<WizardStep>>('WizardContext');
export { WizardProvider, useWizardContext };
@@ -0,0 +1,174 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Formik } from 'formik';
import * as yup from 'yup';
import { WizardStepState } from '@@/Stepper/useWizardSteps';
import { WizardStep, WizardProvider } from './WizardContext';
import { WizardFooter } from './WizardFooter';
import { FormValues } from './type';
function noop() {}
const firstStep: WizardStep = {
id: 'step-1',
label: 'First',
component: () => null,
validateStep: () => yup.object(),
};
const lastStep: WizardStep = {
id: 'step-2',
label: 'Last',
component: () => null,
validateStep: () => yup.object(),
};
function buildWizardContext(
overrides: Partial<WizardStepState<WizardStep>> = {}
): WizardStepState<WizardStep> {
return {
currentStep: firstStep,
currentStepIndex: 0,
isFirstStep: true,
isLastStep: false,
canGoBack: false,
canGoForward: true,
goToNextStep: noop,
goToPreviousStep: noop,
goToStep: noop,
goToStepByIndex: noop,
...overrides,
};
}
const initialFormValues: FormValues = {
name: '',
type: 'git',
git: {
url: '',
connectionOk: false,
authentication: { authEnabled: false },
},
};
const validFormValues: FormValues = {
name: 'my-source',
type: 'git',
git: {
url: 'https://github.com/org/repo.git',
connectionOk: true,
authentication: { authEnabled: false },
},
};
function renderFooter({
wizardContext = buildWizardContext(),
formValues = initialFormValues,
validationSchema,
validateOnMount,
}: {
wizardContext?: WizardStepState<WizardStep>;
formValues?: FormValues;
validationSchema?: Parameters<typeof Formik>[0]['validationSchema'];
validateOnMount?: boolean;
} = {}) {
return render(
<Formik
initialValues={formValues}
onSubmit={noop}
validationSchema={validationSchema}
validateOnMount={validateOnMount}
>
<WizardProvider context={wizardContext}>
<WizardFooter />
</WizardProvider>
</Formik>
);
}
describe('WizardFooter', () => {
it('disables Back on first step', () => {
renderFooter({ wizardContext: buildWizardContext({ isFirstStep: true }) });
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
});
it('enables Back when not on first step', () => {
renderFooter({
wizardContext: buildWizardContext({
isFirstStep: false,
canGoBack: true,
currentStepIndex: 1,
}),
});
expect(screen.getByRole('button', { name: /back/i })).not.toBeDisabled();
});
it('shows Continue on non-last step', () => {
renderFooter({ wizardContext: buildWizardContext({ isLastStep: false }) });
expect(screen.getByRole('button', { name: /continue/i })).toBeVisible();
});
it('shows Create on last step', () => {
renderFooter({
wizardContext: buildWizardContext({
isFirstStep: false,
isLastStep: true,
currentStep: lastStep,
currentStepIndex: 1,
canGoBack: true,
canGoForward: false,
}),
formValues: validFormValues,
});
expect(screen.getByRole('button', { name: /create/i })).toBeVisible();
});
it('disables Continue when form is invalid', async () => {
renderFooter({
wizardContext: buildWizardContext({ isLastStep: false }),
formValues: initialFormValues,
validationSchema: yup.object({ name: yup.string().required() }),
validateOnMount: true,
});
expect(
await screen.findByRole('button', { name: /continue/i })
).toBeDisabled();
});
it('enables Continue when form is valid', async () => {
renderFooter({
wizardContext: buildWizardContext({ isLastStep: false }),
formValues: validFormValues,
validationSchema: yup.object({ name: yup.string().required() }),
validateOnMount: true,
});
expect(
await screen.findByRole('button', { name: /continue/i })
).not.toBeDisabled();
});
it('calls goToPreviousStep when Back is clicked', async () => {
const goToPreviousStep = vi.fn();
const user = userEvent.setup();
renderFooter({
wizardContext: buildWizardContext({
isFirstStep: false,
canGoBack: true,
goToPreviousStep,
}),
});
await user.click(screen.getByRole('button', { name: /back/i }));
expect(goToPreviousStep).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,38 @@
import { useFormikContext } from 'formik';
import { StickyFooter } from '@@/StickyFooter/StickyFooter';
import { Button, LoadingButton } from '@@/buttons';
import { FormValues } from './type';
import { useWizardContext } from './WizardContext';
export function WizardFooter() {
const { isFirstStep, isLastStep, goToPreviousStep } = useWizardContext();
const { isSubmitting, isValid } = useFormikContext<FormValues>();
return (
<StickyFooter className="justify-end gap-4">
<Button
color="default"
type="button"
onClick={goToPreviousStep}
disabled={isFirstStep}
data-cy="gitops-source-wizard-back-button"
size="medium"
>
Back
</Button>
<LoadingButton
color="primary"
type="submit"
disabled={!isValid}
data-cy="gitops-source-wizard-continue-button"
size="medium"
isLoading={isSubmitting}
loadingText={isLastStep ? 'Creating...' : ''}
>
{isLastStep ? 'Create' : 'Continue'}
</LoadingButton>
</StickyFooter>
);
}
@@ -0,0 +1,23 @@
import { Stepper } from '@@/Stepper/Stepper';
import { WizardStep, useWizardContext } from './WizardContext';
type Props = {
steps: WizardStep[];
};
export function WizardHeader({ steps }: Props) {
const { currentStepIndex, goToStepByIndex } = useWizardContext();
return (
<div className="row">
<div className="col-sm-12 px-0">
<Stepper
steps={steps}
currentStepIndex={currentStepIndex}
onStepClick={goToStepByIndex}
/>
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import { Widget } from '@@/Widget';
export function AccessControlStep() {
return (
<>
<Widget.Title
title="Access control"
subtitle="Configure who can use this source to create workflows and deployments."
/>
<Widget.Body></Widget.Body>
</>
);
}
@@ -0,0 +1,35 @@
import { useFormikContext } from 'formik';
import { FormSection } from '@@/form-components/FormSection';
import { GitAuthentication } from '../../components/GitAuthentication';
import { FormValues } from '../type';
export function Authentication() {
const { values, setValues, errors } = useFormikContext<FormValues>();
if (values.type !== 'git') {
return null;
}
const { authentication } = values.git;
return (
<FormSection title="Authentication">
<GitAuthentication
values={authentication}
errors={errors.git?.authentication}
onChange={(changed) =>
setValues((old) => ({
...old,
git: {
...old.git,
authentication: { ...old.git.authentication, ...changed },
},
}))
}
toggleDataCy="git-auth-toggle"
/>
</FormSection>
);
}
@@ -0,0 +1,53 @@
import { useFormikContext } from 'formik';
import { Input } from '@@/form-components/Input';
import { FormControl } from '@@/form-components/FormControl';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormValues } from '../type';
import { Authentication } from './Authentication';
import { ConnectionTest } from './ConnectionTest';
export function ConfigureGit() {
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
if (values.type !== 'git') {
return null;
}
return (
<div className="grid">
<FormControl
inputId="repository-url-input"
label="Repository URL"
required
errors={errors.git?.url}
tooltip="Enter the full URL of your git repository"
>
<Input
id="repository-url-input"
value={values.git.url}
data-cy="repository-url-input"
placeholder="https://github.com/org/repo"
required
onChange={({ target: { value } }) => setFieldValue('git.url', value)}
/>
</FormControl>
<SwitchField
label="Skip TLS Verification"
labelClass="col-sm-3 col-lg-2"
name="TLSSkipVerify"
checked={values.git.tlsSkipVerify || false}
onChange={(value) => setFieldValue('git.tlsSkipVerify', value)}
tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate."
data-cy="tls-skip-verify"
/>
<Authentication />
<ConnectionTest />
</div>
);
}
@@ -0,0 +1,3 @@
export function ConfigureHelm() {
return <>Helm Panel</>;
}
@@ -0,0 +1,3 @@
export function ConfigureRegistry() {
return <>Registry panel</>;
}
@@ -0,0 +1,70 @@
import { useFormikContext } from 'formik';
import { ComponentType } from 'react';
import { Widget } from '@@/Widget';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { FormValues } from '../type';
import { validationSchema } from '../validation';
import { ConfigureGit } from './ConfigureGit';
import { ConfigureHelm } from './ConfigureHelm';
import { ConfigureRegistry } from './ConfigureRegistry';
const panels: Record<
FormValues['type'],
{ title: string; component: ComponentType }
> = {
git: { title: 'Git Repository', component: ConfigureGit },
helm: { title: 'Helm Repository', component: ConfigureHelm },
registry: { title: 'OCI Registry', component: ConfigureRegistry },
};
export function ConfigureStep() {
const { values } = useFormikContext<FormValues>();
const { title, component: ConfigurePanel } = panels[values.type];
return (
<>
<Widget.Title
title={`Configure ${title}`}
subtitle="Enter the connection details and test the connection before proceeding"
/>
<Widget.Body>
<SharedFields />
<ConfigurePanel />
</Widget.Body>
</>
);
}
export function validateConfigureStep() {
return validationSchema().pick(['name', 'git']);
}
function SharedFields() {
const { values, errors, setFieldValue } = useFormikContext<FormValues>();
return (
<div className="grid">
<FormControl
inputId="source-name-input"
label="Source Name"
required
errors={errors.name}
tooltip="A unique name to identify this source in Portainer"
>
<Input
id="source-name-input"
value={values.name}
data-cy="source-name-input"
placeholder="my-source"
required
onChange={({ target: { value } }) => setFieldValue('name', value)}
/>
</FormControl>
</div>
);
}
@@ -0,0 +1,135 @@
import { render, screen, waitFor } from '@testing-library/react';
import { Formik } from 'formik';
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { FormValues } from '../type';
import { ConnectionTest } from './ConnectionTest';
const baseGitValues: FormValues['git'] = {
url: 'https://github.com/org/repo.git',
tlsSkipVerify: false,
connectionOk: false,
authentication: {
authEnabled: false,
},
};
const invalidGitValues: FormValues['git'] = {
url: '',
tlsSkipVerify: false,
connectionOk: false,
authentication: {
authEnabled: false,
},
};
function renderConnectionTest(gitValues: FormValues['git']) {
const initialValues: FormValues = {
name: 'test-source',
type: 'git',
git: gitValues,
};
const Wrapped = withTestQueryProvider(ConnectionTest);
return render(
<Formik initialValues={initialValues} onSubmit={() => {}}>
<Wrapped />
</Formik>
);
}
describe('ConnectionTest', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders nothing when git URL is empty', () => {
renderConnectionTest(invalidGitValues);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('shows "Testing connection…" while debounce is pending, then success', async () => {
server.use(
http.post('/api/gitops/sources/test', () =>
HttpResponse.json({ success: true })
)
);
renderConnectionTest(baseGitValues);
expect(screen.getByText('Testing connection...')).toBeInTheDocument();
expect(screen.queryByText('Connection successful')).not.toBeInTheDocument();
// Advance past the debounce
await vi.advanceTimersByTimeAsync(600);
await waitFor(() => {
expect(screen.getByText('Connection successful')).toBeVisible();
});
expect(screen.queryByText('Testing connection...')).not.toBeInTheDocument();
});
it('shows success alert when gitOpsSourcesTest returns success:true', async () => {
server.use(
http.post('/api/gitops/sources/test', () =>
HttpResponse.json({ success: true })
)
);
renderConnectionTest(baseGitValues);
await vi.advanceTimersByTimeAsync(600);
await waitFor(() => {
expect(screen.getByText('Connection successful')).toBeVisible();
});
});
it('shows failure alert when gitOpsSourcesTest returns success:false', async () => {
server.use(
http.post('/api/gitops/sources/test', () =>
HttpResponse.json({ success: false, error: 'Repository not found' })
)
);
renderConnectionTest(baseGitValues);
await vi.advanceTimersByTimeAsync(600);
await waitFor(() => {
expect(screen.getByText('Repository not found')).toBeVisible();
});
});
it('shows failure alert when the API returns an error', async () => {
const restoreConsole = suppressConsoleLogs();
server.use(
http.post('/api/gitops/sources/test', () =>
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 })
)
);
renderConnectionTest(baseGitValues);
await vi.advanceTimersByTimeAsync(600);
await waitFor(() => {
expect(screen.getByText('Connection failed')).toBeVisible();
});
restoreConsole();
});
});
@@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { useFormikContext } from 'formik';
import { isEqual } from 'lodash';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { Alert } from '@@/Alert';
import { FormValues, gitFormValuesToTestPayload } from '../type';
import { useTestSourceConnection } from '../useTestSourceConnection';
import { validateGitConnection } from '../validation';
export function ConnectionTest() {
const { values, setFieldValue } = useFormikContext<FormValues>();
const { git } = values;
const livePayload = validateGitConnection().isValidSync(git)
? gitFormValuesToTestPayload(git)
: undefined;
const debouncedPayload = useDebouncedValue(livePayload);
const query = useTestSourceConnection(debouncedPayload);
const settled = isEqual(debouncedPayload, livePayload) && !query.isFetching;
const connectionOk = settled && query.data?.success === true;
useEffect(() => {
setFieldValue('git.connectionOk', connectionOk);
}, [connectionOk, setFieldValue]);
if (!livePayload) {
return null;
}
if (!settled) {
return (
<Alert color="info" title="Testing connection...">
Checking that Portainer can reach the repository.
</Alert>
);
}
if (query.isError) {
return (
<Alert color="error" title="Connection failed">
Unable to test the connection. Please try again.
</Alert>
);
}
if (query.data?.success) {
return (
<Alert color="success" title="Connection successful">
Portainer reached the repository with these details.
</Alert>
);
}
return (
<Alert color="error" title="Connection failed">
{query.data?.error || 'Unable to reach the repository.'}
</Alert>
);
}
@@ -0,0 +1,34 @@
import { useFormikContext } from 'formik';
import { Widget } from '@@/Widget';
import { BoxSelector } from '@@/BoxSelector';
import { FormValues } from '../type';
import { validationSchema } from '../validation';
import { sourceTypeOptions } from './typeSelectOptions';
export function TypeSelectStep() {
const { values, setFieldValue } = useFormikContext<FormValues>();
return (
<>
<Widget.Title
title="Select Source type"
subtitle="Choose the type of external source you want to connect to Portainer."
/>
<Widget.Body>
<BoxSelector
value={values.type}
onChange={(type) => setFieldValue('type', type)}
radioName="source-type-selector"
options={sourceTypeOptions}
/>
</Widget.Body>
</>
);
}
export function validateTypeSelectStep() {
return validationSchema().pick(['type']);
}
@@ -0,0 +1,80 @@
import { Cylinder, Radio } from 'lucide-react';
import GitIcon from '@/assets/ico/git.svg?c';
import HelmIcon from '@/assets/ico/helm.svg?c';
import { BoxSelectorOption } from '@@/BoxSelector';
const git: BoxSelectorOption<'git'> = {
id: 'git',
label: 'Git Repository',
value: 'git',
icon: GitIcon,
iconType: 'logo',
description: Description({
txt: 'Connect to a Git repository (GitHub, Gitlab, Bitbucket, etc) to pull configurations files, manifests, and other deployment assets.',
items: ['Branch & tag selection', 'SSH or HTTPS auth', 'Webhook support'],
}),
};
const helm: BoxSelectorOption<'helm'> = {
id: 'helm',
value: 'helm',
label: 'Helm Repository',
icon: HelmIcon,
iconType: 'logo',
description: Description({
txt: 'Connect to a Helm chart repository to deploy and manage Helm charts across your environments.',
items: [
'Chart versioning',
'Values customization',
'Dependency management',
],
}),
disabled: true,
};
const registry: BoxSelectorOption<'registry'> = {
id: 'registry',
value: 'registry',
label: 'OCI Registry',
icon: Radio,
description: Description({
txt: 'Connect to an OCI-compliant container registry to pull artifacts and container images.',
items: [
'Image tags & digests',
'Private registry auth',
'Artifact support',
],
}),
disabled: true,
};
const s3: BoxSelectorOption<'s3'> = {
id: 's3',
value: 's3',
label: 'S3 Bucket',
icon: Cylinder,
description: Description({
txt: 'Connect to an S3-compatible bucket (AWS, S3, MinIO, etc) to fetch configuration files and assets.',
items: ['Prefix filtering', 'IAM or key auth', 'Version support'],
}),
disabled: true,
};
export const sourceTypeOptions = [git, helm, registry, s3];
function Description({ txt, items }: { txt: string; items: string[] }) {
return (
<div>
{txt}
<div className="pl-4 pt-2">
<ul>
{items.map((v, k) => (
<li key={k}>{v}</li>
))}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
import { formValuesToCreatePayload, gitFormValuesToTestPayload } from './type';
const baseGit = {
url: 'https://github.com/org/repo.git',
tlsSkipVerify: false,
connectionOk: false,
};
describe('formValuesToCreatePayload', () => {
it('populates authentication when authEnabled with username and password', () => {
const payload = formValuesToCreatePayload({
name: 'my-source',
type: 'git',
git: {
...baseGit,
authentication: {
authEnabled: true,
username: 'alice',
password: 'secret',
},
},
});
expect(payload.git.authentication).toEqual({
username: 'alice',
password: 'secret',
});
});
it('omits authentication when authEnabled is false', () => {
const payload = formValuesToCreatePayload({
name: 'my-source',
type: 'git',
git: {
...baseGit,
authentication: { authEnabled: false },
},
});
expect(payload.git.authentication).toBeUndefined();
});
it('omits authentication when authEnabled but username is missing', () => {
const payload = formValuesToCreatePayload({
name: 'my-source',
type: 'git',
git: {
...baseGit,
authentication: {
authEnabled: true,
password: 'secret',
},
},
});
expect(payload.git.authentication).toBeUndefined();
});
it('omits authentication when authEnabled but password is missing', () => {
const payload = formValuesToCreatePayload({
name: 'my-source',
type: 'git',
git: {
...baseGit,
authentication: {
authEnabled: true,
username: 'alice',
},
},
});
expect(payload.git.authentication).toBeUndefined();
});
it('does not include connectionOk in the create payload', () => {
const payload = formValuesToCreatePayload({
name: 'my-source',
type: 'git',
git: {
...baseGit,
connectionOk: true,
authentication: { authEnabled: false },
},
});
expect(payload.git).not.toHaveProperty('connectionOk');
});
});
describe('gitFormValuesToTestPayload', () => {
it('populates authentication when authEnabled with username and password', () => {
const payload = gitFormValuesToTestPayload({
...baseGit,
authentication: {
authEnabled: true,
username: 'alice',
password: 'secret',
},
});
expect(payload.authentication).toEqual({
username: 'alice',
password: 'secret',
});
});
it('omits authentication when authEnabled is false', () => {
const payload = gitFormValuesToTestPayload({
...baseGit,
authentication: { authEnabled: false },
});
expect(payload.authentication).toBeUndefined();
});
});
@@ -0,0 +1,64 @@
import {
type SourcesGitAuthenticationPayload,
type SourcesGitSourceCreatePayload,
} from '@api/types.gen';
import { CreateSourcePayload } from './useSourceCreateMutation';
type GitFormValues = {
url: string;
authentication: {
authEnabled: boolean;
username?: string;
password?: string;
};
tlsSkipVerify?: boolean;
/** Mirrors the connection-test result; not sent in the create payload. */
connectionOk: boolean;
};
export const FormValueTypes = ['git', 'registry', 'helm'] as const;
export type FormValues = {
name: string;
type: (typeof FormValueTypes)[number];
git: GitFormValues;
};
export function formValuesToCreatePayload({
name,
type,
git: { authentication, tlsSkipVerify, url },
}: FormValues): CreateSourcePayload {
return {
type,
git: {
name,
tlsSkipVerify,
url,
authentication: buildAuthPayload(authentication),
},
};
}
export function gitFormValuesToTestPayload({
authentication,
url,
tlsSkipVerify,
}: GitFormValues): SourcesGitSourceCreatePayload {
return {
url,
tlsSkipVerify,
authentication: buildAuthPayload(authentication),
};
}
function buildAuthPayload(
auth: GitFormValues['authentication']
): SourcesGitAuthenticationPayload | undefined {
const { authEnabled, username, password } = auth;
if (!authEnabled || !username || !password) {
return undefined;
}
return { username, password };
}
@@ -0,0 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { gitOpsSourcesCreateGit } from '@api/sdk.gen';
import { type SourcesGitSourceCreatePayload } from '@api/types.gen';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { sourceQueryKeys } from '../queries/query-keys';
export type CreateSourcePayload = {
type: 'git' | 'registry' | 'helm';
git: SourcesGitSourceCreatePayload;
};
export function useCreateSourceMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createSource,
...withError('Unable to create source'),
...withInvalidate(queryClient, [sourceQueryKeys.all]),
});
}
async function createSource(payload: CreateSourcePayload) {
const { data } = await gitOpsSourcesCreateGit({ body: payload.git });
return data;
}
@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { gitOpsSourcesTest } from '@api/sdk.gen';
import { type SourcesGitSourceCreatePayload } from '@api/types.gen';
import { strToHash } from '@/react/utils/hash';
import { sourceQueryKeys } from '../queries/query-keys';
export function useTestSourceConnection(
payload: SourcesGitSourceCreatePayload | undefined
) {
const payloadHashedPassword = {
...payload,
authentication: {
...payload?.authentication,
password: null,
passwordHash: payload?.authentication?.password
? strToHash(payload.authentication.password)
: undefined,
},
};
return useQuery({
queryKey: [
...sourceQueryKeys.all,
'connection-test',
payloadHashedPassword,
],
queryFn: async () => {
if (!payload) {
throw new Error('Connection details are required');
}
const { data } = await gitOpsSourcesTest({ body: payload });
return data;
},
enabled: !!payload,
retry: false,
refetchOnWindowFocus: false,
staleTime: 5000,
});
}
@@ -0,0 +1,143 @@
import { validateGitConnection, validationSchema } from './validation';
const baseAuth = {
authEnabled: false,
};
const validGitValues = {
url: 'https://github.com/org/repo.git',
tlsSkipVerify: false,
connectionOk: true,
authentication: baseAuth,
};
describe('validateGitConnection (pick schema — no connectionOk)', () => {
it('passes when url is valid and auth disabled', async () => {
const schema = validateGitConnection();
await expect(
schema.isValid({
url: 'https://github.com/org/repo.git',
tlsSkipVerify: false,
authentication: baseAuth,
})
).resolves.toBe(true);
});
it('does not require connectionOk', async () => {
const schema = validateGitConnection();
await expect(
schema.isValid({
url: 'https://github.com/org/repo.git',
authentication: baseAuth,
})
).resolves.toBe(true);
});
it('rejects empty URL', async () => {
const schema = validateGitConnection();
await expect(
schema.isValid({
url: '',
authentication: baseAuth,
})
).resolves.toBe(false);
});
it('rejects localhost URL', async () => {
const schema = validateGitConnection();
await expect(
schema.isValid({
url: 'http://localhost/repo.git',
authentication: baseAuth,
})
).resolves.toBe(false);
});
it('accepts a normal https URL', async () => {
const schema = validateGitConnection();
await expect(
schema.isValid({
url: 'https://gitlab.example.com/org/repo.git',
authentication: baseAuth,
})
).resolves.toBe(true);
});
});
describe('validationSchema git.authentication', () => {
it('requires username and password when authEnabled is true', async () => {
const schema = validationSchema();
const result = await schema.isValid({
name: 'src',
type: 'git',
git: {
...validGitValues,
authentication: { ...baseAuth, authEnabled: true },
},
});
expect(result).toBe(false);
});
it('passes when authEnabled is false and no credentials provided', async () => {
const schema = validationSchema();
const result = await schema.isValid({
name: 'src',
type: 'git',
git: validGitValues,
});
expect(result).toBe(true);
});
it('passes when authEnabled is true and credentials are provided', async () => {
const schema = validationSchema();
const result = await schema.isValid({
name: 'src',
type: 'git',
git: {
...validGitValues,
authentication: {
...baseAuth,
authEnabled: true,
username: 'alice',
password: 'secret',
},
},
});
expect(result).toBe(true);
});
});
describe('validationSchema full git (requires connectionOk)', () => {
it('fails when connectionOk is false', async () => {
const schema = validationSchema();
const result = await schema.isValid({
name: 'src',
type: 'git',
git: {
...validGitValues,
connectionOk: false,
},
});
expect(result).toBe(false);
});
it('passes when connectionOk is true', async () => {
const schema = validationSchema();
const result = await schema.isValid({
name: 'src',
type: 'git',
git: validGitValues,
});
expect(result).toBe(true);
});
it('requires name', async () => {
const schema = validationSchema();
const result = await schema.isValid({
name: '',
type: 'git',
git: validGitValues,
});
expect(result).toBe(false);
});
});
@@ -0,0 +1,51 @@
import { bool, mixed, object, SchemaOf, string } from 'yup';
import { isValidUrl } from '@@/form-components/validate-url';
import { FormValues, FormValueTypes } from './type';
export function validationSchema(): SchemaOf<FormValues> {
return object({
name: string().required('Name is required.'),
type: mixed<FormValues['type']>()
.oneOf([...FormValueTypes])
.required()
.default('git'),
git: validateGit(),
});
}
export function validateGitConnection() {
return validateGit().pick(['url', 'authentication', 'tlsSkipVerify']);
}
function validateGit(): SchemaOf<FormValues['git']> {
return object({
authentication: object({
authEnabled: bool().required().default(false),
username: string().when('authEnabled', {
is: true,
then: string().required('Username is required'),
}),
password: string().when('authEnabled', {
is: true,
then: string().required('Password is required'),
}),
}),
url: string()
.required('Repository URL is required.')
.test(
'valid repository URL',
'The repository URL must be a valid URL (localhost cannot be used)',
(value) =>
isValidUrl(
value,
(url) => !!url.hostname && url.hostname !== 'localhost'
)
),
tlsSkipVerify: bool(),
connectionOk: bool()
.oneOf([true], 'The connection test must succeed before continuing.')
.required(),
});
}
@@ -79,11 +79,11 @@ function PageContent({ source }: { source: SourceDetail }) {
name: (
<>
Workflows{' '}
<CountDot value={source?.workflows.length} type="workflow" />
<CountDot value={source.workflows?.length ?? 0} type="workflow" />
</>
),
icon: GitCommit,
widget: <WorkflowsTab workflows={source?.workflows ?? []} />,
widget: <WorkflowsTab workflows={source.workflows ?? []} />,
selectedTabParam: 'workflows',
},
],
@@ -2,15 +2,13 @@ import { LockIcon } from 'lucide-react';
import { useFormikContext } from 'formik';
import { Card } from '@@/primitives/Card';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { GitAuthentication } from '../../../components/GitAuthentication';
import { SettingsFormValues } from './types';
export function EditAuthWidget() {
const { values, errors, setFieldValue } =
useFormikContext<SettingsFormValues>();
const { values, errors, setValues } = useFormikContext<SettingsFormValues>();
return (
<Card.Container>
@@ -20,44 +18,19 @@ export function EditAuthWidget() {
subtitle="Choose how Portainer authenticates to this source"
/>
<Card.Body>
<div className="mb-3">
<SwitchField
label="Authentication"
name="authEnabled"
checked={values.authEnabled}
onChange={(checked) => setFieldValue('authEnabled', checked)}
data-cy="source-auth-enabled"
/>
</div>
{values.authEnabled && (
<>
<FormControl label="Username" errors={errors?.username}>
<Input
value={values.username}
name="repository_username"
placeholder="git username"
onChange={(e) => setFieldValue('username', e.target.value)}
data-cy="component-gitUsernameInput"
/>
</FormControl>
<FormControl
label="Personal Access Token"
tooltip="Provide a personal access token or password"
errors={errors?.password}
>
<Input
type="password"
value={values.password}
name="repository_password"
placeholder="*******"
onChange={(e) => setFieldValue('password', e.target.value)}
data-cy="component-gitPasswordInput"
/>
</FormControl>
</>
)}
<GitAuthentication
values={{
authEnabled: values.authEnabled,
username: values.username,
password: values.password,
}}
isEditing
errors={{ username: errors.username, password: errors.password }}
onChange={(changed) =>
setValues((oldValues) => ({ ...oldValues, ...changed }))
}
toggleDataCy="source-auth-enabled"
/>
</Card.Body>
</Card.Container>
);
@@ -5,6 +5,7 @@ import {
SortOption,
} from '@@/SortableList/SortableList';
import { StatusSummaryBar } from '@@/StatusSummaryBar/StatusSummaryBar';
import { AddButton } from '@@/buttons';
import { useSources } from '../queries/useSources';
import { useSourcesSummary } from '../queries/useSourcesSummary';
@@ -72,7 +73,11 @@ export function ListView() {
return (
<>
<PageHeader title="GitOps Sources" breadcrumbs="GitOps Sources" reload />
<PageHeader title="GitOps Sources" breadcrumbs="GitOps Sources" reload>
<div className="ml-auto">
<AddButton data-cy="add-source-button">Add new</AddButton>
</div>
</PageHeader>
<div className="mx-4 mb-4 space-y-4">
<StatusSummaryBar
total={summaryTotal}
@@ -0,0 +1,46 @@
import { SwitchField } from '@@/form-components/SwitchField';
import {
ProviderCredentialFields,
CredentialValues,
} from './ProviderCredentialFields';
type AuthenticationValues = CredentialValues & { authEnabled: boolean };
type Props = {
values: AuthenticationValues;
errors?: { username?: string; password?: string };
isEditing?: boolean;
onChange: (changed: Partial<AuthenticationValues>) => void;
toggleDataCy: string;
};
export function GitAuthentication({
values,
errors,
isEditing,
onChange,
toggleDataCy,
}: Props) {
return (
<>
<div className="mb-4">
<SwitchField
label="Authentication"
name="authentication"
checked={values.authEnabled}
onChange={(value) => onChange({ authEnabled: value })}
data-cy={toggleDataCy}
/>
</div>
{values.authEnabled && (
<ProviderCredentialFields
values={values}
errors={errors}
isEditing={isEditing}
onChange={onChange}
/>
)}
</>
);
}
@@ -0,0 +1,41 @@
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
type Props = {
value: string;
onChange: (value: string) => void;
label: string;
tooltip?: string;
error?: string;
required?: boolean;
};
export function PasswordField({
value,
onChange,
label,
tooltip,
error,
required,
}: Props) {
return (
<FormControl
inputId="Password"
label={label}
errors={error}
required={required}
tooltip={tooltip}
>
<Input
id="Password"
name="password"
type="password"
value={value}
autoComplete="off"
placeholder="*******"
onChange={(e) => onChange(e.target.value)}
data-cy="component-gitPasswordInput"
/>
</FormControl>
);
}
@@ -0,0 +1,40 @@
import { PasswordField } from './PasswordField';
import { UsernameField } from './UsernameField';
export type CredentialValues = {
username?: string;
password?: string;
};
type Props = {
values: CredentialValues;
isEditing?: boolean;
errors?: { username?: string; password?: string };
onChange: (values: Partial<CredentialValues>) => void;
};
export function ProviderCredentialFields({
values: { username, password },
isEditing = false,
errors,
onChange,
}: Props) {
return (
<div className="flex flex-col gap-y-4">
<UsernameField
value={username || ''}
onChange={(value) => onChange({ username: value })}
error={errors?.username}
/>
<PasswordField
value={password || ''}
onChange={(value) => onChange({ password: value })}
label="Personal Access Token"
tooltip="Provide a personal access token or password"
error={errors?.password}
required={!isEditing}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
type Props = {
value: string;
onChange: (value: string) => void;
tooltip?: string;
error?: string;
};
export function UsernameField({ value, onChange, tooltip, error }: Props) {
return (
<FormControl
inputId="Username"
label="Username"
errors={error}
tooltip={tooltip}
>
<Input
id="Username"
name="username"
value={value}
autoComplete="off"
onChange={(e) => onChange(e.target.value)}
data-cy="component-gitUsernameInput"
placeholder="git username"
/>
</FormControl>
);
}
@@ -1,7 +1,7 @@
import { useMutation } from '@tanstack/react-query';
import { type SourcesConnectionTestResult } from '@api/types.gen';
import { gitOpsSourcesTestGit } from '@api/sdk.gen';
import { gitOpsSourcesTestById } from '@api/sdk.gen';
import { withError } from '@/react-tools/react-query';
@@ -15,7 +15,7 @@ async function testSourceConnection(
id: Source['id'],
payload: UpdateSourcePayload
): Promise<ConnectionTestResult> {
const { data } = await gitOpsSourcesTestGit({ path: { id }, body: payload });
const { data } = await gitOpsSourcesTestById({ path: { id }, body: payload });
return data;
}
@@ -12,7 +12,6 @@ export interface Source {
url: string;
status: SourceStatus;
error?: string;
provider?: number;
usedBy: number;
environments: number;
lastSync: number;
+9 -2
View File
@@ -1,14 +1,21 @@
import { ComponentType } from 'react';
import { MutationCache, QueryClient } from '@tanstack/react-query';
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { withReactQuery } from '@/react-tools/withReactQuery';
export function withTestQueryProvider<T>(
WrappedComponent: ComponentType<T & JSX.IntrinsicAttributes>,
{ onMutationError }: { onMutationError?(error: unknown): void } = {}
{
onMutationError,
onQueryError,
}: {
onMutationError?(error: unknown): void;
onQueryError?(error: unknown): void;
} = {}
) {
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
queryCache: new QueryCache({ onError: onQueryError }),
mutationCache: new MutationCache({
onError: onMutationError,
}),
+33
View File
@@ -0,0 +1,33 @@
import {
PropsWithChildren,
createContext as reactCreateContext,
useContext as reactUseContext,
} from 'react';
/**
* Reduce the boilerplate code to create a custom context and provider hook
* @param displayName Display name of the Context in react inspector
* @returns A Provider and custom hook for this context
*/
export function createContext<TContext>(displayName: string) {
const Context = reactCreateContext<TContext | null>(null);
Context.displayName = displayName;
return { Provider, useContext };
function Provider({
children,
context,
}: PropsWithChildren<{ context: TContext }>) {
return <Context.Provider value={context}>{children}</Context.Provider>;
}
function useContext() {
const context = reactUseContext(Context);
if (context === null) {
throw new Error(`should be nested under Provider - ${displayName}`);
}
return context;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { strToHash } from './hash';
describe('strToHash', () => {
it('returns the same value for the same input', () => {
expect(strToHash('password123')).toBe(strToHash('password123'));
});
it('returns different values for different inputs', () => {
expect(strToHash('password123')).not.toBe(strToHash('password456'));
});
});
+8
View File
@@ -0,0 +1,8 @@
/** Non-cryptographic hash for cache keys and display purposes — do not use for security. */
export function strToHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
}