Table of Contents
- M2 — Module catalog and registry: structure and architecture
- Context
- Decisions taken
- 1. The compatibility rule
- 2. Schema — internal/store/migrations
- 3. A shared git invoker — internal/gitcmd
- 4. Storage — internal/store
- 5. The registry — internal/registry/registry.go
- 6. Assembly — internal/source/workspace.go
- 7. Configuration and deployment
- 8. API — internal/httpapi
- 9. Wiring and provenance
- 10. Tests
- 11. Verification
- 12. Docs to update on the way out
- Files touched
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
M2 — Module catalog and registry: structure and architecture
Context
M1 landed a working Linux build: source.Assembler mirror-clones the engine and
creates a worktree, runner.DockerRunner compiles it in a container and collects
the binary into an artifact store. The one thing that pipeline cannot do is the
thing the project exists for — grafting custom engine modules into the tree
before SCons runs.
Two stubs hold that gap open:
internal/registry/registry.go:73—Resolvereturnserrors.New("registry: Resolve not implemented").internal/source/workspace.go:60—TODO(M2): resolve modules and copy each checkout to its DestPath in the tree.Workspace.Modulesis declared and never populated.
Outcome: a user picks Godot 4.4, is offered exactly the modules that work with 4.4, and gets an engine binary with those modules compiled in — with the commit each one resolved to recorded.
The inversion
An earlier draft of this document had each module declare a constraint
(godot_compat: ">=4.3 <5.0") which the registry evaluated against the engine
version at build time. That asks does this module accept this engine? — a
per-module predicate, recomputed on every build, scattered across as many
manifest entries as there are modules.
The question users actually ask is the other one: which modules work with Godot 4.4? That is a list. It is computed once, when 4.4 is added, and touched again only when a module ships an update before the next Godot version arrives. The list — not a constraint — is what the picker shows and what the build gates on.
So compatibility is a property of a Godot version, stored, not a property of a module, evaluated. Membership in the list replaces the predicate; there is no constraint grammar anywhere in the system.
Three things follow, and they are what makes M2 larger than "implement
Resolve":
- The list needs a home. Postgres, which also replaces SQLite for
everything else.
internal/storeis a stub andgo.modnames no database driver, so there is nothing to migrate away from — only a decision not yet taken. configs/modules.yamlandconfigs/godot-versions.yamlgo away. Modules, module versions, and Godot versions are tables.internal/manifestis deleted whole.- Someone has to curate the list. Privileged users administer modules and compatibility over the API, which pulls M6's API tokens forward.
Decisions taken
| Question | Decision | Why |
|---|---|---|
| Direction of the compatibility relation | A stored list per Godot major/minor, not a constraint per module | It is the question users ask, it is computed once instead of on every build, and it can be curated when the automatic answer is wrong |
| What keys the list | (major, minor), not a godot_versions row |
A fork of 4.4 shares 4.4's list rather than needing its own. See the studioteal/4.4 hole below |
| Where the catalog lives | Postgres | It is edited at runtime by admins, so a file in the image is the wrong shape. One database for jobs and catalog both |
| Postgres driver | github.com/jackc/pgx/v5 |
Pure Go, so the "no cgo" non-choice in docs/architecture.md survives the move off SQLite |
| Engine version identity | major/minor are explicit columns an admin sets |
Parsing the ref cannot answer for master, a SHA, or studioteal/4.4, and the old fallback was to skip the check silently |
| Where modules are cloned | Host-side, mirrored like the engine | Reuses syncMirror's pattern; build containers stay on NetworkMode: "none", which they are today (runner/docker.go hostConfig) |
| How a module lands in the tree | Copy the tree, .git excluded, via git archive |
Self-contained workspace. A worktree's .git is a file pointing into a mirror the build container cannot see under workspace_mount: volume — the trap that already empties the engine commit stamp there |
| Admin authentication | M6's API tokens, built early and scoped to the admin surface | Curation needs an identity now; building a throwaway mechanism means building it twice |
The hole this closes
configs/godot-versions.yaml ships studioteal/4.4, a fork branch.
spec.ParseEngineVersion cannot parse it, and the constraint design's rule for
an unparseable ref was skip the check and log one line. Every build against the
StudioTeal fork therefore bypassed compatibility entirely — silently, and by
design, for the two versions that matter most to us.
With major and minor as columns the admin fills in, studioteal/4.4 is
recorded as 4, 4 and shares 4.4's list. ParseEngineVersion survives as the
admin form's suggestion for those two fields, never as a gate.
1. The compatibility rule
A module version records the minimum Godot it supports: min_godot_major
and min_godot_minor. Nothing else. No upper bound, no constraint string, no
grammar to parse.
For Godot X.Y and module M, the module version offered is:
- The row in
godot_module_compatfor(X, Y, M), if one exists. This is the stored list — generated once, then frozen. - Otherwise the newest version of
Mwhose(min_major, min_minor) <= (X, Y). - Otherwise
Mis not offered forX.Y.
Rule 2 is "use the current version for every Godot at or above the declared minimum". It is the generator. Rule 1 is what the generator wrote down.
When the list changes
| Event | Effect |
|---|---|
Admin adds Godot X.Y, a major/minor not seen before |
Generate its full list once, by rule 2 over every module |
Admin adds a version of module M |
Refresh M's row only for the newest (major, minor) in godot_versions |
| Admin pins a row by hand | Row is written with origin = 'admin'. Generation never overwrites it |
| Admin forces a regenerate | Every origin = 'generated' row for that minor is rebuilt |
The second line is the whole point of storing the list rather than recomputing it. A module shipping a 4.5-targeting release must not silently change what 4.3 offers — 4.3's list records what was true when 4.3 was current, and stays that way. Only the newest Godot minor is still accumulating module updates, because it is the only one where a module can still "provide an update before Godot".
Regeneration is the escape hatch for when the automatic answer is wrong, and it respects admin pins, so a hand-curated exception survives it.
Two errors, not one
ErrUnknownModule— no such module inmodulesat all. The user typed a name that does not exist.ErrModuleNotOffered— the module exists but has no row for this Godot minor. Different problem, different fix, so it gets its own message naming the minor and the module's minimum:
Godot 4.2 does not offer every selected module:
telemetry_bridge needs 4.4 or newer
deep_engine_tool needs 4.3 or newer
Collect every violation and return them in one error, not the first. A user fixing a three-module selection should need one round trip.
2. Schema — internal/store/migrations
Embedded in the binary and applied at Open.
-- 0001_catalog.sql
CREATE TABLE godot_versions (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "4.4 stable"
ref TEXT NOT NULL, -- "4.4-stable", "studioteal/4.4"
repo TEXT NOT NULL DEFAULT '', -- '' -> config.godot_repo
major INT NOT NULL, -- explicit, not parsed from ref
minor INT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (ref, repo)
);
CREATE TABLE modules (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- ^[a-z][a-z0-9_]{0,63}$
endpoint TEXT NOT NULL, -- git URL the build host can clone
path TEXT NOT NULL DEFAULT '', -- '' -> modules/<name>
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE module_versions (
id BIGSERIAL PRIMARY KEY,
module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
version TEXT NOT NULL, -- "v0.3.1", the admin-facing label
ref TEXT NOT NULL, -- tag, branch, or SHA to clone
min_godot_major INT NOT NULL,
min_godot_minor INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (module_id, version)
);
CREATE TABLE module_version_requires (
module_version_id BIGINT NOT NULL REFERENCES module_versions(id) ON DELETE CASCADE,
requires_module_id BIGINT NOT NULL REFERENCES modules(id),
PRIMARY KEY (module_version_id, requires_module_id)
);
-- The stored compatibility list. Keyed by major/minor rather than by a
-- godot_versions row, so a fork of 4.4 shares 4.4's list.
CREATE TABLE godot_module_compat (
godot_major INT NOT NULL,
godot_minor INT NOT NULL,
module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
module_version_id BIGINT NOT NULL REFERENCES module_versions(id) ON DELETE CASCADE,
origin TEXT NOT NULL CHECK (origin IN ('generated','admin')),
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (godot_major, godot_minor, module_id)
);
CREATE TABLE api_tokens (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
token_hash BYTEA NOT NULL UNIQUE, -- sha256 of the bearer token
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
0002_jobs.sql carries the job table M3 needs; its shape is unchanged by any of
this, only its dialect.
Two shapes worth defending:
requireshangs off a module version, not a module. A dependency set is a property of a release: v0.3 of a module may needdeep_engine_tooland v0.4 may not. Attaching it to the module would make that unrepresentable and would silently rewrite history for every Godot minor whose frozen list points at the old version.endpointrather thanrepo. It is a git URL like the oldmanifest.Module.Repo, renamed becausegodot_versions.repois also a git URL and the two are read side by side in the resolution path.
3. A shared git invoker — internal/gitcmd
source.Assembler.git (workspace.go:197-215) is exactly what the registry
needs, and copying it would put the deploy-key handling in one of two places.
Extract it.
package gitcmd
type Runner struct {
SSHKeyPath string // deploy key; empty uses the ambient identity
KnownHostsPath string
InsecureSkipHostKeyCheck bool
}
func (r Runner) Run(ctx context.Context, args ...string) (string, error)
// MirrorPath is where a bare mirror of repo lives under cacheDir.
// kind "engine" -> cacheDir/engine/<sha256(repo)[:8]>.git
// kind "module" -> cacheDir/modules/<sha256(repo)[:8]>.git
func MirrorPath(cacheDir, kind, repo string) string
Run keeps M1's behavior verbatim: GIT_TERMINAL_PROMPT=0, trimmed stdout,
stderr folded into the error. When SSHKeyPath is set it also exports
GIT_SSH_COMMAND=ssh -i <key> -o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=yes -o UserKnownHostsFile=<known_hosts>
BatchMode=yes matters for the same reason GIT_TERMINAL_PROMPT=0 does: a
passphrase prompt on a worker with no terminal is indistinguishable from a hang.
MirrorPath("…/cache", "engine", repo) must produce the same path
Assembler.mirrorPath produces today (workspace.go:184) — sha256[:8] hex —
or the existing data/cache/engine/210929d6b009a21f.git is orphaned and the next
build re-clones 2 GB. Assembler gains a Git gitcmd.Runner field and its
private git becomes a one-line delegate; existing source tests must pass
unchanged.
4. Storage — internal/store
store.Open(ctx, dsn) returns a *Postgres over a pgxpool.Pool, applying the
embedded migrations. One type over one pool serves both the job store and the
catalog.
func Open(ctx context.Context, dsn string) (*Postgres, error)
func (p *Postgres) Close() error
*Postgres implements job.Store as M3 planned, plus the catalog queries. Those
are consumed through interfaces declared at the call sites, so registry and
httpapi do not import each other's concerns:
// registry.Catalog, satisfied by *store.Postgres.
type Catalog interface {
// ModulesFor returns the compatibility list for one Godot major/minor.
ModulesFor(ctx context.Context, major, minor int) ([]CatalogEntry, error)
// ModuleFor returns one entry from that list.
ModuleFor(ctx context.Context, major, minor int, name string) (CatalogEntry, error)
}
Admin writes live on *Postgres directly, since only httpapi's admin handlers
call them:
func (p *Postgres) UpsertModule(ctx, m Module) (Module, error)
func (p *Postgres) AddModuleVersion(ctx, moduleName string, v ModuleVersion) (ModuleVersion, error)
func (p *Postgres) UpsertGodotVersion(ctx, v GodotVersion) (GodotVersion, error)
// RefreshCompat rebuilds every origin='generated' row for one minor by rule 2,
// leaving origin='admin' rows alone. Returns how many rows changed.
func (p *Postgres) RefreshCompat(ctx context.Context, major, minor int) (int, error)
// PinCompat writes an origin='admin' row.
func (p *Postgres) PinCompat(ctx context.Context, major, minor int, module, version string) error
AddModuleVersion and UpsertGodotVersion run their generation step in the
same transaction as the insert, so the catalog is never briefly inconsistent
with its list. RefreshCompat on a minor with no godot_versions row is a
no-op rather than an error: it means nobody offers that engine.
5. The registry — internal/registry/registry.go
The registry stops owning a manifest and starts reading a catalog. New(mods, cacheDir) becomes:
type Registry struct {
Catalog Catalog
Git gitcmd.Runner
Log io.Writer // a cold module clone moves nothing anyone can watch
CacheDir string
// per-mirror locks, so M3's build.workers > 1 cannot have two builds
// fetching one mirror concurrently
}
CatalogEntry replaces manifest.Module as the value the rest of the pipeline
passes around:
type CatalogEntry struct {
Name, Endpoint, Path, Description string
Version string // module_versions.version, for provenance and messages
Ref string // what to clone
Requires []string
}
func (e CatalogEntry) DestPath() string // Path, else "modules/" + Name
Resolved changes shape: drop Dir, add Mirror. There is no persistent
per-module checkout any more — the tree is exported straight into the workspace,
so nothing lives at a Dir.
API
// Selection is one module chosen for a build, before anything is fetched.
type Selection struct {
Entry CatalogEntry
Ref string // spec override, else the catalog's ref for this minor
Explicit bool // named in the spec vs. pulled in by requires
}
// Select draws from one Godot minor's compatibility list and expands requires
// transitively. One catalog read, then pure: no network, no clone.
func (r *Registry) Select(ctx context.Context, major, minor int, refs []spec.ModuleRef) ([]Selection, error)
// Resolve selects, then fetches each mirror and records its commit.
func (r *Registry) Resolve(ctx context.Context, major, minor int, refs []spec.ModuleRef) ([]Resolved, error)
// Export writes the module's tree at res.Commit into dest, without .git.
func (r *Registry) Export(ctx context.Context, res Resolved, dest string) error
There is no CheckCompat. The list is the domain a selection is drawn from, so
membership is the gate — a module that is not in the list is not selectable, and
the error says which of the two reasons applies.
Select
Load the minor's list once, then DFS from the explicit refs with a visiting set
for cycles. Rules:
- A name absent from
modulesentirely →ErrUnknownModule. A name present but absent from this minor's list →ErrModuleNotOffered, with the module's minimum in the message. Both before any I/O beyond the one catalog read. - Both failures accumulate; one error names every offender.
- An explicit ref wins over the catalog's ref, and over a default reached through
requires. Ifbrequiresaand the spec saysa@feature-branch,aresolves tofeature-branch. The override changes which commit is cloned, never whether the module is offered — an unlisted module cannot be smuggled in by naming a ref. requiresis read from the selected version of each module, so a diamond resolves against the versions this Godot minor actually offers.- Duplicates collapse.
spec.Validatealready rejects a module listed twice at the top level (validate.go:71); this handles the diamond case. - Output order is topological, dependencies first, ties broken by name — so it is deterministic and so grafting order is stable.
Resolve
Select → per selection, under that repo's lock:
mirror := gitcmd.MirrorPath(r.CacheDir, "module", e.Endpoint).- If
Refis a 40-hex SHA andcat-file -e <sha>^{commit}succeeds against an existing mirror, skip the fetch. A pinned commit that is already local needs no network; module versions are expected to pin, so this is the common path, not an optimization for an edge case. - Otherwise clone
--mirroron first use,remote update --pruneafter — same shape assyncMirror(workspace.go:130), including removing a half-written mirror on a failed clone so it is not mistaken for a good one. rev-parse --verify --end-of-options <ref>^{commit}→Resolved.Commit.
Export
git -C <mirror> archive --format=tar <commit>, stdout piped into stdlib
archive/tar, written into dest. Not worktree add (unreachable .git
pointer under volume mode), not a recursive copy of a checkout (needs a second
cache tree with its own GC), not a symlink (breaks under every container mount).
git archive also honors export-ignore gitattributes for free, and excludes
.git by construction rather than by filter.
Guards, because a tar stream is untrusted input even from a trusted repo:
MkdirAll(dest, 0o755); every entry's cleaned path must stay underdest(tar-slip).- Reject symlink entries whose target escapes
dest. - Skip pax/global headers; preserve mode
&0o777. - Refuse a
destthat already exists and is non-empty.modules/<name>can collide with a stock Godot module directory, and silently overwriting one is a very confusing way to change what the engine does. Fail with both paths named.
Where the strictness lands
The manifest's Validate was the old gate; the database is the new one. Enforce
at write time, in UpsertModule / AddModuleVersion:
namematches^[a-z][a-z0-9_]{0,63}$, the same rule Godot applies. A CHECK constraint as well as a Go check, since the API is not the only writer forever.pathrelative and cleaned: reject absolute, reject any..component. It becomes a filesystem destination inside the workspace.- No two modules may resolve to the same
DestPath(). - Cycle detection in
requires, evaluated over the proposed graph before the transaction commits.a → b → awould hang or blow the stack inSelect; keep a defensivevisitingset there anyway.
6. Assembly — internal/source/workspace.go
Prepare is reordered so the cheap checks come before the expensive clone:
1. validate CacheDir / WorkDir / jobID / GodotRepo (unchanged)
2. if len(s.Modules) > 0 && Registry != nil:
sel := Registry.Select(ctx, major, minor, s.Modules) -- unknown or unoffered dies here
res := Registry.Resolve(ctx, major, minor, s.Modules) -- small repos, fail before the 2 GB engine clone
if len(s.Modules) > 0 && Registry == nil: error
3. syncMirror(engine); rev-parse the engine ref (unchanged)
4. worktree add --detach (unchanged)
5. for each res: dest := filepath.Join(dir, res.Entry.DestPath())
re-check dest is under dir; Registry.Export(ctx, res, dest)
6. ws.Modules = res
Assembler gains GodotMajor/GodotMinor alongside the spec, since the spec
carries a ref and the list is keyed by version. The caller resolves the
godot_versions row and passes both — the assembler does not query the catalog
itself, and never parses the ref.
Step 2 before step 3 is what makes two of M2's acceptance criteria true rather than approximately true.
One bug M2 makes reachable: Prepare returns Workspace{} on error, so a
caller has nothing to Cleanup — and after step 4 a failure now leaks a
registered worktree. Any error in steps 5–6 must call
a.removeWorktree(context.Background(), mirror, dir) on the way out, for the
same reason Cleanup does not use the caller's context.
The DestPath re-check in step 5 is defensive; the write-time validation in §5
is the real gate. Both are cheap.
7. Configuration and deployment
internal/config/config.go
ModulesManifest and VersionsManifest (config.go:27-28, :184-185,
:247-250) are deleted along with DBPath() (config.go:383). In their place:
type DatabaseConfig struct {
DSN string `yaml:"dsn"` // GBB_DATABASE_DSN
MaxConns int32 `yaml:"max_conns"`
ConnectTimeout time.Duration `yaml:"connect_timeout"`
}
type ModulesConfig struct {
SSHKeyPath string `yaml:"ssh_key_path"`
KnownHostsPath string `yaml:"known_hosts_path"`
// InsecureSkipHostKeyCheck is inverted deliberately: the zero value is the
// safe one, and the rest of this config avoids pointer fields.
InsecureSkipHostKeyCheck bool `yaml:"insecure_skip_host_key_check"`
}
Env overrides follow the existing pattern in applyEnv: GBB_DATABASE_DSN,
GBB_MODULES_SSH_KEY_PATH, GBB_MODULES_KNOWN_HOSTS_PATH,
GBB_MODULES_INSECURE_SKIP_HOST_KEY_CHECK. Credentials belong in the DSN from
the environment, following the GBB_S3_* precedent — never in the file.
Validate: a DSN is required. If SSHKeyPath is set and the check is not
skipped, KnownHostsPath is required — otherwise every private clone fails with
an opaque host-key error at the worst possible moment.
deploy/k8s
- A
gbb-postgresSecret holding the DSN, projected asGBB_DATABASE_DSN. - A
gbb-modules-sshSecret holdingid_ed25519andknown_hosts, mounted read-only at/etc/gbb/sshin the server container,defaultMode: 0400— ssh refuses a group- or world-readable key, and the default 0644 will fail. GBB_ADMIN_BOOTSTRAP_TOKENin the same Secret as the DSN.
Add all three to kustomization.yaml and the ConfigMap keys, with placeholders
in secret.yaml matching the gbb-s3 precedent.
Blocker: git and ssh are not in the server image
build/docker/server.Dockerfile:43 runs on
gcr.io/distroless/static-debian12:nonroot, which has no git and no ssh
binary. Host-side cloning cannot work there as it stands — and this is not
only an M2 problem: source.Assembler has always shelled out to git, so the
deployed server could never have assembled a workspace either. M1 only ever ran
--local on a host that had git, which is why it has not surfaced.
docs/architecture.md lists "Shell out to git" as a deliberate choice; the
distroless runtime silently contradicts it. Options:
- Recommended — move the runtime stage to
debian:bookworm-slimwithapt-get install -y --no-install-recommends git openssh-client ca-certificates, keeping the non-root user and the static Go binary. Costs the distroless attack surface, buys a deployment that can actually clone. distroless/base-debian12plus copiedgit/sshbinaries and their shared libraries. Fragile, and breaks on the next base bump.- Run git in a container over the Docker API. Consistent with the runner's
model, but a lot of machinery for
git fetch.
Take (1) in M2 and amend the architecture note. Leaving it to M3 means M3's first in-cluster build fails for a reason that has nothing to do with M3.
Note that pgx is pure Go, so nothing here is forced by the database — the "no
cgo" property still holds, and only git and ssh cost us distroless.
8. API — internal/httpapi
Public
| Route | Change |
|---|---|
GET /api/v1/godot-versions |
Reads godot_versions instead of a manifest. Each entry carries major and minor |
GET /api/v1/modules?godot_ref=<ref> |
The parameter is required. Returns that version's compatibility list, each entry carrying the module version chosen. A bare GET /api/v1/modules is 400 |
There is no version-independent module list any more. That is the inversion made visible at the API boundary: asking "what modules are there" without saying which engine is a question the system no longer has an answer to.
POST /api/v1/builds resolves the spec's godot_ref to a version row and
rejects a selection that its minor does not offer, so a hand-written spec fails
at submission rather than in the queue.
Admin
All behind requireAdmin:
POST /api/v1/admin/modules
PATCH /api/v1/admin/modules/{name}
DELETE /api/v1/admin/modules/{name}
POST /api/v1/admin/modules/{name}/versions
DELETE /api/v1/admin/modules/{name}/versions/{version}
POST /api/v1/admin/godot-versions
PATCH /api/v1/admin/godot-versions/{id}
DELETE /api/v1/admin/godot-versions/{id}
GET /api/v1/admin/compat/{major}.{minor}
PUT /api/v1/admin/compat/{major}.{minor}/modules/{name} -- pin, origin='admin'
DELETE /api/v1/admin/compat/{major}.{minor}/modules/{name}
POST /api/v1/admin/compat/{major}.{minor}/regenerate
POST /api/v1/admin/tokens
DELETE /api/v1/admin/tokens/{id}
DELETE on a module or module version that a frozen compat row points at is
refused with 409 naming the minors that depend on it. Removing it would rewrite
what an old Godot version offers, which is exactly what freezing the list exists
to prevent. Deleting the compat row first is the deliberate path.
Auth
Authorization: Bearer <token>. Look the token up by sha256 hash, require
is_admin, reject revoked_at IS NOT NULL. Constant-time comparison on the
hash, and a 401 that does not distinguish "no token" from "bad token".
This is M6's model built early and scoped to the admin surface. M6 extended it rather than replacing it, exactly as this predicted: the same table, the same middleware and the same rejection now guard the build routes too, with per-token quotas on top. This section is history and needs no change.
The first token is seeded from GBB_ADMIN_BOOTSTRAP_TOKEN at migrate time when
api_tokens is empty; otherwise a fresh deployment has no way in. Log loudly
when it is used, and document rotating it away in docs/admin.md.
9. Wiring and provenance
gbb-server
cmd/gbb-server/main.go:117-128 stops loading two manifests and opens the
database instead. The database is fatal at startup, unlike the manifests it
replaces: the service cannot answer /api/v1/modules or /api/v1/godot-versions
without it, so coming up degraded only moves the failure somewhere less visible.
registry.New takes the store as its Catalog. The same instance is what M3's
queue must hand to its Assembler; add the TODO(M3) next to the runner
construction at main.go:163-167 so the handoff is not missed.
--local
buildLocal (cmd/gbb/main.go:136) opens the database only when
len(s.Modules) > 0, and resolves --godot to a version row on the same
condition. A stock-engine local build must keep working on a machine with no
Postgres, which is how M1 is exercised today.
Print the resolution table after a successful build, next to the artifact digests
(main.go:191-202): name, module version, ref, commit, dest path.
Recording the commits
M2's provenance criterion is "the resolved commit SHA of every module is recorded
on the job", and job.Job has no field for it. Add:
type ModuleResolution struct{ Name, Version, Endpoint, Ref, Commit, DestPath string }
// on Job:
ResolvedModules []ModuleResolution `json:"resolved_modules,omitempty"`
A flat type rather than []registry.Resolved, which carries a CatalogEntry
plus Mirror — a host path with no business in a persisted job record. Version
is here because "which release of the module" is the question a bug report starts
with, and a commit SHA answers it only after a lookup.
M2 delivers the type, Workspace.Modules, and the --local printout. The
persistence is M3's, since there is no queue yet.
web/
types.ts: Module loses godot_compat and gains version; GodotVersion
gains major and minor. api.ts:45 listModules() takes a ref argument.
The picker in routes/+page.svelte must refetch the module list when the
version changes, and clear any selection the new version does not offer. The
module list is a function of the chosen version now; a UI that fetches it once at
load is showing the wrong answer as soon as the user changes anything.
10. Tests
Following the existing fixture pattern in internal/source/workspace_test.go:14
— throwaway local repos, never the network.
| File | Covers |
|---|---|
internal/spec/version_test.go |
Already written and passing. Ref parsing table: 4.4-stable, 4.4.1-stable, 4.5-beta3, v4.4, 4.4, and the ok == false cases master, 3.x, a 40-hex SHA. It informs the admin form now, so keep it |
internal/store/compat_test.go |
Rule 2 selection: newest version at or above the minimum wins; a module with no qualifying version is absent. Rule-table behaviour: adding a Godot minor generates once; adding a module version touches only the newest minor; an origin='admin' row survives RefreshCompat; regenerate rebuilds generated rows only |
internal/store/catalog_test.go |
Write-time validation: bad name, absolute and .. paths, duplicate DestPath, a → b → a cycle. DELETE refused while a frozen compat row references the row |
internal/store/jobs_test.go |
job.Store round-trip against Postgres |
internal/registry/select_test.go |
Transitive expansion, diamond dedupe, cycle, unknown module vs. unoffered module, explicit-ref precedence, that an override cannot admit an unlisted module, deterministic order. Against a fake Catalog — no database, no repos |
internal/registry/resolve_test.go |
Two local fixture repos, one requiring the other: mirror creation, commit recorded, second resolve reuses the mirror, pinned-SHA path skips the fetch |
internal/registry/export_test.go |
Exported tree matches the commit; no .git present; mode bits preserved; non-empty dest refused; tar-slip entry rejected |
internal/httpapi/admin_test.go |
Admin routes reject a missing, non-admin, and revoked token identically; a valid one succeeds. GET /api/v1/modules without godot_ref is 400 |
internal/source/workspace_test.go |
Prepare with a fake catalog grafts into modules/<name>; a failing export leaves no worktree registered |
The store tests need a real Postgres. Gate them on GBB_TEST_DSN and skip
without it, so go test ./... stays runnable on a laptop, and add a Postgres
service container to .github/workflows/ci.yml with that variable set. Git is
already present on the runner, so the registry tests need no new CI wiring.
11. Verification
make check— gofmt,go vet,go test ./...,go build.- Migrations from empty.
store.Openagainst a fresh database creates every table and seeds the bootstrap token; a secondOpenis a no-op. - Catalog round-trip. Through the admin API: add Godot 4.3 and 4.4, add
deep_engine_toolwith v0.3.1 at minimum 4.3, addtelemetry_bridgewith v0.2.0 at minimum 4.4 requiringdeep_engine_tool. ThenGET /api/v1/modules?godot_ref=4.3-stableoffers one module and?godot_ref=4.4-stableoffers two. - The freeze holds. Add
deep_engine_toolv0.4.0 at minimum 4.4. 4.4's list moves to v0.4.0; 4.3's list still says v0.3.1. This is the behaviour the whole design exists for, so it is the one to check by hand before trusting the test. - The fork shares its minor's list. Add
studioteal/4.4withmajor: 4, minor: 4. It offers exactly what4.4-stableoffers — the case the constraint design skipped silently. - Fixture end-to-end. Two local git repos containing a minimal real Godot
module (
config.py,SCsub,register_types.{h,cpp}— commit them undertestdata/, which already has a.gitkeep),brequiringa. Register them withfile://endpoints, then:
Both compile in — grep the SCons output for both module names. Warm ccache makes this ~21 s per M1's measurement, so it is a fast loop../bin/gbb build --godot 4.4-stable --platform linuxbsd --modules b --local - An unoffered module fails immediately. Build 4.2 with
telemetry_bridge: it must fail in under a second, naming the module and its minimum, before any clone. - Unknown module.
--modules nopefails before the engine mirror is touched — confirm by timing and by an untouched mirror mtime. - Admin surface is closed. Every admin route returns 401 without a token and with a non-admin token.
--only-selectedshrinks the binary. Build with and without--only-selectedand compare sizes; this needs no new code, only proof.- Private repo. Point one module endpoint at a real private repo on
git.picklemustard.dev, setmodules.ssh_key_pathandknown_hosts_path, and confirm the clone succeeds and that removingknown_hostsfails loudly rather than hanging. - Server image. After the Dockerfile change,
git --versionandssh -Vinside the built image.
12. Docs to update on the way out
docs/modules.md— rewritten already for this design: noconfigs/modules.yaml, the admin flow,min_godot_major/min_godot_minor, and whatname@refcan and cannot do.docs/admin.md— the operator's guide to the catalog.docs/architecture.md— Registry and Track rows are now real and Postgres-backed; amend the "Shell out togit" non-choice with the runtime-image consequence.docs/deployment.md— Postgres, the two Secrets, the bootstrap token, and the new runtime base image.ROADMAP.md— tick M2, add a "Deviations" section in M1's style.configs/config.example.yaml,deploy/k8s/{secret,configmap,kustomization,statefulset}.yaml,README.md. Deleteconfigs/modules{,.example}.yamlandconfigs/godot-versions{,.example}.yaml.
Files touched
New: internal/gitcmd/gitcmd.go, internal/store/postgres.go,
internal/store/catalog.go, internal/store/compat.go,
internal/store/migrations/*.sql, internal/httpapi/admin.go,
internal/httpapi/auth.go, docs/admin.md, plus the test files in §10.
Modified: internal/registry/registry.go, internal/source/workspace.go,
internal/store/store.go, internal/config/config.go, internal/job/job.go,
internal/httpapi/{server,modules,builds}.go, cmd/gbb/main.go,
cmd/gbb-server/main.go, build/docker/server.Dockerfile, web/src/lib/*,
web/src/routes/+page.svelte, and the docs and manifests in §12.
Deleted: internal/manifest/ (whole package), internal/spec/compat.go
(already removed — it never compiled), configs/modules{,.example}.yaml,
configs/godot-versions{,.example}.yaml.
Guide
- Home
- Getting Started
- Building Godot
- Configuration
- Modules and Administration
- API
- Architecture
- Deployment and Operations
- Security
- Troubleshooting
- Development
Reference
- API
- Architecture
- Modules
- Module registry
- Admin
- Artifacts
- Deployment
- Kubernetes execution
- Durable execution store
- Immutable build configuration
- Download gateway
- Public logs
- Anonymous verification
- Abuse enforcement
- Public service remediation
- Authelia administration
- CI/CD
- macOS SDK