@yunuseon/kayra 0.7.1 → 0.8.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.
- package/README.md +5 -5
- package/ansible/playbooks/apps-prune.yml +6 -6
- package/ansible/playbooks/platform.yml +5 -5
- package/ansible/roles/app/tasks/main.yml +26 -14
- package/ansible/roles/app/templates/app.env.j2 +1 -1
- package/ansible/roles/common/tasks/main.yml +1 -1
- package/ansible/roles/forgejo-config/tasks/main.yml +4 -9
- package/ansible/roles/platform/defaults/main.yml +8 -15
- package/ansible/roles/platform/tasks/main.yml +96 -63
- package/ansible/roles/platform/templates/platform.env.j2 +3 -11
- package/cli/dist/brand.js +0 -3
- package/cli/dist/cli.js +10 -1
- package/cli/dist/commands/server.js +16 -2
- package/cli/dist/deployer/app.js +47 -0
- package/cli/dist/deployer/brand.js +8 -0
- package/cli/dist/deployer/builder.js +196 -0
- package/cli/dist/deployer/bundle.js +49 -0
- package/cli/dist/deployer/compose.js +26 -0
- package/cli/dist/deployer/cron.js +44 -0
- package/cli/dist/deployer/ctl.js +77 -0
- package/cli/dist/deployer/deployer.js +284 -0
- package/cli/dist/deployer/forge.js +25 -0
- package/cli/dist/deployer/http.js +22 -0
- package/cli/dist/deployer/main.js +19 -0
- package/cli/dist/deployer/page.js +32 -0
- package/cli/dist/deployer/process.js +31 -0
- package/cli/dist/deployer/queue.js +28 -0
- package/cli/dist/deployer/records.js +48 -0
- package/cli/dist/deployer/run.js +76 -0
- package/cli/dist/deployer/store.js +31 -0
- package/cli/dist/deployer/webhook.js +31 -0
- package/cli/dist/fleet.js +1 -1
- package/cli/dist/main.js +11 -7
- package/cli/dist/settings.js +6 -10
- package/compose/authelia/configuration.yml +3 -3
- package/compose/deployer/Dockerfile.builder +37 -0
- package/compose/deployer/Dockerfile.deployer +40 -0
- package/compose/deployer/kayra-deployer +6 -0
- package/compose/docker-compose.ci.yml +71 -79
- package/hosts.conf +2 -2
- package/package.json +2 -3
- package/schema/defaults.json +2 -14
- package/schema/server.json +1 -1
- package/schema/vars-platform.json +2 -14
- package/scripts/local/require-main.sh +5 -1
- package/scripts/platform/compose-policy.sh +84 -0
- package/scripts/platform/registry-gc.sh +99 -0
- package/scripts/platform/resolve-images.sh +107 -0
- package/scripts/server/{jenkins-app-apply → app-apply} +9 -10
- package/scripts/server/app-purge +1 -1
- package/scripts/server/app-remove +8 -9
- package/ansible/roles/app/templates/casc-app.yaml.j2 +0 -68
- package/compose/jenkins/Dockerfile +0 -40
- package/compose/jenkins/casc/00-platform.yaml +0 -158
- package/compose/jenkins/plugins.txt +0 -97
- package/compose/jenkins-agent/Dockerfile +0 -24
- package/scripts/server/jenkins-api +0 -28
- package/scripts/server/jenkins-reload +0 -12
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { err, ok } from "../result.js";
|
|
5
|
+
import { field, registered } from "./app.js";
|
|
6
|
+
import { owner, runId, sha } from "./brand.js";
|
|
7
|
+
import { body, json, send } from "./http.js";
|
|
8
|
+
import { page, runPage } from "./page.js";
|
|
9
|
+
import { spawnLines } from "./process.js";
|
|
10
|
+
import { pushedRef, signed } from "./webhook.js";
|
|
11
|
+
const fromFile = (path) => () => (existsSync(path) ? readFileSync(path, "utf8").trim() : "");
|
|
12
|
+
const settings = (env) => {
|
|
13
|
+
const platformDir = env["PLATFORM_DIR"] ?? "/opt/platform";
|
|
14
|
+
return {
|
|
15
|
+
platformDir,
|
|
16
|
+
work: env["WORK_DIR"] ?? "/work",
|
|
17
|
+
deployer: env["DEPLOYER_URL"] ?? "http://deployer:8090",
|
|
18
|
+
resolveImages: env["RESOLVE_IMAGES"] ?? "/opt/kayra/scripts/platform/resolve-images.sh",
|
|
19
|
+
webhookSecret: () => env["WEBHOOK_SECRET"] ?? "",
|
|
20
|
+
gitToken: fromFile("/run/platform-secrets/GIT_TOKEN"),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** git with the platform's token as a header: neither in the clone's config nor in the process list. */
|
|
24
|
+
const gitEnv = (token) => ({
|
|
25
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
26
|
+
GIT_CONFIG_COUNT: "1",
|
|
27
|
+
GIT_CONFIG_KEY_0: "http.extraHeader",
|
|
28
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`,
|
|
29
|
+
});
|
|
30
|
+
/** A full commit id as it is; otherwise the branch or tag it names (a tag's commit, not the tag object). */
|
|
31
|
+
export const resolveRef = async (app, ref, token) => {
|
|
32
|
+
const given = sha(ref);
|
|
33
|
+
if (given.ok)
|
|
34
|
+
return given;
|
|
35
|
+
const exit = await spawnLines(["git", "ls-remote", app.repo, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`], { env: gitEnv(token), capture: true, timeoutMs: 60_000 }, () => undefined);
|
|
36
|
+
if (exit.code !== 0)
|
|
37
|
+
return err(`git ls-remote ${app.repo} failed (exit ${String(exit.code)})`);
|
|
38
|
+
const refs = new Map(exit.stdout.split("\n").flatMap((line) => {
|
|
39
|
+
const [commit, name] = line.split("\t");
|
|
40
|
+
return commit === undefined || name === undefined ? [] : [[name, commit]];
|
|
41
|
+
}));
|
|
42
|
+
const found = refs.get(`refs/tags/${ref}^{}`) ?? refs.get(`refs/heads/${ref}`) ?? refs.get(`refs/tags/${ref}`);
|
|
43
|
+
return found === undefined ? err(`${ref} is no branch or tag of ${app.repo}`) : sha(found);
|
|
44
|
+
};
|
|
45
|
+
const run = async (argv, options, line) => {
|
|
46
|
+
const exit = await spawnLines(argv, options, line);
|
|
47
|
+
return exit.code === 0 ? ok(exit.stdout) : err(`${argv.slice(0, 3).join(" ")} failed (exit ${String(exit.code)})`);
|
|
48
|
+
};
|
|
49
|
+
const sequence = async (steps) => steps.reduce(async (previous, next) => {
|
|
50
|
+
const so = await previous;
|
|
51
|
+
return so.ok ? next() : so;
|
|
52
|
+
}, Promise.resolve(ok("")));
|
|
53
|
+
/**
|
|
54
|
+
* One app's checkout at a commit, its images built where the registry lacks
|
|
55
|
+
* them (resolve-images.sh, through buildkitd), and the compose files app.yml
|
|
56
|
+
* lists at that commit. A clone per app, kept between runs; the deployer never
|
|
57
|
+
* runs two for the same app at once, and neither does this.
|
|
58
|
+
*/
|
|
59
|
+
export const prepare = async (app, commit, s, line) => {
|
|
60
|
+
const dir = join(s.work, app.name);
|
|
61
|
+
const env = gitEnv(s.gitToken());
|
|
62
|
+
const git = (...args) => run(["git", "-C", dir, ...args], { env, timeoutMs: 10 * 60_000 }, line);
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
const fresh = !existsSync(join(dir, ".git"));
|
|
65
|
+
const checkedOut = await sequence([
|
|
66
|
+
() => (fresh ? run(["git", "init", "-q", dir], {}, line) : Promise.resolve(ok(""))),
|
|
67
|
+
() => (fresh ? git("remote", "add", "origin", app.repo) : git("remote", "set-url", "origin", app.repo)),
|
|
68
|
+
() => git("fetch", "--quiet", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"),
|
|
69
|
+
async () => {
|
|
70
|
+
const there = await git("checkout", "--quiet", "--force", "--detach", commit);
|
|
71
|
+
return there.ok ? there : sequence([() => git("fetch", "--quiet", "origin", commit), () => git("checkout", "--quiet", "--force", "--detach", commit)]);
|
|
72
|
+
},
|
|
73
|
+
() => git("clean", "-ffdxq"),
|
|
74
|
+
]);
|
|
75
|
+
if (!checkedOut.ok)
|
|
76
|
+
return checkedOut;
|
|
77
|
+
const root = join(dir, app.path);
|
|
78
|
+
const buildEnv = { REGISTRY: "localhost:5000" };
|
|
79
|
+
const planned = await run(["bash", s.resolveImages, app.name, root, "--plan"], { env: buildEnv, capture: true }, line);
|
|
80
|
+
if (!planned.ok)
|
|
81
|
+
return planned;
|
|
82
|
+
const artifacts = planned.value
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((row) => row !== "")
|
|
85
|
+
.map((row) => row.split("\t"));
|
|
86
|
+
const built = await sequence(artifacts.map(([name]) => () => {
|
|
87
|
+
line(`── Image ${name ?? "?"}`);
|
|
88
|
+
return run(["bash", s.resolveImages, app.name, root, "--ensure", name ?? ""], { env: buildEnv }, line);
|
|
89
|
+
}));
|
|
90
|
+
if (!built.ok)
|
|
91
|
+
return built;
|
|
92
|
+
const listed = await run(["yq", "-o=json", ".compose // []", join(root, "app.yml")], { capture: true }, line);
|
|
93
|
+
if (!listed.ok)
|
|
94
|
+
return listed;
|
|
95
|
+
const files = JSON.parse(listed.value);
|
|
96
|
+
if (!Array.isArray(files) || !files.every((file) => typeof file === "string"))
|
|
97
|
+
return err("app.yml: compose is not a list of paths");
|
|
98
|
+
const outside = files.find((file) => file.startsWith("/") || file.split("/").includes(".."));
|
|
99
|
+
if (outside !== undefined)
|
|
100
|
+
return err(`app.yml: compose file ${outside} is outside the app`);
|
|
101
|
+
const missing = files.find((file) => !existsSync(join(root, file)));
|
|
102
|
+
if (missing !== undefined)
|
|
103
|
+
return err(`app.yml: compose file ${missing} does not exist at ${commit}`);
|
|
104
|
+
return ok({
|
|
105
|
+
sha: commit,
|
|
106
|
+
images: artifacts.map(([, variable, ref]) => ({ variable, ref })),
|
|
107
|
+
compose: files.map((file) => ({ path: file, content: readFileSync(join(root, file), "utf8") })),
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
const hook = async (s, name, request, response) => {
|
|
111
|
+
const raw = await body(request);
|
|
112
|
+
if (raw === undefined || !signed(s.webhookSecret(), raw, request.headers)) {
|
|
113
|
+
send(response, 401, "bad signature\n");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const app = registered(s.platformDir, name);
|
|
117
|
+
if (!app.ok) {
|
|
118
|
+
send(response, 404, `${app.error}\n`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const ref = pushedRef(raw);
|
|
122
|
+
if (ref !== `refs/heads/${app.value.ref}`) {
|
|
123
|
+
send(response, 202, `ignored: ${ref ?? "not a push"} (deploys follow ${app.value.ref})\n`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const forwarded = await fetch(`${s.deployer}/push/${app.value.name}`, { method: "POST" }).catch(() => undefined);
|
|
127
|
+
send(response, forwarded?.ok === true ? 202 : 502, forwarded?.ok === true ? "queued\n" : "the deployer did not answer\n");
|
|
128
|
+
};
|
|
129
|
+
const pages = (s, path, response) => {
|
|
130
|
+
const [, runs, who, id] = path.split("/");
|
|
131
|
+
if (path === "/") {
|
|
132
|
+
send(response, 200, page(join(s.platformDir, "runs")), "text/html; charset=utf-8");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const minted = owner(who ?? "");
|
|
136
|
+
const run = runId(id ?? "");
|
|
137
|
+
if (runs === "runs" && minted.ok && run.ok) {
|
|
138
|
+
send(response, 200, runPage(join(s.platformDir, "runs"), minted.value, run.value), "text/html; charset=utf-8");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
send(response, 404, "not found\n");
|
|
142
|
+
};
|
|
143
|
+
const internal = async (s, path, request, response) => {
|
|
144
|
+
const input = await json(request);
|
|
145
|
+
const name = typeof input === "object" && input !== null ? field(input, "app") : undefined;
|
|
146
|
+
const app = registered(s.platformDir, typeof name === "string" ? name : "");
|
|
147
|
+
if (!app.ok || typeof input !== "object" || input === null) {
|
|
148
|
+
send(response, 404, `${app.ok ? "bad request" : app.error}\n`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (path === "/resolve") {
|
|
152
|
+
const ref = field(input, "ref");
|
|
153
|
+
const resolved = await resolveRef(app.value, typeof ref === "string" ? ref : app.value.ref, s.gitToken());
|
|
154
|
+
send(response, resolved.ok ? 200 : 422, JSON.stringify(resolved.ok ? { sha: resolved.value } : { error: resolved.error }), "application/json");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const commit = sha(String(field(input, "sha")));
|
|
158
|
+
if (path !== "/prepare" || !commit.ok) {
|
|
159
|
+
send(response, 404, "not found\n");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// One JSON object per line: {log} as the build goes, then {bundle} or {error}.
|
|
163
|
+
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
|
|
164
|
+
const prepared = await prepare(app.value, commit.value, s, (text) => response.write(`${JSON.stringify({ log: text })}\n`));
|
|
165
|
+
response.end(`${JSON.stringify(prepared.ok ? { bundle: prepared.value } : { error: prepared.error })}\n`);
|
|
166
|
+
};
|
|
167
|
+
const failing = (handler) => (request, response) => {
|
|
168
|
+
Promise.resolve(handler(request, response)).catch((error) => {
|
|
169
|
+
console.error(error);
|
|
170
|
+
if (!response.headersSent)
|
|
171
|
+
send(response, 500, "internal error\n");
|
|
172
|
+
else
|
|
173
|
+
response.end();
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
export const serveBuilder = (env) => {
|
|
177
|
+
const s = settings(env);
|
|
178
|
+
createServer(failing(async (request, response) => {
|
|
179
|
+
const path = new URL(request.url ?? "/", "http://builder").pathname;
|
|
180
|
+
const hooked = /^\/hook\/([^/]+)\/?$/.exec(path);
|
|
181
|
+
if (request.method === "POST" && hooked?.[1] !== undefined)
|
|
182
|
+
await hook(s, hooked[1], request, response);
|
|
183
|
+
else if (request.method === "GET")
|
|
184
|
+
pages(s, path, response);
|
|
185
|
+
else
|
|
186
|
+
send(response, 405, "method not allowed\n");
|
|
187
|
+
})).listen(8080);
|
|
188
|
+
createServer(failing(async (request, response) => {
|
|
189
|
+
const path = new URL(request.url ?? "/", "http://builder").pathname;
|
|
190
|
+
if (request.method === "POST")
|
|
191
|
+
await internal(s, path, request, response);
|
|
192
|
+
else
|
|
193
|
+
send(response, 405, "method not allowed\n");
|
|
194
|
+
})).listen(8081);
|
|
195
|
+
console.log("builder: :8080 hooks and pages, :8081 resolve and prepare");
|
|
196
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { err, ok } from "../result.js";
|
|
2
|
+
import { field } from "./app.js";
|
|
3
|
+
import { sha } from "./brand.js";
|
|
4
|
+
const relative = (path) => path !== "" && !path.startsWith("/") && !path.split("/").some((segment) => segment === ".." || segment === "");
|
|
5
|
+
export const parseBundle = (app, expected, json) => {
|
|
6
|
+
if (typeof json !== "object" || json === null)
|
|
7
|
+
return err("bundle: not an object");
|
|
8
|
+
const commit = field(json, "sha");
|
|
9
|
+
const images = field(json, "images");
|
|
10
|
+
const compose = field(json, "compose");
|
|
11
|
+
const minted = sha(typeof commit === "string" ? commit : "");
|
|
12
|
+
if (!minted.ok || minted.value !== expected)
|
|
13
|
+
return err(`bundle: for ${String(commit)}, not ${expected}`);
|
|
14
|
+
if (!Array.isArray(images) || !Array.isArray(compose) || compose.length === 0)
|
|
15
|
+
return err("bundle: images or compose missing");
|
|
16
|
+
const own = new RegExp(`^localhost:5000/${app}/[a-z0-9._/-]+:[0-9a-f]{12}$`);
|
|
17
|
+
const parsedImages = images.map((image) => {
|
|
18
|
+
if (typeof image !== "object" || image === null)
|
|
19
|
+
return "an image is not an object";
|
|
20
|
+
const variable = field(image, "variable");
|
|
21
|
+
const ref = field(image, "ref");
|
|
22
|
+
if (typeof variable !== "string" || !/^[A-Z][A-Z0-9_]*_IMAGE$/.test(variable))
|
|
23
|
+
return `image variable ${String(variable)}`;
|
|
24
|
+
if (typeof ref !== "string" || !own.test(ref))
|
|
25
|
+
return `image ${String(ref)} is outside ${app}/`;
|
|
26
|
+
return { variable, ref };
|
|
27
|
+
});
|
|
28
|
+
const parsedCompose = compose.map((file) => {
|
|
29
|
+
if (typeof file !== "object" || file === null)
|
|
30
|
+
return "a compose file is not an object";
|
|
31
|
+
const path = field(file, "path");
|
|
32
|
+
const content = field(file, "content");
|
|
33
|
+
if (typeof path !== "string" || !relative(path))
|
|
34
|
+
return `compose file ${String(path)}`;
|
|
35
|
+
if (typeof content !== "string")
|
|
36
|
+
return `compose file ${path} has no content`;
|
|
37
|
+
return { path, content };
|
|
38
|
+
});
|
|
39
|
+
const problem = [...parsedImages, ...parsedCompose].find((item) => typeof item === "string");
|
|
40
|
+
if (problem !== undefined)
|
|
41
|
+
return err(`bundle: ${problem}`);
|
|
42
|
+
return ok({
|
|
43
|
+
sha: minted.value,
|
|
44
|
+
images: parsedImages.filter((item) => typeof item !== "string"),
|
|
45
|
+
compose: parsedCompose.filter((item) => typeof item !== "string"),
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
/** images.env, as compose reads it next to the app's own .env. */
|
|
49
|
+
export const imagesEnv = (bundle) => bundle.images.map((image) => `${image.variable}=${image.ref}\n`).join("");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's compose project, run in the run's directory: its .env from the
|
|
3
|
+
* platform (operator config), the image refs from this run, the compose files
|
|
4
|
+
* from the commit being deployed. Never COMPOSE_PROJECT_NAME from the
|
|
5
|
+
* environment: -p names the project.
|
|
6
|
+
*/
|
|
7
|
+
const project = (app, appsDir, bundle) => {
|
|
8
|
+
const dir = `${appsDir}/${app.name}`;
|
|
9
|
+
return [
|
|
10
|
+
"compose", "-p", app.name, "--project-directory", dir, "--env-file", `${dir}/.env`, "--env-file", "images.env",
|
|
11
|
+
...bundle.compose.flatMap((file) => ["-f", file.path]),
|
|
12
|
+
];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* What admission checks. --no-env-resolution: otherwise compose reads every
|
|
16
|
+
* env_file here, from any path (another app's secrets included), and bakes it
|
|
17
|
+
* into the config; unresolved, the allow-list sees env_file and refuses it.
|
|
18
|
+
*/
|
|
19
|
+
export const config = (app, appsDir, bundle) => [
|
|
20
|
+
"docker", ...project(app, appsDir, bundle), "config", "--no-env-resolution", "--format", "json",
|
|
21
|
+
];
|
|
22
|
+
export const policy = (app, script) => ["bash", script, app.name, ...app.hosts];
|
|
23
|
+
/** --remove-orphans: a service the app dropped from its compose files must not keep running. */
|
|
24
|
+
export const up = (app, appsDir, bundle) => [
|
|
25
|
+
"docker", ...project(app, appsDir, bundle), "up", "-d", "--wait", "--wait-timeout", "180", "--remove-orphans",
|
|
26
|
+
];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { all, err, map, ok } from "../result.js";
|
|
2
|
+
const range = (from, to, step) => Array.from({ length: Math.floor((to - from) / step) + 1 }, (_, i) => from + i * step);
|
|
3
|
+
const part = (text, min, max) => {
|
|
4
|
+
const match = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(text);
|
|
5
|
+
if (match === null)
|
|
6
|
+
return err(`'${text}'`);
|
|
7
|
+
const [, whole, low, high, every] = match;
|
|
8
|
+
const step = every === undefined ? 1 : Number(every);
|
|
9
|
+
const from = whole === "*" ? min : Number(low);
|
|
10
|
+
const to = whole === "*" ? max : high === undefined ? (every === undefined ? from : max) : Number(high);
|
|
11
|
+
return from < min || to > max || from > to || step < 1 ? err(`'${text}' (${String(min)}-${String(max)})`) : ok(range(from, to, step));
|
|
12
|
+
};
|
|
13
|
+
const values = (text, min, max) => map(all(text.split(",").map((piece) => part(piece, min, max))), (parts) => new Set(parts.flat()));
|
|
14
|
+
export const parseCron = (spec) => {
|
|
15
|
+
const fields = spec.trim().split(/\s+/);
|
|
16
|
+
const [minute, hour, day, month, weekday] = fields;
|
|
17
|
+
if (fields.length !== 5 || minute === undefined || hour === undefined || day === undefined || month === undefined || weekday === undefined) {
|
|
18
|
+
return err(`'${spec}' is not a five-field cron spec`);
|
|
19
|
+
}
|
|
20
|
+
const parsed = all([values(minute, 0, 59), values(hour, 0, 23), values(day, 1, 31), values(month, 1, 12), values(weekday, 0, 7)]);
|
|
21
|
+
if (!parsed.ok)
|
|
22
|
+
return err(`'${spec}': ${parsed.error}`);
|
|
23
|
+
const [minutes, hours, days, months, weekdays] = parsed.value;
|
|
24
|
+
if (minutes === undefined || hours === undefined || days === undefined || months === undefined || weekdays === undefined) {
|
|
25
|
+
return err(`'${spec}' is not a five-field cron spec`);
|
|
26
|
+
}
|
|
27
|
+
return ok({
|
|
28
|
+
minute: minutes,
|
|
29
|
+
hour: hours,
|
|
30
|
+
day: days,
|
|
31
|
+
month: months,
|
|
32
|
+
// 7 is Sunday too.
|
|
33
|
+
weekday: new Set([...weekdays].map((d) => d % 7)),
|
|
34
|
+
eitherDay: day !== "*" && weekday !== "*",
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
export const matches = (cron, at) => {
|
|
38
|
+
const day = cron.day.has(at.getDate());
|
|
39
|
+
const weekday = cron.weekday.has(at.getDay());
|
|
40
|
+
return (cron.minute.has(at.getMinutes()) &&
|
|
41
|
+
cron.hour.has(at.getHours()) &&
|
|
42
|
+
cron.month.has(at.getMonth() + 1) &&
|
|
43
|
+
(cron.eitherDay ? day || weekday : day && weekday));
|
|
44
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { appOwner, appName, owner as mintOwner, runId } from "./brand.js";
|
|
4
|
+
import { releases } from "./records.js";
|
|
5
|
+
import { log, runsOf } from "./store.js";
|
|
6
|
+
/**
|
|
7
|
+
* The operator's side, run inside the deployer container by `kayra app …` and
|
|
8
|
+
* `kayra job …` over SSH. Reads runs/ and releases/ directly; anything that
|
|
9
|
+
* starts a run goes through the daemon's control port, so the queue stays the
|
|
10
|
+
* only place runs begin.
|
|
11
|
+
*/
|
|
12
|
+
const platformDir = process.env["PLATFORM_DIR"] ?? "/opt/platform";
|
|
13
|
+
const runs = join(platformDir, "runs");
|
|
14
|
+
const fail = (message) => {
|
|
15
|
+
console.error(message);
|
|
16
|
+
return 1;
|
|
17
|
+
};
|
|
18
|
+
const post = async (path, payload) => {
|
|
19
|
+
const response = await fetch(`http://127.0.0.1:8091${path}`, { method: "POST", body: JSON.stringify(payload) }).catch(() => undefined);
|
|
20
|
+
if (response === undefined)
|
|
21
|
+
return fail("the deployer is not running");
|
|
22
|
+
process.stdout.write(await response.text());
|
|
23
|
+
return response.ok ? 0 : 1;
|
|
24
|
+
};
|
|
25
|
+
const releasesOf = (app) => {
|
|
26
|
+
const dir = join(platformDir, "releases", app);
|
|
27
|
+
return releases(existsSync(dir) ? readdirSync(dir) : []);
|
|
28
|
+
};
|
|
29
|
+
const commands = {
|
|
30
|
+
runs: ([app]) => {
|
|
31
|
+
const name = appName(app ?? "");
|
|
32
|
+
if (!name.ok)
|
|
33
|
+
return fail(name.error);
|
|
34
|
+
runsOf(runs, appOwner(name.value)).slice(0, 20).forEach((run) => {
|
|
35
|
+
console.log([run.id, run.state.padEnd(8), (run.sha ?? "").slice(0, 12).padEnd(12), run.why.padEnd(8), run.what].join(" "));
|
|
36
|
+
});
|
|
37
|
+
return 0;
|
|
38
|
+
},
|
|
39
|
+
log: ([who, id]) => {
|
|
40
|
+
const minted = mintOwner(who === "gc" || who === "renovate" ? `_${who}` : (who ?? ""));
|
|
41
|
+
if (!minted.ok)
|
|
42
|
+
return fail(minted.error);
|
|
43
|
+
const given = id === undefined ? undefined : runId(id);
|
|
44
|
+
const run = given === undefined ? runsOf(runs, minted.value)[0]?.id : given.ok ? given.value : undefined;
|
|
45
|
+
const text = run === undefined ? undefined : log(runs, minted.value, run);
|
|
46
|
+
if (text === undefined)
|
|
47
|
+
return fail(`no such run of ${minted.value}`);
|
|
48
|
+
process.stdout.write(text);
|
|
49
|
+
return 0;
|
|
50
|
+
},
|
|
51
|
+
deploy: ([app, ref]) => post("/deploy", { app, ref: ref ?? "", why: "deploy" }),
|
|
52
|
+
rollback: ([app, commit]) => {
|
|
53
|
+
const name = appName(app ?? "");
|
|
54
|
+
if (!name.ok)
|
|
55
|
+
return fail(name.error);
|
|
56
|
+
const [live, ...earlier] = releasesOf(name.value);
|
|
57
|
+
if (live === undefined)
|
|
58
|
+
return fail(`${name.value} has not been deployed yet`);
|
|
59
|
+
if (commit === undefined) {
|
|
60
|
+
console.log(`${name.value} runs ${live.sha} (since ${live.time}). Earlier releases:`);
|
|
61
|
+
earlier.forEach((release) => {
|
|
62
|
+
console.log(` ${release.sha} ${release.time}`);
|
|
63
|
+
});
|
|
64
|
+
return earlier.length === 0 ? fail("nothing to roll back to") : 0;
|
|
65
|
+
}
|
|
66
|
+
const target = earlier.find((release) => release.sha.startsWith(commit));
|
|
67
|
+
if (target === undefined || commit.length < 7)
|
|
68
|
+
return fail(`${commit} is not an earlier release of ${name.value}`);
|
|
69
|
+
return post("/deploy", { app: name.value, ref: target.sha, why: "rollback" });
|
|
70
|
+
},
|
|
71
|
+
job: ([job, flag]) => post("/job", { job, dryRun: flag === "--dry-run" }),
|
|
72
|
+
};
|
|
73
|
+
export const ctl = async (args) => {
|
|
74
|
+
const [name, ...rest] = args;
|
|
75
|
+
const command = name === undefined ? undefined : commands[name];
|
|
76
|
+
return command === undefined ? fail(`usage: ctl ${Object.keys(commands).join("|")} …`) : command(rest);
|
|
77
|
+
};
|