diff --git a/api/git/types/types.go b/api/git/types/types.go index ee0d4afdb..521c3bb32 100644 --- a/api/git/types/types.go +++ b/api/git/types/types.go @@ -28,7 +28,7 @@ type RepoConfig struct { // NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit. ConfigFilePath string `example:"docker-compose.yml"` // Git credentials - Authentication *GitAuthentication + Authentication *GitAuthentication `json:",omitempty"` // Repository hash ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"` // TLSSkipVerify skips SSL verification when cloning the Git repository diff --git a/api/gitops/workflows/source_artifact.go b/api/gitops/workflows/source_artifact.go index 81c11aec1..e42ac66ad 100644 --- a/api/gitops/workflows/source_artifact.go +++ b/api/gitops/workflows/source_artifact.go @@ -251,3 +251,27 @@ 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) { + normalizedURL, err := gittypes.NormalizeURL(gittypes.SanitizeURL(url)) + if err != nil { + return false, err + } + + existing, err := tx.Source().ReadAll(func(s portainer.Source) bool { + if s.ID == sourceID || s.Type != portainer.SourceTypeGit || s.GitConfig == nil { + return false + } + + normalized, err := gittypes.NormalizeURL(gittypes.SanitizeURL(s.GitConfig.URL)) + + return err == nil && normalized == normalizedURL + }) + + if err != nil { + return false, err + } + + return len(existing) == 0, nil +} diff --git a/api/gitops/workflows/types.go b/api/gitops/workflows/types.go index 3c24ca8ec..12abab2e6 100644 --- a/api/gitops/workflows/types.go +++ b/api/gitops/workflows/types.go @@ -79,14 +79,14 @@ type WorkflowStatusObject struct { } type Workflow struct { - ID int `json:"id"` - Name string `json:"name"` - Type Type `json:"type"` - Platform DeploymentPlatform `json:"platform"` - Status WorkflowStatusObject `json:"status"` + ID int `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Type Type `json:"type" validate:"required"` + Platform DeploymentPlatform `json:"platform" validate:"required"` + Status WorkflowStatusObject `json:"status" validate:"required"` GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"` AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"` - Target Target `json:"target"` + Target Target `json:"target" validate:"required"` CreationDate int64 `json:"creationDate"` LastSyncDate int64 `json:"lastSyncDate"` } diff --git a/api/http/handler/gitops/sources/create_git.go b/api/http/handler/gitops/sources/create_git.go index ce680d895..19335117e 100644 --- a/api/http/handler/gitops/sources/create_git.go +++ b/api/http/handler/gitops/sources/create_git.go @@ -24,7 +24,7 @@ type GitAuthenticationPayload struct { // GitSourceCreatePayload holds the parameters for creating a git-backed source type GitSourceCreatePayload struct { Name string `json:"name"` - URL string `json:"url"` + URL string `json:"url" validate:"required"` TLSSkipVerify bool `json:"tlsSkipVerify"` Authentication *GitAuthenticationPayload `json:"authentication"` } diff --git a/api/http/handler/gitops/sources/fetch.go b/api/http/handler/gitops/sources/fetch.go new file mode 100644 index 000000000..654ff02f7 --- /dev/null +++ b/api/http/handler/gitops/sources/fetch.go @@ -0,0 +1,54 @@ +package sources + +import ( + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" +) + +// FetchSourceWorkflows returns the workflows and stats for a single source. +func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([]ce.Workflow, ce.SourceStats, error) { + wfs, err := tx.Workflow().ReadAll(func(wf portainer.Workflow) bool { + return slices.ContainsFunc(wf.Artifacts, func(artifact portainer.ArtifactSources) bool { + return slices.Contains(artifact.SourceIDs, src.ID) + }) + }) + if err != nil { + return nil, ce.SourceStats{}, err + } + + if len(wfs) == 0 { + return nil, ce.SourceStats{}, nil + } + + wfIDSet := set.ToSet(slicesx.Map(wfs, func(wf portainer.Workflow) portainer.WorkflowID { return wf.ID })) + + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { + _, ok := wfIDSet[s.WorkflowID] + return ok + }) + if err != nil { + return nil, ce.SourceStats{}, err + } + + unknown := ce.WorkflowPhaseStatus{Status: ce.StatusUnknown} + items := make([]ce.Workflow, 0, len(stacks)) + stats := ce.SourceStats{EndpointIDs: set.Set[portainer.EndpointID]{}} + + for _, stacks := range stacks { + items = append(items, ce.MapStackToWorkflow(stacks, src.GitConfig, unknown, unknown)) + stats.WorkflowCount++ + if stacks.EndpointID != 0 { + stats.EndpointIDs.Add(stacks.EndpointID) + } + if lastSync := ce.StackLastSyncDate(stacks); lastSync > stats.LastSync { + stats.LastSync = lastSync + } + } + + return items, stats, nil +} diff --git a/api/http/handler/gitops/sources/get.go b/api/http/handler/gitops/sources/get.go index 449563afc..07e7848e5 100644 --- a/api/http/handler/gitops/sources/get.go +++ b/api/http/handler/gitops/sources/get.go @@ -2,23 +2,25 @@ package sources import ( "net/http" - "strconv" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" gittypes "github.com/portainer/portainer/api/git/types" - ce "github.com/portainer/portainer/api/gitops/workflows" - "github.com/portainer/portainer/api/http/security" + "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" ) +type gitAuthInfo struct { + Type gittypes.GitCredentialAuthType `json:"type"` + Username string `json:"username"` +} + type connectionInfo struct { - ReferenceName string `json:"referenceName"` - ConfigFilePath string `json:"configFilePath"` - TLSSkipVerify bool `json:"tlsSkipVerify"` - Authentication bool `json:"authentication,omitempty"` + ConfigFilePath string `json:"configFilePath"` + TLSSkipVerify bool `json:"tlsSkipVerify"` + Authentication *gitAuthInfo `json:"authentication,omitempty"` } type autoUpdateInfo struct { @@ -29,9 +31,9 @@ type autoUpdateInfo struct { // SourceDetail extends Source with connection settings and linked workflows. type SourceDetail struct { Source - Connection *connectionInfo `json:"connection,omitempty"` - AutoUpdate *autoUpdateInfo `json:"autoUpdate,omitempty"` - Workflows []ce.Workflow `json:"workflows"` + Connection connectionInfo `json:"connection" validate:"required"` + AutoUpdate *autoUpdateInfo `json:"autoUpdate,omitempty"` + Workflows []workflows.Workflow `json:"workflows"` } // @id GitOpsSourceGet @@ -55,64 +57,65 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H return httperror.BadRequest("Invalid source identifier route variable", err) } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return httperror.InternalServerError("Unable to retrieve info from request context", err) - } + sourceID := portainer.SourceID(srcID) - var src *portainer.Source - var workflows []ce.Workflow + var source *portainer.Source + var sourceWfs []workflows.Workflow + var stats workflows.SourceStats - if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { var err error - src, err = tx.Source().Read(portainer.SourceID(srcID)) + source, err = tx.Source().Read(sourceID) if err != nil { return err } - workflows, err = ce.FetchWorkflows(r.Context(), tx, h.gitService, h.k8sFactory, securityContext, nil) + + sourceWfs, stats, err = FetchSourceWorkflows(tx, source) return err - }); h.dataStore.IsErrObjectNotFound(err) { - return httperror.NotFound("Unable to find a source with the specified identifier", err) + }) + + if h.dataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Source not found", err) } else if err != nil { return httperror.InternalServerError("Unable to retrieve source", err) } - byID := workflowsBySourceID(workflows) - - var wfs []ce.Workflow - if src.GitConfig != nil { - wfs = byID[sourceID(gitSourceKey(src.GitConfig))] - } - - var autoUpdate *portainer.AutoUpdateSettings - if len(wfs) > 0 { - autoUpdate = wfs[0].AutoUpdate - } - - id := strconv.Itoa(int(src.ID)) - url := "" - if src.GitConfig != nil { - url = src.GitConfig.URL - } - - detail := SourceDetail{ - Source: buildSource(id, url, wfs), - Connection: buildConnectionInfo(src.GitConfig), - AutoUpdate: buildAutoUpdateInfo(autoUpdate), - Workflows: redactWorkflowCredentials(wfs), - } + detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.GitConfig, sourceWfs) return response.JSON(w, detail) } -func buildConnectionInfo(cfg *gittypes.RepoConfig) *connectionInfo { - if cfg == nil { - return nil +func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail { + var autoUpdate *autoUpdateInfo + if len(sourceWfs) > 0 { + autoUpdate = buildAutoUpdateInfo(sourceWfs[0].AutoUpdate) } - return &connectionInfo{ - ReferenceName: cfg.ReferenceName, + + return SourceDetail{ + Source: baseSource, + Connection: buildConnectionInfo(cfg), + AutoUpdate: autoUpdate, + Workflows: redactWorkflowCredentials(sourceWfs), + } +} + +func buildConnectionInfo(cfg *gittypes.RepoConfig) connectionInfo { + if cfg == nil { + return connectionInfo{} + } + return connectionInfo{ ConfigFilePath: cfg.ConfigFilePath, TLSSkipVerify: cfg.TLSSkipVerify, - Authentication: cfg.Authentication != nil, + Authentication: buildGitAuthInfo(cfg.Authentication), + } +} + +func buildGitAuthInfo(auth *gittypes.GitAuthentication) *gitAuthInfo { + if auth == nil { + return nil + } + return &gitAuthInfo{ + Type: auth.AuthorizationType, + Username: auth.Username, } } diff --git a/api/http/handler/gitops/sources/get_test.go b/api/http/handler/gitops/sources/get_test.go index c82bce7a2..90bd307e2 100644 --- a/api/http/handler/gitops/sources/get_test.go +++ b/api/http/handler/gitops/sources/get_test.go @@ -41,7 +41,6 @@ func TestGetSource_ReturnsDetail(t *testing.T) { } var srcID portainer.SourceID - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { stack := &portainer.Stack{ID: 1, Name: "my-stack"} srcID = createGitWorkflow(t, tx, stack, cfg) @@ -54,11 +53,9 @@ func TestGetSource_ReturnsDetail(t *testing.T) { h.ServeHTTP(rr, buildGetReq(t, 1, strconv.Itoa(int(srcID)))) detail := decodeSourceDetail(t, rr) - assert.Equal(t, strconv.Itoa(int(srcID)), detail.ID) + assert.Equal(t, srcID, detail.ID) assert.Equal(t, "repo", detail.Name) assert.Equal(t, 1, detail.UsedBy) - require.NotNil(t, detail.Connection) - assert.Equal(t, "refs/heads/main", detail.Connection.ReferenceName) assert.Equal(t, "docker-compose.yml", detail.Connection.ConfigFilePath) assert.True(t, detail.Connection.TLSSkipVerify) require.Len(t, detail.Workflows, 1) @@ -75,7 +72,6 @@ func TestGetSource_RedactsCredentials(t *testing.T) { } var srcID portainer.SourceID - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { stack := &portainer.Stack{ID: 1, Name: "secure-stack"} srcID = createGitWorkflow(t, tx, stack, cfg) @@ -102,7 +98,6 @@ func TestGetSource_AutoUpdate(t *testing.T) { cfg := gitCfg("https://github.com/org/polled") var srcID portainer.SourceID - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { stack := &portainer.Stack{ ID: 1, diff --git a/api/http/handler/gitops/sources/handler.go b/api/http/handler/gitops/sources/handler.go index 650ad1e35..dcc3c749f 100644 --- a/api/http/handler/gitops/sources/handler.go +++ b/api/http/handler/gitops/sources/handler.go @@ -48,5 +48,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor adminRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet) adminRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut) adminRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete) + adminRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost) return h } diff --git a/api/http/handler/gitops/sources/list.go b/api/http/handler/gitops/sources/list.go index 330475d68..d4eece453 100644 --- a/api/http/handler/gitops/sources/list.go +++ b/api/http/handler/gitops/sources/list.go @@ -9,7 +9,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - gittypes "github.com/portainer/portainer/api/git/types" ceWorkflows "github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/utils/filters" @@ -79,7 +78,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle SortBindings: []filters.SortBinding[Source]{ {Key: "name", Fn: func(a, b Source) int { return strings.Compare(a.Name, b.Name) }}, {Key: "status", Fn: func(a, b Source) int { return strings.Compare(string(a.Status), string(b.Status)) }}, - {Key: "type", Fn: func(a, b Source) int { return strings.Compare(a.Type, b.Type) }}, + {Key: "type", Fn: func(a, b Source) int { return strings.Compare(string(a.Type), string(b.Type)) }}, }, }) @@ -129,32 +128,7 @@ func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedReque continue } - var status ceWorkflows.Status - var sourceErr string - if src.GitConfig != nil { - phase, _ := ceWorkflows.ComputeGitPhasesForConfig(ctx, h.gitService, src.GitConfig) - status = phase.Status - sourceErr = phase.Error - } else { - status = ceWorkflows.StatusUnknown - } - - url := "" - if src.GitConfig != nil { - url = gittypes.SanitizeURL(src.GitConfig.URL) - } - - result = append(result, Source{ - ID: strconv.Itoa(int(src.ID)), - Name: src.Name, - Type: sourceTypeString(src.Type), - URL: url, - Status: status, - Error: sourceErr, - UsedBy: s.WorkflowCount, - Environments: len(s.EndpointIDs), - LastSync: s.LastSync, - }) + result = append(result, h.buildSource(ctx, &src, s)) } return result, nil diff --git a/api/http/handler/gitops/sources/source_connection.go b/api/http/handler/gitops/sources/source_connection.go new file mode 100644 index 000000000..3bff390a9 --- /dev/null +++ b/api/http/handler/gitops/sources/source_connection.go @@ -0,0 +1,89 @@ +package sources + +import ( + "context" + "errors" + "io" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsSourcesTestGit +// @summary Test a Git source connection +// @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. +// @description **Access policy**: admin +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Source identifier" +// @param body body GitSourceUpdatePayload false "Optional connection overrides; omitted fields fall back to stored values" +// @success 200 {object} ConnectionTestResult "Connection test result" +// @failure 400 "Invalid request payload" +// @failure 403 "Access denied" +// @failure 404 "Source not found" +// @failure 500 "Server error" +// @router /gitops/sources/{id}/test [post] +func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + sourceID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid source identifier route variable", err) + } + + var payload GitSourceUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil && !errors.Is(err, io.EOF) { + return httperror.BadRequest("Invalid request payload", err) + } + + var src *portainer.Source + if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + src, err = tx.Source().Read(portainer.SourceID(sourceID)) + return err + }); h.dataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a source with the specified identifier", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find source", err) + } + + if err := ApplyGitSourceChanges(src, payload); errors.Is(err, ErrNotGitSource) { + return httperror.BadRequest("Source is not a Git source", err) + } else if err != nil { + return httperror.InternalServerError("Unable to apply source changes", err) + } + + if src.GitConfig == nil { + return httperror.InternalServerError("Source has no git configuration", nil) + } + + result := testSourceConnection(r.Context(), h.gitService, src.GitConfig) + + return response.JSON(w, result) +} + +type ConnectionTestResult struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// 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 + if config.Authentication != nil { + username = config.Authentication.Username + password = config.Authentication.Password + } + + _, err := gitService.ListRefs(ctx, config.URL, username, password, false, config.TLSSkipVerify) + if err != nil { + return ConnectionTestResult{Success: false, Error: err.Error()} + } + + return ConnectionTestResult{Success: true} +} diff --git a/api/http/handler/gitops/sources/types.go b/api/http/handler/gitops/sources/types.go index b39311e97..38ea9a17c 100644 --- a/api/http/handler/gitops/sources/types.go +++ b/api/http/handler/gitops/sources/types.go @@ -1,11 +1,7 @@ package sources import ( - "crypto/sha256" - "encoding/hex" "fmt" - "path" - "strings" portainer "github.com/portainer/portainer/api" gittypes "github.com/portainer/portainer/api/git/types" @@ -14,15 +10,16 @@ import ( // Source represents a unique git repository used as a GitOps source across one or more workflows. type Source struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - URL string `json:"url"` - Status workflows.Status `json:"status"` - Error string `json:"error,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"` + Provider gittypes.GitProvider `json:"provider,omitempty"` + UsedBy int `json:"usedBy"` + Environments int `json:"environments"` + LastSync int64 `json:"lastSync"` } type SourceType string @@ -33,67 +30,24 @@ const ( SourceTypeOCI SourceType = "oci" ) -func parseSourceType(s string) (string, error) { +func parseSourceType(s string) (SourceType, error) { switch SourceType(s) { case SourceTypeGit, SourceTypeHelm, SourceTypeOCI: - return s, nil + return SourceType(s), nil default: return "", fmt.Errorf("invalid source type %q: must be git, helm, or oci", s) } } -func sourceTypeString(t portainer.SourceType) string { +func sourceTypeString(t portainer.SourceType) SourceType { switch t { case portainer.SourceTypeGit: - return string(SourceTypeGit) + return SourceTypeGit case portainer.SourceTypeHelm: - return string(SourceTypeHelm) + return SourceTypeHelm case portainer.SourceTypeRegistry: - return string(SourceTypeOCI) + return SourceTypeOCI default: - return string(SourceTypeGit) + return SourceTypeGit } } - -type sourceGroupKey struct { - URL string - Username string - Password string -} - -func gitSourceKey(cfg *gittypes.RepoConfig) sourceGroupKey { - key := sourceGroupKey{URL: cfg.URL} - if cfg.Authentication != nil { - key.Username = cfg.Authentication.Username - key.Password = cfg.Authentication.Password - } - - return key -} - -func sourceID(key sourceGroupKey) string { - h := sha256.Sum256([]byte(key.URL + "\x00" + key.Username + "\x00" + key.Password)) - return hex.EncodeToString(h[:8]) -} - -func repoName(rawURL string) string { - return strings.TrimSuffix(path.Base(rawURL), ".git") -} - -func worstCaseStatus(statuses []workflows.Status) workflows.Status { - priority := map[workflows.Status]int{ - workflows.StatusError: 4, - workflows.StatusSyncing: 3, - workflows.StatusPaused: 2, - workflows.StatusHealthy: 1, - workflows.StatusUnknown: 0, - } - worst := workflows.StatusUnknown - for _, s := range statuses { - if priority[s] > priority[worst] { - worst = s - } - } - - return worst -} diff --git a/api/http/handler/gitops/sources/update_git.go b/api/http/handler/gitops/sources/update_git.go index 1a5fd6530..54cd50c3e 100644 --- a/api/http/handler/gitops/sources/update_git.go +++ b/api/http/handler/gitops/sources/update_git.go @@ -3,10 +3,12 @@ package sources import ( "errors" "net/http" + "strings" 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" @@ -17,6 +19,27 @@ var ( ErrDuplicateSourceURL = errors.New("a source with this URL already exists") ) +// GitSourceUpdatePayload holds the parameters for creating a git-backed source +type GitSourceUpdatePayload struct { + Name *string `json:"name"` + URL *string `json:"url"` + ReferenceName *string `json:"referenceName"` + TLSSkipVerify *bool `json:"tlsSkipVerify"` + Authentication *GitAuthenticationUpdatePayload `json:"authentication"` +} + +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"` +} + +// Validate implements the portainer.Validatable interface +func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error { + return nil +} + // @id GitOpsSourcesUpdateGit // @summary Update a Git source // @description Updates an existing GitOps source backed by a Git repository. @@ -27,7 +50,7 @@ var ( // @accept json // @produce json // @param id path int true "Source identifier" -// @param body body GitSourceCreatePayload true "Git source details" +// @param body body GitSourceUpdatePayload true "Git source details" // @success 200 {object} portainer.Source // @failure 400 "Invalid request payload" // @failure 403 "Access denied" @@ -36,65 +59,38 @@ var ( // @failure 500 "Server error" // @router /gitops/sources/{id} [put] func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - sourceID, err := request.RetrieveNumericRouteVariableValue(r, "id") + id, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return httperror.BadRequest("Invalid source identifier route variable", err) } - var payload GitSourceCreatePayload + var payload GitSourceUpdatePayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { return httperror.BadRequest("Invalid request payload", err) } + sourceID := portainer.SourceID(id) + var src *portainer.Source if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { var err error - if src, err = tx.Source().Read(portainer.SourceID(sourceID)); err != nil { - return err - } - - if src.Type != portainer.SourceTypeGit { - return ErrNotGitSource - } - - normalizedURL, err := gittypes.NormalizeURL(payload.URL) - if err != nil { - return err - } - - existing, err := tx.Source().ReadAll(func(s portainer.Source) bool { - if s.ID == src.ID || s.Type != portainer.SourceTypeGit || s.GitConfig == nil { - return false + if payload.URL != nil { + if isUnique, err := workflows.ValidateUniqueSourceURL(tx, *payload.URL, sourceID); err != nil { + return err + } else if !isUnique { + return ErrDuplicateSourceURL } + } - normalized, err := gittypes.NormalizeURL(s.GitConfig.URL) - - return err == nil && normalized == normalizedURL - }) - if err != nil { + if src, err = tx.Source().Read(sourceID); err != nil { return err } - if len(existing) > 0 { - return ErrDuplicateSourceURL - } - - var existingAuth *gittypes.GitAuthentication - if src.GitConfig != nil { - existingAuth = src.GitConfig.Authentication - } - - updated := BuildGitSource(payload) - src.Name = updated.Name - src.GitConfig = updated.GitConfig - - if payload.Authentication == nil { - src.GitConfig.Authentication = existingAuth - } else if *payload.Authentication == (GitAuthenticationPayload{}) { - src.GitConfig.Authentication = nil + if err := ApplyGitSourceChanges(src, payload); err != nil { + return err } return tx.Source().Update(src.ID, src) @@ -112,3 +108,66 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe return response.JSON(w, src) } + +// ApplyGitSourceChanges applies the payload changes to the source in place +func ApplyGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error { + if src.Type != portainer.SourceTypeGit { + return ErrNotGitSource + } + + if payload.Name != nil && strings.TrimSpace(*payload.Name) != "" { + src.Name = *payload.Name + } + + gitConfig := src.GitConfig + if gitConfig == nil { + gitConfig = &gittypes.RepoConfig{} + } + + if payload.URL != nil { + gitConfig.URL = *payload.URL + } + + if payload.ReferenceName != nil { + gitConfig.ReferenceName = *payload.ReferenceName + } + + if payload.TLSSkipVerify != nil { + gitConfig.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.GitConfig = gitConfig + + return nil +} diff --git a/api/http/handler/gitops/sources/update_git_test.go b/api/http/handler/gitops/sources/update_git_test.go index dd344f8fa..12d72e3d4 100644 --- a/api/http/handler/gitops/sources/update_git_test.go +++ b/api/http/handler/gitops/sources/update_git_test.go @@ -30,9 +30,9 @@ func TestGitSourceUpdate_Success(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{ - URL: "https://github.com/org/new.git", - Name: "new-name", + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/new.git"), + Name: new("new-name"), }) require.NoError(t, err) @@ -49,38 +49,6 @@ func TestGitSourceUpdate_Success(t *testing.T) { require.Equal(t, "https://github.com/org/new.git", src.GitConfig.URL) } -func TestGitSourceUpdate_DerivesNameFromURL(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 := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) - require.NoError(t, err) - srcID = src.ID - - 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/my-project.git", - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) - - require.Equal(t, http.StatusOK, rr.Code) - - var src portainer.Source - err = json.NewDecoder(rr.Body).Decode(&src) - require.NoError(t, err) - require.Equal(t, "my-project", src.Name) -} - func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) @@ -107,9 +75,9 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{ - URL: "https://github.com/org/repo.git", - Name: "renamed", + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/repo.git"), + Name: new("renamed"), }) require.NoError(t, err) @@ -156,9 +124,9 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{ - URL: "https://github.com/org/repo.git", - Authentication: &GitAuthenticationPayload{}, + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/repo.git"), + Authentication: &GitAuthenticationUpdatePayload{}, }) require.NoError(t, err) @@ -203,11 +171,11 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{ - URL: "https://github.com/org/repo.git", - Authentication: &GitAuthenticationPayload{ - Username: "bob", - Password: "new-secret", + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/repo.git"), + Authentication: &GitAuthenticationUpdatePayload{ + Username: new("bob"), + Password: new("new-secret"), }, }) require.NoError(t, err) @@ -239,7 +207,7 @@ func TestGitSourceUpdate_NotFound(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{URL: "https://github.com/org/repo.git"}) + body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")}) require.NoError(t, err) rr := httptest.NewRecorder() @@ -274,8 +242,8 @@ func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{ - URL: "https://github.com/org/existing.git", + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/existing.git"), }) require.NoError(t, err) @@ -301,7 +269,7 @@ func TestGitSourceUpdate_NotGitSource(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{URL: "https://github.com/org/repo.git"}) + body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")}) require.NoError(t, err) rr := httptest.NewRecorder() @@ -342,7 +310,7 @@ func TestGitSourceUpdate_NonNumericID(t *testing.T) { h := newTestHandler(t, store) - body, err := json.Marshal(GitSourceCreatePayload{URL: "https://github.com/org/repo.git"}) + body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")}) require.NoError(t, err) req := buildUpdateReqWithRawID(t, 1, "not-a-number", body) diff --git a/api/http/handler/gitops/sources/utils.go b/api/http/handler/gitops/sources/utils.go index 5bf98daf0..9dce04bb3 100644 --- a/api/http/handler/gitops/sources/utils.go +++ b/api/http/handler/gitops/sources/utils.go @@ -1,51 +1,44 @@ package sources import ( + "context" + portainer "github.com/portainer/portainer/api" gittypes "github.com/portainer/portainer/api/git/types" ce "github.com/portainer/portainer/api/gitops/workflows" - "github.com/portainer/portainer/api/set" ) -func workflowsBySourceID(workflows []ce.Workflow) map[string][]ce.Workflow { - byID := make(map[string][]ce.Workflow) - for _, wf := range workflows { - if wf.GitConfig != nil { - id := sourceID(gitSourceKey(wf.GitConfig)) - byID[id] = append(byID[id], wf) - } +func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats ce.SourceStats) Source { + var status ce.Status + var sourceErr string + if src.GitConfig != nil { + phase, _ := ce.ComputeGitPhasesForConfig(ctx, h.gitService, src.GitConfig) + status = phase.Status + sourceErr = phase.Error + } else { + status = ce.StatusUnknown } - return byID -} -func buildSource(id, url string, wfs []ce.Workflow) Source { - statuses := make([]ce.Status, 0, len(wfs)) - var sourceError string - var lastSync int64 - endpointIDs := make(set.Set[portainer.EndpointID]) - for _, wf := range wfs { - statuses = append(statuses, wf.Status.Source.Status) - if sourceError == "" && wf.Status.Source.Status == ce.StatusError { - sourceError = wf.Status.Source.Error - } - lastSync = max(lastSync, wf.LastSyncDate) - if wf.Target.EndpointID != 0 { - endpointIDs.Add(wf.Target.EndpointID) - } - for _, id := range wf.Target.ResolvedEndpointIDs { - endpointIDs.Add(id) + url := "" + var provider gittypes.GitProvider + if src.GitConfig != nil { + url = gittypes.SanitizeURL(src.GitConfig.URL) + if src.GitConfig.Authentication != nil { + provider = src.GitConfig.Authentication.Provider } } + return Source{ - ID: id, - Name: repoName(url), - Type: "git", - URL: gittypes.SanitizeURL(url), - Status: worstCaseStatus(statuses), - Error: sourceError, - UsedBy: len(wfs), - Environments: len(endpointIDs), - LastSync: lastSync, + ID: src.ID, + Name: src.Name, + Type: sourceTypeString(src.Type), + URL: url, + Status: status, + Error: sourceErr, + Provider: provider, + UsedBy: stats.WorkflowCount, + Environments: len(stats.EndpointIDs), + LastSync: stats.LastSync, } } diff --git a/api/http/handler/gitops/sources/utils_test.go b/api/http/handler/gitops/sources/utils_test.go index 531b23792..13d8b4896 100644 --- a/api/http/handler/gitops/sources/utils_test.go +++ b/api/http/handler/gitops/sources/utils_test.go @@ -11,90 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestWorkflowsBySourceID(t *testing.T) { - t.Parallel() - - t.Run("groups workflows with same URL and credentials", func(t *testing.T) { - t.Parallel() - cfg := gitCfg("https://github.com/org/repo") - wfs := []ce.Workflow{{GitConfig: cfg}, {GitConfig: cfg}} - byID := workflowsBySourceID(wfs) - assert.Len(t, byID, 1) - for _, group := range byID { - assert.Len(t, group, 2) - } - }) - - t.Run("separates workflows with same URL but different credentials", func(t *testing.T) { - t.Parallel() - wfs := []ce.Workflow{ - {GitConfig: &gittypes.RepoConfig{URL: "https://github.com/org/repo", - Authentication: &gittypes.GitAuthentication{Username: "alice", Password: "pass1"}}}, - {GitConfig: &gittypes.RepoConfig{URL: "https://github.com/org/repo", - Authentication: &gittypes.GitAuthentication{Username: "bob", Password: "pass2"}}}, - } - byID := workflowsBySourceID(wfs) - assert.Len(t, byID, 2) - }) - - t.Run("skips workflows with nil GitConfig", func(t *testing.T) { - t.Parallel() - wfs := []ce.Workflow{{}, {GitConfig: gitCfg("https://github.com/org/repo")}} - byID := workflowsBySourceID(wfs) - assert.Len(t, byID, 1) - }) -} - -func TestBuildSource(t *testing.T) { - t.Parallel() - - t.Run("status is the worst across all workflows", func(t *testing.T) { - t.Parallel() - wfs := []ce.Workflow{ - {Status: ce.WorkflowStatusObject{Source: ce.WorkflowPhaseStatus{Status: ce.StatusHealthy}}}, - {Status: ce.WorkflowStatusObject{Source: ce.WorkflowPhaseStatus{Status: ce.StatusError, Error: "boom"}}}, - } - s := buildSource("id", "https://github.com/org/repo.git", wfs) - assert.Equal(t, ce.StatusError, s.Status) - assert.Equal(t, "boom", s.Error) - }) - - t.Run("usedBy equals the number of workflows", func(t *testing.T) { - t.Parallel() - wfs := make([]ce.Workflow, 3) - s := buildSource("id", "https://github.com/org/repo", wfs) - assert.Equal(t, 3, s.UsedBy) - }) - - t.Run("environments deduplicates endpoint IDs", func(t *testing.T) { - t.Parallel() - wfs := []ce.Workflow{ - {Target: ce.Target{EndpointID: portainer.EndpointID(1)}}, - {Target: ce.Target{EndpointID: portainer.EndpointID(1)}}, // duplicate - {Target: ce.Target{EndpointID: portainer.EndpointID(2)}}, - } - s := buildSource("id", "https://github.com/org/repo", wfs) - assert.Equal(t, 2, s.Environments) - }) - - t.Run("name is extracted from URL", func(t *testing.T) { - t.Parallel() - s := buildSource("id", "https://github.com/org/my-app.git", []ce.Workflow{{}}) - assert.Equal(t, "my-app", s.Name) - }) - - t.Run("lastSync is the maximum across all workflows", func(t *testing.T) { - t.Parallel() - wfs := []ce.Workflow{ - {LastSyncDate: 100}, - {LastSyncDate: 300}, - {LastSyncDate: 200}, - } - s := buildSource("id", "https://github.com/org/repo", wfs) - assert.Equal(t, int64(300), s.LastSync) - }) -} - func TestRedactWorkflowCredentials(t *testing.T) { t.Parallel() @@ -150,21 +66,19 @@ func TestBuildAutoUpdateInfo(t *testing.T) { func TestBuildConnectionInfo(t *testing.T) { t.Parallel() - assert.Nil(t, buildConnectionInfo(nil)) + assert.Equal(t, connectionInfo{}, buildConnectionInfo(nil)) cfg := &gittypes.RepoConfig{ - ReferenceName: "refs/heads/main", ConfigFilePath: "docker-compose.yml", TLSSkipVerify: true, Authentication: &gittypes.GitAuthentication{Username: "user"}, } got := buildConnectionInfo(cfg) - require.NotNil(t, got) - assert.Equal(t, "refs/heads/main", got.ReferenceName) assert.Equal(t, "docker-compose.yml", got.ConfigFilePath) assert.True(t, got.TLSSkipVerify) - assert.True(t, got.Authentication) + require.NotNil(t, got.Authentication) + assert.Equal(t, "user", got.Authentication.Username) got = buildConnectionInfo(&gittypes.RepoConfig{}) - assert.False(t, got.Authentication) + assert.Nil(t, got.Authentication) } diff --git a/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx b/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx index f3f18bbd9..555c15669 100644 --- a/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx +++ b/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx @@ -19,9 +19,6 @@ interface Props { isLoading?: boolean; errorMessage?: string; - - containerClassName?: string; - widgetClassName?: string; } export function ResourceDetailHeader({ @@ -36,11 +33,9 @@ export function ResourceDetailHeader({ actionBar, isLoading, errorMessage, - containerClassName = 'flex items-center gap-4 p-6', - widgetClassName = 'widget-body', }: Props) { return ( - + {errorMessage && ( @@ -48,7 +43,7 @@ export function ResourceDetailHeader({ )} {!errorMessage && ( -
+
( -
-

- Scroll down to see the sticky action bar at the bottom of the - viewport. -

- -
+ +
+

+ Scroll down to see the sticky action bar at the bottom of the + viewport. +

+ +
+
), ], } as Meta; diff --git a/app/react/components/StickyFooter/StickyFooter.tsx b/app/react/components/StickyFooter/StickyFooter.tsx index 0953b3613..6887e9cca 100644 --- a/app/react/components/StickyFooter/StickyFooter.tsx +++ b/app/react/components/StickyFooter/StickyFooter.tsx @@ -7,6 +7,24 @@ interface Props { className?: string; } +/** + * Fixed action bar pinned to the bottom of the viewport. + * + * Wrap the page content in `StickyFooter.Container` so the footer never + * obscures the last element: + * + * ```tsx + * + *
+ * ... + * + * + * + * + *
+ *
+ * ``` + */ export function StickyFooter({ className, children, @@ -26,3 +44,14 @@ export function StickyFooter({
); } + +/** + * Wraps page content to prevent `StickyFooter` from overlapping the bottom + * of the page. Always use this as the outermost wrapper when rendering a + * `StickyFooter` inside a page or form. + */ +function Container({ children }: PropsWithChildren) { + return
{children}
; +} + +StickyFooter.Container = Container; diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index 9bd986644..1cd92e14a 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -1,17 +1,11 @@ -import { - ReactNode, - ComponentProps, - PropsWithChildren, - useMemo, - useEffect, -} from 'react'; -import { useTransitionHook } from '@uirouter/react'; +import { ReactNode, ComponentProps, PropsWithChildren, useMemo } from 'react'; import { JSONSchema7 } from 'json-schema'; import { CodeEditor } from '@@/CodeEditor'; import { FormSectionTitle } from './form-components/FormSectionTitle'; import { FormError } from './form-components/FormError'; +import { usePreventFormExit } from './form-components/usePreventFormExit'; import { confirmWebEditorDiscard } from './modals/confirm'; import { ShortcutsTooltip } from './CodeEditor/ShortcutsTooltip'; @@ -84,36 +78,7 @@ export function usePreventExit( [initialValue, value] ); - const preventExit = check && isChanged; - - // when navigating away from the page with unsaved changes, show a portainer prompt to confirm - useTransitionHook('onBefore', {}, async () => { - if (!preventExit) { - return true; - } - const confirmed = await confirmWebEditorDiscard(); - return confirmed; - }); - - // when reloading or exiting the page with unsaved changes, show a browser prompt to confirm - useEffect(() => { - function handler(event: BeforeUnloadEvent) { - if (!preventExit) { - return undefined; - } - - event.preventDefault(); - // eslint-disable-next-line no-param-reassign - event.returnValue = ''; - return ''; - } - - // if the form is changed, then set the onbeforeunload - window.addEventListener('beforeunload', handler); - return () => { - window.removeEventListener('beforeunload', handler); - }; - }, [preventExit]); + usePreventFormExit(() => isChanged, check, confirmWebEditorDiscard); } function cleanText(value: string) { diff --git a/app/react/components/form-components/usePreventFormExit.ts b/app/react/components/form-components/usePreventFormExit.ts new file mode 100644 index 000000000..8d4e8f6bf --- /dev/null +++ b/app/react/components/form-components/usePreventFormExit.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useTransitionHook } from '@uirouter/react'; + +import { confirmGenericDiscard } from '@@/modals/confirm'; + +export function usePreventFormExit( + isChanged: () => boolean, + check = true, + confirmFn: () => Promise = confirmGenericDiscard +) { + // when navigating away from the page with unsaved changes, show a portainer prompt to confirm + useTransitionHook('onBefore', {}, async () => { + // need to calculate inside the hook because it doesn't have dep array + if (!(check && isChanged())) { + return true; + } + return confirmFn(); + }); + + const preventExit = check && isChanged(); + // when reloading or exiting the page with unsaved changes, show a browser prompt to confirm + useEffect(() => { + function handler(event: BeforeUnloadEvent) { + if (!preventExit) { + return undefined; + } + + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.returnValue = ''; + return ''; + } + + window.addEventListener('beforeunload', handler); + return () => { + window.removeEventListener('beforeunload', handler); + }; + }, [preventExit]); +} diff --git a/app/react/portainer/generated-api/portainer/sdk.gen.ts b/app/react/portainer/generated-api/portainer/sdk.gen.ts index 208543347..8cbea220d 100644 --- a/app/react/portainer/generated-api/portainer/sdk.gen.ts +++ b/app/react/portainer/generated-api/portainer/sdk.gen.ts @@ -448,6 +448,9 @@ import type { GitOpsSourcesSummaryData, GitOpsSourcesSummaryErrors, GitOpsSourcesSummaryResponses, + GitOpsSourcesTestGitData, + GitOpsSourcesTestGitErrors, + GitOpsSourcesTestGitResponses, GitOpsSourcesUpdateGitData, GitOpsSourcesUpdateGitErrors, GitOpsSourcesUpdateGitResponses, @@ -1094,6 +1097,9 @@ import { zGitOpsSourcesListQuery, zGitOpsSourcesListResponse, zGitOpsSourcesSummaryResponse, + zGitOpsSourcesTestGitBody, + zGitOpsSourcesTestGitPath, + zGitOpsSourcesTestGitResponse, zGitOpsSourcesUpdateGitBody, zGitOpsSourcesUpdateGitPath, zGitOpsSourcesUpdateGitResponse, @@ -3962,6 +3968,43 @@ export const gitOpsSourcesUpdateGit = ( }, }); +/** + * Test a Git source connection + * + * Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. + * **Access policy**: admin + */ +export const gitOpsSourcesTestGit = ( + options: Options +) => + (options.client ?? client).post< + GitOpsSourcesTestGitResponses, + GitOpsSourcesTestGitErrors, + ThrowOnError + >({ + requestValidator: async (data) => + await z + .object({ + body: zGitOpsSourcesTestGitBody.optional(), + path: zGitOpsSourcesTestGitPath, + query: z.never().optional(), + }) + .parseAsync(data), + responseType: 'json', + responseValidator: async (data) => + await zGitOpsSourcesTestGitResponse.parseAsync(data), + security: [ + { name: 'X-API-KEY', type: 'apiKey' }, + { name: 'Authorization', type: 'apiKey' }, + ], + url: '/gitops/sources/{id}/test', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + /** * Create a Git source * diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index 8ca040fd9..9cb584750 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -45,13 +45,13 @@ export type WorkflowsWorkflow = { autoUpdate?: PortainerAutoUpdateSettings; creationDate?: number; gitConfig?: GittypesRepoConfig; - id?: number; + id: number; lastSyncDate?: number; - name?: string; - platform?: WorkflowsDeploymentPlatform; - status?: WorkflowsWorkflowStatusObject; - target?: WorkflowsTarget; - type?: WorkflowsType; + name: string; + platform: WorkflowsDeploymentPlatform; + status: WorkflowsWorkflowStatusObject; + target: WorkflowsTarget; + type: WorkflowsType; }; export const WorkflowsType = { @@ -3692,10 +3692,14 @@ export type SslSslUpdatePayload = { Key?: string; }; +export type SourcesGitAuthInfo = { + type?: number; + username?: string; +}; + export type SourcesConnectionInfo = { - authentication?: boolean; + authentication?: SourcesGitAuthInfo; configFilePath?: string; - referenceName?: string; tlsSkipVerify?: boolean; }; @@ -3704,17 +3708,36 @@ export type SourcesAutoUpdateInfo = { mechanism?: string; }; +export const SourcesSourceType = { + /** + * SourceTypeGit + */ + SOURCE_TYPE_GIT: 'git', + /** + * SourceTypeHelm + */ + SOURCE_TYPE_HELM: 'helm', + /** + * SourceTypeOCI + */ + SOURCE_TYPE_OCI: 'oci', +} as const; + +export type SourcesSourceType = + (typeof SourcesSourceType)[keyof typeof SourcesSourceType]; + export type SourcesSourceDetail = { autoUpdate?: SourcesAutoUpdateInfo; - connection?: SourcesConnectionInfo; + connection: SourcesConnectionInfo; environments?: number; error?: string; - id?: string; + id: number; lastSync?: number; - name?: string; - status?: WorkflowsStatus; - type?: string; - url?: string; + name: string; + provider?: number; + status: WorkflowsStatus; + type: SourcesSourceType; + url: string; usedBy?: number; workflows?: Array; }; @@ -3722,21 +3745,36 @@ export type SourcesSourceDetail = { export type SourcesSource = { environments?: number; error?: string; - id?: string; + id: number; lastSync?: number; - name?: string; - status?: WorkflowsStatus; - type?: string; - url?: string; + name: string; + provider?: number; + status: WorkflowsStatus; + type: SourcesSourceType; + url: string; usedBy?: number; }; +export type SourcesGitSourceUpdatePayload = { + authentication?: SourcesGitAuthenticationUpdatePayload; + name?: string; + referenceName?: string; + tlsSkipVerify?: boolean; + url?: string; +}; + +export type SourcesGitAuthenticationUpdatePayload = { + authorizationType?: 0 | 1; + password?: string; + provider?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + username?: string; +}; + export type SourcesGitSourceCreatePayload = { authentication?: SourcesGitAuthenticationPayload; - clearAuthentication?: boolean; name?: string; tlsSkipVerify?: boolean; - url?: string; + url: string; }; export type SourcesGitAuthenticationPayload = { @@ -3746,6 +3784,11 @@ export type SourcesGitAuthenticationPayload = { username?: string; }; +export type SourcesConnectionTestResult = { + error?: string; + success?: boolean; +}; + export type SettingsSettingsUpdatePayload = { /** * Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth @@ -7505,8 +7548,6 @@ export type KubernetesK8sIngressInfo2 = KubernetesK8sIngressInfo; export type KubernetesK8sNamespaceDetails2 = KubernetesK8sNamespaceDetails; -export type SourcesGitSourceCreatePayload2 = SourcesGitSourceCreatePayload; - export type EndpointsEndpointDeleteBatchPayload2 = EndpointsEndpointDeleteBatchPayload; @@ -10704,7 +10745,7 @@ export type GitOpsSourcesUpdateGitData = { /** * Git source details */ - body: SourcesGitSourceCreatePayload2; + body: SourcesGitSourceUpdatePayload; path: { /** * Source identifier @@ -10748,11 +10789,55 @@ export type GitOpsSourcesUpdateGitResponses = { export type GitOpsSourcesUpdateGitResponse = GitOpsSourcesUpdateGitResponses[keyof GitOpsSourcesUpdateGitResponses]; +export type GitOpsSourcesTestGitData = { + /** + * Optional connection overrides; omitted fields fall back to stored values + */ + body?: SourcesGitSourceUpdatePayload; + path: { + /** + * Source identifier + */ + id: number; + }; + query?: never; + url: '/gitops/sources/{id}/test'; +}; + +export type GitOpsSourcesTestGitErrors = { + /** + * Invalid request payload + */ + 400: unknown; + /** + * Access denied + */ + 403: unknown; + /** + * Source not found + */ + 404: unknown; + /** + * Server error + */ + 500: unknown; +}; + +export type GitOpsSourcesTestGitResponses = { + /** + * Connection test result + */ + 200: SourcesConnectionTestResult; +}; + +export type GitOpsSourcesTestGitResponse = + GitOpsSourcesTestGitResponses[keyof GitOpsSourcesTestGitResponses]; + export type GitOpsSourcesCreateGitData = { /** * Git source details */ - body: SourcesGitSourceCreatePayload2; + body: SourcesGitSourceCreatePayload; path?: never; query?: never; url: '/gitops/sources/git'; diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index 331502fbc..5fa6dc976 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -24,6 +24,7 @@ import { PortainerTemplateType, PortainerUserRole, PortainerWebhookType, + SourcesSourceType, UsersAccessLocation, V1AppArmorProfileType, V1ContainerRestartPolicy, @@ -109,13 +110,13 @@ export const zWorkflowsWorkflow = z.object({ autoUpdate: zPortainerAutoUpdateSettings.optional(), creationDate: z.int().optional(), gitConfig: zGittypesRepoConfig.optional(), - id: z.int().optional(), + id: z.int(), lastSyncDate: z.int().optional(), - name: z.string().optional(), - platform: zWorkflowsDeploymentPlatform.optional(), - status: zWorkflowsWorkflowStatusObject.optional(), - target: zWorkflowsTarget.optional(), - type: zWorkflowsType.optional(), + name: z.string(), + platform: zWorkflowsDeploymentPlatform, + status: zWorkflowsWorkflowStatusObject, + target: zWorkflowsTarget, + type: zWorkflowsType, }); export const zWorkflowsStatusSummary = z.object({ @@ -1002,10 +1003,14 @@ export const zSslSslUpdatePayload = z.object({ Key: z.string().optional(), }); +export const zSourcesGitAuthInfo = z.object({ + type: z.int().optional(), + username: z.string().optional(), +}); + export const zSourcesConnectionInfo = z.object({ - authentication: z.boolean().optional(), + authentication: zSourcesGitAuthInfo.optional(), configFilePath: z.string().optional(), - referenceName: z.string().optional(), tlsSkipVerify: z.boolean().optional(), }); @@ -1014,17 +1019,20 @@ export const zSourcesAutoUpdateInfo = z.object({ mechanism: z.string().optional(), }); +export const zSourcesSourceType = z.enum(SourcesSourceType); + export const zSourcesSourceDetail = z.object({ autoUpdate: zSourcesAutoUpdateInfo.optional(), - connection: zSourcesConnectionInfo.optional(), + connection: zSourcesConnectionInfo, environments: z.int().optional(), error: z.string().optional(), - id: z.string().optional(), + id: z.int(), lastSync: z.int().optional(), - name: z.string().optional(), - status: zWorkflowsStatus.optional(), - type: z.string().optional(), - url: z.string().optional(), + name: z.string(), + provider: z.int().optional(), + status: zWorkflowsStatus, + type: zSourcesSourceType, + url: z.string(), usedBy: z.int().optional(), workflows: z.array(zWorkflowsWorkflow).optional(), }); @@ -1032,15 +1040,41 @@ export const zSourcesSourceDetail = z.object({ export const zSourcesSource = z.object({ environments: z.int().optional(), error: z.string().optional(), - id: z.string().optional(), + id: z.int(), lastSync: z.int().optional(), - name: z.string().optional(), - status: zWorkflowsStatus.optional(), - type: z.string().optional(), - url: z.string().optional(), + name: z.string(), + provider: z.int().optional(), + status: zWorkflowsStatus, + type: zSourcesSourceType, + url: z.string(), usedBy: z.int().optional(), }); +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(), +}); + +export const zSourcesGitSourceUpdatePayload = z.object({ + authentication: zSourcesGitAuthenticationUpdatePayload.optional(), + name: z.string().optional(), + referenceName: z.string().optional(), + tlsSkipVerify: z.boolean().optional(), + url: z.string().optional(), +}); + export const zSourcesGitAuthenticationPayload = z.object({ authorizationType: z.int().optional(), password: z.string().optional(), @@ -1050,10 +1084,14 @@ export const zSourcesGitAuthenticationPayload = z.object({ export const zSourcesGitSourceCreatePayload = z.object({ authentication: zSourcesGitAuthenticationPayload.optional(), - clearAuthentication: z.boolean().optional(), name: z.string().optional(), tlsSkipVerify: z.boolean().optional(), - url: z.string().optional(), + url: z.string(), +}); + +export const zSourcesConnectionTestResult = z.object({ + error: z.string().optional(), + success: z.boolean().optional(), }); export const zOauth2AuthStyle = z.enum(Oauth2AuthStyle); @@ -2953,8 +2991,6 @@ export const zKubernetesK8sIngressInfo2 = zKubernetesK8sIngressInfo; export const zKubernetesK8sNamespaceDetails2 = zKubernetesK8sNamespaceDetails; -export const zSourcesGitSourceCreatePayload2 = zSourcesGitSourceCreatePayload; - export const zEndpointsEndpointDeleteBatchPayload2 = zEndpointsEndpointDeleteBatchPayload; @@ -3864,7 +3900,7 @@ export const zGitOpsSourceGetResponse = zSourcesSourceDetail; /** * Git source details */ -export const zGitOpsSourcesUpdateGitBody = zSourcesGitSourceCreatePayload2; +export const zGitOpsSourcesUpdateGitBody = zSourcesGitSourceUpdatePayload; export const zGitOpsSourcesUpdateGitPath = z.object({ id: z.int(), @@ -3875,10 +3911,24 @@ export const zGitOpsSourcesUpdateGitPath = z.object({ */ export const zGitOpsSourcesUpdateGitResponse = zPortainerSource; +/** + * Optional connection overrides; omitted fields fall back to stored values + */ +export const zGitOpsSourcesTestGitBody = zSourcesGitSourceUpdatePayload; + +export const zGitOpsSourcesTestGitPath = z.object({ + id: z.int(), +}); + +/** + * Connection test result + */ +export const zGitOpsSourcesTestGitResponse = zSourcesConnectionTestResult; + /** * Git source details */ -export const zGitOpsSourcesCreateGitBody = zSourcesGitSourceCreatePayload2; +export const zGitOpsSourcesCreateGitBody = zSourcesGitSourceCreatePayload; /** * Created diff --git a/app/react/portainer/gitops/sources/ItemView/ItemView.tsx b/app/react/portainer/gitops/sources/ItemView/ItemView.tsx index eba1af96c..3de2b90ae 100644 --- a/app/react/portainer/gitops/sources/ItemView/ItemView.tsx +++ b/app/react/portainer/gitops/sources/ItemView/ItemView.tsx @@ -1,11 +1,14 @@ -import { useMemo } from 'react'; -import { GitCommit, Settings } from 'lucide-react'; -import { useCurrentStateAndParams } from '@uirouter/react'; +import { useMemo, useState } from 'react'; +import { GitCommit, PenBoxIcon, Settings } from 'lucide-react'; + +import { useIdParam } from '@/react/hooks/useIdParam'; import { PageHeader } from '@@/PageHeader'; import { Tab, WidgetTabs, useCurrentTabIndex } from '@@/Widget/WidgetTabs'; import { ResourceDetailHeaderSkeleton } from '@@/ResourceDetailHeader/ResourceDetailHeaderSkeleton'; import { Alert } from '@@/Alert'; +import { Badge } from '@@/Badge'; +import { Button } from '@@/buttons'; import { SourceDetail, useSource } from '../queries/useSource'; @@ -20,8 +23,7 @@ const breadcrumbs = [ ]; export function ItemView() { - const { params } = useCurrentStateAndParams(); - const sourceId: string = params.sourceId; + const sourceId = useIdParam('sourceId'); const sourceQuery = useSource(sourceId); const source = sourceQuery.data; @@ -57,12 +59,20 @@ export function ItemView() { } function PageContent({ source }: { source: SourceDetail }) { + const [isEditingSettings, setIsEditingSettings] = useState(false); + const tabs: Array = useMemo( () => [ { name: 'Settings', icon: Settings, - widget: , + widget: ( + + ), selectedTabParam: 'settings', }, { @@ -77,7 +87,7 @@ function PageContent({ source }: { source: SourceDetail }) { selectedTabParam: 'workflows', }, ], - [source] + [isEditingSettings, source] ); const currentTabIndex = useCurrentTabIndex(tabs); @@ -90,13 +100,30 @@ function PageContent({ source }: { source: SourceDetail }) { ]} reload /> -
+
- +
+ +
+ {currentTabIndex === 0 && + (isEditingSettings ? ( + Editing + ) : ( + + ))} +
+
{tabs[currentTabIndex].widget}
diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/AuthWidget.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/AuthWidget.tsx index a3e544652..6a41e5346 100644 --- a/app/react/portainer/gitops/sources/ItemView/SettingsTab/AuthWidget.tsx +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/AuthWidget.tsx @@ -3,10 +3,12 @@ import { LockIcon } from 'lucide-react'; import { Card } from '@@/primitives/Card'; import { Badge } from '@@/Badge'; +import { SourceDetail } from '../../queries/useSource'; + import { DetailField } from './DetailField'; interface Props { - auth?: boolean; + auth?: SourceDetail['connection']['authentication']; } export function AuthWidget({ auth }: Props) { @@ -26,12 +28,12 @@ export function AuthWidget({ auth }: Props) { Basic - + - ******** + {auth.username} diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/ConnectionDetailsWidget.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/ConnectionDetailsWidget.tsx index f1acf72bd..97da3dc1e 100644 --- a/app/react/portainer/gitops/sources/ItemView/SettingsTab/ConnectionDetailsWidget.tsx +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/ConnectionDetailsWidget.tsx @@ -14,24 +14,13 @@ interface Props { export function ConnectionDetailsWidget({ source }: Props) { const typeConfig = source.type ? SOURCE_TYPES[source.type] : undefined; - const branch = - source.connection?.referenceName?.replace(/^refs\/heads\//, '') || '-'; return ( - // Edit - // - // } + subtitle="Source name, URL, and connection settings" />
@@ -55,9 +44,6 @@ export function ConnectionDetailsWidget({ source }: Props) { {source.url ?? '-'} - - {branch} - ); diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditAuthWidget.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditAuthWidget.tsx new file mode 100644 index 000000000..e01526107 --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditAuthWidget.tsx @@ -0,0 +1,64 @@ +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 { SettingsFormValues } from './types'; + +export function EditAuthWidget() { + const { values, errors, setFieldValue } = + useFormikContext(); + + return ( + + + +
+ setFieldValue('authEnabled', checked)} + data-cy="source-auth-enabled" + /> +
+ + {values.authEnabled && ( + <> + + setFieldValue('username', e.target.value)} + data-cy="component-gitUsernameInput" + /> + + + + setFieldValue('password', e.target.value)} + data-cy="component-gitPasswordInput" + /> + + + )} +
+
+ ); +} diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditConnectionDetailsWidget.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditConnectionDetailsWidget.tsx new file mode 100644 index 000000000..d7d6dbc6c --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/EditConnectionDetailsWidget.tsx @@ -0,0 +1,56 @@ +import { LinkIcon } from 'lucide-react'; +import { useFormikContext } from 'formik'; + +import { Card } from '@@/primitives/Card'; +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; +import { SwitchField } from '@@/form-components/SwitchField'; + +import { SettingsFormValues } from './types'; + +export function EditConnectionDetailsWidget() { + const { values, errors, setFieldValue } = + useFormikContext(); + + return ( + + + + + setFieldValue('name', e.target.value)} + data-cy="source-name-input" + /> + + + setFieldValue('url', e.target.value)} + data-cy="source-url-input" + /> + + setFieldValue('tlsSkipVerify', checked)} + data-cy="source-tls-skip-verify" + /> + + + ); +} diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/SettingsForm.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/SettingsForm.tsx new file mode 100644 index 000000000..117dcef0b --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/SettingsForm.tsx @@ -0,0 +1,93 @@ +import { useRef } from 'react'; +import { Form, Formik, FormikProps } from 'formik'; + +import { notifySuccess } from '@/portainer/services/notifications'; + +import { Button } from '@@/buttons'; +import { LoadingButton } from '@@/buttons/LoadingButton'; +import { StickyFooter } from '@@/StickyFooter/StickyFooter'; +import { usePreventFormExit } from '@@/form-components/usePreventFormExit'; + +import { SourceDetail } from '../../../queries/useSource'; +import { useUpdateSourceMutation } from '../../../queries/useUpdateSourceMutation'; + +import { EditConnectionDetailsWidget } from './EditConnectionDetailsWidget'; +import { EditAuthWidget } from './EditAuthWidget'; +import { TestConnectionWidget } from './TestConnectionWidget'; +import { SettingsFormValues, validationSchema } from './types'; +import { buildUpdatePayload } from './payload'; + +interface Props { + source: SourceDetail; + onCancel: () => void; +} + +export function SettingsForm({ source, onCancel }: Props) { + const updateSource = useUpdateSourceMutation(source.id); + const formikRef = useRef>(null); + + usePreventFormExit(() => !!formikRef.current?.dirty); + + const initialValues: SettingsFormValues = { + name: source.name ?? '', + url: source.url ?? '', + tlsSkipVerify: source.connection.tlsSkipVerify ?? false, + authEnabled: !!source.connection.authentication, + username: source.connection.authentication?.username ?? '', + password: '', + }; + + return ( + { + const payload = buildUpdatePayload(values, initialValues); + + updateSource.mutate(payload, { + onSuccess: () => { + notifySuccess('Source updated', ''); + onCancel(); + }, + onSettled: () => setSubmitting(false), + }); + }} + > + {({ handleSubmit, isValid, dirty, isSubmitting, resetForm }) => ( + +
+ + + + + + + Save Changes + + + +
+ )} +
+ ); +} diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/TestConnectionWidget.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/TestConnectionWidget.tsx new file mode 100644 index 000000000..8a5a3a72b --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/TestConnectionWidget.tsx @@ -0,0 +1,41 @@ +import { ArrowLeftRight } from 'lucide-react'; +import { useFormikContext } from 'formik'; + +import { Card } from '@@/primitives/Card'; + +import { Source } from '../../../types'; +import { TestConnectionButton } from '../../TestConnectionButton'; + +import { SettingsFormValues } from './types'; +import { buildUpdatePayload } from './payload'; + +interface Props { + sourceId: Source['id']; +} + +export function TestConnectionWidget({ sourceId }: Props) { + const { values, initialValues } = useFormikContext(); + + const payload = buildUpdatePayload(values, initialValues); + + return ( + + + +

+ Test the connection to verify your settings are correct before saving. +

+ +
+
+ ); +} diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/payload.ts b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/payload.ts new file mode 100644 index 000000000..007e0ecc9 --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/payload.ts @@ -0,0 +1,41 @@ +import { UpdateSourcePayload } from '../../../queries/useUpdateSourceMutation'; + +import { SettingsFormValues } from './types'; + +export function buildUpdatePayload( + values: SettingsFormValues, + initialValues: SettingsFormValues +): UpdateSourcePayload { + return { + name: changed(values.name, initialValues.name), + url: changed(values.url, initialValues.url), + tlsSkipVerify: changed(values.tlsSkipVerify, initialValues.tlsSkipVerify), + authentication: buildAuthenticationPayload(values, initialValues), + }; +} + +function buildAuthenticationPayload( + values: SettingsFormValues, + initialValues: SettingsFormValues +) { + if (!values.authEnabled && initialValues.authEnabled) { + return {}; + } + + if (!values.authEnabled) { + return undefined; + } + + const payload = { + username: changed(values.username, initialValues.username), + password: changed(values.password, initialValues.password), + }; + + return Object.values(payload).some((v) => v !== undefined) + ? payload + : undefined; +} + +function changed(value: T, initialValue: T) { + return value === initialValue ? undefined : value; +} diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/types.ts b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/types.ts new file mode 100644 index 000000000..c2c890bdf --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/EditForm/types.ts @@ -0,0 +1,23 @@ +import { boolean as yupBoolean, object, string } from 'yup'; + +export interface SettingsFormValues { + name: string; + url: string; + tlsSkipVerify: boolean; + authEnabled: boolean; + username: string; + password: string; +} + +export const validationSchema = object({ + name: string().required('Name is required'), + url: string().required('Repository URL is required'), + tlsSkipVerify: yupBoolean().defined(), + authEnabled: yupBoolean().defined(), + username: string().when('authEnabled', { + is: true, + then: (schema) => schema.required('Username is required'), + otherwise: (schema) => schema.optional(), + }), + password: string().optional(), +}); diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/SettingsTab.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/SettingsTab.tsx index 9540a66be..4994bef0c 100644 --- a/app/react/portainer/gitops/sources/ItemView/SettingsTab/SettingsTab.tsx +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/SettingsTab.tsx @@ -4,16 +4,25 @@ import { ConnectionDetailsWidget } from './ConnectionDetailsWidget'; import { AuthWidget } from './AuthWidget'; import { AutoUpdateWidget } from './AutoUpdateWidget'; import { SyncStatusWidget } from './SyncStatusWidget'; +import { SettingsForm } from './EditForm/SettingsForm'; interface Props { source: SourceDetail; + isEditing: boolean; + onEditingChange: (isEditing: boolean) => void; } -export function SettingsTab({ source }: Props) { +export function SettingsTab({ source, isEditing, onEditingChange }: Props) { + if (isEditing) { + return ( + onEditingChange(false)} /> + ); + } + return ( <> - + diff --git a/app/react/portainer/gitops/sources/ItemView/SettingsTab/SyncStatusWidget.tsx b/app/react/portainer/gitops/sources/ItemView/SettingsTab/SyncStatusWidget.tsx index 62f102d20..4261f2825 100644 --- a/app/react/portainer/gitops/sources/ItemView/SettingsTab/SyncStatusWidget.tsx +++ b/app/react/portainer/gitops/sources/ItemView/SettingsTab/SyncStatusWidget.tsx @@ -1,11 +1,11 @@ -import { AlertTriangle, ShieldAlertIcon } from 'lucide-react'; +import { ShieldAlertIcon } from 'lucide-react'; import { capitalize } from 'lodash'; import moment from 'moment'; -import { Icon } from '@@/Icon'; import { Card } from '@@/primitives/Card'; import { StatusDot } from '@@/primitives/StatusDot'; import { Badge } from '@@/Badge'; +import { Alert } from '@@/Alert'; import { SourceDetail } from '../../queries/useSource'; @@ -46,12 +46,7 @@ export function SyncStatusWidget({ source }: Props) { {lastSyncLabel}
- {source.error && ( -
- - {source.error} -
- )} + {source.error && {source.error}}
); diff --git a/app/react/portainer/gitops/sources/ItemView/SourceResourceHeader.tsx b/app/react/portainer/gitops/sources/ItemView/SourceResourceHeader.tsx index be50ac708..47f0c800d 100644 --- a/app/react/portainer/gitops/sources/ItemView/SourceResourceHeader.tsx +++ b/app/react/portainer/gitops/sources/ItemView/SourceResourceHeader.tsx @@ -1,14 +1,22 @@ import { GitBranch, GitCommitIcon, ClockIcon } from 'lucide-react'; import moment from 'moment'; +import { useRouter } from '@uirouter/react'; + +import { notifySuccess } from '@/portainer/services/notifications'; import { Icon } from '@@/Icon'; import { ResourceDetailHeader } from '@@/ResourceDetailHeader/ResourceDetailHeader'; import { HeaderStats } from '@@/ResourceDetailHeader/HeaderStats'; import { ResourceStatBlock } from '@@/ResourceDetailHeader/ResourceStatBlock'; +import { DeleteButton } from '@@/buttons/DeleteButton'; +import { ActionBarShell } from '@@/ResourceDetailHeader/ActionBarShell'; import { StatusBadge } from '../../WorkflowsView/WorkflowBadges'; import { SOURCE_TYPES } from '../types'; import { SourceDetail } from '../queries/useSource'; +import { useDeleteSourceMutation } from '../queries/useDeleteSourceMutation'; + +import { TestConnectionButton } from './TestConnectionButton'; interface Props { source: SourceDetail; @@ -54,6 +62,49 @@ export function SourceResourceHeader({ source }: Props) { } + actionBar={ + + + +
+ 0} + /> +
+
+ } + /> + ); +} + +function SourceDeleteButton({ + sourceId, + hasWorkflows, +}: { + sourceId: SourceDetail['id']; + hasWorkflows: boolean; +}) { + const mutation = useDeleteSourceMutation(); + const router = useRouter(); + + return ( + + mutation.mutate(sourceId, { + onSuccess() { + notifySuccess('Success', 'Source deleted'); + router.stateService.go('^'); + }, + }) + } + isLoading={mutation.isLoading} /> ); } diff --git a/app/react/portainer/gitops/sources/ItemView/TestConnectionButton.tsx b/app/react/portainer/gitops/sources/ItemView/TestConnectionButton.tsx new file mode 100644 index 000000000..6fc404324 --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/TestConnectionButton.tsx @@ -0,0 +1,92 @@ +import { ArrowLeftRight, CheckCircle, XCircle } from 'lucide-react'; + +import { LoadingButton } from '@@/buttons'; +import { Icon } from '@@/Icon'; +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; + +import { Source } from '../types'; +import { + ConnectionTestResult, + useTestSourceConnectionMutation, +} from '../queries/useTestSourceConnectionMutation'; +import { UpdateSourcePayload } from '../queries/useUpdateSourceMutation'; + +interface Props { + sourceId: Source['id']; + payload?: UpdateSourcePayload; + 'data-cy': string; + showError?: boolean; +} + +export function TestConnectionButton({ + sourceId, + payload, + 'data-cy': dataCy, + showError, +}: Props) { + const mutation = useTestSourceConnectionMutation(); + + return ( +
+ mutation.mutate({ id: sourceId, payload })} + type="button" + data-cy={dataCy} + > + Test connection + + {mutation.isSuccess && ( + + )} +
+ ); +} + +function ConnectionTestStatusIcon({ + result, + showError, +}: { + result: ConnectionTestResult; + showError?: boolean; +}) { + if (result.success) { + return ( + <> + + Connection successful + + ); + } + + const errorMessage = result.error ?? 'Connection failed'; + + if (showError) { + return ( + + + {errorMessage} + + ); + } + + return ( + + + + {errorMessage} + + + ); +} diff --git a/app/react/portainer/gitops/sources/ItemView/WorkflowsTab.tsx b/app/react/portainer/gitops/sources/ItemView/WorkflowsTab.tsx index 4e00738f3..962bbfa2f 100644 --- a/app/react/portainer/gitops/sources/ItemView/WorkflowsTab.tsx +++ b/app/react/portainer/gitops/sources/ItemView/WorkflowsTab.tsx @@ -33,7 +33,9 @@ export function WorkflowsTab({ workflows }: Props) { {workflows.length === 0 ? ( -

No workflows using this source.

+

+ No workflows are using this source. +

) : ( diff --git a/app/react/portainer/gitops/sources/queries/query-keys.ts b/app/react/portainer/gitops/sources/queries/query-keys.ts index 06115fe2c..6a4374d10 100644 --- a/app/react/portainer/gitops/sources/queries/query-keys.ts +++ b/app/react/portainer/gitops/sources/queries/query-keys.ts @@ -2,5 +2,5 @@ export const sourceQueryKeys = { all: ['gitops', 'sources'] as const, list: (params: object) => [...sourceQueryKeys.all, 'list', params] as const, summary: () => [...sourceQueryKeys.all, 'summary'] as const, - detail: (id: string) => [...sourceQueryKeys.all, 'detail', id] as const, + detail: (id: number) => [...sourceQueryKeys.all, 'detail', id] as const, }; diff --git a/app/react/portainer/gitops/sources/queries/useDeleteSourceMutation.ts b/app/react/portainer/gitops/sources/queries/useDeleteSourceMutation.ts index 9a0e8b9d5..6c5dff5d4 100644 --- a/app/react/portainer/gitops/sources/queries/useDeleteSourceMutation.ts +++ b/app/react/portainer/gitops/sources/queries/useDeleteSourceMutation.ts @@ -1,12 +1,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import axios from '@/portainer/services/axios/axios'; +import { gitOpsSourcesDelete } from '@api/sdk.gen'; + import { withError } from '@/react-tools/react-query'; +import { Source } from '../types'; + import { sourceQueryKeys } from './query-keys'; -async function deleteSource(id: string): Promise { - await axios.delete(`/gitops/sources/${id}`); +async function deleteSource(id: Source['id']): Promise { + await gitOpsSourcesDelete({ path: { id } }); } export function useDeleteSourceMutation() { diff --git a/app/react/portainer/gitops/sources/queries/useSource.ts b/app/react/portainer/gitops/sources/queries/useSource.ts index b063e29b4..298ecb6c9 100644 --- a/app/react/portainer/gitops/sources/queries/useSource.ts +++ b/app/react/portainer/gitops/sources/queries/useSource.ts @@ -2,38 +2,33 @@ import { useQuery } from '@tanstack/react-query'; import axios from '@/portainer/services/axios/axios'; import { withError } from '@/react-tools/react-query'; +import { + SourcesAutoUpdateInfo, + SourcesConnectionInfo, +} from '@/react/portainer/generated-api/portainer/types.gen'; import { Source } from '../types'; import { Workflow } from '../../WorkflowsView/types'; import { sourceQueryKeys } from './query-keys'; -export interface ConnectionInfo { - referenceName: string; - configFilePath: string; - tlsSkipVerify: boolean; - authentication?: boolean; -} - -export interface AutoUpdateInfo { - mechanism?: string; - fetchInterval?: string; -} +export type ConnectionInfo = SourcesConnectionInfo; +export type AutoUpdateInfo = SourcesAutoUpdateInfo; export interface SourceDetail extends Source { - connection?: ConnectionInfo; + connection: ConnectionInfo; autoUpdate?: AutoUpdateInfo; workflows: Workflow[]; } -async function getSource(id: string): Promise { +async function getSource(id: Source['id']): Promise { const { data } = await axios.get(`/gitops/sources/${id}`); return data; } -export function useSource(id: string | undefined) { +export function useSource(id: Source['id'] | undefined) { return useQuery({ - queryKey: sourceQueryKeys.detail(id ?? ''), + queryKey: sourceQueryKeys.detail(id!), queryFn: () => getSource(id!), enabled: !!id, ...withError('Failed loading source'), diff --git a/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts b/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts index b89826142..de4c05285 100644 --- a/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts +++ b/app/react/portainer/gitops/sources/queries/useTestSourceConnectionMutation.ts @@ -1,15 +1,33 @@ import { useMutation } from '@tanstack/react-query'; -import axios from '@/portainer/services/axios/axios'; +import { type SourcesConnectionTestResult } from '@api/types.gen'; +import { gitOpsSourcesTestGit } from '@api/sdk.gen'; + import { withError } from '@/react-tools/react-query'; -async function testSourceConnection(id: string): Promise { - await axios.post(`/gitops/sources/${id}/test`); +import { Source } from '../types'; + +import { UpdateSourcePayload } from './useUpdateSourceMutation'; + +export type ConnectionTestResult = SourcesConnectionTestResult; + +async function testSourceConnection( + id: Source['id'], + payload: UpdateSourcePayload +): Promise { + const { data } = await gitOpsSourcesTestGit({ path: { id }, body: payload }); + return data; } export function useTestSourceConnectionMutation() { return useMutation({ - mutationFn: testSourceConnection, + mutationFn: ({ + id, + payload = {}, + }: { + id: Source['id']; + payload?: UpdateSourcePayload; + }) => testSourceConnection(id, payload), ...withError('Connection test failed'), }); } diff --git a/app/react/portainer/gitops/sources/queries/useUpdateSourceMutation.ts b/app/react/portainer/gitops/sources/queries/useUpdateSourceMutation.ts new file mode 100644 index 000000000..8855cd53d --- /dev/null +++ b/app/react/portainer/gitops/sources/queries/useUpdateSourceMutation.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { gitOpsSourcesUpdateGit } from '@api/sdk.gen'; + +import { withError } from '@/react-tools/react-query'; +import { SourcesGitSourceUpdatePayload } from '@/react/portainer/generated-api/portainer/types.gen'; + +import { Source } from '../types'; + +import { sourceQueryKeys } from './query-keys'; + +export type UpdateSourcePayload = SourcesGitSourceUpdatePayload; + +async function updateSource( + id: Source['id'], + payload: UpdateSourcePayload +): Promise { + await gitOpsSourcesUpdateGit({ path: { id }, body: payload }); +} + +export function useUpdateSourceMutation(id: Source['id']) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: UpdateSourcePayload) => updateSource(id, payload), + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: sourceQueryKeys.detail(id), + }); + }, + ...withError('Unable to update source'), + }); +} diff --git a/app/react/portainer/gitops/sources/types.ts b/app/react/portainer/gitops/sources/types.ts index 09fd9d2ef..a43030c3c 100644 --- a/app/react/portainer/gitops/sources/types.ts +++ b/app/react/portainer/gitops/sources/types.ts @@ -1,21 +1,18 @@ import { type LucideIcon, Box, GitBranch, Package } from 'lucide-react'; -export type SourceStatus = - | 'healthy' - | 'error' - | 'syncing' - | 'paused' - | 'unknown'; -export type SourceType = 'git' | 'helm' | 'oci'; +import { WorkflowsStatus, SourcesSourceType } from '@api/types.gen'; + +export type SourceStatus = WorkflowsStatus; +export type SourceType = SourcesSourceType; export interface Source { - id: string; + id: number; name: string; type: SourceType; url: string; status: SourceStatus; error?: string; - provider?: string; + provider?: number; usedBy: number; environments: number; lastSync: number;