@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
|
|
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("
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
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"
|