From 1a1e2fe4f0dd6a263f15a5c21af333970e4218e1 Mon Sep 17 00:00:00 2001 From: thetechnologist1911 Date: Thu, 17 Sep 2026 19:02:47 -0400 Subject: [PATCH 1/3] fix(agent): make the docker-app check actually work Two stacked bugs meant docker-app failed for every user, on every image, on every platform, in v0.1.0 and v0.1.1. docs/recipes-docker-app.md leads with it as the strongest verification available, the README lists it, and restorable init writes it (commented) into every generated recipe, so anyone uncommenting what the tool produced got exit 2. nat.NewPort takes the protocol first, then the port. The call passed them the other way round, so "tcp" was parsed as a port number and the check died before the container was created. git log -S shows the line unchanged since the original docker sandbox commit: it never worked. With that fixed, MappedPort surfaced a second bug. It inspected once, straight after start, but the daemon fills NetworkSettings.Ports asynchronously, so the binding is reliably absent for the first few hundred milliseconds. It now polls to a deadline. It also reports a container that exited before publishing as exactly that, rather than "port is not published", which sent users looking at networking when the real problem was an app dying on the restored data. Why this shipped: dockerapp_test.go asserts against a fake ContainerRunner, so it verifies the spec the check builds and never executes the code that consumes it, and no e2e covered docker-app at all. run-docker.sh now boots a real nginx against restored data and asserts both a pass and a readiness failure, and asserts the failure is never "not published" so the port race cannot come back silently. Confirmed the new cases are not vacuous: with the pre-fix docker.go restored, case 3 fails and the suite never reaches case 4. --- agent/e2e/run-docker.sh | 88 ++++++++++++++++++++++++++++++++ agent/internal/sandbox/docker.go | 48 +++++++++++++---- 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/agent/e2e/run-docker.sh b/agent/e2e/run-docker.sh index cb0d36d..7dc68e0 100755 --- a/agent/e2e/run-docker.sh +++ b/agent/e2e/run-docker.sh @@ -137,4 +137,92 @@ PY assert_docker_clean "failing run" assert_sandbox_empty + +# ── docker-app ─────────────────────────────────────────────────────────────── +# docker-app shipped broken in v0.1.0 and v0.1.1 and nothing caught it: its +# unit test uses a fake ContainerRunner, so the real StartContainer and +# MappedPort path had never executed in CI. These cases run it for real. + +log "case 3: docker-app boots the real image against restored data (exit 0)" +APP_SRC="$WORK/src-app" +mkdir -p "$APP_SRC/site" +echo "restored-ok" > "$APP_SRC/site/index.html" +APP_REPO="$WORK/app-repo" +restic -r "$APP_REPO" init -q +restic -r "$APP_REPO" backup -q "$APP_SRC" + +cat > "$WORK/recipe-app.yaml" < "$WORK/agent-app.yaml" < "$WORK/app.json" +python3 - "$WORK/app.json" <<'PYEOF' +import json, sys +doc = json.load(open(sys.argv[1])) +assert doc["status"] == "pass", doc +[check] = doc["checks"] +assert check["type"] == "docker-app", check +assert check["status"] == "pass", check +PYEOF +assert_docker_clean "docker-app passing run" +assert_sandbox_empty + +log "case 4: docker-app fails when restored data lacks the asserted page (exit 1)" +APP_BAD_SRC="$WORK/src-app-bad" +mkdir -p "$APP_BAD_SRC/site" +echo "placeholder" > "$APP_BAD_SRC/site/other.html" # index.html absent +APP_BAD_REPO="$WORK/app-bad-repo" +restic -r "$APP_BAD_REPO" init -q +restic -r "$APP_BAD_REPO" backup -q "$APP_BAD_SRC" + +cat > "$WORK/recipe-app-bad.yaml" < "$WORK/agent-app-bad.yaml" < "$WORK/app-bad.json" || got=$? +[ "$got" -eq 1 ] || fail "expected exit 1 for docker-app with missing page, got $got" +python3 - "$WORK/app-bad.json" <<'PYEOF' +import json, sys +doc = json.load(open(sys.argv[1])) +assert doc["status"] == "fail", doc +[check] = doc["checks"] +assert check["status"] == "fail", check +msg = check["message"] +# Must fail on readiness, never on "not published". That message meant the +# port lookup lost its race with the daemon and blamed the wrong thing. +assert "not published" not in msg, f"port race regressed: {msg!r}" +assert "not ready" in msg or "exited early" in msg, f"unclear message: {msg!r}" +PYEOF +assert_docker_clean "docker-app failing run" +assert_sandbox_empty + log "all docker e2e cases passed" diff --git a/agent/internal/sandbox/docker.go b/agent/internal/sandbox/docker.go index 7f34689..6c0179b 100644 --- a/agent/internal/sandbox/docker.go +++ b/agent/internal/sandbox/docker.go @@ -24,6 +24,14 @@ import ( // still runs when the surrounding run is cancelled (SIGTERM, timeout). const cleanupTimeout = 60 * time.Second +// How long to wait for the daemon to publish a container's port, and how often +// to re-check. Publication is asynchronous, so the binding is routinely absent +// for a moment after start; a single inspect loses that race every time. +const ( + portPublishTimeout = 15 * time.Second + portPollInterval = 100 * time.Millisecond +) + // ContainerSpec describes a throwaway container for a verification check. type ContainerSpec struct { Image string @@ -93,7 +101,9 @@ func (d *Docker) StartContainer(ctx context.Context, spec ContainerSpec) (string } host := &container.HostConfig{Binds: spec.Binds} if spec.PublishPort != "" { - port, err := nat.NewPort(strings.TrimSuffix(spec.PublishPort, "/tcp"), "tcp") + // NewPort takes the protocol first, then the port. Passing them the + // other way round parses "tcp" as a port number and fails every time. + port, err := nat.NewPort("tcp", strings.TrimSuffix(spec.PublishPort, "/tcp")) if err != nil { return "", fmt.Errorf("invalid port %q: %w", spec.PublishPort, err) } @@ -184,16 +194,36 @@ func (d *Docker) CopyTo(ctx context.Context, id, dstDir, name string, content io // MappedPort returns the ephemeral host port bound to the given container // port ("8080/tcp"). func (d *Docker) MappedPort(ctx context.Context, id, containerPort string) (string, error) { - inspect, err := d.cli.ContainerInspect(ctx, id) - if err != nil { - return "", fmt.Errorf("inspect container: %w", err) - } port := nat.Port(containerPort) - bindings := inspect.NetworkSettings.Ports[port] - if len(bindings) == 0 { - return "", fmt.Errorf("container port %s is not published", containerPort) + deadline := time.Now().Add(portPublishTimeout) + for { + inspect, err := d.cli.ContainerInspect(ctx, id) + if err != nil { + return "", fmt.Errorf("inspect container: %w", err) + } + // The daemon fills NetworkSettings.Ports asynchronously, so an inspect + // issued straight after start reliably returns an empty binding even + // though HostConfig already carries it. Poll rather than read once. + if b := inspect.NetworkSettings.Ports[port]; len(b) > 0 && b[0].HostPort != "" { + return b[0].HostPort, nil + } + // An app that dies on the restored data never publishes anything. That + // is the common real failure here, so name it instead of blaming the + // port and sending the user looking in the wrong place. + if !inspect.State.Running { + return "", fmt.Errorf("container exited early with code %d before publishing port %s", + inspect.State.ExitCode, containerPort) + } + if time.Now().After(deadline) { + return "", fmt.Errorf("container port %s was not published within %s", + containerPort, portPublishTimeout) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(portPollInterval): + } } - return bindings[0].HostPort, nil } // State reports whether the container is still running, and its exit code From 2b1dbbcb602a4091b3e8c21b0ac224436393d799 Mon Sep 17 00:00:00 2001 From: thetechnologist1911 Date: Thu, 17 Sep 2026 19:07:06 -0400 Subject: [PATCH 2/3] fix(agent): say what actually went wrong Four message problems, each hit on a path a new user is likely to take. The human result went to stderr, so `restorable test > result.txt` captured nothing while --json captured everything. docs/commands.md promises "progress goes to stderr, the result to stdout"; now both modes agree. Database failures discarded the reason. psql prints the cause first and then echoes the statement with a caret under it, so keeping the last two lines kept the caret and dropped `relation "assets" does not exist`. Messages now start from the line naming the failure: before: count rows in "assets": LINE 1: SELECT count(*) FROM "assets" / ^ after: count rows in "assets": ERROR: relation "assets" does not exist / LINE 1: SELECT count(*) FROM "assets" The redaction added earlier still strips the LINE segment before transport, so the reason travels and the row data does not. A wrong password or mistyped repo path is the most common first-run mistake, and it printed restic's raw JSON with the one useful sentence buried inside. It now reads: restic snapshots: exit status 12: Fatal: wrong password or no key found. A missing agent.yaml said only that the file does not exist, which is unhelpful to someone who has not met `restorable init` yet. It now names the command. --- agent/cmd/run.go | 7 ++- agent/cmd/test.go | 8 ++- agent/internal/config/config.go | 6 +++ agent/internal/recipes/container.go | 25 +++++++++ agent/internal/recipes/diagnostic_test.go | 62 +++++++++++++++++++++++ agent/internal/recipes/mysql.go | 4 +- agent/internal/recipes/postgres.go | 4 +- agent/internal/restic/restic.go | 26 +++++++++- 8 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 agent/internal/recipes/diagnostic_test.go diff --git a/agent/cmd/run.go b/agent/cmd/run.go index 308403c..4099465 100644 --- a/agent/cmd/run.go +++ b/agent/cmd/run.go @@ -2,6 +2,7 @@ package cmd import ( "errors" + "fmt" "log" "os" @@ -52,7 +53,11 @@ func newRunCmd() *cobra.Command { logger.Printf("write result: %v", err) } } else { - cmd.Println(res.Human()) + // The result goes to stdout, progress to stderr, so `restorable test + // >> log` captures the verdict. cmd.Println writes to stderr. + if _, err := fmt.Fprintln(cmd.OutOrStdout(), res.Human()); err != nil { + logger.Printf("write result: %v", err) + } } logger.Printf("restore test finished: %s", res.Status) } diff --git a/agent/cmd/test.go b/agent/cmd/test.go index b71aca6..0585c03 100644 --- a/agent/cmd/test.go +++ b/agent/cmd/test.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" "github.com/restorable-dev/restorable/agent/internal/config" @@ -49,7 +51,11 @@ func newTestCmd() *cobra.Command { return err } } else { - cmd.Println(res.Human()) + // The result goes to stdout, progress to stderr, so `restorable test + // >> log` captures the verdict. cmd.Println writes to stderr. + if _, err := fmt.Fprintln(cmd.OutOrStdout(), res.Human()); err != nil { + return err + } } if code := res.ExitCode(); code != 0 { return &exitError{code: code} diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index c4e8f46..1dcc68d 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -52,6 +52,12 @@ var envNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // resolved relative to the config file's directory. func Load(path string) (*Config, error) { f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + // The first thing a new user hits if they skip straight to `test`. + // Name the command that creates the file rather than leaving them to + // find it in the docs. + return nil, fmt.Errorf("no config at %s — run `restorable init --repo ` to create one", path) + } if err != nil { return nil, fmt.Errorf("open config: %w", err) } diff --git a/agent/internal/recipes/container.go b/agent/internal/recipes/container.go index cb59eef..a6cce6c 100644 --- a/agent/internal/recipes/container.go +++ b/agent/internal/recipes/container.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "regexp" "strings" "time" @@ -160,6 +161,30 @@ func openDump(path, tmpDir string) (r io.ReadCloser, size int64, custom bool, er return f, info.Size(), string(head) == "PGDMP", nil } +// errLineRe finds the line carrying the actual reason a database command +// failed. +var errLineRe = regexp.MustCompile(`(?i)\b(ERROR|FATAL)\b`) + +// diagnostic summarises command output for a user-facing message, starting +// from the line that says what went wrong. +// +// tail() alone was wrong here: psql reports the reason first and then echoes +// the offending statement with a caret under it, so keeping the last two lines +// kept the caret and discarded "relation \"assets\" does not exist". Users got +// an arrow pointing at nothing, and the quickstart promises the message names +// exactly what was missing. +func diagnostic(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i, line := range lines { + if !errLineRe.MatchString(line) { + continue + } + end := min(i+n, len(lines)) + return strings.Join(lines[i:end], " / ") + } + return tail(s, n) +} + // tail returns the last n lines of command output for error messages. func tail(s string, n int) string { lines := strings.Split(strings.TrimRight(s, "\n"), "\n") diff --git a/agent/internal/recipes/diagnostic_test.go b/agent/internal/recipes/diagnostic_test.go new file mode 100644 index 0000000..3516119 --- /dev/null +++ b/agent/internal/recipes/diagnostic_test.go @@ -0,0 +1,62 @@ +package recipes + +import ( + "strings" + "testing" +) + +// psql and mysql print the reason first and then echo the offending statement +// with a caret under it. tail() kept the caret and dropped the reason, so +// users saw an arrow pointing at nothing while the sentence explaining the +// failure was discarded. +func TestDiagnosticKeepsTheReason(t *testing.T) { + tests := []struct { + name string + out string + n int + mustHave string + }{ + { + name: "postgres missing relation", + out: "ERROR: relation \"assets\" does not exist\n" + + "LINE 1: SELECT count(*) FROM \"assets\"\n" + + " ^", + n: 2, + mustHave: `relation "assets" does not exist`, + }, + { + name: "reason preceded by noise", + out: "Pager usage is off.\n" + + "psql:/tmp/dump:3: ERROR: syntax error at or near \")\"\n" + + "LINE 1: ...\n" + + " ^", + n: 2, + mustHave: "syntax error at or near", + }, + { + name: "mysql fatal", + out: "mysql: [Warning] Using a password on the command line\nERROR 1146 (42S02) at line 3: Table 'restorable.users' doesn't exist", + n: 2, + mustHave: "doesn't exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := diagnostic(tt.out, tt.n) + if !strings.Contains(got, tt.mustHave) { + t.Errorf("dropped the reason\n want substring: %s\n got: %s", tt.mustHave, got) + } + }) + } +} + +// With nothing error-shaped to anchor on, fall back to the old behaviour +// rather than returning nothing. +func TestDiagnosticFallsBackToTail(t *testing.T) { + out := "line one\nline two\nline three" + got := diagnostic(out, 2) + if !strings.Contains(got, "line three") { + t.Errorf("expected tail fallback, got %q", got) + } +} diff --git a/agent/internal/recipes/mysql.go b/agent/internal/recipes/mysql.go index 1d7fd85..bea612c 100644 --- a/agent/internal/recipes/mysql.go +++ b/agent/internal/recipes/mysql.go @@ -101,7 +101,7 @@ func (c *MySQLCheck) Run(ctx context.Context, t *Target) (report.Status, string) return report.StatusError, err.Error() } if code != 0 { - return report.StatusFail, fmt.Sprintf("dump %q failed to load into mysql: %s", c.Dump, tail(out, 3)) + return report.StatusFail, fmt.Sprintf("dump %q failed to load into mysql: %s", c.Dump, diagnostic(out, 3)) } for _, tbl := range c.Tables { @@ -112,7 +112,7 @@ func (c *MySQLCheck) Run(ctx context.Context, t *Target) (report.Status, string) return report.StatusError, err.Error() } if code != 0 { - return report.StatusFail, fmt.Sprintf("count rows in %q: %s", tbl.Name, tail(out, 2)) + return report.StatusFail, fmt.Sprintf("count rows in %q: %s", tbl.Name, diagnostic(out, 2)) } n, err := strconv.ParseInt(strings.TrimSpace(stripMySQLWarning(out)), 10, 64) if err != nil { diff --git a/agent/internal/recipes/postgres.go b/agent/internal/recipes/postgres.go index ba97eb8..2c44861 100644 --- a/agent/internal/recipes/postgres.go +++ b/agent/internal/recipes/postgres.go @@ -142,7 +142,7 @@ func (c *PostgresCheck) Run(ctx context.Context, t *Target) (report.Status, stri return report.StatusError, err.Error() } if code != 0 { - return report.StatusFail, fmt.Sprintf("dump %q failed to load into postgres: %s", c.Dump, tail(out, 3)) + return report.StatusFail, fmt.Sprintf("dump %q failed to load into postgres: %s", c.Dump, diagnostic(out, 3)) } for _, tbl := range c.Tables { @@ -152,7 +152,7 @@ func (c *PostgresCheck) Run(ctx context.Context, t *Target) (report.Status, stri return report.StatusError, err.Error() } if code != 0 { - return report.StatusFail, fmt.Sprintf("count rows in %q: %s", tbl.Name, tail(out, 2)) + return report.StatusFail, fmt.Sprintf("count rows in %q: %s", tbl.Name, diagnostic(out, 2)) } n, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64) if err != nil { diff --git a/agent/internal/restic/restic.go b/agent/internal/restic/restic.go index 71cc095..43ebaef 100644 --- a/agent/internal/restic/restic.go +++ b/agent/internal/restic/restic.go @@ -86,7 +86,7 @@ func (r *Runner) run(ctx context.Context, sub subcommand, out io.Writer, args .. cmd.Stderr = &stderr cmd.Env = r.env() if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(stderr.String()) + msg := resticMessage(strings.TrimSpace(stderr.String())) if msg == "" { return fmt.Errorf("restic %s: %w", sub, err) } @@ -95,6 +95,30 @@ func (r *Runner) run(ctx context.Context, sub subcommand, out io.Writer, args .. return nil } +// resticMessage pulls the readable sentence out of restic's error output. +// +// Several subcommands are invoked with --json, and restic then reports +// failures as {"message_type":"exit_error","code":12,"message":"Fatal: wrong +// password or no key found"}. A wrong password or a mistyped repo path is the +// most common first-run mistake there is, so showing someone the raw object +// and burying the one sentence that tells them what to fix is the worst place +// to do it. +func resticMessage(stderr string) string { + for _, line := range strings.Split(stderr, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "{") { + continue + } + var e struct { + Message string `json:"message"` + } + if json.Unmarshal([]byte(line), &e) == nil && e.Message != "" { + return e.Message + } + } + return stderr +} + // Version returns the restic version string. func (r *Runner) Version(ctx context.Context) (string, error) { var out bytes.Buffer From 5953fad68983cd21022286267d7d10bb8f11f095 Mon Sep 17 00:00:00 2001 From: thetechnologist1911 Date: Thu, 17 Sep 2026 19:09:55 -0400 Subject: [PATCH 3/3] docs: fix instructions that do not work as written Every command in the quickstart is now runnable verbatim. Three were not. The cron line used RESTIC_PASSWORD_FILE, which the agent has never supported, so step 4 failed every time it ran. Worse, it failed into a log file nobody reads, which is precisely the silent-watcher failure this product exists to announce. It now passes the password the supported way. It also splits the streams rather than using 2>&1: with --json and stderr merged in, the results file was never parseable JSON. `restic ls latest` was offered as the way to discover paths to assert, at the exact moment a user is least sure what to write, but nothing in the quickstart ever sets RESTIC_REPOSITORY, so it errored out. Now passes -r. Step 5 said to register against https:// and to "create an account on the dashboard" without saying where that is. The URL appears nowhere in the repo, so the step could not be completed. It names restorable.dev. recipes/immich.yaml could not pass for anyone. Immich names its dumps immich-db-backup-20250729T114018-v1.136.0-pg14.17.sql.gz, so the literal "immich-db-backup-latest.sql.gz" can never exist, and the asserted tables were renamed to singular upstream (verified: the schema declares @Table('user') and @Table('asset')). Since recipes take literal paths with no globbing, the postgres check is commented out with the shell line that gives the dump a stable name. A first run that fails for reasons that are not the user's backup is worse than no recipe. The README and agent-configuration both listed six read-only subcommands; the closed type has seven. SECURITY.md already said seven, so the repo contradicted itself on the one claim a security-minded reader will diff against the source. alerts.md attributed the hourly cron to vercel.json, which actually schedules the daily backstop. The hourly run is a GitHub Actions workflow, which is where you look when alerts are late, and which gets disabled after repository inactivity. Billing docs advertised enforced limits and a price while the live site gives everything away during the open beta. Repo readers arrive first, so they were seeing the worse offer. --- README.md | 17 +++++++++++------ docs/agent-configuration.md | 3 ++- docs/alerts.md | 7 +++++-- docs/billing.md | 7 ++++++- docs/quickstart.md | 19 +++++++++++++------ recipes/immich.yaml | 38 ++++++++++++++++++++++--------------- 6 files changed, 60 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f2ac9f9..d4f14f9 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ and `sqlite` don't. - **Read-only against your repository.** The agent shells out to *your* restic and can only run `version`, `snapshots`, `ls`, `restore`, `check`, - `dump` — the whitelist is enforced in the type system; `forget`/`prune` + `dump`, `cat`. The whitelist is enforced in the type system; `forget`/`prune` are a compile error, not a code-review promise. - **Sandboxes are always destroyed** — on success, failure, and panic. Disk space is checked *before* any restore begins. @@ -115,20 +115,25 @@ and `sqlite` don't. The hosted dashboard adds run history across repos, alerting on failure, stale-backup detection (fires when your *testing* silently dies — the -meta-failure). Free tier: 1 repo, monthly tests, 1 alert channel. Pro -($8/mo or $80/yr): unlimited repos, any schedule, all channels, 12-month -history (monthly confidence reports coming soon). The agent works forever -without it. +meta-failure). + +**It is all free during the open beta.** Every Pro feature, no card, no +trial clock. The plans below are what it will cost when the beta ends. +Free: 1 repo, monthly tests, 1 alert channel. Pro ($8/mo or $80/yr): +unlimited repos, any schedule, all channels, 12-month history. The agent +works forever without any of it. ## Docs +[Quickstart](docs/quickstart.md) · [Configuration](docs/agent-configuration.md) · [Commands](docs/commands.md) · [files](docs/recipes-files.md) · [databases](docs/recipes-databases.md) · [docker-app](docs/recipes-docker-app.md) · [Alerts](docs/alerts.md) · -[Cloud](docs/cloud.md) +[Cloud](docs/cloud.md) · +[Plans](docs/billing.md) ## Building from source diff --git a/docs/agent-configuration.md b/docs/agent-configuration.md index 113d2c4..f64cbdf 100644 --- a/docs/agent-configuration.md +++ b/docs/agent-configuration.md @@ -47,6 +47,7 @@ the test aborts with a clear error and nothing is restored. ## Read-only guarantee The agent only ever runs read operations against your repository: `version`, -`snapshots`, `ls`, `restore`, `check`, `dump`. This whitelist is enforced in +`snapshots`, `ls`, `restore`, `check`, `dump`, `cat`. This whitelist is +enforced in the code's type system — the exec wrapper physically cannot run `forget`, `prune`, or anything else. diff --git a/docs/alerts.md b/docs/alerts.md index 3eb1167..8f91ba0 100644 --- a/docs/alerts.md +++ b/docs/alerts.md @@ -43,5 +43,8 @@ before it receives real alerts. ## Cron -Stale and silent detection run from an hourly scheduled invocation of -`/api/cron/alerts` (`vercel.json`), authenticated with `CRON_SECRET`. +Stale and silent detection run from `/api/cron/alerts`, authenticated with +`CRON_SECRET`. Two schedulers call it: a GitHub Actions workflow +(`.github/workflows/cron-alerts.yml`) hourly, and a Vercel cron +(`vercel.json`) daily as a backstop. If alerts seem late, check the Actions +workflow first: scheduled workflows get disabled after repository inactivity. diff --git a/docs/billing.md b/docs/billing.md index 536c62f..d19e17e 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -1,5 +1,10 @@ # Plans & billing +> **Open beta: everything here is free right now.** Every Pro feature is +> unlocked for every account, no card and no trial clock. The limits and +> prices below describe what happens when the beta ends, not what is +> enforced today. + | | Free | Pro ($8/mo or $80/yr) | |---|---|---| | Repositories | 1 | Unlimited | @@ -13,7 +18,7 @@ alert channel. Pro removes the limits and adds monthly confidence reports ## How limits behave -Limits are enforced server-side, at the moment of growth: +Once the beta ends, limits are enforced server-side, at the moment of growth: - A run for a **new** repository beyond your limit is rejected with a clear `plan limit` error. Repositories that already report are never blocked. diff --git a/docs/quickstart.md b/docs/quickstart.md index a7848e5..b303312 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -53,8 +53,12 @@ checks: checksum_sample: 5 # verify 5 random restored files against the repository ``` -Not sure what paths to assert? `restic ls latest | head -50` shows what's in -your latest snapshot. Backing up Nextcloud, Vaultwarden, or Immich? Copy a +Not sure what paths to assert? This shows what's in your latest snapshot: + +```sh +restic -r /srv/backups/restic ls latest | head -50 +``` + Backing up Nextcloud, Vaultwarden, or Immich? Copy a [ready-made recipe](../recipes/) instead. ## 3. Run your first test (1 min + restore time) @@ -85,10 +89,12 @@ the number that matters on the day you actually need it. ## 4. Make it automatic (2 min) -Cron (uses exit codes: 0 pass, 1 fail, 2 error): +Cron (uses exit codes: 0 pass, 1 fail, 2 error). Results go to stdout and +progress to stderr, so keep them apart and the results file stays one JSON +object per run: ``` -0 3 * * 0 cd /etc/restorable && RESTIC_PASSWORD_FILE=/etc/restorable/pw restorable test --json >> /var/log/restorable.log 2>&1 +0 3 * * 0 RESTIC_PASSWORD="$(cat /etc/restorable/pw)" restorable test --config /etc/restorable/agent.yaml --json >> /var/log/restorable.jsonl 2>> /var/log/restorable.log ``` Or run the built-in daemon — add `schedule: "0 3 * * 0"` to `agent.yaml` and: @@ -99,11 +105,12 @@ restorable run ## 5. Optional: alerts + dashboard (3 min) -Create an account on the dashboard, mint a registration token under +Create an account at [restorable.dev](https://restorable.dev), mint a +registration token under **Agents**, and connect: ```sh -restorable register --url https:// --token rrt_… +restorable register --url https://restorable.dev --token rrt_… ``` Every test now reports pass/fail metadata (never your data) to your diff --git a/recipes/immich.yaml b/recipes/immich.yaml index 88d4e31..08f987c 100644 --- a/recipes/immich.yaml +++ b/recipes/immich.yaml @@ -1,9 +1,7 @@ # Immich restore verification. # # Assumes your backup covers Immich's UPLOAD_LOCATION, which also holds the -# automatic database dumps Immich writes to backups/ (gzipped pg_dump files — -# supported directly). Adjust the dump path to your newest dump file, or -# point it at wherever your own pg_dump job writes. +# automatic database dumps Immich writes to backups/. name: immich checks: - type: files @@ -13,15 +11,25 @@ checks: - path: upload/ checksum_sample: 5 - # Immich's own DB dumps land in backups/ under the upload location. - # The dump filename contains a timestamp; point this at the one your - # backup captures (a stable symlink or copy step in your backup job - # makes this deterministic). - - type: postgres - dump: backups/immich-db-backup-latest.sql.gz - image: postgres:16-alpine - tables: - - name: users - min_rows: 1 - - name: assets - min_rows: 1 + # Immich names its dumps with a timestamp and two version numbers, e.g. + # backups/immich-db-backup-20250729T114018-v1.136.0-pg14.17.sql.gz + # so there is no fixed path to assert: the filename changes every run and + # again on every upgrade. Recipes are literal paths, with no globbing. + # + # To verify the database too, give your backup job a stable name to point + # at, then uncomment the check below and use that path: + # + # cp "$(ls -t /path/to/upload/backups/immich-db-backup-*.sql.gz | head -1)" \ + # /path/to/upload/backups/latest.sql.gz + # + # - type: postgres + # dump: backups/latest.sql.gz + # image: postgres:16-alpine + # tables: + # # Singular: Immich declares @Table('user') and @Table('asset'). + # # They were renamed from the plural forms in the 1.13x line, so check + # # your own dump if you are on something older. + # - name: user + # min_rows: 1 + # - name: asset + # min_rows: 1