@yunuseon/kayra 0.8.1 → 0.9.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.
@@ -347,3 +347,12 @@
347
347
  chdir: "{{ platform_dir }}"
348
348
  when: platform_files.results | selectattr('item', 'eq', 'buildkit/') | selectattr('changed') | list | length > 0
349
349
  changed_when: true
350
+
351
+ # Authelia reads its configuration once at startup; the user database it
352
+ # watches itself (authentication_backend.file.watch).
353
+ - name: Restart Authelia when its config changed
354
+ ansible.builtin.command: docker compose restart authelia
355
+ args:
356
+ chdir: "{{ platform_dir }}"
357
+ when: platform_files.results | selectattr('item', 'eq', 'authelia/configuration.yml') | selectattr('changed') | list | length > 0
358
+ changed_when: true
package/cli/dist/brand.js CHANGED
@@ -34,5 +34,6 @@ const minting = (metavar, mint, placeholder) => ({
34
34
  format: (value) => value,
35
35
  });
36
36
  export const appNameParser = minting("APP", appName, "app");
37
+ export const serverNameParser = minting("SERVER", serverName, "server");
37
38
  export const domainParser = minting("DOMAIN", domain, "example.com");
38
39
  export const subdomainParser = minting("SUBDOMAIN", subdomain, "www");
package/cli/dist/cli.js CHANGED
@@ -2,10 +2,12 @@ import { command, constant, multiple, object, option, optional, or } from "@opti
2
2
  import { message } from "@optique/core/message";
3
3
  import { argument } from "@optique/core/primitives";
4
4
  import { choice, string } from "@optique/core/valueparser";
5
- import { appNameParser, domainParser, subdomainParser } from "./brand.js";
5
+ import { appNameParser, domainParser, serverNameParser, 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) => {
8
- const server = argument(choice(fleet.servers.map((each) => each.name), { metavar: "SERVER" }));
8
+ const [first, ...rest] = fleet.servers.map((each) => each.name);
9
+ // Outside a fleet there is no name to offer; main refuses every server command there.
10
+ const server = argument(first === undefined ? serverNameParser : choice([first, ...rest], { metavar: "SERVER" }));
9
11
  // eslint-disable-next-line functional/prefer-immutable-types -- Optique's Message is readonly only at the top
10
12
  const onServer = (name, brief) => command(name, object({ name: constant(name), server }), { brief });
11
13
  return object({
@@ -14,11 +16,13 @@ export const parser = (fleet) => {
14
16
  // On one server
15
17
  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
18
  brief: message `Restore the latest backup to a scratch dir, check it, delete it.`,
17
- }), command("app", or(command("purge", object({ name: constant("app purge"), server, app: argument(appNameParser) }), {
19
+ }), command("app", or(command("check", object({ name: constant("app check"), dir: optional(argument(string({ metavar: "DIR" }))) }), {
20
+ brief: message `In an app's repository: what the platform checks at deploy (app.yml, images, compose policy). No fleet needed.`,
21
+ }), command("purge", object({ name: constant("app purge"), server, app: argument(appNameParser) }), {
18
22
  brief: message `Delete a removed app's volumes: its data, irreversibly.`,
19
23
  }), command("runs", object({ name: constant("app runs"), server, app: argument(appNameParser) }), {
20
24
  brief: message `The app's latest runs: id, result, commit, why.`,
21
- }), command("logs", object({ name: constant("app logs"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "RUN" }))) }), { brief: message `A run's log, the newest unless named.` }), command("deploy", object({ name: constant("app deploy"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "REF" }))) }), { brief: message `Build and deploy a branch, tag or commit (default: the app's ref).` }), command("rollback", object({ name: constant("app rollback"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "SHA" }))) }), { brief: message `List earlier releases, or deploy one of them again.` })), { brief: message `An app on a server: its runs, logs, deploys, rollbacks, and purging its data.` }), command("job", object({
25
+ }), command("logs", object({ name: constant("app logs"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "RUN" }))) }), { brief: message `A run's log, the newest unless named.` }), command("deploy", object({ name: constant("app deploy"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "REF" }))) }), { brief: message `Build and deploy a branch, tag or commit (default: the app's ref).` }), command("rollback", object({ name: constant("app rollback"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "SHA" }))) }), { brief: message `List earlier releases, or deploy one of them again.` })), { brief: message `An app: check it before a push, or on a server its runs, logs, deploys, rollbacks, and purging its data.` }), command("job", object({
22
26
  name: constant("job"),
23
27
  server,
24
28
  job: argument(choice(["gc", "renovate"], { metavar: "JOB" })),
@@ -0,0 +1,35 @@
1
+ import { appName, within } from "../brand.js";
2
+ import { yamlOf } from "../fleet.js";
3
+ import { run } from "../plan.js";
4
+ import { err, flatMap, map } from "../result.js";
5
+ import { appManifestSchema } from "../settings.js";
6
+ import { checkShape } from "../validate.js";
7
+ const mapError = (result, f) => result.ok ? result : err(f(result.error));
8
+ const tool = (program, hint) => ({
9
+ kind: "check",
10
+ label: `${program} is installed`,
11
+ probe: { kind: "on-path", program },
12
+ hint,
13
+ onFailure: "stop",
14
+ });
15
+ /**
16
+ * In an app's own repository, before a push: what the platform checks when it
17
+ * deploys the app — app.yml against its schema here, then app-check.sh (the
18
+ * image plan and the compose policy, the same scripts the builder and the
19
+ * deployer run). Needs no fleet and no server.
20
+ */
21
+ export const appCheck = (kayra, root, manifest) => {
22
+ if (manifest === undefined)
23
+ return err(`no app.yml in ${root}`);
24
+ const shaped = flatMap(flatMap(mapError(yamlOf(manifest), (problem) => `app.yml ${problem}`), (values) => checkShape("app.yml", values, appManifestSchema)), (values) => {
25
+ const parsed = appManifestSchema.safeParse(values);
26
+ return parsed.success ? appName(parsed.data.name) : err("app.yml: invalid");
27
+ });
28
+ return map(shaped, () => [
29
+ tool("git", "brew install git"),
30
+ tool("jq", "brew install jq"),
31
+ tool("yq", "brew install yq"),
32
+ tool("docker", "the docker CLI with Compose; no daemon is needed"),
33
+ run(["bash", within(kayra, "scripts", "local", "app-check.sh"), root]),
34
+ ]);
35
+ };
package/cli/dist/main.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "@optique/run";
3
3
  import { createInterface } from "node:readline/promises";
4
- import { absolutePath, kayraRoot, within } from "./brand.js";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { absolutePath, fleetRoot, kayraRoot, within } from "./brand.js";
7
+ import { appCheck } from "./commands/app-check.js";
5
8
  import { parser } from "./cli.js";
6
9
  import { doctor } from "./commands/doctor.js";
7
10
  import * as server from "./commands/server.js";
@@ -81,12 +84,28 @@ const fail = (message) => {
81
84
  console.error(`kayra: ${message}`);
82
85
  process.exit(1);
83
86
  };
84
- const fleet = flatMap(findFleetRoot(here.value), loadFleet);
87
+ const found = flatMap(findFleetRoot(here.value), loadFleet);
88
+ // Outside a fleet only `app check` runs: it works in an app's repository.
89
+ const fleet = found.ok ? found : ok({ root: fleetRoot(here.value), servers: [] });
85
90
  if (!fleet.ok)
86
91
  fail(fleet.error);
87
92
  else {
88
93
  const args = run(parser(fleet.value), { programName: "kayra", help: "both", completion: "both" });
89
- if (args.command.name === "status") {
94
+ if (!found.ok && args.command.name !== "app check")
95
+ fail(found.error);
96
+ if (args.command.name === "app check") {
97
+ const root = absolutePath(resolve(here.value, args.command.dir ?? "."));
98
+ const manifestPath = root.ok ? join(root.value, "app.yml") : "";
99
+ const steps = flatMap(root, (dir) => appCheck(kayra, dir, existsSync(manifestPath) ? readFileSync(manifestPath, "utf8") : undefined));
100
+ if (!steps.ok)
101
+ fail(steps.error);
102
+ else if (args.dryRun)
103
+ render(steps.value).forEach(io.out);
104
+ // eslint-disable-next-line functional/immutable-data -- the exit status; process.exit() could cut piped output short
105
+ else
106
+ process.exitCode = (await interpret(steps.value, io)) ? 0 : 1;
107
+ }
108
+ else if (args.command.name === "status") {
90
109
  (await status(fleet.value)).forEach(io.out);
91
110
  }
92
111
  else if (args.command.name === "validate") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunuseon/kayra",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "A self-hosted app platform and the CLI that runs it: Traefik, Authelia, a registry, rootless BuildKit and a small deployer per server, set up with OpenTofu and Ansible.",
5
5
  "license": "MIT",
6
6
  "author": "Yunus Öztürk",
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # app-check.sh <app-root> — what the platform checks when it deploys an app,
4
+ # checked in the app's own repository, before a push: run by `kayra app
5
+ # check`, which has checked app.yml against its schema first.
6
+ #
7
+ # - app.yml's compose files exist, inside the app; its subdomains are not
8
+ # the platform's (hosts.conf)
9
+ # - every docker/image.json: its image in the app's namespace, a commit
10
+ # touching its inputs (resolve-images.sh --plan, as the builder runs it)
11
+ # - the compose config those files resolve to passes the compose policy
12
+ # (compose-policy.sh, as the deployer runs it), with placeholder values for
13
+ # the images, config and secrets, and hosts under example.test
14
+ #
15
+ # Needs git, jq, yq and the docker CLI with Compose (no daemon: `compose
16
+ # config` only reads files). Written for macOS's bash 3.2 too: no mapfile.
17
+ # =============================================================================
18
+ set -euo pipefail
19
+
20
+ KAYRA_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
21
+ root="$(cd "${1:?Usage: app-check.sh <app-root>}" && pwd)"
22
+ manifest="${root}/app.yml"
23
+ domain=example.test
24
+ failed=0
25
+
26
+ fail() { echo "✗ $*"; failed=1; }
27
+ pass() { echo "✓ $*"; }
28
+
29
+ name="$(yq -r '.name' "$manifest")"
30
+ files="$(yq -r '.compose[]' "$manifest")"
31
+ subdomains="$(yq -r '.subdomains[]' "$manifest")"
32
+ reserved="$(awk '!/^[[:space:]]*(#|$)/ {print $1}' "${KAYRA_ROOT}/hosts.conf")"
33
+
34
+ # ── app.yml ─────────────────────────────────────────────────────────────────
35
+ while IFS= read -r file; do
36
+ case "$file" in
37
+ /*|..|../*|*/../*|*/..) fail "compose file ${file} is outside the app" ;;
38
+ *) [ -f "${root}/${file}" ] || fail "compose file ${file} does not exist" ;;
39
+ esac
40
+ done <<< "$files"
41
+ while IFS= read -r sub; do
42
+ if grep -qxF -- "$sub" <<< "$reserved"; then fail "subdomain ${sub} is the platform's"; fi
43
+ done <<< "$subdomains"
44
+ [ "$failed" = 0 ] && pass "app.yml: compose $(paste -sd' ' - <<< "$files") · subdomains $(paste -sd' ' - <<< "$subdomains")"
45
+
46
+ # ── Images ──────────────────────────────────────────────────────────────────
47
+ if plan="$(bash "${KAYRA_ROOT}/scripts/platform/resolve-images.sh" "$name" "$root" --plan)"; then
48
+ pass "images: $(awk -F'\t' '{printf "%s%s", sep, $1; sep=", "}' <<< "$plan")"
49
+ else
50
+ fail "images: resolve-images.sh refused the image plan (above)"
51
+ fi
52
+ [ "$failed" = 0 ] || exit 1
53
+
54
+ # ── Compose policy ──────────────────────────────────────────────────────────
55
+ work="$(mktemp -d)"
56
+ trap 'rm -rf "$work"' EXIT
57
+ {
58
+ echo "COMPOSE_PROJECT_NAME=${name}"
59
+ echo "DOMAIN=${domain}"
60
+ yq -r '.config // [] | .[] | . + "=check"' "$manifest"
61
+ awk -F'\t' 'NF == 3 {print $2 "=" $3}' <<< "$plan"
62
+ } > "${work}/.env"
63
+ # Secrets reach compose as the deploy's environment, never through a file.
64
+ while IFS= read -r key; do
65
+ [ -z "$key" ] || export "${key}=check"
66
+ done <<< "$(yq -r '.secrets // [] | .[]' "$manifest")"
67
+
68
+ set -- docker compose -p "$name" --project-directory "$root" --env-file "${work}/.env"
69
+ while IFS= read -r file; do set -- "$@" -f "${root}/${file}"; done <<< "$files"
70
+ "$@" config --no-env-resolution --format json > "${work}/config.json" \
71
+ || { fail "compose: docker compose config failed (above)"; exit 1; }
72
+
73
+ set --
74
+ while IFS= read -r sub; do set -- "$@" "${sub}.${domain}"; done <<< "$subdomains"
75
+ if violations="$(bash "${KAYRA_ROOT}/scripts/platform/compose-policy.sh" "$name" "$@" < "${work}/config.json")"; then
76
+ pass "compose policy: admitted"
77
+ else
78
+ while IFS= read -r line; do fail "compose policy: ${line}"; done <<< "$violations"
79
+ fi
80
+ exit "$failed"