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