Merge pull request #30 from claude-hdb/feat/project-registry

a project registry — the list of what exists (#25)
This commit is contained in:
Daniel Marin 2026-07-13 21:45:23 +01:00 committed by GitHub
commit dba865f0f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 533 additions and 2 deletions

View file

@ -51,6 +51,8 @@ environments.yaml # bindings: the team each env's token must belon
# which server it deploys onto, the S3 destination,
# GitHub App name, guards — and, per project,
# the destination it deploys onto + its smoke target
# …plus `projects:`, the registry: which projects
# exist, and in which environments
secrets/<repo>.<env>.env.age # age-encrypted values for the ${…} placeholders
.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit)
.coolify/<name>.env # …the same, for a NAMED instance (see below)
@ -357,6 +359,54 @@ own (it hangs off a project) and no API path scopes by one: **Coolify
environments are an organizational construct, not an auth boundary.** The team
is the only boundary there is, so it is the one cast asserts.
## The registry: which projects exist
`environments.yaml` says where things deploy *to*, and how a project you have
already named is placed once it is there. Until the `projects:` block, nothing in
it said **which projects exist at all** — "every project" was a thing the
operator remembered:
```yaml
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
```
Keyed by the **full `<org>/<repo>` slug**, and the key *is* the repo — there is
no `repo:` field inside, because a second place to write the same string is a
second place for it to be wrong. Unlike `github_apps`, a bare `<repo>` key is
**refused** rather than resolved: this block is new, so it has no state files in
the wild to keep working, and a bare `<repo>` is unique only *within* an org —
which is exactly why it is not a key. `environments:` lists **our** environment
names (the values `--env` takes), never Coolify's.
The block is optional; a state file written before it loads unchanged.
**It has to be true, so cast checks that it is — at parse time, for every verb.**
Two ways it could quietly stop being true, both refused:
- an environment name that no `environments:` block defines (a typo). The project
is real and its environment imaginary, so a fleet run visits nothing for it,
reports nothing, and exits clean.
- an `environments.<env>.projects.<repo>` binding — a destination, a smoke target
— in an environment the registry does not register that project for. The two
blocks then describe two different fleets: state real enough for a direct
`cast apply` to use, invisible to every fleet run. (Checked only when
`projects:` is present.)
Both refusals defend one failure: **a silently skipped project reads exactly like
a clean one.** Silence is the one report that must never be ambiguous.
What it unlocks, neither of which was possible without a list to iterate:
- **fleet operations**`cast diff --all` / `apply --all` over every project in
an environment.
- **rebuild-from-state** — "restore this Coolify from the state repo" cannot even
be *attempted* without knowing what was on it. The registry is the difference
between a documented recovery and an archaeology exercise.
## Two projects, one box: destinations
A **destination** is the Docker network a resource is created on. A server has a

View file

@ -158,6 +158,51 @@ softened by an implementation detail):
refuses `--path` combined with `--env prod`: prod always reads the default
branch, so a feature-branch checkout can never reach it.
## The registry (`projects:`)
The top-level `projects:` block is the list of **which projects exist**, and in
which environments. It is the only place that says so: `environments:` says where
things deploy to, `environments.<env>.projects.<repo>` says how an already-named
project is placed, `github_apps` says how to clone one you have already named.
```yaml
projects:
heavy-duty/incubator:
environments: [prod, staging]
```
- **Keyed by the full `<org>/<repo>` slug, with no bare-`<repo>` fallback.** The
key is the repo; there is no `repo:` field. `github_apps` and
`environments.<env>.projects` accept a bare key because state files in the wild
are written that way; this block is new and has none, so it requires the slug —
a bare `<repo>` is unique only *within* an org.
- **`environments:` names OUR environments** — the keys of the `environments:`
block, the values `--env` takes — never Coolify's. Non-empty.
- **Optional.** A state file with no `projects:` block loads unchanged, and
`projectsIn` reports `[]` for every environment.
**Validated at parse time, so every verb refuses a registry that lies.** Two
refusals, both defending the same failure — *a silently skipped project reads
exactly like a clean one*, which makes silence, the most common report there is,
ambiguous:
1. **An environment that does not exist** (a typo in `projects.<slug>.environments`)
is an error naming the unknown environment and listing the known ones. Left
alone, the project would be registered into an environment no command can
visit: a fleet run skips it, reports nothing, exits clean.
2. **A binding the registry does not register.** Every
`environments.<env>.projects.<slug>` key must be a project the registry
registers *for that environment*. Otherwise the two blocks describe two
different fleets: a `destination_uuid` or `smoke_target` real enough for a
direct `cast apply <repo> --env <env>` to act on, and invisible to every
fleet run over that environment. Enforced **only when `projects:` is
present**, so pre-registry state files keep loading.
The registry is what makes two things possible, neither of which can be attempted
without a list to iterate: **fleet operations** (`--all`), and
**rebuild-from-state** — restoring a Coolify from the state repo, which is
otherwise an assumption, since you cannot restore what you cannot enumerate.
## Placement (destinations)
A **destination** is the Docker network a resource is created on. It is declared

View file

@ -58,6 +58,32 @@ const ProjectBindingSchema = z
export type ProjectBinding = z.infer<typeof ProjectBindingSchema>;
// One project in the registry — the top-level `projects:` block, which is the
// list of what EXISTS. Nothing else in this file says that: `environments:`
// says where things are deployed to, `environments.<env>.projects.<repo>` says
// how one project is placed once you already know it is there, and
// `github_apps` says how to clone one you have already named. "Every project"
// was, until this block, a thing the operator remembered.
//
// Two things need the list, and neither can be built without it: fleet
// operations (`cast diff --all`, #26 — iterating "every project in this
// environment") and rebuild-from-state (#27 — a Coolify restored from the state
// repo, which cannot even be attempted without knowing what was on it).
const RegisteredProjectSchema = z
.object({
// OUR environment names — the values `--env` takes, the keys of the
// `environments:` block above — never Coolify's. The distinction is the same
// one `--env` vs `--environment` draws everywhere else in cast.
//
// Non-empty: a project registered into no environment is not a registration,
// it is a line of YAML that reads like one. It would be skipped by every
// fleet run silently.
environments: z.array(z.string().min(1)).nonempty(),
})
.strict();
export type RegisteredProject = z.infer<typeof RegisteredProjectSchema>;
const BindingsSchema = z
.object({
environments: z.record(
@ -100,6 +126,22 @@ const BindingsSchema = z
})
.strict(),
),
// THE REGISTRY: which projects exist at all, keyed by the full `<org>/<repo>`
// slug. The key IS the repo — there is no `repo:` field inside, because a
// second place to write the same string is a second place for it to be
// wrong.
//
// Full slug REQUIRED, with no bare-`<repo>` fallback — the one place in this
// file where that fallback does not exist. `github_apps` and
// `environments.<env>.projects` carry one because they predate the lesson
// (#12) and there are state files in the wild keyed the old way; this block
// is new, has no such files, and so gets to be right from the start. A bare
// `<repo>` is unique only *within* an org, which is precisely why it is not
// a key.
//
// Optional: a state file written before the registry existed keeps loading
// untouched, and `projectsIn` answers `[]` for it.
projects: z.record(RegisteredProjectSchema).optional(),
// Keyed by the repo the App clones for. Prefer the FULL `<org>/<repo>`
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
// existing state files keep working.
@ -112,7 +154,136 @@ const BindingsSchema = z
// cannot distinguish prod's app from staging's either.
smoke_target: z.string().optional(),
})
.strict();
.strict()
// The registry only earns its keep if it is TRUE. Every check here defends the
// same failure: a project that a fleet run never visits, because a fleet run
// that skips a project prints exactly what a fleet run over a clean project
// prints — nothing. Silence is the one report that must never be ambiguous, so
// these are parse-time errors (every verb loads bindings, so every verb refuses
// a registry that lies) rather than warnings some command might print.
.superRefine((bindings, ctx) => {
const registry = bindings.projects;
if (!registry) return;
const knownEnvs = Object.keys(bindings.environments);
const knownEnvList = knownEnvs.join(", ") || "(none)";
for (const [slug, project] of Object.entries(registry)) {
// A key with no `/` is not a repo. See the schema note above: no fallback.
if (!slug.includes("/")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["projects", slug],
message: [
`projects["${slug}"] is not a repo — a registry key has no meaning without its org`,
"",
` found: projects["${slug}"]`,
" wanted: a full <org>/<repo> slug",
"",
"A bare <repo> is unique only *within* an org: heavy-duty/incubator and",
"acme/incubator collapse onto one entry, and the registry then claims one",
"project where there are two. Unlike github_apps, this block is new and has",
"no legacy state files to support, so there is no bare-<repo> fallback.",
"",
" projects:",
` <org>/${slug}:`,
` environments: [${project.environments.join(", ")}]`,
].join("\n"),
});
}
// Every environment named here must be one that actually exists. A typo
// makes the project real but its environment imaginary — so `--all` visits
// nothing for it, reports nothing about it, and exits clean.
for (const envName of project.environments) {
if (!(envName in bindings.environments)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["projects", slug, "environments"],
message: [
`projects["${slug}"] is registered in environment "${envName}", which does not exist`,
"",
` registered: projects["${slug}"].environments → ${project.environments.join(", ")}`,
` known envs: ${knownEnvList}`,
"",
"An environment nothing defines is one that no command can visit: a fleet",
"run would skip this project, and a silently skipped project reads exactly",
"like a clean one. Fix the name, or declare the environment:",
"",
" environments:",
` ${envName}:`,
" server: <server>",
" team: { id: <id>, name: <name> }",
].join("\n"),
});
}
}
}
// The other direction, and the one that rots quietly: a project carrying
// per-environment state (destination_uuid, smoke_target — #21) in an
// environment the registry does not register it for. That state is then real
// enough to be used by a direct `cast apply <repo> --env <env>` and invisible
// to every fleet run — the two blocks describing two different fleets.
//
// Only checked when `projects:` is present, so state files written before the
// registry keep loading exactly as they did.
for (const [envName, env] of Object.entries(bindings.environments)) {
for (const key of Object.keys(env.projects ?? {})) {
const entry = registry[key];
if (entry?.environments.includes(envName)) continue;
// The likeliest cause, worth saying out loud: a legacy bare-<repo> key
// (which projectBindingFor still resolves) under a registry that is
// correctly keyed by slug. The fix is a rename, not a registration.
const slugFor = key.includes("/")
? undefined
: Object.keys(registry).find((s) => s.endsWith(`/${key}`));
const cause = slugFor
? [
` registry has: projects["${slugFor}"]`,
"",
"The binding uses the legacy bare-<repo> key. The registry is keyed by the",
`full slug, so rename it to match — environments.${envName}.projects["${slugFor}"].`,
]
: entry
? [
` registered for: ${entry.environments.join(", ")}`,
"",
"Register the project in this environment, or drop the binding — state that",
"no fleet run will ever visit is state that stops being true without anyone",
"finding out:",
"",
" projects:",
` ${key}:`,
` environments: [${[...entry.environments, envName].join(", ")}]`,
]
: [
` registry has: ${Object.keys(registry).join(", ") || "(nothing)"}`,
"",
"The project is not in the registry at all, so no fleet run will ever visit",
"it — while this binding says it is deployed here. Register it, or drop the",
"binding:",
"",
" projects:",
` ${key}:`,
` environments: [${envName}]`,
];
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["environments", envName, "projects", key],
message: [
`environments.${envName}.projects["${key}"] is bound in an environment the registry does not register it for`,
"",
` bound at: environments.${envName}.projects["${key}"]`,
...cause,
].join("\n"),
});
}
}
});
export type Bindings = z.infer<typeof BindingsSchema>;
@ -188,6 +359,27 @@ export function smokeTargetFor(
return undefined;
}
// The `<org>/<repo>` slugs registered for one environment — the list a fleet
// operation iterates (`cast diff --all`, #26) and a rebuild reads (#27).
//
// SORTED, deliberately: the order of keys in a YAML file is an accident of who
// typed what when, and a fleet run's output — which a human reads top to bottom,
// and CI diffs — must not reshuffle because someone appended a project. The
// registry is a set; this returns it as one.
//
// `[]` when there is no registry, which is every state file written before this
// block existed. That is the honest answer for "which projects are registered
// here" when nothing is registered anywhere — and it makes a fleet verb over an
// unmigrated state file a clean no-op rather than a crash.
export function projectsIn(bindings: Bindings, envName: string): string[] {
const registry = bindings.projects;
if (!registry) return [];
return Object.entries(registry)
.filter(([, project]) => project.environments.includes(envName))
.map(([slug]) => slug)
.sort();
}
export function loadBindings(
path: string,
opts: { overrideText?: string } = {},
@ -195,7 +387,20 @@ export function loadBindings(
const text = opts.overrideText ?? readFileSync(path, "utf8");
const result = BindingsSchema.safeParse(parse(text));
if (!result.success) {
throw new Error(`invalid bindings ${path}: ${result.error.message}`);
// Zod's own `.message` is the entire issue array as JSON — which renders the
// refusals above as one long line of `\n` escapes, i.e. throws away the part
// of them that was worth writing. Render the issues instead.
const detail = result.error.issues
.map((issue) => {
// A multi-line message is one WE wrote: it already names the path, the
// cause, and the YAML to write. A one-line message is zod's ("Required"),
// and is useless without the path it happened at.
if (issue.message.includes("\n")) return issue.message;
const where = issue.path.map(String).join(".");
return where ? `${where}: ${issue.message}` : issue.message;
})
.join("\n\n");
throw new Error(`invalid bindings ${path}:\n\n${detail}`);
}
return result.data;
}

View file

@ -5,6 +5,7 @@ import {
githubAppNameFor,
loadBindings,
projectBindingFor,
projectsIn,
smokeTargetFor,
} from "../src/bindings.js";
@ -223,3 +224,233 @@ github_apps: {}
).toThrow(/invalid bindings/);
});
});
// The registry: the list of which projects exist at all. Everything it is FOR
// (fleet iteration, rebuild-from-state) depends on it being true, and the way it
// stops being true is silent — see the refusals below.
describe("the project registry", () => {
const twoEnvs = `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
staging:
server: staging-box
team: { id: 0, name: Root Team }
`;
it("registers projects per environment, keyed by the full slug", () => {
const b = loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
github_apps: {}
`,
});
expect(b.projects).toEqual({
"heavy-duty/incubator": { environments: ["prod", "staging"] },
"acme/client-site": { environments: ["prod"] },
});
});
describe("projectsIn", () => {
const b = loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
github_apps: {}
`,
});
// Sorted, not file-order: a fleet run's output is read by a human and diffed
// by CI, and must not reshuffle because someone appended a project.
it("gives an environment's projects, sorted", () => {
expect(projectsIn(b, "prod")).toEqual([
"acme/client-site",
"heavy-duty/incubator",
]);
});
it("gives only the projects registered for that environment", () => {
expect(projectsIn(b, "staging")).toEqual(["heavy-duty/incubator"]);
});
it("is empty for an environment no project is registered in", () => {
expect(projectsIn(b, "nowhere")).toEqual([]);
});
});
// The refusal the issue is actually about. A typo'd environment name makes the
// project real and its environment imaginary: `cast diff --all` visits nothing
// for it, reports nothing, and exits clean — and a silently skipped project
// reads exactly like a clean one.
it("refuses an environment that does not exist, naming the ones that do", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, stagng]
github_apps: {}
`,
});
expect(err).toThrow(/environment "stagng", which does not exist/);
expect(err).toThrow(/known envs:\s+prod, staging/);
});
// The other direction, and the one that rots quietly: per-environment state
// (#21) sitting in an environment the registry does not register the project
// for. The two blocks then describe two different fleets.
it("refuses a binding in an environment the registry does not register", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
staging:
server: staging-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(
/environments\.staging\.projects\["heavy-duty\/incubator"\]/,
);
expect(err).toThrow(/registered for:\s+prod/);
});
it("refuses a binding for a project the registry does not carry at all", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
acme/client-site:
destination_uuid: dest-client
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/environments\.prod\.projects\["acme\/client-site"\]/);
expect(err).toThrow(/registry has:\s+heavy-duty\/incubator/);
});
// A legacy bare-<repo> binding key (projectBindingFor still resolves one) under
// a slug-keyed registry is drift with an obvious fix — say which fix.
it("tells a legacy bare-<repo> binding key which slug to rename to", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
incubator:
smoke_target: core
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/registry has:\s+projects\["heavy-duty\/incubator"\]/);
expect(err).toThrow(/legacy bare-<repo> key/);
});
// No fallback here, unlike github_apps: this block is new, so it has no state
// files in the wild to keep working, and a bare <repo> is unique only within an
// org — which is exactly why it is not a key.
it("refuses a bare <repo> registry key — the org is not optional", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/projects\["incubator"\] is not a repo/);
expect(err).toThrow(/full <org>\/<repo> slug/);
});
// A project registered into nothing is a line of YAML that reads like a
// registration and is skipped by every fleet run.
it("refuses a project registered into no environment", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: []
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
it("rejects an unknown key inside a registry entry", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod]
repo: heavy-duty/incubator
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
// Back-compat: the registry is optional, and every state file written before it
// existed has no `projects:` block. Such a file loads unchanged — including its
// per-environment bindings, which are NOT checked against a registry that is
// not there.
describe("with no registry at all", () => {
const b = loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
smoke_target: core
github_apps: {}
`,
});
it("loads, and keeps its per-environment bindings working", () => {
expect(b.projects).toBe(undefined);
expect(
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
).toBe("dest-abc");
});
it("has no projects registered in any environment", () => {
expect(projectsIn(b, "prod")).toEqual([]);
});
});
});