@yunuseon/kayra 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { appManifestSchema, appValuesSchema, defaultsSchema, serverKinds, serverSchema, settings, varsSchema, } from "./settings.js";
3
+ const version = "https://json-schema.org/draft/2020-12/schema";
4
+ const jsonSchema = (title, schema) => ({
5
+ ...z.toJSONSchema(schema, { target: "draft-2020-12", unrepresentable: "any" }),
6
+ $schema: version,
7
+ title,
8
+ });
9
+ /** The fleet's files as JSON Schemas, for editors: published in the package under schema/. */
10
+ export const jsonSchemas = () => ({
11
+ "server.json": jsonSchema("server.yml", serverSchema),
12
+ "defaults.json": jsonSchema("defaults.yml", defaultsSchema),
13
+ ...Object.fromEntries(serverKinds.map((kind) => [`vars-${kind}.json`, jsonSchema(`vars.yml (kind ${kind})`, varsSchema(kind))])),
14
+ "app.json": jsonSchema("app.yml", appManifestSchema),
15
+ "app-values.json": jsonSchema("apps/<app>.yml and apps/<app>.secrets.yml", appValuesSchema),
16
+ });
17
+ const kindsOf = (each) => each.kinds.join(", ");
18
+ /** MDX reads <…> as a component and {…} as an expression; a table cell's | ends it. */
19
+ const escaped = (description) => description.replaceAll("|", "\\|").replaceAll("<", "&lt;").replaceAll("{", "&#123;").replaceAll("}", "&#125;");
20
+ const row = (each) => `| \`${each.name}\` | ${kindsOf(each)} | ${escaped(each.description)} |`;
21
+ const table = (chosen) => ["| Setting | Kinds | What it is |", "|---|---|---|", ...chosen.map(row)].join("\n");
22
+ const editorLine = (kind) => `# yaml-language-server: $schema=../../node_modules/@yunuseon/kayra/schema/vars-${kind}.json`;
23
+ /** The docs site's settings page, generated from the same registry as the schemas. */
24
+ export const settingsPage = () => [
25
+ "---",
26
+ "title: Settings",
27
+ "description: Every setting a fleet can give its servers, generated from kayra's schemas.",
28
+ "---",
29
+ "",
30
+ "{/* Generated by `npm run schemas` from cli/src/settings.ts — edit there, not here. */}",
31
+ "",
32
+ "A server's `vars.yml` may set the settings of its kind and nothing else: an unknown key stops every",
33
+ "kayra command, with a suggestion where it looks like a typo. `servers/defaults.yml` holds settings",
34
+ "shared by every server. Secrets go in `secrets.yml` — a secret in `vars.yml` is refused.",
35
+ "",
36
+ "Editors complete and check these files from the JSON Schemas in the package (`schema/`), e.g. as",
37
+ "the first line of a platform server's `vars.yml`:",
38
+ "",
39
+ "```yaml",
40
+ editorLine("platform"),
41
+ "```",
42
+ "",
43
+ "## Settings",
44
+ "",
45
+ table(settings.filter((each) => !each.secret)),
46
+ "",
47
+ "## Secrets",
48
+ "",
49
+ table(settings.filter((each) => each.secret)),
50
+ "",
51
+ "Ansible's own variables (`ansible_*`) are allowed everywhere.",
52
+ "",
53
+ ].join("\n");
@@ -0,0 +1,137 @@
1
+ import { z } from "zod";
2
+ /** The server kinds, each with its playbook; settings say which kinds read them. */
3
+ export const serverKinds = ["platform", "edge", "nas"];
4
+ const setting = (name, kinds, schema, description) => ({ name, kinds, secret: false, schema: schema.describe(description), description });
5
+ const secret = (name, kinds, schema, description) => ({ name, kinds, secret: true, schema: schema.describe(description), description });
6
+ const text = z.string();
7
+ const texts = z.array(z.string());
8
+ const platform = ["platform"];
9
+ const edge = ["edge"];
10
+ const nas = ["nas"];
11
+ /** Nested settings check the fields they know and allow others: a typo is caught at the top. */
12
+ const record = (fields) => z.looseObject(fields);
13
+ export const settings = [
14
+ // Every kind that serves HTTPS
15
+ setting("acme_email", ["platform", "edge"], text, "The Let's Encrypt account contact: expiry warnings land here."),
16
+ setting("docker_version", ["platform", "edge"], text, "The Docker CE major the docker role pins and holds, as an apt version glob (default 5:29*)."),
17
+ // platform: the platform role
18
+ setting("git_remote_url", platform, text, "kayra's repository: Jenkins loads the pipeline library and platform jobs from it."),
19
+ setting("platform_ref", platform, text, "The ref of git_remote_url Jenkins loads from; defaults to the tag of the kayra version that ran (v<version>). Only a test server sets a branch."),
20
+ setting("jenkins_admin_user", platform, text, "Jenkins' local admin user (default admin)."),
21
+ setting("authelia_policy", platform, z.enum(["one_factor", "two_factor"]), "What Authelia asks of staff: two_factor (default) or one_factor."),
22
+ setting("authelia_smtp_address", platform, text, "SMTP server for Authelia's mails (passkey enrolment); empty: mails go to a file."),
23
+ setting("authelia_smtp_username", platform, text, "SMTP user for Authelia."),
24
+ setting("authelia_smtp_sender", platform, text, "Sender address of Authelia's mails."),
25
+ setting("acme_dns", platform, z.enum(["", "hetzner"]), "Certificates by DNS-01 instead of HTTP-01, for a server the internet never reaches: the DNS provider (hetzner)."),
26
+ setting("renovate_repositories", platform, texts, "owner/repo of the repos this server's platform-renovate keeps up to date; the job exists only where the list is not empty."),
27
+ setting("renovate_schedule", platform, text, "When platform-renovate runs, as a Jenkins cron spec (default weekly: H 4 * * 1)."),
28
+ setting("renovate_platform", platform, z.enum(["github", "forgejo"]), "Where the repos live: github (default) or forgejo."),
29
+ setting("renovate_endpoint", platform, text, "With a Forgejo: its API on platform-internal, e.g. http://forgejo:3000/api/v1."),
30
+ setting("renovate_git_url", platform, z.enum(["default", "ssh", "endpoint"]), "How Renovate clones; endpoint with a Forgejo, so it uses the internal address."),
31
+ setting("renovate_host_rules", platform, z.array(record({ matchHost: text })), "Renovate hostRules: allowInternal for a Forgejo on platform-internal, credentials for a private registry."),
32
+ setting("vm", platform, record({ address: text, gateway: text, disk: text }), "target home: the VM kayra provision builds — fixed address (CIDR), gateway, disk size."),
33
+ setting("forgejo_config", platform, record({
34
+ url: text,
35
+ container: text,
36
+ runners: z.array(record({ name: text, scope: text.optional() })).optional(),
37
+ tokens: z.array(record({ user: text, name: text, scopes: texts, secret: text })).optional(),
38
+ users: z.array(record({ name: text, email: text })).optional(),
39
+ orgs: z.array(record({ name: text, owner: text, visibility: z.enum(["public", "limited", "private"]) })).optional(),
40
+ ssh_keys: z.array(record({ user: text, title: text, key: text })).optional(),
41
+ repos: z
42
+ .array(record({
43
+ name: text,
44
+ settings: z.record(z.string(), z.unknown()).optional(),
45
+ collaborators: z.array(record({ user: text, permission: text })).optional(),
46
+ webhooks: texts.optional(),
47
+ push_mirror: text.optional(),
48
+ pull_mirror: text.optional(),
49
+ }))
50
+ .optional(),
51
+ }), "What this server's own Forgejo holds, as code: users, SSH keys, organizations, runners, repos (settings, collaborators, webhooks, mirrors) and the tokens it mints for the platform."),
52
+ // platform and nas: the compose-stacks role
53
+ setting("compose_stacks", ["platform", "nas"], record({ dir: text, stacks: texts }), "The server's own compose stacks: their directory on the server and the names under stacks/ to run."),
54
+ // nas
55
+ setting("nas_root", nas, text, "The only directory the NAS play writes in, asserted before any change (e.g. /volume2)."),
56
+ setting("restic_backup", nas, record({
57
+ dir: text,
58
+ repository: text,
59
+ schedule: text,
60
+ restore_test_schedule: text,
61
+ paths: texts,
62
+ restore_test_paths: texts.optional(),
63
+ exclude: texts.optional(),
64
+ keep: record({ daily: z.number(), weekly: z.number(), monthly: z.number() }).optional(),
65
+ ssh: record({ public_key: text, host: text, user: text, port: z.number(), known_host: text }).optional(),
66
+ }), "The nightly restic backup: what, where to (SFTP), when, how long kept, and the monthly restore test."),
67
+ // edge
68
+ setting("wireguard", edge, record({ address: text, port: z.number(), peer_public_key: text, peer_allowed_ips: texts, keepalive: z.number().optional() }), "The tunnel into the private network: the edge's address, port, and the one peer (its key, what it may reach)."),
69
+ setting("edge_routes", edge, z.array(record({ subdomains: texts, upstream: text, forwarded_headers: z.boolean().optional() })), "Public names the edge serves: subdomains of the domain and the upstream inside the tunnel they go to."),
70
+ setting("backup_check", edge, record({
71
+ stale_after_hours: z.number(),
72
+ github_repository: text,
73
+ github_app_id: text,
74
+ repositories: z.array(record({ name: text, url: text, user: text, restore_test_max_days: z.number().optional() })),
75
+ }), "The hourly check of the private network's backups: when a snapshot is stale, where issues go, which repositories."),
76
+ // Secrets
77
+ secret("jenkins_admin_password", platform, text, "Jenkins' admin password."),
78
+ secret("git_token", platform, text, "Token Jenkins clones and posts commit statuses with."),
79
+ secret("github_webhook_secret", platform, text, "HMAC secret of the apps' GitHub push webhooks."),
80
+ secret("renovate_token", platform, text, "Token platform-renovate opens PRs with."),
81
+ secret("renovate_github_com_token", platform, text, "Public-read GitHub token for Renovate's lookups off GitHub."),
82
+ secret("authelia_session_secret", platform, text, "Authelia's session secret."),
83
+ secret("authelia_storage_encryption_key", platform, text, "Authelia's storage encryption key."),
84
+ secret("authelia_smtp_password", platform, text, "Authelia's SMTP password."),
85
+ secret("authelia_users", platform, z.record(z.string(), z.unknown()), "Staff accounts: name, email, argon2id password hash."),
86
+ secret("acme_dns_token", platform, text, "With acme_dns: the DNS provider's token, for DNS-01 only."),
87
+ secret("forgejo_admin_token", platform, text, "forgejo-config's token: admin, organization, repository, user (read and write)."),
88
+ secret("forgejo_runner_secrets", platform, z.record(z.string(), text), "Per runner name: the 40-character secret shared by the runner and Forgejo."),
89
+ secret("forgejo_action_secrets", platform, z.array(record({ repo: text, name: text, value: text })), "Actions secrets per repo."),
90
+ secret("forgejo_push_mirror_credentials", platform, record({ username: text, password: text }), "The account push mirrors push with."),
91
+ secret("compose_stack_secrets", ["platform", "nas"], z.record(z.string(), z.record(z.string(), z.unknown())), "Per stack: the variables of its .env."),
92
+ secret("compose_registry_logins", ["platform", "nas"], z.array(record({ registry: text, username: text, password: text })), "Private registries the stacks pull from."),
93
+ secret("restic_backup_password", nas, text, "The restic repository's password."),
94
+ secret("restic_backup_ssh_key", nas, text, "The SSH key the backup reaches the Storage Box with."),
95
+ secret("restic_backup_postgres_passwords", nas, z.record(z.string(), text), "Per database container: the password its dump connects with."),
96
+ secret("wireguard_private_key", edge, text, "The edge's WireGuard private key."),
97
+ secret("wireguard_peer_psk", edge, text, "The tunnel's preshared key."),
98
+ secret("backup_check_github_app_key", edge, text, "The GitHub App's private key the backup check opens issues with."),
99
+ secret("backup_check_passwords", edge, z.record(z.string(), text), "Per watched repository: its read-only password."),
100
+ ];
101
+ /** Ansible's own variables (connection, privilege escalation) are always allowed. */
102
+ export const isAnsibleVariable = (key) => key.startsWith("ansible_");
103
+ export const settingsFor = (kind, secrets) => settings.filter((each) => each.secret === secrets && each.kinds.includes(kind));
104
+ const shape = (chosen) => Object.fromEntries(chosen.map((each) => [each.name, each.schema.optional()]));
105
+ /** vars.yml for a kind: its settings and nothing else (Ansible's own variables apart). */
106
+ export const varsSchema = (kind) => z.strictObject(shape(settingsFor(kind, false)));
107
+ /** defaults.yml: settings shared by every server, so any kind's. */
108
+ export const defaultsSchema = z.strictObject(shape(settings.filter((each) => !each.secret)));
109
+ /** secrets.yml: the known secrets typed; other keys may be helpers that vars.yml references. */
110
+ export const secretsSchema = (kind) => z.looseObject(shape(settingsFor(kind, true)));
111
+ /** server.yml: topology only. */
112
+ export const serverSchema = z.strictObject({
113
+ kind: z.enum(serverKinds).optional().describe("Which playbook: platform (default; Traefik, Authelia, Jenkins, apps), edge or nas."),
114
+ target: z.enum(["hetzner", "multipass", "home"]).describe("Where it runs: hetzner, multipass (a local VM) or home (a machine nothing creates)."),
115
+ domain: z.string().describe("The server's domain; platform subdomains and the apps' hang off it."),
116
+ vault: z.boolean().optional().describe("secrets.yml is vault-encrypted (required outside multipass)."),
117
+ vault_password_file: z.string().optional().describe("The vault password file; without it, Ansible asks."),
118
+ apps: z
119
+ .array(z.strictObject({
120
+ name: z.string(),
121
+ repo: z.string().describe("The app's repository, https://."),
122
+ ref: z.string().optional().describe("The branch to build and deploy (default main)."),
123
+ path: z.string().optional().describe("The app's root inside the repository (default .)."),
124
+ }))
125
+ .optional()
126
+ .describe("The apps this server runs (platform only)."),
127
+ });
128
+ /** app.yml, at the root of an app's repository: the app contract. */
129
+ export const appManifestSchema = z.strictObject({
130
+ name: z.string().describe("The app's name, as in the server.yml entry."),
131
+ compose: z.array(z.string()).describe("Compose files, relative to the app root, in order."),
132
+ subdomains: z.array(z.string()).describe("Hostnames the app claims: <sub>.<domain>."),
133
+ secrets: z.array(z.string()).optional().describe("Secret keys; their values come from the fleet's apps/<app>.secrets.yml."),
134
+ config: z.array(z.string()).optional().describe("Config keys; their values come from the fleet's apps/<app>.yml."),
135
+ });
136
+ /** apps/<app>.yml and apps/<app>.secrets.yml: flat key → value. */
137
+ export const appValuesSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
@@ -0,0 +1,50 @@
1
+ import { err, ok } from "./result.js";
2
+ import { isAnsibleVariable, settings } from "./settings.js";
3
+ /** Edit distance, for "did you mean". */
4
+ const indices = (length) => [...Array(length).keys()];
5
+ const distance = (a, b) => indices(b.length)
6
+ .reduce((row, j) => indices(a.length).reduce((next, i) => [
7
+ ...next,
8
+ Math.min((row[i + 1] ?? 0) + 1, (next[i] ?? 0) + 1, (row[i] ?? 0) + (a.charAt(i) === b.charAt(j) ? 0 : 1)),
9
+ ], [j + 1]), indices(a.length + 1))
10
+ .at(-1) ?? 0;
11
+ const suggestion = (key, known) => {
12
+ const closest = known
13
+ .map((name) => ({ name, cost: distance(key, name) }))
14
+ .toSorted((x, y) => x.cost - y.cost)
15
+ .at(0);
16
+ return closest !== undefined && closest.cost <= Math.max(2, Math.floor(key.length / 4)) ? ` — did you mean '${closest.name}'?` : "";
17
+ };
18
+ /** The first problem Zod found, said for the file it is in. */
19
+ const firstIssue = (file, error, known, unknownKey) => {
20
+ const issue = error.issues.at(0);
21
+ if (issue === undefined)
22
+ return `${file} is invalid`;
23
+ if (issue.code === "unrecognized_keys") {
24
+ const key = issue.keys.at(0) ?? "";
25
+ const where = issue.path.length === 0 ? "" : ` (in ${issue.path.join(".")})`;
26
+ return `${file}: '${key}' ${unknownKey}${where}${issue.path.length === 0 ? suggestion(key, known) : ""}`;
27
+ }
28
+ return `${file}: ${issue.path.length === 0 ? "" : `${issue.path.join(".")}: `}${issue.message}`;
29
+ };
30
+ const secretNames = settings.filter((each) => each.secret).map((each) => each.name);
31
+ /**
32
+ * A settings file (vars.yml, defaults.yml) against its schema: a secret in it
33
+ * is refused first, then unknown keys and wrong types. Ansible's own variables
34
+ * are always allowed.
35
+ */
36
+ export const checkSettings = (file, values, schema, known) => {
37
+ const leaked = Object.keys(values).find((key) => secretNames.includes(key));
38
+ if (leaked !== undefined)
39
+ return err(`${file}: '${leaked}' is a secret — it goes in secrets.yml`);
40
+ const own = Object.fromEntries(Object.entries(values).filter(([key]) => !isAnsibleVariable(key)));
41
+ const parsed = schema.safeParse(own);
42
+ return parsed.success ? ok(values) : err(firstIssue(file, parsed.error, known, "is not a setting"));
43
+ };
44
+ /** The names a kind's vars.yml may set. */
45
+ export const knownSettings = (kind) => settings.filter((each) => !each.secret && (kind === undefined || each.kinds.includes(kind))).map((each) => each.name);
46
+ /** A YAML document against a schema, for files whose keys are not settings (server.yml, app.yml). */
47
+ export const checkShape = (file, values, schema) => {
48
+ const parsed = schema.safeParse(values);
49
+ return parsed.success ? ok(values) : err(firstIssue(file, parsed.error, [], "is not a key it knows"));
50
+ };
@@ -8,6 +8,13 @@
8
8
  # Rootless: no process sandbox, and the rootless worker cannot use overlayfs
9
9
  # on every kernel, so let BuildKit pick a snapshotter it can actually run.
10
10
  max-parallelism = 2
11
+ # The cache shares the server's disk with everything else; unbounded, it
12
+ # filled alaz (27 GB). Collected down to maxUsedSpace, sooner when the disk
13
+ # has less than minFreeSpace left; never below reservedSpace.
14
+ gc = true
15
+ reservedSpace = "2GB"
16
+ maxUsedSpace = "10GB"
17
+ minFreeSpace = "20%"
11
18
 
12
19
  [registry."registry:5000"]
13
20
  http = true
@@ -6,7 +6,7 @@
6
6
 
7
7
  services:
8
8
  registry:
9
- image: registry:2.8.3
9
+ image: registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
10
10
  restart: unless-stopped
11
11
  environment:
12
12
  # registry-gc.sh deletes manifests nothing references any more.
@@ -30,7 +30,7 @@ services:
30
30
  # container, not the box.
31
31
  # ---------------------------------------------------------------------------
32
32
  buildkitd:
33
- image: moby/buildkit:v0.34.0-rootless
33
+ image: moby/buildkit:v0.34.0-rootless@sha256:65eca3c01cd1180c8f64565c4e3c5983af5145818b0e6fa863385d099eb73855
34
34
  restart: unless-stopped
35
35
  # Flags go in the command: this image does not read BUILDKITD_FLAGS (that
36
36
  # is a buildx convention), and without them it listens on a unix socket
@@ -5,13 +5,15 @@
5
5
 
6
6
  services:
7
7
  traefik:
8
- image: traefik:v3.7.14
8
+ image: traefik:v3.7.14@sha256:575fa15b135078fe5e50aa847987d96dbddd7b093c172429618404df73f3fa7c
9
9
  restart: unless-stopped
10
10
  ports:
11
11
  - "80:80"
12
12
  - "443:443"
13
+ # No Docker socket: containers are read through socket-proxy. A mount,
14
+ # even :ro, would be the whole Docker API, i.e. root on the host, for the
15
+ # one service the internet talks to.
13
16
  volumes:
14
- - /var/run/docker.sock:/var/run/docker.sock:ro
15
17
  - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
16
18
  - ./traefik/dynamic:/etc/traefik/dynamic:ro
17
19
  - ./traefik/certs:/certs:ro
@@ -19,6 +21,7 @@ services:
19
21
  networks:
20
22
  - public
21
23
  - internal
24
+ - socket
22
25
  labels:
23
26
  # The dashboard used to ride on an unauthenticated published :8080.
24
27
  # It speaks no auth of its own, which is exactly what forwardAuth is for.
@@ -29,6 +32,27 @@ services:
29
32
  - "traefik.http.routers.dashboard.service=api@internal"
30
33
  - "traefik.http.routers.dashboard.middlewares=authelia@file,security-headers@file"
31
34
 
35
+ # ---------------------------------------------------------------------------
36
+ # Traefik's view of Docker: the socket behind an allow-list. Read-only
37
+ # (POST off), and only containers, events, version and ping — what the
38
+ # Docker provider asks for. Reachable on platform-socket only, a network
39
+ # with no route out that nothing but Traefik joins.
40
+ # ---------------------------------------------------------------------------
41
+ socket-proxy:
42
+ image: tecnativa/docker-socket-proxy:v0.5.0@sha256:1f5038b54f06c3e18422902cf00ba21803d1c97805aae032e5e6673d532d3459
43
+ restart: unless-stopped
44
+ environment:
45
+ - CONTAINERS=1
46
+ - EVENTS=1
47
+ - VERSION=1
48
+ - PING=1
49
+ - POST=0
50
+ volumes:
51
+ - /var/run/docker.sock:/var/run/docker.sock:ro
52
+ mem_limit: 64m
53
+ networks:
54
+ - socket
55
+
32
56
  # ---------------------------------------------------------------------------
33
57
  # Authelia — single sign-on for services that sit behind Traefik.
34
58
  #
@@ -36,7 +60,7 @@ services:
36
60
  # infinite redirect. The portal has to stay reachable to authenticate anyone.
37
61
  # ---------------------------------------------------------------------------
38
62
  authelia:
39
- image: authelia/authelia:4.39.28
63
+ image: authelia/authelia:4.39.28@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54
40
64
  restart: unless-stopped
41
65
  environment:
42
66
  - X_AUTHELIA_CONFIG=/config/configuration.yml
@@ -75,6 +99,10 @@ networks:
75
99
  name: platform-public
76
100
  internal:
77
101
  name: platform-internal
102
+ # Traefik and socket-proxy only; internal, so the proxy has no way out.
103
+ socket:
104
+ name: platform-socket
105
+ internal: true
78
106
 
79
107
  volumes:
80
108
  acme-data:
@@ -35,7 +35,8 @@ certificatesResolvers:
35
35
 
36
36
  providers:
37
37
  docker:
38
- endpoint: "unix:///var/run/docker.sock"
38
+ # The read-only socket proxy (compose: socket-proxy), not the socket.
39
+ endpoint: "tcp://socket-proxy:2375"
39
40
  exposedByDefault: false
40
41
  network: platform-public
41
42
  file:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunuseon/kayra",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "A self-hosted app platform and the CLI that runs it: Traefik, Authelia, Jenkins, a registry and rootless BuildKit per server, set up with OpenTofu and Ansible.",
5
5
  "license": "MIT",
6
6
  "author": "Yunus Öztürk",
@@ -23,7 +23,8 @@
23
23
  "terraform/",
24
24
  "scripts/",
25
25
  "cloud-init.yml.tpl",
26
- "hosts.conf"
26
+ "hosts.conf",
27
+ "schema/"
27
28
  ],
28
29
  "publishConfig": {
29
30
  "access": "public",
@@ -35,12 +36,14 @@
35
36
  "prepack": "npm run build",
36
37
  "typecheck": "tsc -p cli --noEmit",
37
38
  "lint": "cd cli && eslint --flag unstable_native_nodejs_ts_config .",
38
- "test": "node --test 'cli/test/**/*.test.ts'"
39
+ "test": "node --test 'cli/test/**/*.test.ts'",
40
+ "schemas": "node cli/scripts/schemas.ts"
39
41
  },
40
42
  "dependencies": {
41
43
  "@optique/core": "1.3.3",
42
44
  "@optique/run": "1.3.3",
43
- "yaml": "2.9.1"
45
+ "yaml": "2.9.1",
46
+ "zod": "4.6.5"
44
47
  },
45
48
  "devDependencies": {
46
49
  "@eslint/js": "10.0.1",
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "propertyNames": {
5
+ "type": "string"
6
+ },
7
+ "additionalProperties": {
8
+ "type": [
9
+ "string",
10
+ "number",
11
+ "boolean"
12
+ ]
13
+ },
14
+ "title": "apps/<app>.yml and apps/<app>.secrets.yml"
15
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "name": {
6
+ "type": "string",
7
+ "description": "The app's name, as in the server.yml entry."
8
+ },
9
+ "compose": {
10
+ "type": "array",
11
+ "items": {
12
+ "type": "string"
13
+ },
14
+ "description": "Compose files, relative to the app root, in order."
15
+ },
16
+ "subdomains": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "string"
20
+ },
21
+ "description": "Hostnames the app claims: <sub>.<domain>."
22
+ },
23
+ "secrets": {
24
+ "description": "Secret keys; their values come from the fleet's apps/<app>.secrets.yml.",
25
+ "type": "array",
26
+ "items": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "config": {
31
+ "description": "Config keys; their values come from the fleet's apps/<app>.yml.",
32
+ "type": "array",
33
+ "items": {
34
+ "type": "string"
35
+ }
36
+ }
37
+ },
38
+ "required": [
39
+ "name",
40
+ "compose",
41
+ "subdomains"
42
+ ],
43
+ "additionalProperties": false,
44
+ "title": "app.yml"
45
+ }