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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AUDIT_OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,10 @@ into each rather than duplicated as new work items.
- TCL-44 — F37/host-helper (constrained extractor, entry policy, bounds).
- TCL-45 — scheduled/manual backup unification (one engine + artifact
schema + private workspaces + protected credentials). Architectural.
Partially landed 2026-10-20 (D3): scheduled jobs run the same Client
engine as manual creates (`backup run`), artifacts share one naming
scheme, and S3 credentials travel encrypted at rest; MYSQL_PWD
transport and engine-side credential plumbing remain.
- TCL-47 — engine auth adapters + S3 session tokens. Medium; with F37.
- TCL-48 — F42 (durable webhook queue).
- TCL-49 — F40 (pin webhook builds to the event commit): LANDED
Expand Down Expand Up @@ -628,7 +632,10 @@ all packages ok. No push performed.
fail-open-on-parse-error that F25 closed.
- A33 — F22: backup credentials in host-visible command text (AWS env
assignments, MYSQL_PWD via docker exec -e); needs the container-side
credential-file plumbing shared with the engine images.
credential-file plumbing shared with the engine images. Partially
improved 2026-10-20 (D3): the stored server backup target decrypts its
S3 credentials inside the invocation's own shell (never argv, never the
crontab); the legacy --endpoint flag path and MYSQL_PWD remain.
- A34 — F42 durable webhook queue (ack-before-durable-job remains; A36
closed the dedup-race half).
- A35 — F40 webhook build pinning to the event commit: LANDED 2026-09-22
Expand Down
62 changes: 61 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,67 @@ All notable changes to teploy are documented here. Format follows [Keep a Change

### Added

- One backup target per server, read at run time (`backup-target-v1`;
contracts corpus rev 14). `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
output), with the S3 credentials read from stdin as two lines (access
key, then secret key; prompted with echo off on a terminal) and stored
age-encrypted with the server's own identity — used whether or not an
endpoint is set, ambient AWS chain only when none are stored.
`show [--json]` never emits secrets; `unset` removes the target.
Setting a target rewrites teploy-generated backup cron lines (by marker)
to the `backup run` form — hand-written commands under a teploy marker
are left untouched and reported — and `teploy doctor` gains a
`backup-cron` check flagging cron lines with embedded credentials
(fail) or bucket flags (warn).
- `teploy backup create|list|restore|prune|schedule [--app X --host S]
--json` are folderless and default to the stored target
(`backup-json-v1`): `list --json` reports parsed rows
`{id, kind: volumes|accessory|teploy, accessory?, size_bytes,
created_at, key}` across the volumes, accessories and teploy prefixes
plus schedules discovered from teploy cron markers (was: raw
`aws s3 ls` lines); `create --json` reports the created backup's
identity and size; `restore --json` reports the quiesced receipt. The
legacy `--bucket`-style flags still work and override the stored
target. `teploy dr` and `teploy accessory backup|restore|
verify-backup` default to the stored target as well.
- `teploy backup run --app X [--accessory Y]` — the cron entrypoint, runs
on the server like `autodeploy redeploy` and reads the target and the
per-app retention at run time. Scheduled jobs are a single
argument-free cron line; retention moves to a per-app store
(`backup schedule` and `accessory backup --schedule` write it;
`--max-age-days` is new on the latter). `backup unschedule` removes a
schedule. Failure alerting moved from the cron line into `backup run`
(same `/deployments/<app>/backup-alert.sh`, same signatures).
- Restore is now quiesced: under the app's fenced lock the app's
containers (and a generic accessory's container, for accessory
restores) are stopped, the backup is promoted with the existing
two-phase recovery discipline, the containers are started again and
health-checked. On failure — or an unhealthy app afterwards — the
pre-restore recovery copy is kept and named. Non-interactive restores
require `--yes`.
- Events `backup.run`, `backup.restore` and `backup_target.set` (detail
allowlist: id, kind, accessory, error — never bucket or credential
material; the server-level target event is fanned out to every app
known on the server).

### Changed

- `backup list --json` now emits the versioned envelope above instead of
a bare array of raw `aws s3 ls` lines. The old shape was never a
contracts-corpus artifact and no consumer is known to parse it;
consumers should gate on `backup-json-v1`.

### Deferred

- A `backup: {schedule, keep_last, max_age_days, accessories}` app-config
key (schedule/retention from Teploy-home config) is deferred: making a
config key authoritative over crontab entries hooks cron reconciliation
into deploy, which does not fit this change. Per-app schedules remain
managed with `backup schedule --app X --host S` (folderless), which
stores both halves server-side.
- Folderless per-command reads (server-authority plan D2): `teploy secret
set/get/list/rm/rotate --app X --host S` read exactly the server facts
they need — no teploy.yml, no reconstructed config. The secrets provider
Expand Down Expand Up @@ -48,7 +109,6 @@ All notable changes to teploy are documented here. Format follows [Keep a Change
old path so an older teploy keeps encrypting to the same identity;
`teploy doctor` reports the location (`age-identity` check). Non-root
deploy users that cannot write `/etc/teploy` keep using the old path.

## [0.1.44] - 2026-10-19

### Added
Expand Down
70 changes: 50 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ standalone.
| **Asset bridging** | Shared volume prevents 404s on hashed assets during deploys |
| **Destinations** | `teploy deploy -d staging` — per-environment config overlays |
| **Notifications** | Webhook notifications on deploy/rollback/failure |
| **Backups** | `teploy backup` — volumes to S3 |
| **Backups** | `teploy backup` — volumes to S3; `teploy server backup-target` — the per-server target |
| **Templates** | One-command deploys for common self-hosted apps |
| **Deploy locking** | `teploy lock` — freeze deploys during incidents; `teploy lock status --json` reports who holds the lock |
| **Vulnerability gate** | `scan: true` — Trivy scan blocks deploys on fixable CRITICALs |
Expand Down Expand Up @@ -563,30 +563,60 @@ The mode is recorded with the preview, so redeploying the branch keeps it.
`preview list --json` rows carry a `url` with the scheme actually served.

### Backups
```
teploy backup create # backup volumes to S3
teploy backup list / restore <id>
teploy backup schedule # cron-driven backups
teploy backup prune # apply a retention policy

# Retention: keep only the newest N, and/or drop anything older than D days.
teploy backup create --bucket b --keep-last 7 # auto-prune after backup
teploy backup schedule "0 3 * * *" --bucket b --keep-last 7 # nightly, keep 7
teploy backup prune --bucket b --keep-last 3 --max-age-days 30
teploy backup prune --bucket b --accessory db --keep-last 5
```
One backup target per server, stored on the server and read at run time —
no bucket or credentials in crontabs, scripts or laptops:

```
# Store the target (credentials on stdin, two lines; age-encrypted on the
# server at /etc/teploy/backup-target.json, 0600):
printf 'ACCESS\nSECRET\n' | teploy server backup-target set --host prod \
--bucket b [--region r] [--endpoint https://s3.example.com] [--prefix p]
teploy server backup-target show --host prod [--json] # never secrets
teploy server backup-target unset --host prod

teploy backup create --app myapp --host prod [--json] # volumes (+ server data)
teploy backup create --app myapp --host prod --accessory db --json
teploy backup list --app myapp --host prod --json # parsed rows + schedules
teploy backup restore <id> --app myapp --host prod --yes --json
teploy backup schedule "0 3 * * *" --app myapp --host prod --keep-last 7
teploy backup prune --app myapp --host prod --keep-last 3 --max-age-days 30
```

Every command also works from an app directory without `--app/--host`, and
the legacy `--bucket/--region/--endpoint` flags still work (they override
the stored target). `teploy dr` and `teploy accessory verify-backup`
default to the stored target too.

Stored credentials are used whether or not an endpoint is set; without
them the server's ambient AWS credential chain applies. A non-root deploy
user who cannot write `/etc/teploy` gets `~/.config/teploy/` instead, and
the command says so.

Scheduled jobs are a single argument-free line —
`/usr/local/bin/teploy backup run --app myapp` — with the retention half
of the schedule stored per app and read at run time, so changing either
never rewrites cron. `teploy server backup-target set` rewrites existing
teploy-generated backup cron lines to that form (hand-written commands
under a teploy marker are left alone and reported), and `teploy doctor`
flags any cron line that still embeds credentials or bucket flags.

Restore quiesces: the app's containers (and a generic accessory's) are
stopped under the app's fenced lock, the backup is promoted with the
two-phase recovery discipline, the containers are started again and
health-checked. On failure — or an unhealthy app afterwards — the
pre-restore recovery copy is kept and named. Non-interactive restores
require `--yes`.

`--keep-last N` is a floor (the newest N are always kept, even if old);
`--max-age-days D` deletes older backups but never below that floor. A failed
backup never triggers a prune. Both `backup create` and scheduled cron backups
alert on failure via the app's `notifications` config — `create` uses all
channels; the headless cron job posts to the first **webhook** channel (SMTP and
other channels need the CLI, which isn't on the server).

`backup schedule` uploads a small alert script to
`/deployments/<app>/backup-alert.sh` (mode 0700) and points cron at it, rather
than inlining a `curl` in the crontab line. That is what lets the alert be
signed: the signature covers a timestamp, `date +%s` contains a `%`, and crontab
treats `%` as a newline escape. If a signing secret is configured (see
channels; the headless `backup run` posts to the first **webhook** channel
through the alert script `backup schedule` uploads to
`/deployments/<app>/backup-alert.sh` (mode 0700). That is what lets the alert
be signed: the signature covers a timestamp, `date +%s` contains a `%`, and
crontab treats `%` as a newline escape. If a signing secret is configured (see
`notifications.secret` / `TEPLOY_WEBHOOK_SECRET`) the alert carries the same
`X-Teploy-Signature` headers as every other delivery; otherwise it goes
unsigned, and `backup schedule` says so on its output so a verifying receiver
Expand Down
9 changes: 6 additions & 3 deletions contracts/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Neutron/Nucleus dependency and a public mirror.

| Corpus rev | Emitting CLI | Machine Interface | Notes |
|---|---|---|---|
| 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 <commit> --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. |
| 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 <commit> --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/<hash>.source.yml 0600) with a new valid fixture. version-handshake advertises `lock-wait-v1` (global `--wait <duration>`; 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/<app>/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. |
Expand Down Expand Up @@ -45,10 +45,13 @@ 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 |
| 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 |
| backup-restore-envelope | yes (rev 15) | valid x2 (ok, unhealthy; real backupRestoreDTO) | teploy-cli |
| releases-envelope | yes (rev 14) | valid x2 (container app, static app; real releasesDTO) | teploy-cli |
| rollback-result | yes (rev 14) | valid (real rollbackResult) | teploy-cli |
| redeploy-result | yes (rev 14) | valid x2 (teploy-home, folder-home; real redeployDTO) | teploy-cli |

## Rules

- Fixtures under `valid/` and `legacy/` are GENERATED from the real
Expand Down
12 changes: 12 additions & 0 deletions contracts/fixtures/backup-create-envelope/valid/accessory.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"machine_interface": 2,
"host": "192.0.2.10",
"app": "blog",
"id": "20261020-031704-1f2e3d4c5b6a7988",
"kind": "accessory",
"accessory": "db",
"size_bytes": 2097152,
"created_at": "2026-10-20T03:17:04Z",
"key": "s3://teploy-backups/blog/accessories/db/20261020-031704-1f2e3d4c5b6a7988.sql.gz",
"pruned": 0
}
12 changes: 12 additions & 0 deletions contracts/fixtures/backup-create-envelope/valid/volumes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"machine_interface": 2,
"host": "192.0.2.10",
"app": "blog",
"id": "20261020-031704-1f2e3d4c5b6a7988",
"kind": "volumes",
"size_bytes": 1048576,
"created_at": "2026-10-20T03:17:04Z",
"key": "s3://teploy-backups/blog/volumes/20261020-031704-1f2e3d4c5b6a7988.tar.gz",
"teploy_key": "s3://teploy-backups/blog/teploy/20261020-031704-1f2e3d4c5b6a7988.tar.gz",
"pruned": 1
}
53 changes: 53 additions & 0 deletions contracts/fixtures/backup-list-envelope/valid/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"machine_interface": 2,
"host": "192.0.2.10",
"app": "blog",
"target": {
"bucket": "teploy-backups",
"region": "us-east-1",
"has_credentials": true
},
"backups": [
{
"id": "20261020-031704-1f2e3d4c5b6a7988",
"kind": "volumes",
"size_bytes": 1048576,
"created_at": "2026-10-20T03:17:04Z",
"key": "s3://teploy-backups/blog/volumes/20261020-031704-1f2e3d4c5b6a7988.tar.gz",
"restorable": true
},
{
"id": "20261020-031704-1f2e3d4c5b6a7988",
"kind": "teploy",
"size_bytes": 4096,
"created_at": "2026-10-20T03:17:04Z",
"key": "s3://teploy-backups/blog/teploy/20261020-031704-1f2e3d4c5b6a7988.tar.gz",
"restorable": true
},
{
"id": "20261019-030001",
"kind": "accessory",
"accessory": "db",
"size_bytes": 2097152,
"created_at": "2026-10-19T03:00:01Z",
"key": "s3://teploy-backups/blog/accessories/db/20261019-030001.sql.gz",
"restorable": true
}
],
"schedules": [
{
"kind": "volumes",
"cron": "30 3 * * *",
"keep_last": 7,
"form": "run"
},
{
"kind": "accessory",
"accessory": "db",
"cron": "15 2 * * *",
"keep_last": 5,
"max_age_days": 30,
"form": "run"
}
]
}
Loading
Loading