gitmote

gitmote  ·  history  ·  branches

refactor: drop the redundant object-store startup check

d7f89ecbd100d16acf27a15fc3dedd0a581ea15a
Atanas Minev <atmin@protonmail.com> · 2026-07-17 15:08

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 09363e7..1c4b95c 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -348,15 +348,6 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, nil, nil, noop, fmt.Errorf("object store: %w", err)
}
- // Fail fast on the refs-without-objects split: the metadata restored, so refs
- // would be advertised, but the objects are unreachable in the store (genuine
- // object-store loss under an otherwise-correct root). A loud crash at startup
- // beats silently serving clones/pushes/browses that cannot succeed.
- if err := verifyObjectStore(ctx, md, objs); err != nil {
- _ = md.Close()
- return nil, nil, nil, nil, noop, fmt.Errorf("object store consistency: %w", err)
- }
-
// First-run auto-bootstrap: a fresh empty-bucket instance is usable without a
// second command. Runs before anything serves; non-fatal (logged, not returned).
maybeAutoBootstrap(ctx, md, logger)
@@ -489,47 +480,6 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return handler, ui, reportAPI, md.IsLeader, cleanup, nil
}
-// verifyObjectStore fails fast on the "refs without objects" split: the
-// metadata restored (so refs will be advertised) but the object store holds no
-// objects for a repo that has refs. Serving in that state advertises refs it
-// cannot fulfil — clones get upload-pack "not our ref", pushes fail with
-// "unpacker error", browses 404. Objects and metadata share one storage root, so
-// a wrong root restores neither (a clean empty instance) rather than tripping
-// this; a failure here therefore means genuine object-store loss under an
-// otherwise-correct root. A fresh instance (no repo has refs yet) passes — absent
-// objects are correct there.
-func verifyObjectStore(ctx context.Context, md *meta.Metadata, objs store.Store) error {
- repos, err := md.ListRepos(ctx)
- if err != nil {
- return fmt.Errorf("list repos: %w", err)
- }
- for _, r := range repos {
- refs, err := md.ListRefs(ctx, r.ID)
- if err != nil {
- return fmt.Errorf("list refs for %q: %w", r.Name, err)
- }
- if len(refs) == 0 {
- continue // no history yet — the store is correctly empty for this repo
- }
- // This repo has refs, so a correctly-addressed store holds at least one
- // object for it. One repo suffices: the storage root is global, so an
- // empty result here means every repo's objects are unreachable.
- keys, err := objs.List(ctx, r.Name+"/objects/")
- if err != nil {
- return fmt.Errorf("list objects for %q: %w", r.Name, err)
- }
- if len(keys) == 0 {
- return fmt.Errorf("repo %q has %d ref(s) in metadata but no objects in the store "+
- "under %q/objects/ — refs restored but their objects did not. Objects and "+
- "metadata share one storage root, so this means object-store loss: verify "+
- "GITMOTE_S3_BUCKET and GITMOTE_S3_ENDPOINT address the root these refs belong to",
- r.Name, len(refs), r.Name)
- }
- return nil // a repo with refs has objects — the store is reachable
- }
- return nil // no repo has refs yet — fresh instance, nothing to verify
-}
-
// buildUI constructs the management UI (always on). Its session cookie key is
// GITMOTE_COOKIE_KEY when set, else an auto-generated key persisted in meta and
// restored across restart / scale-to-zero — so a session stays valid through an
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index 0accf1e..113c9d1 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -600,70 +600,3 @@ func TestRunFailsOnBadAddr(t *testing.T) {
t.Errorf("run error = %q, want a listen error", err)
}
}
-
-// TestVerifyObjectStore covers the startup guard against the refs-without-objects
-// split: a fresh instance and a healthy store pass, but refs whose objects are
-// unreachable fail loud. (The prefix cause is now prevented — the bucket records
-// its own object prefix — so the guard points at the object store itself.)
-func TestVerifyObjectStore(t *testing.T) {
- ctx := context.Background()
- const repoName = "gitmote"
- const head = "aa1c7cbe7b72b787270f456d61fe0046a46cba12"
-
- openMeta := func(t *testing.T) *meta.Metadata {
- t.Helper()
- m, err := meta.Open(ctx, meta.Config{LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3")})
- if err != nil {
- t.Fatalf("meta.Open: %v", err)
- }
- t.Cleanup(func() { _ = m.Close() })
- return m
- }
- withRef := func(t *testing.T) *meta.Metadata {
- t.Helper()
- m := openMeta(t)
- r, err := m.CreateRepo(ctx, repoName, "master")
- if err != nil {
- t.Fatalf("CreateRepo: %v", err)
- }
- if err := m.CASRef(ctx, r.ID, "refs/heads/master", meta.ZeroSHA, head); err != nil {
- t.Fatalf("CASRef: %v", err)
- }
- return m
- }
-
- t.Run("fresh instance with no refs passes", func(t *testing.T) {
- m := openMeta(t)
- if _, err := m.CreateRepo(ctx, repoName, "master"); err != nil {
- t.Fatalf("CreateRepo: %v", err)
- }
- // A repo exists but nothing was pushed — an empty store is correct.
- if err := verifyObjectStore(ctx, m, store.NewMem()); err != nil {
- t.Errorf("fresh instance rejected: %v", err)
- }
- })
-
- t.Run("refs with objects present passes", func(t *testing.T) {
- m := withRef(t)
- s := store.NewMem()
- if err := s.Put(ctx, repoName+"/objects/aa/1c7cbe", bytes.NewReader([]byte("x"))); err != nil {
- t.Fatalf("Put: %v", err)
- }
- if err := verifyObjectStore(ctx, m, s); err != nil {
- t.Errorf("healthy store rejected: %v", err)
- }
- })
-
- t.Run("refs without objects fails loud", func(t *testing.T) {
- m := withRef(t)
- // Refs restored but the store is empty at the prefix — the regression.
- err := verifyObjectStore(ctx, m, store.NewMem())
- if err == nil {
- t.Fatal("expected an error for refs-without-objects, got nil")
- }
- if !strings.Contains(err.Error(), "no objects in the store") ||
- !strings.Contains(err.Error(), "GITMOTE_S3_BUCKET") {
- t.Errorf("error must name the split and point at the object store/bucket; got: %v", err)
- }
- })
-}
diff --git a/docs/ops.md b/docs/ops.md
index 90e082e..1edfedb 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -91,13 +91,14 @@ Setting these on the container does nothing for CI, and vice versa.
| `AWS_REGION` | region; defaults to `us-east-1` (ignored by most S3-compatible stores). **Set it for real AWS S3** (e.g. `fr-par` on Scaleway) |
| `GITMOTE_S3_ENDPOINT` | `https://s3.fr-par.scw.cloud` — the S3 endpoint; **omit for real AWS S3** |
-**Auto-managed — leave unset; generated and persisted in meta on first run (an
-explicit env still wins).** No longer part of the required surface.
+**Auto-managed — leave unset; a sensible default applies and an explicit env still
+wins.** Not part of the required surface.
| Variable | Value / meaning |
|----------|-----------------|
-| `GITMOTE_COOKIE_KEY` | signs management-UI session cookies; auto-generated + persisted (stable across restart / scale-to-zero) |
-| `WORKER_SECRET` | runner report-back auth; auto-generated + persisted **when a CI trigger is configured** (below) |
+| `GITMOTE_COOKIE_KEY` | signs management-UI session cookies; auto-generated + persisted in meta (stable across restart / scale-to-zero) |
+| `WORKER_SECRET` | runner report-back auth; auto-generated + persisted in meta **when a CI trigger is configured** (below) |
+| `GITMOTE_DATA` | base dir for the db (`meta.sqlite3`), cache, and socket; **preset to `/data` in the image** — mount a volume there to keep the cache across restarts. Ephemeral by design (restored from S3 on cold start) |
**CI — optional, off by default** (runs are still recorded; nothing executes until
one of these turns a trigger on — see [CI substrate](#ci-substrate)).
@@ -111,12 +112,6 @@ one of these turns a trigger on — see [CI substrate](#ci-substrate)).
| `GITMOTE_CI_SECRET_KEY_V<n>` | secret — base64 of 32 bytes; **env-only** master key for per-repo CI secrets, never persisted ([safety.md §5](architecture/safety.md)). Add `_V2`, … to rotate (highest is current; old envelopes still decrypt). Unset → the secrets UI is off and none are injected |
| `GITMOTE_CI_ALLOW_BUILDS` | `1`/`true` → let workflows build container images on a **daemon-backed local/VPS** runner (leaves `act`'s host-Docker-socket mount in place). ⚠️ **Hands untrusted workflow code the host daemon (host root) — trusted repos only** ([safety.md §7](architecture/safety.md)). Unset/false (default) → the socket mount is suppressed. No effect on the Scaleway substrate (can't build regardless) |
-**Advanced overrides — rarely needed** (the defaults are derived).
-
-| Variable | Value / meaning |
-|----------|-----------------|
-| `GITMOTE_DATA` | base dir for the db (`meta.sqlite3`), cache, and socket; **preset to `/data` in the image** — mount a volume there to keep the cache across restarts. Ephemeral by design (restored from S3 on cold start) |
-
`GITMOTE_DATA` is ephemeral on purpose: the object store + litestream replica are
the durable state, and the local disk is a cache. On a cold start (scale-to-zero →
wake, or a redeploy) the DB is restored from the derived replica and repos