@yunuseon/kayra 0.10.0 → 0.12.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/cli/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ export const parser = (fleet) => {
14
14
  dryRun: option("--dry-run", { description: message `Print the plan instead of running it.` }),
15
15
  command: or(
16
16
  // On one server
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 })), {
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("verify", message `Whether a platform server works as its users see it: logins, certificates, webhooks, apps, runs, alerts.`), 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 })), {
18
18
  brief: message `Restore the latest backup to a scratch dir, check it, delete it.`,
19
19
  }), command("app", or(command("check", object({ name: constant("app check"), dir: optional(argument(string({ metavar: "DIR" }))) }), {
20
20
  brief: message `In an app's repository: what the platform checks at deploy (app.yml, images, compose policy). No fleet needed.`,
@@ -148,7 +148,13 @@ export const hosts = (engine, server) => ok(server.target === "multipass"
148
148
  ]
149
149
  : [{ kind: "note", text: `hosts: ${server.target} uses real DNS, nothing to do.` }]);
150
150
  const sequence = (plans) => map(all(plans), (each) => each.flat());
151
- export const deploy = (engine, server) => sequence([setup(engine, server), apps(engine, server)]);
151
+ /** Whether a platform server works as its users see it (scripts/local/verify.sh); other kinds have nothing to check yet. */
152
+ const verifyStep = (engine, server) => run(["bash", script(engine, "verify.sh"), server.name], fleetEnv(engine));
153
+ export const verify = (engine, server) => server.kind === "platform"
154
+ ? ok([verifyStep(engine, server)])
155
+ : err(`'${server.name}' is of kind ${server.kind}: kayra verify checks platform servers only.`);
156
+ /** Host setup, then the apps; a platform server is verified at the end, so a deploy that leaves it broken fails. */
157
+ export const deploy = (engine, server) => map(sequence([setup(engine, server), apps(engine, server)]), (plan) => server.kind === "platform" ? [...plan, verifyStep(engine, server)] : plan);
152
158
  export const up = (engine, server) => sequence([provision(engine, server), deploy(engine, server), hosts(engine, server)]);
153
159
  export const down = (engine, server) => server.terraform
154
160
  ? ok([
package/cli/dist/fleet.js CHANGED
@@ -39,8 +39,12 @@ const vault = (descriptor) => {
39
39
  ? map(absolutePath(file), (path) => ({ kind: "file", file: vaultPasswordFile(path) }))
40
40
  : err("vault_password_file must be a path");
41
41
  };
42
- /** Apps live in their own repo: the builder clones it over HTTPS, and the app root stays inside it. */
43
- const app = (value) => {
42
+ /**
43
+ * Apps live in their own repo: the builder clones it over HTTPS, and the app
44
+ * root stays inside it. A multipass server, a test machine by definition, may
45
+ * clone over plain HTTP too: the upgrade lab serves its fixture app that way.
46
+ */
47
+ const app = (target) => (value) => {
44
48
  const entry = mapping(value);
45
49
  if (entry === undefined)
46
50
  return err("an app must be a mapping");
@@ -48,8 +52,9 @@ const app = (value) => {
48
52
  if (!name.ok)
49
53
  return name;
50
54
  const repo = entry["repo"];
51
- if (typeof repo !== "string" || !repo.startsWith("https://"))
52
- return err(`app ${name.value}: repo must be an https:// URL`);
55
+ const schemes = target === "multipass" ? ["https://", "http://"] : ["https://"];
56
+ if (typeof repo !== "string" || !schemes.some((scheme) => repo.startsWith(scheme)))
57
+ return err(`app ${name.value}: repo must be an https:// URL${target === "multipass" ? " (or http:// on multipass)" : ""}`);
53
58
  const path = entry["path"] ?? ".";
54
59
  if (typeof path !== "string" || `/${path}/`.includes("/../"))
55
60
  return err(`app ${name.value}: path must stay inside its repo`);
@@ -98,7 +103,7 @@ export const parseServer = (files) => {
98
103
  return err("apps must be a list");
99
104
  if (kind.value !== "platform" && declaredApps.length > 0)
100
105
  return err("apps run on platform servers only");
101
- const apps = all(declaredApps.map(app));
106
+ const apps = all(declaredApps.map(app(target.value)));
102
107
  if (!apps.ok)
103
108
  return apps;
104
109
  const shape = checkShape("server.yml", descriptor.value, serverSchema);
package/cli/dist/main.js CHANGED
@@ -38,6 +38,8 @@ const onServer = (on, target, command) => {
38
38
  return server.setup(on, target);
39
39
  case "plan":
40
40
  return server.preview(on, target);
41
+ case "verify":
42
+ return server.verify(on, target);
41
43
  case "apps":
42
44
  return server.apps(on, target);
43
45
  case "hosts":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunuseon/kayra",
3
- "version": "0.10.0",
3
+ "version": "0.12.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,84 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # guard.sh — Claude Code's PreToolUse hook on Bash, in kayra and every fleet
4
+ # (.claude/settings.json): an AI agent may build, test and read freely, but a
5
+ # command that changes something real waits for the operator to approve it,
6
+ # in that moment.
7
+ #
8
+ # Reads the hook's JSON on stdin; prints a permissionDecision "ask" with the
9
+ # reason for:
10
+ # - kayra commands that change a server (up, provision, setup, deploy,
11
+ # apps, adopt, down, app purge|deploy|rollback, job) unless --dry-run or
12
+ # the server is a multipass one (its server.yml under the project)
13
+ # - ansible / ansible-playbook against an inventory, ansible-vault reading
14
+ # or writing a vault
15
+ # - merging a PR, API writes (gh api / tea api with PUT, PATCH, DELETE),
16
+ # deleting or editing a repository, publishing a release or a package
17
+ # - git push of main or a tag, a force push, deleting a remote branch
18
+ # - tofu / terraform apply, destroy, import, state changes
19
+ # - destructive commands over SSH (rm, docker rm/stop/prune/down, systemctl…)
20
+ # Anything else: no output, the normal permission flow.
21
+ #
22
+ # It reads the command's text, so it stops mistakes and instructions smuggled
23
+ # in through files or web pages, not a determined adversary: a script it does
24
+ # not read runs whatever it contains. Cases: ci/tests/guard/run.sh.
25
+ # =============================================================================
26
+ set -uo pipefail
27
+
28
+ command="$(jq -r '.tool_input.command // empty' 2>/dev/null)"
29
+ [ -n "$command" ] || exit 0
30
+ project="${CLAUDE_PROJECT_DIR:-$PWD}"
31
+
32
+ ask() {
33
+ jq -n --arg reason "$1" \
34
+ '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: ("guard: " + $reason)}}'
35
+ exit 0
36
+ }
37
+ has() { grep -qE -- "$1" <<< "$command"; }
38
+
39
+ # ── kayra changing a server ─────────────────────────────────────────────────
40
+ kayra='(^|[;&|(` ]|/)(kayra|kayra\.js|main\.ts|main\.js)'
41
+ verbs='(up|provision|setup|deploy|apps|adopt|down|hosts|job|app (purge|deploy|rollback))'
42
+ if has "${kayra} +(--[a-z-]+ +)*${verbs}( |$)" && ! has '--dry-run'; then
43
+ # The server: the word after the verb (after the app subcommand for app …).
44
+ server="$(grep -oE -- "${kayra} +(--[a-z-]+ +)*${verbs} +[a-z0-9][a-z0-9-]*" <<< "$command" | head -1 | awk '{print $NF}')"
45
+ target=""
46
+ [ -z "$server" ] || [ ! -f "${project}/servers/${server}/server.yml" ] \
47
+ || target="$(sed -n 's/^target: *\([a-z]*\).*/\1/p' "${project}/servers/${server}/server.yml")"
48
+ [ "$target" = multipass ] || ask "kayra changes ${server:-a server}${target:+ (target ${target})}"
49
+ fi
50
+
51
+ # ── Ansible and the vaults ──────────────────────────────────────────────────
52
+ has '(^|[;&|( ])ansible-vault +(decrypt|view|edit|encrypt|encrypt_string|rekey|create)( |$)' \
53
+ && ask "ansible-vault opens or writes a vault"
54
+ has '(^|[;&|( ])ansible(-playbook)? .*(-i|--inventory)[ =]' && ask "ansible runs against an inventory"
55
+
56
+ # ── The forges ──────────────────────────────────────────────────────────────
57
+ has '(^|[;&|( ])(gh|tea) +(pr|pulls) +merge( |$)' && ask "a pull request is merged"
58
+ has '(^|[;&|( ])(gh|tea) +api .*(-X|--method)[ =]*(PUT|PATCH|DELETE)' && ask "a forge API write"
59
+ has '(^|[;&|( ])gh +repo +(delete|edit|rename|archive)( |$)' && ask "a repository is changed or deleted"
60
+ has '(^|[;&|( ])gh +release +(create|delete|edit|upload)( |$)' && ask "a release is published or changed"
61
+ has '(^|[;&|( ])npm +publish( |$)' && ! has 'npm +publish.*--dry-run' && ask "a package is published"
62
+
63
+ # ── git push of what others build on ────────────────────────────────────────
64
+ if has '(^|[;&|( ])git +push( |$)'; then
65
+ has 'git +push.*( |:)(main|master)( |$)' && ask "main is pushed"
66
+ has 'git +push.*( --tags| --follow-tags| refs/tags/| v[0-9])' && ask "a tag is pushed (a release)"
67
+ has 'git +push.*( -f( |$)| --force| \+)' && ask "a force push"
68
+ # A push that names no branch pushes the current one: main, if that is where we are.
69
+ case "$(git branch --show-current 2>/dev/null)" in
70
+ main|master) has 'git +push( +-[a-zA-Z-]+)*( +origin)?( *$|[;&|])' && ask "main is pushed (the current branch)" ;;
71
+ esac
72
+ has 'git +push.*( --delete| -d( |$)| :[^ ])' && ask "a remote branch or tag is deleted"
73
+ fi
74
+
75
+ # ── OpenTofu / Terraform ────────────────────────────────────────────────────
76
+ has '(^|[;&|( ])(tofu|terraform) +(-chdir=[^ ]+ +)?(apply|destroy|import|state +(rm|mv|push|replace-provider)|taint|untaint|force-unlock)( |$)' \
77
+ && ask "OpenTofu changes infrastructure or state"
78
+
79
+ # ── Destructive commands on a server ────────────────────────────────────────
80
+ if has '(^|[;&|( ])(kayra +ssh|ssh) '; then
81
+ has '(rm +-|docker +(volume|image|system|container|network) +(rm|prune)|docker +(rm|stop|kill|restart)( |$)|compose +(down|stop|rm|restart|kill)( |$)|systemctl +(stop|restart|disable|mask)|reboot|shutdown|apt(-get)? +(install|remove|purge)|ufw |iptables )' \
82
+ && ask "a destructive command over SSH"
83
+ fi
84
+ exit 0
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # verify.sh <server> — is a platform server working, as its users see it?
4
+ # Run by `kayra verify` and at the end of `kayra deploy` / `kayra up`.
5
+ #
6
+ # From here, the way the world reaches it (each name pinned to the server's
7
+ # address, so neither DNS nor /etc/hosts has to be in place yet):
8
+ # - auth.<domain> answers; the platform's own pages (hosts.conf) send a
9
+ # browser to the login, not a 403 from rules Authelia never loaded
10
+ # - every certificate is a real one (Let's Encrypt or mkcert), never
11
+ # Traefik's default
12
+ # - an unsigned webhook is refused; every app hostname answers, not 5xx
13
+ # On the server (one SSH call):
14
+ # - every app in server.yml is registered, and nothing else
15
+ # - every app's containers run, none unhealthy; its last run did not fail
16
+ # - every platform container runs, none unhealthy; no alert is open
17
+ #
18
+ # Certificates and routes can lag a fresh deploy (ACME, DNS-01's minute), so
19
+ # the probes retry for VERIFY_WAIT seconds (default 150) before failing.
20
+ # Written for macOS's bash 3.2 too.
21
+ # =============================================================================
22
+ set -euo pipefail
23
+
24
+ KAYRA_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
25
+ FLEET_ROOT="${FLEET_ROOT:?the directory of the fleet (servers/), set by kayra}"
26
+ name="${1:?Usage: verify.sh <server>}"
27
+ dir="${FLEET_ROOT}/servers/${name}"
28
+ wait="${VERIFY_WAIT:-150}"
29
+ failed=0
30
+
31
+ fail() { echo "✗ $*"; failed=1; }
32
+ pass() { echo "✓ $*"; }
33
+
34
+ domain="$(yq -r '.domain' "${dir}/server.yml")"
35
+ inventory="${dir}/inventory.ini"
36
+ [ -f "$inventory" ] || { fail "no inventory for ${name}: provision it first"; exit 1; }
37
+ field() { awk -v key="$1" '{for (i = 1; i <= NF; i++) if (index($i, key "=") == 1) {print substr($i, length(key) + 2); exit}}' "$inventory"; }
38
+ address="$(field ansible_host)"
39
+ port="$(field ansible_port)"
40
+ user="$(field ansible_user)"
41
+ key="$(field ansible_ssh_private_key_file)"
42
+ registered_wanted="$(yq -r '.apps // [] | .[].name' "${dir}/server.yml")"
43
+
44
+ # ── From outside ────────────────────────────────────────────────────────────
45
+ # "<status> <redirect> <verify result> <issuer CN>" for a host, pinned to the server.
46
+ probe() {
47
+ local host="$1" method="${2:-GET}" result issuer
48
+ result="$(curl -s -o /dev/null -m 10 -X "$method" -H 'Accept: text/html' \
49
+ --resolve "${host}:443:${address}" -w '%{http_code} %{redirect_url} %{ssl_verify_result}' \
50
+ "https://${host}/" 2>/dev/null || echo "000 - -")"
51
+ issuer="$(openssl s_client -connect "${address}:443" -servername "$host" </dev/null 2>/dev/null \
52
+ | openssl x509 -noout -issuer 2>/dev/null | sed -n 's/.*CN *= *//p')"
53
+ echo "${result} ${issuer:-none}"
54
+ }
55
+
56
+ # Probes until `test` accepts the answer or VERIFY_WAIT runs out; prints the last answer.
57
+ until_ok() {
58
+ local host="$1" method="$2" test="$3" deadline answer
59
+ deadline=$(( $(date +%s) + wait ))
60
+ while :; do
61
+ answer="$(probe "$host" "$method")"
62
+ if "$test" "$answer"; then echo "$answer"; return 0; fi
63
+ [ "$(date +%s)" -lt "$deadline" ] || { echo "$answer"; return 1; }
64
+ sleep 10
65
+ done
66
+ }
67
+
68
+ real_cert() { case "$1" in *"TRAEFIK DEFAULT CERT"|*" none") return 1 ;; esac; [ "$(cut -d' ' -f3 <<< "$1")" = 0 ]; }
69
+ answers() { real_cert "$1" && [ "$(cut -d' ' -f1 <<< "$1")" = 200 ]; }
70
+ to_login() { real_cert "$1" && [ "$(cut -d' ' -f1 <<< "$1")" = 302 ] && case "$(cut -d' ' -f2 <<< "$1")" in "https://auth.${domain}/"*) return 0 ;; esac; return 1; }
71
+ not_5xx() { real_cert "$1" && case "$(cut -d' ' -f1 <<< "$1")" in 000|5*) return 1 ;; esac; }
72
+ refused() { real_cert "$1" && [ "$(cut -d' ' -f1 <<< "$1")" = 401 ]; }
73
+
74
+ describe() { echo "HTTP $(cut -d' ' -f1 <<< "$1"), certificate by $(cut -d' ' -f4- <<< "$1") (verify $(cut -d' ' -f3 <<< "$1"))"; }
75
+
76
+ check() {
77
+ local label="$1" host="$2" method="$3" test="$4" answer
78
+ if answer="$(until_ok "$host" "$method" "$test")"; then pass "$label"; else fail "${label}: $(describe "$answer")"; fi
79
+ }
80
+
81
+ check "auth.${domain} answers" "auth.${domain}" GET answers
82
+ while IFS= read -r sub; do
83
+ [ "$sub" = auth ] || check "${sub}.${domain} sends a browser to the login" "${sub}.${domain}" GET to_login
84
+ done < <(awk '!/^[[:space:]]*(#|$)/ {print $1}' "${KAYRA_ROOT}/hosts.conf")
85
+
86
+ first_app="$(head -1 <<< "$registered_wanted")"
87
+ if [ -n "$first_app" ]; then
88
+ hook() {
89
+ curl -s -o /dev/null -m 10 -X POST --resolve "deploy.${domain}:443:${address}" \
90
+ -w '%{http_code}' "https://deploy.${domain}/hook/${first_app}" 2>/dev/null || echo 000
91
+ }
92
+ code="$(hook)"
93
+ [ "$code" = 401 ] && pass "an unsigned webhook is refused" || fail "an unsigned webhook to /hook/${first_app}: HTTP ${code}, not 401"
94
+ while IFS= read -r sub; do
95
+ [ -z "$sub" ] || check "${sub}.${domain} answers" "${sub}.${domain}" GET not_5xx
96
+ done < <(bash "${KAYRA_ROOT}/scripts/local/app-manifests.sh" --subdomains "$name")
97
+ fi
98
+
99
+ # ── On the server ───────────────────────────────────────────────────────────
100
+ facts="$(ssh -p "${port:-22}" -i "$key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
101
+ "${user}@${address}" 'set -e
102
+ cd /opt/platform
103
+ echo "registered $(ls apps 2>/dev/null | sed -n "s/\.json$//p" | sort | tr "\n" " ")"
104
+ echo "alerts $(ls alerts 2>/dev/null | tr "\n" " ")"
105
+ docker compose ps -a --format "{{.Service}}|{{.State}}|{{.Health}}" | sed "s/^/platform /"
106
+ for app in $(ls apps 2>/dev/null | sed -n "s/\.json$//p"); do
107
+ docker ps -a --filter "label=com.docker.compose.project=$app" --format "{{.Names}}|{{.State}}|{{.Status}}" | sed "s/^/container $app /"
108
+ last=$(ls runs/$app 2>/dev/null | sort | tail -1)
109
+ [ -z "$last" ] || echo "run $app $(sed -n "s/.*\"state\":\"\([a-z]*\)\".*/\1/p" runs/$app/$last/meta.json) $last"
110
+ done' 2>&1)" || { fail "the server answers over SSH: ${facts}"; exit 1; }
111
+
112
+ wanted="$(sort <<< "$registered_wanted" | tr '\n' ' ')"
113
+ have="$(sed -n 's/^registered //p' <<< "$facts")"
114
+ wanted="$(xargs <<< "$wanted")"
115
+ have="$(xargs <<< "$have")"
116
+ [ "$wanted" = "$have" ] && pass "registered: ${have:-no apps}" \
117
+ || fail "registered: ${have:-nothing}; server.yml says ${wanted:-no apps} (kayra apps ${name})"
118
+
119
+ alerts="$(sed -n 's/^alerts //p' <<< "$facts" | xargs)"
120
+ [ -z "$alerts" ] && pass "no alert is open" || fail "open alerts: ${alerts}"
121
+
122
+ bad_platform="$(sed -n 's/^platform //p' <<< "$facts" | awk -F'|' '$2 != "running" || $3 == "unhealthy" || $3 == "starting" {print $1 " " $2 " " $3}')"
123
+ [ -z "$bad_platform" ] && pass "every platform container runs" || fail "platform containers: $(paste -sd',' - <<< "$bad_platform")"
124
+
125
+ while IFS= read -r app; do
126
+ [ -n "$app" ] || continue
127
+ containers="$(sed -n "s/^container ${app} //p" <<< "$facts")"
128
+ run="$(sed -n "s/^run ${app} //p" <<< "$facts")"
129
+ # One-shot containers (an init, a migration) exit 0 and stay exited.
130
+ bad="$(awk -F'|' '$2 != "running" && $3 !~ /^Exited \(0\)/ || $3 ~ /unhealthy|health: starting/ {print $1 " " $3}' <<< "$containers")"
131
+ # Registered and never run: waiting for its first push, not broken.
132
+ if [ -z "$containers" ] && [ -z "$run" ]; then pass "${app}: not deployed yet"; continue
133
+ elif [ -z "$containers" ]; then fail "${app}: no containers"
134
+ elif [ -n "$bad" ]; then fail "${app}: $(paste -sd',' - <<< "$bad")"
135
+ else pass "${app}: $(grep -c . <<< "$containers") container(s) up"; fi
136
+ case "$run" in
137
+ failure*) fail "${app}: its last run failed ($(cut -d' ' -f2 <<< "$run"): kayra app logs ${name} ${app})" ;;
138
+ "") pass "${app}: no runs yet" ;;
139
+ *) pass "${app}: last run $(cut -d' ' -f1 <<< "$run")" ;;
140
+ esac
141
+ done <<< "$registered_wanted"
142
+
143
+ exit "$failed"