f5a505c468
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
246 lines
9.2 KiB
Go
246 lines
9.2 KiB
Go
// Package workflowsync keeps workspace workflows in sync with definition
|
|
// files stored in a configured GitHub repository. A background poller fetches
|
|
// the configured directory on an interval and applies changes through the
|
|
// workflow service; users can also force a sync from the settings UI. Synced
|
|
// workflows coexist with manually-created ones — see workflows.source.
|
|
package workflowsync
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/authcircuit"
|
|
"github.com/kandev/kandev/internal/common/securityutil"
|
|
)
|
|
|
|
// Defaults for optional config fields.
|
|
const (
|
|
DefaultBranch = "main"
|
|
DefaultPath = ".kandev/workflows"
|
|
DefaultIntervalSeconds = 300
|
|
MinIntervalSeconds = 60
|
|
// MaxIntervalSeconds caps the poll interval at 30 days — far beyond any
|
|
// sensible cadence, and safely below time.Duration overflow territory.
|
|
MaxIntervalSeconds = 30 * 24 * 60 * 60
|
|
)
|
|
|
|
// Supported sync sources. A workspace has exactly one.
|
|
const (
|
|
ProviderGitHub = "github"
|
|
ProviderGitLab = "gitlab"
|
|
)
|
|
|
|
// ErrInvalidConfig marks validation failures on SetConfigRequest so handlers
|
|
// can map them to 400.
|
|
var ErrInvalidConfig = errors.New("invalid workflow sync config")
|
|
|
|
// ErrNotConfigured is returned when a sync is requested for a workspace that
|
|
// has no workflow sync config.
|
|
var ErrNotConfigured = errors.New("workflow sync is not configured for this workspace")
|
|
|
|
// Config is the per-workspace workflow sync configuration plus the status of
|
|
// the most recent sync attempt (written by the poller and force syncs).
|
|
type Config struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
// Provider selects the sync source: ProviderGitHub uses RepoOwner and
|
|
// RepoName, ProviderGitLab uses ProjectPath.
|
|
Provider string `json:"provider"`
|
|
RepoOwner string `json:"repo_owner"`
|
|
RepoName string `json:"repo_name"`
|
|
ProjectPath string `json:"project_path"`
|
|
Branch string `json:"branch"`
|
|
Path string `json:"path"`
|
|
IntervalSeconds int `json:"interval_seconds"`
|
|
PollEnabled bool `json:"poll_enabled"`
|
|
LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
|
|
LastOk bool `json:"last_ok"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastWarnings []string `json:"last_warnings,omitempty"`
|
|
LastHash string `json:"-"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// FailureClass, ConsecutiveFailures, and NextRetryAt persist the
|
|
// generation-aware circuit-breaker state for this config (see
|
|
// internal/common/authcircuit). Exposed read-only to operators so a
|
|
// permanently-failing config is visibly distinguishable from one that is
|
|
// simply between polls.
|
|
FailureClass authcircuit.FailureClass `json:"failure_class,omitempty"`
|
|
ConsecutiveFailures int `json:"consecutive_failures,omitempty"`
|
|
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
|
|
// ConfigFingerprint and CredentialFingerprint are internal-only circuit
|
|
// identity keys (never secrets — see authcircuit.State.Fingerprint) and
|
|
// are not surfaced over the API.
|
|
ConfigFingerprint string `json:"-"`
|
|
CredentialFingerprint string `json:"-"`
|
|
}
|
|
|
|
// circuitState extracts the persisted authcircuit.State embedded in this
|
|
// config's flat columns.
|
|
func (c *Config) circuitState() authcircuit.State {
|
|
return authcircuit.State{
|
|
FailureClass: c.FailureClass,
|
|
ConsecutiveFailures: c.ConsecutiveFailures,
|
|
NextRetryAt: c.NextRetryAt,
|
|
Fingerprint: c.CredentialFingerprint,
|
|
}
|
|
}
|
|
|
|
// applyCircuitState writes an updated authcircuit.State back onto the
|
|
// config's flat columns (the in-memory mirror of what Store persists).
|
|
func (c *Config) applyCircuitState(state authcircuit.State) {
|
|
c.FailureClass = state.FailureClass
|
|
c.ConsecutiveFailures = state.ConsecutiveFailures
|
|
c.NextRetryAt = state.NextRetryAt
|
|
c.CredentialFingerprint = state.Fingerprint
|
|
}
|
|
|
|
// circuitOpen reports whether this config's circuit is currently open (skip
|
|
// syncing) at the given time.
|
|
func (c *Config) circuitOpen(now time.Time) bool {
|
|
return c.circuitState().Open(now)
|
|
}
|
|
|
|
// fingerprint is a stable, non-secret digest of the fields that define
|
|
// "what this config points at". Changing any of these fields (a deliberate
|
|
// SetConfig call) always resets the circuit — see Store.UpsertConfigForWorkspace.
|
|
func (r *SetConfigRequest) fingerprint() string {
|
|
return strings.Join([]string{
|
|
r.Provider, r.RepoOwner, r.RepoName, r.ProjectPath, r.Branch, r.Path,
|
|
fmt.Sprintf("%d", r.IntervalSeconds),
|
|
}, "\x1f")
|
|
}
|
|
|
|
// SetConfigRequest is the payload for creating or updating a workspace's
|
|
// workflow sync config. Branch, Path, and IntervalSeconds fall back to
|
|
// defaults when empty/zero.
|
|
type SetConfigRequest struct {
|
|
// Provider is optional; an empty value means ProviderGitHub, which keeps
|
|
// pre-GitLab API clients working unchanged.
|
|
Provider string `json:"provider"`
|
|
RepoOwner string `json:"repo_owner"`
|
|
RepoName string `json:"repo_name"`
|
|
ProjectPath string `json:"project_path"`
|
|
Branch string `json:"branch"`
|
|
Path string `json:"path"`
|
|
IntervalSeconds int `json:"interval_seconds"`
|
|
// PollEnabled controls the background polling loop; nil defaults to
|
|
// true. When false the workspace only syncs via "Sync now".
|
|
PollEnabled *bool `json:"poll_enabled"`
|
|
}
|
|
|
|
// normalizeTarget validates the repository coordinates for the selected
|
|
// provider and clears the other provider's fields, so a switch cannot leave
|
|
// stale values behind.
|
|
func (r *SetConfigRequest) normalizeTarget() error {
|
|
r.Provider = strings.TrimSpace(r.Provider)
|
|
if r.Provider == "" {
|
|
r.Provider = ProviderGitHub
|
|
}
|
|
r.RepoOwner = strings.TrimSpace(r.RepoOwner)
|
|
r.RepoName = strings.TrimSpace(r.RepoName)
|
|
r.ProjectPath = strings.TrimSpace(r.ProjectPath)
|
|
|
|
switch r.Provider {
|
|
case ProviderGitHub:
|
|
return r.normalizeGitHubTarget()
|
|
case ProviderGitLab:
|
|
return r.normalizeGitLabTarget()
|
|
default:
|
|
return fmt.Errorf("%w: provider must be %q or %q", ErrInvalidConfig, ProviderGitHub, ProviderGitLab)
|
|
}
|
|
}
|
|
|
|
func (r *SetConfigRequest) normalizeGitHubTarget() error {
|
|
if r.ProjectPath != "" {
|
|
return fmt.Errorf("%w: project_path is only valid for the %q provider", ErrInvalidConfig, ProviderGitLab)
|
|
}
|
|
if r.RepoOwner == "" || strings.ContainsAny(r.RepoOwner, "/ ") {
|
|
return fmt.Errorf("%w: repo_owner is required and cannot contain slashes or spaces", ErrInvalidConfig)
|
|
}
|
|
if r.RepoName == "" || strings.ContainsAny(r.RepoName, "/ ") {
|
|
return fmt.Errorf("%w: repo_name is required and cannot contain slashes or spaces", ErrInvalidConfig)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeGitLabTarget requires a namespace path. GitLab projects live at
|
|
// arbitrarily nested paths ("group/subgroup/project"), so the value is
|
|
// validated as a whole rather than split into owner and name.
|
|
func (r *SetConfigRequest) normalizeGitLabTarget() error {
|
|
if r.RepoOwner != "" || r.RepoName != "" {
|
|
return fmt.Errorf(
|
|
"%w: repo_owner and repo_name are only valid for the %q provider", ErrInvalidConfig, ProviderGitHub)
|
|
}
|
|
if r.ProjectPath == "" {
|
|
return fmt.Errorf("%w: project_path is required", ErrInvalidConfig)
|
|
}
|
|
if strings.Contains(r.ProjectPath, " ") {
|
|
return fmt.Errorf("%w: project_path cannot contain spaces", ErrInvalidConfig)
|
|
}
|
|
segments := strings.Split(r.ProjectPath, "/")
|
|
if len(segments) < 2 {
|
|
return fmt.Errorf(
|
|
"%w: project_path must include the namespace, e.g. \"group/project\"", ErrInvalidConfig)
|
|
}
|
|
for _, segment := range segments {
|
|
if segment == "" {
|
|
return fmt.Errorf(
|
|
"%w: project_path cannot have empty segments or leading/trailing slashes", ErrInvalidConfig)
|
|
}
|
|
if segment == "." || segment == ".." {
|
|
return fmt.Errorf("%w: project_path cannot contain \".\" or \"..\" segments", ErrInvalidConfig)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Normalize validates the request and fills defaults. It returns a wrapped
|
|
// ErrInvalidConfig on bad input.
|
|
func (r *SetConfigRequest) Normalize() error {
|
|
r.Branch = strings.TrimSpace(r.Branch)
|
|
r.Path = strings.Trim(strings.TrimSpace(r.Path), "/")
|
|
if err := r.normalizeTarget(); err != nil {
|
|
return err
|
|
}
|
|
if r.Branch == "" {
|
|
r.Branch = DefaultBranch
|
|
}
|
|
if !securityutil.IsValidBranchName(r.Branch) {
|
|
return fmt.Errorf("%w: branch is not a valid git branch name", ErrInvalidConfig)
|
|
}
|
|
if r.Path == "" {
|
|
r.Path = DefaultPath
|
|
}
|
|
for _, segment := range strings.Split(r.Path, "/") {
|
|
if segment == ".." {
|
|
return fmt.Errorf("%w: path cannot contain \"..\"", ErrInvalidConfig)
|
|
}
|
|
}
|
|
if r.IntervalSeconds == 0 {
|
|
r.IntervalSeconds = DefaultIntervalSeconds
|
|
}
|
|
if r.IntervalSeconds < MinIntervalSeconds {
|
|
return fmt.Errorf("%w: interval_seconds must be at least %d", ErrInvalidConfig, MinIntervalSeconds)
|
|
}
|
|
if r.IntervalSeconds > MaxIntervalSeconds {
|
|
return fmt.Errorf("%w: interval_seconds must be at most %d", ErrInvalidConfig, MaxIntervalSeconds)
|
|
}
|
|
if r.PollEnabled == nil {
|
|
enabled := true
|
|
r.PollEnabled = &enabled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SyncResult reports the outcome of one sync run for a workspace.
|
|
type SyncResult struct {
|
|
Created []string `json:"created"`
|
|
Updated []string `json:"updated"`
|
|
Deleted []string `json:"deleted"`
|
|
Warnings []string `json:"warnings"`
|
|
Unchanged bool `json:"unchanged"`
|
|
}
|