@yunuseon/kayra 0.5.1 → 0.6.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.
@@ -34,6 +34,8 @@
34
34
  register: ufw_status
35
35
  changed_when: false
36
36
  failed_when: false
37
+ # Only reads, so it runs in check mode too (kayra plan).
38
+ check_mode: false
37
39
 
38
40
  - name: Disable ufw
39
41
  ansible.builtin.command: ufw disable
@@ -31,6 +31,8 @@
31
31
  ansible.builtin.command: timedatectl show --property=Timezone --value
32
32
  register: current_timezone
33
33
  changed_when: false
34
+ # Only reads, so it runs in check mode too (kayra plan).
35
+ check_mode: false
34
36
 
35
37
  - name: Set timezone
36
38
  ansible.builtin.command: timedatectl set-timezone {{ timezone | default('Europe/Berlin') }}
@@ -2,7 +2,7 @@
2
2
  # =============================================================================
3
3
  # forgejo-config role — what a Forgejo holds beyond its own config file, as
4
4
  # code: users and their SSH keys, organizations, Actions runners, repos (created empty
5
- # where missing) and their settings (issues, pull requests, actions), Actions
5
+ # where missing, or as a pull mirror of another forge) and their settings (issues, pull requests, actions), Actions
6
6
  # secrets, collaborators, webhooks and push mirrors, from
7
7
  # forgejo_config in the server's vars.yml. Adds what is missing through the
8
8
  # API, adopts what already matches, never deletes. Talks to Forgejo on the
@@ -17,6 +17,8 @@
17
17
  until: forgejo_version.status == 200
18
18
  retries: 30
19
19
  delay: 2
20
+ # Only reads, so it runs in check mode too (kayra plan).
21
+ check_mode: false
20
22
 
21
23
  - name: Look up users
22
24
  ansible.builtin.uri:
@@ -27,6 +29,8 @@
27
29
  loop: "{{ forgejo_config.users | default([]) }}"
28
30
  loop_control: {label: "{{ item.name }}"}
29
31
  no_log: true
32
+ # Only reads, so it runs in check mode too (kayra plan).
33
+ check_mode: false
30
34
 
31
35
  # Random password, never stored: these users work with tokens.
32
36
  - name: Create missing users
@@ -55,6 +59,8 @@
55
59
  loop: "{{ forgejo_config.orgs | default([]) }}"
56
60
  loop_control: {label: "{{ item.name }}"}
57
61
  no_log: true
62
+ # Only reads, so it runs in check mode too (kayra plan).
63
+ check_mode: false
58
64
 
59
65
  # An organization owns repos and packages alike, and its visibility is
60
66
  # theirs: a private one keeps what it owns, images included, from the world.
@@ -93,6 +99,8 @@
93
99
  loop: "{{ forgejo_config.ssh_keys | default([]) }}"
94
100
  loop_control: {label: "{{ item.user }} {{ item.title }}"}
95
101
  no_log: true
102
+ # Only reads, so it runs in check mode too (kayra plan).
103
+ check_mode: false
96
104
 
97
105
  # Matched by type and key, whatever title or comment either side carries.
98
106
  - name: Add missing SSH keys
@@ -140,6 +148,8 @@
140
148
  loop: "{{ forgejo_config.repos }}"
141
149
  loop_control: {label: "{{ item.name }}"}
142
150
  no_log: true
151
+ # Only reads, so it runs in check mode too (kayra plan).
152
+ check_mode: false
143
153
 
144
154
  # Empty, and private from the start where its settings say so (the settings
145
155
  # are applied below too, but a public repo, even for a moment, is published);
@@ -157,7 +167,30 @@
157
167
  private: "{{ item.settings.private | default(false) }}"
158
168
  status_code: [201]
159
169
  changed_when: true
160
- loop: "{{ forgejo_repos.results | selectattr('status', 'eq', 404) | map(attribute='item') | list }}"
170
+ loop: "{{ forgejo_repos.results | selectattr('status', 'eq', 404) | map(attribute='item') | rejectattr('pull_mirror', 'defined') | list }}"
171
+ loop_control: {label: "{{ item.name }}"}
172
+ no_log: true
173
+
174
+ # A repo whose home is elsewhere (pull_mirror: its clone URL): Forgejo clones
175
+ # it once and fetches it again on mirror_interval (a setting, as
176
+ # mirror_interval: 10m0s). An existing repo is adopted as it is.
177
+ - name: Create missing pull mirrors
178
+ ansible.builtin.uri:
179
+ url: "{{ forgejo_config.url }}/api/v1/repos/migrate"
180
+ method: POST
181
+ headers: {Authorization: "token {{ forgejo_admin_token }}"}
182
+ body_format: json
183
+ body:
184
+ clone_addr: "{{ item.pull_mirror }}"
185
+ repo_owner: "{{ item.name.split('/')[0] }}"
186
+ repo_name: "{{ item.name.split('/')[1] }}"
187
+ mirror: true
188
+ private: "{{ item.settings.private | default(false) }}"
189
+ service: git
190
+ status_code: [201]
191
+ timeout: 300
192
+ changed_when: true
193
+ loop: "{{ forgejo_repos.results | selectattr('status', 'eq', 404) | map(attribute='item') | selectattr('pull_mirror', 'defined') | list }}"
161
194
  loop_control: {label: "{{ item.name }}"}
162
195
  no_log: true
163
196
 
@@ -170,6 +203,8 @@
170
203
  loop: "{{ forgejo_config.repos | subelements('collaborators', skip_missing=True) }}"
171
204
  loop_control: {label: "{{ item.0.name }} {{ item.1.user }}"}
172
205
  no_log: true
206
+ # Only reads, so it runs in check mode too (kayra plan).
207
+ check_mode: false
173
208
 
174
209
  - name: Grant collaborators their permission
175
210
  ansible.builtin.uri:
@@ -222,6 +257,8 @@
222
257
  loop: "{{ forgejo_config.repos | product(['hooks', 'push_mirrors']) | list }}"
223
258
  loop_control: {label: "{{ item.0.name }} {{ item.1 }}"}
224
259
  no_log: true
260
+ # Only reads, so it runs in check mode too (kayra plan).
261
+ check_mode: false
225
262
 
226
263
  # A push to any branch calls the URL (with notifyCommit: Jenkins polls).
227
264
  - name: Add missing webhooks
@@ -303,6 +340,8 @@
303
340
  loop: "{{ service_token_files.results }}"
304
341
  loop_control: {label: "{{ item.item.secret }}"}
305
342
  no_log: true
343
+ # Only reads, so it runs in check mode too (kayra plan).
344
+ check_mode: false
306
345
 
307
346
  - name: Mint service tokens
308
347
  ansible.builtin.command:
@@ -106,6 +106,8 @@
106
106
  group: deploy
107
107
  mode: "0600"
108
108
  become: true
109
+ # It holds secrets: --diff (kayra plan) says that it would change, never how.
110
+ diff: false
109
111
 
110
112
  - name: Render Authelia secrets
111
113
  ansible.builtin.copy:
@@ -213,6 +215,8 @@
213
215
  group: deploy
214
216
  mode: "0600"
215
217
  become: true
218
+ # It holds secrets: --diff (kayra plan) says that it would change, never how.
219
+ diff: false
216
220
 
217
221
  # The registry used to have a public, basic-auth router. Nothing outside the
218
222
  # server pushes or pulls, so it is gone; the copy above never deletes files.
@@ -256,6 +260,8 @@
256
260
  delay: 10
257
261
  until: jenkins_health.rc == 0
258
262
  changed_when: false
263
+ # Only reads, so it runs in check mode too (kayra plan).
264
+ check_mode: false
259
265
 
260
266
  # casc/ is a bind mount, so a changed 00-platform.yaml only takes effect on
261
267
  # a reload; so do new tokens, read from files by ${readFile:…}.
package/cli/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { command, constant, multiple, object, option, or } from "@optique/core";
1
+ import { command, constant, multiple, object, option, optional, or } from "@optique/core";
2
2
  import { message } from "@optique/core/message";
3
3
  import { argument } from "@optique/core/primitives";
4
4
  import { choice } from "@optique/core/valueparser";
@@ -10,12 +10,18 @@ export const parser = (fleet) => {
10
10
  const onServer = (name, brief) => command(name, object({ name: constant(name), server }), { brief });
11
11
  return object({
12
12
  dryRun: option("--dry-run", { description: message `Print the plan instead of running it.` }),
13
- command: or(command("status", object({ name: constant("status") }), {
14
- brief: message `Every server and whether a machine exists for it.`,
15
- }), onServer("up", message `Provision, set up everything, point /etc/hosts at it.`), onServer("provision", message `Create or update the machine (target home: build the image to import).`), onServer("deploy", message `Host setup and every app, no provisioning.`), onServer("setup", message `The host layer only: the playbook for the server's kind.`), onServer("apps", message `Make the server's apps match server.yml: register each, remove the rest (data kept).`), onServer("hosts", message `Point the domain at a multipass VM in /etc/hosts (sudo).`), onServer("ssh", message `SSH into the server.`), onServer("down", message `Destroy the machine.`), onServer("adopt", message `Once, for a server we did not create: add the deploy user as root.`), command("backup", command("test", object({ name: constant("backup test"), server })), {
13
+ command: or(
14
+ // On one server
15
+ or(onServer("up", message `Provision, set up everything, point /etc/hosts at it.`), onServer("provision", message `Create or update the machine (target home: build the image to import).`), onServer("deploy", message `Host setup and every app, no provisioning.`), onServer("setup", message `The host layer only: the playbook for the server's kind.`), onServer("plan", message `What provision and setup would change, changing nothing (OpenTofu plan, Ansible check mode).`), onServer("apps", message `Make the server's apps match server.yml: register each, remove the rest (data kept).`), onServer("hosts", message `Point the domain at a multipass VM in /etc/hosts (sudo).`), onServer("ssh", message `SSH into the server.`), onServer("down", message `Destroy the machine.`), onServer("adopt", message `Once, for a server we did not create: add the deploy user as root.`), command("backup", command("test", object({ name: constant("backup test"), server })), {
16
16
  brief: message `Restore the latest backup to a scratch dir, check it, delete it.`,
17
- }), command("app", command("purge", object({ name: constant("app purge"), server, app: argument(appNameParser) })), { brief: message `Delete a removed app's volumes: its data, irreversibly.` }), command("certs", object({ name: constant("certs"), domain: argument(domainParser), subdomains: multiple(argument(subdomainParser)) }), { brief: message `mkcert certs for a domain and its subdomains (local dev, multipass).` }), command("doctor", object({ name: constant("doctor") }), {
17
+ }), command("app", command("purge", object({ name: constant("app purge"), server, app: argument(appNameParser) })), { brief: message `Delete a removed app's volumes: its data, irreversibly.` })),
18
+ // On the whole fleet
19
+ or(command("status", object({ name: constant("status") }), {
20
+ brief: message `Every server and whether a machine exists for it.`,
21
+ }), command("certs", object({ name: constant("certs"), domain: argument(domainParser), subdomains: multiple(argument(subdomainParser)) }), { brief: message `mkcert certs for a domain and its subdomains (local dev, multipass).` }), command("validate", object({ name: constant("validate"), server: optional(server) }), {
22
+ brief: message `Check the fleet beyond its schemas: secrets, apps against their contract, stacks, routes.`,
23
+ }), command("doctor", object({ name: constant("doctor") }), {
18
24
  brief: message `Check that this Mac can run every command on every server.`,
19
- })),
25
+ }))),
20
26
  });
21
27
  };
@@ -62,6 +62,24 @@ export const provision = (engine, server) => ok([
62
62
  ? [run(["tofu", terraform(server), "init", "-input=false"]), run(["tofu", terraform(server), "apply", "-auto-approve"])]
63
63
  : []),
64
64
  ]);
65
+ /** The playbook for the server's kind, as setup runs it; plan adds check mode. */
66
+ const hostPlaybook = (engine, server, extra = []) => run([
67
+ "ansible-playbook",
68
+ "-i",
69
+ inventory(server),
70
+ ansiblePath(engine, "playbooks", `${server.kind}.yml`),
71
+ "-e",
72
+ `server=${server.name}`,
73
+ "-e",
74
+ `server_dir=${server.dir}`,
75
+ "-e",
76
+ `domain=${server.domain}`,
77
+ "-e",
78
+ `target=${server.target}`,
79
+ "-e",
80
+ `kayra_version=${engine.version}`,
81
+ ...extra,
82
+ ], ansibleEnv(engine, vaultEnv(server)));
65
83
  /** The host layer: the playbook for the server's kind. */
66
84
  export const setup = (engine, server) => ok([
67
85
  fromMain(engine, server),
@@ -71,22 +89,21 @@ export const setup = (engine, server) => ok([
71
89
  : []),
72
90
  ...reachable(engine, server),
73
91
  collections(engine, vaultEnv(server)),
74
- run([
75
- "ansible-playbook",
76
- "-i",
77
- inventory(server),
78
- ansiblePath(engine, "playbooks", `${server.kind}.yml`),
79
- "-e",
80
- `server=${server.name}`,
81
- "-e",
82
- `server_dir=${server.dir}`,
83
- "-e",
84
- `domain=${server.domain}`,
85
- "-e",
86
- `target=${server.target}`,
87
- "-e",
88
- `kayra_version=${engine.version}`,
89
- ], ansibleEnv(engine, vaultEnv(server))),
92
+ hostPlaybook(engine, server),
93
+ ]);
94
+ /**
95
+ * What provision and setup would change, changing nothing: OpenTofu's plan and
96
+ * the playbook in check mode, with diffs (files holding secrets say only that
97
+ * they would change). From any checkout: it previews a branch against a server.
98
+ */
99
+ export const preview = (engine, server) => ok([
100
+ ...reachable(engine, server),
101
+ ...(server.terraform
102
+ ? [run(["tofu", terraform(server), "init", "-input=false"]), run(["tofu", terraform(server), "plan", "-input=false", "-lock=false"])]
103
+ : []),
104
+ collections(engine, vaultEnv(server)),
105
+ hostPlaybook(engine, server, ["--check", "--diff"]),
106
+ { kind: "note", text: `kayra plan changed nothing on ${server.name}: the changed tasks above are what kayra setup would do.` },
90
107
  ]);
91
108
  /** Register every app in server.yml, then remove the rest (their data kept). */
92
109
  export const apps = (engine, server) => {
@@ -0,0 +1,84 @@
1
+ import { appManifestSchema, secretsSchema, settings } from "../settings.js";
2
+ const problem = (text) => ({ problem: true, text });
3
+ const skipped = (text) => ({ problem: false, text });
4
+ const mapping = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
5
+ const strings = (value) => Array.isArray(value) ? value.filter((each) => typeof each === "string") : [];
6
+ const secretSettingNames = (kind) => settings.filter((each) => each.secret && each.kinds.includes(kind)).map((each) => each.name);
7
+ /** secrets.yml: known secrets typed; anything else must be a helper vars.yml uses. */
8
+ const secretsFindings = (facts) => {
9
+ if (facts.secrets.kind === "locked")
10
+ return [skipped(`secrets.yml not checked: ${facts.secrets.reason}`)];
11
+ const values = facts.secrets.values;
12
+ const typed = secretsSchema(facts.server.kind).safeParse(values);
13
+ const typeProblems = typed.success
14
+ ? []
15
+ : typed.error.issues.slice(0, 1).map((issue) => problem(`secrets.yml: ${issue.path.join(".")}: ${issue.message}`));
16
+ const known = secretSettingNames(facts.server.kind);
17
+ const unused = Object.keys(values)
18
+ .filter((key) => !known.includes(key) && !key.startsWith("ansible_"))
19
+ .filter((key) => !new RegExp(`\\b${key}\\b`).test(facts.varsText))
20
+ .map((key) => problem(`secrets.yml: '${key}' is no secret of a ${facts.server.kind} server, and vars.yml does not use it`));
21
+ return [...typeProblems, ...unused];
22
+ };
23
+ /** compose_stacks.stacks and stacks/ name the same stacks. */
24
+ const stackFindings = (facts) => {
25
+ const listed = strings(mapping(facts.vars["compose_stacks"])["stacks"]);
26
+ return [
27
+ ...listed
28
+ .filter((name) => !facts.stackDirs.includes(name))
29
+ .map((name) => problem(`compose_stacks.stacks: '${name}' has no stacks/${name}/compose.yaml`)),
30
+ ...facts.stackDirs
31
+ .filter((name) => !listed.includes(name))
32
+ .map((name) => problem(`stacks/${name} is not in compose_stacks.stacks, so it never runs`)),
33
+ ];
34
+ };
35
+ /** edge_routes: subdomains and an upstream host:port. */
36
+ const routeFindings = (facts) => {
37
+ const routes = Array.isArray(facts.vars["edge_routes"]) ? facts.vars["edge_routes"] : [];
38
+ return routes.flatMap((route, index) => {
39
+ const fields = mapping(route);
40
+ const upstream = fields["upstream"];
41
+ return [
42
+ ...strings(fields["subdomains"])
43
+ .filter((sub) => !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sub))
44
+ .map((sub) => problem(`edge_routes[${String(index)}]: '${sub}' is not a subdomain`)),
45
+ ...(typeof upstream === "string" && /^[^\s:]+:\d+$/.test(upstream)
46
+ ? []
47
+ : [problem(`edge_routes[${String(index)}]: upstream must be host:port`)]),
48
+ ];
49
+ });
50
+ };
51
+ /** Each app's contract against the fleet's values for it. */
52
+ const appFindings = (facts) => facts.server.apps.flatMap((app) => {
53
+ const manifest = facts.manifests[app.name];
54
+ if (manifest === undefined)
55
+ return [skipped(`app ${app.name} not checked: not cloned yet (kayra apps clones it)`)];
56
+ const parsed = appManifestSchema.safeParse(manifest);
57
+ if (!parsed.success)
58
+ return [problem(`app ${app.name}: its app.yml: ${parsed.error.issues.at(0)?.message ?? "invalid"}`)];
59
+ if (parsed.data.name !== app.name)
60
+ return [problem(`app ${app.name}: its app.yml says name ${parsed.data.name}`)];
61
+ const config = facts.appConfig[app.name] ?? {};
62
+ const missingConfig = (parsed.data.config ?? [])
63
+ .filter((key) => !(key in config))
64
+ .map((key) => problem(`apps/${app.name}.yml: no value for ${key}, which app.yml asks for`));
65
+ const secrets = facts.appSecrets[app.name];
66
+ const missingSecrets = secrets === undefined || secrets.kind === "locked"
67
+ ? (parsed.data.secrets ?? []).length === 0
68
+ ? []
69
+ : [skipped(`apps/${app.name}.secrets.yml not checked: ${secrets?.reason ?? "missing"}`)]
70
+ : (parsed.data.secrets ?? [])
71
+ .filter((key) => !(key in secrets.values))
72
+ .map((key) => problem(`apps/${app.name}.secrets.yml: no value for ${key}, which app.yml asks for`));
73
+ return [...missingConfig, ...missingSecrets];
74
+ });
75
+ /** Everything validate finds for one server, problems first. */
76
+ export const findings = (facts) => [...secretsFindings(facts), ...stackFindings(facts), ...routeFindings(facts), ...appFindings(facts)].toSorted((a, b) => Number(b.problem) - Number(a.problem));
77
+ /** One server's report: a line saying whether it holds, then each finding. */
78
+ export const report = (name, found) => {
79
+ const problems = found.filter((each) => each.problem);
80
+ return [
81
+ `${problems.length === 0 ? "ok " : "FAIL"} ${name}`,
82
+ ...found.map((each) => ` ${each.problem ? "✗" : "·"} ${each.text}`),
83
+ ];
84
+ };
package/cli/dist/fleet.js CHANGED
@@ -1,12 +1,23 @@
1
- import { parse } from "yaml";
1
+ import { parseDocument } from "yaml";
2
2
  import { absolutePath, appName, domain, vaultPasswordFile, } from "./brand.js";
3
3
  import { all, err, map, ok } from "./result.js";
4
- export const serverKinds = ["platform", "edge", "nas"];
4
+ import { serverKinds, serverSchema, varsSchema } from "./settings.js";
5
+ import { checkSettings, checkShape, knownSettings } from "./validate.js";
6
+ export { serverKinds };
5
7
  export const serverTargets = ["multipass", "hetzner", "home"];
6
8
  const mapping = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : undefined;
9
+ /** YAML text as a value; a syntax error is a value too, not an exception. */
10
+ export const yamlOf = (text) => {
11
+ const document = parseDocument(text);
12
+ const problem = document.errors.at(0);
13
+ return problem === undefined ? ok(document.toJS()) : err(`is not valid YAML: ${problem.message.split("\n")[0] ?? ""}`);
14
+ };
7
15
  const fields = (text) => {
8
- const value = parse(text);
9
- if (value === null)
16
+ const parsed = yamlOf(text);
17
+ if (!parsed.ok)
18
+ return parsed;
19
+ const value = parsed.value;
20
+ if (value === null || value === undefined)
10
21
  return ok({});
11
22
  const result = mapping(value);
12
23
  return result === undefined ? err("is not a YAML mapping") : ok(result);
@@ -90,6 +101,9 @@ export const parseServer = (files) => {
90
101
  const apps = all(declaredApps.map(app));
91
102
  if (!apps.ok)
92
103
  return apps;
104
+ const shape = checkShape("server.yml", descriptor.value, serverSchema);
105
+ if (!shape.ok)
106
+ return shape;
93
107
  if (target.value === "home" && files.inventory === undefined)
94
108
  return err("target home needs a hand-written inventory.ini next to it");
95
109
  if (target.value !== "home" && !files.terraform)
@@ -110,6 +124,9 @@ export const parseServer = (files) => {
110
124
  const vars = fields(files.vars);
111
125
  if (!vars.ok)
112
126
  return err(`vars.yml ${vars.error}`);
127
+ const varsChecked = checkSettings("vars.yml", vars.value, varsSchema(kind.value), knownSettings(kind.value));
128
+ if (!varsChecked.ok)
129
+ return varsChecked;
113
130
  const backupDir = mapping(vars.value["restic_backup"])?.["dir"];
114
131
  return ok({
115
132
  name: files.name,
@@ -0,0 +1,57 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { yamlOf } from "./fleet.js";
4
+ import { capture } from "./interpret.js";
5
+ const text = (path) => (existsSync(path) ? readFileSync(path, "utf8") : undefined);
6
+ const mappingOf = (source) => {
7
+ if (source === undefined)
8
+ return {};
9
+ const parsed = yamlOf(source);
10
+ return parsed.ok && typeof parsed.value === "object" && parsed.value !== null && !Array.isArray(parsed.value)
11
+ ? Object.fromEntries(Object.entries(parsed.value))
12
+ : {};
13
+ };
14
+ const vaulted = (source) => source.startsWith("$ANSIBLE_VAULT");
15
+ /** A secrets file's values: plaintext as is, a vault only with the server's password file. */
16
+ const secretsOf = async (server, path) => {
17
+ const source = text(path);
18
+ if (source === undefined)
19
+ return { kind: "locked", reason: "missing" };
20
+ if (!vaulted(source))
21
+ return { kind: "readable", values: mappingOf(source) };
22
+ if (server.vault.kind !== "file" || !existsSync(server.vault.file))
23
+ return { kind: "locked", reason: "vaulted, and no vault password file here" };
24
+ const opened = await capture(["ansible-vault", "view", "--vault-password-file", server.vault.file, path], "capture");
25
+ return opened.code === 0
26
+ ? { kind: "readable", values: mappingOf(opened.stdout) }
27
+ : { kind: "locked", reason: "the vault password does not open it" };
28
+ };
29
+ const appPath = (entry) => {
30
+ const parsed = JSON.parse(entry);
31
+ const path = typeof parsed === "object" && parsed !== null && "path" in parsed ? parsed.path : undefined;
32
+ return typeof path === "string" ? path : ".";
33
+ };
34
+ /** What validate needs of a server, read from the fleet (and its clones under .cache/apps). */
35
+ export const gather = async (root, server) => {
36
+ const varsText = text(join(server.dir, "vars.yml")) ?? "";
37
+ const stacks = join(server.dir, "stacks");
38
+ const appSecrets = await Promise.all(server.apps.map(async (app) => [app.name, await secretsOf(server, join(server.dir, "apps", `${app.name}.secrets.yml`))]));
39
+ return {
40
+ server,
41
+ vars: mappingOf(varsText),
42
+ varsText,
43
+ secrets: await secretsOf(server, join(server.dir, "secrets.yml")),
44
+ stackDirs: existsSync(stacks)
45
+ ? readdirSync(stacks, { withFileTypes: true })
46
+ .filter((entry) => entry.isDirectory())
47
+ .map((entry) => entry.name)
48
+ .toSorted()
49
+ : [],
50
+ appConfig: Object.fromEntries(server.apps.map((app) => [app.name, mappingOf(text(join(server.dir, "apps", `${app.name}.yml`)))])),
51
+ appSecrets: Object.fromEntries(appSecrets),
52
+ manifests: Object.fromEntries(server.apps.flatMap((app) => {
53
+ const manifest = text(join(root, ".cache", "apps", app.name, appPath(app.entry), "app.yml"));
54
+ return manifest === undefined ? [] : [[app.name, mappingOf(manifest)]];
55
+ })),
56
+ };
57
+ };
package/cli/dist/load.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { fleetRoot, serverDir, serverName, within } from "./brand.js";
4
- import { parseServer } from "./fleet.js";
5
- import { all, err, map, ok } from "./result.js";
4
+ import { parseServer, yamlOf } from "./fleet.js";
5
+ import { all, err, flatMap, map, ok } from "./result.js";
6
+ import { defaultsSchema } from "./settings.js";
7
+ import { checkSettings, knownSettings } from "./validate.js";
6
8
  /** The fleet is the nearest directory, from `from` upwards, with a servers/defaults.yml. */
7
9
  export const findFleetRoot = (from) => {
8
10
  if (existsSync(join(from, "servers", "defaults.yml")))
@@ -42,7 +44,20 @@ const readServer = (root, entry) => {
42
44
  });
43
45
  return server.ok ? server : err(`servers/${entry}/server.yml: ${server.error}`);
44
46
  };
45
- export const loadFleet = (root) => map(all(readdirSync(join(root, "servers"), { withFileTypes: true })
47
+ /** servers/defaults.yml: settings shared by every server, any kind's. */
48
+ const checkDefaults = (root) => {
49
+ const parsed = yamlOf(readFileSync(join(root, "servers", "defaults.yml"), "utf8"));
50
+ if (!parsed.ok)
51
+ return err(`servers/defaults.yml ${parsed.error}`);
52
+ const values = parsed.value;
53
+ if (values === null || values === undefined)
54
+ return ok(true);
55
+ if (typeof values !== "object" || Array.isArray(values))
56
+ return err("servers/defaults.yml is not a YAML mapping");
57
+ return map(checkSettings("servers/defaults.yml", Object.fromEntries(Object.entries(values)), defaultsSchema, knownSettings(undefined)), () => true);
58
+ };
59
+ export const loadFleet = (root) => flatMap(checkDefaults(root), () => fleetOf(root));
60
+ const fleetOf = (root) => map(all(readdirSync(join(root, "servers"), { withFileTypes: true })
46
61
  .filter((entry) => entry.isDirectory() && existsSync(join(root, "servers", entry.name, "server.yml")))
47
62
  .map((entry) => entry.name)
48
63
  .toSorted()
package/cli/dist/main.js CHANGED
@@ -7,6 +7,8 @@ import { parser } from "./cli.js";
7
7
  import { doctor } from "./commands/doctor.js";
8
8
  import * as server from "./commands/server.js";
9
9
  import { status } from "./commands/status.js";
10
+ import { findings, report } from "./commands/validate.js";
11
+ import { gather } from "./gather.js";
10
12
  import { serverNamed } from "./fleet.js";
11
13
  import { interpret } from "./interpret.js";
12
14
  import { findFleetRoot, loadFleet } from "./load.js";
@@ -31,6 +33,8 @@ const onServer = (on, target, command) => {
31
33
  return server.deploy(on, target);
32
34
  case "setup":
33
35
  return server.setup(on, target);
36
+ case "plan":
37
+ return server.preview(on, target);
34
38
  case "apps":
35
39
  return server.apps(on, target);
36
40
  case "hosts":
@@ -81,6 +85,16 @@ else {
81
85
  if (args.command.name === "status") {
82
86
  (await status(fleet.value)).forEach(io.out);
83
87
  }
88
+ else if (args.command.name === "validate") {
89
+ const chosen = args.command.server;
90
+ const servers = fleet.value.servers.filter((each) => chosen === undefined || each.name === chosen);
91
+ const reports = await Promise.all(servers.map(async (each) => findings(await gather(fleet.value.root, each))));
92
+ reports.forEach((found, index) => {
93
+ report(servers[index]?.name ?? "", found).forEach(io.out);
94
+ });
95
+ // eslint-disable-next-line functional/immutable-data -- the exit status; process.exit() could cut piped output short
96
+ process.exitCode = reports.some((found) => found.some((each) => each.problem)) ? 1 : 0;
97
+ }
84
98
  else {
85
99
  const steps = plan(fleet.value, args.command);
86
100
  if (!steps.ok)
@@ -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");