diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml index a9ba551f..a883fcb8 100644 --- a/.github/workflows/engine-ci.yml +++ b/.github/workflows/engine-ci.yml @@ -68,22 +68,29 @@ jobs: # # Go standard library vulnerabilities fixed in go1.25.9 / go1.26.2 (released 2026-04-07). # Reachable in go < 1.25.9 via the listed call chains: - # GO-2026-4865 (html/template: XSS via JS template literals) — grpc→net/trace→html/template - # GO-2026-4869 (archive/tar: unbounded memory in sparse GNU tar archives) — docker/docker→archive/tar - # GO-2026-4870 (crypto/tls: TLS 1.3 key-update deadlock) — EmailSendConnector→crypto/tls - # 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 + # GO-2026-4865 (html/template) — grpc->net/trace->html/template + # GO-2026-4869 (archive/tar) — docker/docker->archive/tar + # GO-2026-4870 (crypto/tls) — EmailSendConnector->crypto/tls + # GO-2026-4946 (crypto/x509) — EmailSendConnector->crypto/tls->crypto/x509 + # GO-2026-4947 (crypto/x509) — EmailSendConnector->crypto/tls->crypto/x509 # Remove these five exclusions once go-version in this workflow is >= 1.25.9. + # + # Transitive dependencies of go-git (github.com/go-git/go-git/v5); govulncheck + # confirms code does not call the affected symbols. Track upstream and remove + # when go-git ships fixed transitive dependency versions: + # 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 diff --git a/CLAUDE.md b/CLAUDE.md index 4e3b5f71..7a973556 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ mantle repos add --url --credential # Register a GitOps sou 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 repos sync # Force an immediate sync of a registered repo 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 @@ -114,7 +115,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 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. +> 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 `. ## License diff --git a/packages/engine/go.mod b/packages/engine/go.mod index 924c1f6c..02a13c9d 100644 --- a/packages/engine/go.mod +++ b/packages/engine/go.mod @@ -14,6 +14,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 github.com/coreos/go-oidc/v3 v3.17.0 github.com/docker/docker v28.5.2+incompatible + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/go-git/go-git/v5 v5.18.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/cel-go v0.27.0 github.com/googleapis/gax-go/v2 v2.15.0 @@ -26,6 +28,7 @@ require ( github.com/testcontainers/testcontainers-go v0.41.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 golang.org/x/oauth2 v0.35.0 + golang.org/x/term v0.41.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -42,10 +45,8 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect - github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/PuerkitoBio/purell v1.1.1 // indirect - github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect @@ -65,30 +66,31 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.19.6 // indirect - github.com/go-openapi/spec v0.20.4 // indirect - github.com/go-openapi/swag v0.19.15 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -97,12 +99,12 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mailru/easyjson v0.7.6 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect @@ -116,6 +118,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -124,17 +127,19 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil/v4 v4.26.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/swaggo/swag v1.16.6 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect @@ -147,18 +152,15 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect google.golang.org/api v0.247.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/packages/engine/go.sum b/packages/engine/go.sum index 37c0c9c1..32d6d6e3 100644 --- a/packages/engine/go.sum +++ b/packages/engine/go.sum @@ -34,16 +34,17 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= @@ -94,6 +95,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -109,9 +112,10 @@ github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSw github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -127,12 +131,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= @@ -144,6 +152,16 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -153,20 +171,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= -github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= -github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= -github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= @@ -194,8 +204,10 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -215,10 +227,6 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -247,13 +255,16 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -281,12 +292,17 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -303,15 +319,14 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= -github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais= github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 h1:AOtFXssrDlLm84A2sTTR/AhvJiYbrIuCO59d+Ro9Tb0= @@ -320,6 +335,8 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -355,17 +372,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= @@ -379,9 +395,11 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -410,8 +428,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= @@ -429,15 +445,14 @@ google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/packages/engine/internal/audit/audit.go b/packages/engine/internal/audit/audit.go index dfc7433c..502ffd43 100644 --- a/packages/engine/internal/audit/audit.go +++ b/packages/engine/internal/audit/audit.go @@ -66,6 +66,12 @@ const ( ActionRepoAdded Action = "repo.created" ActionRepoUpdated Action = "repo.updated" ActionRepoRemoved Action = "repo.deleted" + + ActionGitSyncStarted Action = "git.sync.started" + ActionGitSyncCompleted Action = "git.sync.completed" + ActionGitSyncFailed Action = "git.sync.failed" + ActionGitSyncValidationFailed Action = "git.sync.validation_failed" + ActionGitSyncApplyFailed Action = "git.sync.apply_failed" ) // Resource identifies the target of an audit event. diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go index 7fe69b70..56401e59 100644 --- a/packages/engine/internal/cli/repos.go +++ b/packages/engine/internal/cli/repos.go @@ -2,11 +2,15 @@ package cli import ( "fmt" + "os" + "path/filepath" "text/tabwriter" "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" "github.com/dvflw/mantle/internal/repo" + "github.com/dvflw/mantle/internal/repo/sync" + "github.com/dvflw/mantle/internal/secret" "github.com/spf13/cobra" ) @@ -17,16 +21,17 @@ 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 + 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.`, } cmd.AddCommand(newReposAddCommand()) cmd.AddCommand(newReposListCommand()) cmd.AddCommand(newReposStatusCommand()) cmd.AddCommand(newReposRemoveCommand()) + cmd.AddCommand(newReposSyncCommand()) return cmd } @@ -124,7 +129,11 @@ outcome of the most recent sync attempt (SHA, timestamp, and any error).`, 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) + if r.AutoApply { + fmt.Fprintln(out, "Auto-Apply: true") + } else { + fmt.Fprintln(out, "Auto-Apply: false (manual sync only — background poller does not run this repo)") + } fmt.Fprintf(out, "Prune: %t\n", r.Prune) fmt.Fprintf(out, "Enabled: %t\n", r.Enabled) if r.LastSyncAt != nil { @@ -209,3 +218,61 @@ state, and timestamp of the last successful sync.`, }, } } + +func newReposSyncCommand() *cobra.Command { + var fromDir string + cmd := &cobra.Command{ + Use: "sync ", + Short: "Force an immediate sync of a registered repo", + Long: `Runs one sync pass for the named repo: pulls the remote, walks the +configured path, and applies each workflow YAML. Returns after the sync +completes. Use --from-dir to point at a pre-populated directory instead +of cloning (useful for tests and CI-driven deployments).`, + 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 + } + 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), + } + } + report, err := sync.SyncRepo(cmd.Context(), store.DB, store, r, driver) + if err != nil { + return fmt.Errorf("sync %q: %w", r.Name, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Synced %s — applied=%d unchanged=%d failures=%d (SHA %s)\n", + r.Name, report.Applied, report.Unchanged, len(report.Failures), report.SHA) + if len(report.Failures) > 0 { + 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 +} diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go index 078247d9..a263abaf 100644 --- a/packages/engine/internal/cli/repos_test.go +++ b/packages/engine/internal/cli/repos_test.go @@ -174,3 +174,41 @@ func TestReposRemove_DeletesRow(t *testing.T) { t.Errorf("unexpected stdout: %q", stdout.String()) } } + +func TestReposSync_UsesNoopDriverWithFromDir(t *testing.T) { + cfg := reposCtx(t) + // Seed a repo. + { + root := NewRootCommand() + var seedStderr bytes.Buffer + root.SetErr(&seedStderr) + root.SetArgs([]string{"repos", "add", "acme", + "--url", "https://example.com/a.git", + "--credential", "pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) + } + } + + // Pre-populate a fixture dir. NoopDriver creates BasePath// + // and the sync engine walks r.Path (default "/") inside that dir. + // An empty dir yields Applied=0 Unchanged=0 — valid outcome. + fixture := t.TempDir() + + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "sync", "acme", + "--from-dir", fixture, + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("sync: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "Synced acme") { + t.Errorf("expected 'Synced acme' in stdout, got %q", stdout.String()) + } +} diff --git a/packages/engine/internal/cli/serve.go b/packages/engine/internal/cli/serve.go index 7fdb2ff3..973ab34d 100644 --- a/packages/engine/internal/cli/serve.go +++ b/packages/engine/internal/cli/serve.go @@ -3,7 +3,9 @@ package cli import ( "context" "fmt" + "os" "os/signal" + "path/filepath" "syscall" "time" @@ -15,6 +17,8 @@ import ( "github.com/dvflw/mantle/internal/db" "github.com/dvflw/mantle/internal/engine" "github.com/dvflw/mantle/internal/logging" + "github.com/dvflw/mantle/internal/repo" + reposync "github.com/dvflw/mantle/internal/repo/sync" "github.com/dvflw/mantle/internal/secret" "github.com/dvflw/mantle/internal/server" "github.com/spf13/cobra" @@ -139,6 +143,38 @@ func newServeCommand() *cobra.Command { ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGTERM, syscall.SIGINT) defer stop() + // Reconcile git_sync repos from config into the DB. Errors are fatal + // at startup — a config mismatch must be fixed before the server runs. + repoStore := &repo.Store{DB: database, Actor: "server"} + if err := reposync.Reconcile(ctx, repoStore, cfg.GitSync.Repos); err != nil { + return fmt.Errorf("reconciling git_sync repos: %w", err) + } + + // Launch the git sync poller. It exits cleanly when ctx is cancelled. + artifactBase := cfg.Storage.Path + if artifactBase == "" { + artifactBase = filepath.Join(os.TempDir(), "mantle-artifacts") + } + var secretResolver *secret.Resolver + if 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: database, Encryptor: encryptor}, + } + } + poller := &reposync.Poller{ + DB: database, + Store: repoStore, + Driver: &reposync.GoGitDriver{ + BasePath: filepath.Join(artifactBase, "git"), + Auth: reposync.NewAuthResolver(ctx, secretResolver), + }, + } + go poller.Run(ctx) + fmt.Fprintf(cmd.OutOrStdout(), "Mantle server starting on %s\n", cfg.API.Address) return srv.Start(ctx) }, diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index 3246d930..3ee27f31 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -272,12 +272,33 @@ func (s *Store) Delete(ctx context.Context, name string) error { 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. +// UpdateSyncState records the outcome of a sync attempt. syncErr should +// be the empty string on success — it is stored as NULL so the +// LastSyncError field on Repo stays empty after a clean sync, even if +// an earlier attempt had failed. +func (s *Store) UpdateSyncState(ctx context.Context, id, sha, syncErr string) error { + var errVal interface{} + if syncErr == "" { + errVal = nil + } else { + errVal = syncErr + } + _, err := s.DB.ExecContext(ctx, + `UPDATE git_repos + SET last_sync_sha = $2, last_sync_at = NOW(), last_sync_error = $3 + WHERE id = $1`, + id, sha, errVal, + ) + if err != nil { + return fmt.Errorf("updating sync state for %s: %w", id, err) + } + return nil +} + +// validateURL rejects URLs that embed credentials inline (the `user:pass@host` +// form). Operators must put 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 { diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go index 2ca3fbb8..aa27731e 100644 --- a/packages/engine/internal/repo/store_test.go +++ b/packages/engine/internal/repo/store_test.go @@ -293,3 +293,51 @@ func TestStore_Create_RejectsURLWithUsernameOnly(t *testing.T) { t.Error("expected url-credential rejection for username-only URL") } } + +func TestStore_UpdateSyncState_WritesFields(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + r, err := store.Create(ctx, CreateParams{ + Name: "acme", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.UpdateSyncState(ctx, r.ID, "deadbeef", "pull failed"); err != nil { + t.Fatalf("UpdateSyncState: %v", err) + } + got, err := store.Get(ctx, "acme") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.LastSyncSHA != "deadbeef" { + t.Errorf("LastSyncSHA: got %q, want deadbeef", got.LastSyncSHA) + } + if got.LastSyncAt == nil { + t.Error("LastSyncAt should be set") + } + if got.LastSyncError != "pull failed" { + t.Errorf("LastSyncError: got %q, want %q", got.LastSyncError, "pull failed") + } +} + +func TestStore_UpdateSyncState_ClearsErrorWhenEmpty(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + r, err := store.Create(ctx, CreateParams{ + Name: "clean", URL: "https://example.com/b.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + _ = store.UpdateSyncState(ctx, r.ID, "abc", "earlier error") + if err := store.UpdateSyncState(ctx, r.ID, "def", ""); err != nil { + t.Fatalf("UpdateSyncState: %v", err) + } + got, _ := store.Get(ctx, "clean") + if got.LastSyncError != "" { + t.Errorf("LastSyncError should clear to empty, got %q", got.LastSyncError) + } +} diff --git a/packages/engine/internal/repo/sync/auth.go b/packages/engine/internal/repo/sync/auth.go new file mode 100644 index 00000000..99e62c4b --- /dev/null +++ b/packages/engine/internal/repo/sync/auth.go @@ -0,0 +1,54 @@ +package sync + +import ( + "context" + "fmt" + + "github.com/dvflw/mantle/internal/secret" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +// authLookup is a function that resolves a credential name to its field map. +// Defined as a type so tests can inject a fake without needing a live DB. +type authLookup func(ctx context.Context, name string) (map[string]string, error) + +// NewAuthResolver returns a callback suitable for GoGitDriver.Auth. The +// resolver looks up a credential by name via the provided resolver, then +// converts the git-type fields into a go-git AuthMethod. +// +// Only HTTPS tokens are supported in this release. SSH keys return an +// explicit error so operators see a clear message instead of a silent +// authentication failure — SSH support lands in Plan C. +func NewAuthResolver(ctx context.Context, resolver *secret.Resolver) func(string) (transport.AuthMethod, error) { + if resolver == nil { + return newAuthResolverFromLookup(ctx, nil) + } + return newAuthResolverFromLookup(ctx, resolver.Resolve) +} + +// newAuthResolverFromLookup builds the Auth callback from any authLookup +// function. It is the testable core; NewAuthResolver adapts the production +// secret.Resolver to this interface. +func newAuthResolverFromLookup(ctx context.Context, lookup authLookup) func(string) (transport.AuthMethod, error) { + return func(name string) (transport.AuthMethod, error) { + if lookup == nil { + return nil, fmt.Errorf("secret resolver not configured") + } + fields, err := lookup(ctx, name) + if err != nil { + return nil, fmt.Errorf("resolving credential %q: %w", name, err) + } + if token := fields["token"]; token != "" { + username := fields["username"] + if username == "" { + username = "x-token-auth" + } + return &http.BasicAuth{Username: username, Password: token}, nil + } + if fields["ssh_key"] != "" { + return nil, fmt.Errorf("ssh_key credentials are not supported yet; use a token credential for now (SSH auth ships in Plan C)") + } + return nil, fmt.Errorf("credential %q has no token or ssh_key", name) + } +} diff --git a/packages/engine/internal/repo/sync/auth_test.go b/packages/engine/internal/repo/sync/auth_test.go new file mode 100644 index 00000000..3599f687 --- /dev/null +++ b/packages/engine/internal/repo/sync/auth_test.go @@ -0,0 +1,121 @@ +package sync + +import ( + "context" + "errors" + "testing" + + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +func TestNewAuthResolver(t *testing.T) { + ctx := context.Background() + + t.Run("token credential returns BasicAuth with default username", func(t *testing.T) { + lookup := func(_ context.Context, name string) (map[string]string, error) { + return map[string]string{"token": "ghp_secret123"}, nil + } + fn := newAuthResolverFromLookup(ctx, lookup) + auth, err := fn("my-cred") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + basic, ok := auth.(*http.BasicAuth) + if !ok { + t.Fatalf("expected *http.BasicAuth, got %T", auth) + } + if basic.Password != "ghp_secret123" { + t.Errorf("password = %q, want %q", basic.Password, "ghp_secret123") + } + if basic.Username != "x-token-auth" { + t.Errorf("username = %q, want %q (default)", basic.Username, "x-token-auth") + } + }) + + t.Run("token credential with explicit username preserves it", func(t *testing.T) { + lookup := func(_ context.Context, name string) (map[string]string, error) { + return map[string]string{"token": "ghp_secret123", "username": "mybot"}, nil + } + fn := newAuthResolverFromLookup(ctx, lookup) + auth, err := fn("my-cred") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + basic, ok := auth.(*http.BasicAuth) + if !ok { + t.Fatalf("expected *http.BasicAuth, got %T", auth) + } + if basic.Username != "mybot" { + t.Errorf("username = %q, want %q", basic.Username, "mybot") + } + }) + + t.Run("ssh_key credential returns unsupported error", func(t *testing.T) { + lookup := func(_ context.Context, name string) (map[string]string, error) { + return map[string]string{"ssh_key": "-----BEGIN OPENSSH PRIVATE KEY-----"}, nil + } + fn := newAuthResolverFromLookup(ctx, lookup) + _, err := fn("my-ssh-cred") + if err == nil { + t.Fatal("expected error for ssh_key credential, got nil") + } + if !containsString(err.Error(), "ssh_key credentials are not supported") { + t.Errorf("error = %q, want SSH not supported message", err.Error()) + } + }) + + t.Run("credential with no token or ssh_key returns error", func(t *testing.T) { + lookup := func(_ context.Context, name string) (map[string]string, error) { + return map[string]string{"username": "someone"}, nil + } + fn := newAuthResolverFromLookup(ctx, lookup) + _, err := fn("empty-cred") + if err == nil { + t.Fatal("expected error for empty credential, got nil") + } + if !containsString(err.Error(), "no token or ssh_key") { + t.Errorf("error = %q, want 'no token or ssh_key' message", err.Error()) + } + }) + + t.Run("nil lookup returns resolver-not-configured error", func(t *testing.T) { + fn := newAuthResolverFromLookup(ctx, nil) + _, err := fn("any-cred") + if err == nil { + t.Fatal("expected error for nil resolver, got nil") + } + if !containsString(err.Error(), "secret resolver not configured") { + t.Errorf("error = %q, want 'secret resolver not configured'", err.Error()) + } + }) + + t.Run("lookup error is wrapped and returned", func(t *testing.T) { + lookup := func(_ context.Context, name string) (map[string]string, error) { + return nil, errors.New("credential not found") + } + fn := newAuthResolverFromLookup(ctx, lookup) + _, err := fn("missing-cred") + if err == nil { + t.Fatal("expected error, got nil") + } + if !containsString(err.Error(), "resolving credential") { + t.Errorf("error = %q, want wrapping 'resolving credential'", err.Error()) + } + if !containsString(err.Error(), "credential not found") { + t.Errorf("error = %q, want wrapped 'credential not found'", err.Error()) + } + }) +} + +// containsString is a small helper to avoid importing strings in test assertions. +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + func() bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false + }()) +} diff --git a/packages/engine/internal/repo/sync/discover.go b/packages/engine/internal/repo/sync/discover.go new file mode 100644 index 00000000..44bef61c --- /dev/null +++ b/packages/engine/internal/repo/sync/discover.go @@ -0,0 +1,96 @@ +package sync + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// DiscoveredFile is one YAML file found by Discover. +type DiscoveredFile struct { + RelPath string // path relative to the scan root (BasePath + Path) + Bytes []byte // file contents + Hash string // sha256 hex of Bytes +} + +// Discover walks scanRoot/subPath and returns every .yaml / .yml file +// inside it (recursively). Files outside subPath are ignored. The scan +// is single-threaded and reads each file into memory — workflow YAML +// files are small and the total repo size is bounded by the sync +// engine's upstream guardrails. +// +// Returns an error when subPath doesn't resolve to a directory under +// scanRoot so callers can distinguish "repo layout changed" from "no +// workflows found." +func Discover(scanRoot, subPath string) ([]DiscoveredFile, error) { + absRoot, err := filepath.Abs(scanRoot) + if err != nil { + return nil, fmt.Errorf("resolving scan root: %w", err) + } + target := filepath.Join(absRoot, filepath.FromSlash(strings.TrimPrefix(subPath, "/"))) + info, err := os.Stat(target) + if err != nil { + return nil, fmt.Errorf("scanning %s: %w", target, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", target) + } + + // os.OpenRoot pins reads to the target directory, preventing symlink + // TOCTOU traversal (gosec G122 / CWE-367). + root, err := os.OpenRoot(target) + if err != nil { + return nil, fmt.Errorf("opening scan root: %w", err) + } + defer root.Close() + + var out []DiscoveredFile + err = filepath.WalkDir(target, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if !d.Type().IsRegular() { + return nil + } + if !isYAML(d.Name()) { + return nil + } + rel, relErr := filepath.Rel(target, path) + if relErr != nil { + return fmt.Errorf("computing relpath for %s: %w", path, relErr) + } + f, openErr := root.Open(rel) + if openErr != nil { + return fmt.Errorf("reading %s: %w", rel, openErr) + } + b, readErr := io.ReadAll(f) + f.Close() + if readErr != nil { + return fmt.Errorf("reading %s: %w", rel, readErr) + } + h := sha256.Sum256(b) + out = append(out, DiscoveredFile{ + RelPath: rel, + Bytes: b, + Hash: hex.EncodeToString(h[:]), + }) + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +func isYAML(name string) bool { + lower := strings.ToLower(name) + return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") +} diff --git a/packages/engine/internal/repo/sync/discover_test.go b/packages/engine/internal/repo/sync/discover_test.go new file mode 100644 index 00000000..64ed85c4 --- /dev/null +++ b/packages/engine/internal/repo/sync/discover_test.go @@ -0,0 +1,77 @@ +package sync + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiscover_CollectsYAMLFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.yaml", "name: alpha\nsteps: []\n") + writeFile(t, dir, "b.yml", "name: beta\nsteps: []\n") + writeFile(t, dir, "readme.md", "not yaml") + writeFile(t, dir, "nested/c.yaml", "name: gamma\nsteps: []\n") + + found, err := Discover(dir, "/") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(found) != 3 { + t.Fatalf("got %d files, want 3: %+v", len(found), found) + } + names := map[string]bool{} + for _, f := range found { + names[filepath.Base(f.RelPath)] = true + if f.Hash == "" { + t.Errorf("empty hash on %s", f.RelPath) + } + if len(f.Bytes) == 0 { + t.Errorf("empty bytes on %s", f.RelPath) + } + } + for _, want := range []string{"a.yaml", "b.yml", "c.yaml"} { + if !names[want] { + t.Errorf("missing %s in results", want) + } + } +} + +func TestDiscover_RespectsSubPath(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "root.yaml", "name: root\n") + writeFile(t, dir, "workflows/a.yaml", "name: a\n") + writeFile(t, dir, "workflows/b.yaml", "name: b\n") + + found, err := Discover(dir, "/workflows") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(found) != 2 { + t.Fatalf("got %d, want 2", len(found)) + } + for _, f := range found { + if filepath.Base(f.RelPath) == "root.yaml" { + t.Errorf("root.yaml should not be discovered with path=/workflows") + } + } +} + +func TestDiscover_MissingPath(t *testing.T) { + dir := t.TempDir() + _, err := Discover(dir, "/does-not-exist") + if err == nil { + t.Error("expected error for missing subpath") + } +} + +func writeFile(t *testing.T, dir, rel, content string) { + t.Helper() + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", full, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", full, err) + } +} diff --git a/packages/engine/internal/repo/sync/driver.go b/packages/engine/internal/repo/sync/driver.go new file mode 100644 index 00000000..cff423ae --- /dev/null +++ b/packages/engine/internal/repo/sync/driver.go @@ -0,0 +1,30 @@ +// Package sync implements the GitOps sync engine for issue #16. It reads +// workflow YAML files from a path a Driver populates, diffs them against +// the currently applied workflow versions, and calls workflow.Save for +// anything that changed. The engine does not care how the Driver +// populated the path — that separation lets us ship a go-git driver for +// standalone deployments, a noop driver for tests and CI-driven +// deployments, and (in a future release) a k8s sidecar driver that +// reads from a shared volume. +package sync + +import ( + "context" + + "github.com/dvflw/mantle/internal/repo" +) + +// PullResult describes the state of a repo after a Driver pull. +type PullResult struct { + LocalPath string // absolute path where the repo was checked out + SHA string // commit SHA at HEAD after the pull +} + +// Driver is the interface sync drivers implement. Pull must either leave +// a fully-checked-out working tree at LocalPath pointing at the tip of +// the configured branch, or return an error. The sync engine owns the +// lifecycle of LocalPath — drivers should place it inside the artifact +// store's git/ prefix so it survives across syncs but is reapable. +type Driver interface { + Pull(ctx context.Context, r *repo.Repo) (PullResult, error) +} diff --git a/packages/engine/internal/repo/sync/engine.go b/packages/engine/internal/repo/sync/engine.go new file mode 100644 index 00000000..acdafe7d --- /dev/null +++ b/packages/engine/internal/repo/sync/engine.go @@ -0,0 +1,166 @@ +package sync + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/dvflw/mantle/internal/audit" + "github.com/dvflw/mantle/internal/auth" + "github.com/dvflw/mantle/internal/repo" + "github.com/dvflw/mantle/internal/workflow" +) + +// Report is the outcome of a single sync pass over one repo. +type Report struct { + SHA string + Applied int + Unchanged int + Failures []FileResult +} + +// FileResult captures a per-file failure so operators can trace which +// YAML file caused trouble without having to re-run the sync. +type FileResult struct { + RelPath string + Err string +} + +// SyncRepo runs one sync cycle for r: pulls via driver, walks the +// configured path, and applies each workflow YAML. Per-file failures +// accumulate in the report but do not abort the sync — the engine's +// contract is "apply everything you can, then tell the operator what +// broke." Emits audit events for start, success, validation errors, +// and final completion. Always updates LastSyncAt on the repo, even +// on partial failure. +func SyncRepo(ctx context.Context, database *sql.DB, store *repo.Store, r *repo.Repo, driver Driver) (*Report, error) { + teamID := auth.TeamIDFromContext(ctx) + + emit(ctx, database, audit.Event{ + Timestamp: time.Now(), + Actor: "sync", + Action: audit.ActionGitSyncStarted, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": r.Name}, + }) + + pull, err := driver.Pull(ctx, r) + if err != nil { + _ = store.UpdateSyncState(ctx, r.ID, "", fmt.Sprintf("pull failed: %v", err)) + emit(ctx, database, audit.Event{ + Timestamp: time.Now(), + Actor: "sync", + Action: audit.ActionGitSyncFailed, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": r.Name, "error": err.Error()}, + }) + return nil, fmt.Errorf("driver pull: %w", err) + } + + files, err := Discover(pull.LocalPath, r.Path) + if err != nil { + _ = store.UpdateSyncState(ctx, r.ID, pull.SHA, fmt.Sprintf("discover failed: %v", err)) + emit(ctx, database, audit.Event{ + Timestamp: time.Now(), + Actor: "sync", + Action: audit.ActionGitSyncFailed, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": r.Name, "error": err.Error()}, + }) + return nil, fmt.Errorf("discover: %w", err) + } + + report := &Report{SHA: pull.SHA} + for _, f := range files { + parseResult, parseErr := workflow.ParseBytes(f.Bytes) + if parseErr != nil { + report.Failures = append(report.Failures, FileResult{RelPath: f.RelPath, Err: parseErr.Error()}) + emit(ctx, database, audit.Event{ + Timestamp: time.Now(), + Actor: "sync", + Action: audit.ActionGitSyncValidationFailed, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": r.Name, "file": f.RelPath, "error": parseErr.Error()}, + }) + continue + } + version, saveErr := workflow.Save(ctx, database, parseResult, f.Bytes) + if saveErr != nil { + report.Failures = append(report.Failures, FileResult{RelPath: f.RelPath, Err: saveErr.Error()}) + emit(ctx, database, audit.Event{ + Timestamp: time.Now(), + Actor: "sync", + Action: audit.ActionGitSyncApplyFailed, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": r.Name, "file": f.RelPath, "error": saveErr.Error()}, + }) + continue + } + if version == 0 { + report.Unchanged++ + } else { + report.Applied++ + } + } + + errSummary := "" + if len(report.Failures) > 0 { + errSummary = summarizeFailures(report.Failures) + } + _ = store.UpdateSyncState(ctx, r.ID, pull.SHA, errSummary) + + emit(ctx, database, audit.Event{ + Timestamp: time.Now(), + Actor: "sync", + Action: audit.ActionGitSyncCompleted, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{ + "name": r.Name, + "sha": pull.SHA, + "applied": fmt.Sprintf("%d", report.Applied), + "unchanged": fmt.Sprintf("%d", report.Unchanged), + "failures": fmt.Sprintf("%d", len(report.Failures)), + }, + }) + return report, nil +} + +// emit opens a short-lived transaction for the audit write. Errors are +// swallowed — an audit emission failure must not fail the sync. +func emit(ctx context.Context, database *sql.DB, e audit.Event) { + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return + } + defer tx.Rollback() //nolint:errcheck + if err := audit.EmitTx(ctx, tx, e); err != nil { + return + } + _ = tx.Commit() +} + +// summarizeFailures joins up to three failure messages into a single +// string suitable for last_sync_error. Audit events carry the full +// detail; this column is for at-a-glance CLI output. +func summarizeFailures(fs []FileResult) string { + if len(fs) == 0 { + return "" + } + var parts []string + for i, f := range fs { + if i == 3 { + parts = append(parts, fmt.Sprintf("... and %d more", len(fs)-3)) + break + } + parts = append(parts, fmt.Sprintf("%s: %s", f.RelPath, f.Err)) + } + return strings.Join(parts, "; ") +} diff --git a/packages/engine/internal/repo/sync/engine_test.go b/packages/engine/internal/repo/sync/engine_test.go new file mode 100644 index 00000000..73c16f85 --- /dev/null +++ b/packages/engine/internal/repo/sync/engine_test.go @@ -0,0 +1,160 @@ +package sync + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" + "github.com/dvflw/mantle/internal/repo" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +func setupEngineTest(t *testing.T) (*sql.DB, *repo.Store) { + t.Helper() + ctx := context.Background() + pg, 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("Postgres (CI): %v", err) + } + t.Skipf("Postgres: %v", err) + } + t.Cleanup(func() { _ = pg.Terminate(ctx) }) + connStr, _ := pg.ConnectionString(ctx, "sslmode=disable") + 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, &repo.Store{DB: database, Actor: "test"} +} + +// validWorkflowYAML returns a minimal valid workflow YAML for name. +// Must pass workflow.ParseBytes + workflow.Validate without error. +func validWorkflowYAML(name string) string { + return "name: " + name + "\nsteps:\n - name: hello\n action: http/request\n" +} + +func TestSyncRepo_AppliesNewFiles(t *testing.T) { + database, store := setupEngineTest(t) + ctx := context.Background() + r, err := store.Create(ctx, repo.CreateParams{ + Name: "acme", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", AutoApply: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + base := t.TempDir() + repoDir := filepath.Join(base, r.ID) + _ = os.MkdirAll(repoDir, 0o755) + if err := os.WriteFile(filepath.Join(repoDir, "wf1.yaml"), []byte(validWorkflowYAML("wf1")), 0o644); err != nil { + t.Fatalf("write wf1: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "wf2.yaml"), []byte(validWorkflowYAML("wf2")), 0o644); err != nil { + t.Fatalf("write wf2: %v", err) + } + + driver := &NoopDriver{BasePath: base, SHA: "sha-1"} + report, err := SyncRepo(ctx, database, store, r, driver) + if err != nil { + t.Fatalf("SyncRepo: %v", err) + } + if report.Applied != 2 { + t.Errorf("Applied: got %d, want 2", report.Applied) + } + if report.Unchanged != 0 { + t.Errorf("Unchanged: got %d, want 0", report.Unchanged) + } + if len(report.Failures) != 0 { + t.Errorf("Failures: got %+v, want none", report.Failures) + } + if report.SHA != "sha-1" { + t.Errorf("SHA: got %q, want sha-1", report.SHA) + } + + got, _ := store.Get(ctx, "acme") + if got.LastSyncSHA != "sha-1" { + t.Errorf("LastSyncSHA not recorded: %q", got.LastSyncSHA) + } + if got.LastSyncError != "" { + t.Errorf("LastSyncError should be empty, got %q", got.LastSyncError) + } +} + +func TestSyncRepo_UnchangedYieldsNoApply(t *testing.T) { + database, store := setupEngineTest(t) + ctx := context.Background() + r, _ := store.Create(ctx, repo.CreateParams{ + Name: "stable", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", AutoApply: true, + }) + base := t.TempDir() + repoDir := filepath.Join(base, r.ID) + _ = os.MkdirAll(repoDir, 0o755) + _ = os.WriteFile(filepath.Join(repoDir, "wf.yaml"), []byte(validWorkflowYAML("wf")), 0o644) + + d := &NoopDriver{BasePath: base, SHA: "first"} + if _, err := SyncRepo(ctx, database, store, r, d); err != nil { + t.Fatalf("first sync: %v", err) + } + d.SHA = "second" + report, err := SyncRepo(ctx, database, store, r, d) + if err != nil { + t.Fatalf("second sync: %v", err) + } + if report.Applied != 0 || report.Unchanged != 1 { + t.Errorf("second sync: applied=%d unchanged=%d, want 0/1", report.Applied, report.Unchanged) + } +} + +func TestSyncRepo_InvalidFileDoesNotAbortOthers(t *testing.T) { + database, store := setupEngineTest(t) + ctx := context.Background() + r, _ := store.Create(ctx, repo.CreateParams{ + Name: "mixed", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", AutoApply: true, + }) + base := t.TempDir() + repoDir := filepath.Join(base, r.ID) + _ = os.MkdirAll(repoDir, 0o755) + _ = os.WriteFile(filepath.Join(repoDir, "good.yaml"), []byte(validWorkflowYAML("good")), 0o644) + _ = os.WriteFile(filepath.Join(repoDir, "bad.yaml"), []byte("::: not yaml :::\n"), 0o644) + + d := &NoopDriver{BasePath: base, SHA: "mixed-sha"} + report, err := SyncRepo(ctx, database, store, r, d) + if err != nil { + t.Fatalf("SyncRepo should not error on per-file failures: %v", err) + } + if report.Applied != 1 { + t.Errorf("Applied: got %d, want 1", report.Applied) + } + if len(report.Failures) != 1 { + t.Errorf("Failures: got %d, want 1", len(report.Failures)) + } + got, _ := store.Get(ctx, "mixed") + if got.LastSyncError == "" { + t.Error("LastSyncError should describe the failed file") + } +} diff --git a/packages/engine/internal/repo/sync/gogit_driver.go b/packages/engine/internal/repo/sync/gogit_driver.go new file mode 100644 index 00000000..66f8a76b --- /dev/null +++ b/packages/engine/internal/repo/sync/gogit_driver.go @@ -0,0 +1,103 @@ +package sync + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/dvflw/mantle/internal/repo" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport" +) + +// GoGitDriver clones or fast-forwards the configured branch into a +// subdirectory of BasePath. Intended for standalone deployments where +// Mantle is the git client — k8s deployments should run git-sync as a +// sidecar and use NoopDriver pointing at the shared volume instead. +type GoGitDriver struct { + // BasePath is the parent directory for every cloned repo. Each + // repo.Repo gets a subdir named after its UUID so names can never + // collide and the path is stable across restarts. + BasePath string + // Auth is an optional hook that resolves the repo's credential name + // to go-git auth material (e.g., an HTTPS token or an SSH key). The + // sync engine's wiring populates this; tests leave it nil to exercise + // the anonymous-clone path against local bare repos. + Auth func(credentialName string) (transport.AuthMethod, error) +} + +// Pull clones if the directory does not exist, otherwise fetches and +// hard-resets to the tip of the configured branch. Returns the HEAD SHA +// after the operation. +func (d *GoGitDriver) Pull(ctx context.Context, r *repo.Repo) (PullResult, error) { + if d.BasePath == "" { + return PullResult{}, fmt.Errorf("GoGitDriver requires BasePath") + } + if r == nil || r.ID == "" || r.URL == "" { + return PullResult{}, fmt.Errorf("GoGitDriver requires repo with ID and URL") + } + dir := filepath.Join(d.BasePath, r.ID) + + var auth transport.AuthMethod + if d.Auth != nil && r.Credential != "" { + resolved, err := d.Auth(r.Credential) + if err != nil { + return PullResult{}, fmt.Errorf("resolving credential %q: %w", r.Credential, err) + } + auth = resolved + } + + branch := r.Branch + if branch == "" { + branch = "main" + } + + repository, err := git.PlainOpen(dir) + if errors.Is(err, git.ErrRepositoryNotExists) { + repository, err = git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ + URL: r.URL, + Auth: auth, + ReferenceName: plumbing.NewBranchReferenceName(branch), + SingleBranch: true, + Depth: 1, + }) + if err != nil { + // Clean up a partial clone so the next attempt starts fresh. + _ = os.RemoveAll(dir) + return PullResult{}, fmt.Errorf("clone: %w", err) + } + } else if err != nil { + return PullResult{}, fmt.Errorf("open existing repo: %w", err) + } else { + if fetchErr := repository.FetchContext(ctx, &git.FetchOptions{ + Auth: auth, + RefSpecs: []config.RefSpec{ + config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branch, branch)), + }, + Force: true, + }); fetchErr != nil && !errors.Is(fetchErr, git.NoErrAlreadyUpToDate) { + return PullResult{}, fmt.Errorf("fetch: %w", fetchErr) + } + wt, wtErr := repository.Worktree() + if wtErr != nil { + return PullResult{}, fmt.Errorf("worktree: %w", wtErr) + } + remoteRef, refErr := repository.Reference(plumbing.NewRemoteReferenceName("origin", branch), true) + if refErr != nil { + return PullResult{}, fmt.Errorf("resolve remote branch %q: %w", branch, refErr) + } + if resetErr := wt.Reset(&git.ResetOptions{Commit: remoteRef.Hash(), Mode: git.HardReset}); resetErr != nil { + return PullResult{}, fmt.Errorf("hard reset to %s: %w", remoteRef.Hash(), resetErr) + } + } + + head, err := repository.Head() + if err != nil { + return PullResult{}, fmt.Errorf("resolving HEAD: %w", err) + } + return PullResult{LocalPath: dir, SHA: head.Hash().String()}, nil +} diff --git a/packages/engine/internal/repo/sync/gogit_driver_test.go b/packages/engine/internal/repo/sync/gogit_driver_test.go new file mode 100644 index 00000000..42dc98bc --- /dev/null +++ b/packages/engine/internal/repo/sync/gogit_driver_test.go @@ -0,0 +1,89 @@ +package sync + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/dvflw/mantle/internal/repo" +) + +// setupBareRepo creates a bare git repo on disk, seeds one commit that +// contains workflows/wf.yaml, and returns (bareRepoPath, commitSHA). +// Uses the host's git CLI so the test doesn't depend on go-git's own +// bare-repo construction paths being right. +func setupBareRepo(t *testing.T) (string, string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git CLI not available") + } + dir := t.TempDir() + seed := filepath.Join(dir, "seed") + bare := filepath.Join(dir, "bare.git") + mustRun(t, dir, "git", "init", "-b", "main", seed) + _ = os.MkdirAll(filepath.Join(seed, "workflows"), 0o755) + if err := os.WriteFile(filepath.Join(seed, "workflows", "wf.yaml"), []byte("name: wf\n"), 0o644); err != nil { + t.Fatalf("write wf: %v", err) + } + mustRun(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "add", ".") + mustRun(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "seed") + mustRun(t, dir, "git", "clone", "--bare", seed, bare) + shaBytes, err := exec.Command("git", "-C", bare, "rev-parse", "HEAD").Output() + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + sha := string(shaBytes) + sha = sha[:len(sha)-1] // trim newline + return bare, sha +} + +func mustRun(t *testing.T, dir, bin string, args ...string) { + t.Helper() + cmd := exec.Command(bin, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v failed: %v\n%s", bin, args, err, out) + } +} + +func TestGoGitDriver_Pull_ClonesThenUpdates(t *testing.T) { + bare, initialSHA := setupBareRepo(t) + base := t.TempDir() + + d := &GoGitDriver{BasePath: base} + r := &repo.Repo{ID: "r1", URL: bare, Branch: "main"} + + // First pull: fresh clone. + got, err := d.Pull(context.Background(), r) + if err != nil { + t.Fatalf("first pull: %v", err) + } + if got.SHA != initialSHA { + t.Errorf("SHA: got %q, want %q", got.SHA, initialSHA) + } + if _, err := os.Stat(filepath.Join(got.LocalPath, "workflows", "wf.yaml")); err != nil { + t.Errorf("wf.yaml not checked out: %v", err) + } + + // Add a new commit to the source, push to bare, pull again. + seed := filepath.Join(filepath.Dir(bare), "seed") + if err := os.WriteFile(filepath.Join(seed, "workflows", "wf2.yaml"), []byte("name: wf2\n"), 0o644); err != nil { + t.Fatalf("write wf2: %v", err) + } + mustRun(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "add", ".") + mustRun(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "two") + mustRun(t, seed, "git", "push", bare, "main") + + got2, err := d.Pull(context.Background(), r) + if err != nil { + t.Fatalf("second pull: %v", err) + } + if got2.SHA == initialSHA { + t.Error("SHA did not advance after second commit") + } + if _, err := os.Stat(filepath.Join(got2.LocalPath, "workflows", "wf2.yaml")); err != nil { + t.Errorf("wf2.yaml not checked out after update: %v", err) + } +} diff --git a/packages/engine/internal/repo/sync/noop_driver.go b/packages/engine/internal/repo/sync/noop_driver.go new file mode 100644 index 00000000..abc5fc56 --- /dev/null +++ b/packages/engine/internal/repo/sync/noop_driver.go @@ -0,0 +1,35 @@ +package sync + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/dvflw/mantle/internal/repo" +) + +// NoopDriver returns a path under BasePath without fetching anything. +// Used by tests (callers pre-populate the directory with YAML fixtures) +// and by CI-driven deployments where external tooling already checks +// out the repo before Mantle reads from it. +type NoopDriver struct { + BasePath string // directory containing one subdir per repo ID + SHA string // fake SHA returned by Pull, lets tests assert it propagates +} + +// Pull returns the deterministic path BasePath/ and creates +// the directory on demand so downstream callers don't fail on os.Stat. +func (d *NoopDriver) Pull(_ context.Context, r *repo.Repo) (PullResult, error) { + if d.BasePath == "" { + return PullResult{}, fmt.Errorf("NoopDriver requires BasePath") + } + if r == nil || r.ID == "" { + return PullResult{}, fmt.Errorf("NoopDriver requires a repo with an ID") + } + path := filepath.Join(d.BasePath, r.ID) + if err := os.MkdirAll(path, 0o755); err != nil { + return PullResult{}, fmt.Errorf("creating %s: %w", path, err) + } + return PullResult{LocalPath: path, SHA: d.SHA}, nil +} diff --git a/packages/engine/internal/repo/sync/noop_driver_test.go b/packages/engine/internal/repo/sync/noop_driver_test.go new file mode 100644 index 00000000..0bc1547a --- /dev/null +++ b/packages/engine/internal/repo/sync/noop_driver_test.go @@ -0,0 +1,41 @@ +package sync + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/dvflw/mantle/internal/repo" +) + +func TestNoopDriver_Pull_ReturnsConfiguredPath(t *testing.T) { + dir := t.TempDir() + d := &NoopDriver{BasePath: dir, SHA: "abc123"} + got, err := d.Pull(context.Background(), &repo.Repo{ID: "r1"}) + if err != nil { + t.Fatalf("Pull: %v", err) + } + want := filepath.Join(dir, "r1") + if got.LocalPath != want { + t.Errorf("LocalPath: got %q, want %q", got.LocalPath, want) + } + if got.SHA != "abc123" { + t.Errorf("SHA: got %q, want %q", got.SHA, "abc123") + } + if _, err := os.Stat(got.LocalPath); err != nil { + t.Errorf("LocalPath does not exist: %v", err) + } +} + +func TestNoopDriver_Pull_CreatesDirectoryOnDemand(t *testing.T) { + dir := t.TempDir() + d := &NoopDriver{BasePath: dir} + _, err := d.Pull(context.Background(), &repo.Repo{ID: "new-one"}) + if err != nil { + t.Fatalf("Pull: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "new-one")); err != nil { + t.Errorf("expected subdir to be created: %v", err) + } +} diff --git a/packages/engine/internal/repo/sync/poller.go b/packages/engine/internal/repo/sync/poller.go new file mode 100644 index 00000000..c1935d46 --- /dev/null +++ b/packages/engine/internal/repo/sync/poller.go @@ -0,0 +1,97 @@ +package sync + +import ( + "context" + "database/sql" + "log/slog" + "sync" + "time" + + "github.com/dvflw/mantle/internal/repo" +) + +// Poller owns the background goroutines that drive auto-apply syncs. +// Construct one per process (typically from `mantle serve`) and call +// Run with a cancellable context. Run returns when ctx is done. +type Poller struct { + DB *sql.DB + Store *repo.Store + Driver Driver + + // OnSync, if set, is called after every sync attempt. Useful for + // metrics emission and tests; production wiring leaves it nil. + OnSync func(*repo.Repo, *Report, error) + + // MinInterval lets tests override the 10s minimum poll_interval + // floor. Production leaves it zero; the poller uses 10s as the + // safety floor. + MinInterval time.Duration +} + +// Run starts one goroutine per enabled, auto-apply repo. Each ticks at +// the repo's poll_interval and calls SyncRepo. Returns when ctx is +// cancelled — all child goroutines exit within one tick. +func (p *Poller) Run(ctx context.Context) { + // Run snapshots the repo list once at startup and launches one goroutine + // per enabled auto-apply repo. Repos added or reconfigured after Run + // begins will NOT be picked up until the server restarts. Live + // reconfiguration is Plan C scope — the simpler snapshot design + // keeps Plan B focused on proving the sync loop works. + repos, err := p.Store.List(ctx) + if err != nil { + slog.ErrorContext(ctx, "poller: list repos failed", "err", err) + return + } + + var wg sync.WaitGroup + for i := range repos { + r := repos[i] + if !r.Enabled || !r.AutoApply { + continue + } + wg.Add(1) + go func(r repo.Repo) { + defer wg.Done() + p.tickLoop(ctx, &r) + }(r) + } + wg.Wait() +} + +func (p *Poller) tickLoop(ctx context.Context, r *repo.Repo) { + interval, err := time.ParseDuration(r.PollInterval) + if err != nil { + slog.ErrorContext(ctx, "poller: bad poll_interval", "repo", r.Name, "value", r.PollInterval, "err", err) + return + } + // MinInterval overrides the safety floor. In production it is zero and the + // floor is 10s. In tests it is set to a small value so ticks fire quickly + // without requiring a stored interval below the store's validation minimum. + floor := p.MinInterval + if floor == 0 { + floor = 10 * time.Second + } + if interval < floor { + interval = floor + } + // When MinInterval is explicitly set (test mode) use it as the actual tick + // rate, bypassing the stored interval entirely, so timing-sensitive tests + // don't have to store sub-10s intervals that would fail store validation. + if p.MinInterval != 0 { + interval = p.MinInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + report, syncErr := SyncRepo(ctx, p.DB, p.Store, r, p.Driver) + if p.OnSync != nil { + p.OnSync(r, report, syncErr) + } + } + } +} diff --git a/packages/engine/internal/repo/sync/poller_test.go b/packages/engine/internal/repo/sync/poller_test.go new file mode 100644 index 00000000..2431669a --- /dev/null +++ b/packages/engine/internal/repo/sync/poller_test.go @@ -0,0 +1,94 @@ +package sync + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/dvflw/mantle/internal/repo" +) + +func TestPoller_TicksAndStops(t *testing.T) { + database, store := setupEngineTest(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + r, err := store.Create(ctx, repo.CreateParams{ + Name: "acme", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "10s", Credential: "pat", + AutoApply: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + base := t.TempDir() + repoDir := filepath.Join(base, r.ID) + _ = os.MkdirAll(repoDir, 0o755) + _ = os.WriteFile(filepath.Join(repoDir, "wf.yaml"), []byte(validWorkflowYAML("wf")), 0o644) + + var calls int + var mu sync.Mutex + driver := &NoopDriver{BasePath: base, SHA: "x"} + p := &Poller{ + DB: database, + Store: store, + Driver: driver, + OnSync: func(_ *repo.Repo, _ *Report, _ error) { + mu.Lock() + calls++ + mu.Unlock() + }, + MinInterval: time.Millisecond, + } + go p.Run(ctx) + + time.Sleep(250 * time.Millisecond) + cancel() + + mu.Lock() + defer mu.Unlock() + if calls < 2 { + t.Errorf("expected >=2 sync calls, got %d", calls) + } +} + +func TestPoller_SkipsDisabledAndAutoApplyFalse(t *testing.T) { + database, store := setupEngineTest(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := store.Create(ctx, repo.CreateParams{ + Name: "manual", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "10s", Credential: "pat", + AutoApply: false, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + var called bool + var mu sync.Mutex + p := &Poller{ + DB: database, + Store: store, + Driver: &NoopDriver{BasePath: t.TempDir(), SHA: "x"}, + OnSync: func(_ *repo.Repo, _ *Report, _ error) { + mu.Lock() + called = true + mu.Unlock() + }, + MinInterval: time.Millisecond, + } + go p.Run(ctx) + time.Sleep(150 * time.Millisecond) + cancel() + + mu.Lock() + defer mu.Unlock() + if called { + t.Error("poller synced an auto_apply:false repo") + } +} diff --git a/packages/engine/internal/repo/sync/reconcile.go b/packages/engine/internal/repo/sync/reconcile.go new file mode 100644 index 00000000..5d93bccf --- /dev/null +++ b/packages/engine/internal/repo/sync/reconcile.go @@ -0,0 +1,61 @@ +package sync + +import ( + "context" + "errors" + "fmt" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/repo" +) + +// Reconcile applies every entry in cfg as either a create (name new to +// the DB) or an update (name already present). Repos that exist in the +// DB but not in cfg are untouched — operators may have registered them +// via `mantle repos add` and we refuse to silently nuke their work. +func Reconcile(ctx context.Context, store *repo.Store, cfg []config.GitSyncRepo) error { + for _, entry := range cfg { + existing, err := store.Get(ctx, entry.Name) + if errors.Is(err, repo.ErrNotFound) { + _, createErr := store.Create(ctx, repo.CreateParams{ + Name: entry.Name, + URL: entry.URL, + Branch: defaultString(entry.Branch, "main"), + Path: defaultString(entry.Path, "/"), + PollInterval: defaultString(entry.PollInterval, "60s"), + Credential: entry.Credential, + AutoApply: entry.AutoApply, + Prune: entry.Prune, + }) + if createErr != nil { + return fmt.Errorf("reconcile create %q: %w", entry.Name, createErr) + } + continue + } + if err != nil { + return fmt.Errorf("reconcile lookup %q: %w", entry.Name, err) + } + _, err = store.Update(ctx, entry.Name, repo.UpdateParams{ + Branch: defaultString(entry.Branch, "main"), + Path: defaultString(entry.Path, "/"), + PollInterval: defaultString(entry.PollInterval, "60s"), + Credential: entry.Credential, + AutoApply: entry.AutoApply, + Prune: entry.Prune, + Enabled: existing.Enabled, + }) + if err != nil { + return fmt.Errorf("reconcile update %q: %w", entry.Name, err) + } + } + return nil +} + +// defaultString returns fallback when v is empty, so config entries that +// omit a field pick up the same defaults the DB schema uses. +func defaultString(v, fallback string) string { + if v == "" { + return fallback + } + return v +} diff --git a/packages/engine/internal/repo/sync/reconcile_test.go b/packages/engine/internal/repo/sync/reconcile_test.go new file mode 100644 index 00000000..f56158d8 --- /dev/null +++ b/packages/engine/internal/repo/sync/reconcile_test.go @@ -0,0 +1,67 @@ +package sync + +import ( + "context" + "testing" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/repo" +) + +func TestReconcile_CreatesNewEntries(t *testing.T) { + _, store := setupEngineTest(t) + ctx := context.Background() + + cfg := []config.GitSyncRepo{ + {Name: "from-config", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", AutoApply: true, Prune: true}, + } + if err := Reconcile(ctx, store, cfg); err != nil { + t.Fatalf("Reconcile: %v", err) + } + got, err := store.Get(ctx, "from-config") + if err != nil { + t.Fatalf("Get after reconcile: %v", err) + } + if got.URL != "https://example.com/a.git" || got.Credential != "pat" { + t.Errorf("reconciled repo wrong: %+v", got) + } +} + +func TestReconcile_UpdatesExistingByName(t *testing.T) { + _, store := setupEngineTest(t) + ctx := context.Background() + _, err := store.Create(ctx, repo.CreateParams{ + Name: "shared", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat-v1", AutoApply: true, Prune: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + cfg := []config.GitSyncRepo{ + {Name: "shared", URL: "https://example.com/a.git", Branch: "release", + Path: "/", PollInterval: "120s", Credential: "pat-v2", AutoApply: false, Prune: true}, + } + if err := Reconcile(ctx, store, cfg); err != nil { + t.Fatalf("Reconcile: %v", err) + } + got, _ := store.Get(ctx, "shared") + if got.Branch != "release" || got.Credential != "pat-v2" || got.AutoApply { + t.Errorf("reconcile did not update fields: %+v", got) + } +} + +func TestReconcile_PreservesReposNotInConfig(t *testing.T) { + _, store := setupEngineTest(t) + ctx := context.Background() + _, _ = store.Create(ctx, repo.CreateParams{ + Name: "cli-only", URL: "https://example.com/b.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", + }) + if err := Reconcile(ctx, store, nil); err != nil { + t.Fatalf("Reconcile with empty config: %v", err) + } + if _, err := store.Get(ctx, "cli-only"); err != nil { + t.Errorf("CLI-created repo should survive empty reconcile: %v", err) + } +}