Skip to content

Commit ae7254f

Browse files
feat: git sync foundation (Plan A of issue #16) (#133)
* feat(secret): add git credential type Adds the 'git' credential type with token, ssh_key, and username fields, plus a RequireAtLeastOne validator ensuring token or ssh_key is always present. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(audit): add repo.{added,updated,removed} actions * feat(db): add 019_git_repos migration * feat(repo): add Repo struct and validators Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(repo): add Store.Create with audit emission Implements the first CRUD method on the repo.Store type. Create validates name and poll_interval before touching the database, inserts the row, emits a repo.added audit event inside the same transaction, and returns the persisted Repo struct. Four integration tests cover the happy path, duplicate name rejection, and both validation guards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(repo): add Store.Get with nullable last-sync scan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(repo): add Store.List Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(repo): add Store.Update for mutable fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(repo): add Store.Delete with audit emission Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(config): add git_sync.repos block Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cli): add mantle repos root command Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cli): add mantle repos add Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cli): add mantle repos list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cli): add mantle repos status Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): add mantle repos remove Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document mantle repos CLI and git_sync config * fix: address pre-push reviewer feedback for git sync foundation - Clarify that Plan A ships registry + CLI only (sync engine lands later) - Rewrite --prune flag help to state the positive behavior - Mark webhook_secret as sensitive in Repo struct - Reject repo URLs that embed credentials inline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(repo): reject any userinfo in URL, not just passwords GitHub PATs are valid as a username in HTTPS URLs (user@host), so the previous check (hasPassword only) left a bypass. Now any u.User != nil is rejected. Renamed TestStore_Create_AllowsURLWithUsernameOnly to _RejectsURLWithUsernameOnly and flipped the assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(repo): include last_sync_* and webhook_secret in Update RETURNING clause The Update method was returning a partial Repo that silently dropped last_sync_sha/at/error and webhook_secret. The RETURNING clause now mirrors Get's SELECT list, and the subsequent scan populates the nullable fields using sql.NullString/NullTime with the same pattern as Get. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(repo): preserve existing Branch/Path in Update when caller passes empty Empty Branch/Path in UpdateParams previously overwrote the stored value with an empty string. Now a CASE expression in the SQL keeps the stored value when the caller passes ''. This is equivalent to Option B (preserve-on-empty) without a second round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migration): add ON DELETE RESTRICT to git_repos.team_id FK Without an explicit action the FK defaulted to NO ACTION (functionally RESTRICT but implicit). Adding RESTRICT makes the intent unambiguous: deleting a team with registered repos is a hard error, not silent cascade. Operators must remove repos before removing the team. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(config): validate git_sync.repos entries in Load Load now iterates cfg.GitSync.Repos and validates each entry: - repo.ValidateName for the name field - url is required and must not embed userinfo (same rule as store.Create) - repo.ValidatePollInterval when poll_interval is non-empty No import cycle: internal/repo production code does not import internal/config (only repo's _test.go does, which is excluded from the dependency graph). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migration): rework git_repos enabled index to composite (team_id, enabled) Rename idx_git_repos_enabled to idx_git_repos_team_enabled and make it a composite (team_id, enabled) partial index. Queries that filter by team and enabled status now hit a single index instead of intersecting two. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): add Long+Example to repos status/list; move repos to admin group; comment GitSync env binding - repos status and repos list were missing Long descriptions and Example blocks; add both, styled to match repos add. - newReposCommand belongs with admin/gitops infra config, not the workflow lifecycle group; move it to the admin group in root.go. - Add inline comment on GitSyncConfig.Repos explaining why no BindEnv calls are present (Viper cannot bind list-valued env vars). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: cover Path/PollInterval/Prune in GitSync config test; add empty-string git cred case - TestLoadConfig_GitSyncRepos was only asserting Name, URL, Branch, Credential, AutoApply. Add explicit checks for Path, PollInterval, and Prune so the full parsed struct is verified. - TestGitCredentialType_ValidateRequiresTokenOrSSHKey gains a case for {"token": "", "ssh_key": ""} — present-but-empty values must also fail the RequireAtLeastOne check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(test): tighten empty-state assertion; extract seedRepo helper - TestReposList_EmptyState: replace strings.Contains with an exact strings.TrimSpace equality check so stray whitespace or extra output would surface as a failure rather than passing silently. - Extract the repeated four-line "seed a repo via add" block into a seedRepo(t, cfg, name) helper; replace all four inline copies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(repo): tighten validRepoNamePattern to reject trailing hyphens and underscores Change from ^[a-z0-9][a-z0-9_-]*$ to ^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$ so that names like "my-" or "repo_" are rejected. The updated pattern requires the last character to be alphanumeric while still permitting single-character names (pure alnum). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(config): inline git_sync validators to break test import cycle The config → repo → auth chain caused `go test` to fail in both ./internal/auth and ./internal/repo with "import cycle not allowed in test". Fix by inlining validateGitSyncName, validateGitSyncPollInterval, and validateGitSyncURL as unexported helpers in config.go, removing the import edge from config → repo. The canonical exported symbols in internal/repo/types.go are unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(git-sync): address PR #133 review findings Inline (required): - config.go: validate that each git_sync.repos entry has a non-empty credential; the DB has credential NOT NULL, so a missing value would fail at persistence time with a cryptic constraint error - 019_git_repos.sql: drop webhook_secret column — storing an HMAC secret as plaintext TEXT is a security risk; Plan C will add a proper migration that stores it as an encrypted credential reference - store.go, types.go: remove webhook_secret from RETURNING/SELECT queries, scans, and the Repo struct to match the updated schema Nitpicks: - audit.go: align repo action verbs with the rest of the domain: repo.added → repo.created, repo.removed → repo.deleted (constant identifiers unchanged to minimise callsite churn) - repos_test.go: remove unused context return from reposCtx; callers already discarded it with _ and pass the DB URL via --database-url flags - config_test.go: add TestLoad_GitSyncRepos_Validation with table-driven negative cases covering invalid name, missing url, embedded credentials, url with no scheme/host, missing credential, and poll_interval below 10s - config.go: tighten validateGitSyncURL to reject strings that url.Parse accepts as relative references but lack both scheme and host, unless they match the scp-style SSH format (git@host:path) - 019_git_repos.sql: add comment explaining why credential is a plain name reference rather than a FK — prevents registration before the credential exists, which is a common IaC bootstrapping pattern https://claude.ai/code/session_01MqwQDFpdQTksPDHrMi4cKB --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ac6a6dd commit ae7254f

13 files changed

Lines changed: 1474 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ mantle env update <name> # Replace inputs/env on an existing environment
8080
mantle env list # List named environments
8181
mantle env get <name> # Show environment details (env values redacted; --reveal to unredact, audited)
8282
mantle env delete <name> -y # Delete a named environment (requires --yes)
83+
mantle repos add <name> --url <url> --credential <cred> # Register a GitOps source repo
84+
mantle repos list # List registered repos with last-sync status
85+
mantle repos status <name> # Show detailed repo status
86+
mantle repos remove <name> -y # Unregister a repo (requires --yes)
8387
mantle run <wf> --values f.yaml # Run with a values file (inputs + env overrides)
8488
mantle run <wf> --env <name> # Run against a stored named environment
8589
mantle plan <wf> --env <name> # Plan; appends resolved inputs/env with source
@@ -91,6 +95,27 @@ mantle serve # Start persistent server
9195
- **Workflow inputs** (consumed by `inputs.<name>` in CEL): `--input` flags > `--values` file `inputs:` > `--env` named-environment `inputs` > workflow definition `default`
9296
- **Env vars** (consumed by `env.<KEY>` in CEL): `MANTLE_ENV_*` OS vars > `--values` file `env:` > `--env` named-environment `env` > config `env:` section in `mantle.yaml`
9397

98+
## GitOps Config
99+
100+
Register repos in `mantle.yaml` under `git_sync.repos`:
101+
102+
```yaml
103+
git_sync:
104+
repos:
105+
- name: acme
106+
url: https://github.com/acme/workflows.git
107+
branch: main
108+
path: /
109+
poll_interval: 60s
110+
credential: github-pat # must reference a secret of type: git
111+
auto_apply: true
112+
prune: true
113+
```
114+
115+
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.
116+
117+
> 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.
118+
94119
## License
95120

96121
BSL/SSPL-style — source available, no commercial resale of forks.

packages/engine/internal/audit/audit.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ const (
6161
ActionEnvironmentUpdated Action = "environment.updated"
6262
ActionEnvironmentDeleted Action = "environment.deleted"
6363
ActionEnvironmentRevealed Action = "environment.revealed"
64+
65+
// Git repo operations.
66+
ActionRepoAdded Action = "repo.created"
67+
ActionRepoUpdated Action = "repo.updated"
68+
ActionRepoRemoved Action = "repo.deleted"
6469
)
6570

6671
// Resource identifies the target of an audit event.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"text/tabwriter"
6+
7+
"github.com/dvflw/mantle/internal/config"
8+
"github.com/dvflw/mantle/internal/db"
9+
"github.com/dvflw/mantle/internal/repo"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
// newReposCommand returns the "repos" subcommand for managing registered
14+
// GitOps source repositories (issue #16). Subcommands handle registration,
15+
// listing, detailed status, and removal. Sync behavior lives in Plan B.
16+
func newReposCommand() *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "repos",
19+
Short: "Manage GitOps workflow source repositories",
20+
Long: `Registers GitOps source repositories whose workflow YAML definitions will be
21+
synced into this Mantle instance. This command manages the registry; the sync
22+
engine itself (sidecar, file discovery, validate/plan/apply) ships in a later
23+
milestone. Auth material is stored in a "git" credential type
24+
(` + "`mantle secrets create --type git`" + `) and referenced here by name.`,
25+
}
26+
cmd.AddCommand(newReposAddCommand())
27+
cmd.AddCommand(newReposListCommand())
28+
cmd.AddCommand(newReposStatusCommand())
29+
cmd.AddCommand(newReposRemoveCommand())
30+
return cmd
31+
}
32+
33+
// newRepoStore builds a repo.Store from the current command context.
34+
func newRepoStore(cmd *cobra.Command) (*repo.Store, func(), error) {
35+
cfg := config.FromContext(cmd.Context())
36+
if cfg == nil {
37+
return nil, nil, fmt.Errorf("config not loaded")
38+
}
39+
database, err := db.Open(cfg.Database)
40+
if err != nil {
41+
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
42+
}
43+
store := &repo.Store{DB: database, Actor: "cli"}
44+
cleanup := func() { database.Close() }
45+
return store, cleanup, nil
46+
}
47+
48+
func newReposAddCommand() *cobra.Command {
49+
var url, branch, path, pollInterval, credential string
50+
var autoApply, prune bool
51+
52+
cmd := &cobra.Command{
53+
Use: "add <name>",
54+
Short: "Register a new GitOps source repo",
55+
Long: `Registers a new repository to sync workflow definitions from. The named
56+
credential must already exist and be of type "git".`,
57+
Example: ` mantle repos add acme --url https://github.com/acme/workflows.git --credential github-pat
58+
mantle repos add staging --url git@github.com:acme/wf.git --credential github-ssh --branch release`,
59+
Args: cobra.ExactArgs(1),
60+
RunE: func(cmd *cobra.Command, args []string) error {
61+
store, cleanup, err := newRepoStore(cmd)
62+
if err != nil {
63+
return err
64+
}
65+
defer cleanup()
66+
67+
r, err := store.Create(cmd.Context(), repo.CreateParams{
68+
Name: args[0],
69+
URL: url,
70+
Branch: branch,
71+
Path: path,
72+
PollInterval: pollInterval,
73+
Credential: credential,
74+
AutoApply: autoApply,
75+
Prune: prune,
76+
})
77+
if err != nil {
78+
return err
79+
}
80+
fmt.Fprintf(cmd.OutOrStdout(), "Added repo %q (%s)\n", r.Name, r.ID)
81+
return nil
82+
},
83+
}
84+
85+
cmd.Flags().StringVar(&url, "url", "", "Git repository URL (required)")
86+
cmd.Flags().StringVar(&branch, "branch", "main", "Branch to sync")
87+
cmd.Flags().StringVar(&path, "path", "/", "Subdirectory inside the repo to scan")
88+
cmd.Flags().StringVar(&pollInterval, "poll-interval", "60s", "Interval between syncs (Go duration, min 10s)")
89+
cmd.Flags().StringVar(&credential, "credential", "", "Git credential name (required)")
90+
cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes (false = plan-only)")
91+
cmd.Flags().BoolVar(&prune, "prune", true, "When true, workflows deleted from the repo are disabled in Mantle")
92+
_ = cmd.MarkFlagRequired("url")
93+
_ = cmd.MarkFlagRequired("credential")
94+
95+
return cmd
96+
}
97+
98+
func newReposStatusCommand() *cobra.Command {
99+
return &cobra.Command{
100+
Use: "status <name>",
101+
Short: "Show detailed status for a registered repo",
102+
Long: `Displays all persisted fields for a registered repository, including the
103+
URL, branch, polling interval, credential reference, enable state, and the
104+
outcome of the most recent sync attempt (SHA, timestamp, and any error).`,
105+
Example: ` mantle repos status acme
106+
mantle repos status staging --database-url postgres://localhost/mantle`,
107+
Args: cobra.ExactArgs(1),
108+
RunE: func(cmd *cobra.Command, args []string) error {
109+
store, cleanup, err := newRepoStore(cmd)
110+
if err != nil {
111+
return err
112+
}
113+
defer cleanup()
114+
115+
r, err := store.Get(cmd.Context(), args[0])
116+
if err != nil {
117+
return err
118+
}
119+
out := cmd.OutOrStdout()
120+
fmt.Fprintf(out, "Name: %s\n", r.Name)
121+
fmt.Fprintf(out, "ID: %s\n", r.ID)
122+
fmt.Fprintf(out, "URL: %s\n", r.URL)
123+
fmt.Fprintf(out, "Branch: %s\n", r.Branch)
124+
fmt.Fprintf(out, "Path: %s\n", r.Path)
125+
fmt.Fprintf(out, "Poll: %s\n", r.PollInterval)
126+
fmt.Fprintf(out, "Credential: %s\n", r.Credential)
127+
fmt.Fprintf(out, "Auto-Apply: %t\n", r.AutoApply)
128+
fmt.Fprintf(out, "Prune: %t\n", r.Prune)
129+
fmt.Fprintf(out, "Enabled: %t\n", r.Enabled)
130+
if r.LastSyncAt != nil {
131+
fmt.Fprintf(out, "Last Sync: %s (SHA %s)\n",
132+
r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC"), r.LastSyncSHA)
133+
} else {
134+
fmt.Fprintln(out, "Last Sync: (never)")
135+
}
136+
if r.LastSyncError != "" {
137+
fmt.Fprintf(out, "Last Error: %s\n", r.LastSyncError)
138+
}
139+
return nil
140+
},
141+
}
142+
}
143+
144+
func newReposRemoveCommand() *cobra.Command {
145+
var yes bool
146+
cmd := &cobra.Command{
147+
Use: "remove <name>",
148+
Short: "Unregister a GitOps source repo",
149+
Long: `Unregisters a repo. Any previously applied workflows remain in place — this
150+
command only stops future syncs. Requires --yes to confirm.`,
151+
Args: cobra.ExactArgs(1),
152+
RunE: func(cmd *cobra.Command, args []string) error {
153+
if !yes {
154+
return fmt.Errorf("refusing to remove %q without --yes", args[0])
155+
}
156+
store, cleanup, err := newRepoStore(cmd)
157+
if err != nil {
158+
return err
159+
}
160+
defer cleanup()
161+
162+
if err := store.Delete(cmd.Context(), args[0]); err != nil {
163+
return err
164+
}
165+
fmt.Fprintf(cmd.OutOrStdout(), "Removed repo %q\n", args[0])
166+
return nil
167+
},
168+
}
169+
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Confirm deletion (required)")
170+
return cmd
171+
}
172+
173+
func newReposListCommand() *cobra.Command {
174+
return &cobra.Command{
175+
Use: "list",
176+
Short: "List all registered GitOps repos",
177+
Long: `Prints a table of all repositories registered for the current team, ordered
178+
by name. Columns include the repo URL, target branch, auto-apply flag, enable
179+
state, and timestamp of the last successful sync.`,
180+
Example: ` mantle repos list
181+
mantle repos list --database-url postgres://localhost/mantle`,
182+
Args: cobra.NoArgs,
183+
RunE: func(cmd *cobra.Command, args []string) error {
184+
store, cleanup, err := newRepoStore(cmd)
185+
if err != nil {
186+
return err
187+
}
188+
defer cleanup()
189+
190+
repos, err := store.List(cmd.Context())
191+
if err != nil {
192+
return err
193+
}
194+
if len(repos) == 0 {
195+
fmt.Fprintln(cmd.OutOrStdout(), "(no repos)")
196+
return nil
197+
}
198+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
199+
fmt.Fprintln(w, "NAME\tURL\tBRANCH\tAUTO-APPLY\tENABLED\tLAST SYNC")
200+
for _, r := range repos {
201+
last := "(never)"
202+
if r.LastSyncAt != nil {
203+
last = r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC")
204+
}
205+
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%t\t%s\n",
206+
r.Name, r.URL, r.Branch, r.AutoApply, r.Enabled, last)
207+
}
208+
return w.Flush()
209+
},
210+
}
211+
}

0 commit comments

Comments
 (0)