gitmote

gitmote  ·  history  ·  branches

refactor!: fold S3 storage config into one GITMOTE_S3_BUCKET root

08c77b63f1d33ca1ea26110215f5e426a0591717
Atanas Minev <atmin@protonmail.com> · 2026-07-17 14:40

GITMOTE_S3_BUCKET now takes "bucket" or "bucket/base"; the base scopes objects,
CI logs, and the metadata replica alike, so refs and objects share one storage
root — point at the wrong one and nothing restores, never a half-restored state.
This drops GITMOTE_S3_PREFIX, GITMOTE_DB_REPLICA, GITMOTE_DB/CACHE/SOCK,
GITMOTE_HOOK/RUNNER, and GITMOTE_ADMIN_HANDLE, and defaults AWS_REGION. The core
is a storage root plus credentials.

BREAKING CHANGE: objects moved under the storage root (no separate
GITMOTE_S3_PREFIX) and the removed vars no longer apply — clear the bucket and
re-push.
diff --git a/README.md b/README.md
index f767b41..73888e1 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,6 @@ not a running server.
docker run --rm -p 8080:8080 \
-e GITMOTE_S3_BUCKET=my-gitmote-bucket \
-e GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \ # your provider's endpoint
- -e GITMOTE_S3_PREFIX=objects \ # MUST match the bucket's existing prefix
-e AWS_REGION=fr-par \
-e AWS_ACCESS_KEY_ID=… -e AWS_SECRET_ACCESS_KEY=… \
ghcr.io/atmin/gitmote:master
@@ -78,20 +77,18 @@ git remote add gitmote http://admin:<token>@localhost:8080/<repo>
git push gitmote main
```
-Two things to get right:
+Point at the same **storage root** (`GITMOTE_S3_BUCKET`), endpoint, and region as
+before. Objects and metadata share that root, so a later instance either finds the
+whole forge or, pointed at the wrong root, sees a clean empty one — never a
+half-restored state.
-- **`GITMOTE_S3_PREFIX` is a data address, not a preference.** A local instance
- pointed at an existing bucket must use the **same** prefix (and endpoint/region)
- that bucket was written with, or gitmote comes up advertising refs whose objects
- it can't find and aborts startup. If you created the bucket with this same
- quickstart, match whatever you used then.
-- **Use your existing token.** A populated bucket already has an admin; don't
- re-bootstrap it — sign in with the token you saved (or `bootstrap -reissue` to
- mint a fresh one).
+**Use your existing token.** A populated bucket already has an admin; don't
+re-bootstrap it — sign in with the token you saved (or `bootstrap -reissue` to
+mint a fresh one).
**Only one writer at a time — and that's enforced, not your job.** gitmote takes a
-single-writer *lease* on the bucket, so running a second instance (or a stray cloud
-container) can't corrupt anything: whoever holds the lease writes, the other serves
+single-writer *lease* on the storage root, so running a second instance (or a stray
+cloud container) can't corrupt anything: whoever holds the lease writes, the other serves
reads and returns a retryable `503` on push. So a local on-demand forge is safe
even if you also run gitmote in the cloud — no coordination, no harm. Details in
[docs/ops.md](docs/ops.md) (“Single writer is a correctness invariant”).
@@ -143,8 +140,8 @@ no daemon. Deployment — Scaleway Serverless Containers, single writer,
An empty instance has no users, so token auth would be a chicken-and-egg — so the
server **auto-bootstraps on first run**: when it is the writer and no admin
-exists, it creates the admin (`GITMOTE_ADMIN_HANDLE`, default `admin`) and prints
-a one-time token to the logs (see the Quickstart). No second command.
+exists, it creates the `admin` user and prints a one-time token to the logs (see
+the Quickstart). No second command.
You can still bootstrap by hand against the bucket **before** the server is live
(`gitmote bootstrap [-handle …] [-repo …]`), and recover a lost token with
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 6a82aca..09363e7 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -77,7 +77,7 @@ func main() {
// S3 for the server to restore.
func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
- handle := fs.String("handle", os.Getenv("GITMOTE_ADMIN_HANDLE"), "admin user handle (default admin, or GITMOTE_ADMIN_HANDLE)")
+ handle := fs.String("handle", bootstrap.DefaultAdminHandle, "admin user handle")
repoName := fs.String("repo", "", "optional initial repository, e.g. gitmote (repos are otherwise made in the UI)")
branch := fs.String("default-branch", "main", "default branch for the initial repo")
reissue := fs.Bool("reissue", false, "mint a fresh token for the existing admin (recover a lost token)")
@@ -166,7 +166,7 @@ func maybeAutoBootstrap(ctx context.Context, md *meta.Metadata, logger *slog.Log
if exists {
return
}
- res, err := bootstrap.Run(ctx, md, bootstrap.Options{AdminHandle: os.Getenv("GITMOTE_ADMIN_HANDLE")})
+ res, err := bootstrap.Run(ctx, md, bootstrap.Options{AdminHandle: bootstrap.DefaultAdminHandle})
if err != nil {
logger.Error("auto-bootstrap failed; bootstrap by hand", "error", err)
return
@@ -312,9 +312,8 @@ func alwaysServable(p string) bool {
// meta. It shares this metadata handle, so it runs alongside the git server.
//
// The db, cache, and socket derive from GITMOTE_DATA (see dataDir); metadata
-// replicates to a bucket-derived replica (see replicaTarget). The push path
-// installs the pre-receive hook binary at GITMOTE_HOOK (default gitmote-hook
-// alongside this binary).
+// replicates to a root-derived replica (see replicaTarget). The push path
+// installs the pre-receive hook binary (gitmote-hook, beside this binary).
func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *webui.Handler, *ci.ReportAPI, func() bool, func() error, error) {
noop := func() error { return nil }
@@ -323,11 +322,6 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, nil, nil, noop, nil
}
- objs, err := store.NewS3FromEnv(ctx)
- if err != nil {
- return nil, nil, nil, nil, noop, fmt.Errorf("object store: %w", err)
- }
-
// RoleAuto: this instance becomes the writer when it can acquire the lease,
// otherwise a read-only follower — so a rolling deploy's brief old+new overlap
// is one writer + one follower, never two writers (safety.md §1). With no
@@ -344,10 +338,20 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
logger.Warn("lost the writer lease; now read-only", "error", err)
})
- // Fail fast on the refs-without-objects split (typically a wrong or removed
- // GITMOTE_S3_PREFIX): the metadata restored, so refs would be advertised, but
- // the objects are unreachable in the store. A loud crash at startup beats
- // silently serving clones/pushes/browses that cannot succeed.
+ // Objects, CI logs, and the metadata replica all live under the storage root
+ // (GITMOTE_S3_BUCKET, "bucket" or "bucket/base"), so they share fate: point at
+ // the wrong root and nothing restores (a clean, empty instance), never the
+ // half-restored refs-without-objects state.
+ objs, err := store.NewS3FromEnv(ctx)
+ if err != nil {
+ _ = md.Close()
+ 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)
@@ -489,11 +493,11 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
// 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. The usual cause is a wrong or removed
-// GITMOTE_S3_PREFIX: object keys are {prefix}/{repo}/objects/…, but the metadata
-// replica is prefix-independent, so a bad prefix strands every object while refs
-// still restore. A fresh instance (no repo has refs yet) passes — absent objects
-// are correct there.
+// "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 {
@@ -508,7 +512,7 @@ func verifyObjectStore(ctx context.Context, md *meta.Metadata, objs store.Store)
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 object prefix is global, so an
+ // 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 {
@@ -516,10 +520,9 @@ func verifyObjectStore(ctx context.Context, md *meta.Metadata, objs store.Store)
}
if len(keys) == 0 {
return fmt.Errorf("repo %q has %d ref(s) in metadata but no objects in the store "+
- "under %q/objects/ — the objects are unreachable at the current "+
- "GITMOTE_S3_PREFIX (object keys are {prefix}/{repo}/objects/…; the meta replica "+
- "is prefix-independent, so refs restored while objects did not). Verify "+
- "GITMOTE_S3_PREFIX matches the prefix the objects were written under",
+ "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
@@ -579,28 +582,18 @@ func genSecret() ([]byte, error) {
return enc, nil
}
-// hookBinaryPath resolves the pre-receive hook executable: GITMOTE_HOOK if set,
-// otherwise gitmote-hook next to this binary.
-func hookBinaryPath() string {
- if p := os.Getenv("GITMOTE_HOOK"); p != "" {
- return p
- }
- if exe, err := os.Executable(); err == nil {
- return filepath.Join(filepath.Dir(exe), "gitmote-hook")
- }
- return "gitmote-hook"
-}
+// hookBinaryPath is the pre-receive hook executable, gitmote-hook beside this
+// binary. runnerBinaryPath is the CI runner the local trigger spawns.
+func hookBinaryPath() string { return besideExe("gitmote-hook") }
+func runnerBinaryPath() string { return besideExe("gitmote-runner") }
-// runnerBinaryPath resolves the CI runner executable the local trigger spawns:
-// GITMOTE_RUNNER if set, otherwise gitmote-runner next to this binary.
-func runnerBinaryPath() string {
- if p := os.Getenv("GITMOTE_RUNNER"); p != "" {
- return p
- }
+// besideExe resolves a sibling binary next to this executable, falling back to a
+// bare name (found on PATH) when the executable path can't be determined.
+func besideExe(name string) string {
if exe, err := os.Executable(); err == nil {
- return filepath.Join(filepath.Dir(exe), "gitmote-runner")
+ return filepath.Join(filepath.Dir(exe), name)
}
- return "gitmote-runner"
+ return name
}
// metaConfigFromEnv builds the metadata database config from the environment.
@@ -629,37 +622,36 @@ func metaConfigFromEnv(logger *slog.Logger, role s3lite.Role) meta.Config {
return cfg
}
-// replicaTarget resolves the metadata replica URL. An explicit GITMOTE_DB_REPLICA
-// wins as an override; otherwise, whenever a bucket is set, the replica is derived
-// as s3://{bucket}/meta — a sibling of the git objects. A bucket alone therefore
-// yields durability and the single-writer lease with no second env.
-//
-// Derive from the bucket only, never bucket+prefix: s3://{bucket}/{prefix}meta
-// would be a different path and orphan the existing sibling replica. The sibling
-// layout (objects/ + meta) is deliberate.
+// replicaTarget derives the metadata replica URL from the storage root: the
+// metadata lives at {root}/meta, a sibling of the git objects under the same
+// root, so a bucket alone yields durability and the single-writer lease with no
+// second env. Placing it under the base (not the bare bucket) is deliberate: refs
+// and objects then share fate, so a wrong root restores nothing rather than
+// stranding objects behind refs that still advertise.
func replicaTarget() string {
- if replica := os.Getenv("GITMOTE_DB_REPLICA"); replica != "" {
- return replica
+ bucket, prefix := store.ParseBucket(os.Getenv("GITMOTE_S3_BUCKET"))
+ if bucket == "" {
+ return ""
}
- if bucket := os.Getenv("GITMOTE_S3_BUCKET"); bucket != "" {
- return "s3://" + bucket + "/meta"
+ root := bucket
+ if prefix != "" {
+ root += "/" + prefix
}
- return ""
+ return "s3://" + root + "/meta"
}
// dataDir is the base directory for gitmote's local, restore-from-S3 state:
-// GITMOTE_DATA, defaulting to a temp dir. The db, cache, and socket derive from
-// it — one -v ./data:/data mounts the lot — each still overridable individually.
+// GITMOTE_DATA, defaulting to a temp dir. The db, cache, and socket all live
+// under it — one -v ./data:/data mounts the lot.
func dataDir() string {
return envOr("GITMOTE_DATA", filepath.Join(os.TempDir(), "gitmote"))
}
-// dbPath, cachePath, and sockPath resolve the metadata DB, materialized-repo
-// cache, and pre-receive hook socket under dataDir, honoring the per-path
-// overrides (GITMOTE_DB / GITMOTE_CACHE / GITMOTE_SOCK) when set.
-func dbPath() string { return envOr("GITMOTE_DB", filepath.Join(dataDir(), "meta.sqlite3")) }
-func cachePath() string { return envOr("GITMOTE_CACHE", filepath.Join(dataDir(), "cache")) }
-func sockPath() string { return envOr("GITMOTE_SOCK", filepath.Join(dataDir(), "gitmote.sock")) }
+// dbPath, cachePath, and sockPath are the metadata DB, materialized-repo cache,
+// and pre-receive hook socket under dataDir.
+func dbPath() string { return filepath.Join(dataDir(), "meta.sqlite3") }
+func cachePath() string { return filepath.Join(dataDir(), "cache") }
+func sockPath() string { return filepath.Join(dataDir(), "gitmote.sock") }
// envOr returns the value of the environment variable key, or fallback if
// it is unset or empty.
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index 3f4a764..0accf1e 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -210,8 +210,7 @@ func TestRunShutsDownOnCancel(t *testing.T) {
}
func TestRunBootstrap(t *testing.T) {
- dbPath := filepath.Join(t.TempDir(), "meta.sqlite3")
- t.Setenv("GITMOTE_DB", dbPath)
+ t.Setenv("GITMOTE_DATA", t.TempDir())
var out bytes.Buffer
args := []string{"-handle", "atmin", "-repo", "gitmote"}
@@ -233,9 +232,9 @@ func TestRunBootstrap(t *testing.T) {
}
func TestRunBootstrapDefaultsHandleAndReissues(t *testing.T) {
- dbPath := filepath.Join(t.TempDir(), "meta.sqlite3")
- t.Setenv("GITMOTE_DB", dbPath)
- t.Setenv("GITMOTE_ADMIN_HANDLE", "")
+ dataDir := t.TempDir()
+ t.Setenv("GITMOTE_DATA", dataDir)
+ dbPath := filepath.Join(dataDir, "meta.sqlite3")
// No handle, no repo: the admin defaults to "admin" and a token is printed.
var out bytes.Buffer
@@ -274,7 +273,6 @@ func TestRunBootstrapDefaultsHandleAndReissues(t *testing.T) {
func TestMaybeAutoBootstrapCreatesAdminOnceOnFreshLeader(t *testing.T) {
ctx := context.Background()
- t.Setenv("GITMOTE_ADMIN_HANDLE", "")
md := openMeta(t) // local-only → RoleOff → always leader
logger := slog.New(slog.DiscardHandler)
@@ -318,26 +316,21 @@ func extractToken(t *testing.T, s string) string {
}
func TestReplicaTarget(t *testing.T) {
- // A bucket alone derives s3://{bucket}/meta; the object prefix must NOT leak
- // into the replica path (that would orphan the existing sibling replica). An
- // explicit GITMOTE_DB_REPLICA overrides the derivation.
+ // The replica lives at {root}/meta, under the same storage root as the objects
+ // — so a "bucket/base" spec scopes the replica too (they share fate).
tests := []struct {
- name string
- replica string
- bucket string
- prefix string
- want string
+ name string
+ bucket string
+ want string
}{
- {"derived from bucket", "", "gitmote", "", "s3://gitmote/meta"},
- {"prefix does not leak", "", "gitmote", "objects/", "s3://gitmote/meta"},
- {"explicit replica overrides", "s3://other/wal", "gitmote", "objects/", "s3://other/wal"},
- {"no bucket, no replica", "", "", "", ""},
+ {"bucket only", "gitmote", "s3://gitmote/meta"},
+ {"bucket with base", "shared/gitmote", "s3://shared/gitmote/meta"},
+ {"nested base", "shared/a/b", "s3://shared/a/b/meta"},
+ {"no bucket", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- t.Setenv("GITMOTE_DB_REPLICA", tt.replica)
t.Setenv("GITMOTE_S3_BUCKET", tt.bucket)
- t.Setenv("GITMOTE_S3_PREFIX", tt.prefix)
if got := replicaTarget(); got != tt.want {
t.Errorf("replicaTarget() = %q, want %q", got, tt.want)
}
@@ -346,26 +339,24 @@ func TestReplicaTarget(t *testing.T) {
}
func TestMetaConfigDerivesReplicaAndRole(t *testing.T) {
- // With a bucket set, metaConfigFromEnv derives the replica and applies the
- // leased role — a bucket always means durability + the single-writer lease.
- t.Setenv("GITMOTE_DB_REPLICA", "")
- t.Setenv("GITMOTE_S3_BUCKET", "gitmote")
- t.Setenv("GITMOTE_S3_PREFIX", "objects/")
+ // With a storage root set, metaConfigFromEnv derives the replica under it and
+ // applies the leased role — a root always means durability + the single-writer
+ // lease, and the base scopes the replica.
+ t.Setenv("GITMOTE_S3_BUCKET", "shared/gitmote")
t.Setenv("GITMOTE_DATA", t.TempDir())
cfg := metaConfigFromEnv(nil, s3lite.RoleAuto)
- if cfg.RestoreFrom != "s3://gitmote/meta" || cfg.BackupTo != "s3://gitmote/meta" {
- t.Errorf("replica = %q / %q, want s3://gitmote/meta (prefix must not leak)", cfg.RestoreFrom, cfg.BackupTo)
+ if cfg.RestoreFrom != "s3://shared/gitmote/meta" || cfg.BackupTo != "s3://shared/gitmote/meta" {
+ t.Errorf("replica = %q / %q, want s3://shared/gitmote/meta (base scopes the replica)", cfg.RestoreFrom, cfg.BackupTo)
}
if cfg.Role != s3lite.RoleAuto {
- t.Errorf("Role = %v, want RoleAuto (a bucket must yield the lease)", cfg.Role)
+ t.Errorf("Role = %v, want RoleAuto (a root must yield the lease)", cfg.Role)
}
}
func TestMetaConfigNoBucketStaysRoleOff(t *testing.T) {
- // No bucket and no replica: nothing to coordinate on, so the database stays
- // local-only and RoleOff (always writer) — tests and ephemeral runs unchanged.
- t.Setenv("GITMOTE_DB_REPLICA", "")
+ // No bucket: nothing to coordinate on, so the database stays local-only and
+ // RoleOff (always writer) — tests and ephemeral runs unchanged.
t.Setenv("GITMOTE_S3_BUCKET", "")
cfg := metaConfigFromEnv(nil, s3lite.RoleAuto)
@@ -378,12 +369,9 @@ func TestMetaConfigNoBucketStaysRoleOff(t *testing.T) {
}
func TestDataPathsUnderGitmoteData(t *testing.T) {
- // GITMOTE_DATA alone places the db, cache, and socket under it.
+ // GITMOTE_DATA places the db, cache, and socket under it.
dir := t.TempDir()
t.Setenv("GITMOTE_DATA", dir)
- t.Setenv("GITMOTE_DB", "")
- t.Setenv("GITMOTE_CACHE", "")
- t.Setenv("GITMOTE_SOCK", "")
if got, want := dbPath(), filepath.Join(dir, "meta.sqlite3"); got != want {
t.Errorf("dbPath() = %q, want %q", got, want)
@@ -396,15 +384,6 @@ func TestDataPathsUnderGitmoteData(t *testing.T) {
}
}
-func TestDataPathOverride(t *testing.T) {
- // The explicit per-path vars still override the GITMOTE_DATA derivation.
- t.Setenv("GITMOTE_DATA", "/data")
- t.Setenv("GITMOTE_DB", "/custom/db.sqlite3")
- if got, want := dbPath(), "/custom/db.sqlite3"; got != want {
- t.Errorf("dbPath() = %q, want the GITMOTE_DB override %q", got, want)
- }
-}
-
// openMeta opens a fresh, local-only metadata DB at a temp path.
func openMeta(t *testing.T) *meta.Metadata {
t.Helper()
@@ -623,8 +602,9 @@ func TestRunFailsOnBadAddr(t *testing.T) {
}
// TestVerifyObjectStore covers the startup guard against the refs-without-objects
-// split (the wrong/removed GITMOTE_S3_PREFIX regression): a fresh instance and a
-// healthy store pass, but refs whose objects are unreachable fail loud.
+// 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"
@@ -681,8 +661,9 @@ func TestVerifyObjectStore(t *testing.T) {
if err == nil {
t.Fatal("expected an error for refs-without-objects, got nil")
}
- if !strings.Contains(err.Error(), "GITMOTE_S3_PREFIX") {
- t.Errorf("error must name the likely cause GITMOTE_S3_PREFIX; got: %v", err)
+ 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/docker-compose.yml b/docker-compose.yml
index 22f1664..d6690a8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,13 +35,8 @@ services:
image: gitmote:e2e
depends_on: [minio]
environment:
- GITMOTE_S3_BUCKET: gitmote
+ GITMOTE_S3_BUCKET: gitmote # storage root; derives the replica (s3://gitmote/meta)
GITMOTE_S3_ENDPOINT: http://minio:9000
- GITMOTE_DATA: /data # db, cache, and socket all live here
- # A bucket alone derives the replica (s3://gitmote/meta), so replication is
- # on by default. e2e-restore.sh sets this explicitly to the same value while
- # it wipes the volume, exercising the restore-from-S3 cold-start path.
- GITMOTE_DB_REPLICA: ${GITMOTE_DB_REPLICA:-}
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
diff --git a/docs/architecture/ci.md b/docs/architecture/ci.md
index 9a9f75f..64eeaf6 100644
--- a/docs/architecture/ci.md
+++ b/docs/architecture/ci.md
@@ -73,7 +73,7 @@ and `act` keeps its default nested-container behavior.
Three leader-only, litestream-replicated tables in
[`internal/meta`](../../internal/meta): `ci_runs`, `ci_jobs`, `ci_secrets`. Logs
-are append-only blobs under a top-level `ci/` key space
+are append-only blobs under a `ci/` key space within the storage root
(`ci/{repoID}/{runID}/{jobID}.log`), PUT before the `ci_jobs` pointer is
recorded, with a size cap (explicit truncation marker, never silent).
diff --git a/docs/architecture/safety.md b/docs/architecture/safety.md
index eddb7d1..dc7a5ac 100644
--- a/docs/architecture/safety.md
+++ b/docs/architecture/safety.md
@@ -10,8 +10,8 @@
into one writer. **The one rule: never two writers on the same WAL** — and this
is enforced by a **lease**, not by procedure. The server opens s3lite in
`RoleAuto`: it writes only while it holds the lease (litestream's `s3.Leaser`,
- a conditional-write CAS on `lock.json` in the replica bucket) and otherwise runs
- as a read-only follower. This makes the S3 backend's support for **atomic
+ a conditional-write CAS on `lock.json` beside the replica, at `{root}/meta/lock.json`)
+ and otherwise runs as a read-only follower. This makes the S3 backend's support for **atomic
conditional writes** (compare-and-swap: `If-None-Match`/`If-Match`) a correctness
requirement — the lease can't be enforced without it, so a provider lacking the
primitive cannot safely back gitmote. gitmote gates **all metadata-derived requests** on
diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md
index 62babbb..c05bc27 100644
--- a/docs/architecture/storage.md
+++ b/docs/architecture/storage.md
@@ -42,13 +42,16 @@
### S3 (immutable — _content before pointer_)
-Mirrors the on-disk bare repo's immutable directories, per repo prefix. Refs are
-deliberately **excluded** — they live in s3lite.
+Mirrors the on-disk bare repo's immutable directories, under the storage root
+(the whole bucket, or a `bucket/base` sub-path — see [ops.md](../ops.md)). Refs
+are deliberately **excluded** — they live in s3lite (also under the root, at
+`meta/`, so objects and metadata share fate). Keys below are relative to the root.
-| Prefix | Contents |
+| Key | Contents |
| ---------------------------------------- | ------------------------------------------------ |
| `{repo}/objects/…` | Loose git objects (git's own `ab/cdef…` fan-out) |
| `{repo}/objects/pack/pack-*.pack` `.idx` | Packfiles + indexes |
+| `ci/{repo}/{run}/{job}.log` | CI run logs (append-only) |
| `lfs/{repo}/{oid}` | Large-file blobs (deferred — see [open questions](open-questions.md)) |
Sync is done with the S3 SDK directly (single static binary, no external
diff --git a/docs/evolution/reader-writer-split.md b/docs/evolution/reader-writer-split.md
index a5a05e8..ff6887a 100644
--- a/docs/evolution/reader-writer-split.md
+++ b/docs/evolution/reader-writer-split.md
@@ -33,7 +33,7 @@ Stop conflating "an instance is running" with "an instance is the writer." Let
writers, by construction.
**How it shipped.** The coordination substrate is a conditional-write CAS on the
-replica bucket (`If-Match`/`If-None-Match`, which the S3 provider must support —
+replica's `lock.json` (`If-Match`/`If-None-Match`, which the S3 provider must support —
Scaleway and AWS S3 do) — no second provider, no moving the WAL. litestream ships the lease primitive
(`litestream.Leaser` / `s3.Leaser`: `If-None-Match:*` to acquire, `If-Match` to
renew, 30 s TTL, monotonic `Generation` fencing token), but does **not** auto-gate
diff --git a/docs/ops.md b/docs/ops.md
index f8cfd57..90e082e 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -18,9 +18,11 @@ Containers like atmin.net, but that is incidental and may change.
Reachable at **`gitmote.atmin.net`** (CNAME → the container endpoint). EU-resident
infrastructure, consistent with atmin.net's stance.
-One bucket, two prefixes: `objects/` (immutable git objects/packs) and `meta/`
-(the litestream WAL of the metadata SQLite). Refs are the source of truth and
-live in the metadata DB; objects live under `objects/`.
+Everything lives under one **storage root** — the whole bucket, or a `bucket/base`
+sub-path when sharing a bucket (see `GITMOTE_S3_BUCKET`). Under the root:
+`{repo}/objects/` (immutable git objects/packs), `ci/` (run logs), and `meta/`
+(the litestream WAL of the metadata SQLite). Refs are the source of truth and live
+in the metadata DB; objects live beside it, all under the same root.
## ⚠️ Single writer is a correctness invariant
@@ -32,8 +34,8 @@ in-process-race concern: it is a data-integrity precondition.
The invariant is enforced **by a lease, not by procedure.** The server opens
its metadata in s3lite `RoleAuto`: it becomes the writer only while it holds a
lease (litestream's `s3.Leaser` — an `If-None-Match`/`If-Match` conditional-write
-CAS on `lock.json` in the same bucket), and runs as a read-only follower
-otherwise. This makes **atomic conditional writes a hard requirement on the S3
+CAS on `lock.json` beside the metadata replica, at `{root}/meta/lock.json`), and
+runs as a read-only follower otherwise. This makes **atomic conditional writes a hard requirement on the S3
provider**: the lease — and therefore the single-writer correctness invariant —
holds only if the backend supports the compare-and-swap primitive (`If-None-Match`
to acquire, `If-Match` to renew/release). Most do (AWS S3, Scaleway, …); one that
@@ -80,13 +82,13 @@ Setting these on the container does nothing for CI, and vice versa.
> `update`.** Plain `environment-variables` are a separate map, unaffected. The CI
> deploy's `update` sets no env vars, so it preserves both maps.
-**Required — the whole core.** A bucket plus its credentials is a complete forge.
+**Required — the whole core.** A storage root plus its credentials is a complete forge.
| Variable | Value / meaning |
|----------|-----------------|
-| `GITMOTE_S3_BUCKET` | `gitmote` — a bucket alone derives the metadata replica (`s3://gitmote/meta`) and the single-writer lease |
+| `GITMOTE_S3_BUCKET` | the **storage root**: `gitmote` (a whole bucket) or `bucket/base` to scope the forge under one sub-path of a shared bucket. Derives the metadata replica (`{root}/meta`) and the single-writer lease |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | secret — S3 key pair; covers both the object store and the litestream replica (Scaleway API key on prod) |
-| `AWS_REGION` | `fr-par` (any valid region for non-AWS S3) |
+| `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
@@ -114,11 +116,6 @@ one of these turns a trigger on — see [CI substrate](#ci-substrate)).
| 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_DB_REPLICA` | override the derived `s3://{bucket}/meta` replica target |
-| `GITMOTE_DB` / `GITMOTE_CACHE` / `GITMOTE_SOCK` | override the individual paths that otherwise derive from `GITMOTE_DATA` |
-| `GITMOTE_S3_PREFIX` | key prefix for git objects inside the bucket (does **not** affect the `meta` replica path). The code default is empty (objects at `{repo}/objects/…`), which is valid for a *fresh* bucket — but **prod set `objects`** to get the `objects/` + `meta/` [one-bucket layout](#infrastructure), so every object was written under keys like `objects/{repo}/objects/…`. ⚠️ **This is a data-address, not a preference: once objects exist under it, the prefix is load-bearing — never remove or change it on a running instance.** Reverting to the default doesn't move the data, it just makes the server look at the wrong (empty) keys. Because the `meta` replica is prefix-independent, refs still restore — so the instance comes up advertising refs it cannot serve (clone → `not our ref`, push → `unpacker error`, browse → 404) |
-| `GITMOTE_HOOK` / `GITMOTE_RUNNER` | hook / CI-runner binary paths (default beside the server) |
-| `GITMOTE_ADMIN_HANDLE` | first-run auto-bootstrap admin handle (default `admin`) |
`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 →
@@ -242,7 +239,6 @@ scw container container create \
port=8080 \
environment-variables.GITMOTE_S3_BUCKET=gitmote \
environment-variables.GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
- environment-variables.GITMOTE_S3_PREFIX=objects \
environment-variables.GITMOTE_DATA=/tmp/gitmote \
environment-variables.AWS_REGION=fr-par \
secret-environment-variables.AWS_ACCESS_KEY_ID=<KEY> \
@@ -276,9 +272,9 @@ scw jobs definition update <JOB_DEFINITION_ID> image-uri=ghcr.io/atmin/gitmote-r
An empty instance has no admin/token. **On first start the server auto-bootstraps
itself**: when it is the writer and no admin exists, it creates the admin (handle
-from `GITMOTE_ADMIN_HANDLE`, default `admin`), mints a token, and prints it once
-to the logs behind an unmissable banner. So a fresh `docker run` against an empty
-bucket is usable without a second command — just grab the token from the logs.
+`admin`), mints a token, and prints it once to the logs behind an unmissable
+banner. So a fresh `docker run` against an empty bucket is usable without a second
+command — just grab the token from the logs.
The token **transits the logs**, which are operator-visible (same trust boundary
as the env and bucket). Treat it as a one-time credential: after first sign-in,
diff --git a/internal/store/s3.go b/internal/store/s3.go
index 77ce776..c883cd7 100644
--- a/internal/store/s3.go
+++ b/internal/store/s3.go
@@ -42,21 +42,32 @@ func NewS3(client *s3.Client, bucket, prefix string) *S3 {
}
}
+// ParseBucket splits the storage-root spec "bucket[/base/path]" into the S3
+// bucket name and an optional key prefix. S3 bucket names cannot contain "/", so
+// the first segment is the bucket and the remainder is a prefix prepended to
+// every key — scoping a whole forge (objects, CI logs, and, via the caller, the
+// metadata replica) under one root of a bucket it may share.
+func ParseBucket(raw string) (bucket, prefix string) {
+ raw = strings.Trim(raw, "/")
+ bucket, prefix, _ = strings.Cut(raw, "/")
+ return bucket, prefix
+}
+
// NewS3FromEnv builds an S3 store from the environment:
//
-// - GITMOTE_S3_BUCKET — bucket name (required)
+// - GITMOTE_S3_BUCKET — storage root, "bucket" or "bucket/base/path" (required)
// - GITMOTE_S3_ENDPOINT — custom endpoint, e.g. a local MinIO (optional;
// implies path-style addressing)
-// - GITMOTE_S3_PREFIX — key prefix inside the bucket (optional)
//
// Region and credentials come from the standard AWS environment
-// (AWS_REGION, AWS_ACCESS_KEY_ID, …) and config files.
+// (AWS_REGION, AWS_ACCESS_KEY_ID, …) and config files; the region defaults to
+// us-east-1 when unset (ignored by most S3-compatible stores).
func NewS3FromEnv(ctx context.Context) (*S3, error) {
- bucket := os.Getenv("GITMOTE_S3_BUCKET")
+ bucket, prefix := ParseBucket(os.Getenv("GITMOTE_S3_BUCKET"))
if bucket == "" {
return nil, errors.New("store: GITMOTE_S3_BUCKET is not set")
}
- cfg, err := config.LoadDefaultConfig(ctx)
+ cfg, err := config.LoadDefaultConfig(ctx, config.WithDefaultRegion("us-east-1"))
if err != nil {
return nil, fmt.Errorf("store: load aws config: %w", err)
}
@@ -68,7 +79,7 @@ func NewS3FromEnv(ctx context.Context) (*S3, error) {
o.UsePathStyle = true
}
})
- return NewS3(client, bucket, os.Getenv("GITMOTE_S3_PREFIX")), nil
+ return NewS3(client, bucket, prefix), nil
}
// Put implements Store. The uploader streams r, switching to multipart for
diff --git a/internal/store/s3_test.go b/internal/store/s3_test.go
index a4f2da1..01ccedd 100644
--- a/internal/store/s3_test.go
+++ b/internal/store/s3_test.go
@@ -25,6 +25,25 @@ func TestS3Conformance(t *testing.T) {
// newTestS3 builds an S3 store scoped to a unique prefix so concurrent test
// runs can share a bucket, and deletes everything under it on cleanup.
+func TestParseBucket(t *testing.T) {
+ tests := []struct {
+ raw, bucket, prefix string
+ }{
+ {"gitmote", "gitmote", ""},
+ {"shared/gitmote", "shared", "gitmote"},
+ {"shared/a/b", "shared", "a/b"},
+ {"/gitmote/", "gitmote", ""},
+ {"shared/gitmote/", "shared", "gitmote"},
+ {"", "", ""},
+ }
+ for _, tt := range tests {
+ b, p := ParseBucket(tt.raw)
+ if b != tt.bucket || p != tt.prefix {
+ t.Errorf("ParseBucket(%q) = (%q, %q), want (%q, %q)", tt.raw, b, p, tt.bucket, tt.prefix)
+ }
+ }
+}
+
func newTestS3(t *testing.T) Store {
t.Helper()
ctx := context.Background()
@@ -33,7 +52,10 @@ func newTestS3(t *testing.T) Store {
if _, err := rand.Read(suffix[:]); err != nil {
t.Fatalf("rand: %v", err)
}
- t.Setenv("GITMOTE_S3_PREFIX", fmt.Sprintf("conformance/%s/%s", t.Name(), hex.EncodeToString(suffix[:])))
+ // Scope this run to a unique sub-path of the operator's bucket by extending the
+ // storage-root spec — concurrent runs share a bucket without colliding.
+ base := os.Getenv("GITMOTE_S3_BUCKET")
+ t.Setenv("GITMOTE_S3_BUCKET", fmt.Sprintf("%s/conformance/%s/%s", base, t.Name(), hex.EncodeToString(suffix[:])))
s, err := NewS3FromEnv(ctx)
if err != nil {
diff --git a/scripts/dev.sh b/scripts/dev.sh
index b7d59b8..bdaec2e 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -18,8 +18,6 @@ export AWS_REGION="us-east-1"
export AWS_ACCESS_KEY_ID="minioadmin"
export AWS_SECRET_ACCESS_KEY="minioadmin"
export GITMOTE_DATA="$ROOT/data"
-export GITMOTE_HOOK="$ROOT/bin/gitmote-hook"
-export GITMOTE_RUNNER="$ROOT/bin/gitmote-runner"
export GITMOTE_COOKIE_KEY="dev-cookie-key-not-for-production"
# Enable local CI: on a push, gitmote records a run and spawns the runner
diff --git a/scripts/e2e-restore.sh b/scripts/e2e-restore.sh
index e06a02f..9ef6020 100755
--- a/scripts/e2e-restore.sh
+++ b/scripts/e2e-restore.sh
@@ -11,8 +11,8 @@ ROOT="$(git rev-parse --show-toplevel)"
BRANCH="$(git -C "$ROOT" symbolic-ref --quiet --short HEAD || echo master)"
PUSHED="$(git -C "$ROOT" rev-parse HEAD)"
-# Turn on replication for this run (docker-compose.yml reads it; empty otherwise).
-export GITMOTE_DB_REPLICA="s3://gitmote/meta"
+# Replication is on whenever a bucket is set (the root derives the replica), so
+# there is nothing to turn on here — just the volume to wipe mid-run.
DB_VOLUME="gitmote-e2e_gitmote-data"
export GIT_TERMINAL_PROMPT=0