diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index d6eff75..213508a 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -2726,3 +2726,26 @@ outside this repository. and OpenBao. Rollback now warns, naming the keys, when `.env` or `secrets/` changed after the target release was recorded, and says to redeploy. Not covered: OpenBao rotations (not observable from the host). + +## 2026-10-20 — git sources (D4): deferred surface + +Landed in PR #44: `git:` code sources for Teploy-home apps, per-app +credentials age-encrypted outside /deployments, the single `trigger poll` +admitter, and the minutely systemd timer. Deliberately deferred (documented +in README/CHANGELOG): + +- **Submodules**: `git.submodules: true` is refused at validation; a + repository carrying `.gitmodules` fails the build explicitly. + Credential-scoped submodule fetching needs per-submodule authorization + decisions (a submodule URL is attacker-chosen in a PR). +- **Static + git**: static Teploy-home apps with `git:` stay refused + (static deploys still need a code source — `build_remote` on a git + checkout is unbuilt plumbing). +- **Non-privileged git.repository changes**: switching to a `file://` + repository IS privileged (reads server-local paths); switching between + remote repositories is not — a non-admin editor can repoint an app at + any reachable remote and build its code. The build runs server-side + with no host mounts by default, so this is app-code authority, not host + authority; revisit if deploy hooks or host binds interact. +- **Timer auto-install**: offered best-effort after `config source key` + only (`--no-timer` skips); `teploy setup` does not install it yet. diff --git a/CHANGELOG.md b/CHANGELOG.md index c114304..fe6ca02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,59 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ### Added +- Teploy-home apps can build from a git repository: a `git:` block + (`repository`, `branch`, `paths`, `poll`, `submodules`) in the config, + `image:` becoming optional when it is set (`git-source-v1`; contracts + corpus rev 16 adds `source-show-envelope`, `source-key-envelope` and + `trigger-poll-envelope`). Works with GitHub, GitLab, Forgejo/Gitea, + Cursor Origin and any git host over SSH or HTTPS. `context:` and + `dockerfile:` are relative to the repository root; `build_secrets:` + passes age-store values to the build as `docker build --secret` + (values never enter the config, image layers, argv or logs). +- `teploy config source key --app X --host S [--type ssh|token]` + generates an ed25519 deploy key on the server (public half printed) + or stores an HTTPS token read from stdin. Credentials live + age-encrypted outside `/deployments` (Caddy cannot read them) and are + used via `GIT_SSH_COMMAND` / `GIT_ASKPASS` — never in URLs, + `.git/config` or command lines. `config source host-key` pins the + forge's SSH host key (`ssh-keyscan` on the server, fingerprint shown, + optional `--expected-fingerprint` verification); fetches run with + `StrictHostKeyChecking=yes` against it. `config source show --json` + (no secrets) and `config source remove` round out the surface. +- `teploy trigger poll` is the one admitter for git sources: dedup by + (app, ref, commit), a per-ref watermark with an ancestry check on the + server-side mirror, one recorded refusal per force-push (refused until + `teploy trigger reset --app X --ref refs/heads/`), changed + paths computed on the server and filtered by `git.paths` (defaulting + to `context:`), a failed commit retried at most twice, and the newest + admitted head always deployed (`trigger-poll-v1`). `--all` polls every + due app for the server timer; `--now --hint ` is the webhook wake + (the remote head wins over the hint). `teploy trigger retry --app X` + re-arms a failed commit. +- `teploy trigger install-timer --host S` installs an idempotent systemd + timer running `trigger poll --all` every minute (each app honors its + own `git.poll` interval from admissions timestamps; nothing per-app in + crontab); it is also offered after `config source key`. `teploy doctor` + gains a `git-trigger` check: the timer is present and its resident + binary supports `trigger poll` whenever any app has a `git:` source. +- Deploying a Teploy-home app with `git:` set (`teploy deploy --app X`) + builds the branch head from the mirror under the app lock and records + the commit as the release's `source_revision` (release provenance and + the deploy event); a manual deploy honors the same force-push refusal + and advances the watermark. +- New event kinds `trigger.admit`/`trigger.refuse` detail `reason`, and + `source.connect` records credential lifecycle (type and fingerprint + only). + +### Notes + +- Static Teploy-home apps with `git:` remain refused (static deploys + still need a code source); submodules are refused pending + implementation; `file://` repositories are accepted as a privileged + config change (same-server sources). + - One backup target per server, read at run time (`backup-target-v1`; - contracts corpus rev 14). `teploy server backup-target set --host S + contracts corpus rev 15). `teploy server backup-target set --host S --bucket B [--region] [--endpoint] [--prefix]` stores it at `/etc/teploy/backup-target.json` (0600, outside `/deployments`; `~/.config/teploy/` fallback for a non-root deploy user, said so on diff --git a/README.md b/README.md index 6daff43..839e8db 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,60 @@ local TLS/static-build paths are refused too: the app deploys to the server that stores its config. Folder deploys of a Teploy-home app are refused, and so are deploys by teploy CLIs older than this feature. +### Code from a git repository + +A Teploy-home app can build from a git repository instead of a pre-built +image — GitHub, GitLab, Forgejo/Gitea, Cursor Origin, or any git host over +SSH or HTTPS: + +```yaml +app: blog +domain: blog.example.com +git: + repository: git@github.com:me/blog.git # or https://github.com/me/blog.git + branch: main + paths: ["web/**"] # optional monorepo filter + poll: 5m # or "off": webhook wakes only +# context: ./web # optional, relative to the repo root +# dockerfile: Dockerfile # optional, relative to the context +build_secrets: [NPM_TOKEN] # age-store keys -> docker build --secret +``` + +Connect it once per app: + +``` +teploy config source key --app blog --host prod # ed25519 deploy key: + # prints the public half to register +teploy config source key --app blog --host prod --type token < token-file +teploy config source host-key --app blog --host prod # pin the forge's SSH host key +teploy config source show --app blog --host prod --json # public state, no secrets +teploy config source remove --app blog --host prod +``` + +The credential never leaves the server: keys and tokens are age-encrypted +outside `/deployments` (a compromised Caddy cannot read them), SSH fetches +use `StrictHostKeyChecking=yes` against the pinned host key, and HTTPS +tokens travel via `GIT_ASKPASS` — never in a URL, `.git/config` or a +command line. + +`teploy trigger poll` is the single admitter: it checks the branch with the +app's credential, dedups by (app, ref, commit), requires new heads to +descend from the deployed watermark (a force-push is refused, once, until +`teploy trigger reset --app blog --ref refs/heads/main`), filters changed +paths by `git.paths` (defaulting to `context:`), retries a failed commit at +most twice, and always deploys the newest admitted head. `teploy deploy +--app blog --host prod` builds the head from the server-side mirror under +the same rules. A systemd timer runs the poll for every app: + +``` +teploy trigger install-timer --host prod # teploy-trigger-poll.timer, every minute +teploy trigger poll --app blog --host prod --now # manual / webhook wake +teploy trigger retry --app blog --host prod # re-arm a failed commit +``` + +Static apps cannot use `git:` yet (static deploys still need a code +source), and submodules are refused pending implementation. + ### Secrets and env ``` teploy env set KEY=value # set env var diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index cf6cf46..686bd34 100644 --- a/contracts/MANIFEST.md +++ b/contracts/MANIFEST.md @@ -10,8 +10,10 @@ Neutron/Nucleus dependency and a public mirror. | Corpus rev | Emitting CLI | Machine Interface | Notes | |---|---|---|---| +| 16 | main (server-authority spec P3 / plan D4, git sources) | 2 | Additive. New artifacts source-show-envelope (`config source show --json`: repository/branch/poll, public credential facts — type, deploy-key public half + fingerprint, token username, pinned host-key fingerprints — and the per-ref admission state), source-key-envelope (`config source key --json` for ssh/token and `host-key --json`) and trigger-poll-envelope (`trigger poll --json`: one result object per app — ref, head, admitted, reason vocabulary incl. force-push/retry-limit/not-due, deployed_release), all fixtures GENERATED from the real DTOs. version-handshake advertises `git-source-v1` (git: code source, per-app credentials outside /deployments, build_secrets) and `trigger-poll-v1` (the one admitter + reset/retry/install-timer) (regenerated mi2.json). No MI bump. | | 15 | main (server-authority spec D3: backup target) | 2 | Additive. One backup target per server, stored outside /deployments with age-encrypted credentials and read at run time (`server backup-target set/show/unset`; `backup-target-v1`). New artifacts backup-target-envelope, backup-list-envelope, backup-create-envelope, backup-restore-envelope — fixtures GENERATED from the real DTO encoders (`backup list --json` now reports parsed rows {id, kind, accessory, size_bytes, created_at, key} plus schedules discovered from teploy cron markers; the old bare-array-of-raw-`aws s3 ls`-lines `backup list --json` shape is gone — it was never a corpus artifact and pinned no consumer). `backup create/restore --json` envelopes; restore quiesces the app under its fenced lock (backup-json-v1). version-handshake advertises `backup-target-v1` and `backup-json-v1` (regenerated mi2.json). No MI bump. | | 14 | main (server-authority plan D2, folderless reads) | 2 | Additive. New artifacts releases-envelope (`teploy releases --app X --host S --json`: container release records newest first with current/rollback_available (+reason); static apps list retained releases — both via the envelope, the folder-static legacy bare array is unchanged), rollback-result (`teploy rollback [--to X] --json`: {app, server, from, to, ok}; failures keep the error envelope) and redeploy-result (`teploy redeploy [--json]`: {app, server, home, release, image, ok}). Fixtures GENERATED from the real DTOs. version-handshake advertises `secret-stdin-v1` (secret set KEY --stdin; folderless secret set/get/list/rm/rotate), `releases-json-v1`, `rollback-json-v1`, `redeploy-v1` (fresh deploy of the current release's recorded image, env re-resolved) and `env-config-guard-v1` (env set/unset refuse keys a Teploy-home config declares) (regenerated mi2.json). No MI bump. || 13 | main (server-authority spec P2 support) | 2 | Additive. New artifact config-merge-envelope (`teploy config merge --base --file - --json`: merged config + conflicting keys, read-only), fixture GENERATED from the real configMergeDTO. version-handshake advertises `config-merge-v1`, `config-unprivileged-v1` (`config ... --unprivileged` refuses host-reaching changes) and `deploy-home-v1` (`deploy --app X --image T --home teploy` creates a new Teploy-home app) (regenerated mi2.json). No MI bump. | +| 13 | main (server-authority spec P2 support) | 2 | Additive. New artifact config-merge-envelope (`teploy config merge --base --file - --json`: merged config + conflicting keys, read-only), fixture GENERATED from the real configMergeDTO. version-handshake advertises `config-merge-v1`, `config-unprivileged-v1` (`config ... --unprivileged` refuses host-reaching changes) and `deploy-home-v1` (`deploy --app X --image T --home teploy` creates a new Teploy-home app) (regenerated mi2.json). No MI bump. | | 12 | main (server-authority spec P1 tail) | 2 | Additive. release-record gains optional `source_sha256` (folder-home deploys store the deployed teploy.yml, detected secrets masked, as meta/.source.yml 0600) with a new valid fixture. version-handshake advertises `lock-wait-v1` (global `--wait `; lock refusals name the holder) (regenerated mi2.json). `config-home-v1` now also covers `config home teploy --from-server`, `init --from-server` and `template install --home teploy`. DR bundles are schema 2 (config home + server data); they are not a wire artifact of this corpus. No MI bump. | | 11 | main (server-authority spec P1, config home) | 2 | Additive. New artifacts config-envelope (`teploy config get|home|set --json`: home folder/teploy, config commit, applied_commit of the running release, content) and config-history-envelope (`config history --json`), fixtures GENERATED from the real DTOs (teploy-home with config changed since deploy, folder-home, two-commit history). app-list-envelope gains optional `undeployed[]` (`--include-undeployed`, v3 tombstones) with a new valid fixture; release-record gains optional `config_commit` and `actor` with a new valid fixture. version-handshake advertises `config-home-v1` and `undeployed-list-v1` (regenerated mi2.json). State schema v3 (v2 + `tombstone`) marks Teploy-home apps; it is server state, not a wire artifact, and older CLIs refuse it by design. No MI bump. | | 10 | main (server-authority spec P1, events) | 2 | Additive. New artifact events-envelope: `teploy events --json` (per-app server event history, `/deployments/.teploy/apps//events.jsonl`), schema + one valid fixture GENERATED from the real `writeEventsJSON` encoder (started/succeeded deploy pair + key-names-only env.set). version-handshake advertises `events-v1` and `actor-flag-v1` (regenerated mi2.json). No MI bump. | @@ -45,6 +47,9 @@ Neutron/Nucleus dependency and a public mirror. | config-envelope | yes (rev 11) | valid x2 (teploy-home, folder-home; real configDTO) | teploy-cli | | config-history-envelope | yes (rev 11) | valid (real configHistoryDTO) | teploy-cli | | config-merge-envelope | yes (rev 13) | valid (real configMergeDTO) | teploy-cli | +| source-show-envelope | yes (rev 16) | valid x2 (connected, no-source; real sourceShowDTO) | teploy-cli | +| source-key-envelope | yes (rev 16) | valid x2 (ssh, token; real sourceKeyDTO) | teploy-cli | +| trigger-poll-envelope | yes (rev 16) | valid x3 (deployed, force-push-refusal, not-due; real gitsource.PollResult) | teploy-cli | | backup-target-envelope | yes (rev 15) | valid x2 (configured, unconfigured; real backupTargetDTO) | teploy-cli | | backup-list-envelope | yes (rev 15) | valid x2 (app, legacy-schedule; real backupListDTO) | teploy-cli | | backup-create-envelope | yes (rev 15) | valid x2 (volumes, accessory; real backupCreateDTO) | teploy-cli | diff --git a/contracts/fixtures/source-key-envelope/valid/ssh.json b/contracts/fixtures/source-key-envelope/valid/ssh.json new file mode 100644 index 0000000..1f67024 --- /dev/null +++ b/contracts/fixtures/source-key-envelope/valid/ssh.json @@ -0,0 +1,9 @@ +{ + "machine_interface": 2, + "host": "192.0.2.10", + "app": "blog", + "type": "ssh", + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmM9PmN0c9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2 teploy-deploy-blog", + "fingerprint": "256 SHA256:5kO5b5VqPm0s9R8tT1uV2wX3yZ4a5B6c7D8e9F0g1H2 teploy-deploy-blog (ED25519)", + "created": true +} diff --git a/contracts/fixtures/source-key-envelope/valid/token.json b/contracts/fixtures/source-key-envelope/valid/token.json new file mode 100644 index 0000000..112d28d --- /dev/null +++ b/contracts/fixtures/source-key-envelope/valid/token.json @@ -0,0 +1,8 @@ +{ + "machine_interface": 2, + "host": "192.0.2.10", + "app": "shop", + "type": "token", + "username": "x-access-token", + "created": true +} diff --git a/contracts/fixtures/source-show-envelope/valid/connected.json b/contracts/fixtures/source-show-envelope/valid/connected.json new file mode 100644 index 0000000..92ef05e --- /dev/null +++ b/contracts/fixtures/source-show-envelope/valid/connected.json @@ -0,0 +1,22 @@ +{ + "machine_interface": 2, + "host": "192.0.2.10", + "app": "blog", + "repository": "git@github.com:useteploy/blog.git", + "branch": "main", + "poll": "5m", + "credential": { + "type": "ssh", + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmM9PmN0c9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2 teploy-deploy-blog", + "fingerprint": "256 SHA256:5kO5b5VqPm0s9R8tT1uV2wX3yZ4a5B6c7D8e9F0g1H2 teploy-deploy-blog (ED25519)", + "known_host_fingerprints": [ + "256 SHA256:u6nB4D9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2i3J4k5 no comment (ED25519)" + ] + }, + "trigger": { + "ref": "refs/heads/main", + "watermark": "3d9c1f0b8a7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c", + "last_head": "3d9c1f0b8a7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c", + "last_deployed_release": "3d9c1f0e8a7e" + } +} diff --git a/contracts/fixtures/source-show-envelope/valid/no-source.json b/contracts/fixtures/source-show-envelope/valid/no-source.json new file mode 100644 index 0000000..e88f940 --- /dev/null +++ b/contracts/fixtures/source-show-envelope/valid/no-source.json @@ -0,0 +1,5 @@ +{ + "machine_interface": 2, + "host": "192.0.2.10", + "app": "legacy" +} diff --git a/contracts/fixtures/trigger-poll-envelope/valid/deployed.json b/contracts/fixtures/trigger-poll-envelope/valid/deployed.json new file mode 100644 index 0000000..4289644 --- /dev/null +++ b/contracts/fixtures/trigger-poll-envelope/valid/deployed.json @@ -0,0 +1,10 @@ +{ + "machine_interface": 2, + "app": "blog", + "host": "192.0.2.10", + "ref": "refs/heads/main", + "head": "3d9c1f0b8a7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c", + "admitted": true, + "reason": "deployed", + "deployed_release": "3d9c1f0e8a7e" +} diff --git a/contracts/fixtures/trigger-poll-envelope/valid/force-push-refusal.json b/contracts/fixtures/trigger-poll-envelope/valid/force-push-refusal.json new file mode 100644 index 0000000..fcfe10c --- /dev/null +++ b/contracts/fixtures/trigger-poll-envelope/valid/force-push-refusal.json @@ -0,0 +1,9 @@ +{ + "machine_interface": 2, + "app": "blog", + "host": "192.0.2.10", + "ref": "refs/heads/main", + "head": "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b", + "admitted": false, + "reason": "force-push" +} diff --git a/contracts/fixtures/trigger-poll-envelope/valid/not-due.json b/contracts/fixtures/trigger-poll-envelope/valid/not-due.json new file mode 100644 index 0000000..700f3e8 --- /dev/null +++ b/contracts/fixtures/trigger-poll-envelope/valid/not-due.json @@ -0,0 +1,7 @@ +{ + "machine_interface": 2, + "app": "blog", + "ref": "refs/heads/main", + "admitted": false, + "reason": "not-due" +} diff --git a/contracts/fixtures/version-handshake/valid/mi2.json b/contracts/fixtures/version-handshake/valid/mi2.json index a3ff53f..9d1b5ab 100644 --- a/contracts/fixtures/version-handshake/valid/mi2.json +++ b/contracts/fixtures/version-handshake/valid/mi2.json @@ -14,6 +14,7 @@ "env-set-stdin", "error-envelope", "events-v1", + "git-source-v1", "health-modes", "kv-set-stdin", "lock-wait-v1", @@ -34,6 +35,7 @@ "server-update", "template-var-stdin", "trigger-convergence-v1", + "trigger-poll-v1", "undeployed-list-v1" ], "machine_interface": 2, diff --git a/contracts/schema/source-key-envelope.schema.json b/contracts/schema/source-key-envelope.schema.json new file mode 100644 index 0000000..7679963 --- /dev/null +++ b/contracts/schema/source-key-envelope.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://teploy.github.io/contracts/schema/source-key-envelope.schema.json", + "title": "`teploy config source key --json` / `host-key --json`: the credential just created or pinned (public facts only, rev 14)", + "type": "object", + "required": ["machine_interface", "host", "app", "type", "created"], + "properties": { + "machine_interface": {"type": "integer", "minimum": 1}, + "host": {"type": "string", "minLength": 1}, + "app": {"type": "string", "minLength": 1}, + "type": {"type": "string", "enum": ["ssh", "token", "host-key", "none"]}, + "public_key": {"type": "string", "description": "The generated deploy key's public half (ssh only)."}, + "fingerprint": {"type": "string"}, + "username": {"type": "string", "description": "Username the token is sent as (token only)."}, + "created": {"type": "boolean", "description": "False when an equivalent credential already existed and was kept."}, + "host_key_fingerprints": {"type": "array", "items": {"type": "string"}, "description": "Pinned host keys (host-key only)."} + } +} diff --git a/contracts/schema/source-show-envelope.schema.json b/contracts/schema/source-show-envelope.schema.json new file mode 100644 index 0000000..d14012a --- /dev/null +++ b/contracts/schema/source-show-envelope.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://teploy.github.io/contracts/schema/source-show-envelope.schema.json", + "title": "`teploy config source show --json`: an app's git source public state (no secrets, rev 14)", + "type": "object", + "required": ["machine_interface", "host", "app"], + "properties": { + "machine_interface": {"type": "integer", "minimum": 1}, + "host": {"type": "string", "minLength": 1}, + "app": {"type": "string", "minLength": 1}, + "repository": {"type": "string", "minLength": 1, "description": "The git: repository URL, empty when the app has no git source."}, + "branch": {"type": "string"}, + "poll": {"type": "string", "description": "Effective poll interval (\"5m\" default) or \"off\"."}, + "credential": { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string", "enum": ["ssh", "token"]}, + "public_key": {"type": "string", "description": "The deploy key's public half (ssh only)."}, + "fingerprint": {"type": "string", "description": "ssh-keygen -lf form."}, + "username": {"type": "string", "description": "Username sent with the token (token only)."}, + "known_host_fingerprints": {"type": "array", "items": {"type": "string"}} + }, + "additionalProperties": false + }, + "trigger": { + "type": "object", + "required": ["ref"], + "properties": { + "ref": {"type": "string", "pattern": "^refs/heads/"}, + "watermark": {"type": "string", "description": "Last admitted commit for the ref (the ancestry bar)."}, + "last_head": {"type": "string"}, + "refused_head": {"type": "string", "description": "Non-empty while a force-push is refused (until `trigger reset`)."}, + "last_deployed_release": {"type": "string"} + }, + "additionalProperties": false + } + } +} diff --git a/contracts/schema/trigger-poll-envelope.schema.json b/contracts/schema/trigger-poll-envelope.schema.json new file mode 100644 index 0000000..72207f5 --- /dev/null +++ b/contracts/schema/trigger-poll-envelope.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://teploy.github.io/contracts/schema/trigger-poll-envelope.schema.json", + "title": "`teploy trigger poll --json`: one app's admission result (one JSON object per app; `--all` emits one line each, rev 14)", + "type": "object", + "required": ["machine_interface", "app", "ref", "admitted"], + "properties": { + "machine_interface": {"type": "integer", "minimum": 1}, + "app": {"type": "string", "minLength": 1}, + "host": {"type": "string", "description": "The server the poll ran against (empty for local timer mode)."}, + "ref": {"type": "string", "description": "The polled ref (refs/heads/), or empty for a pre-refusal result such as a busy lock."}, + "head": {"type": "string", "description": "The remote head considered."}, + "admitted": {"type": "boolean", "description": "True only when this poll deployed a new commit."}, + "reason": { + "type": "string", + "enum": [ + "deployed", + "up-to-date", + "not-due", + "poll-off", + "no-relevant-changes", + "force-push", + "retry-limit", + "branch-missing", + "failed", + "locked", + "no-git-source" + ] + }, + "deployed_release": {"type": "string", "description": "The release version of the deploy this poll performed."} + } +} diff --git a/internal/build/build.go b/internal/build/build.go index 936f2bd..2117c9a 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -84,12 +84,24 @@ type BuildConfig struct { // "Dockerfile" in the context root). Dockerfile string Platform string // e.g. "linux/arm64" (optional) + // Secrets are BuildKit build secrets: id plus a server-local file + // holding the value (0600, outside the build context). Dockerfile + // mounts them with RUN --mount=type=secret,id=...; the values never + // enter the image layers, the command line or the logs. + Secrets []BuildSecret +} + +// BuildSecret is one `docker build --secret` mount. +type BuildSecret struct { + ID string + Path string } // Builder runs Docker or Nixpacks builds on the server via SSH. type Builder struct { - exec ssh.Executor - stdout io.Writer + exec ssh.Executor + stdout io.Writer + secrets []BuildSecret } // NewBuilder creates a Builder backed by the given SSH executor. @@ -97,6 +109,13 @@ func NewBuilder(exec ssh.Executor, stdout io.Writer) *Builder { return &Builder{exec: exec, stdout: stdout} } +// WithSecrets returns a builder that passes the given BuildKit secrets +// to Dockerfile builds (ignored elsewhere; Nixpacks has no equivalent). +func (b *Builder) WithSecrets(secrets []BuildSecret) *Builder { + b.secrets = secrets + return b +} + // Build runs the appropriate build command on the server and returns the image tag. func (b *Builder) Build(ctx context.Context, cfg BuildConfig) (string, error) { tag := ImageTag(cfg.App, cfg.Version) @@ -112,10 +131,18 @@ func (b *Builder) Build(ctx context.Context, cfg BuildConfig) (string, error) { } func (b *Builder) buildDockerfile(ctx context.Context, tag, buildDir, contextSub, dockerfile, platform string) error { + // The plain build keeps its historical byte-identical command; + // BuildKit is only forced on for --secret mounts (D4 build_secrets). cmd := "docker build -t " + ssh.ShellQuote(tag) + if len(b.secrets) > 0 { + cmd = "DOCKER_BUILDKIT=1 " + cmd + } if platform != "" { cmd += " --platform " + ssh.ShellQuote(platform) } + for _, s := range b.secrets { + cmd += " --secret id=" + ssh.ShellQuote(s.ID) + ",src=" + ssh.ShellQuote(s.Path) + } // Remote paths are POSIX; use path.Join and quote for the shell. cmd += remoteBuildTail(buildDir, contextSub, dockerfile) return b.exec.RunStream(ctx, cmd, b.stdout, b.stdout) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index d914a8d..43476ee 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "os" "path/filepath" "strings" @@ -529,3 +530,19 @@ func TestBuild_ContextSubdirNixpacks(t *testing.T) { t.Errorf("nixpacks should build the context subdir, got: %s", nixpacksCall) } } + +func TestBuild_DockerfileWithSecrets(t *testing.T) { + exec := ssh.NewMockExecutor("host", + ssh.MockCommand{Match: "DOCKER_BUILDKIT=1 docker build", Output: ""}, + ) + builder := NewBuilder(exec, io.Discard) + if _, err := builder.WithSecrets([]BuildSecret{{ID: "NPM_TOKEN", Path: "/run/secrets/NPM_TOKEN"}}).Build(context.Background(), BuildConfig{ + App: "myapp", Version: "abc1234", Mode: ModeDockerfile, BuildDir: "/deployments/myapp/build", + }); err != nil { + t.Fatal(err) + } + want := "DOCKER_BUILDKIT=1 docker build -t 'myapp-build-abc1234' --secret id='NPM_TOKEN',src='/run/secrets/NPM_TOKEN' /deployments/myapp/build" + if got := exec.Calls[len(exec.Calls)-1]; got != want { + t.Fatalf("build command = %q, want %q", got, want) + } +} diff --git a/internal/cli/config.go b/internal/cli/config.go index d0782af..2a23b84 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -49,7 +49,7 @@ secret values go in 'teploy secret set'.`, cmd.AddCommand(newConfigGetCmd(flags), newConfigSetCmd(flags), newConfigEditCmd(flags), newConfigMergeCmd(flags), newConfigDiffCmd(flags), newConfigHistoryCmd(flags), newConfigRestoreCmd(flags), newConfigPullCmd(flags), newConfigPushCmd(flags), newConfigHomeCmd(flags), - newConfigPurgeHistoryCmd(flags)) + newConfigPurgeHistoryCmd(flags), newConfigSourceCmd(flags)) return cmd } diff --git a/internal/cli/configsource.go b/internal/cli/configsource.go new file mode 100644 index 0000000..269078b --- /dev/null +++ b/internal/cli/configsource.go @@ -0,0 +1,381 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "strings" + + "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/confighome" + "github.com/useteploy/teploy/internal/events" + "github.com/useteploy/teploy/internal/gitsource" + "github.com/useteploy/teploy/internal/secret" + "github.com/useteploy/teploy/internal/ssh" +) + +// `teploy config source` — the D4 git-source connection surface: per-app +// credentials stored age-encrypted OUTSIDE /deployments, the forge host +// key pin, and the public source/admission state. + +func newConfigSourceCmd(flags *Flags) *cobra.Command { + cmd := &cobra.Command{ + Use: "source", + Short: "Manage an app's git source credential and host key", + Long: `A Teploy-home app with a git: block builds from a git repository — +GitHub, GitLab, Forgejo/Gitea, Cursor Origin, or any git host over SSH or +HTTPS. The server fetches it with a per-app credential that never leaves +the server: + + teploy config source key --app X --host S generate an ed25519 deploy key + (print the public half to register) + teploy config source key --app X --host S --type token + store an HTTPS token read from stdin + teploy config source host-key --app X --host S pin the forge's SSH host key + teploy config source show --app X --host S --json public state, no secrets + teploy config source remove --app X --host S forget the credential + +Credentials live age-encrypted outside /deployments (/etc/teploy/apps as +root), so a compromised Caddy cannot read them. HTTPS tokens are used via +GIT_ASKPASS and SSH keys via GIT_SSH_COMMAND — never in URLs, .git/config +or a command line. Fetches use StrictHostKeyChecking=yes against the +pinned host key.`, + } + cmd.AddCommand(newConfigSourceKeyCmd(flags), newConfigSourceHostKeyCmd(flags), + newConfigSourceShowCmd(flags), newConfigSourceRemoveCmd(flags)) + return cmd +} + +// sourceCredentialDTO is the public half of a credential (never a secret). +type sourceCredentialDTO struct { + Type string `json:"type"` + PublicKey string `json:"public_key,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Username string `json:"username,omitempty"` + KnownHosts []string `json:"known_host_fingerprints,omitempty"` +} + +// sourceTriggerDTO is one ref's admission state (the dash repository card). +type sourceTriggerDTO struct { + Ref string `json:"ref"` + Watermark string `json:"watermark,omitempty"` + LastHead string `json:"last_head,omitempty"` + RefusedHead string `json:"refused_head,omitempty"` + LastDeployedRelease string `json:"last_deployed_release,omitempty"` +} + +// sourceShowDTO is `config source show --json`. +type sourceShowDTO struct { + MachineInterface int `json:"machine_interface"` + Host string `json:"host"` + App string `json:"app"` + Repository string `json:"repository,omitempty"` + Branch string `json:"branch,omitempty"` + Poll string `json:"poll,omitempty"` + Credential *sourceCredentialDTO `json:"credential,omitempty"` + Trigger *sourceTriggerDTO `json:"trigger,omitempty"` +} + +// sourceKeyDTO is `config source key --json` / `host-key --json`. +type sourceKeyDTO struct { + MachineInterface int `json:"machine_interface"` + Host string `json:"host"` + App string `json:"app"` + Type string `json:"type"` + PublicKey string `json:"public_key,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Username string `json:"username,omitempty"` + Created bool `json:"created"` + HostKeys []string `json:"host_key_fingerprints,omitempty"` +} + +func newConfigSourceKeyCmd(flags *Flags) *cobra.Command { + var ( + appName, keyType, username string + rotate, noTimer, asJSON bool + ) + cmd := &cobra.Command{ + Use: "key", + Short: "Generate the app's deploy key (or store an HTTPS token from stdin)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if appName == "" { + return fmt.Errorf("--app is required") + } + if keyType != "ssh" && keyType != "token" { + return fmt.Errorf("--type must be ssh or token") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + t, err := openConfigTarget(ctx, flags, appName) + if err != nil { + return err + } + defer t.exec.Close() + out := cmd.OutOrStdout() + + var info gitsource.KeyInfo + if keyType == "token" { + token, err := readTokenStdin(cmd.InOrStdin()) + if err != nil { + return err + } + if err := secret.NewManager(t.exec).EnsureAge(ctx); err != nil { + return err + } + if info, err = gitsource.SetToken(ctx, t.exec, t.app, username, token); err != nil { + return err + } + } else { + if err := secret.NewManager(t.exec).EnsureAge(ctx); err != nil { + return err + } + if info, err = gitsource.GenerateKey(ctx, t.exec, t.app, rotate); err != nil { + return err + } + } + events.Record(ctx, t.exec, events.Event{App: t.app, Kind: events.KindSourceConnect, Outcome: events.Succeeded, + Detail: map[string]any{"action": "connect", "type": info.Type, "fingerprint": info.Fingerprint}}) + + if asJSON { + return json.NewEncoder(out).Encode(sourceKeyDTO{ + MachineInterface: MachineInterface, Host: t.server, App: t.app, + Type: info.Type, PublicKey: info.PublicKey, Fingerprint: info.Fingerprint, + Username: info.Username, Created: info.Created, + }) + } + switch info.Type { + case "ssh": + fmt.Fprintf(out, "Deploy key for %s (%s)\n %s\n", t.app, info.Fingerprint, info.PublicKey) + fmt.Fprintf(out, "Add it as a read-only deploy key to the repository, then pin the forge host key:\n") + fmt.Fprintf(out, " teploy config source host-key --app %s --host %s\n", t.app, t.server) + if !info.Created { + fmt.Fprintf(out, "(key already existed; --rotate replaces it)\n") + } + case "token": + fmt.Fprintf(out, "Token stored for %s (username %s)\n", t.app, info.Username) + } + if !noTimer { + offerTriggerTimer(ctx, t, out) + } + return nil + }, + } + cmd.Flags().StringVar(&appName, "app", "", "app the credential belongs to (required)") + cmd.Flags().StringVar(&keyType, "type", "ssh", "credential type: ssh (deploy key) or token (HTTPS, read from stdin)") + cmd.Flags().StringVar(&username, "username", "", "username sent with the token (default x-access-token)") + cmd.Flags().BoolVar(&rotate, "rotate", false, "replace an existing SSH key") + cmd.Flags().BoolVar(&noTimer, "no-timer", false, "do not try to install the server poll timer") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +// readTokenStdin reads the HTTPS token from stdin (never argv). +func readTokenStdin(r io.Reader) ([]byte, error) { + token, err := io.ReadAll(io.LimitReader(r, 8192)) + if err != nil { + return nil, err + } + token = []byte(strings.TrimRight(string(token), "\r\n")) + if len(token) == 0 { + return nil, fmt.Errorf("no token on stdin — pass it with: teploy config source key --type token ... < token-file") + } + return token, nil +} + +func newConfigSourceHostKeyCmd(flags *Flags) *cobra.Command { + var appName, expected string + var asJSON bool + cmd := &cobra.Command{ + Use: "host-key", + Short: "Pin the forge's SSH host key (ssh-keyscan on the server)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if appName == "" { + return fmt.Errorf("--app is required") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + t, err := openConfigTarget(ctx, flags, appName) + if err != nil { + return err + } + defer t.exec.Close() + repository, err := sourceRepository(ctx, t) + if err != nil { + return err + } + entries, err := gitsource.PinHostKey(ctx, t.exec, t.app, repository, expected) + if err != nil { + return err + } + var fps []string + for _, e := range entries { + fps = append(fps, e.Fingerprint) + } + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(sourceKeyDTO{ + MachineInterface: MachineInterface, Host: t.server, App: t.app, + Type: "host-key", Created: true, HostKeys: fps, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "Pinned host keys for %s:\n", repository) + for _, e := range entries { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", e.Fingerprint) + } + return nil + }, + } + cmd.Flags().StringVar(&appName, "app", "", "app whose repository host to pin (required)") + cmd.Flags().StringVar(&expected, "expected-fingerprint", "", "refuse unless the host presents this fingerprint (SHA256:...)") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +func newConfigSourceShowCmd(flags *Flags) *cobra.Command { + var appName string + var asJSON bool + cmd := &cobra.Command{ + Use: "show", + Short: "Show the app's git source state (no secrets)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if appName == "" { + return fmt.Errorf("--app is required") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + t, err := openConfigTarget(ctx, flags, appName) + if err != nil { + return err + } + defer t.exec.Close() + dto := sourceShowDTO{MachineInterface: MachineInterface, Host: t.server, App: t.app} + if _, head, err := confighome.Head(ctx, t.exec, t.app); err == nil && head != "" { + if raw, _, err := confighome.Read(ctx, t.exec, t.app, head); err == nil { + if cfg, err := confighome.Load(raw, t.app); err == nil && cfg.Git != nil { + dto.Repository = cfg.Git.Repository + dto.Branch = cfg.Git.Branch + if cfg.Git.Poll == "" { + dto.Poll = "5m" + } else { + dto.Poll = cfg.Git.Poll + } + } + } + } + if show, err := gitsource.Show(ctx, t.exec, t.app); err == nil { + cred := &sourceCredentialDTO{Type: show.Type, PublicKey: show.PublicKey, Fingerprint: show.Fingerprint, Username: show.Username} + for _, kh := range show.KnownHosts { + cred.KnownHosts = append(cred.KnownHosts, kh.Fingerprint) + } + dto.Credential = cred + } + if dto.Repository != "" { + if st, err := gitsource.LoadState(ctx, t.exec, t.app); err == nil && st.Repository == dto.Repository { + ref := "refs/heads/" + dto.Branch + if r := st.Ref(ref); r != nil { + trig := &sourceTriggerDTO{Ref: ref, Watermark: r.Watermark, LastHead: r.LastSeen, RefusedHead: r.RefusedHead} + if c := st.Commits[ref+"/"+short12(r.Watermark)]; c != nil { + trig.LastDeployedRelease = c.Release + } + dto.Trigger = trig + } + } + } + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(dto) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s on %s\n", t.app, t.server) + if dto.Repository == "" { + fmt.Fprintf(cmd.OutOrStdout(), " source: none (set git: in its config)\n") + } else { + fmt.Fprintf(cmd.OutOrStdout(), " source: %s (branch %s, poll %s)\n", dto.Repository, dto.Branch, dto.Poll) + } + if dto.Credential == nil { + fmt.Fprintf(cmd.OutOrStdout(), " credential: none\n") + } else { + fmt.Fprintf(cmd.OutOrStdout(), " credential: %s", dto.Credential.Type) + if dto.Credential.Fingerprint != "" { + fmt.Fprintf(cmd.OutOrStdout(), " (%s)", dto.Credential.Fingerprint) + } + fmt.Fprintln(cmd.OutOrStdout()) + for _, kh := range dto.Credential.KnownHosts { + fmt.Fprintf(cmd.OutOrStdout(), " host key: %s\n", kh) + } + } + if dto.Trigger != nil { + fmt.Fprintf(cmd.OutOrStdout(), " watermark: %s (last head %s)\n", short12(dto.Trigger.Watermark), short12(dto.Trigger.LastHead)) + if dto.Trigger.RefusedHead != "" { + fmt.Fprintf(cmd.OutOrStdout(), " refused: %s — reset with `teploy trigger reset --app %s --ref %s`\n", short12(dto.Trigger.RefusedHead), t.app, dto.Trigger.Ref) + } + } + return nil + }, + } + cmd.Flags().StringVar(&appName, "app", "", "app to show (required)") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +func newConfigSourceRemoveCmd(flags *Flags) *cobra.Command { + var appName string + var asJSON bool + cmd := &cobra.Command{ + Use: "remove", + Short: "Forget the app's credential, host keys and code mirror", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if appName == "" { + return fmt.Errorf("--app is required") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + t, err := openConfigTarget(ctx, flags, appName) + if err != nil { + return err + } + defer t.exec.Close() + if err := gitsource.Remove(ctx, t.exec, t.app); err != nil { + return err + } + // The mirror holds only code, but it is the credential's + // working set: forget it with the credential. + if _, err := t.exec.Run(ctx, "rm -rf "+ssh.ShellQuote(confighome.Dir(t.app)+"/mirror.git")); err != nil { + return err + } + events.Record(ctx, t.exec, events.Event{App: t.app, Kind: events.KindSourceConnect, Outcome: events.Succeeded, + Detail: map[string]any{"action": "remove"}}) + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(sourceKeyDTO{ + MachineInterface: MachineInterface, Host: t.server, App: t.app, Type: "none", + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed %s's git credential and mirror\n", t.app) + return nil + }, + } + cmd.Flags().StringVar(&appName, "app", "", "app whose credential to remove (required)") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +// sourceRepository reads the app's git.repository from its Teploy config. +func sourceRepository(ctx context.Context, t *configTarget) (string, error) { + raw, _, err := readHead(ctx, t) + if err != nil { + return "", err + } + cfg, err := config.LoadAppBytes(raw) + if err != nil { + return "", err + } + if cfg.Git == nil || cfg.Git.Repository == "" { + return "", fmt.Errorf("%s has no git: source in its config — set one first (teploy config set --app %s --host %s git.repository= git.branch=)", t.app, t.app, t.server) + } + return cfg.Git.Repository, nil +} diff --git a/internal/cli/contracts_golden_test.go b/internal/cli/contracts_golden_test.go index 68a6dfb..965adf2 100644 --- a/internal/cli/contracts_golden_test.go +++ b/internal/cli/contracts_golden_test.go @@ -23,6 +23,7 @@ import ( "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/confighome" "github.com/useteploy/teploy/internal/events" + "github.com/useteploy/teploy/internal/gitsource" "github.com/useteploy/teploy/internal/preview" "github.com/useteploy/teploy/internal/releasemeta" "github.com/useteploy/teploy/internal/ssh" @@ -572,3 +573,41 @@ func TestContractsD2Golden(t *testing.T) { MachineInterface: MachineInterface, App: "legacy", Server: "infra-home", Home: "folder", Release: "abc1234", Image: "example/legacy:abc1234", OK: true}) } + +func TestContractsGitSourceGolden(t *testing.T) { + sha := "3d9c1f0b8a7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c" + writeFixture(t, "source-show-envelope/valid/connected.json", sourceShowDTO{ + MachineInterface: MachineInterface, Host: "192.0.2.10", App: "blog", + Repository: "git@github.com:useteploy/blog.git", Branch: "main", Poll: "5m", + Credential: &sourceCredentialDTO{ + Type: "ssh", PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmM9PmN0c9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2 teploy-deploy-blog", + Fingerprint: "256 SHA256:5kO5b5VqPm0s9R8tT1uV2wX3yZ4a5B6c7D8e9F0g1H2 teploy-deploy-blog (ED25519)", + KnownHosts: []string{"256 SHA256:u6nB4D9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2i3J4k5 no comment (ED25519)"}, + }, + Trigger: &sourceTriggerDTO{ + Ref: "refs/heads/main", Watermark: sha, LastHead: sha, LastDeployedRelease: "3d9c1f0e8a7e", + }, + }) + writeFixture(t, "source-show-envelope/valid/no-source.json", sourceShowDTO{ + MachineInterface: MachineInterface, Host: "192.0.2.10", App: "legacy", + }) + writeFixture(t, "source-key-envelope/valid/ssh.json", sourceKeyDTO{ + MachineInterface: MachineInterface, Host: "192.0.2.10", App: "blog", Type: "ssh", Created: true, + PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmM9PmN0c9Jl0uR0aX1w2y3z4a5b6c7d8e9f0g1h2 teploy-deploy-blog", + Fingerprint: "256 SHA256:5kO5b5VqPm0s9R8tT1uV2wX3yZ4a5B6c7D8e9F0g1H2 teploy-deploy-blog (ED25519)", + }) + writeFixture(t, "source-key-envelope/valid/token.json", sourceKeyDTO{ + MachineInterface: MachineInterface, Host: "192.0.2.10", App: "shop", Type: "token", Username: "x-access-token", Created: true, + }) + writeFixture(t, "trigger-poll-envelope/valid/deployed.json", gitsource.PollResult{ + MachineInterface: MachineInterface, App: "blog", Host: "192.0.2.10", Ref: "refs/heads/main", + Head: sha, Admitted: true, Reason: "deployed", Release: "3d9c1f0e8a7e", + }) + writeFixture(t, "trigger-poll-envelope/valid/force-push-refusal.json", gitsource.PollResult{ + MachineInterface: MachineInterface, App: "blog", Host: "192.0.2.10", Ref: "refs/heads/main", + Head: "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b", Admitted: false, Reason: "force-push", + }) + writeFixture(t, "trigger-poll-envelope/valid/not-due.json", gitsource.PollResult{ + MachineInterface: MachineInterface, App: "blog", Host: "", Ref: "refs/heads/main", Admitted: false, Reason: "not-due", + }) +} diff --git a/internal/cli/deployserver.go b/internal/cli/deployserver.go index a429de8..e4579da 100644 --- a/internal/cli/deployserver.go +++ b/internal/cli/deployserver.go @@ -4,15 +4,19 @@ import ( "context" "errors" "fmt" + "io" "os" "os/signal" "strconv" + "strings" "time" + "github.com/useteploy/teploy/internal/build" "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/confighome" "github.com/useteploy/teploy/internal/dns" "github.com/useteploy/teploy/internal/docker" + "github.com/useteploy/teploy/internal/gitsource" "github.com/useteploy/teploy/internal/releasemeta" "github.com/useteploy/teploy/internal/ssh" "github.com/useteploy/teploy/internal/state" @@ -74,7 +78,14 @@ func deployServerConfig(ctx context.Context, executor ssh.Executor, host, server } defer state.ReleaseLockFenced(executor, lk, app) lk.StartRenewal(executor) + return deployServerConfigLocked(ctx, executor, host, serverName, app, image, version, skipDNSCheck, migrateVolumes, lk, "") +} +// deployServerConfigLocked is deployServerConfig under a lock the caller +// already holds (the poll admitter) and an optional pre-admitted git +// commit: gitCommit != "" deploys exactly that commit; "" resolves the +// branch head the way a manual deploy does. +func deployServerConfigLocked(ctx context.Context, executor ssh.Executor, host, serverName, app, image, version string, skipDNSCheck, migrateVolumes bool, lk *state.Lock, gitCommit string) error { gate, err := confighome.CheckGate(ctx, executor, app, lk, os.Stdout) if err != nil { return refuseAdmission(err) @@ -116,22 +127,53 @@ func deployServerConfig(ctx context.Context, executor ssh.Executor, host, server appCfg.ConfigHome = confighome.HomeTeploy appCfg.ConfigCommit = commit - image = appCfg.Image + // Git source: build the admitted (or, for a manual deploy, the + // current) branch head from the server mirror. + sourceCommit := "" + if appCfg.Git != nil { + if gitCommit != "" { + sourceCommit = gitCommit + } else { + if refused, rerr := gitsource.RefusedHead(ctx, executor, app, appCfg.Git.Repository, appCfg.Git.Branch); rerr == nil && refused != "" { + return refuseAdmission(fmt.Errorf("%s's branch %s was force-pushed to %s and is refused until the watermark is reset — run `teploy trigger reset --app %s --ref refs/heads/%s`", app, appCfg.Git.Branch, short12(refused), app, appCfg.Git.Branch)) + } + if sourceCommit, err = gitsource.ResolveHead(ctx, executor, app, appCfg.Git); err != nil { + return refuseAdmission(err) + } + } + appCfg.SourceRevision = sourceCommit + } else if appCfg.Image != "" { + image = appCfg.Image + } + if version == "" { - version = versionFromImage(image) - if version == "" { - version = fmt.Sprintf("%d", time.Now().Unix()) + if appCfg.Git != nil { + version = short12(sourceCommit) + } else { + version = versionFromImage(image) + if version == "" { + version = fmt.Sprintf("%d", time.Now().Unix()) + } } } else if err := validateVersionArg(version); err != nil { return refuseAdmission(err) } fmt.Printf("Deploying %s from its Teploy config %s\n", app, short12(commit)) + if appCfg.Git != nil { + fmt.Printf("Building %s@%s from %s (branch %s)\n", app, short12(sourceCommit), appCfg.Git.Repository, appCfg.Git.Branch) + } if err := admitDeploymentVolumes(ctx, &state.FencedExecutor{Executor: executor, Lock: lk}, appCfg); err != nil { return err } att := releasemeta.MustAttempt(app, version) - if err := ensureImage(ctx, docker.NewClient(executor), image, os.Stdout); err != nil { + if appCfg.Git != nil { + built, err := buildFromGitSource(ctx, executor, appCfg, att, sourceCommit, os.Stdout) + if err != nil { + return err + } + image = built + } else if err := ensureImage(ctx, docker.NewClient(executor), image, os.Stdout); err != nil { return err } if !skipDNSCheck && appCfg.Domain != "" && @@ -144,7 +186,99 @@ func deployServerConfig(ctx context.Context, executor ssh.Executor, host, server fmt.Println(" DNS validated") } } - return deployBuiltImageFenced(ctx, executor, appCfg, image, version, host, migrateVolumes, false, "", lk, &att, "") + err = deployBuiltImageFenced(ctx, executor, appCfg, image, version, host, migrateVolumes, appCfg.Git != nil, "", lk, &att, "") + if err != nil { + return err + } + if appCfg.Git != nil && gitCommit == "" { + // A manual deploy is a valid way past a retry cap; record it so + // the next poll does not redo the same head. + if rerr := gitsource.RecordManualDeploy(ctx, executor, app, appCfg.Git.Repository, appCfg.Git.Branch, sourceCommit, version); rerr != nil { + fmt.Printf("Warning: could not advance %s's git watermark: %v\n", app, rerr) + } + } + return nil +} + +// buildFromGitSource exports the commit's tree from the per-app mirror +// into the attempt's build directory and builds it server-side (context: +// and dockerfile: are relative to the repository root). build_secrets are +// decrypted to 0600 files outside the build context, mounted with +// `docker build --secret`, and removed immediately after. +func buildFromGitSource(ctx context.Context, executor ssh.Executor, appCfg *config.AppConfig, att releasemeta.Attempt, commit string, out io.Writer) (string, error) { + remoteDir := att.BuildDir() + if _, err := executor.Run(ctx, att.MkdirCmd("build")); err != nil { + return "", fmt.Errorf("creating build directory: %w", err) + } + contextDir := appCfg.Context + if contextDir == "" { + contextDir = "." + } + mode, hasGitmodules, err := gitsource.ExportArchive(ctx, executor, appCfg.App, remoteDir, commit, contextDir, appCfg.Dockerfile) + if err != nil { + return "", refuseAdmission(fmt.Errorf("exporting %s@%s from the mirror: %w", appCfg.App, short12(commit), err)) + } + if hasGitmodules { + return "", refuseAdmission(fmt.Errorf("%s's repository declares submodules, which this teploy version does not build — flatten the repository or keep the app in folder home", appCfg.App)) + } + var buildMode build.Mode + switch mode { + case "dockerfile": + buildMode = build.ModeDockerfile + case "nixpacks": + if len(appCfg.BuildSecrets) > 0 { + return "", refuseAdmission(fmt.Errorf("build_secrets need a Dockerfile build (RUN --mount=type=secret) — %s's repository has none", appCfg.App)) + } + buildMode = build.ModeNixpacks + default: + return "", fmt.Errorf("unknown build mode %q", mode) + } + secrets, cleanup, err := prepareGitBuildSecrets(ctx, executor, appCfg) + if err != nil { + return "", err + } + defer cleanup() + image, err := build.NewBuilder(executor, out).WithSecrets(secrets).Build(ctx, build.BuildConfig{ + App: appCfg.App, + Version: short12(commit), + Mode: buildMode, + BuildDir: remoteDir, + Context: appCfg.Context, + Dockerfile: appCfg.Dockerfile, + Platform: appCfg.Platform, + }) + if err != nil { + return "", fmt.Errorf("building image: %w", err) + } + return image, nil +} + +// prepareGitBuildSecrets decrypts each named age-store secret into a +// server-side private directory for the duration of one build. +func prepareGitBuildSecrets(ctx context.Context, executor ssh.Executor, appCfg *config.AppConfig) ([]build.BuildSecret, func(), error) { + if len(appCfg.BuildSecrets) == 0 { + return nil, func() {}, nil + } + dirOut, err := executor.Run(ctx, `base=/tmp; [ -d /dev/shm ] && base=/dev/shm; mktemp -d "$base/teploy-build-secrets.XXXXXXXX"`) + if err != nil { + return nil, func() {}, fmt.Errorf("creating the build-secret directory: %w", err) + } + dir := strings.TrimSpace(dirOut) + if dir == "" || strings.Contains(dir, "\n") { + return nil, func() {}, fmt.Errorf("unusable build-secret directory") + } + var out []build.BuildSecret + for _, key := range appCfg.BuildSecrets { + dest := dir + "/" + key + if err := gitsource.SealBuildSecret(ctx, executor, appCfg.App, key, dest); err != nil { + executor.Run(context.Background(), "rm -rf "+ssh.ShellQuote(dir)) + return nil, func() {}, refuseAdmission(fmt.Errorf("preparing build secret %s: %w", key, err)) + } + out = append(out, build.BuildSecret{ID: key, Path: dest}) + } + return out, func() { + executor.Run(context.Background(), "rm -rf "+ssh.ShellQuote(dir)) + }, nil } // createServerConfigApp creates a NEW app in Teploy home from ad-hoc deploy diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 443f0b2..f18e436 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -203,7 +203,7 @@ func doctorRun(ctx context.Context, deps doctorDeps, flags *Flags, serverName st if executor == nil { const sshDown = "restore SSH connectivity (see the ssh check above), then re-run teploy doctor" - for _, name := range []string{"docker", "disk", "registry", "caddy", "compatibility", "repair-debt", "secret-exposure", "server-tools", "config-home", "backup-cron", "age-identity"} { + for _, name := range []string{"docker", "disk", "registry", "caddy", "compatibility", "repair-debt", "secret-exposure", "server-tools", "config-home", "backup-cron", "age-identity", "git-trigger"} { skipFail(name, "SSH unreachable", sshDown) } } else { @@ -224,6 +224,7 @@ func doctorRun(ctx context.Context, deps doctorDeps, flags *Flags, serverName st report.Checks = append(report.Checks, doctorConfigHomeCheck(ctx, executor)) report.Checks = append(report.Checks, doctorBackupCronCheck(ctx, executor)) report.Checks = append(report.Checks, doctorAgeIdentityCheck(ctx, executor)) + report.Checks = append(report.Checks, doctorGitTriggerCheck(ctx, executor, deps)) } report.summarize() @@ -905,3 +906,33 @@ func doctorAgeIdentityCheck(ctx context.Context, exec ssh.Executor) doctorCheck } return doctorCheck{Name: "age-identity", Result: doctorWarn, Detail: "unrecognized age identity state: " + strings.Join(f, " ")} } + +// doctorGitTriggerCheck verifies the git-source trigger (D4): when any +// app on the server builds from git:, the minutely poll timer must be +// installed and its resident teploy binary must support `trigger poll`. +func doctorGitTriggerCheck(ctx context.Context, exec ssh.Executor, deps doctorDeps) doctorCheck { + apps, err := listGitApps(ctx, exec) + if err != nil { + return doctorCheck{Name: "git-trigger", Result: doctorWarn, Detail: err.Error(), + Remediation: "inspect /deployments/.teploy/apps on the server"} + } + if len(apps) == 0 { + return doctorCheck{Name: "git-trigger", Result: doctorOK, Detail: "no git-source apps"} + } + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.Name + } + if _, err := exec.Run(ctx, "systemctl is-enabled "+ssh.ShellQuote(triggerPollTimer)+" >/dev/null 2>&1"); err != nil { + return doctorCheck{Name: "git-trigger", Result: doctorWarn, + Detail: fmt.Sprintf("git-source apps (%s) but no %s — nothing polls them", strings.Join(names, ", "), triggerPollTimer), + Remediation: "run `teploy trigger install-timer --host ` (webhook wakes still work; the timer catches missed ones)"} + } + if _, err := exec.Run(ctx, ssh.ShellQuote(serverTeployBinaryPath)+" trigger poll --help >/dev/null 2>&1"); err != nil { + return doctorCheck{Name: "git-trigger", Result: doctorWarn, + Detail: fmt.Sprintf("the timer's teploy binary (%s) does not support `trigger poll`", serverTeployBinaryPath), + Remediation: "run `teploy trigger install-timer --host ` to refresh the server binary"} + } + return doctorCheck{Name: "git-trigger", Result: doctorOK, + Detail: fmt.Sprintf("%s installed; %d git-source app(s): %s", triggerPollTimer, len(apps), strings.Join(names, ", "))} +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index ee0c64e..8be8013 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -19,7 +19,7 @@ import ( // against (adding checks is additive at the end; renaming/removing is a // machine-interface bump). var doctorCheckOrder = []string{ - "git", "config", "ssh", "docker", "disk", "registry", "caddy", "compatibility", "repair-debt", "secret-exposure", "server-tools", "config-home", "backup-cron", "age-identity"} + "git", "config", "ssh", "docker", "disk", "registry", "caddy", "compatibility", "repair-debt", "secret-exposure", "server-tools", "config-home", "backup-cron", "age-identity", "git-trigger"} // doctorHappyMock registers every remote read a fully healthy target // answers, for the app config returned by doctorTestApp. @@ -33,6 +33,7 @@ func doctorHappyMock() *ssh.MockExecutor { ssh.MockCommand{Match: "scan=$(find /deployments ", Output: ""}, ssh.MockCommand{Match: "for t in git python3", Output: ""}, ssh.MockCommand{Match: "for f in /deployments/*/state.json", Output: ""}, + ssh.MockCommand{Match: "ls -1 '/deployments/.teploy/apps'", Output: ""}, ssh.MockCommand{Match: "crontab -l", Output: ""}, ssh.MockCommand{Match: "if [ -r /etc/teploy/age-key ]", Output: "present labsent movable"}, ) @@ -83,8 +84,8 @@ func TestDoctorAllChecksPass(t *testing.T) { if ex == nil { t.Fatal("expected an executor back") } - if report.Summary != (doctorSummary{OK: 14, Warn: 0, Fail: 0}) { - t.Fatalf("summary = %+v, want 14 ok", report.Summary) + if report.Summary != (doctorSummary{OK: 15, Warn: 0, Fail: 0}) { + t.Fatalf("summary = %+v, want 15 ok", report.Summary) } if len(report.Checks) != len(doctorCheckOrder) { t.Fatalf("got %d checks, want %d", len(report.Checks), len(doctorCheckOrder)) @@ -131,8 +132,8 @@ func TestDoctorJSONStableShape(t *testing.T) { t.Fatalf("machine_interface = %v, want %d", doc["machine_interface"], MachineInterface) } checks, ok := doc["checks"].([]any) - if !ok || len(checks) != 14 { - t.Fatalf("checks = %#v, want 14 entries", doc["checks"]) + if !ok || len(checks) != 15 { + t.Fatalf("checks = %#v, want 15 entries", doc["checks"]) } for i, raw := range checks { check, ok := raw.(map[string]any) @@ -186,6 +187,7 @@ func TestDoctorHumanTable(t *testing.T) { ssh.MockCommand{Match: "scan=$(find /deployments ", Output: ""}, ssh.MockCommand{Match: "for t in git python3", Output: ""}, ssh.MockCommand{Match: "for f in /deployments/*/state.json", Output: ""}, + ssh.MockCommand{Match: "ls -1 '/deployments/.teploy/apps'", Output: ""}, ssh.MockCommand{Match: "crontab -l", Output: ""}, ssh.MockCommand{Match: "if [ -r /etc/teploy/age-key ]", Output: "present labsent movable"}, ) @@ -201,7 +203,7 @@ func TestDoctorHumanTable(t *testing.T) { if !strings.Contains(rendered, "fix:") { t.Fatalf("table missing the remediation line:\n%s", rendered) } - if !strings.Contains(rendered, "Summary: 13 ok, 0 warn, 1 fail") { + if !strings.Contains(rendered, "Summary: 14 ok, 0 warn, 1 fail") { t.Fatalf("table summary line wrong:\n%s", rendered) } assertDoctorReadOnlyCalls(t, mock.Calls) @@ -674,7 +676,7 @@ func TestDoctorEndToEndAllOK(t *testing.T) { if err := runDoctor(doctorTestDeps(mock), &Flags{}, "", &out); err != nil { t.Fatalf("runDoctor: %v", err) } - if !strings.Contains(out.String(), "Summary: 14 ok, 0 warn, 0 fail") { + if !strings.Contains(out.String(), "Summary: 15 ok, 0 warn, 0 fail") { t.Fatalf("human report wrong:\n%s", out.String()) } assertDoctorReadOnlyCalls(t, mock.Calls) @@ -697,6 +699,7 @@ func assertDoctorReadOnlyCalls(t *testing.T, calls []string) { "find /deployments ", "for t in git python3", "for f in /deployments/*/state.json", + "ls -1 '/deployments/.teploy/apps'", "crontab -l", } for _, call := range calls { diff --git a/internal/cli/machineinterface.go b/internal/cli/machineinterface.go index b125b74..682b1f2 100644 --- a/internal/cli/machineinterface.go +++ b/internal/cli/machineinterface.go @@ -139,6 +139,12 @@ const ( // CapDeployHomeV1: `deploy --app X --image T --home teploy` creates a // new app in Teploy home from the ad-hoc flags, then deploys it. CapDeployHomeV1 = "deploy-home-v1" + // CapGitSourceV1: Teploy-home apps may build from a git repository + // (`git:` config key): credential, mirror, archive into the attempt dir. + CapGitSourceV1 = "git-source-v1" + // CapTriggerPollV1: `trigger poll [--app|--all] [--now] [--hint]` is the + // single admitter (dedup, watermark+ancestry, force-push refusal). + CapTriggerPollV1 = "trigger-poll-v1" // CapUndeployedListV1: `app list --include-undeployed` returns // never-deployed and removed Teploy-home apps in an "undeployed" array. CapUndeployedListV1 = "undeployed-list-v1" @@ -209,6 +215,8 @@ func MachineCapabilities() []string { CapConfigMergeV1, CapConfigUnprivilegedV1, CapDeployHomeV1, + CapGitSourceV1, + CapTriggerPollV1, CapBackupTargetV1, CapBackupJSONV1, CapSecretStdinV1, diff --git a/internal/cli/machineinterface_test.go b/internal/cli/machineinterface_test.go index 764e0ad..bde5325 100644 --- a/internal/cli/machineinterface_test.go +++ b/internal/cli/machineinterface_test.go @@ -105,7 +105,7 @@ func TestCapabilityTokenRegistry(t *testing.T) { "trigger-convergence-v1", } want = append(want, "actor-flag-v1", "events-v1", "config-home-v1", "undeployed-list-v1", "lock-wait-v1", "config-merge-v1", "config-unprivileged-v1", "deploy-home-v1", "backup-target-v1", "backup-json-v1") - want = append(want, "secret-stdin-v1", "releases-json-v1", "rollback-json-v1", "redeploy-v1", "env-config-guard-v1") + want = append(want, "secret-stdin-v1", "releases-json-v1", "rollback-json-v1", "redeploy-v1", "env-config-guard-v1", "git-source-v1", "trigger-poll-v1") sort.Strings(want) got := MachineCapabilities() if len(got) != len(want) { diff --git a/internal/cli/remove.go b/internal/cli/remove.go index 5e524e7..7c9ad6c 100644 --- a/internal/cli/remove.go +++ b/internal/cli/remove.go @@ -14,6 +14,7 @@ import ( "github.com/useteploy/teploy/internal/caddy" "github.com/useteploy/teploy/internal/confighome" "github.com/useteploy/teploy/internal/docker" + "github.com/useteploy/teploy/internal/gitsource" "github.com/useteploy/teploy/internal/ssh" "github.com/useteploy/teploy/internal/state" ) @@ -283,6 +284,12 @@ func executeRemove(ctx context.Context, exec ssh.Executor, app string, domains [ if _, err := exec.Run(ctx, "rm -rf -- "+ssh.ShellQuote(confighome.Dir(app))); err != nil { return nil, fmt.Errorf("deleting %s: %w", confighome.Dir(app), err) } + // The git credential lives outside /deployments (D4); forget it + // with everything else. Best-effort: a missing helper or age is + // not a reason to keep a half-removed app. + if err := gitsource.Remove(ctx, exec, app); err != nil { + fmt.Fprintf(out, "Warning: could not remove %s's git credential: %v\n", app, err) + } case serverHome && lk != nil: if err := state.WriteTombstone(ctx, exec, app, lk); err != nil { return nil, fmt.Errorf("recording %s as not deployed: %w", app, err) diff --git a/internal/cli/root.go b/internal/cli/root.go index ff97951..7fdfc98 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,14 @@ import ( "github.com/spf13/cobra" ) +// cliBuildVersion is the version NewRootCmd was built with (used where a +// subcommand deep below root needs it, e.g. verifying a server upload). +var cliBuildVersion = "dev" + +// teployVersionForServer is the version string a server-side binary +// upload of THIS CLI is expected to report. +func teployVersionForServer() string { return cliBuildVersion } + // Flags holds the global CLI flags, passed to subcommands explicitly. type Flags struct { Host string @@ -46,6 +54,7 @@ type Flags struct { func NewRootCmd(version string) *cobra.Command { flags := &Flags{} + cliBuildVersion = version root := &cobra.Command{ Use: "teploy", diff --git a/internal/cli/trigger_identity.go b/internal/cli/trigger_identity.go index 241336e..ab96a19 100644 --- a/internal/cli/trigger_identity.go +++ b/internal/cli/trigger_identity.go @@ -219,5 +219,7 @@ func newTriggerCmd(flags *Flags) *cobra.Command { }} identity.Flags().StringVar(&image, "image", "", "image reference used by the execution binding (empty for source build)") cmd.AddCommand(identity) + cmd.AddCommand(newTriggerPollCmd(flags), newTriggerResetCmd(flags), newTriggerRetryCmd(flags), + newTriggerInstallTimerCmd(flags), newTriggerUninstallTimerCmd(flags)) return cmd } diff --git a/internal/cli/trigger_poll.go b/internal/cli/trigger_poll.go new file mode 100644 index 0000000..e73a4fc --- /dev/null +++ b/internal/cli/trigger_poll.go @@ -0,0 +1,530 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "strings" + + "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/confighome" + "github.com/useteploy/teploy/internal/docker" + "github.com/useteploy/teploy/internal/events" + "github.com/useteploy/teploy/internal/gitsource" + "github.com/useteploy/teploy/internal/ssh" + "github.com/useteploy/teploy/internal/state" +) + +// `teploy trigger poll` — the ONE admitter for git-source apps (D4 as +// amended by the P3 plan): webhooks and the server timer only wake it. +// Dedup by (app, ref, commit); a per-ref watermark with an ancestry check +// on the mirror; one recorded refusal per force-push (refused until +// `trigger reset`); changed paths filtered by git.paths (defaulting to +// context:); a failed commit retried at most twice; the newest admitted +// head always wins. + +const ( + triggerPollService = "teploy-trigger-poll.service" + triggerPollTimer = "teploy-trigger-poll.timer" + serverTeployBinDir = "/deployments/.bin" +) + +func newTriggerPollCmd(flags *Flags) *cobra.Command { + var ( + appName, hint string + now, all, asJSON bool + ) + cmd := &cobra.Command{ + Use: "poll [--app X --host S [--now] [--hint ]] | --all", + Short: "Admit and deploy new heads of git-source apps (the one admitter)", + Long: `Checks each app's branch with its stored credential and deploys the +newest head that passes admission: not a duplicate of the watermark, a +descendant of it (a force-push is refused, once, until ` + "`teploy trigger reset`" + `), +matching git.paths when set, and not past its retry budget (a failed +commit is retried at most twice; then only a new commit, ` + "`teploy trigger retry`" + ` +or a redeploy restarts it). + +--app polls one app now (a webhook wake passes --now --hint ; the +remote head always wins over the hint). --all polls every git-source app +on the server whose interval is due — the systemd timer +(` + "`teploy trigger install-timer`" + `) runs it every minute. --json prints one +result object per app.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + out := cmd.OutOrStdout() + restore := quietDeployNarration(asJSON) + defer restore() + if all { + return runTriggerPollAll(ctx, flags, asJSON, out) + } + if appName == "" { + return fmt.Errorf("--app is required (or --all)") + } + t, err := openConfigTarget(ctx, flags, appName) + if err != nil { + return err + } + defer t.exec.Close() + res, err := pollGitApp(ctx, t, now, hint) + if asJSON { + res.MachineInterface = MachineInterface + res.Host = t.server + if encodeErr := encodeJSONLine(out, res); encodeErr != nil && err == nil { + return encodeErr + } + return err + } + if err != nil { + return err + } + printPollResult(out, res) + return nil + }, + } + cmd.Flags().StringVar(&appName, "app", "", "app to poll") + cmd.Flags().StringVar(&hint, "hint", "", "advisory head from a webhook (the remote head wins)") + cmd.Flags().BoolVar(&now, "now", false, "poll immediately: ignore the interval and poll: off") + cmd.Flags().BoolVar(&all, "all", false, "poll every due git-source app on the server (timer mode)") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output (one JSON object per app)") + return cmd +} + +// quietDeployNarration moves the deploy engine's human narration to +// stderr while JSON goes to the captured stdout writer. Returns the +// restore func (always call). +func quietDeployNarration(asJSON bool) func() { + if !asJSON { + return func() {} + } + orig := os.Stdout + os.Stdout = os.Stderr + return func() { os.Stdout = orig } +} + +// pollGitApp runs one app's poll under its fenced lock. A busy lock is a +// result ("locked"), not an error: the next minute's timer catches up. +func pollGitApp(ctx context.Context, t *configTarget, now bool, hint string) (gitsource.PollResult, error) { + exec := t.exec + if err := docker.NewClient(exec).EnsureManagedDirectory(ctx, t.app, "", ""); err != nil { + return gitsource.PollResult{}, fmt.Errorf("creating app directory: %w", err) + } + lk, err := state.AcquireLockFenced(ctx, exec, t.app) + if err != nil { + if strings.Contains(err.Error(), "already locked") { + return gitsource.PollResult{App: t.app, Reason: "locked"}, nil + } + return gitsource.PollResult{}, err + } + defer state.ReleaseLockFenced(exec, lk, t.app) + lk.StartRenewal(exec) + + gate, err := confighome.CheckGate(ctx, exec, t.app, lk, os.Stdout) + if err != nil { + return gitsource.PollResult{}, err + } + if !gate.Server { + return gitsource.PollResult{}, fmt.Errorf("%s's settings are not edited in Teploy — git sources are a Teploy-home feature", t.app) + } + if gate.Head == "" { + return gitsource.PollResult{}, fmt.Errorf("%s has no config yet", t.app) + } + raw, commit, err := confighome.Read(ctx, exec, t.app, gate.Head) + if err != nil { + return gitsource.PollResult{}, err + } + appCfg, err := confighome.Load(raw, t.app) + if err != nil { + return gitsource.PollResult{}, err + } + if appCfg.Git == nil { + return gitsource.PollResult{MachineInterface: MachineInterface, App: t.app, Reason: "no-git-source"}, nil + } + return gitsource.Poll(ctx, gitsource.PollDeps{ + Exec: exec, + App: t.app, + AppCf: appCfg, + Now: now, + Hint: hint, + RecordEvent: func(kind, outcome string, detail map[string]any) { + events.Record(ctx, exec, events.Event{App: t.app, Kind: kind, Outcome: outcome, ConfigCommit: commit, Detail: detail}) + }, + Deploy: func(ctx context.Context, sourceCommit, version string) error { + return deployServerConfigLocked(ctx, exec, exec.Host(), t.server, t.app, "", version, true, false, lk, sourceCommit) + }, + }) +} + +// gitApp is one enumerated git-source app on a server. +type gitApp struct { + Name string + Cfg *config.AppConfig +} + +// listGitApps enumerates the server's Teploy-home apps that build from +// git (used by --all and doctor). +func listGitApps(ctx context.Context, exec ssh.Executor) ([]gitApp, error) { + out, err := exec.Run(ctx, "ls -1 "+ssh.ShellQuote(confighome.AppsDir())+" 2>/dev/null || true") + if err != nil { + return nil, fmt.Errorf("listing apps: %w", err) + } + var apps []gitApp + for _, name := range strings.Fields(out) { + if name == "" || strings.ContainsAny(name, "/.") { + continue + } + if err := config.ValidateName(name); err != nil { + continue + } + exists, head, err := confighome.Head(ctx, exec, name) + if err != nil || !exists || head == "" { + continue + } + raw, _, err := confighome.Read(ctx, exec, name, head) + if err != nil { + continue + } + cfg, err := confighome.Load(raw, name) + if err != nil || cfg.Git == nil { + continue + } + apps = append(apps, gitApp{Name: name, Cfg: cfg}) + } + return apps, nil +} + +func runTriggerPollAll(ctx context.Context, flags *Flags, asJSON bool, out io.Writer) error { + var exec ssh.Executor + if flags.Host != "" { + host, user, key, err := config.ResolveServer(flags.Host, flags.Host, flags.User, flags.Key) + if err != nil { + return err + } + ex, err := ssh.Connect(ctx, ssh.ConnectConfig{Host: host, User: user, KeyPath: key}) + if err != nil { + return err + } + defer ex.Close() + exec = ex + } else { + // Timer mode: running on the server itself. + exec = ssh.NewLocalExecutor() + } + apps, err := listGitApps(ctx, exec) + if err != nil { + return err + } + for _, app := range apps { + t := &configTarget{app: app.Name, server: flags.Host, exec: exec} + res, err := pollGitApp(ctx, t, false, "") + if err != nil { + fmt.Fprintf(os.Stderr, "trigger poll %s: %v\n", app.Name, err) + continue + } + if asJSON { + res.MachineInterface = MachineInterface + res.Host = flags.Host + if err := encodeJSONLine(out, res); err != nil { + return err + } + } else if res.Reason != "not-due" && res.Reason != "" { + printPollResult(out, res) + } + } + return nil +} + +func printPollResult(out io.Writer, res gitsource.PollResult) { + switch res.Reason { + case gitsource.ReasonDeployed: + fmt.Fprintf(out, "%s: deployed %s (release %s)\n", res.App, short12(res.Head), res.Release) + case gitsource.ReasonUpToDate: + fmt.Fprintf(out, "%s: up to date at %s\n", res.App, short12(res.Head)) + case "": + fmt.Fprintf(out, "%s: done\n", res.App) + default: + fmt.Fprintf(out, "%s: %s (%s)\n", res.App, res.Reason, short12(res.Head)) + } +} + +func encodeJSONLine(w io.Writer, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(data)) + return err +} + +// gitSourceOf reads the app's git: block from its Teploy config. +func gitSourceOf(ctx context.Context, t *configTarget) (*config.GitSource, error) { + raw, _, err := readHead(ctx, t) + if err != nil { + return nil, err + } + cfg, err := config.LoadAppBytes(raw) + if err != nil { + return nil, err + } + if cfg.Git == nil { + return nil, fmt.Errorf("%s has no git: source in its config", t.app) + } + return cfg.Git, nil +} + +func newTriggerResetCmd(flags *Flags) *cobra.Command { + var appName, ref string + var asJSON bool + cmd := &cobra.Command{ + Use: "reset", + Short: "Clear a ref's watermark (re-admit after a force-push)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if appName == "" || ref == "" { + return fmt.Errorf("--app and --ref refs/heads/ are required") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + out := cmd.OutOrStdout() + t, err := openConfigTarget(ctx, flags, appName) + if err != nil { + return err + } + defer t.exec.Close() + g, err := gitSourceOf(ctx, t) + if err != nil { + return err + } + if ref != "refs/heads/"+g.Branch { + return fmt.Errorf("%s builds %s's branch %s — reset --ref refs/heads/%s", t.app, g.Repository, g.Branch, g.Branch) + } + if err := gitsource.ResetRef(ctx, t.exec, t.app, g.Repository, ref); err != nil { + return err + } + events.Record(ctx, t.exec, events.Event{App: t.app, Kind: events.KindWatermarkReset, Outcome: events.Succeeded, Detail: map[string]any{"ref": ref}}) + if asJSON { + return encodeJSONLine(out, map[string]any{"machine_interface": MachineInterface, "app": t.app, "ref": ref, "reset": true}) + } + fmt.Fprintf(out, "Cleared %s's %s watermark — the next poll admits the current head\n", t.app, ref) + return nil + }, + } + cmd.Flags().StringVar(&appName, "app", "", "app (required)") + cmd.Flags().StringVar(&ref, "ref", "", "ref to reset, e.g. refs/heads/main (required)") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +func newTriggerRetryCmd(flags *Flags) *cobra.Command { + var appName string + var asJSON bool + cmd := &cobra.Command{ + Use: "retry", + Short: "Reset a failed commit's retry budget and poll it now", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if appName == "" { + return fmt.Errorf("--app is required") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + out := cmd.OutOrStdout() + restore := quietDeployNarration(asJSON) + defer restore() + t, err := openConfigTarget(ctx, flags, appName) + if err != nil { + return err + } + defer t.exec.Close() + g, err := gitSourceOf(ctx, t) + if err != nil { + return err + } + if _, err := gitsource.RetryClear(ctx, t.exec, t.app, g.Repository, g.Branch); err != nil { + return err + } + res, err := pollGitApp(ctx, t, true, "") + if asJSON { + res.MachineInterface = MachineInterface + res.Host = t.server + if encodeErr := encodeJSONLine(out, res); encodeErr != nil && err == nil { + return encodeErr + } + return err + } + if err != nil { + return err + } + printPollResult(out, res) + return nil + }, + } + cmd.Flags().StringVar(&appName, "app", "", "app (required)") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +func newTriggerInstallTimerCmd(flags *Flags) *cobra.Command { + var binaryPath string + var asJSON bool + cmd := &cobra.Command{ + Use: "install-timer --host ", + Short: "Install the systemd timer running `trigger poll --all` every minute", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.Host == "" { + return fmt.Errorf("--host is required") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + host, user, key, err := config.ResolveServer(flags.Host, flags.Host, flags.User, flags.Key) + if err != nil { + return err + } + exec, err := ssh.Connect(ctx, ssh.ConnectConfig{Host: host, User: user, KeyPath: key}) + if err != nil { + return err + } + defer exec.Close() + bin, err := ensureTriggerBinary(ctx, exec, binaryPath) + if err != nil { + return err + } + if err := installTriggerUnits(ctx, exec, bin); err != nil { + return err + } + if asJSON { + return encodeJSONLine(cmd.OutOrStdout(), map[string]any{"machine_interface": MachineInterface, "host": flags.Host, "timer": triggerPollTimer, "binary": bin}) + } + fmt.Fprintf(cmd.OutOrStdout(), "Installed %s (running %s trigger poll --all every minute)\n", triggerPollTimer, bin) + return nil + }, + } + cmd.Flags().StringVar(&binaryPath, "binary", "", "upload this local teploy binary for the timer instead of the latest release") + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +func newTriggerUninstallTimerCmd(flags *Flags) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "uninstall-timer --host ", + Short: "Remove the trigger poll timer and service", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.Host == "" { + return fmt.Errorf("--host is required") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + host, user, key, err := config.ResolveServer(flags.Host, flags.Host, flags.User, flags.Key) + if err != nil { + return err + } + exec, err := ssh.Connect(ctx, ssh.ConnectConfig{Host: host, User: user, KeyPath: key}) + if err != nil { + return err + } + defer exec.Close() + sudo := sudoPrefixFor(ctx, exec) + for _, u := range []string{triggerPollTimer, triggerPollService} { + exec.Run(ctx, sudo+"systemctl disable --now "+ssh.ShellQuote(u)+" 2>/dev/null") + if _, err := exec.Run(ctx, sudo+"rm -f /etc/systemd/system/"+u); err != nil { + return err + } + } + if _, err := exec.Run(ctx, sudo+"systemctl daemon-reload"); err != nil { + return err + } + if asJSON { + return encodeJSONLine(cmd.OutOrStdout(), map[string]any{"machine_interface": MachineInterface, "host": flags.Host, "removed": true}) + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed %s and %s\n", triggerPollTimer, triggerPollService) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", flags.JSON, "machine-readable output") + return cmd +} + +// ensureTriggerBinary makes sure a teploy binary that supports +// `trigger poll` exists on the server and returns its path: the released +// /deployments/.bin/teploy when it qualifies, a dev upload with --binary +// (installed beside it as teploy-dev), or a fresh release download. +func ensureTriggerBinary(ctx context.Context, exec ssh.Executor, localBinary string) (string, error) { + // The publish step flocks .install.lock, so the + // directory must exist first (a server that never ran autodeploy or + // heal has no /deployments/.bin yet). + if _, err := exec.Run(ctx, "mkdir -p "+ssh.ShellQuote(serverTeployBinDir)); err != nil { + return "", fmt.Errorf("creating %s: %w", serverTeployBinDir, err) + } + if localBinary != "" { + data, err := os.ReadFile(localBinary) + if err != nil { + return "", fmt.Errorf("reading %s: %w", localBinary, err) + } + dst := serverTeployBinDir + "/teploy-dev" + if err := installServerBinary(ctx, exec, dst, data, teployVersionForServer(), "trigger poll"); err != nil { + return "", err + } + return dst, nil + } + const released = serverTeployBinDir + "/teploy" + if _, err := exec.Run(ctx, ssh.ShellQuote(released)+" trigger poll --help >/dev/null 2>&1"); err == nil { + return released, nil + } + if _, err := deployTeployBinaryToServer(ctx, exec, released, "trigger poll"); err != nil { + return "", fmt.Errorf("installing the server teploy binary: %w (install one manually at %s and re-run)", err, released) + } + return released, nil +} + +// installTriggerUnits writes the service + timer and enables it. +func installTriggerUnits(ctx context.Context, exec ssh.Executor, bin string) error { + sudo := sudoPrefixFor(ctx, exec) + userLine := "" + if out, err := exec.Run(ctx, "id -u"); err == nil && strings.TrimSpace(out) != "0" { + if who, err := exec.Run(ctx, "id -un"); err == nil && strings.TrimSpace(who) != "" { + userLine = "User=" + strings.TrimSpace(who) + "\n" + } + } + service := "[Unit]\nDescription=Teploy git-source poll (admits and deploys new heads)\n\n[Service]\nType=oneshot\n" + + userLine + "ExecStart=" + bin + " trigger poll --all\n" + timer := "[Unit]\nDescription=Run the Teploy git-source poll every minute\n\n[Timer]\nOnBootSec=90\nOnUnitActiveSec=60\nAccuracySec=5s\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n" + if err := installSystemdUnit(ctx, exec, sudo, triggerPollService, service); err != nil { + return err + } + if err := installSystemdUnit(ctx, exec, sudo, triggerPollTimer, timer); err != nil { + return err + } + if _, err := exec.Run(ctx, sudo+"systemctl daemon-reload"); err != nil { + return fmt.Errorf("systemctl daemon-reload: %w", err) + } + if _, err := exec.Run(ctx, sudo+"systemctl enable --now "+ssh.ShellQuote(triggerPollTimer)); err != nil { + return fmt.Errorf("enabling %s: %w", triggerPollTimer, err) + } + return nil +} + +// offerTriggerTimer best-effort installs the poll timer after a +// credential is created (the first thing a git-source app needs). +func offerTriggerTimer(ctx context.Context, t *configTarget, out io.Writer) { + if _, err := t.exec.Run(ctx, "systemctl is-enabled "+ssh.ShellQuote(triggerPollTimer)+" >/dev/null 2>&1"); err == nil { + return + } + bin, err := ensureTriggerBinary(ctx, t.exec, "") + if err != nil { + fmt.Fprintf(out, "Note: the server poll timer is not installed yet (%v)\n install it with: teploy trigger install-timer --host %s\n", err, t.server) + return + } + if err := installTriggerUnits(ctx, t.exec, bin); err != nil { + fmt.Fprintf(out, "Note: could not install the server poll timer (%v)\n install it with: teploy trigger install-timer --host %s\n", err, t.server) + return + } + fmt.Fprintf(out, "Installed the server poll timer (%s)\n", triggerPollTimer) +} diff --git a/internal/config/app.go b/internal/config/app.go index 2c024a2..5073666 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -14,6 +14,7 @@ import ( "slices" "strconv" "strings" + "time" "github.com/BurntSushi/toml" "gopkg.in/yaml.v3" @@ -38,6 +39,177 @@ var validMemory = regexp.MustCompile(`(?i)^\d+(\.\d+)?[bkmg]?$`) // validCPU matches a --cpus value: a positive decimal core count ("0.5", "2"). var validCPU = regexp.MustCompile(`^\d+(\.\d+)?$`) +// validSecretKeyShape matches the age-store key grammar (mirrors +// secret.ValidateKey, kept local so config does not import the store). +var validSecretKeyShape = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`) + +// validateGitSource checks the `git:` block and `build_secrets:`. The +// home rule (git: only in Teploy home) lives in the folder loader, not +// here — LoadAppBytes is shared by both homes. +func (c *AppConfig) validateGitSource() error { + if c.Git == nil { + if len(c.BuildSecrets) > 0 { + return fmt.Errorf("'build_secrets:' needs 'git:' — build secrets are passed to the repository build") + } + return nil + } + g := c.Git + if c.Image != "" { + return fmt.Errorf("'image:' cannot be set with 'git:' — remove one (git: builds from the repository, image: deploys a pre-built image)") + } + if g.Repository == "" { + return fmt.Errorf("git.repository: is required (e.g. git@github.com:org/repo or https://github.com/org/repo.git)") + } + if err := ValidateGitRepository(g.Repository); err != nil { + return fmt.Errorf("git.repository: %w", err) + } + if g.Branch == "" { + return fmt.Errorf("git.branch: is required (the branch the server polls and builds)") + } + if err := ValidateGitBranch(g.Branch); err != nil { + return fmt.Errorf("git.branch: %w", err) + } + for _, p := range g.Paths { + if err := validateGitPath(p); err != nil { + return fmt.Errorf("git.paths: %w", err) + } + } + switch g.Poll { + case "", "off": + default: + if _, err := ParseGitPoll(g.Poll); err != nil { + return err + } + } + if g.Submodules { + return fmt.Errorf("git.submodules: submodule fetching is not implemented yet — flatten the repository or keep this app in folder home") + } + if c.Context != "" { + if err := validateGitPath(c.Context); err != nil { + return fmt.Errorf("context: %w (with git: it is relative to the repository root)", err) + } + } + if c.Dockerfile != "" { + if err := validateGitPath(c.Dockerfile); err != nil { + return fmt.Errorf("dockerfile: %w (with git: it is relative to the build context)", err) + } + } + for _, k := range c.BuildSecrets { + if !validSecretKeyShape.MatchString(k) { + return fmt.Errorf("build_secrets: invalid key %q — letters, digits, underscore, hyphen and dot only; must not start with a dash or dot", k) + } + } + return nil +} + +// validateGitPath accepts a relative path without traversal. +func validateGitPath(p string) error { + if p == "" || p == "." { + return fmt.Errorf("must not be empty") + } + if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") || filepath.IsAbs(p) { + return fmt.Errorf("%q must be relative to the repository root", p) + } + if p != filepath.Clean(p) || strings.Contains(p, "..") { + return fmt.Errorf("%q must be a clean relative path without ..", p) + } + if strings.ContainsAny(p, "\x00\n\r") { + return fmt.Errorf("%q must not contain control characters", p) + } + return nil +} + +// ValidateGitRepository checks a git remote locator a Teploy-home `git:` +// block may name: ssh://, scp-like user@host:path, https:// or file:// +// (a same-server repository; changing to or from file:// is a privileged +// config change). http://, git:// and other transports are refused — the +// first leaks the token, the second is unauthenticated, and arbitrary +// transports (ext::) would run commands. A URL may never embed a +// password: it would live in the config, the mirror and the logs. +func ValidateGitRepository(repo string) error { + if repo == "" { + return fmt.Errorf("is empty") + } + if strings.HasPrefix(repo, "-") || strings.ContainsAny(repo, " \x00\n\r\t") { + return fmt.Errorf("%q is not a repository URL", repo) + } + lower := strings.ToLower(repo) + switch { + case strings.HasPrefix(lower, "ssh://"), strings.HasPrefix(lower, "file://"): + if u, err := url.Parse(repo); err != nil || u.Path == "" { + return fmt.Errorf("%q is not a valid ssh:///file:// repository URL", repo) + } + case strings.HasPrefix(lower, "https://"): + u, err := url.Parse(repo) + if err != nil || u.Host == "" { + return fmt.Errorf("%q is not a valid https repository URL", repo) + } + if u.User != nil { + if _, exists := u.User.Password(); exists { + return fmt.Errorf("%q embeds a password — pass a token with `teploy config source key --type token` instead", repo) + } + } + case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "git://"): + return fmt.Errorf("%q uses an unencrypted transport — use ssh:// or https://", repo) + default: + // scp-like: user@host:path (colon before any slash, no scheme). + head, _, _ := strings.Cut(repo, "/") + if !strings.Contains(head, ":") || strings.HasPrefix(repo, "/") { + return fmt.Errorf("%q is not a repository URL (ssh://, user@host:path, https:// or file://)", repo) + } + host, _, ok := strings.Cut(head, ":") + if !ok || host == "" { + return fmt.Errorf("%q is not a valid scp-style repository (want user@host:path)", repo) + } + if at := strings.LastIndex(host, "@"); at >= 0 { + host = host[at+1:] + } + if host == "" || strings.ContainsAny(host, "()[]{}|;`$&<>\"'") { + return fmt.Errorf("%q is not a valid scp-style repository (want user@host:path)", repo) + } + } + return nil +} + +// GitRepositoryIsLocal reports whether the repository is a file:// path on +// the app's own server (a privileged locator: it reads server-local files). +func GitRepositoryIsLocal(repo string) bool { + return strings.HasPrefix(strings.ToLower(repo), "file://") +} + +// ValidateGitBranch enforces one branch-name component (no slashes, no +// ref-syntax metacharacters) — the admitter maps it onto refs/heads/. +func ValidateGitBranch(branch string) error { + if branch == "" { + return fmt.Errorf("is empty") + } + if strings.HasPrefix(branch, "-") || strings.HasPrefix(branch, ".") || strings.HasSuffix(branch, ".") || + strings.HasSuffix(branch, ".lock") || strings.HasSuffix(branch, "/") || strings.Contains(branch, "//") || + strings.Contains(branch, "..") || strings.ContainsAny(branch, " ~^:?*[\\/\x00\n\r\t") || strings.Contains(branch, "@{") { + return fmt.Errorf("%q is not a valid branch name", branch) + } + if len(branch) > 200 { + return fmt.Errorf("is too long (max 200 chars)") + } + return nil +} + +// ParseGitPoll parses a git.poll interval (minimum one minute; the server +// timer wakes every minute and each app honors its own interval). +func ParseGitPoll(v string) (time.Duration, error) { + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("git.poll: %q is neither \"off\" nor a duration like 5m", v) + } + if d < time.Minute { + return 0, fmt.Errorf("git.poll: %s is below the one-minute minimum", v) + } + if d > 24*time.Hour { + return 0, fmt.Errorf("git.poll: %s is above the 24h maximum", v) + } + return d, nil +} + // validateResources checks a memory/CPU pair from either an app or an accessory. // which names the offending block for the error message. func validateResources(which, memory, cpu string) error { @@ -86,6 +258,25 @@ type AutodeployConfig struct { Paths []string `yaml:"paths,omitempty" toml:"paths"` } +// GitSource names the git repository a Teploy-home app builds from (the +// `git:` block). The server fetches the branch into a per-app mirror with +// the app's stored credential and builds the admitted commit; `context:` +// and `dockerfile:` are interpreted relative to the repository root. +// Valid only in Teploy home (folder apps build from their folder) — the +// folder loader refuses it. `poll` is the server poll interval ("5m", +// minimum 1m) or "off" (webhook wakes only). +type GitSource struct { + Repository string `yaml:"repository,omitempty" toml:"repository"` + Branch string `yaml:"branch,omitempty" toml:"branch"` + // Paths restricts which changes trigger a deploy (monorepo filter); + // empty plus a custom `context:` defaults to that context's directory. + Paths []string `yaml:"paths,omitempty" toml:"paths"` + Poll string `yaml:"poll,omitempty" toml:"poll"` + // Submodules is accepted for forward-compatibility; true is refused + // until submodule fetching is implemented. + Submodules bool `yaml:"submodules,omitempty" toml:"submodules"` +} + // CanaryCount resolves the canary spec against the fleet size: integer count // or "N%" (rounded up, so a nonzero percent of a small fleet is never zero), // clamped to [1, total-1] — a canary that is the whole fleet is not a canary. @@ -406,6 +597,14 @@ type AppConfig struct { // (monorepo change filtering); absent (nil) = every push on the watched // branch deploys, the historical behavior. Autodeploy *AutodeployConfig `yaml:"autodeploy,omitempty" toml:"autodeploy"` + // Git is the `git:` block: the repository this app builds from + // (Teploy home only). With it set, `image:` must be empty — the + // app builds from source. + Git *GitSource `yaml:"git,omitempty" toml:"git"` + // BuildSecrets names age-store keys passed to the build as + // `docker build --secret id=` (values never enter the config, + // image layers or logs). Requires `git:`. + BuildSecrets []string `yaml:"build_secrets,omitempty" toml:"build_secrets"` // Scan runs a Trivy vulnerability scan on the image server-side before // containers start: HIGH+CRITICAL findings are reported, and fixable // CRITICALs block the deploy. @@ -1009,6 +1208,9 @@ func (c *AppConfig) validate() error { default: return fmt.Errorf("'type' must be 'container' or 'static' (got %q)", c.Type) } + if err := c.validateGitSource(); err != nil { + return err + } if c.TLS != nil { if c.TLS.Internal { if c.TLS.Cert != "" || c.TLS.Key != "" { @@ -1261,6 +1463,9 @@ func LoadApp(dir string) (*AppConfig, error) { if err := cfg.validate(); err != nil { return nil, invalidConfig(fmt.Errorf("invalid %s: %w", name, err)) } + if cfg.Git != nil { + return nil, invalidConfig(fmt.Errorf("invalid %s: git: is a Teploy-home key — a folder app builds from its folder; move the app with `teploy config home teploy` to build from a repository", name)) + } return &cfg, nil } @@ -1348,6 +1553,9 @@ func LoadAppWithDestination(dir, dest string, opts OverlayOptions) (*AppConfig, if err := base.validate(); err != nil { return nil, invalidConfig(fmt.Errorf("invalid config after merging %s: %w", name, err)) } + if base.Git != nil { + return nil, invalidConfig(fmt.Errorf("invalid config after merging %s: git: is a Teploy-home key — a folder app builds from its folder", name)) + } return base, nil } diff --git a/internal/config/git_source_test.go b/internal/config/git_source_test.go new file mode 100644 index 0000000..27069d0 --- /dev/null +++ b/internal/config/git_source_test.go @@ -0,0 +1,119 @@ +package config + +import ( + "os" + "strings" + "testing" + "time" +) + +func TestValidateGitRepository(t *testing.T) { + ok := []string{ + "git@github.com:useteploy/teploy.git", + "ssh://git@github.com:22/useteploy/teploy.git", + "ssh://git@forge.internal:2222/org/repo.git", + "https://github.com/useteploy/teploy.git", + "https://user@github.com/useteploy/teploy.git", + "file:///srv/git/repo.git", + } + for _, repo := range ok { + if err := ValidateGitRepository(repo); err != nil { + t.Errorf("ValidateGitRepository(%q) = %v, want ok", repo, err) + } + } + bad := map[string]string{ + "": "empty", + "http://github.com/org/repo": "unencrypted", + "https://user:pass@host/r": "password", + "git://host/r": "git protocol", + "-oProxyCommand=x": "dash", + "/local/path": "bare path", + "ext::sh -c id": "ext", + "git@github.com:useteploy/x git push --force": "space", + } + for repo, why := range bad { + if err := ValidateGitRepository(repo); err == nil { + t.Errorf("ValidateGitRepository(%q) accepted (%s)", repo, why) + } + } + if !GitRepositoryIsLocal("file:///x") || GitRepositoryIsLocal("ssh://x/y") { + t.Error("GitRepositoryIsLocal misclassifies") + } +} + +func TestValidateGitBranch(t *testing.T) { + for _, b := range []string{"main", "develop", "release-1.2", "v1_x"} { + if err := ValidateGitBranch(b); err != nil { + t.Errorf("ValidateGitBranch(%q) = %v", b, err) + } + } + for _, b := range []string{"", "-x", ".x", "x.", "a..b", "a//b", "a/b", "x.lock", "a b", "a~", "x^", "@{x"} { + if err := ValidateGitBranch(b); err == nil { + t.Errorf("ValidateGitBranch(%q) accepted", b) + } + } +} + +func TestParseGitPoll(t *testing.T) { + for _, v := range []string{"1m", "5m", "30m", "2h", "24h"} { + if _, err := ParseGitPoll(v); err != nil { + t.Errorf("ParseGitPoll(%q) = %v", v, err) + } + } + for _, v := range []string{"0s", "30s", "48h", "5x", "off"} { + if _, err := ParseGitPoll(v); err == nil { + t.Errorf("ParseGitPoll(%q) accepted", v) + } + } + if d, _ := ParseGitPoll("90s"); d != 90*time.Second { + t.Errorf("ParseGitPoll(90s) = %v", d) + } +} + +func TestLoadAppRefusesGitInFolder(t *testing.T) { + dir := t.TempDir() + write := func(body string) { + t.Helper() + if err := osWriteFile(dir+"/teploy.yml", body); err != nil { + t.Fatal(err) + } + } + write("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n") + _, err := LoadApp(dir) + if err == nil || !strings.Contains(err.Error(), "Teploy-home") { + t.Fatalf("folder LoadApp with git: = %v, want Teploy-home refusal", err) + } +} + +func TestLoadAppBytesGitValidation(t *testing.T) { + load := func(body string) error { + t.Helper() + _, err := LoadAppBytes([]byte(body)) + return err + } + if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n"); err != nil { + t.Fatalf("valid git: rejected: %v", err) + } + if err := load("app: demo\ndomain: demo.invalid\nimage: nginx:1\ngit:\n repository: git@github.com:org/repo\n branch: main\n"); err == nil || !strings.Contains(err.Error(), "image") { + t.Fatalf("image+git = %v, want refusal", err) + } + if err := load("app: demo\ndomain: demo.invalid\ngit:\n branch: main\n"); err == nil || !strings.Contains(err.Error(), "repository") { + t.Fatalf("missing repository = %v", err) + } + if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n submodules: true\n"); err == nil || !strings.Contains(err.Error(), "submodules") { + t.Fatalf("submodules: true = %v, want refusal", err) + } + if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n poll: 30s\n"); err == nil || !strings.Contains(err.Error(), "poll") { + t.Fatalf("poll: 30s = %v, want refusal", err) + } + if err := load("app: demo\ndomain: demo.invalid\nbuild_secrets: [TOKEN]\n"); err == nil || !strings.Contains(err.Error(), "build_secrets") { + t.Fatalf("build_secrets without git = %v, want refusal", err) + } + if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n paths: [\"../escape\"]\n"); err == nil || !strings.Contains(err.Error(), "paths") { + t.Fatalf("escaping path = %v, want refusal", err) + } +} + +func osWriteFile(path, body string) error { + return os.WriteFile(path, []byte(body), 0o600) +} diff --git a/internal/confighome/git_test.go b/internal/confighome/git_test.go new file mode 100644 index 0000000..3e7f025 --- /dev/null +++ b/internal/confighome/git_test.go @@ -0,0 +1,22 @@ +package confighome + +import ( + "strings" + "testing" +) + +func TestLoadGitSource(t *testing.T) { + git := "git:\n repository: git@github.com:org/repo\n branch: main\n" + if _, err := Load([]byte("app: demo\ndomain: demo.invalid\n"+git), "demo"); err != nil { + t.Fatalf("git: without image rejected: %v", err) + } + if _, err := Load([]byte("app: demo\ndomain: demo.invalid\nimage: nginx:1\n"+git), "demo"); err == nil || !strings.Contains(err.Error(), "image") { + t.Fatalf("image+git = %v, want refusal", err) + } + if _, err := Load([]byte("app: demo\ndomain: demo.invalid\ntype: static\nsource: ./site\n"+git), "demo"); err == nil || !strings.Contains(err.Error(), "git") { + t.Fatalf("static+git = %v, want refusal", err) + } + if _, err := Load([]byte("app: demo\ndomain: demo.invalid\n"+git+"build_secrets: [NPM_TOKEN]\n"), "demo"); err != nil { + t.Fatalf("git + build_secrets rejected: %v", err) + } +} diff --git a/internal/confighome/store.go b/internal/confighome/store.go index a75de6c..cd50750 100644 --- a/internal/confighome/store.go +++ b/internal/confighome/store.go @@ -79,6 +79,10 @@ type Author struct { // Dir is the app's server data directory. func Dir(app string) string { return rootDir() + "/apps/" + app } +// AppsDir is the directory holding every app's server data (the poll +// timer and doctor enumerate git-source apps from it). +func AppsDir() string { return rootDir() + "/apps" } + // RepoPath is the app's config repository. func RepoPath(app string) string { return Dir(app) + "/config.git" } diff --git a/internal/confighome/validate.go b/internal/confighome/validate.go index cf9b5f2..60f8d82 100644 --- a/internal/confighome/validate.go +++ b/internal/confighome/validate.go @@ -56,8 +56,11 @@ func Load(raw []byte, app string) (*config.AppConfig, error) { if len(cfg.Build) > 0 && !cfg.BuildRemote { add("build: commands would run in the deploying machine's shell — set build_remote: true") } - } else if cfg.Image == "" { - add("image: is required — a Teploy-home app has no folder to build from") + if cfg.Git != nil { + add("git: static sites cannot build from a git repository yet — keep this app in folder home") + } + } else if cfg.Image == "" && cfg.Git == nil { + add("image: is required — a Teploy-home app has no folder to build from (or set git: to build from a repository)") } keys := make([]string, 0, len(cfg.Env)) for k := range cfg.Env { @@ -178,6 +181,11 @@ func PrivilegedChange(prev, next *config.AppConfig) bool { prev.Network != next.Network { return true } + // A file:// git repository reads server-local paths with the deploy + // user's rights — pointing one anywhere new reaches the host. + if gitLocalRepo(prev) != gitLocalRepo(next) { + return true + } if (prev.Ingress == config.IngressHost || next.Ingress == config.IngressHost) && prev.Port != next.Port { return true } @@ -207,3 +215,11 @@ func hostBinds(vols map[string]string) string { sort.Strings(out) return strings.Join(out, ",") } + +// gitLocalRepo names a config's file:// repository ("" when none/remote). +func gitLocalRepo(cfg *config.AppConfig) string { + if cfg.Git != nil && config.GitRepositoryIsLocal(cfg.Git.Repository) { + return cfg.Git.Repository + } + return "" +} diff --git a/internal/events/events.go b/internal/events/events.go index a5ae1b3..d003142 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -109,6 +109,7 @@ const ( KindMaintenanceOff = "maintenance.off" KindGateRepair = "gate.repair" KindWatermarkReset = "watermark.reset" + KindSourceConnect = "source.connect" KindBackupRun = "backup.run" KindBackupRestore = "backup.restore" KindBackupTarget = "backup_target.set" @@ -132,8 +133,8 @@ var detailAllow = map[string][]string{ KindConfigSet: {"privileged", "keys", "previous_commit"}, KindConfigHome: {"from", "to"}, KindRemove: {"purge", "redirect", "error"}, - KindTriggerAdmit: {"ref", "commit", "delivery_id", "preview"}, - KindTriggerRefuse: {"ref", "commit", "delivery_id", "reason"}, + KindTriggerAdmit: {"ref", "commit", "delivery_id", "preview", "reason"}, + KindTriggerRefuse: {"ref", "commit", "delivery_id", "reason", "error"}, KindPreviewDeploy: {"preview", "branch", "error"}, KindPreviewDestroy: {"preview", "error"}, KindLock: {"message"}, diff --git a/internal/gitsource/gitsource.go b/internal/gitsource/gitsource.go new file mode 100644 index 0000000..ab94862 --- /dev/null +++ b/internal/gitsource/gitsource.go @@ -0,0 +1,528 @@ +// Package gitsource implements the D4 git code source for Teploy-home +// apps: per-app credentials stored age-encrypted outside /deployments, a +// server-side bare mirror, and the one admitter (`trigger poll`) that +// decides which branch head deploys. Everything runs through the +// server-side python helper over the app's executor, so private keys and +// tokens never leave the server. +package gitsource + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/ssh" +) + +// ErrAbsent reports a missing credential, ref or commit (helper exit 3). +var ErrAbsent = errors.New("not found") + +// ErrRefused is a refusal with an operator remedy (host-key pin mismatch). +type ErrRefused struct{ Reason string } + +func (e *ErrRefused) Error() string { return e.Reason } + +// KeyInfo is the public half of a generated deploy key or stored token. +type KeyInfo struct { + Type string `json:"type"` + PublicKey string `json:"public_key"` + Fingerprint string `json:"fingerprint"` + Username string `json:"username,omitempty"` + Created bool `json:"created"` +} + +// HostKeyEntry is one pinned host key line with its fingerprint. +type HostKeyEntry struct { + Line string `json:"line"` + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` +} + +// ShowState is `config source show`: public facts only, never a secret. +type ShowState struct { + Type string `json:"type"` + CreatedAt int64 `json:"created_at,omitempty"` + PublicKey string `json:"public_key,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Username string `json:"username,omitempty"` + KnownHosts []HostKeyEntry `json:"known_hosts,omitempty"` +} + +// GenerateKey creates (or returns) the app's ed25519 deploy key, +// age-encrypted outside /deployments, and prints only its public half. +func GenerateKey(ctx context.Context, exec ssh.Executor, app string, rotate bool) (KeyInfo, error) { + arg := "keep" + if rotate { + arg = "rotate" + } + var out KeyInfo + if err := runJSON(ctx, exec, &out, nil, "keygen", app, arg); err != nil { + return KeyInfo{}, err + } + return out, nil +} + +// SetToken stores an HTTPS token read from stdin (never in argv). +func SetToken(ctx context.Context, exec ssh.Executor, app, username string, token []byte) (KeyInfo, error) { + var out KeyInfo + if err := runJSON(ctx, exec, &out, token, "token", app, username); err != nil { + return KeyInfo{}, err + } + return out, nil +} + +// PinHostKey scans the repository host's SSH keys, verifies an expected +// fingerprint when given, and stores them as the app's known_hosts. +func PinHostKey(ctx context.Context, exec ssh.Executor, app, repository, expectedFingerprint string) ([]HostKeyEntry, error) { + host, port, err := RepositorySSHHost(repository) + if err != nil { + return nil, err + } + var out struct { + Entries []HostKeyEntry `json:"entries"` + Matched bool `json:"matched"` + } + res := sshRun(ctx, exec, nil, "hostkey", app, host, strconv.Itoa(port), expectedFingerprint) + if res.Err != nil { + return nil, fmt.Errorf("scanning %s:%d: %w", host, port, res.Err) + } + switch res.ExitCode { + case 0, 5: + if err := jsonUnmarshal(res.Stdout, &out); err != nil { + return nil, fmt.Errorf("scanning %s:%d: unreadable reply", host, port) + } + case 3: + return nil, fmt.Errorf("ssh-keyscan found no keys for %s:%d — is the forge's SSH endpoint up?", host, port) + default: + return nil, helperError("scanning host keys", res) + } + if expectedFingerprint != "" && !out.Matched { + return out.Entries, &ErrRefused{Reason: fmt.Sprintf("host %s presented %s, expected %s — if the host really changed, verify the new fingerprint out of band and re-run without --expected-fingerprint", host, fingerprintOf(out.Entries), expectedFingerprint)} + } + return out.Entries, nil +} + +func fingerprintOf(entries []HostKeyEntry) string { + for _, e := range entries { + if e.Fingerprint != "" { + return e.Fingerprint + } + } + return "no keys" +} + +// Show returns the app's public source state (no secrets). +func Show(ctx context.Context, exec ssh.Executor, app string) (ShowState, error) { + var out ShowState + err := runJSON(ctx, exec, &out, nil, "show", app) + if errors.Is(err, ErrAbsent) { + return ShowState{Type: "none"}, ErrAbsent + } + return out, err +} + +// Remove deletes the app's credential directory (token, key, host keys). +func Remove(ctx context.Context, exec ssh.Executor, app string) error { + return runOK(ctx, exec, nil, "remove", app) +} + +// LsRemote resolves the remote ref's head with the app credential. +// ErrAbsent means the ref is not there. +func LsRemote(ctx context.Context, exec ssh.Executor, app, repository, branch string) (string, error) { + out, err := run(ctx, exec, nil, "lsremote", app, repository, "refs/heads/"+branch) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// FetchMirror fetches the branch into the per-app mirror and verifies sha +// arrived. Returns the sha actually fetched. +func FetchMirror(ctx context.Context, exec ssh.Executor, app, repository, branch, sha string) (string, error) { + out, err := run(ctx, exec, nil, "fetch", app, repository, branch, sha) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// IsAncestor reports whether a is an ancestor of b in the mirror. +// ErrAbsent means not an ancestor; exit 4 (unverifiable, object missing) +// is reported as a distinct message wrapped in ErrAbsent-like semantics: +// the caller treats it as a force-push refusal with a reset remedy. +func IsAncestor(ctx context.Context, exec ssh.Executor, app, branch, a, b string) (bool, error) { + res := sshRun(ctx, exec, nil, "ancestor", app, branch, a, b) + if res.Err != nil { + return false, fmt.Errorf("checking ancestry: %w", res.Err) + } + switch res.ExitCode { + case 0: + return true, nil + case 3: + return false, nil + case 4: + return false, &ErrRefused{Reason: fmt.Sprintf("commit %s is no longer in %s's mirror (history was rewritten and pruned) — run `teploy trigger reset --app %s --ref refs/heads/%s` to restart from the current head", short(a), app, app, branch)} + } + return false, helperError("checking ancestry", res) +} + +// DiffNames lists files changed between two commits in the mirror. +func DiffNames(ctx context.Context, exec ssh.Executor, app, branch, from, to string) ([]string, error) { + out, err := run(ctx, exec, nil, "diff", app, branch, from, to) + if err != nil { + return nil, err + } + fields := strings.Fields(out) + if len(fields) == 0 { + return nil, nil + } + return fields, nil +} + +// ExportArchive extracts the branch's tree into dest and reports the +// build mode plus whether the repository declares submodules. +func ExportArchive(ctx context.Context, exec ssh.Executor, app, dest, branch, contextDir, dockerfile string) (mode string, hasGitmodules bool, err error) { + var out struct { + Mode string `json:"mode"` + HasGitmodules bool `json:"has_gitmodules"` + } + if err := runJSON(ctx, exec, &out, nil, "export", app, dest, branch, contextDir, dockerfile); err != nil { + return "", false, err + } + return out.Mode, out.HasGitmodules, nil +} + +// SealBuildSecret decrypts one age-store secret into dest (0600) on the +// server, for `docker build --secret`. src-dest never carries the value. +func SealBuildSecret(ctx context.Context, exec ssh.Executor, app, key, dest string) error { + return runOK(ctx, exec, nil, "seal-secret", app, key, dest) +} + +// --------------------------------------------------------------------------- +// Admission state + +// Commit outcomes (admissions ledger). +const ( + RecordDeployed = "deployed" + RecordFailed = "failed" + RecordSkipped = "skipped" + RecordRefused = "refused" +) + +// MaxDeployAttempts is one attempt plus the two retries a failed commit +// gets before only a new commit, `trigger retry` or a redeploy restarts it. +const MaxDeployAttempts = 3 + +// PollState is the admitter's durable record under +// /deployments/.teploy/apps//admissions/state.json. +type PollState struct { + SchemaVersion int `json:"schema_version"` + Repository string `json:"repository,omitempty"` + Refs map[string]*RefState `json:"refs,omitempty"` + Commits map[string]*CommitInfo `json:"commits,omitempty"` + LastPoll int64 `json:"last_poll,omitempty"` +} + +// RefState is the watermark of one (repository, ref). +type RefState struct { + Watermark string `json:"watermark,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + RefusedHead string `json:"refused_head,omitempty"` + RefusedAt int64 `json:"refused_at,omitempty"` +} + +// CommitInfo is the bounded per-commit ledger entry (dedup + retries). +type CommitInfo struct { + Status string `json:"status"` + Attempts int `json:"attempts,omitempty"` + LastAttempt int64 `json:"last_attempt,omitempty"` + LastError string `json:"last_error,omitempty"` + Release string `json:"release,omitempty"` +} + +// LoadState reads the admissions state ({} when absent). +func LoadState(ctx context.Context, exec ssh.Executor, app string) (*PollState, error) { + var st PollState + res := sshRun(ctx, exec, nil, "state-get", app) + if res.Err != nil { + return nil, fmt.Errorf("reading %s's admissions: %w", app, res.Err) + } + if res.ExitCode != 0 { + return nil, helperError("reading admissions", res) + } + if err := jsonUnmarshal(res.Stdout, &st); err != nil { + return nil, fmt.Errorf("reading %s's admissions: %w", app, err) + } + return &st, nil +} + +// SaveState writes the admissions state atomically (flock'd) and keeps +// the watermark commits reachable in the mirror. +func SaveState(ctx context.Context, exec ssh.Executor, app string, st *PollState) error { + prune(st) + data, err := jsonMarshal(st) + if err != nil { + return err + } + return runOK(ctx, exec, data, "state-put", app) +} + +// prune bounds the commit ledger to the newest maxLedger entries. +func prune(st *PollState) { + const maxLedger = 100 + if len(st.Commits) <= maxLedger { + return + } + // Drop the least-recently-attempted entries; watermarks never depend + // on the ledger (ancestry uses the mirror refs). + type kv struct { + k string + v *CommitInfo + } + var entries []kv + for k, v := range st.Commits { + entries = append(entries, kv{k, v}) + } + for _, e := range entries { + if len(st.Commits) <= maxLedger { + break + } + if !isWatermark(st, e.k) { + delete(st.Commits, e.k) + } + } +} + +func isWatermark(st *PollState, ledgerKey string) bool { + for _, r := range st.Refs { + if r != nil && ledgerKey == r.Watermark { + return true + } + } + return false +} + +// Ref returns the ref's state, creating it when missing. +func (s *PollState) Ref(ref string) *RefState { + if s.Refs == nil { + s.Refs = map[string]*RefState{} + } + r, ok := s.Refs[ref] + if !ok || r == nil { + r = &RefState{} + s.Refs[ref] = r + } + return r +} + +// Commit returns the commit's ledger entry, creating it when missing. +func (s *PollState) Commit(ref, sha string) *CommitInfo { + if s.Commits == nil { + s.Commits = map[string]*CommitInfo{} + } + k := ledgerKey(ref, sha) + c, ok := s.Commits[k] + if !ok || c == nil { + c = &CommitInfo{} + s.Commits[k] = c + } + return c +} + +func ledgerKey(ref, sha string) string { return ref + "/" + short(sha) } + +func short(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} + +// --------------------------------------------------------------------------- +// Repository URL helpers + +// RepositorySSHHost extracts host and port for host-key pinning from an +// ssh:// or scp-style repository. https/file repositories need no pin. +func RepositorySSHHost(repository string) (string, int, error) { + lower := strings.ToLower(repository) + if strings.HasPrefix(lower, "file://") || strings.HasPrefix(lower, "https://") { + return "", 0, fmt.Errorf("%s is not an SSH repository — no host key to pin", repository) + } + if strings.HasPrefix(lower, "ssh://") { + u, err := url.Parse(repository) + if err != nil || u.Host == "" { + return "", 0, fmt.Errorf("%q is not a valid ssh:// repository", repository) + } + host := u.Hostname() + port := 22 + if p := u.Port(); p != "" { + if n, err := strconv.Atoi(p); err == nil { + port = n + } + } + return host, port, nil + } + // scp-like user@host:path + slash := strings.Index(repository, "/") + colon := strings.Index(repository, ":") + if colon <= 0 || (slash >= 0 && slash < colon) { + return "", 0, fmt.Errorf("%q is not an SSH repository — no host key to pin", repository) + } + hostpart := repository[:colon] + if at := strings.LastIndex(hostpart, "@"); at >= 0 { + hostpart = hostpart[at+1:] + } + return hostpart, 22, nil +} + +// PathRelevant reports whether any changed file matches the filter: +// empty filter deploys on any change. Entries are directories (trailing +// slash or bare path prefix) or path.Match globs; the build context +// directory always matches its own files. +func PathRelevant(changed []string, filter []string) bool { + if len(filter) == 0 { + return true + } + for _, c := range changed { + for _, p := range filter { + if p == "" { + continue + } + if strings.HasPrefix(p, "/") { + p = strings.TrimPrefix(p, "/") + } + if c == p || strings.HasPrefix(c, p+"/") || strings.HasPrefix(c, strings.TrimSuffix(p, "/")+"/") { + return true + } + if m, err := path.Match(p, c); err == nil && m { + return true + } + // A bare glob ("*.go") also matches nested files' basenames. + if !strings.Contains(p, "/") { + if m, err := path.Match(p, path.Base(c)); err == nil && m { + return true + } + } + // A bare directory entry ("web") also covers "web/**". + if strings.HasSuffix(p, "/**") && strings.HasPrefix(c, strings.TrimSuffix(p, "**")) { + return true + } + } + } + return false +} + +// DefaultPaths is the path filter a config implies: git.paths when set, +// else the context directory, else everything. +func DefaultPaths(appCfg *config.AppConfig) []string { + if appCfg == nil || appCfg.Git == nil { + return nil + } + if len(appCfg.Git.Paths) > 0 { + return appCfg.Git.Paths + } + if context := contextDirOf(appCfg); context != "" { + return []string{context} + } + return nil +} + +// ContextDir returns the config's build context relative to the repo +// root ("" means the root itself). +func contextDirOf(c *config.AppConfig) string { + if c == nil || c.Context == "" || c.Context == "." { + return "" + } + return c.Context +} + +// PollInterval parses the configured poll interval; "" defaults to 5m. +func PollInterval(g *config.GitSource) (time.Duration, bool) { + if g == nil || g.Poll == "" { + return 5 * time.Minute, true + } + if g.Poll == "off" { + return 0, false + } + d, err := config.ParseGitPoll(g.Poll) + if err != nil { + return 5 * time.Minute, true + } + return d, true +} + +// --------------------------------------------------------------------------- +// helper plumbing + +func sshRun(ctx context.Context, exec ssh.Executor, stdin []byte, args ...string) ssh.Result { + cmd := "python3 -c " + ssh.ShellQuote(helper) + for _, a := range args { + cmd += " " + ssh.ShellQuote(a) + } + if stdin != nil { + return ssh.RunInputDetailed(ctx, exec, cmd, bytes.NewReader(stdin)) + } + return ssh.RunDetailed(ctx, exec, cmd) +} + +func helperError(what string, res ssh.Result) error { + msg := strings.TrimSpace(string(res.Stderr)) + if msg == "" { + msg = strings.TrimSpace(string(res.Stdout)) + } + return fmt.Errorf("%s: exit %d: %s", what, res.ExitCode, msg) +} + +func run(ctx context.Context, exec ssh.Executor, stdin []byte, args ...string) (string, error) { + res := sshRun(ctx, exec, stdin, args...) + if res.Err != nil { + return "", res.Err + } + if res.ExitCode == 3 { + return "", ErrAbsent + } + if res.ExitCode != 0 { + return "", helperError(strings.Join(args, " "), res) + } + return string(res.Stdout), nil +} + +func runOK(ctx context.Context, exec ssh.Executor, stdin []byte, args ...string) error { + _, err := run(ctx, exec, stdin, args...) + return err +} + +func runJSON(ctx context.Context, exec ssh.Executor, out any, stdin []byte, args ...string) error { + res := sshRun(ctx, exec, stdin, args...) + if res.Err != nil { + return res.Err + } + switch res.ExitCode { + case 0: + if err := jsonUnmarshal(res.Stdout, out); err != nil { + return fmt.Errorf("%s: unreadable helper reply", args[0]) + } + return nil + case 3: + return ErrAbsent + case 5: + return &ErrRefused{Reason: strings.TrimSpace(string(res.Stderr))} + } + return helperError(args[0], res) +} + +func jsonUnmarshal(data []byte, v any) error { + return json.Unmarshal(bytes.TrimSpace(data), v) +} + +func jsonMarshal(v any) ([]byte, error) { + return json.Marshal(v) +} diff --git a/internal/gitsource/helper.go b/internal/gitsource/helper.go new file mode 100644 index 0000000..14e53ba --- /dev/null +++ b/internal/gitsource/helper.go @@ -0,0 +1,409 @@ +package gitsource + +// helper is the single server-side actor for git sources (D4). It runs +// over the app's executor (SSH from a client, or locally for the server +// timer) exactly like the confighome and events helpers, so no git +// credential, plaintext key or token ever crosses to a client. +// +// Layout on the server: +// +// credentials: /etc/teploy/apps/ (root) or +// ~/.config/teploy/apps/ (non-root) +// key.age, key.pub, token.age, known_hosts, source.json +// mirror: /deployments/.teploy/apps//mirror.git (code only) +// admissions: /deployments/.teploy/apps//admissions/ +// state.json (+ .state.lock, flock'd; watermark refs live +// in the mirror under refs/teploy/wm/) +// +// Secrets never appear in argv: tokens arrive on stdin, the deploy key is +// decrypted into a 0600 file under a private tmpfs directory for exactly +// one git invocation, and GIT_SSH_COMMAND / GIT_ASKPASS point at files. +// The age identity is tried at /etc/teploy/age-key then +// /deployments/.age-key (W2 moves it; both are accepted), and a missing +// identity is created at the historical /deployments/.age-key path — the +// same place the secret store creates it. +// +// The script deliberately avoids backslash escapes so it can live in a Go +// raw string: newlines are chr(10), stderr goes through print(). +// +// Exit codes: 0 ok; 3 "absent" (credential / ref / commit missing); +// 4 unverifiable ancestry; 5 host-key pin mismatch; 1 other errors. +const helper = ` +import fcntl, json, os, pwd, shutil, subprocess, sys, tempfile, time + +CRED_ROOT = os.environ.get("TEPLOY_GIT_CRED_ROOT_TEST") +CONFIG_ROOT = os.environ.get("TEPLOY_CONFIG_ROOT_TEST") or "/deployments/.teploy" +DEPLOYMENTS = os.environ.get("TEPLOY_DEPLOYMENTS_TEST") or "/deployments" +IDENTITIES = [p for p in os.environ.get("TEPLOY_AGE_IDENTITIES", "/etc/teploy/age-key:/deployments/.age-key").split(":") if p] +NL = chr(10) + +def fail(msg, code=1): + print(msg, file=sys.stderr) + sys.exit(code) + +def run(argv, data=None, env=None, check=True): + e = dict(os.environ) + e["GIT_TERMINAL_PROMPT"] = "0" + e.pop("GIT_DIR", None) + e.pop("SSH_AUTH_SOCK", None) + if env: + e.update(env) + r = subprocess.run(argv, input=data, capture_output=True, env=e) + if check and r.returncode != 0: + fail(r.stderr.decode(errors="replace")) + return r + +def cred_dir(app): + if "/" in app or app.startswith("."): + fail("invalid app name") + if CRED_ROOT: + base = CRED_ROOT + else: + uid = os.getuid() + if uid == 0: + base = "/etc/teploy/apps" + else: + home = os.environ.get("HOME") or pwd.getpwuid(uid).pw_dir + base = os.path.join(home, ".config/teploy/apps") + return os.path.join(base, app) + +def meta_path(app): + return os.path.join(cred_dir(app), "source.json") + +def read_meta(app): + try: + with open(meta_path(app)) as f: + return json.load(f) + except FileNotFoundError: + return None + +def write_meta(app, meta): + d = cred_dir(app) + os.makedirs(d, mode=0o700, exist_ok=True) + meta["updated_at"] = int(time.time()) + tmp = meta_path(app) + ".tmp" + with open(tmp, "w") as f: + json.dump(meta, f) + os.chmod(tmp, 0o600) + os.rename(tmp, meta_path(app)) + +def ensure_identity(): + have = [p for p in IDENTITIES if os.path.exists(p)] + if have: + return have + target = "/deployments/.age-key" + os.makedirs(os.path.dirname(target), mode=0o700, exist_ok=True) + r = subprocess.run(["age-keygen", "-o", target], capture_output=True) + if r.returncode != 0: + fail("no age identity and age-keygen failed: " + r.stderr.decode(errors="replace")) + os.chmod(target, 0o600) + return [target] + +def recipient(): + for p in ensure_identity(): + r = subprocess.run(["grep", "public key:", p], capture_output=True) + out = r.stdout.decode().strip() + if out: + return out.split()[-1] + fail("age identity carries no public key") + +def encrypt_to(data_bytes, dest): + os.makedirs(os.path.dirname(dest), mode=0o700, exist_ok=True) + r = run(["age", "-r", recipient()], data=data_bytes, check=False) + if r.returncode != 0: + fail("encrypting: " + r.stderr.decode(errors="replace")) + tmp = dest + ".tmp" + with open(tmp, "wb") as f: + f.write(r.stdout) + os.chmod(tmp, 0o600) + os.rename(tmp, dest) + +def decrypt(path): + args = ["age", "-d"] + for p in ensure_identity(): + args += ["-i", p] + r = run(args + [path], check=False) + if r.returncode != 0: + fail("decrypting " + os.path.basename(path) + ": " + r.stderr.decode(errors="replace")) + return r.stdout + +def fingerprint(pub): + r = subprocess.run(["ssh-keygen", "-lf", "-"], input=pub.encode(), capture_output=True) + return r.stdout.decode().strip().splitlines()[0] if r.stdout else "" + +def mirror_dir(app): + return os.path.join(CONFIG_ROOT, "apps", app, "mirror.git") + +def ensure_mirror(app): + d = mirror_dir(app) + if os.path.isdir(d): + return d + os.makedirs(os.path.dirname(d), mode=0o700, exist_ok=True) + tmp = d + ".init-%d" % os.getpid() + r = subprocess.run(["git", "init", "-q", "--bare", tmp], capture_output=True) + if r.returncode != 0: + fail(r.stderr.decode(errors="replace")) + for k, v in [("gc.auto", "0"), ("gc.autoDetach", "false"), ("fetch.prune", "false")]: + run(["git", "--git-dir=" + tmp, "config", k, v]) + os.chmod(tmp, 0o700) + os.rename(tmp, d) + return d + +def git_env(app): + meta = read_meta(app) or {"type": "none"} + t = meta.get("type", "none") + d = cred_dir(app) + tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else None + tmp = tempfile.mkdtemp(prefix="teploy-git-", dir=tmpdir) + os.chmod(tmp, 0o700) + env = {} + if t == "ssh": + kh = os.path.join(d, "known_hosts") + if not os.path.exists(kh): + shutil.rmtree(tmp, ignore_errors=True) + fail("no pinned host key for %s - run: teploy config source host-key --app %s --host " % (app, app), 3) + key = os.path.join(tmp, "key") + with open(key, "wb") as f: + f.write(decrypt(os.path.join(d, "key.age"))) + os.chmod(key, 0o600) + env["GIT_SSH_COMMAND"] = " ".join([ + "ssh", "-i", key, "-o", "IdentitiesOnly=yes", "-o", "UserKnownHostsFile=" + kh, + "-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes", + "-o", "PasswordAuthentication=no", "-o", "ControlPath=none"]) + elif t == "token": + env["TEPLOY_GIT_TOKEN"] = decrypt(os.path.join(d, "token.age")).decode(errors="replace").strip(NL) + env["TEPLOY_GIT_USERNAME"] = meta.get("username") or "x-access-token" + ap = os.path.join(tmp, "askpass") + script = NL.join([ + "#!/bin/sh", + 'case "$1" in', + ' *Username*|*username*) echo "$TEPLOY_GIT_USERNAME";;', + ' *) echo "$TEPLOY_GIT_TOKEN";;', + "esac"]) + with open(ap, "w") as f: + f.write(script) + os.chmod(ap, 0o700) + env["GIT_ASKPASS"] = ap + env["GIT_CONFIG_COUNT"] = "1" + env["GIT_CONFIG_KEY_0"] = "credential.helper" + env["GIT_CONFIG_VALUE_0"] = "" + return env, tmp + +def with_credential(app, argv, data=None): + env, tmp = git_env(app) + try: + return run(["git", "-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=user"] + argv, + data=data, env=env, check=False) + finally: + shutil.rmtree(tmp, ignore_errors=True) + +def state_path(app): + return os.path.join(CONFIG_ROOT, "apps", app, "admissions", "state.json") + +def load_state(app): + try: + with open(state_path(app)) as f: + return json.load(f) + except FileNotFoundError: + return {} + +def save_state(app, state): + d = os.path.dirname(state_path(app)) + os.makedirs(d, mode=0o700, exist_ok=True) + lk = os.open(os.path.join(d, ".state.lock"), os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(lk, fcntl.LOCK_EX) + tmp = state_path(app) + ".tmp" + with open(tmp, "w") as f: + json.dump(state, f) + os.chmod(tmp, 0o600) + os.rename(tmp, state_path(app)) + m = ensure_mirror(app) + for branch, sha in (state.get("watermarks") or {}).items(): + if sha: + run(["git", "--git-dir=" + m, "update-ref", "refs/teploy/wm/" + branch, sha], check=False) + finally: + fcntl.flock(lk, fcntl.LOCK_UN) + os.close(lk) + +def out_json(v): + print(json.dumps(v)) + +def host_fingerprints(lines): + entries = [] + for l in lines: + parts = l.split() + if len(parts) < 3: + continue + kf = subprocess.run(["ssh-keygen", "-lf", "-"], input=(parts[1] + " " + parts[2] + NL).encode(), capture_output=True) + entries.append({"line": l, "type": parts[1], "fingerprint": kf.stdout.decode().strip()}) + return entries + +cmd, app = sys.argv[1], sys.argv[2] + +if cmd == "keygen": + rotate = len(sys.argv) > 3 and sys.argv[3] == "rotate" + d = cred_dir(app) + created = not os.path.exists(os.path.join(d, "key.age")) + if created or rotate: + os.makedirs(d, mode=0o700, exist_ok=True) + tmp = tempfile.mkdtemp(prefix="teploy-keygen-") + try: + r = subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "teploy-deploy-" + app, "-f", os.path.join(tmp, "k")], capture_output=True) + if r.returncode != 0: + fail(r.stderr.decode(errors="replace")) + with open(os.path.join(tmp, "k"), "rb") as f: + encrypt_to(f.read(), os.path.join(d, "key.age")) + shutil.copy(os.path.join(tmp, "k.pub"), os.path.join(d, "key.pub")) + os.chmod(os.path.join(d, "key.pub"), 0o644) + finally: + shutil.rmtree(tmp, ignore_errors=True) + write_meta(app, {"type": "ssh", "created_at": int(time.time())}) + elif read_meta(app) is None: + write_meta(app, {"type": "ssh", "created_at": int(time.time())}) + pub = open(os.path.join(cred_dir(app), "key.pub")).read() + out_json({"type": "ssh", "public_key": pub.strip(), "fingerprint": fingerprint(pub), "created": created or rotate}) + +elif cmd == "token": + username = sys.argv[3] if len(sys.argv) > 3 else "" + token = sys.stdin.buffer.read() + if not token.strip(): + fail("empty token on stdin") + encrypt_to(token, os.path.join(cred_dir(app), "token.age")) + prev = read_meta(app) or {} + meta = {"type": "token", "username": username or "x-access-token", "created_at": prev.get("created_at") or int(time.time())} + d = cred_dir(app) + for stale in ("key.age", "key.pub"): + try: + os.unlink(os.path.join(d, stale)) + except FileNotFoundError: + pass + write_meta(app, meta) + out_json({"type": "token", "username": meta["username"], "created": True}) + +elif cmd == "hostkey": + host, port = sys.argv[3], sys.argv[4] + expect = sys.argv[5] if len(sys.argv) > 5 else "" + r = subprocess.run(["ssh-keyscan", "-p", port, "-t", "ed25519,ecdsa,rsa", host], capture_output=True) + lines = [l for l in r.stdout.decode(errors="replace").splitlines() if l.strip() and not l.startswith("#")] + if not lines: + fail("ssh-keyscan found no keys for %s:%s" % (host, port), 3) + entries = host_fingerprints(lines) + if expect: + fps = [e["fingerprint"].split()[1] for e in entries if len(e["fingerprint"].split()) > 1] + if expect not in fps: + out_json({"entries": entries, "matched": False}) + sys.exit(5) + d = cred_dir(app) + os.makedirs(d, mode=0o700, exist_ok=True) + tmp = os.path.join(d, "known_hosts.tmp") + with open(tmp, "w") as f: + f.write(NL.join(e["line"] for e in entries) + NL) + os.chmod(tmp, 0o600) + os.rename(tmp, os.path.join(d, "known_hosts")) + out_json({"entries": entries, "matched": True}) + +elif cmd == "show": + meta = read_meta(app) + if not meta: + out_json({"type": "none"}) + sys.exit(3) + out = {"type": meta.get("type", "none"), "created_at": meta.get("created_at")} + d = cred_dir(app) + if meta.get("type") == "ssh" and os.path.exists(os.path.join(d, "key.pub")): + pub = open(os.path.join(d, "key.pub")).read() + out["public_key"] = pub.strip() + out["fingerprint"] = fingerprint(pub) + if meta.get("type") == "token": + out["username"] = meta.get("username") + if os.path.exists(os.path.join(d, "known_hosts")): + out["known_hosts"] = host_fingerprints([l.strip() for l in open(os.path.join(d, "known_hosts")) if l.strip()]) + out_json(out) + +elif cmd == "remove": + d = cred_dir(app) + removed = os.path.isdir(d) + if removed: + shutil.rmtree(d, ignore_errors=True) + print("removed" if removed else "absent") + +elif cmd == "lsremote": + url, ref = sys.argv[3], sys.argv[4] + r = with_credential(app, ["ls-remote", url, ref]) + if r.returncode != 0: + fail(r.stderr.decode(errors="replace")) + line = r.stdout.decode().strip().splitlines()[0] if r.stdout.strip() else "" + if not line: + sys.exit(3) + print(line.split()[0]) + +elif cmd == "fetch": + url, branch, sha = sys.argv[3], sys.argv[4], sys.argv[5] + m = ensure_mirror(app) + r = with_credential(app, ["--git-dir=" + m, "fetch", "--no-tags", "--force", url, "+refs/heads/%s:refs/heads/%s" % (branch, branch)]) + if r.returncode != 0: + fail(r.stderr.decode(errors="replace")) + v = run(["git", "--git-dir=" + m, "rev-parse", "--verify", "-q", sha + "^{commit}"], check=False) + if v.returncode != 0: + fail("fetched branch does not contain %s (branch moved?)" % sha, 3) + print(v.stdout.decode().strip()) + +elif cmd == "ancestor": + branch, a, b = sys.argv[3], sys.argv[4], sys.argv[5] + m = mirror_dir(app) + for oid in (a, b): + if run(["git", "--git-dir=" + m, "cat-file", "-e", oid + "^{commit}"], check=False).returncode != 0: + fail("commit %s not in the mirror" % oid, 4) + r = run(["git", "--git-dir=" + m, "merge-base", "--is-ancestor", a, b], check=False) + sys.exit(0 if r.returncode == 0 else 3) + +elif cmd == "diff": + branch, a, b = sys.argv[3], sys.argv[4], sys.argv[5] + m = mirror_dir(app) + r = run(["git", "--git-dir=" + m, "diff", "--name-only", a, b], check=False) + if r.returncode != 0: + fail(r.stderr.decode(errors="replace")) + sys.stdout.write(r.stdout.decode(errors="replace")) + +elif cmd == "export": + dest, branch, context, dockerfile = sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6] + m = mirror_dir(app) + os.makedirs(dest, mode=0o700, exist_ok=True) + r = run(["git", "--git-dir=" + m, "archive", "--format=tar", branch], check=False) + if r.returncode != 0: + fail(r.stderr.decode(errors="replace")) + t = subprocess.run(["tar", "-x", "-C", dest], input=r.stdout, capture_output=True) + if t.returncode != 0: + fail(t.stderr.decode(errors="replace")) + root = dest if context in ("", ".") else os.path.join(dest, context) + name = dockerfile or "Dockerfile" + mode = "dockerfile" if os.path.exists(os.path.join(root, name)) else "nixpacks" + if mode == "nixpacks" and dockerfile: + fail("dockerfile %s not found in the repository (context %s)" % (dockerfile, context or "."), 1) + gm = run(["git", "--git-dir=" + m, "cat-file", "-e", branch + ":.gitmodules"], check=False) + out_json({"mode": mode, "has_gitmodules": gm.returncode == 0}) + +elif cmd == "state-get": + out_json(load_state(app)) + +elif cmd == "state-put": + state = json.load(sys.stdin) + save_state(app, state) + print("ok") + +elif cmd == "seal-secret": + key, dest = sys.argv[3], sys.argv[4] + src = os.path.join(DEPLOYMENTS, app, "secrets", key + ".age") + if not os.path.exists(src): + fail("secret %s is not set (teploy secret set %s)" % (key, key), 3) + data = decrypt(src) + os.makedirs(os.path.dirname(dest), mode=0o700, exist_ok=True) + tmp = dest + ".tmp" + with open(tmp, "wb") as f: + f.write(data) + os.chmod(tmp, 0o600) + os.rename(tmp, dest) + +else: + fail("unknown command %s" % cmd, 2) +` diff --git a/internal/gitsource/poll.go b/internal/gitsource/poll.go new file mode 100644 index 0000000..f639e83 --- /dev/null +++ b/internal/gitsource/poll.go @@ -0,0 +1,399 @@ +package gitsource + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/ssh" +) + +// Reason vocabulary for `trigger poll --json` (contracts corpus). +const ( + ReasonDeployed = "deployed" + ReasonUpToDate = "up-to-date" + ReasonNotDue = "not-due" + ReasonPollOff = "poll-off" + ReasonNoChanges = "no-relevant-changes" + ReasonForcePush = "force-push" + ReasonRetryLimit = "retry-limit" + ReasonBranchGone = "branch-missing" + ReasonFailed = "failed" +) + +// PollResult is the `trigger poll --json` object (one per app; `--all` +// emits one JSON line per app in this same shape). +type PollResult struct { + MachineInterface int `json:"machine_interface"` + App string `json:"app"` + Host string `json:"host,omitempty"` + Ref string `json:"ref"` + Head string `json:"head,omitempty"` + Admitted bool `json:"admitted"` + Reason string `json:"reason,omitempty"` + Release string `json:"deployed_release,omitempty"` +} + +// PollEventKind selects admit or refuse in Deps.RecordEvent. +const ( + EventAdmit = "trigger.admit" + EventRefuse = "trigger.refuse" +) + +// PollDeps carries everything Poll needs beyond the repository: the +// loaded app config (Git non-nil), the deploy callback (run under the +// app lock the CALLER holds — poll never locks) and the event recorder. +type PollDeps struct { + Exec ssh.Executor + App string + AppCf *config.AppConfig + // Now is --now: ignore the interval and poll: off (a webhook wake). + Now bool + // Hint is an advisory head (a webhook payload); the remote head + // always wins — the hint only wakes the poll. + Hint string + // RecordEvent appends to the app's server history (nil = tests). + RecordEvent func(kind, outcome string, detail map[string]any) + // Deploy builds and deploys the commit under the caller's lock and + // returns when the release is committed. + Deploy func(ctx context.Context, commit, version string) error + // Clock overrides time.Now (tests); nil uses the real one. + Clock func() time.Time +} + +func (d PollDeps) now() time.Time { + if d.Clock != nil { + return d.Clock() + } + return time.Now() +} + +func (d PollDeps) event(kind, outcome string, detail map[string]any) { + if d.RecordEvent != nil { + d.RecordEvent(kind, outcome, detail) + } +} + +// Poll is the one admitter (D4): ls-remote the branch with the app +// credential, dedup by (app, ref, commit), enforce the per-ref watermark +// with an ancestry check on the mirror, refuse force-pushes until +// `trigger reset`, filter by changed paths, cap retries of a failed +// commit, and always deploy the newest admitted head. All decisions and +// their records happen under the app lock the caller holds. +func Poll(ctx context.Context, d PollDeps) (PollResult, error) { + g := d.AppCf.Git + if g == nil { + return PollResult{}, fmt.Errorf("%s has no git: source", d.App) + } + ref := "refs/heads/" + g.Branch + res := PollResult{App: d.App, Ref: ref} + + interval, polling := PollInterval(g) + if !polling && !d.Now { + res.Reason = ReasonPollOff + return res, nil + } + + st, err := LoadState(ctx, d.Exec, d.App) + if err != nil { + return res, err + } + if st.SchemaVersion == 0 { + st.SchemaVersion = 1 + } + if st.Repository != "" && st.Repository != g.Repository { + // The repository changed: old watermarks prove nothing about the + // new history — start fresh (the mirror refetches over it). + st.Refs, st.Commits = nil, nil + } + st.Repository = g.Repository + + if !d.Now { + if elapsed := d.now().Unix() - st.LastPoll; st.LastPoll > 0 && elapsed >= 0 && time.Duration(elapsed)*time.Second < interval { + res.Reason = ReasonNotDue + return res, nil + } + } + st.LastPoll = d.now().Unix() + + r := st.Ref(ref) + head, err := LsRemote(ctx, d.Exec, d.App, g.Repository, g.Branch) + if err != nil { + if errors.Is(err, ErrAbsent) { + had := r.LastSeen != "" + r.LastSeen = "" + if had { + d.event(EventRefuse, "refused", map[string]any{"ref": ref, "reason": ReasonBranchGone}) + } + if err := SaveState(ctx, d.Exec, d.App, st); err != nil { + return res, err + } + res.Reason = ReasonBranchGone + return res, nil + } + return res, fmt.Errorf("checking %s's branch %s: %w", d.App, g.Branch, err) + } + res.Head = head + + if reason, done := deduped(st, r, ref, head); done { + if err := SaveState(ctx, d.Exec, d.App, st); err != nil { + return res, err + } + res.Reason = reason + return res, nil + } + + // The remote head is the truth (a hint only woke us); fetching may + // even land on something newer — supersedes always win. + fetched, err := FetchMirror(ctx, d.Exec, d.App, g.Repository, g.Branch, head) + if err != nil { + return res, fmt.Errorf("fetching %s's mirror: %w", d.App, err) + } + if fetched != head { + head, res.Head = fetched, fetched + if reason, done := deduped(st, r, ref, head); done { + if err := SaveState(ctx, d.Exec, d.App, st); err != nil { + return res, err + } + res.Reason = reason + return res, nil + } + } + r.LastSeen = head + + if r.Watermark != "" { + ok, err := IsAncestor(ctx, d.Exec, d.App, g.Branch, r.Watermark, head) + if err != nil { + var refused *ErrRefused + if errors.As(err, &refused) { + return d.refuse(ctx, st, r, ref, head, ReasonForcePush, res, err) + } + return res, err + } + if !ok { + return d.refuse(ctx, st, r, ref, head, ReasonForcePush, res, nil) + } + } + + if r.Watermark != "" { + changed, err := DiffNames(ctx, d.Exec, d.App, g.Branch, r.Watermark, head) + if err != nil { + return res, fmt.Errorf("computing changed paths for %s: %w", d.App, err) + } + if !PathRelevant(changed, DefaultPaths(d.AppCf)) { + c := st.Commit(ref, head) + c.Status = RecordSkipped + r.Watermark = head + if err := SaveState(ctx, d.Exec, d.App, st); err != nil { + return res, err + } + res.Reason = ReasonNoChanges + return res, nil + } + } + + c := st.Commit(ref, head) + why := "new-commit" + if c.Attempts > 0 { + why = "retry" + } + d.event(EventAdmit, "started", map[string]any{"ref": ref, "commit": head, "reason": why}) + + version := short(head) + deployErr := d.Deploy(ctx, head, version) + c.Attempts++ + c.LastAttempt = d.now().Unix() + if deployErr != nil { + c.Status = RecordFailed + c.LastError = truncate(deployErr.Error(), 300) + if c.Attempts >= MaxDeployAttempts { + // One refusal per commit: after this, only a new commit, + // `trigger retry` or a redeploy restarts it. + d.event(EventRefuse, "refused", map[string]any{"ref": ref, "commit": head, "reason": ReasonRetryLimit, "error": c.LastError}) + } + if err := SaveState(ctx, d.Exec, d.App, st); err != nil { + return res, err + } + res.Reason = ReasonFailed + return res, deployErr + } + c.Status = RecordDeployed + c.Release = version + c.LastError = "" + r.Watermark = head + r.RefusedHead = "" + if err := SaveState(ctx, d.Exec, d.App, st); err != nil { + return res, err + } + res.Admitted = true + res.Reason = ReasonDeployed + res.Release = version + return res, nil +} + +// refuse records one force-push refusal per (ref, head) and refuses +// until `teploy trigger reset --app --ref `. +func (d PollDeps) refuse(ctx context.Context, st *PollState, r *RefState, ref, head, reason string, res PollResult, cause error) (PollResult, error) { + if r.RefusedHead != head { + d.event(EventRefuse, "refused", map[string]any{"ref": ref, "commit": head, "reason": reason}) + } + r.RefusedHead = head + r.RefusedAt = d.now().Unix() + st.Commit(ref, head).Status = RecordRefused + if err := SaveState(ctx, d.Exec, d.App, st); err != nil { + return res, err + } + res.Reason = reason + if cause != nil { + return res, fmt.Errorf("%s: %w", d.App, cause) + } + return res, nil +} + +// deduped reports the cheap no-work outcomes decidable before/after the +// fetch: same head as the watermark or an already-final ledger record. +// Read-only: never creates ledger entries. +func deduped(st *PollState, r *RefState, ref, head string) (string, bool) { + if head == "" { + return ReasonBranchGone, true + } + if head == r.Watermark && r.Watermark != "" { + return ReasonUpToDate, true + } + if r.RefusedHead == head { + return ReasonForcePush, true + } + switch c := st.Commits[ledgerKey(ref, head)]; { + case c == nil: + case c.Status == RecordDeployed || c.Status == RecordSkipped: + return ReasonUpToDate, true + case c.Status == RecordFailed: + if c.Attempts >= MaxDeployAttempts { + return ReasonRetryLimit, true + } + case c.Status == RecordRefused: + return ReasonForcePush, true + } + return "", false +} + +// ResolveHead resolves the branch head for a manual deploy (deploy from +// config with git: set): ls-remote with the credential, then fetch the +// mirror so the build can archive the commit. +func ResolveHead(ctx context.Context, exec ssh.Executor, app string, g *config.GitSource) (string, error) { + head, err := LsRemote(ctx, exec, app, g.Repository, g.Branch) + if errors.Is(err, ErrAbsent) { + return "", fmt.Errorf("branch %s does not exist on %s", g.Branch, g.Repository) + } + if err != nil { + return "", fmt.Errorf("checking %s's branch %s: %w", app, g.Branch, err) + } + return FetchMirror(ctx, exec, app, g.Repository, g.Branch, head) +} + +// RefusedHead returns the ref's refused head ("" when none) so a manual +// deploy can honor the same force-push refusal the poller records. +func RefusedHead(ctx context.Context, exec ssh.Executor, app, repository, branch string) (string, error) { + st, err := LoadState(ctx, exec, app) + if err != nil { + return "", err + } + if st.Repository != "" && st.Repository != repository { + return "", nil + } + return st.Ref("refs/heads/" + branch).RefusedHead, nil +} + +// RecordManualDeploy advances the watermark after a successful manual +// deploy (deploy from config / redeploy): the release record already +// carries the commit; this keeps the next poll from redoing it. +func RecordManualDeploy(ctx context.Context, exec ssh.Executor, app, repository, branch, commit, release string) error { + st, err := LoadState(ctx, exec, app) + if err != nil { + return err + } + if st.SchemaVersion == 0 { + st.SchemaVersion = 1 + } + if st.Repository != "" && st.Repository != repository { + st.Refs, st.Commits = nil, nil + } + st.Repository = repository + ref := "refs/heads/" + branch + r := st.Ref(ref) + r.Watermark = commit + r.LastSeen = commit + r.RefusedHead = "" + c := st.Commit(ref, commit) + c.Status = RecordDeployed + c.Release = release + c.LastError = "" + return SaveState(ctx, exec, app, st) +} + +// ResetRef clears a ref's watermark, refusal and ledger (`trigger +// reset`): the next poll admits the current head with no ancestry bar. +func ResetRef(ctx context.Context, exec ssh.Executor, app, repository, ref string) error { + if !strings.HasPrefix(ref, "refs/heads/") || ref == "refs/heads/" || strings.ContainsAny(strings.TrimPrefix(ref, "refs/heads/"), "/ ") { + return fmt.Errorf("ref must be refs/heads/") + } + st, err := LoadState(ctx, exec, app) + if err != nil { + return err + } + if st.SchemaVersion == 0 { + st.SchemaVersion = 1 + } + if st.Repository != "" && st.Repository != repository { + st.Refs, st.Commits = nil, nil + } + st.Repository = repository + delete(st.Refs, ref) + prefix := ref + "/" + for k := range st.Commits { + if strings.HasPrefix(k, prefix) { + delete(st.Commits, k) + } + } + return SaveState(ctx, exec, app, st) +} + +// RetryClear resets the failed commit's attempt budget so the next poll +// tries it again (`trigger retry`). Returns the head it cleared. +func RetryClear(ctx context.Context, exec ssh.Executor, app, repository, branch string) (string, error) { + st, err := LoadState(ctx, exec, app) + if err != nil { + return "", err + } + if st.Repository != "" && st.Repository != repository { + return "", fmt.Errorf("%s now builds from a different repository", app) + } + ref := "refs/heads/" + branch + r := st.Ref(ref) + head := r.LastSeen + if head == "" { + head = r.Watermark + } + if head == "" { + return "", fmt.Errorf("nothing to retry — %s has no recorded head for %s", app, ref) + } + c := st.Commit(ref, head) + if c.Status != RecordFailed { + return head, nil + } + c.Attempts = 0 + if err := SaveState(ctx, exec, app, st); err != nil { + return "", err + } + return head, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/gitsource/poll_test.go b/internal/gitsource/poll_test.go new file mode 100644 index 0000000..42823a8 --- /dev/null +++ b/internal/gitsource/poll_test.go @@ -0,0 +1,434 @@ +package gitsource + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/ssh" +) + +// gitFixture is a local bare repository served over file:// — the same +// helper commands production uses, against a repository the test owns. +type gitFixture struct { + t *testing.T + bare string + work string +} + +func newGitFixture(t *testing.T, branch string) *gitFixture { + t.Helper() + skipWithoutTools(t) + f := &gitFixture{t: t, bare: filepath.Join(t.TempDir(), "src.git"), work: t.TempDir()} + if out, err := exec.Command("git", "init", "-q", "--bare", "-b", branch, f.bare).CombinedOutput(); err != nil { + // Older git without -b for bare repos: init then set HEAD. + if err2 := exec.Command("git", "init", "-q", "--bare", f.bare).Run(); err2 != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + exec.Command("git", "--git-dir", f.bare, "symbolic-ref", "HEAD", "refs/heads/"+branch).Run() + } + f.git(f.work, "init", "-q", "-b", branch) + f.commit("Dockerfile", "FROM traefik/whoami") + f.git(f.work, "push", "-q", "file://"+f.bare, branch) + return f +} + +func skipWithoutTools(t *testing.T) { + t.Helper() + for _, tool := range []string{"git", "python3"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not available", tool) + } + } +} + +func (f *gitFixture) git(dir string, args ...string) string { + f.t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.email=t@t", "-c", "user.name=t"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + f.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func (f *gitFixture) commit(file, content string) string { + f.t.Helper() + path := filepath.Join(f.work, file) + os.MkdirAll(filepath.Dir(path), 0o755) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } + f.git(f.work, "add", "-A") + f.git(f.work, "commit", "-q", "-m", "add "+file) + return strings.TrimSpace(f.git(f.work, "rev-parse", "HEAD")) +} + +func (f *gitFixture) head() string { + f.t.Helper() + return strings.TrimSpace(f.git(f.work, "rev-parse", "HEAD")) +} + +func (f *gitFixture) push(branch string) { + f.t.Helper() + f.git(f.work, "push", "-q", "file://"+f.bare, branch) +} + +func (f *gitFixture) forcePush(branch string) { + f.t.Helper() + f.git(f.work, "push", "-q", "-f", "file://"+f.bare, branch) +} + +// pollEnv wires the helper's test roots and returns a LocalExecutor. +func pollEnv(t *testing.T) ssh.Executor { + t.Helper() + skipWithoutTools(t) + root := filepath.Join(t.TempDir(), ".teploy") + t.Setenv("TEPLOY_CONFIG_ROOT_TEST", root) + t.Setenv("TEPLOY_GIT_CRED_ROOT_TEST", filepath.Join(t.TempDir(), "creds")) + t.Setenv("TEPLOY_DEPLOYMENTS_TEST", filepath.Join(t.TempDir(), "deployments")) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + return ssh.NewLocalExecutor() +} + +type pollHarness struct { + exec ssh.Executor + app string + cfg *config.AppConfig + deploys []string + fail error + events []string +} + +func newPollHarness(t *testing.T, f *gitFixture, paths []string, poll string) *pollHarness { + exec := pollEnv(t) + return &pollHarness{ + exec: exec, + app: "w4app", + cfg: &config.AppConfig{App: "w4app", Git: &config.GitSource{ + Repository: "file://" + f.bare, Branch: "main", Paths: paths, Poll: poll, + }}, + } +} + +func (h *pollHarness) poll(ctx context.Context, now bool) (PollResult, error) { + return Poll(ctx, PollDeps{ + Exec: h.exec, App: h.app, AppCf: h.cfg, Now: now, + RecordEvent: func(kind, outcome string, detail map[string]any) { + h.events = append(h.events, fmt.Sprintf("%s/%s:%v", kind, outcome, detail["reason"])) + }, + Deploy: func(ctx context.Context, commit, version string) error { + h.deploys = append(h.deploys, commit) + return h.fail + }, + }) +} + +func TestPollAdmitsDedupsAndSupersedes(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, nil, "") + ctx := context.Background() + + c1 := f.head() + res, err := h.poll(ctx, true) + if err != nil || !res.Admitted || res.Reason != ReasonDeployed || res.Head != c1 { + t.Fatalf("first poll = %+v err=%v, want admitted %s", res, err, c1) + } + if len(h.deploys) != 1 || h.deploys[0] != c1 { + t.Fatalf("deploys = %v", h.deploys) + } + + res, err = h.poll(ctx, true) + if err != nil || res.Admitted || res.Reason != ReasonUpToDate { + t.Fatalf("second poll = %+v err=%v, want up-to-date", res, err) + } + if len(h.deploys) != 1 { + t.Fatalf("duplicate deploy: %v", h.deploys) + } + + c2 := f.commit("app.txt", "v2") + f.push("main") + // A stale hint (the previous head) must not deploy the wrong commit. + res, err = h.pollWithHint(ctx, c1) + if err != nil || !res.Admitted || res.Head != c2 { + t.Fatalf("poll after push = %+v err=%v, want admitted %s", res, err, c2) + } + + // The watermark is per (repository, ref): state carries it. + st, err := LoadState(ctx, h.exec, h.app) + if err != nil || st.Ref("refs/heads/main").Watermark != c2 { + t.Fatalf("watermark = %+v err=%v", st.Ref("refs/heads/main"), err) + } +} + +func (h *pollHarness) pollWithHint(ctx context.Context, hint string) (PollResult, error) { + deploysBefore := len(h.deploys) + res, err := Poll(ctx, PollDeps{ + Exec: h.exec, App: h.app, AppCf: h.cfg, Now: true, Hint: hint, + RecordEvent: func(kind, outcome string, detail map[string]any) { + h.events = append(h.events, fmt.Sprintf("%s/%s:%v", kind, outcome, detail["reason"])) + }, + Deploy: func(ctx context.Context, commit, version string) error { + h.deploys = append(h.deploys, commit) + return h.fail + }, + }) + if len(h.deploys) != deploysBefore+1 { + h.deploys = append(h.deploys, "(none)") + } + return res, err +} + +func TestPollIntervalAndOff(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, nil, "10m") + ctx := context.Background() + + // No state yet: the first poll bootstraps immediately. + if res, err := h.poll(ctx, false); err != nil || !res.Admitted { + t.Fatalf("first poll without state = %+v err=%v", res, err) + } + if res, err := h.poll(ctx, false); err != nil || res.Reason != ReasonNotDue { + t.Fatalf("second poll = %+v err=%v, want not-due", res, err) + } + + off := newPollHarness(t, f, nil, "off") + if res, err := off.poll(ctx, false); err != nil || res.Reason != ReasonPollOff { + t.Fatalf("poll: off without --now = %+v err=%v", res, err) + } + if res, err := off.poll(ctx, true); err != nil || !res.Admitted { + t.Fatalf("poll: off with --now (webhook wake) = %+v err=%v", res, err) + } +} + +func TestPollForcePushRefusalAndReset(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, nil, "") + ctx := context.Background() + + if _, err := h.poll(ctx, true); err != nil { + t.Fatal(err) + } + f.commit("app.txt", "v2") + f.push("main") + if _, err := h.poll(ctx, true); err != nil { + t.Fatal(err) + } + + // Rewrite history: reset to c1 and commit a different change. + f.git(f.work, "reset", "-q", "--hard", "HEAD~1") + divergent := f.commit("other.txt", "divergent") + f.forcePush("main") + + res, err := h.poll(ctx, true) + if err != nil { + t.Fatalf("force-push poll err=%v", err) + } + if res.Admitted || res.Reason != ReasonForcePush || res.Head != divergent { + t.Fatalf("force-push poll = %+v, want refused %s", res, divergent) + } + refuses := countEvents(h, EventRefuse) + if refuses == 0 { + t.Fatalf("no refusal event: %v", h.events) + } + // Still refused on later polls, without a new event or deploy. + res, err = h.poll(ctx, true) + if err != nil || res.Reason != ReasonForcePush || countEvents(h, EventRefuse) != refuses { + t.Fatalf("repeat force-push poll = %+v err=%v events=%v", res, err, h.events) + } + if len(h.deploys) != 2 { + t.Fatalf("force-push must not deploy: %v", h.deploys) + } + + // Reset re-admits the current head. + if err := ResetRef(ctx, h.exec, h.app, "file://"+f.bare, "refs/heads/main"); err != nil { + t.Fatal(err) + } + res, err = h.poll(ctx, true) + if err != nil || !res.Admitted || res.Head != divergent { + t.Fatalf("poll after reset = %+v err=%v", res, err) + } + + // A manual deploy honors the same refusal. + f.git(f.work, "reset", "-q", "--hard", "HEAD~1") + again := f.commit("third.txt", "divergent-2") + f.forcePush("main") + if _, err := h.poll(ctx, true); err != nil { + t.Fatal(err) + } + if refused, err := RefusedHead(ctx, h.exec, h.app, "file://"+f.bare, "main"); err != nil || refused != again { + t.Fatalf("refused head = %q err=%v, want %q", refused, err, again) + } +} + +func TestPollPathFilter(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, []string{"web/**"}, "") + ctx := context.Background() + + if _, err := h.poll(ctx, true); err != nil { + t.Fatal(err) + } + f.commit("docs/readme.md", "docs only") + f.push("main") + res, err := h.poll(ctx, true) + if err != nil || res.Admitted || res.Reason != ReasonNoChanges { + t.Fatalf("docs-only poll = %+v err=%v, want no-relevant-changes", res, err) + } + if len(h.deploys) != 1 { + t.Fatalf("docs-only change deployed: %v", h.deploys) + } + // The skipped head advanced the watermark: still quiet next poll. + res, err = h.poll(ctx, true) + if err != nil || res.Reason != ReasonUpToDate { + t.Fatalf("poll after skip = %+v err=%v", res, err) + } + web := f.commit("web/index.html", "") + f.push("main") + res, err = h.poll(ctx, true) + if err != nil || !res.Admitted || res.Head != web { + t.Fatalf("web poll = %+v err=%v", res, err) + } +} + +func TestPollRetryBudget(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, nil, "") + h.fail = errors.New("build exploded") + ctx := context.Background() + + for i := 1; i <= MaxDeployAttempts; i++ { + res, err := h.poll(ctx, true) + if err == nil || res.Admitted { + t.Fatalf("failing poll %d = %+v err=%v", i, res, err) + } + if len(h.deploys) != i { + t.Fatalf("attempt %d: deploys=%v", i, h.deploys) + } + } + if countEvents(h, EventRefuse+"/refused:"+ReasonRetryLimit) != 1 { + t.Fatalf("retry-limit events = %v", h.events) + } + // Past the budget: no more attempts. + if res, err := h.poll(ctx, true); err != nil || res.Reason != ReasonRetryLimit || len(h.deploys) != MaxDeployAttempts { + t.Fatalf("post-budget poll = %+v err=%v deploys=%v", res, err, h.deploys) + } + // A new commit restarts the budget. + h.fail = nil + next := f.commit("fix.txt", "fixed") + f.push("main") + if res, err := h.poll(ctx, true); err != nil || !res.Admitted || res.Head != next { + t.Fatalf("poll after fix = %+v err=%v", res, err) + } + + // ...and `trigger retry` re-arms a failed head. + h.fail = errors.New("again") + failure := f.commit("flaky.txt", "flaky") + f.push("main") + for i := 0; i < MaxDeployAttempts; i++ { + h.poll(ctx, true) + } + if res, _ := h.poll(ctx, true); res.Reason != ReasonRetryLimit { + t.Fatalf("expected retry-limit, got %+v", res) + } + if _, err := RetryClear(ctx, h.exec, h.app, "file://"+f.bare, "main"); err != nil { + t.Fatal(err) + } + h.fail = nil + if res, err := h.poll(ctx, true); err != nil || !res.Admitted || res.Head != failure { + t.Fatalf("poll after retry = %+v err=%v", res, err) + } +} + +func TestPollRepositoryChangeResetsWatermarks(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, nil, "") + ctx := context.Background() + if _, err := h.poll(ctx, true); err != nil { + t.Fatal(err) + } + + // A different repository with unrelated history: no ancestry, yet it + // must deploy (old watermarks do not apply). + other := newGitFixture(t, "main") + h.cfg.Git.Repository = "file://" + other.bare + res, err := h.poll(ctx, true) + if err != nil || !res.Admitted { + t.Fatalf("poll after repository change = %+v err=%v", res, err) + } +} + +func TestPollBranchMissing(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, nil, "") + ctx := context.Background() + if _, err := h.poll(ctx, true); err != nil { + t.Fatal(err) + } + h.cfg.Git.Branch = "gone" + res, err := h.poll(ctx, true) + if err != nil || res.Reason != ReasonBranchGone { + t.Fatalf("poll for missing branch = %+v err=%v", res, err) + } +} + +func TestManualDeployRecordsWatermark(t *testing.T) { + f := newGitFixture(t, "main") + h := newPollHarness(t, f, nil, "") + ctx := context.Background() + head, err := ResolveHead(ctx, h.exec, h.app, h.cfg.Git) + if err != nil { + t.Fatal(err) + } + if head != f.head() { + t.Fatalf("ResolveHead = %s, want %s", head, f.head()) + } + if err := RecordManualDeploy(ctx, h.exec, h.app, h.cfg.Git.Repository, "main", head, "abc123"); err != nil { + t.Fatal(err) + } + // The next poll treats the manually deployed head as done. + if res, err := h.poll(ctx, true); err != nil || res.Admitted || res.Reason != ReasonUpToDate { + t.Fatalf("poll after manual deploy = %+v err=%v", res, err) + } +} + +func TestPathRelevant(t *testing.T) { + cases := []struct { + changed, filter []string + want bool + }{ + {[]string{"a.txt"}, nil, true}, + {[]string{"web/a.txt"}, []string{"web"}, true}, + {[]string{"web/a.txt"}, []string{"web/"}, true}, + {[]string{"docs/a.txt"}, []string{"web"}, false}, + {[]string{"web/a.txt"}, []string{"web/**"}, true}, + {[]string{"webx/a.txt"}, []string{"web"}, false}, + {[]string{"src/main.go"}, []string{"*.go"}, true}, + {[]string{"src/main.go"}, []string{"*.rs"}, false}, + {[]string{}, []string{"web"}, false}, + } + for _, c := range cases { + if got := PathRelevant(c.changed, c.filter); got != c.want { + t.Errorf("PathRelevant(%v, %v) = %v, want %v", c.changed, c.filter, got, c.want) + } + } +} + +func countEvents(h *pollHarness, prefix string) int { + n := 0 + for _, e := range h.events { + if strings.HasPrefix(e, prefix) { + n++ + } + } + return n +}