Services could not carry hostnames through cast: `desiredFromManifest` dropped a service's `domains` and warned they were a manual Coolify UI act, citing a re-checked "no flat `domains` on a 4.1.2 service, on any route." The audit (#72) disproved that — the same failure mode #51 corrected for backup schedules. The FLAT shape genuinely has no route; the per-container CAPABILITY was there at 4.1.2 all along. `POST /services` and `PATCH /services/{uuid}` both take a `urls` list ([{name, url}], url comma-joined) that `applyServiceUrls` matches to a `ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}` loads `applications` and returns each `fqdn` (verified against ServicesController v4.1.2). So services now speak the SAME per-container vocabulary a dockercompose app does: - **Manifest:** `service_domains: { <container>: [url] }` replaces the flat, unhonorable `domains` on a service (a flat list cannot name which container a hostname belongs to — exactly what `urls` requires). Canonicalized (keys and each URL array sorted) so container order never false-drifts. - **Write:** `serviceApiFields` builds `urls` on create and update. - **Read/diff:** a supplementary `GET /services/{uuid}` per service (`attachServiceDomains`, gated to `diff`/`apply` like backups) projects `applications[].fqdn` back into `service_domains`, so a declared hostname is compared every run — no perpetual drift, no manual UI step. - **Pre-flight:** a service create's `service_domains` joins `desiredDomainsOfCreate`, the more important because a service create whose domain conflicts is DELETED server-side before the 409 (rollback). Two limits stated out loud: the read is fail-closed (an unreachable/ unrecognized `GET /services/{uuid}` aborts rather than projecting empty and re-PATCHing forever), and `inventory --emit-draft` does not yet make the per-service GET, so a drafted service's hostnames are still declared by hand (same as backups) — draft/semantics say so. `npm run check` clean · 514 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
210 lines
8.8 KiB
TypeScript
210 lines
8.8 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { parse } from "yaml";
|
|
import { z } from "zod";
|
|
|
|
// Coolify validates every checkout-relative path on create and 422s anything
|
|
// that is not absolute. Transcribed from coolify v4.1.2:
|
|
// `bootstrap/helpers/api.php::sharedDataApplications()` binds
|
|
// `base_directory`/`publish_directory` to `ValidationPatterns::directoryPathRules()`
|
|
// and `docker_compose_location` to `ValidationPatterns::filePathRules()`, and
|
|
// `app/Support/ValidationPatterns.php` defines the two patterns below. The only
|
|
// difference between them: a file path needs at least one character after the
|
|
// slash, a directory path may be the bare `/` (the checkout root).
|
|
const COOLIFY_FILE_PATH = /^\/[a-zA-Z0-9._/~@+-]+$/;
|
|
const COOLIFY_DIRECTORY_PATH = /^\/[a-zA-Z0-9._/~@+-]*$/;
|
|
|
|
// These are refinements, not normalizations, and must stay that way: cast does
|
|
// not quietly rewrite what the manifest says. A value that would 422 gets fixed
|
|
// in the file, in a commit, once — not repaired in memory on every run. And the
|
|
// check belongs here, at parse time, because it is a property of the manifest
|
|
// and of nothing else: by the time a create returns its bare 422, `apply` has
|
|
// already made the project and the environment, and the run is half-applied.
|
|
const composeFilePath = z
|
|
.string()
|
|
.regex(
|
|
COOLIFY_FILE_PATH,
|
|
"compose_file must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /docker-compose.yaml, not docker-compose.yaml",
|
|
);
|
|
|
|
const repoDirectoryPath = (field: string) =>
|
|
z
|
|
.string()
|
|
.regex(
|
|
COOLIFY_DIRECTORY_PATH,
|
|
`${field} must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /apps/core, not apps/core; the checkout root is /`,
|
|
);
|
|
|
|
const AppSpecSchema = z
|
|
.object({
|
|
source: z.object({ repo: z.string(), branch: z.string() }).strict(),
|
|
build: z
|
|
.object({
|
|
pack: z.enum(["nixpacks", "static", "dockerfile", "dockercompose"]),
|
|
base_directory: repoDirectoryPath("base_directory"),
|
|
publish_directory: repoDirectoryPath("publish_directory").optional(),
|
|
compose_file: composeFilePath.optional(),
|
|
// The three build/run commands and the static flag Coolify accepts on
|
|
// both the create (POST /applications/private-github-app) and the
|
|
// update (PATCH /applications/{uuid}) routes. They are free-form
|
|
// strings passed through verbatim — cast does not parse or validate the
|
|
// shell in them, only whether they belong on this pack (superRefine
|
|
// below). `static` maps to Coolify's `is_static`: it makes Coolify
|
|
// SERVE `publish_directory` and run NO start command, which is exactly
|
|
// the fix for a static site in a workspace monorepo that otherwise gets
|
|
// built and RUN from the repo-root package.json (#63).
|
|
install_command: z.string().optional(),
|
|
build_command: z.string().optional(),
|
|
start_command: z.string().optional(),
|
|
static: z.boolean().optional(),
|
|
})
|
|
.strict(),
|
|
port: z.number().int().optional(),
|
|
healthcheck: z.string().optional(),
|
|
domains: z.array(z.string()).optional(),
|
|
service_domains: z.record(z.array(z.string())).optional(),
|
|
env_template: z.string().optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((app, ctx) => {
|
|
if (app.build.pack === "dockercompose") {
|
|
if (!app.build.compose_file)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "dockercompose apps require build.compose_file",
|
|
});
|
|
if (!app.service_domains)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "dockercompose apps require service_domains",
|
|
});
|
|
for (const k of ["port", "healthcheck", "domains"] as const)
|
|
if (app[k] !== undefined)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `${k} not allowed on a dockercompose app (lives in the compose file)`,
|
|
});
|
|
if (app.build.publish_directory)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "publish_directory not allowed on a dockercompose app",
|
|
});
|
|
// A compose app builds and runs from its compose file — Coolify never
|
|
// consults these on it. Reject them at parse time rather than post them
|
|
// and have them silently ignored (the same reasoning as publish_directory
|
|
// and port above).
|
|
for (const k of [
|
|
"install_command",
|
|
"build_command",
|
|
"start_command",
|
|
] as const)
|
|
if (app.build[k] !== undefined)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `build.${k} not allowed on a dockercompose app (it builds from its compose file)`,
|
|
});
|
|
if (app.build.static !== undefined)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message:
|
|
"build.static not allowed on a dockercompose app (a compose file decides what is served)",
|
|
});
|
|
} else {
|
|
if (!app.domains)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "domains required (non-compose app)",
|
|
});
|
|
if (app.service_domains || app.build.compose_file)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message:
|
|
"service_domains/compose_file only allowed with pack dockercompose",
|
|
});
|
|
// `static: true` tells Coolify to serve publish_directory and run no
|
|
// start command — so a static app with nothing to serve is almost
|
|
// certainly a mistake, and one that would deploy green while serving an
|
|
// empty site. Catch it in the file, once, not on a live box.
|
|
if (app.build.static === true && !app.build.publish_directory)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message:
|
|
"build.static: true serves publish_directory and runs no start command — but no publish_directory is set, so there is nothing to serve",
|
|
});
|
|
}
|
|
});
|
|
|
|
const DatabaseSpecSchema = z
|
|
.object({
|
|
type: z.enum(["postgresql", "redis"]),
|
|
version: z.string().optional(),
|
|
backup: z
|
|
.object({ frequency: z.string(), retention: z.number().int() })
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const ServiceSpecSchema = z
|
|
.object({
|
|
type: z.string(),
|
|
// Per-container hostnames, exactly the vocabulary a dockercompose app uses
|
|
// (a map of container name -> URLs). A Coolify service is a bundle of
|
|
// containers (`ServiceApplication`s), and a hostname is set on ONE of them —
|
|
// so a flat `domains: string[]` cannot say which, and cannot build the
|
|
// `urls: [{name, url}]` payload the API matches to a container by name
|
|
// (cast#72, verified against ServicesController@applyServiceUrls v4.1.2).
|
|
// The name is the container's, discoverable from a `cast diff` read-back or
|
|
// the Coolify UI. Written on create/PATCH, read back off
|
|
// `service.applications[].fqdn`, and diffed like any other field.
|
|
service_domains: z.record(z.array(z.string())).optional(),
|
|
env_template: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const EnvironmentSpecSchema = z
|
|
.object({
|
|
applications: z.record(AppSpecSchema),
|
|
databases: z.record(DatabaseSpecSchema).optional(),
|
|
services: z.record(ServiceSpecSchema).optional(),
|
|
// Secret names whose values the PROVIDER generates — a Coolify-created
|
|
// Postgres/Redis URL, a service's own generated credentials. `capture`
|
|
// writes these as the literal `pending-coolify-generated` and never copies
|
|
// the source box's live value: that value points at the SOURCE box's
|
|
// database, so carrying it over would be confidently wrong in a way that
|
|
// looks entirely plausible, and the target's real URL does not exist until
|
|
// Coolify creates the resource.
|
|
//
|
|
// It is a manifest property rather than a flag the operator has to
|
|
// remember, because the manifest is what knows DATABASE_URL comes from a
|
|
// database it declares. Optional: a manifest that names none simply has no
|
|
// generated secrets, and `capture` will say so in its plan.
|
|
generated_secrets: z.array(z.string()).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const ManifestSchema = z
|
|
.object({
|
|
project: z.string(),
|
|
environments: z.record(EnvironmentSpecSchema),
|
|
})
|
|
.strict();
|
|
|
|
export type AppSpec = z.infer<typeof AppSpecSchema>;
|
|
export type DatabaseSpec = z.infer<typeof DatabaseSpecSchema>;
|
|
export type ServiceSpec = z.infer<typeof ServiceSpecSchema>;
|
|
export type EnvironmentSpec = z.infer<typeof EnvironmentSpecSchema>;
|
|
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
|
|
export function loadManifest(
|
|
path: string,
|
|
opts: { overrideText?: string } = {},
|
|
): Manifest {
|
|
const text = opts.overrideText ?? readFileSync(path, "utf8");
|
|
const result = ManifestSchema.safeParse(parse(text));
|
|
if (!result.success) {
|
|
throw new Error(
|
|
`invalid manifest ${path}: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
|
|
);
|
|
}
|
|
return result.data;
|
|
}
|