@yunuseon/kayra 0.6.0 → 0.7.1

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.
@@ -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).`), 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 })), {
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
- ? [run(["tofu", terraform(server), "init", "-input=false"]), run(["tofu", terraform(server), "apply", "-auto-approve"])]
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
- ? [run(["tofu", terraform(server), "init", "-input=false"]), run(["tofu", terraform(server), "plan", "-input=false", "-lock=false"])]
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
- export const ssh = (server) => server.inventory === undefined
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.6.0",
3
+ "version": "0.7.1",
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
@@ -33,6 +33,6 @@ others=()
33
33
  for dir in servers/*/; do
34
34
  [ "$dir" = "servers/${name}/" ] || others+=(":(exclude)${dir}")
35
35
  done
36
- dirty="$(git status --porcelain --untracked-files=no -- . "${others[@]}")"
36
+ dirty="$(git status --porcelain --untracked-files=no -- . ${others[@]+"${others[@]}"})"
37
37
  [ -z "$dirty" ] || fail "Uncommitted changes would be deployed; commit them through a PR first:
38
38
  ${dirty}"