114 lines
3.3 KiB
TypeScript
114 lines
3.3 KiB
TypeScript
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import { parse } from "yaml";
|
||
|
|
import { z } from "zod";
|
||
|
|
|
||
|
|
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: z.string(),
|
||
|
|
publish_directory: z.string().optional(),
|
||
|
|
compose_file: z.string().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",
|
||
|
|
});
|
||
|
|
} 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",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
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(),
|
||
|
|
domains: 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(),
|
||
|
|
})
|
||
|
|
.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;
|
||
|
|
}
|