gitmote

gitmote  ·  history  ·  branches

feat: first-run auto-bootstrap and token reissue

19617d40f188593af2d80f05c8293bc094def2c3
Atanas Minev <atmin@protonmail.com> · 2026-07-05 09:49

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 1128993..75003e4 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -68,16 +68,18 @@ func main() {
}
}
-// runBootstrap creates the first admin, token, and repo from an empty instance.
-// It opens the metadata DB per the environment (the data dir, and the replica
-// derived from the bucket) and prints the one-time token to out on success. The deferred
-// Close durably flushes replication, so this short-lived process reliably pushes
-// the new admin/token/repo to S3 for the server to restore.
+// runBootstrap creates the first admin and token from an empty instance (or, with
+// -reissue, mints a fresh token for the existing admin). It opens the metadata DB
+// per the environment (the data dir, and the replica derived from the bucket) and
+// prints the one-time token to out on success. The deferred Close durably flushes
+// replication, so this short-lived process reliably pushes the new admin/token to
+// 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 (or GITMOTE_ADMIN_HANDLE)")
- repoName := fs.String("repo", "", "initial repository, e.g. atmin/gitmote")
+ handle := fs.String("handle", os.Getenv("GITMOTE_ADMIN_HANDLE"), "admin user handle (default admin, or GITMOTE_ADMIN_HANDLE)")
+ repoName := fs.String("repo", "", "optional initial repository, e.g. atmin/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)")
if err := fs.Parse(args); err != nil {
return err
}
@@ -91,29 +93,92 @@ func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
}
defer func() { _ = md.Close() }()
- res, err := bootstrap.Run(ctx, md, bootstrap.Options{
- AdminHandle: *handle,
- RepoName: *repoName,
- DefaultBranch: *branch,
- })
+ opts := bootstrap.Options{AdminHandle: *handle, RepoName: *repoName, DefaultBranch: *branch}
+ if *reissue {
+ res, err := bootstrap.Reissue(ctx, md, opts)
+ if err != nil {
+ return err
+ }
+ return writeBootstrapBanner(out, res)
+ }
+
+ res, err := bootstrap.Run(ctx, md, opts)
if err != nil {
return err
}
-
if res.AlreadyBootstrapped {
- _, err := io.WriteString(out, "already bootstrapped: an admin exists; nothing to do\n")
+ _, err := io.WriteString(out, "already bootstrapped: an admin exists; nothing to do "+
+ "(run with -reissue to mint a fresh token)\n")
return err
}
+ return writeBootstrapBanner(out, res)
+}
- _, err = fmt.Fprintf(out,
- "admin user: %s\n"+
- "initial repo: %s\n\n"+
- "access token (shown once — save it now):\n\n %s\n\n"+
- "clone/push with: git clone http://%s:<token>@<host>/%s\n",
- res.Admin.Handle, res.Repo.Name, res.RawToken, res.Admin.Handle, res.Repo.Name)
+// writeBootstrapBanner prints the one-time admin token behind an unmissable,
+// self-explanatory banner: what it is, how to sign in and clone, that it won't be
+// shown again, and how to re-mint if lost. Used by the CLI and the first-run
+// auto-bootstrap (its output is the operator's only chance to capture the token).
+func writeBootstrapBanner(out io.Writer, res *bootstrap.Result) error {
+ const bar = "========================================================================"
+ repoLines := fmt.Sprintf(
+ " Repos: create one in the UI, then clone:\n"+
+ " git clone http://%s:<token>@<host>/<owner>/<repo>\n",
+ res.Admin.Handle)
+ if res.Repo != nil {
+ repoLines = fmt.Sprintf(
+ " Clone: git clone http://%s:<token>@<host>/%s\n",
+ res.Admin.Handle, res.Repo.Name)
+ }
+ _, err := fmt.Fprintf(out, "\n%s\n"+
+ " gitmote access token — SAVE IT NOW (shown only once)\n"+
+ "%s\n\n"+
+ " admin user: %s\n\n"+
+ " access token:\n\n %s\n\n"+
+ " Sign in: open http://<host>/login and paste the token\n"+
+ "%s"+
+ "\n This token is NOT stored and will NOT be shown again.\n"+
+ " Lost it? Stop the server (scale to zero) and run: gitmote bootstrap -reissue\n"+
+ " Then rotate: sign in, mint your own token, and revoke this one.\n"+
+ "%s\n\n",
+ bar, bar, res.Admin.Handle, res.RawToken, repoLines, bar)
return err
}
+// maybeAutoBootstrap makes a fresh instance usable without a second command: on
+// the leader with no admin yet, it creates the admin and mints a token, logging
+// it once behind an unmissable banner (its own output is the operator's only
+// chance to capture the token). Later boots (admin exists) and followers are a
+// no-op — a first-ever boot is uncontended, so it is necessarily the leader.
+//
+// It never creates a repo (made in the UI). Non-fatal: a failure is logged, not
+// returned, so the server still serves; the operator can bootstrap by hand and,
+// if an admin was created without a token, recover with `bootstrap -reissue`.
+func maybeAutoBootstrap(ctx context.Context, md *meta.Metadata, logger *slog.Logger) {
+ if !md.IsLeader() {
+ return
+ }
+ exists, err := md.AdminExists(ctx)
+ if err != nil {
+ logger.Error("auto-bootstrap: check for an admin failed", "error", err)
+ return
+ }
+ if exists {
+ return
+ }
+ res, err := bootstrap.Run(ctx, md, bootstrap.Options{AdminHandle: os.Getenv("GITMOTE_ADMIN_HANDLE")})
+ if err != nil {
+ logger.Error("auto-bootstrap failed; bootstrap by hand", "error", err)
+ return
+ }
+ if res.AlreadyBootstrapped {
+ return // raced another writer that bootstrapped first
+ }
+ logger.Info("auto-bootstrapped the first admin; the one-time token is printed below", "admin", res.Admin.Handle)
+ if err := writeBootstrapBanner(os.Stderr, res); err != nil {
+ logger.Error("auto-bootstrap: printing the token banner failed", "error", err)
+ }
+}
+
// run starts the HTTP server and blocks until ctx is cancelled or a
// SIGINT/SIGTERM arrives, then shuts the server down gracefully.
func run(ctx context.Context, logger *slog.Logger, addr string) error {
@@ -278,6 +343,10 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
logger.Warn("lost the writer lease; now read-only", "error", 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)
+
writer, err := githttp.NewWriter(md, objs, hookBinaryPath(), sockPath(), logger)
if err != nil {
_ = md.Close()
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index e39e13e..3cad599 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "regexp"
"strings"
"testing"
"time"
@@ -178,6 +179,91 @@ 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", "")
+
+ // No handle, no repo: the admin defaults to "admin" and a token is printed.
+ var out bytes.Buffer
+ if err := runBootstrap(context.Background(), nil, &out); err != nil {
+ t.Fatalf("runBootstrap: %v", err)
+ }
+ if !strings.Contains(out.String(), "admin user: admin") || !strings.Contains(out.String(), "access token") {
+ t.Errorf("bootstrap output missing default admin/token:\n%s", out.String())
+ }
+ first := extractToken(t, out.String())
+
+ // Only the hash is at rest: the token's secret half never lands in the DB
+ // (runBootstrap's Close checkpointed the WAL into the file above).
+ _, secret, ok := strings.Cut(first, ".")
+ if !ok || secret == "" {
+ t.Fatalf("token %q has no secret half", first)
+ }
+ db, err := os.ReadFile(dbPath)
+ if err != nil {
+ t.Fatalf("read db: %v", err)
+ }
+ if bytes.Contains(db, []byte(secret)) {
+ t.Error("the raw token secret is stored in the metadata DB; only its hash should be")
+ }
+
+ // -reissue mints a fresh token for the existing admin.
+ var out2 bytes.Buffer
+ if err := runBootstrap(context.Background(), []string{"-reissue"}, &out2); err != nil {
+ t.Fatalf("reissue runBootstrap: %v", err)
+ }
+ second := extractToken(t, out2.String())
+ if second == first {
+ t.Errorf("reissue returned the same token %q, want a fresh one", second)
+ }
+}
+
+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)
+
+ // Fresh instance: the default admin is created, no repo.
+ maybeAutoBootstrap(ctx, md, logger)
+ admin, err := md.GetUser(ctx, "admin")
+ if err != nil {
+ t.Fatalf("default admin not created: %v", err)
+ }
+ if !admin.IsAdmin {
+ t.Error("bootstrapped user is not a global admin")
+ }
+ tokens, err := md.ListTokens(ctx, admin.ID)
+ if err != nil {
+ t.Fatalf("ListTokens: %v", err)
+ }
+ if len(tokens) != 1 {
+ t.Fatalf("admin has %d tokens, want 1", len(tokens))
+ }
+
+ // Idempotent: a second boot (admin exists) mints nothing more.
+ maybeAutoBootstrap(ctx, md, logger)
+ tokens2, _ := md.ListTokens(ctx, admin.ID)
+ if len(tokens2) != 1 {
+ t.Errorf("second auto-bootstrap changed token count to %d, want 1 (no-op)", len(tokens2))
+ }
+ users, _ := md.ListUsers(ctx)
+ if len(users) != 1 {
+ t.Errorf("user count = %d, want 1 (no second admin)", len(users))
+ }
+}
+
+// extractToken pulls the gmt_ token out of a bootstrap banner.
+func extractToken(t *testing.T, s string) string {
+ t.Helper()
+ tok := regexp.MustCompile(`gmt_[0-9a-f]+\.[0-9a-f]+`).FindString(s)
+ if tok == "" {
+ t.Fatalf("no token in output:\n%s", s)
+ }
+ return tok
+}
+
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
diff --git a/docs/ops.md b/docs/ops.md
index ca15851..f02c834 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -203,26 +203,45 @@ scw container domain list container-id=<CONTAINER_ID> # status pending → r
# the CI credentials, NOT container env vars. See "CI secrets (GitHub Actions)".
```
-## Bootstrap (run once, before the server is live)
+## Bootstrap (first run is automatic)
-An empty instance has no admin/token. Bootstrap **from your machine against the
-prod bucket** so you are transiently the single writer — do this *before* the
-container serves traffic (or while it is scaled to zero). Bootstrap opens in
-`RoleWriter`: it **acquires the writer lease** and fails loudly if the server
-already holds it, so running it while the server is live can no longer produce
-two writers — it just refuses.
+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.
+
+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,
+mint your own token in the UI and revoke the bootstrap one (see below).
+
+No initial repo is created — make repos in the UI in two clicks.
+
+### Manual bootstrap / token recovery
+
+Both run **from your machine against the bucket** so you are transiently the
+single writer — do this *before* the container serves traffic (or while it is
+scaled to zero). They open in `RoleWriter`: acquire the writer lease and fail
+loudly if the server already holds it, so they can never race the live writer.
```bash
-GITMOTE_S3_BUCKET=gitmote \
-GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
-GITMOTE_DATA=/tmp/bootstrap \
-AWS_REGION=fr-par AWS_ACCESS_KEY_ID=<KEY> AWS_SECRET_ACCESS_KEY=<SECRET> \
- gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master
+# Common env for both:
+export GITMOTE_S3_BUCKET=gitmote \
+ GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
+ GITMOTE_DATA=/tmp/bootstrap \
+ AWS_REGION=fr-par AWS_ACCESS_KEY_ID=<KEY> AWS_SECRET_ACCESS_KEY=<SECRET>
+
+# Bootstrap by hand (optional -repo). Idempotent — refuses to clobber an admin:
+gitmote bootstrap -handle atmin
+
+# Lost the token? Mint a fresh one for the existing admin (safe: whoever can run
+# this against the bucket already has total infra control):
+gitmote bootstrap -reissue -handle atmin
```
-It prints the access token once (save it) and durably flushes the new state to
-`s3://gitmote/meta` on `Close`; the server restores it on first start. Re-running
-is safe — it refuses to clobber an existing admin.
+Each prints the access token once (save it) and durably flushes the new state to
+`s3://gitmote/meta` on `Close`; the server restores it on next start. Only the
+token's hash is ever stored — the raw token is never recoverable, hence `-reissue`.
## Deploying
diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go
index 4408505..8b74cf7 100644
--- a/internal/bootstrap/bootstrap.go
+++ b/internal/bootstrap/bootstrap.go
@@ -1,24 +1,31 @@
// Package bootstrap takes an empty instance to a usable one: the first admin
-// user, a token to authenticate as them, and an initial repo — per
-// docs/notes/bootstrap.md. The schema itself is created by meta.Open; this fills
-// in the day-one rows that token auth otherwise makes a chicken-and-egg.
+// user and a token to authenticate as them — per docs/notes/bootstrap.md. The
+// schema itself is created by meta.Open; this fills in the day-one rows that
+// token auth otherwise makes a chicken-and-egg. Repos are created later in the
+// UI, so bootstrap no longer needs one (it stays optional for scripts/tests).
package bootstrap
import (
"context"
+ "errors"
"fmt"
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
)
+// DefaultAdminHandle is the first admin's handle when none is given — so a plain
+// `docker run` needs no extra input.
+const DefaultAdminHandle = "admin"
+
// Options parameterize a bootstrap.
type Options struct {
- // AdminHandle is the first global admin's handle. Required.
+ // AdminHandle is the first global admin's handle; empty means DefaultAdminHandle.
AdminHandle string
- // RepoName is the initial repo, e.g. "atmin/gitmote". Required.
+ // RepoName is an optional initial repo, e.g. "atmin/gitmote"; empty creates
+ // none (repos are made in the UI).
RepoName string
- // DefaultBranch for the initial repo; empty means "main".
+ // DefaultBranch for the initial repo; empty means "main". Ignored without a repo.
DefaultBranch string
// TokenLabel labels the minted admin token; empty means "bootstrap".
TokenLabel string
@@ -29,7 +36,8 @@ type Options struct {
type Result struct {
AlreadyBootstrapped bool
Admin *meta.User
- Repo *meta.Repo
+ // Repo is the initial repo when one was requested, else nil.
+ Repo *meta.Repo
// RawToken is the admin's personal access token, shown exactly once. It is
// never recoverable afterward.
RawToken string
@@ -37,15 +45,13 @@ type Result struct {
// Run bootstraps md. It refuses to clobber an existing admin: if any global
// admin already exists it returns a Result with AlreadyBootstrapped set and
-// makes no changes, so re-running is safe. Otherwise it creates the admin,
-// mints a token, creates the initial repo, and grants the admin repo-admin
-// access.
+// makes no changes, so re-running is safe. Otherwise it creates the admin, mints
+// a token, and — only when opts.RepoName is set — the initial repo with a
+// repo-admin ACL.
func Run(ctx context.Context, md *meta.Metadata, opts Options) (*Result, error) {
- if opts.AdminHandle == "" {
- return nil, fmt.Errorf("bootstrap: admin handle is required")
- }
- if opts.RepoName == "" {
- return nil, fmt.Errorf("bootstrap: repo name is required")
+ handle := opts.AdminHandle
+ if handle == "" {
+ handle = DefaultAdminHandle
}
exists, err := md.AdminExists(ctx)
@@ -56,30 +62,76 @@ func Run(ctx context.Context, md *meta.Metadata, opts Options) (*Result, error)
return &Result{AlreadyBootstrapped: true}, nil
}
- admin, err := md.CreateAdmin(ctx, opts.AdminHandle)
+ admin, err := md.CreateAdmin(ctx, handle)
if err != nil {
return nil, fmt.Errorf("bootstrap: create admin: %w", err)
}
- raw, selector, verifier, err := auth.Mint()
+ raw, err := mintToken(ctx, md, admin.ID, opts.TokenLabel)
if err != nil {
- return nil, fmt.Errorf("bootstrap: mint token: %w", err)
+ return nil, err
}
+
+ res := &Result{Admin: admin, RawToken: raw}
+ if opts.RepoName != "" {
+ repo, err := md.CreateRepo(ctx, opts.RepoName, opts.DefaultBranch)
+ if err != nil {
+ return nil, fmt.Errorf("bootstrap: create repo: %w", err)
+ }
+ if err := md.SetACL(ctx, repo.ID, admin.ID, meta.PermAdmin); err != nil {
+ return nil, fmt.Errorf("bootstrap: grant admin acl: %w", err)
+ }
+ res.Repo = repo
+ }
+ return res, nil
+}
+
+// Reissue mints a fresh token for the existing admin (identified by handle, or
+// DefaultAdminHandle) and returns it once — the recovery path for a lost token.
+// Whoever can run the container against the bucket already has total infra
+// control, so an out-of-band re-mint is the correct authz boundary. It requires
+// an existing admin (run bootstrap first) and the writer lease (run it while the
+// server is idle/stopped). No token is ever stored at rest — only its hash.
+func Reissue(ctx context.Context, md *meta.Metadata, opts Options) (*Result, error) {
+ handle := opts.AdminHandle
+ if handle == "" {
+ handle = DefaultAdminHandle
+ }
+
+ user, err := md.GetUser(ctx, handle)
+ if errors.Is(err, meta.ErrNotFound) {
+ return nil, fmt.Errorf("bootstrap: no user %q to reissue for; run bootstrap first", handle)
+ }
+ if err != nil {
+ return nil, err
+ }
+ if !user.IsAdmin {
+ return nil, fmt.Errorf("bootstrap: user %q is not an admin", handle)
+ }
+
label := opts.TokenLabel
if label == "" {
- label = "bootstrap"
+ label = "bootstrap (reissued)"
}
- if _, err := md.CreateToken(ctx, admin.ID, selector, verifier, label); err != nil {
- return nil, fmt.Errorf("bootstrap: store token: %w", err)
+ raw, err := mintToken(ctx, md, user.ID, label)
+ if err != nil {
+ return nil, err
}
+ return &Result{Admin: user, RawToken: raw}, nil
+}
- repo, err := md.CreateRepo(ctx, opts.RepoName, opts.DefaultBranch)
+// mintToken mints a personal access token for userID and stores only its hash,
+// returning the raw token (shown once). label defaults to "bootstrap".
+func mintToken(ctx context.Context, md *meta.Metadata, userID int64, label string) (string, error) {
+ if label == "" {
+ label = "bootstrap"
+ }
+ raw, selector, verifier, err := auth.Mint()
if err != nil {
- return nil, fmt.Errorf("bootstrap: create repo: %w", err)
+ return "", fmt.Errorf("bootstrap: mint token: %w", err)
}
- if err := md.SetACL(ctx, repo.ID, admin.ID, meta.PermAdmin); err != nil {
- return nil, fmt.Errorf("bootstrap: grant admin acl: %w", err)
+ if _, err := md.CreateToken(ctx, userID, selector, verifier, label); err != nil {
+ return "", fmt.Errorf("bootstrap: store token: %w", err)
}
-
- return &Result{Admin: admin, Repo: repo, RawToken: raw}, nil
+ return raw, nil
}
diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go
index 301df7c..7613f2f 100644
--- a/internal/bootstrap/bootstrap_test.go
+++ b/internal/bootstrap/bootstrap_test.go
@@ -23,7 +23,13 @@ func openMeta(t *testing.T) *meta.Metadata {
return m
}
-func TestRunCreatesUsableInstance(t *testing.T) {
+// verifyToken resolves raw to its owner — the token-only check, no repo needed.
+func verifyToken(t *testing.T, m *meta.Metadata, raw string) (*meta.User, error) {
+ t.Helper()
+ return auth.NewGuard(m).VerifyToken(context.Background(), raw)
+}
+
+func TestRunCreatesUsableInstanceWithRepo(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
@@ -44,12 +50,11 @@ func TestRunCreatesUsableInstance(t *testing.T) {
t.Fatal("no token returned")
}
- // The printed token authenticates as the admin and is authorized to write
- // the initial repo (the admin holds a repo-admin ACL).
- guard := auth.NewGuard(m)
+ // The printed token authenticates as the admin and is authorized to write the
+ // initial repo (the admin holds a repo-admin ACL).
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+res.RawToken)
- user, err := guard.Authorize(req, "atmin/gitmote", meta.PermWrite)
+ user, err := auth.NewGuard(m).Authorize(req, "atmin/gitmote", meta.PermWrite)
if err != nil {
t.Fatalf("Authorize with bootstrap token: %v", err)
}
@@ -58,17 +63,41 @@ func TestRunCreatesUsableInstance(t *testing.T) {
}
}
+func TestRunDefaultsHandleAndSkipsRepo(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+
+ // No handle and no repo: the admin defaults to "admin" and no repo is made.
+ res, err := Run(ctx, m, Options{})
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if res.Admin == nil || res.Admin.Handle != DefaultAdminHandle {
+ t.Fatalf("admin = %+v, want default handle %q", res.Admin, DefaultAdminHandle)
+ }
+ if res.Repo != nil {
+ t.Errorf("repo = %+v, want none created", res.Repo)
+ }
+ if res.RawToken == "" {
+ t.Fatal("no token returned")
+ }
+ // The token authenticates as the (global-admin) user with no repo present.
+ if _, err := verifyToken(t, m, res.RawToken); err != nil {
+ t.Fatalf("VerifyToken with bootstrap token: %v", err)
+ }
+}
+
func TestRunIsIdempotentAndRefusesToClobber(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
- first, err := Run(ctx, m, Options{AdminHandle: "atmin", RepoName: "atmin/gitmote"})
+ first, err := Run(ctx, m, Options{AdminHandle: "atmin"})
if err != nil {
t.Fatalf("first Run: %v", err)
}
// A second run must not create a new admin or mint a new token.
- second, err := Run(ctx, m, Options{AdminHandle: "someone-else", RepoName: "other/repo"})
+ second, err := Run(ctx, m, Options{AdminHandle: "someone-else"})
if err != nil {
t.Fatalf("second Run: %v", err)
}
@@ -79,12 +108,9 @@ func TestRunIsIdempotentAndRefusesToClobber(t *testing.T) {
t.Error("second Run minted a token")
}
- // The original admin still authenticates; the would-be second admin was
- // never created.
- guard := auth.NewGuard(m)
- req := httptest.NewRequest(http.MethodGet, "/", nil)
- req.Header.Set("Authorization", "Bearer "+first.RawToken)
- if _, err := guard.Authorize(req, "atmin/gitmote", meta.PermAdmin); err != nil {
+ // The original admin still authenticates; the would-be second admin was never
+ // created.
+ if _, err := verifyToken(t, m, first.RawToken); err != nil {
t.Errorf("original admin no longer authorized: %v", err)
}
if _, err := m.GetUser(ctx, "someone-else"); err == nil {
@@ -92,14 +118,50 @@ func TestRunIsIdempotentAndRefusesToClobber(t *testing.T) {
}
}
-func TestRunValidatesOptions(t *testing.T) {
+func TestReissueMintsFreshTokenForExistingAdmin(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
- if _, err := Run(ctx, m, Options{RepoName: "a/b"}); err == nil {
- t.Error("Run without a handle succeeded, want error")
+ first, err := Run(ctx, m, Options{AdminHandle: "atmin"})
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ res, err := Reissue(ctx, m, Options{AdminHandle: "atmin"})
+ if err != nil {
+ t.Fatalf("Reissue: %v", err)
+ }
+ if res.RawToken == "" || res.RawToken == first.RawToken {
+ t.Fatalf("reissued token = %q, want a fresh non-empty token", res.RawToken)
+ }
+ if res.Admin.ID != first.Admin.ID {
+ t.Errorf("reissued for user %d, want the existing admin %d", res.Admin.ID, first.Admin.ID)
+ }
+
+ // Both the old and the freshly-reissued token authenticate as the admin (the
+ // old one isn't revoked — reissue only adds a new token).
+ if _, err := verifyToken(t, m, res.RawToken); err != nil {
+ t.Errorf("reissued token not authorized: %v", err)
+ }
+ if _, err := verifyToken(t, m, first.RawToken); err != nil {
+ t.Errorf("original token no longer authorized after reissue: %v", err)
+ }
+}
+
+func TestReissueRequiresAnExistingAdmin(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+
+ // No admin yet: reissue has nothing to reissue for.
+ if _, err := Reissue(ctx, m, Options{}); err == nil {
+ t.Error("Reissue on an empty instance succeeded, want an error")
+ }
+
+ // A non-admin user must not be reissued for either.
+ if _, err := m.CreateUser(ctx, "plain"); err != nil {
+ t.Fatalf("CreateUser: %v", err)
}
- if _, err := Run(ctx, m, Options{AdminHandle: "atmin"}); err == nil {
- t.Error("Run without a repo succeeded, want error")
+ if _, err := Reissue(ctx, m, Options{AdminHandle: "plain"}); err == nil {
+ t.Error("Reissue for a non-admin succeeded, want an error")
}
}
diff --git a/scripts/dev.sh b/scripts/dev.sh
index de23002..8069481 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -1,19 +1,17 @@
#!/usr/bin/env bash
# Local dev loop: MinIO in a container + a natively-run gitmote, with a
-# bootstrapped admin/token/repo persisted under data/ across restarts.
-# Invoked by `make dev`; `make dev-reset` wipes the state it creates.
+# bootstrapped admin/repo persisted under data/ across restarts and a fresh token
+# minted on each run. Invoked by `make dev`; `make dev-reset` wipes the state.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
COMPOSE=(docker compose -p gitmote-dev -f docker-compose.dev.yml)
-TOKEN_FILE="$ROOT/data/dev-token"
# All-local configuration — none of these are secret. The db, cache, and socket
-# live under data/ (gitignored, one GITMOTE_DATA dir) so refs survive restarts;
-# the token below is minted against that same DB, so the two stay in sync. The
-# bucket alone now derives the metadata replica + single-writer lease.
+# live under data/ (gitignored, one GITMOTE_DATA dir) so refs survive restarts.
+# The bucket alone now derives the metadata replica + single-writer lease.
export GITMOTE_S3_BUCKET="gitmote"
export GITMOTE_S3_ENDPOINT="http://localhost:9100"
export AWS_REGION="us-east-1"
@@ -53,22 +51,16 @@ echo "--- starting MinIO (S3 :9100, console :9101) ---"
"${COMPOSE[@]}" up -d minio
"${COMPOSE[@]}" run --rm mc
-# Bootstrap exactly once and persist the one-time token. The token file is the
-# marker for "dev is set up"; it and the DB are wiped together by dev-reset, so
-# they never drift.
-if [ ! -f "$TOKEN_FILE" ]; then
- echo "--- bootstrapping admin/token/repo (first run) ---"
- out="$(bin/gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master)"
- if printf '%s' "$out" | grep -q "already bootstrapped"; then
- echo "ERROR: the metadata DB already has an admin but data/dev-token is missing." >&2
- echo "The one-time token can't be recovered — run 'make dev-reset' then 'make dev'." >&2
- exit 1
- fi
- token="$(printf '%s\n' "$out" | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
- [ -n "$token" ] || { printf '%s\n' "$out"; echo "ERROR: no token in bootstrap output" >&2; exit 1; }
- printf '%s\n' "$token" >"$TOKEN_FILE"
-fi
-TOKEN="$(cat "$TOKEN_FILE")"
+# Ensure the admin (+ a dev repo to clone) exist, then mint a fresh token to
+# show. Both steps are idempotent and run before the server takes the lease:
+# bootstrap creates on first run and no-ops after; -reissue mints a new token for
+# the existing admin every run, so there is no token file to keep in sync (the
+# server also auto-bootstraps on a truly empty bucket — this just makes the token
+# and the dev repo available up front). 'make dev-reset' wipes the DB to start over.
+echo "--- ensuring admin + dev repo (idempotent) ---"
+bin/gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master >/dev/null
+TOKEN="$(bin/gitmote bootstrap -reissue -handle atmin | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
+[ -n "$TOKEN" ] || { echo "ERROR: no token from 'bootstrap -reissue'" >&2; exit 1; }
cat <<EOF
diff --git a/tasks/README.md b/tasks/README.md
index b241cef..1a82116 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -11,13 +11,9 @@ gitmote trivial to run**: `docker run ghcr.io/atmin/gitmote` with a handful of
envs, on a laptop or on Scaleway, identically. Kill it — fine; restart it — it
restores from S3 and continues. Localhost is a first-class deployment.
-Two independent chains plus a docs cap — order within a chain matters, the chains
-run in parallel:
-
-**Chain A — shrink the surface**
-- [onboarding-bootstrap.md](onboarding-bootstrap.md) — first-run **auto-bootstrap**
- prints the admin token once to the logs with clear instructions (no setup page,
- no race); `bootstrap --reissue` recovers a lost token.
+Chain A (shrink the surface — auto-generated secrets, first-run auto-bootstrap)
+has landed. What remains is the image chain plus a docs cap — order within the
+chain matters:
**Chain B — the image**
- [public-registry.md](public-registry.md) — publish `ghcr.io/atmin/gitmote` +
diff --git a/tasks/onboarding-bootstrap.md b/tasks/onboarding-bootstrap.md
deleted file mode 100644
index 0d4b7e4..0000000
--- a/tasks/onboarding-bootstrap.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# First-run auto-bootstrap + token recovery
-
-Part of [easy to operate](README.md). A fresh empty-bucket `docker run` should be
-usable without a second command, and a lost token must be recoverable without
-nuking the bucket. Lands after [minimal-env-secrets](minimal-env-secrets.md) (the
-cookie key must resolve for the UI).
-
-## Spec
-
-- **Auto-bootstrap on first run.** When this instance is the leader and **no admin
- exists**, create the admin (handle from `GITMOTE_ADMIN_HANDLE`, default
- `admin`), mint a token, and print it **once** to the logs behind an unmissable,
- self-explanatory banner: what it is, how to sign in (paste at `/login`), how to
- clone/push, that it won't be shown again, and how to re-mint if lost. No web
- setup page, no unauthenticated surface, no first-run race.
-- **No initial repo.** Drop the bootstrap-creates-a-repo step; repos are made in
- the UI in two clicks. Removes the `-repo` / `-default-branch` inputs.
-- **Recovery via `bootstrap --reissue`.** Mints a fresh token for the existing
- admin and prints it once — the safe answer to a lost token: whoever can run the
- container against the bucket already has total infra control, so out-of-band
- re-mint is the correct authz boundary. It needs the writer lease, so run it
- while the server is idle/stopped (scale-to-zero frees the lease). No token is
- ever stored at rest — meta keeps only the hash.
-
-Why logs, not a page: the log is operator-only (same trust boundary as the env
-and bucket), so a one-time token there is strictly simpler than a new HTTP
-surface — no gating, no race, no claim-code machinery.
-
-## Current
-
-`gitmote bootstrap` is a separate CLI subcommand (needs `-handle`/`-repo`),
-refuse-on-exists ([internal/bootstrap](../internal/bootstrap), `AdminExists()`
-gates it), mints a token once. A fresh server is unusable until someone runs it;
-a lost token means lock-out (re-bootstrap refuses) with only "nuke the bucket" as
-an escape. `data/dev-token` is a local dev convenience, not a prod model.
-
-## Change
-
-- On server startup, after `meta.Open`, when leader && `!AdminExists()`: run
- `bootstrap.Run` and log the token with the banner. Subsequent boots: admin
- exists → skip. (A follower can't reach this — a first-ever boot is uncontended,
- so it is the leader.)
-- Make the repo optional in `bootstrap.Run`; default the admin handle to `admin`.
-- Add a `--reissue` flag (or mode) to the `bootstrap` CLI: with an admin present,
- mint a fresh token for it and print once (reuses the existing RoleWriter/lease
- machinery — no second subcommand).
-- Update `scripts/dev.sh` to rely on auto-bootstrap (drop the manual bootstrap
- step + the `data/dev-token` dance; read the token from the server log or just
- re-issue).
-
-## Verify
-
-- Empty bucket: the first `docker run` logs a token + clear instructions; signing
- in at `/login` with it works.
-- Second boot: no new token (admin exists); existing sessions/tokens still work.
-- `bootstrap --reissue` mints a working fresh token for the existing admin without
- touching other data; requires the writer lease.
-- Only the hash is stored (assert no raw token in meta/S3).
-- Docs note: the token transits the logs (operator-visible) — rotate it after
- first login by minting your own and revoking `bootstrap`.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; golden + failure paths.