diff --git a/CLAUDE.md b/CLAUDE.md index ca7b990..4e3b5f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ mantle env update # Replace inputs/env on an existing environment mantle env list # List named environments mantle env get # Show environment details (env values redacted; --reveal to unredact, audited) mantle env delete -y # Delete a named environment (requires --yes) +mantle repos add --url --credential # Register a GitOps source repo +mantle repos list # List registered repos with last-sync status +mantle repos status # Show detailed repo status +mantle repos remove -y # Unregister a repo (requires --yes) mantle run --values f.yaml # Run with a values file (inputs + env overrides) mantle run --env # Run against a stored named environment mantle plan --env # Plan; appends resolved inputs/env with source @@ -91,6 +95,27 @@ mantle serve # Start persistent server - **Workflow inputs** (consumed by `inputs.` in CEL): `--input` flags > `--values` file `inputs:` > `--env` named-environment `inputs` > workflow definition `default` - **Env vars** (consumed by `env.` in CEL): `MANTLE_ENV_*` OS vars > `--values` file `env:` > `--env` named-environment `env` > config `env:` section in `mantle.yaml` +## GitOps Config + +Register repos in `mantle.yaml` under `git_sync.repos`: + +```yaml +git_sync: + repos: + - name: acme + url: https://github.com/acme/workflows.git + branch: main + path: / + poll_interval: 60s + credential: github-pat # must reference a secret of type: git + auto_apply: true + prune: true +``` + +Credentials of type `git` accept `token` (for HTTPS), `ssh_key` (for SSH), and optional `username`. At least one of `token` or `ssh_key` is required. + +> Plan A ships the repo registry and CLI only. The sync engine that consumes this block — pulling from the sidecar, validating, and applying workflow YAML — ships in the next milestone. Registering a repo today has no runtime effect yet beyond persistence. + ## License BSL/SSPL-style — source available, no commercial resale of forks. diff --git a/packages/engine/internal/audit/audit.go b/packages/engine/internal/audit/audit.go index 3f547cf..dfc7433 100644 --- a/packages/engine/internal/audit/audit.go +++ b/packages/engine/internal/audit/audit.go @@ -61,6 +61,11 @@ const ( ActionEnvironmentUpdated Action = "environment.updated" ActionEnvironmentDeleted Action = "environment.deleted" ActionEnvironmentRevealed Action = "environment.revealed" + + // Git repo operations. + ActionRepoAdded Action = "repo.created" + ActionRepoUpdated Action = "repo.updated" + ActionRepoRemoved Action = "repo.deleted" ) // Resource identifies the target of an audit event. diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go new file mode 100644 index 0000000..7fe69b7 --- /dev/null +++ b/packages/engine/internal/cli/repos.go @@ -0,0 +1,211 @@ +package cli + +import ( + "fmt" + "text/tabwriter" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/repo" + "github.com/spf13/cobra" +) + +// newReposCommand returns the "repos" subcommand for managing registered +// GitOps source repositories (issue #16). Subcommands handle registration, +// listing, detailed status, and removal. Sync behavior lives in Plan B. +func newReposCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "repos", + Short: "Manage GitOps workflow source repositories", + Long: `Registers GitOps source repositories whose workflow YAML definitions will be +synced into this Mantle instance. This command manages the registry; the sync +engine itself (sidecar, file discovery, validate/plan/apply) ships in a later +milestone. Auth material is stored in a "git" credential type +(` + "`mantle secrets create --type git`" + `) and referenced here by name.`, + } + cmd.AddCommand(newReposAddCommand()) + cmd.AddCommand(newReposListCommand()) + cmd.AddCommand(newReposStatusCommand()) + cmd.AddCommand(newReposRemoveCommand()) + return cmd +} + +// newRepoStore builds a repo.Store from the current command context. +func newRepoStore(cmd *cobra.Command) (*repo.Store, func(), error) { + cfg := config.FromContext(cmd.Context()) + if cfg == nil { + return nil, nil, fmt.Errorf("config not loaded") + } + database, err := db.Open(cfg.Database) + if err != nil { + return nil, nil, fmt.Errorf("failed to connect to database: %w", err) + } + store := &repo.Store{DB: database, Actor: "cli"} + cleanup := func() { database.Close() } + return store, cleanup, nil +} + +func newReposAddCommand() *cobra.Command { + var url, branch, path, pollInterval, credential string + var autoApply, prune bool + + cmd := &cobra.Command{ + Use: "add ", + Short: "Register a new GitOps source repo", + Long: `Registers a new repository to sync workflow definitions from. The named +credential must already exist and be of type "git".`, + Example: ` mantle repos add acme --url https://github.com/acme/workflows.git --credential github-pat + mantle repos add staging --url git@github.com:acme/wf.git --credential github-ssh --branch release`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + r, err := store.Create(cmd.Context(), repo.CreateParams{ + Name: args[0], + URL: url, + Branch: branch, + Path: path, + PollInterval: pollInterval, + Credential: credential, + AutoApply: autoApply, + Prune: prune, + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Added repo %q (%s)\n", r.Name, r.ID) + return nil + }, + } + + cmd.Flags().StringVar(&url, "url", "", "Git repository URL (required)") + cmd.Flags().StringVar(&branch, "branch", "main", "Branch to sync") + cmd.Flags().StringVar(&path, "path", "/", "Subdirectory inside the repo to scan") + cmd.Flags().StringVar(&pollInterval, "poll-interval", "60s", "Interval between syncs (Go duration, min 10s)") + cmd.Flags().StringVar(&credential, "credential", "", "Git credential name (required)") + cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes (false = plan-only)") + cmd.Flags().BoolVar(&prune, "prune", true, "When true, workflows deleted from the repo are disabled in Mantle") + _ = cmd.MarkFlagRequired("url") + _ = cmd.MarkFlagRequired("credential") + + return cmd +} + +func newReposStatusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status ", + Short: "Show detailed status for a registered repo", + Long: `Displays all persisted fields for a registered repository, including the +URL, branch, polling interval, credential reference, enable state, and the +outcome of the most recent sync attempt (SHA, timestamp, and any error).`, + Example: ` mantle repos status acme + mantle repos status staging --database-url postgres://localhost/mantle`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + r, err := store.Get(cmd.Context(), args[0]) + if err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Name: %s\n", r.Name) + fmt.Fprintf(out, "ID: %s\n", r.ID) + fmt.Fprintf(out, "URL: %s\n", r.URL) + fmt.Fprintf(out, "Branch: %s\n", r.Branch) + fmt.Fprintf(out, "Path: %s\n", r.Path) + fmt.Fprintf(out, "Poll: %s\n", r.PollInterval) + fmt.Fprintf(out, "Credential: %s\n", r.Credential) + fmt.Fprintf(out, "Auto-Apply: %t\n", r.AutoApply) + fmt.Fprintf(out, "Prune: %t\n", r.Prune) + fmt.Fprintf(out, "Enabled: %t\n", r.Enabled) + if r.LastSyncAt != nil { + fmt.Fprintf(out, "Last Sync: %s (SHA %s)\n", + r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC"), r.LastSyncSHA) + } else { + fmt.Fprintln(out, "Last Sync: (never)") + } + if r.LastSyncError != "" { + fmt.Fprintf(out, "Last Error: %s\n", r.LastSyncError) + } + return nil + }, + } +} + +func newReposRemoveCommand() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "remove ", + Short: "Unregister a GitOps source repo", + Long: `Unregisters a repo. Any previously applied workflows remain in place — this +command only stops future syncs. Requires --yes to confirm.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + return fmt.Errorf("refusing to remove %q without --yes", args[0]) + } + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + if err := store.Delete(cmd.Context(), args[0]); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed repo %q\n", args[0]) + return nil + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Confirm deletion (required)") + return cmd +} + +func newReposListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all registered GitOps repos", + Long: `Prints a table of all repositories registered for the current team, ordered +by name. Columns include the repo URL, target branch, auto-apply flag, enable +state, and timestamp of the last successful sync.`, + Example: ` mantle repos list + mantle repos list --database-url postgres://localhost/mantle`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + repos, err := store.List(cmd.Context()) + if err != nil { + return err + } + if len(repos) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "(no repos)") + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tURL\tBRANCH\tAUTO-APPLY\tENABLED\tLAST SYNC") + for _, r := range repos { + last := "(never)" + if r.LastSyncAt != nil { + last = r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC") + } + fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%t\t%s\n", + r.Name, r.URL, r.Branch, r.AutoApply, r.Enabled, last) + } + return w.Flush() + }, + } +} diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go new file mode 100644 index 0000000..078247d --- /dev/null +++ b/packages/engine/internal/cli/repos_test.go @@ -0,0 +1,176 @@ +package cli + +import ( + "bytes" + "os" + "strings" + "testing" + "time" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// reposCtx spins up a Postgres container, runs migrations, and returns a +// *config.Config with the DatabaseConfig attached. Callers pass the database +// URL via --database-url flags; the config is used only to read that URL. +func reposCtx(t *testing.T) *config.Config { + t.Helper() + bg := t.Context() + pgContainer, err := postgres.Run(bg, + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + if os.Getenv("CI") != "" { + t.Fatalf("Postgres (CI): %v", err) + } + t.Skipf("Postgres: %v", err) + } + t.Cleanup(func() { _ = pgContainer.Terminate(bg) }) + connStr, err := pgContainer.ConnectionString(bg, "sslmode=disable") + if err != nil { + t.Fatalf("ConnectionString: %v", err) + } + dbCfg := config.DatabaseConfig{URL: connStr} + conn, err := db.Open(dbCfg) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { conn.Close() }) + if err := db.Migrate(bg, conn); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return &config.Config{Database: dbCfg} +} + +// seedRepo registers a single repo via the add command, failing the test on +// error. Shared by tests that need at least one row in git_repos. +func seedRepo(t *testing.T, cfg *config.Config, name string) { + t.Helper() + root := NewRootCommand() + var seedStderr bytes.Buffer + root.SetErr(&seedStderr) + root.SetArgs([]string{"repos", "add", name, + "--url", "https://example.com/a.git", + "--credential", "pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("seedRepo(%q): %v\nstderr: %s", name, err, seedStderr.String()) + } +} + +func TestReposAdd_PersistsRepo(t *testing.T) { + cfg := reposCtx(t) + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "add", "acme", + "--url", "https://github.com/acme/workflows.git", + "--credential", "github-pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "Added repo \"acme\"") { + t.Errorf("unexpected stdout: %q", stdout.String()) + } +} + +func TestReposList_ShowsRegisteredRepos(t *testing.T) { + cfg := reposCtx(t) + seedRepo(t, cfg, "acme") + + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "list", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("list: %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "acme") || !strings.Contains(out, "NAME") { + t.Errorf("list output missing expected fields: %q", out) + } +} + +func TestReposList_EmptyState(t *testing.T) { + cfg := reposCtx(t) + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "list", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("list: %v\nstderr: %s", err, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "(no repos)" { + t.Errorf("empty list output: %q", stdout.String()) + } +} + +func TestReposStatus_ShowsDetails(t *testing.T) { + cfg := reposCtx(t) + seedRepo(t, cfg, "acme") + + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "status", "acme", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("status: %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"Name:", "URL:", "Branch:", "Credential:", "Auto-Apply:"} { + if !strings.Contains(out, want) { + t.Errorf("status output missing %q: %s", want, out) + } + } +} + +func TestReposRemove_RequiresYesFlag(t *testing.T) { + cfg := reposCtx(t) + seedRepo(t, cfg, "acme") + + root := NewRootCommand() + var stderr bytes.Buffer + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "remove", "acme", "--database-url", cfg.Database.URL}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "--yes") { + t.Errorf("expected --yes error, got %v", err) + } +} + +func TestReposRemove_DeletesRow(t *testing.T) { + cfg := reposCtx(t) + seedRepo(t, cfg, "acme") + + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "remove", "acme", "--yes", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("remove: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "Removed repo \"acme\"") { + t.Errorf("unexpected stdout: %q", stdout.String()) + } +} diff --git a/packages/engine/internal/cli/root.go b/packages/engine/internal/cli/root.go index 918ad78..00c869a 100644 --- a/packages/engine/internal/cli/root.go +++ b/packages/engine/internal/cli/root.go @@ -82,6 +82,7 @@ Full documentation: https://mantle.dvflw.co/docs`, newLibraryCommand(), newCleanupCommand(), newBudgetCommand(), + newReposCommand(), ) // Info. diff --git a/packages/engine/internal/config/config.go b/packages/engine/internal/config/config.go index f566176..7fd76b5 100644 --- a/packages/engine/internal/config/config.go +++ b/packages/engine/internal/config/config.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/url" "os" + "regexp" "strings" "time" @@ -33,6 +34,7 @@ type Config struct { GCP GCPConfig `mapstructure:"gcp"` Azure AzureConfig `mapstructure:"azure"` Storage StorageConfig `mapstructure:"storage"` + GitSync GitSyncConfig `mapstructure:"git_sync"` Env map[string]string `mapstructure:"env"` } @@ -67,6 +69,32 @@ type StorageConfig struct { Retention string `mapstructure:"retention"` // Duration string, e.g. "24h". Empty = no auto-cleanup. } +// GitSyncConfig configures GitOps-style workflow sync (issue #16). Plan A +// parses and exposes this block so operators can version their repo +// registry in mantle.yaml; the startup reconciliation that materializes +// entries into git_repos rows ships with the sync engine in a later +// milestone. +type GitSyncConfig struct { + // Repos is list-valued; Viper cannot bind individual list elements to env + // vars, so no BindEnv calls are present for git_sync.repos — this is + // intentional. Repos are configured exclusively via the config file. + Repos []GitSyncRepo `mapstructure:"repos"` +} + +// GitSyncRepo mirrors the relevant fields of the git_repos table for +// config-driven registration. Fields default to the same values as the +// DB when omitted. +type GitSyncRepo struct { + Name string `mapstructure:"name"` + URL string `mapstructure:"url"` + Branch string `mapstructure:"branch"` + Path string `mapstructure:"path"` + PollInterval string `mapstructure:"poll_interval"` + Credential string `mapstructure:"credential"` + AutoApply bool `mapstructure:"auto_apply"` + Prune bool `mapstructure:"prune"` +} + // AuthConfig holds authentication configuration. type AuthConfig struct { OIDC OIDCConfig `mapstructure:"oidc"` @@ -329,6 +357,31 @@ func Load(cmd *cobra.Command) (*Config, error) { cfg.Env = normalized } + // Validate git_sync.repos entries. Each repo must have a valid name, + // a valid poll_interval, and a URL free of embedded credentials. + // The URL check is intentionally a subset of the store's validateURL: + // it only needs to catch the "user@host" form that people may put in a + // config file. Full store-level validation runs again at registration time. + for i, r := range cfg.GitSync.Repos { + if err := validateGitSyncName(r.Name); err != nil { + return nil, fmt.Errorf("git_sync.repos[%d]: %w", i, err) + } + if r.URL == "" { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): url is required", i, r.Name) + } + if err := validateGitSyncURL(r.URL); err != nil { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): %w", i, r.Name, err) + } + if r.Credential == "" { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): credential is required", i, r.Name) + } + if r.PollInterval != "" { + if err := validateGitSyncPollInterval(r.PollInterval); err != nil { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): %w", i, r.Name, err) + } + } + } + // Validate budget reset_day range. if cfg.Engine.Budget.ResetDay < 1 || cfg.Engine.Budget.ResetDay > 28 { if cfg.Engine.Budget.ResetMode == budget.ResetModeRolling { @@ -363,3 +416,64 @@ func Load(cmd *cobra.Command) (*Config, error) { return &cfg, nil } + +// gitSyncNamePattern enforces DNS-label-like names: lowercase alphanumerics, +// underscores, and hyphens, starting and ending with an alphanumeric. +// This is a local copy of the pattern in packages/engine/internal/repo/types.go +// kept here to avoid a config → repo → auth test-time import cycle. +var gitSyncNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`) + +// scpStyleSSHPattern matches scp-style SSH git URLs (e.g. git@github.com:user/repo.git). +// url.Parse does not recognise these as having a scheme or host, so they need a +// separate check after the standard parse-based validation. +var scpStyleSSHPattern = regexp.MustCompile(`^[^@\s]+@[^:\s]+:.+$`) + +// validateGitSyncName is a local copy of repo.ValidateName to avoid a +// config → repo → auth test-time import cycle. Keep in sync with the +// canonical version in packages/engine/internal/repo/types.go. +func validateGitSyncName(name string) error { + if name == "" { + return fmt.Errorf("repo name is required") + } + if len(name) > 63 { + return fmt.Errorf("invalid repo name %q: length %d exceeds 63-char cap", name, len(name)) + } + if !gitSyncNamePattern.MatchString(name) { + return fmt.Errorf("invalid repo name %q: must match %s", name, gitSyncNamePattern.String()) + } + return nil +} + +// validateGitSyncPollInterval is a local copy of repo.ValidatePollInterval. +// See validateGitSyncName for the rationale. +func validateGitSyncPollInterval(interval string) error { + d, err := time.ParseDuration(interval) + if err != nil { + return fmt.Errorf("invalid poll_interval %q: %w", interval, err) + } + if d < 10*time.Second { + return fmt.Errorf("poll_interval %q below 10s minimum", interval) + } + return nil +} + +// validateGitSyncURL rejects URLs that embed credentials in the userinfo +// component (https://user@host or https://user:pass@host) and also rejects +// strings that are neither a well-formed URL (scheme + host) nor a valid +// scp-style SSH reference (user@host:path). All auth material must flow +// through the Credential reference. +func validateGitSyncURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid url %q: %w", raw, err) + } + if u.User != nil { + return fmt.Errorf("repo url must not embed credentials — use the Credential field instead") + } + if u.Scheme == "" || u.Host == "" { + if !scpStyleSSHPattern.MatchString(raw) { + return fmt.Errorf("invalid url %q: must have scheme and host (e.g. https://host/path) or use scp-style SSH format (git@host:path)", raw) + } + } + return nil +} diff --git a/packages/engine/internal/config/config_test.go b/packages/engine/internal/config/config_test.go index a780344..799a625 100644 --- a/packages/engine/internal/config/config_test.go +++ b/packages/engine/internal/config/config_test.go @@ -467,3 +467,129 @@ log: assert.Empty(t, cfg.Env) } + +func TestLoadConfig_GitSyncRepos(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mantle.yaml") + yaml := `version: 1 +database: + url: postgres://localhost/mantle +git_sync: + repos: + - name: acme + url: https://github.com/acme/workflows.git + branch: main + path: / + poll_interval: 60s + credential: github-pat + auto_apply: true + prune: true +` + if err := os.WriteFile(path, []byte(yaml), 0600); err != nil { + t.Fatalf("writing config: %v", err) + } + cmd := newTestCommand() + _ = cmd.Flags().Set("config", path) + cfg, err := Load(cmd) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.GitSync.Repos) != 1 { + t.Fatalf("got %d repos, want 1", len(cfg.GitSync.Repos)) + } + r := cfg.GitSync.Repos[0] + if r.Name != "acme" || r.URL != "https://github.com/acme/workflows.git" || + r.Branch != "main" || r.Credential != "github-pat" || !r.AutoApply { + t.Errorf("parsed repo: %+v", r) + } + if r.Path != "/" { + t.Errorf("Path: got %q, want %q", r.Path, "/") + } + if r.PollInterval != "60s" { + t.Errorf("PollInterval: got %q, want %q", r.PollInterval, "60s") + } + if !r.Prune { + t.Errorf("Prune: got false, want true") + } +} + +func TestLoad_GitSyncRepos_Validation(t *testing.T) { + base := `version: 1 +database: + url: postgres://localhost/mantle +git_sync: + repos: +` + cases := []struct { + name string + repo string + wantErr string + }{ + { + name: "invalid name", + repo: ` - name: "Bad Name!" + url: https://example.com/a.git + credential: pat +`, + wantErr: "invalid repo name", + }, + { + name: "missing url", + repo: ` - name: acme + credential: pat +`, + wantErr: "url is required", + }, + { + name: "url with embedded credentials", + repo: ` - name: acme + url: https://user:pass@github.com/a.git + credential: pat +`, + wantErr: "must not embed credentials", + }, + { + name: "url with no scheme or host", + repo: ` - name: acme + url: "not a url" + credential: pat +`, + wantErr: "must have scheme and host", + }, + { + name: "missing credential", + repo: ` - name: acme + url: https://github.com/a.git +`, + wantErr: "credential is required", + }, + { + name: "poll_interval below minimum", + repo: ` - name: acme + url: https://github.com/a.git + credential: pat + poll_interval: 5s +`, + wantErr: "below 10s minimum", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mantle.yaml") + if err := os.WriteFile(path, []byte(base+tc.repo), 0600); err != nil { + t.Fatalf("writing config: %v", err) + } + cmd := newTestCommand() + _ = cmd.Flags().Set("config", path) + _, err := Load(cmd) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} diff --git a/packages/engine/internal/db/migrations/019_git_repos.sql b/packages/engine/internal/db/migrations/019_git_repos.sql new file mode 100644 index 0000000..21dc334 --- /dev/null +++ b/packages/engine/internal/db/migrations/019_git_repos.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- git_repos stores configuration for GitOps workflow sources (issue #16). +-- Each row represents a remote git repository that Mantle will pull from +-- and whose .yaml/.yml files will be applied as workflow definitions. +-- Raw auth material lives in the credentials table; this row references +-- it by name only. +-- +-- The `name` column is a human-readable identifier for CLI ergonomics +-- (`mantle repos status `), unique per team. It does not derive +-- from the repo URL because multiple teams may share the same upstream. +-- +-- credential is stored as a plain name reference (TEXT NOT NULL) rather +-- than a foreign key. A FK to credentials(team_id, name) would prevent +-- operators from registering a repo before creating the credential, which +-- is a common IaC pattern (define everything in one pass, then apply). +-- The sync engine resolves and validates the credential at sync time. +CREATE TABLE git_repos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT DEFAULT '00000000-0000-0000-0000-000000000001', + name TEXT NOT NULL, + url TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + path TEXT NOT NULL DEFAULT '/', + poll_interval TEXT NOT NULL DEFAULT '60s', + credential TEXT NOT NULL, + auto_apply BOOLEAN NOT NULL DEFAULT TRUE, + prune BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_sync_sha TEXT, + last_sync_at TIMESTAMPTZ, + last_sync_error TEXT, + -- webhook_secret is intentionally absent in Plan A: the webhook receiver + -- (Plan C) will add a new migration that stores the HMAC secret as an + -- encrypted credential reference rather than plaintext TEXT. + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT git_repos_team_name_key UNIQUE(team_id, name) +); + +CREATE INDEX idx_git_repos_team ON git_repos(team_id); +CREATE INDEX idx_git_repos_team_enabled ON git_repos(team_id, enabled) WHERE enabled = TRUE; + +-- +goose Down +DROP TABLE IF EXISTS git_repos; diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go new file mode 100644 index 0000000..3246d93 --- /dev/null +++ b/packages/engine/internal/repo/store.go @@ -0,0 +1,327 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "time" + + "github.com/dvflw/mantle/internal/audit" + "github.com/dvflw/mantle/internal/auth" +) + +// Store handles CRUD operations for registered git repos. Every +// state-changing method emits an audit event in the same transaction +// as the write so audit log and state never drift. +type Store struct { + DB *sql.DB + Actor string // defaults to "cli" when empty +} + +func (s *Store) actor() string { + if s.Actor == "" { + return "cli" + } + return s.Actor +} + +// ErrNotFound is returned when a lookup by name does not match a row in +// the current team scope. +var ErrNotFound = errors.New("repo not found") + +// CreateParams captures the fields required to register a new repo. +// Fields with empty defaults (Branch, Path, PollInterval) are filled +// in by the caller using the same defaults the `git_repos` table uses. +type CreateParams struct { + Name string + URL string + Branch string + Path string + PollInterval string + Credential string + AutoApply bool + Prune bool +} + +// Create inserts a new repo row and emits a repo.added audit event. +func (s *Store) Create(ctx context.Context, p CreateParams) (*Repo, error) { + if err := ValidateName(p.Name); err != nil { + return nil, err + } + if err := ValidatePollInterval(p.PollInterval); err != nil { + return nil, err + } + if p.URL == "" { + return nil, fmt.Errorf("repo url is required") + } + if p.Credential == "" { + return nil, fmt.Errorf("credential is required") + } + if err := validateURL(p.URL); err != nil { + return nil, err + } + + teamID := auth.TeamIDFromContext(ctx) + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("starting transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var r Repo + err = tx.QueryRowContext(ctx, + `INSERT INTO git_repos + (team_id, name, url, branch, path, poll_interval, credential, auto_apply, prune) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, created_at, updated_at`, + teamID, p.Name, p.URL, p.Branch, p.Path, p.PollInterval, p.Credential, + p.AutoApply, p.Prune, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, + &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &r.CreatedAt, &r.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("creating repo %q: %w", p.Name, err) + } + + if err := audit.EmitTx(ctx, tx, audit.Event{ + Timestamp: time.Now(), + Actor: s.actor(), + Action: audit.ActionRepoAdded, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": p.Name, "url": p.URL}, + }); err != nil { + return nil, fmt.Errorf("emitting audit event: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("committing repo create: %w", err) + } + return &r, nil +} + +// List returns all repos in the current team scope, ordered by name. +// Last-sync fields are populated when non-null. The credential name is +// returned but raw secret material is never loaded here. +func (s *Store) List(ctx context.Context) ([]Repo, error) { + teamID := auth.TeamIDFromContext(ctx) + + rows, err := s.DB.QueryContext(ctx, + `SELECT id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, last_sync_sha, last_sync_at, + last_sync_error, created_at, updated_at + FROM git_repos WHERE team_id = $1 ORDER BY name`, + teamID, + ) + if err != nil { + return nil, fmt.Errorf("listing repos: %w", err) + } + defer rows.Close() + + var repos []Repo + for rows.Next() { + var r Repo + var lastSyncAt sql.NullTime + var lastSyncSHA, lastSyncError sql.NullString + if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, + &r.PollInterval, &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &lastSyncSHA, &lastSyncAt, &lastSyncError, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning repo: %w", err) + } + if lastSyncSHA.Valid { + r.LastSyncSHA = lastSyncSHA.String + } + if lastSyncAt.Valid { + t := lastSyncAt.Time + r.LastSyncAt = &t + } + if lastSyncError.Valid { + r.LastSyncError = lastSyncError.String + } + repos = append(repos, r) + } + return repos, rows.Err() +} + +// UpdateParams captures the mutable fields of a repo. Name and URL are +// intentionally omitted — changing either requires delete + recreate so +// that audit history clearly reflects the identity change. +type UpdateParams struct { + Branch string + Path string + PollInterval string + Credential string + AutoApply bool + Prune bool + Enabled bool +} + +// Update replaces the mutable fields of a repo by name and emits a +// repo.updated audit event in the same transaction. Empty Branch/Path +// are treated as "keep existing" — the SQL preserves the stored value +// rather than overwriting it with an empty string. +func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, error) { + if err := ValidatePollInterval(p.PollInterval); err != nil { + return nil, err + } + if p.Credential == "" { + return nil, fmt.Errorf("credential is required") + } + + teamID := auth.TeamIDFromContext(ctx) + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("starting transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var r Repo + var lastSyncAt sql.NullTime + var lastSyncSHA, lastSyncError sql.NullString + err = tx.QueryRowContext(ctx, + `UPDATE git_repos + SET branch = CASE WHEN $3 = '' THEN branch ELSE $3 END, + path = CASE WHEN $4 = '' THEN path ELSE $4 END, + poll_interval = $5, credential = $6, + auto_apply = $7, prune = $8, enabled = $9, updated_at = NOW() + WHERE name = $1 AND team_id = $2 + RETURNING id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, last_sync_sha, last_sync_at, + last_sync_error, created_at, updated_at`, + name, teamID, p.Branch, p.Path, p.PollInterval, p.Credential, + p.AutoApply, p.Prune, p.Enabled, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, + &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &lastSyncSHA, &lastSyncAt, &lastSyncError, + &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %q", ErrNotFound, name) + } + if err != nil { + return nil, fmt.Errorf("updating repo %q: %w", name, err) + } + if lastSyncSHA.Valid { + r.LastSyncSHA = lastSyncSHA.String + } + if lastSyncAt.Valid { + t := lastSyncAt.Time + r.LastSyncAt = &t + } + if lastSyncError.Valid { + r.LastSyncError = lastSyncError.String + } + + if err := audit.EmitTx(ctx, tx, audit.Event{ + Timestamp: time.Now(), + Actor: s.actor(), + Action: audit.ActionRepoUpdated, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": name}, + }); err != nil { + return nil, fmt.Errorf("emitting audit event: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("committing repo update: %w", err) + } + return &r, nil +} + +// Delete removes a repo by name and emits a repo.removed audit event in +// the same transaction. Returns ErrNotFound when no row matches. +func (s *Store) Delete(ctx context.Context, name string) error { + teamID := auth.TeamIDFromContext(ctx) + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("starting transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var deletedID string + err = tx.QueryRowContext(ctx, + `DELETE FROM git_repos WHERE name = $1 AND team_id = $2 RETURNING id`, + name, teamID, + ).Scan(&deletedID) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %q", ErrNotFound, name) + } + if err != nil { + return fmt.Errorf("deleting repo %q: %w", name, err) + } + + if err := audit.EmitTx(ctx, tx, audit.Event{ + Timestamp: time.Now(), + Actor: s.actor(), + Action: audit.ActionRepoRemoved, + Resource: audit.Resource{Type: "git_repo", ID: deletedID}, + TeamID: teamID, + Metadata: map[string]string{"name": name}, + }); err != nil { + return fmt.Errorf("emitting audit event: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing repo delete: %w", err) + } + return nil +} + +// validateURL rejects URLs that embed any userinfo (user or user:pass). +// GitHub PATs are valid as a username in HTTPS URLs, so even a bare +// "user@host" form must be rejected. Operators must store credential +// material in a "git" secret and reference it via the Credential field, +// never inline in the URL, so we don't risk persisting or displaying +// tokens that live in git_repos.url. +func validateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid url %q: %w", raw, err) + } + if u.User != nil { + return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead") + } + return nil +} + +// Get retrieves a repo by name within the current team scope. Returns +// ErrNotFound when no row matches. +func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { + teamID := auth.TeamIDFromContext(ctx) + + var r Repo + var lastSyncAt sql.NullTime + var lastSyncSHA, lastSyncError sql.NullString + err := s.DB.QueryRowContext(ctx, + `SELECT id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, last_sync_sha, last_sync_at, + last_sync_error, created_at, updated_at + FROM git_repos WHERE name = $1 AND team_id = $2`, + name, teamID, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, + &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &lastSyncSHA, &lastSyncAt, &lastSyncError, + &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %q", ErrNotFound, name) + } + if err != nil { + return nil, fmt.Errorf("querying repo: %w", err) + } + if lastSyncSHA.Valid { + r.LastSyncSHA = lastSyncSHA.String + } + if lastSyncAt.Valid { + t := lastSyncAt.Time + r.LastSyncAt = &t + } + if lastSyncError.Valid { + r.LastSyncError = lastSyncError.String + } + return &r, nil +} diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go new file mode 100644 index 0000000..2ca3fbb --- /dev/null +++ b/packages/engine/internal/repo/store_test.go @@ -0,0 +1,295 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "os" + "testing" + "time" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// setupTestDB spins up a Postgres container, runs migrations, and returns +// the connection. Copied from internal/environment/store_test.go to keep +// both packages decoupled from a shared test helper. +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + ctx := context.Background() + pgContainer, err := postgres.Run(ctx, + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + if os.Getenv("CI") != "" { + t.Fatalf("Could not start Postgres container (CI): %v", err) + } + t.Skipf("Could not start Postgres container: %v", err) + } + t.Cleanup(func() { _ = pgContainer.Terminate(ctx) }) + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("ConnectionString: %v", err) + } + database, err := db.Open(config.DatabaseConfig{URL: connStr}) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := db.Migrate(ctx, database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return database +} + +func newTestStore(t *testing.T) *Store { + t.Helper() + return &Store{DB: setupTestDB(t), Actor: "test"} +} + +// defaultCtx returns the default single-tenant test context. TeamIDFromContext +// returns auth.DefaultTeamID when no authenticated user is present, which +// matches the FK default on git_repos. +func defaultCtx() context.Context { + return context.Background() +} + +func TestStore_Create_PersistsRow(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + + r, err := store.Create(ctx, CreateParams{ + Name: "acme", + URL: "https://github.com/acme/workflows.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "github-pat", + AutoApply: true, + Prune: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if r.ID == "" { + t.Error("expected generated ID") + } + if r.Name != "acme" { + t.Errorf("Name: got %q, want %q", r.Name, "acme") + } + if !r.Enabled { + t.Error("Enabled should default to true") + } +} + +func TestStore_Create_RejectsDuplicateName(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + base := CreateParams{ + Name: "dup", + URL: "https://example.com/a.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + } + if _, err := store.Create(ctx, base); err != nil { + t.Fatalf("first Create: %v", err) + } + if _, err := store.Create(ctx, base); err == nil { + t.Error("expected duplicate-name error") + } +} + +func TestStore_Create_ValidatesName(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Create(ctx, CreateParams{ + Name: "Bad Name!", + URL: "https://example.com/a.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + }) + if err == nil { + t.Error("expected name validation error") + } +} + +func TestStore_Create_ValidatesPollInterval(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Create(ctx, CreateParams{ + Name: "slow", + URL: "https://example.com/a.git", + Branch: "main", + Path: "/", + PollInterval: "5s", + Credential: "pat", + }) + if err == nil { + t.Error("expected poll_interval floor error") + } +} + +func TestStore_Get_ReturnsRow(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + created, err := store.Create(ctx, CreateParams{ + Name: "acme", + URL: "https://github.com/acme/workflows.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + got, err := store.Get(ctx, "acme") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID != created.ID { + t.Errorf("ID: got %q, want %q", got.ID, created.ID) + } +} + +func TestStore_Get_NotFound(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Get(ctx, "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestStore_List_ReturnsAllReposForTeam(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + for _, name := range []string{"zeta", "alpha", "mike"} { + if _, err := store.Create(ctx, CreateParams{ + Name: name, + URL: "https://example.com/" + name + ".git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + }); err != nil { + t.Fatalf("Create %s: %v", name, err) + } + } + repos, err := store.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(repos) != 3 { + t.Fatalf("len: got %d, want 3", len(repos)) + } + // ORDER BY name — alpha, mike, zeta. + want := []string{"alpha", "mike", "zeta"} + for i, r := range repos { + if r.Name != want[i] { + t.Errorf("index %d: got %q, want %q", i, r.Name, want[i]) + } + } +} + +func TestStore_Update_ReplacesMutableFields(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + if _, err := store.Create(ctx, CreateParams{ + Name: "acme", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", AutoApply: true, Prune: true, + }); err != nil { + t.Fatalf("Create: %v", err) + } + updated, err := store.Update(ctx, "acme", UpdateParams{ + Branch: "release", Path: "/workflows", PollInterval: "120s", + Credential: "pat-v2", AutoApply: false, Prune: false, Enabled: true, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Branch != "release" || updated.Path != "/workflows" || + updated.PollInterval != "120s" || updated.Credential != "pat-v2" || + updated.AutoApply || updated.Prune { + t.Errorf("Update did not persist fields: %+v", updated) + } +} + +func TestStore_Update_NotFound(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Update(ctx, "nope", UpdateParams{ + Branch: "main", Path: "/", PollInterval: "60s", Credential: "pat", + }) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestStore_Delete_RemovesRow(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + if _, err := store.Create(ctx, CreateParams{ + Name: "acme", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", + }); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.Delete(ctx, "acme"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Get(ctx, "acme"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestStore_Delete_NotFound(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + if err := store.Delete(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestStore_Create_RejectsURLWithPassword(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Create(ctx, CreateParams{ + Name: "leaky", URL: "https://user:secret@github.com/acme/wf.git", + Branch: "main", Path: "/", PollInterval: "60s", Credential: "pat", + }) + if err == nil { + t.Error("expected url-credential rejection") + } +} + +func TestStore_Create_RejectsURLWithUsernameOnly(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + // GitHub PATs are valid in the username position for HTTPS — we still reject + // them so no credential material ever lives in git_repos.url. + _, err := store.Create(ctx, CreateParams{ + Name: "leaky2", URL: "https://acme@github.com/acme/wf.git", + Branch: "main", Path: "/", PollInterval: "60s", Credential: "pat", + }) + if err == nil { + t.Error("expected url-credential rejection for username-only URL") + } +} diff --git a/packages/engine/internal/repo/types.go b/packages/engine/internal/repo/types.go new file mode 100644 index 0000000..7978e7a --- /dev/null +++ b/packages/engine/internal/repo/types.go @@ -0,0 +1,74 @@ +// Package repo manages registered GitOps source repositories (issue #16). +// Each Repo references encrypted auth material in the credentials table +// by name and stores last-sync state for observability. The sync engine +// itself (file discovery, validate/plan/apply pipeline) lives in a +// separate package and is out of scope for this package. +package repo + +import ( + "fmt" + "regexp" + "time" +) + +// maxRepoNameLength caps names at the DNS label limit (RFC 1035) — same +// rationale as environments: names embed into log lines, metric labels, +// and URL path segments without escaping. +const maxRepoNameLength = 63 + +// validRepoNamePattern enforces DNS-label-like names: lowercase +// alphanumerics, underscores, and hyphens, starting and ending with an +// alphanumeric. Single-character names (pure alnum) are allowed. +var validRepoNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`) + +// Repo represents a registered git repository that Mantle pulls workflow +// definitions from. +type Repo struct { + ID string + Name string + URL string + Branch string + Path string + PollInterval string // Go duration literal, e.g., "60s" + Credential string // name of the git-type credential row + AutoApply bool + Prune bool + Enabled bool + LastSyncSHA string + LastSyncAt *time.Time + LastSyncError string + CreatedAt time.Time + UpdatedAt time.Time +} + +// ValidateName returns an error when name violates the allowed pattern +// or length. Exported because the CLI validates the flag before calling +// into the store for faster feedback on typos. +func ValidateName(name string) error { + if name == "" { + return fmt.Errorf("repo name is required") + } + if len(name) > maxRepoNameLength { + return fmt.Errorf("invalid repo name %q: length %d exceeds %d-char cap", + name, len(name), maxRepoNameLength) + } + if !validRepoNamePattern.MatchString(name) { + return fmt.Errorf("invalid repo name %q: must match %s", + name, validRepoNamePattern.String()) + } + return nil +} + +// ValidatePollInterval returns an error when interval cannot be parsed +// as a Go duration or is below the 10-second minimum. The floor exists +// to prevent operators from hammering their git provider's rate limits. +func ValidatePollInterval(interval string) error { + d, err := time.ParseDuration(interval) + if err != nil { + return fmt.Errorf("invalid poll_interval %q: %w", interval, err) + } + if d < 10*time.Second { + return fmt.Errorf("poll_interval %q below 10s minimum", interval) + } + return nil +} diff --git a/packages/engine/internal/secret/types.go b/packages/engine/internal/secret/types.go index e954ddd..b259a88 100644 --- a/packages/engine/internal/secret/types.go +++ b/packages/engine/internal/secret/types.go @@ -14,8 +14,9 @@ type FieldDef struct { // CredentialType defines the schema for a credential type. type CredentialType struct { - Name string - Fields []FieldDef + Name string + Fields []FieldDef + RequireAtLeastOne []string // at least one of these field names must be non-empty } // Validate checks that all required fields are present in the data map. @@ -28,6 +29,19 @@ func (ct *CredentialType) Validate(data map[string]string) error { } } } + if len(ct.RequireAtLeastOne) > 0 { + anySet := false + for _, name := range ct.RequireAtLeastOne { + if v, ok := data[name]; ok && v != "" { + anySet = true + break + } + } + if !anySet { + return fmt.Errorf("credential type %q requires at least one of: %s", + ct.Name, strings.Join(ct.RequireAtLeastOne, ", ")) + } + } return nil } @@ -86,6 +100,16 @@ var builtinTypes = map[string]*CredentialType{ {Name: "client_key", Required: false}, }, }, + "git": { + Name: "git", + Fields: []FieldDef{ + {Name: "token", Required: false}, + {Name: "ssh_key", Required: false}, + {Name: "username", Required: false}, + }, + // At-least-one validator below guarantees we have auth material. + RequireAtLeastOne: []string{"token", "ssh_key"}, + }, } // GetType returns the credential type definition, or an error if unknown. diff --git a/packages/engine/internal/secret/types_test.go b/packages/engine/internal/secret/types_test.go index 923c5ff..9fec174 100644 --- a/packages/engine/internal/secret/types_test.go +++ b/packages/engine/internal/secret/types_test.go @@ -41,7 +41,7 @@ func TestGetType_Unknown(t *testing.T) { } func TestGetType_AllBuiltins(t *testing.T) { - for _, name := range []string{"generic", "bearer", "openai", "basic"} { + for _, name := range []string{"generic", "bearer", "openai", "basic", "git"} { ct, err := GetType(name) if err != nil { t.Errorf("GetType(%q) error: %v", name, err) @@ -76,3 +76,52 @@ func TestCredentialType_Docker(t *testing.T) { t.Errorf("full TLS data should be valid: %v", err) } } + +func TestGitCredentialType_Fields(t *testing.T) { + ct, err := GetType("git") + if err != nil { + t.Fatalf("GetType(\"git\") error: %v", err) + } + want := map[string]bool{ + "token": false, + "ssh_key": false, + "username": false, + } + got := map[string]bool{} + for _, f := range ct.Fields { + got[f.Name] = f.Required + } + if len(got) != len(want) { + t.Fatalf("git type fields: got %v, want %v", got, want) + } + for name, required := range want { + if gotReq, ok := got[name]; !ok { + t.Errorf("git type missing field %q", name) + } else if gotReq != required { + t.Errorf("field %q required: got %v, want %v", name, gotReq, required) + } + } +} + +func TestGitCredentialType_ValidateRequiresTokenOrSSHKey(t *testing.T) { + ct, err := GetType("git") + if err != nil { + t.Fatalf("GetType(\"git\") error: %v", err) + } + // Neither token nor ssh_key — should fail. + if err := ct.Validate(map[string]string{"username": "git"}); err == nil { + t.Error("expected error when both token and ssh_key are empty") + } + // Explicitly empty strings for both — should also fail. + if err := ct.Validate(map[string]string{"token": "", "ssh_key": ""}); err == nil { + t.Error("expected error when token and ssh_key are both present but empty") + } + // token-only — should succeed. + if err := ct.Validate(map[string]string{"token": "ghp_xxx"}); err != nil { + t.Errorf("token-only: unexpected error %v", err) + } + // ssh_key-only — should succeed. + if err := ct.Validate(map[string]string{"ssh_key": "----BEGIN KEY----"}); err != nil { + t.Errorf("ssh_key-only: unexpected error %v", err) + } +}