@yunuseon/kayra 0.6.0 → 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.
- package/ansible/roles/docker/tasks/main.yml +3 -2
- package/ansible/roles/platform/tasks/main.yml +12 -0
- package/cli/dist/cli.js +2 -2
- package/cli/dist/commands/server.js +22 -4
- package/cli/dist/main.js +2 -2
- package/compose/buildkit/buildkitd.toml +7 -0
- package/compose/docker-compose.ci.yml +2 -2
- package/compose/docker-compose.yml +3 -3
- package/package.json +1 -1
- package/scripts/local/require-latest-state.sh +36 -0
|
@@ -33,10 +33,11 @@
|
|
|
33
33
|
become: true
|
|
34
34
|
|
|
35
35
|
# Unrotated json-file logs grow until the disk is full. `local` is compressed
|
|
36
|
-
# and rotated; it applies to containers created after the change.
|
|
36
|
+
# and rotated; it applies to containers created after the change. The build
|
|
37
|
+
# cache of `docker build` (a CI runner's, say) grows the same way.
|
|
37
38
|
- name: Configure the Docker daemon
|
|
38
39
|
ansible.builtin.copy:
|
|
39
|
-
content: "{{ {'log-driver': 'local', 'log-opts': {'max-size': '10m', 'max-file': '3'}} | to_nice_json }}\n"
|
|
40
|
+
content: "{{ {'log-driver': 'local', 'log-opts': {'max-size': '10m', 'max-file': '3'}, 'builder': {'gc': {'enabled': true, 'defaultKeepStorage': '5GB'}}} | to_nice_json }}\n"
|
|
40
41
|
dest: /etc/docker/daemon.json
|
|
41
42
|
owner: root
|
|
42
43
|
group: root
|
|
@@ -231,10 +231,14 @@
|
|
|
231
231
|
# Start. `up` recreates any container whose interpolated config changed, so
|
|
232
232
|
# a changed .env needs nothing extra.
|
|
233
233
|
# ---------------------------------------------------------------------------
|
|
234
|
+
# Pinned by digest, so an image that is there is the right one: pulling only
|
|
235
|
+
# what is missing makes a run without changes report none (and `kayra plan`
|
|
236
|
+
# exact, where policy always is changed in check mode by definition).
|
|
234
237
|
- name: Pull platform images
|
|
235
238
|
community.docker.docker_compose_v2_pull:
|
|
236
239
|
project_src: "{{ platform_dir }}"
|
|
237
240
|
ignore_buildable: true
|
|
241
|
+
policy: missing
|
|
238
242
|
|
|
239
243
|
- name: Start platform
|
|
240
244
|
community.docker.docker_compose_v2:
|
|
@@ -249,6 +253,14 @@
|
|
|
249
253
|
when: traefik_static_config is changed
|
|
250
254
|
changed_when: true
|
|
251
255
|
|
|
256
|
+
# buildkitd reads its config once at startup, like Traefik.
|
|
257
|
+
- name: Restart BuildKit when its config changed
|
|
258
|
+
ansible.builtin.command: docker compose restart buildkitd
|
|
259
|
+
args:
|
|
260
|
+
chdir: "{{ platform_dir }}"
|
|
261
|
+
when: platform_files.results | selectattr('item', 'eq', 'buildkit/') | selectattr('changed') | list | length > 0
|
|
262
|
+
changed_when: true
|
|
263
|
+
|
|
252
264
|
# Inside the controller, not through Traefik: jenkins.<domain> sits behind
|
|
253
265
|
# Authelia, which would answer every call here with a login redirect.
|
|
254
266
|
- name: Wait for Jenkins
|
package/cli/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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
|
-
import { choice } from "@optique/core/valueparser";
|
|
4
|
+
import { choice, string } from "@optique/core/valueparser";
|
|
5
5
|
import { appNameParser, domainParser, subdomainParser } from "./brand.js";
|
|
6
6
|
/** The arguments kayra takes; server names are the fleet's, so they complete and are checked while parsing. */
|
|
7
7
|
export const parser = (fleet) => {
|
|
@@ -12,7 +12,7 @@ export const parser = (fleet) => {
|
|
|
12
12
|
dryRun: option("--dry-run", { description: message `Print the plan instead of running it.` }),
|
|
13
13
|
command: or(
|
|
14
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).`),
|
|
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).`), command("ssh", object({ name: constant("ssh"), server, remote: multiple(argument(string({ metavar: "COMMAND" }))) }), { brief: message `SSH into the server, or run a command there (after --).` }), 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
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
18
|
// On the whole fleet
|
|
@@ -26,6 +26,13 @@ const fromMain = (engine, server) => ({
|
|
|
26
26
|
probe: { kind: "exits-zero", argv: ["bash", script(engine, "require-main.sh"), server.name], env: fleetEnv(engine) },
|
|
27
27
|
onFailure: "stop",
|
|
28
28
|
});
|
|
29
|
+
/** OpenTofu only on the newest committed state: none waiting on an unmerged branch. */
|
|
30
|
+
const latestState = (engine, server) => ({
|
|
31
|
+
kind: "check",
|
|
32
|
+
label: `${server.name}'s OpenTofu state is the newest`,
|
|
33
|
+
probe: { kind: "exits-zero", argv: ["bash", script(engine, "require-latest-state.sh"), within(server.dir, "terraform")] },
|
|
34
|
+
onFailure: "stop",
|
|
35
|
+
});
|
|
29
36
|
/** Before Ansible's UNREACHABLE: why a home machine cannot be reached. */
|
|
30
37
|
const reachable = (engine, server) => server.target === "home"
|
|
31
38
|
? [
|
|
@@ -59,7 +66,11 @@ export const provision = (engine, server) => ok([
|
|
|
59
66
|
fromMain(engine, server),
|
|
60
67
|
...(server.vm ? [run(["bash", script(engine, "home-image.sh"), server.name], fleetEnv(engine))] : []),
|
|
61
68
|
...(server.terraform
|
|
62
|
-
? [
|
|
69
|
+
? [
|
|
70
|
+
latestState(engine, server),
|
|
71
|
+
run(["tofu", terraform(server), "init", "-input=false"]),
|
|
72
|
+
run(["tofu", terraform(server), "apply", "-auto-approve"]),
|
|
73
|
+
]
|
|
63
74
|
: []),
|
|
64
75
|
]);
|
|
65
76
|
/** The playbook for the server's kind, as setup runs it; plan adds check mode. */
|
|
@@ -99,7 +110,11 @@ export const setup = (engine, server) => ok([
|
|
|
99
110
|
export const preview = (engine, server) => ok([
|
|
100
111
|
...reachable(engine, server),
|
|
101
112
|
...(server.terraform
|
|
102
|
-
? [
|
|
113
|
+
? [
|
|
114
|
+
latestState(engine, server),
|
|
115
|
+
run(["tofu", terraform(server), "init", "-input=false"]),
|
|
116
|
+
run(["tofu", terraform(server), "plan", "-input=false", "-lock=false"]),
|
|
117
|
+
]
|
|
103
118
|
: []),
|
|
104
119
|
collections(engine, vaultEnv(server)),
|
|
105
120
|
hostPlaybook(engine, server, ["--check", "--diff"]),
|
|
@@ -137,8 +152,9 @@ export const hosts = (engine, server) => ok(server.target === "multipass"
|
|
|
137
152
|
const sequence = (plans) => map(all(plans), (each) => each.flat());
|
|
138
153
|
export const deploy = (engine, server) => sequence([setup(engine, server), apps(engine, server)]);
|
|
139
154
|
export const up = (engine, server) => sequence([provision(engine, server), deploy(engine, server), hosts(engine, server)]);
|
|
140
|
-
export const down = (server) => server.terraform
|
|
155
|
+
export const down = (engine, server) => server.terraform
|
|
141
156
|
? ok([
|
|
157
|
+
latestState(engine, server),
|
|
142
158
|
...(server.target === "hetzner"
|
|
143
159
|
? [
|
|
144
160
|
{
|
|
@@ -212,7 +228,8 @@ export const backupTest = (engine, server) => {
|
|
|
212
228
|
]),
|
|
213
229
|
]);
|
|
214
230
|
};
|
|
215
|
-
|
|
231
|
+
/** An interactive shell, or the remote command as ssh runs it (the remote shell reads it). */
|
|
232
|
+
export const ssh = (server, remote) => server.inventory === undefined
|
|
216
233
|
? err(`no inventory for '${server.name}' — provision it first.`)
|
|
217
234
|
: ok([
|
|
218
235
|
run([
|
|
@@ -226,6 +243,7 @@ export const ssh = (server) => server.inventory === undefined
|
|
|
226
243
|
"-o",
|
|
227
244
|
"UserKnownHostsFile=/dev/null",
|
|
228
245
|
`${server.inventory.user}@${server.inventory.host}`,
|
|
246
|
+
...remote,
|
|
229
247
|
]),
|
|
230
248
|
]);
|
|
231
249
|
/** mkcert certs for a domain and its subdomains, for local dev and multipass. */
|
package/cli/dist/main.js
CHANGED
|
@@ -40,9 +40,9 @@ const onServer = (on, target, command) => {
|
|
|
40
40
|
case "hosts":
|
|
41
41
|
return server.hosts(on, target);
|
|
42
42
|
case "ssh":
|
|
43
|
-
return server.ssh(target);
|
|
43
|
+
return server.ssh(target, command.remote);
|
|
44
44
|
case "down":
|
|
45
|
-
return server.down(target);
|
|
45
|
+
return server.down(on, target);
|
|
46
46
|
case "adopt":
|
|
47
47
|
return server.adopt(on, target);
|
|
48
48
|
case "backup test":
|
|
@@ -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,7 +5,7 @@
|
|
|
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"
|
|
@@ -39,7 +39,7 @@ services:
|
|
|
39
39
|
# with no route out that nothing but Traefik joins.
|
|
40
40
|
# ---------------------------------------------------------------------------
|
|
41
41
|
socket-proxy:
|
|
42
|
-
image: tecnativa/docker-socket-proxy:v0.5.0
|
|
42
|
+
image: tecnativa/docker-socket-proxy:v0.5.0@sha256:1f5038b54f06c3e18422902cf00ba21803d1c97805aae032e5e6673d532d3459
|
|
43
43
|
restart: unless-stopped
|
|
44
44
|
environment:
|
|
45
45
|
- CONTAINERS=1
|
|
@@ -60,7 +60,7 @@ services:
|
|
|
60
60
|
# infinite redirect. The portal has to stay reachable to authenticate anyone.
|
|
61
61
|
# ---------------------------------------------------------------------------
|
|
62
62
|
authelia:
|
|
63
|
-
image: authelia/authelia:4.39.28
|
|
63
|
+
image: authelia/authelia:4.39.28@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54
|
|
64
64
|
restart: unless-stopped
|
|
65
65
|
environment:
|
|
66
66
|
- X_AUTHELIA_CONFIG=/config/configuration.yml
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunuseon/kayra",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Refuse to run OpenTofu on a state that an unmerged branch has moved past.
|
|
3
|
+
#
|
|
4
|
+
# The fleet commits its (encrypted) states, each change through a PR. Until
|
|
5
|
+
# that PR is merged, main holds the state from before: an apply from there
|
|
6
|
+
# would work from a stale state. The envelope keeps lineage and serial in
|
|
7
|
+
# plaintext, so a newer state is one with the same lineage and a higher
|
|
8
|
+
# serial on any branch of origin.
|
|
9
|
+
#
|
|
10
|
+
# Usage: require-latest-state.sh <terraform-dir>
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
dir="${1:?Usage: require-latest-state.sh <terraform-dir>}"
|
|
14
|
+
state="${dir}/terraform.tfstate"
|
|
15
|
+
[ -f "$state" ] || exit 0
|
|
16
|
+
cd "$dir"
|
|
17
|
+
git ls-files --error-unmatch terraform.tfstate >/dev/null 2>&1 || exit 0
|
|
18
|
+
|
|
19
|
+
path="$(git rev-parse --show-prefix)terraform.tfstate"
|
|
20
|
+
lineage="$(jq -r .lineage terraform.tfstate)"
|
|
21
|
+
serial="$(jq -r .serial terraform.tfstate)"
|
|
22
|
+
git fetch -q --prune origin || { echo "ERROR: could not fetch origin to compare ${path} against." >&2; exit 1; }
|
|
23
|
+
|
|
24
|
+
newer=()
|
|
25
|
+
while read -r ref; do
|
|
26
|
+
theirs="$(git show "${ref}:${path}" 2>/dev/null)" || continue
|
|
27
|
+
[ "$(jq -r .lineage <<<"$theirs")" = "$lineage" ] || continue
|
|
28
|
+
their_serial="$(jq -r .serial <<<"$theirs")"
|
|
29
|
+
[ "$their_serial" -gt "$serial" ] && newer+=("${ref#refs/remotes/} (serial ${their_serial})")
|
|
30
|
+
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin | grep -v '/HEAD$')
|
|
31
|
+
|
|
32
|
+
[ "${#newer[@]}" -eq 0 ] && exit 0
|
|
33
|
+
echo "ERROR: ${path} here is serial ${serial}; a newer one waits on:" >&2
|
|
34
|
+
printf ' %s\n' "${newer[@]}" >&2
|
|
35
|
+
echo " Merge that PR, then: git pull" >&2
|
|
36
|
+
exit 1
|