@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.
- package/ansible/playbooks/edge.yml +2 -0
- package/ansible/roles/common/tasks/main.yml +2 -0
- package/ansible/roles/forgejo-config/tasks/main.yml +41 -2
- package/ansible/roles/platform/tasks/main.yml +6 -0
- package/cli/dist/cli.js +12 -6
- package/cli/dist/commands/server.js +33 -16
- package/cli/dist/commands/validate.js +84 -0
- package/cli/dist/fleet.js +21 -4
- package/cli/dist/gather.js +57 -0
- package/cli/dist/load.js +18 -3
- package/cli/dist/main.js +14 -0
- package/cli/dist/schemas.js +53 -0
- package/cli/dist/settings.js +137 -0
- package/cli/dist/validate.js +50 -0
- package/compose/docker-compose.yml +29 -1
- package/compose/traefik/traefik.yml +2 -1
- package/package.json +7 -4
- package/schema/app-values.json +15 -0
- package/schema/app.json +45 -0
- package/schema/defaults.json +534 -0
- package/schema/server.json +71 -0
- package/schema/vars-edge.json +121 -0
- package/schema/vars-nas.json +124 -0
- package/schema/vars-platform.json +333 -0
|
@@ -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(
|
|
14
|
-
|
|
15
|
-
|
|
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.` })
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
9
|
-
if (
|
|
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
|
-
|
|
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("<", "<").replaceAll("{", "{").replaceAll("}", "}");
|
|
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");
|