Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/engine-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,23 @@ jobs:
# GO-2026-4946 (crypto/x509: policy-validation DoS) — EmailSendConnector→crypto/tls→crypto/x509
# GO-2026-4947 (crypto/x509: certificate chain-building DoS) — EmailSendConnector→crypto/tls→crypto/x509
# Remove these five exclusions once go-version in this workflow is >= 1.25.9.
#
# go-git v5 transitive dependency vulnerabilities — "your code does not appear to call
# the vulnerable code" per govulncheck. No upstream fix available; go-git is a direct
# dependency of the sync engine (feat/git-sync-engine). Track upstream for fixes.
# GO-2026-4918, GO-2026-4945, GO-2026-5013, GO-2026-5015, GO-2026-5017,
# GO-2026-5018, GO-2026-5019, GO-2026-5020, GO-2026-5021, GO-2026-5026
OUTPUT=$(govulncheck ./... 2>&1 || true)
echo "$OUTPUT"
# Emit each actionable vulnerability ID as a GHA error annotation for visibility.
ALL_IDS=$(echo "$OUTPUT" | grep -oE 'GO-[0-9]+-[0-9]+' | sort -u || true)
echo "govulncheck IDs found: ${ALL_IDS:-none}"
ACTIONABLE_IDS=$(echo "$ALL_IDS" | grep -vE '^(GO-2026-4887|GO-2026-4883|GO-2026-4865|GO-2026-4869|GO-2026-4870|GO-2026-4946|GO-2026-4947)$' || true)
KNOWN='GO-2026-4887|GO-2026-4883|GO-2026-4865|GO-2026-4869|GO-2026-4870|GO-2026-4946|GO-2026-4947|GO-2026-4918|GO-2026-4945|GO-2026-5013|GO-2026-5015|GO-2026-5017|GO-2026-5018|GO-2026-5019|GO-2026-5020|GO-2026-5021|GO-2026-5026'
ACTIONABLE_IDS=$(echo "$ALL_IDS" | grep -vE "^($KNOWN)$" || true)
for ID in $ACTIONABLE_IDS; do
echo "::error file=go.mod,line=1,title=Unfixed vulnerability::$ID — add to exclusion list or upgrade the affected dependency"
done
ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE 'GO-2026-4887|GO-2026-4883|GO-2026-4865|GO-2026-4869|GO-2026-4870|GO-2026-4946|GO-2026-4947' || true)
ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE "$KNOWN" || true)
if [ -n "$ACTIONABLE" ]; then
exit 1
fi
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ mantle repos list # List registered repos with last-sync status
mantle repos status <name> # Show detailed repo status
mantle repos remove <name> -y # Unregister a repo (requires --yes)
mantle repos sync <name> # Force an immediate sync of a registered repo
mantle repos plan <name> # Dry-run: show what a sync would change
mantle repos apply <name> # Manual sync (for auto_apply:false repos)
mantle repos update <name> --credential <cred> # Update a repo's mutable fields
mantle run <wf> --values f.yaml # Run with a values file (inputs + env overrides)
mantle run <wf> --env <name> # Run against a stored named environment
mantle plan <wf> --env <name> # Plan; appends resolved inputs/env with source
Expand Down Expand Up @@ -115,7 +118,7 @@ git_sync:

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 shipped the repo registry and CLI. Plan B ships the sync engine — when `mantle serve` runs, it reconciles this block into `git_repos` rows and then polls each enabled `auto_apply: true` repo every `poll_interval`, applying any changed workflow YAML. Force an immediate sync with `mantle repos sync <name>`.
> **GitOps is fully shipped.** `mantle serve` reconciles this block into `git_repos` rows, then polls each enabled `auto_apply: true` repo every `poll_interval`. Workflows removed from the repo are disabled automatically when `prune: true`. Force an on-demand sync via `mantle repos sync <name>`, preview with `mantle repos plan <name>`, or manually apply via `mantle repos apply <name>`. Register push webhooks from GitHub / GitLab / Gitea pointing at `POST /hooks/git/<repo-id>` with `webhook_secret` set on the repo to trigger immediate syncs — Mantle verifies the HMAC signature via `X-Hub-Signature-256`.

## License

Expand Down
2 changes: 2 additions & 0 deletions packages/engine/internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const (
ActionGitSyncFailed Action = "git.sync.failed"
ActionGitSyncValidationFailed Action = "git.sync.validation_failed"
ActionGitSyncApplyFailed Action = "git.sync.apply_failed"
ActionGitSyncPruned Action = "git.sync.pruned"
ActionGitPushReceived Action = "git.push.received"
)

// Resource identifies the target of an audit event.
Expand Down
207 changes: 175 additions & 32 deletions packages/engine/internal/cli/repos.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,20 @@ func newReposCommand() *cobra.Command {
Short: "Manage GitOps workflow source repositories",
Long: `Registers GitOps source repositories whose workflow YAML definitions are
synced into this Mantle instance. This command manages the registry; use
` + "`mantle repos sync`" + ` for on-demand syncs, or start ` + "`mantle serve`" + ` to let the
background poller run. Auth material is stored in a "git" credential type
(` + "`mantle secrets create --type git`" + `) and referenced here by name.`,
` + "`mantle repos sync`" + ` for ad-hoc syncs, ` + "`mantle repos plan`" + ` to preview changes
without writing, ` + "`mantle repos apply`" + ` for manual control over auto_apply:false
repos, or start ` + "`mantle serve`" + ` to let the background poller run. Auth material
is stored in a "git" credential type (` + "`mantle secrets create --type git`" + `) and
referenced here by name.`,
}
cmd.AddCommand(newReposAddCommand())
cmd.AddCommand(newReposUpdateCommand())
cmd.AddCommand(newReposListCommand())
cmd.AddCommand(newReposStatusCommand())
cmd.AddCommand(newReposRemoveCommand())
cmd.AddCommand(newReposSyncCommand())
cmd.AddCommand(newReposPlanCommand())
cmd.AddCommand(newReposApplyCommand())
return cmd
}

Expand All @@ -51,7 +56,7 @@ func newRepoStore(cmd *cobra.Command) (*repo.Store, func(), error) {
}

func newReposAddCommand() *cobra.Command {
var url, branch, path, pollInterval, credential string
var url, branch, path, pollInterval, credential, webhookSecret string
var autoApply, prune bool

cmd := &cobra.Command{
Expand All @@ -70,14 +75,15 @@ credential must already exist and be of type "git".`,
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,
Name: args[0],
URL: url,
Branch: branch,
Path: path,
PollInterval: pollInterval,
Credential: credential,
AutoApply: autoApply,
Prune: prune,
WebhookSecret: webhookSecret,
})
if err != nil {
return err
Expand All @@ -94,6 +100,8 @@ credential must already exist and be of type "git".`,
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.Flags().StringVar(&webhookSecret, "webhook-secret", "",
"HMAC secret for push webhook verification (empty = no verification)")
_ = cmd.MarkFlagRequired("url")
_ = cmd.MarkFlagRequired("credential")

Expand Down Expand Up @@ -168,7 +176,16 @@ command only stops future syncs. Requires --yes to confirm.`,
}
defer cleanup()

if err := store.Delete(cmd.Context(), args[0]); err != nil {
cfg := config.FromContext(cmd.Context())
cloneBase := ""
if cfg != nil {
artifactBase := cfg.Storage.Path
if artifactBase == "" {
artifactBase = filepath.Join(os.TempDir(), "mantle-artifacts")
}
cloneBase = filepath.Join(artifactBase, "git")
}
if err := store.Delete(cmd.Context(), args[0], cloneBase); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Removed repo %q\n", args[0])
Expand Down Expand Up @@ -219,6 +236,51 @@ state, and timestamp of the last successful sync.`,
}
}

func newReposUpdateCommand() *cobra.Command {
var branch, path, pollInterval, credential, webhookSecret string
var autoApply, prune, enabled bool

cmd := &cobra.Command{
Use: "update <name>",
Short: "Update a registered repo's mutable fields",
Long: `Replaces the branch, path, poll-interval, credential, auto-apply, prune,
and enabled flags on an existing repo. URL and name are immutable — to
change them, delete and recreate.
All flags must be provided — update replaces the full set of mutable fields.
Omitting a flag resets that field to its default value.`,
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.Update(cmd.Context(), args[0], repo.UpdateParams{
Branch: branch, Path: path, PollInterval: pollInterval,
Credential: credential, AutoApply: autoApply, Prune: prune, Enabled: enabled,
WebhookSecret: webhookSecret,
})
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Updated repo %q\n", r.Name)
return nil
},
}
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")
cmd.Flags().StringVar(&credential, "credential", "", "Git credential name (required)")
cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes")
cmd.Flags().BoolVar(&prune, "prune", true, "Disable workflows deleted from the repo")
cmd.Flags().BoolVar(&enabled, "enabled", true, "Whether the repo is active")
cmd.Flags().StringVar(&webhookSecret, "webhook-secret", "",
"HMAC secret for push webhook verification (empty = no verification)")
_ = cmd.MarkFlagRequired("credential")
return cmd
}

func newReposSyncCommand() *cobra.Command {
var fromDir string
cmd := &cobra.Command{
Expand All @@ -240,25 +302,7 @@ of cloning (useful for tests and CI-driven deployments).`,
if err != nil {
return err
}
var driver sync.Driver
if fromDir != "" {
driver = &sync.NoopDriver{BasePath: fromDir}
} else {
var secretResolver *secret.Resolver
if cfg := config.FromContext(cmd.Context()); cfg != nil && cfg.Encryption.Key != "" {
encryptor, encErr := secret.NewEncryptor(cfg.Encryption.Key)
if encErr != nil {
return fmt.Errorf("configuring encryption for git sync: %w", encErr)
}
secretResolver = &secret.Resolver{
Store: &secret.Store{DB: store.DB, Encryptor: encryptor},
}
}
driver = &sync.GoGitDriver{
BasePath: filepath.Join(os.TempDir(), "mantle-repos"),
Auth: sync.NewAuthResolver(cmd.Context(), secretResolver),
}
}
driver := buildSyncDriver(cmd, store, fromDir)
report, err := sync.SyncRepo(cmd.Context(), store.DB, store, r, driver)
if err != nil {
return fmt.Errorf("sync %q: %w", r.Name, err)
Expand All @@ -276,3 +320,102 @@ of cloning (useful for tests and CI-driven deployments).`,
cmd.Flags().StringVar(&fromDir, "from-dir", "", "Path to an already-cloned repo directory (skips git pull; useful for CI pipelines or local testing)")
return cmd
}

func newReposPlanCommand() *cobra.Command {
var fromDir string
cmd := &cobra.Command{
Use: "plan <name>",
Short: "Dry-run: show what a sync would change",
Long: `Plans a sync without writing anything: pulls the repo, discovers files,
and classifies each as "would apply" (new or changed) or "unchanged". Use
before mantle repos apply to preview pending changes before they land.`,
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
}
driver := buildSyncDriver(cmd, store, fromDir)
report, err := sync.PlanRepo(cmd.Context(), store.DB, r, driver)
if err != nil {
return fmt.Errorf("plan %q: %w", r.Name, err)
}
fmt.Fprintf(cmd.OutOrStdout(),
"Plan for %s — would apply=%d unchanged=%d failures=%d (SHA %s)\n",
r.Name, report.Applied, report.Unchanged, len(report.Failures), report.SHA)
for _, f := range report.Failures {
fmt.Fprintf(cmd.ErrOrStderr(), " FAIL %s: %s\n", f.RelPath, f.Err)
}
return nil
},
}
cmd.Flags().StringVar(&fromDir, "from-dir", "", "Path to an already-cloned repo directory (skips git pull; useful for CI pipelines or local testing)")
return cmd
}

func newReposApplyCommand() *cobra.Command {
var fromDir string
cmd := &cobra.Command{
Use: "apply <name>",
Short: "Apply pending workflow changes from a repo",
Long: `Runs one sync pass for the named repo. Useful when auto_apply is false and you
want explicit control over when workflow YAML lands in Mantle. Also works for
auto_apply:true repos when you want to apply immediately without waiting for
the next poll.`,
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
}
driver := buildSyncDriver(cmd, store, fromDir)
report, err := sync.SyncRepo(cmd.Context(), store.DB, store, r, driver)
if err != nil {
return fmt.Errorf("apply %q: %w", r.Name, err)
}
fmt.Fprintf(cmd.OutOrStdout(),
"Applied %s — applied=%d unchanged=%d failures=%d (SHA %s)\n",
r.Name, report.Applied, report.Unchanged, len(report.Failures), report.SHA)
for _, f := range report.Failures {
fmt.Fprintf(cmd.ErrOrStderr(), " FAIL %s: %s\n", f.RelPath, f.Err)
}
return nil
},
}
cmd.Flags().StringVar(&fromDir, "from-dir", "", "Path to an already-cloned repo directory (skips git pull; useful for CI pipelines or local testing)")
return cmd
}

// buildSyncDriver picks NoopDriver when --from-dir is set, otherwise a
// GoGitDriver rooted at a temp path. Extracted from newReposSyncCommand so
// plan and apply don't duplicate the branching logic.
func buildSyncDriver(cmd *cobra.Command, store *repo.Store, fromDir string) sync.Driver {
if fromDir != "" {
return &sync.NoopDriver{BasePath: fromDir}
}
var secretResolver *secret.Resolver
if cfg := config.FromContext(cmd.Context()); cfg != nil && cfg.Encryption.Key != "" {
encryptor, encErr := secret.NewEncryptor(cfg.Encryption.Key)
if encErr == nil {
secretResolver = &secret.Resolver{
Store: &secret.Store{DB: store.DB, Encryptor: encryptor},
}
}
}
return &sync.GoGitDriver{
BasePath: filepath.Join(os.TempDir(), "mantle-repos"),
Auth: sync.NewAuthResolver(cmd.Context(), secretResolver),
}
}
Loading