stabilize, add daemon state, add base build script

This commit is contained in:
JOLIMAITRE Matthieu 2023-12-10 22:04:40 +01:00
parent a1963cf491
commit 241d50e42a
12 changed files with 512 additions and 46 deletions

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# BDMGR.D TS
Implémentation de BDMGR daemon en typescript.
## Dépendances
Deno :
```sh
curl -fsSL https://deno.land/x/install/install.sh | sh
```
## Usage
Démon :
```sh
sudo ./daemon.ts
```
Controleur :
```sh
control.ts - controls BDMGRDTS daemon.
Usage:
./control.ts Command [ARG...]
Commands:
help Prints a help message.
stop Stops the daemon.
status Get a list of enabled containers.
create NAME BASE Create a new container from a specific base.
enable NAME Enables a container.
disable NAME Disables a container.
reload NAME Reloads a container, equivalent to enable then disable.
```

View file

@ -1,31 +1,93 @@
#!/bin/env -S deno run -A --unstable #!/bin/env -S deno run -A --unstable
import { daemon_send, new_cmd_disable, new_cmd_enable, new_cmd_status } from "./src/lib.ts"; import { daemon_send, new_cmd_disable, new_cmd_enable, new_cmd_status, new_cmd_stop } from "./src/lib.ts";
import { socket_path } from "./src/lib/paths.ts"; import { load_all_configs, new_container_config, save_container_config } from "./src/lib/config.ts";
import { load_base, new_container_context } from "./src/lib/create.ts";
import { container_paths, socket_path } from "./src/lib/paths.ts";
import { log_from, run } from "./src/lib/utils.ts";
await main(); const log = log_from("control");
if (import.meta.main) await main();
async function main() { async function main() {
const [kind, ...rest] = Deno.args; const [command, ...rest] = Deno.args;
if (kind === "status") { if (["-h", "--help", "help"].includes(command)) {
const res = await daemon_send(socket_path(), new_cmd_status()); return console.log(`control.ts - controls BDMGRDTS daemon.
console.log({ res }); Usage:
./control.ts Command [ARG...]
Commands:
help Prints a help message.
stop Stops the daemon.
status Get a list of enabled containers.
create NAME BASE Create a new container from a specific base.
enable NAME Enables a container.
disable NAME Disables a container.
reload NAME Reloads a container, equivalent to enable then disable.
`);
} }
if (kind === "enable") { if (command === "create") {
const [name, base] = rest;
if (name === undefined) log("expects NAME argument");
if (base === undefined) log("expects BASE argument");
await create(name, base);
return;
}
if (command === "status") {
const res = await daemon_send(socket_path(), new_cmd_status());
log(res);
return;
}
if (command === "enable") {
const [name] = rest; const [name] = rest;
if (name === undefined) log("expects NAME argument");
const res = await daemon_send(socket_path(), new_cmd_enable(name)); const res = await daemon_send(socket_path(), new_cmd_enable(name));
console.log({ res }); log(res);
return;
} }
if (kind === "disable") { if (command === "disable") {
const [name] = rest; const [name] = rest;
if (name === undefined) log("expects NAME argument");
const res = await daemon_send(socket_path(), new_cmd_disable(name)); const res = await daemon_send(socket_path(), new_cmd_disable(name));
console.log({ res }); log(res);
return;
} }
if (kind === "stop") { if (command === "reload") {
const res = await daemon_send(socket_path(), new_cmd_status()); const [name] = rest;
console.log({ res }); if (name === undefined) log("expects NAME argument");
const res = await daemon_send(socket_path(), new_cmd_enable(name));
log(res);
return;
} }
if (command === "stop") {
const res = await daemon_send(socket_path(), new_cmd_stop());
log(res);
return;
}
log("unknown command", command);
}
export async function create(name: string, base_name: string) {
log("loading base", base_name);
const base = await load_base(base_name);
const known_containers = await load_all_configs();
const paths = container_paths(name);
await Deno.mkdir(paths.base);
const configuration = new_container_config(name);
log("creating the container", name, "at", paths.base);
const context = new_container_context(paths.root, configuration, known_containers);
await Deno.mkdir(paths.root);
await run("sudo", "chown", "root:root", paths.root);
await base.build(context);
await save_container_config(configuration);
} }

View file

@ -1,50 +1,127 @@
#!/bin/env -S deno run -A --unstable #!/bin/env -S deno run -A --unstable
import { daemon_listen, Runner, start_runner } from "./src/lib.ts"; import { daemon_listen, Runner, start_runner } from "./src/lib.ts";
import { new_container_config } from "./src/lib/config.ts"; import { load_container_config } from "./src/lib/config.ts";
import { socket_path } from "./src/lib/paths.ts"; import { socket_path, state_path } from "./src/lib/paths.ts";
import { log_from, sleep } from "./src/lib/utils.ts";
await main(); const log = log_from("daemon");
if (import.meta.main) await main();
async function main() { async function main() {
const server = daemon_listen(socket_path()); const state = await create_state();
const enabled = new Map<string, Runner>(); const server = await daemon_listen(socket_path());
log("listening to", socket_path());
async function finish() { async function finish() {
log("stopping");
server.server.close(); server.server.close();
for (const runner of state.enabled.values()) {
try {
await runner.stop();
} catch (_) { /* on s'en fou */ }
}
await Deno.remove(socket_path()); await Deno.remove(socket_path());
for (const runner of enabled.values()) await runner.stop();
Deno.exit(0); Deno.exit(0);
} }
Deno.addSignalListener("SIGINT", finish); Deno.addSignalListener("SIGINT", finish);
console.log("listening to", socket_path());
for await (const { cmd, respond } of server) { for await (const { cmd, respond } of server) {
console.log("received", { cmd }); log("received", cmd);
if (cmd.kind === "status") { if (cmd.kind === "status") {
await respond(JSON.stringify(Array.from(enabled.keys()))); await respond(JSON.stringify(state.list()));
continue; continue;
} }
if (cmd.kind === "enable") { if (cmd.kind === "enable") {
const config = new_container_config(cmd.name); // TODO (async () => {
const runner = start_runner(config); try {
enabled.set(cmd.name, runner); await state.enable(cmd.name);
await respond("enabled"); await state.save();
await respond("enabled");
} catch (error) {
log("experienced failure", error);
await respond(`${error}`);
}
})();
continue; continue;
} }
if (cmd.kind === "disable") { if (cmd.kind === "disable") {
await enabled.get(cmd.name)?.stop(); (async () => {
enabled.delete(cmd.name); try {
await respond("disabled"); await state.disable(cmd.name);
await state.save();
await respond("disabled");
} catch (error) {
log("experienced failure", error);
await respond(`${error}`);
}
})();
continue;
}
if (cmd.kind === "reload") {
(async () => {
try {
await state.disable(cmd.name);
await sleep(500);
await state.enable(cmd.name);
await respond("reloaded");
} catch (error) {
log("experienced failure", error);
await respond(`${error}`);
}
})();
continue; continue;
} }
if (cmd.kind === "stop") { if (cmd.kind === "stop") {
await respond(JSON.stringify("stopping, ++")); await state.save();
await respond("adios");
await finish(); await finish();
} }
await respond("unknown"); await respond("unknown command");
} }
} }
async function create_state() {
const self = {
enabled: new Map<string, Runner>(),
async enable(name: string) {
if (self.enabled.has(name)) throw new Error("Container already enabled");
log("loading container", name);
const config = await load_container_config(name); // TODO
if (config === null) throw new Error("can't read config");
log("starting container", name);
self.enabled.set(name, await start_runner(config));
log("container", name, "started");
},
async disable(name: string) {
const container = self.enabled.get(name);
if (container === undefined) throw new Error("Container not found");
await container.stop();
self.enabled.delete(name);
},
list() {
return Array.from(self.enabled.keys());
},
async save() {
const content = JSON.stringify({ enabled: self.list() });
await Deno.writeTextFile(state_path(), content);
},
};
try {
log("trying to recover state from", state_path());
const loaded = JSON.parse(await Deno.readTextFile(state_path()));
for (const name of loaded.enabled ?? []) {
await self.enable(name);
}
log("successfully loaded from", state_path());
} catch (error) {
log("experienced failure", error);
}
return self;
}

View file

@ -0,0 +1,26 @@
import { Base, BaseContext } from "../../../src/lib.ts";
export { build };
const build: Base = async (c) => {
// installs base
await c.sh(`pacstrap -K -c '${c.root_dir}' base`);
await c.sh(`rm -fr '${c.root_dir}/etc/securetty' '${c.root_dir}/usr/share/factory/etc/securetty'`);
await c.sh_in(`printf 'root:noussommesdescrabes' | chpasswd --crypt-method DES`);
await pacman(c, "nano", "git", "base-devel");
await c.sh_in(`systemctl enable systemd-networkd.service`);
// ssh
await pacman(c, "openssh");
await c.sh_in(`
echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config
systemctl enable sshd.service
`);
const port = c.available_port(2250);
c.redirect(port, 22);
};
async function pacman(c: BaseContext, ...packages: string[]) {
await c.sh_in(`pacman -Syu --noconfirm '${packages.join("' '")}'`);
}

14
instance/src/bin/proxy.ts Normal file → Executable file
View file

@ -1,11 +1,15 @@
#!/bin/env -S deno run -A --unstable #!/bin/env -S deno run -A --unstable
await main(); if (import.meta.main) await main();
async function main() { async function main() {
const [from_port, to_ip, to_port] = Deno.args; const [from_port, to_ip, to_port] = Deno.args;
if (to_port === undefined) Deno.exit(2); if (to_port === undefined) {
console.error("[proxy] usage: ./proxy.ts <from_port> <to_ip> <to_port>");
Deno.exit(2);
}
const server = Deno.listen({ transport: "tcp", port: parseInt(from_port) }); const server = Deno.listen({ transport: "tcp", port: parseInt(from_port) });
console.log("[proxy] listening on port", from_port, "redirecting to", to_ip, "port", to_port);
for await (const connection of server) { for await (const connection of server) {
try { try {
@ -16,3 +20,9 @@ async function main() {
} catch (_) { /* isok */ } } catch (_) { /* isok */ }
} }
} }
export function proxy_command(from_port: number, to_ip: string, to_port: number) {
const path = new URL("", import.meta.url).pathname;
const args = [from_port, to_ip, to_port].map((v) => v.toString());
return new Deno.Command(path, { args });
}

View file

@ -1,6 +1,13 @@
export type { Base, BaseContext } from "./lib/create.ts";
import { toText } from "https://deno.land/std@0.208.0/streams/to_text.ts"; import { toText } from "https://deno.land/std@0.208.0/streams/to_text.ts";
import { lines } from "./lib/utils.ts"; import { lines, log_from, loop_process, LoopProcess, run, sleep } from "./lib/utils.ts";
import { ContainerConfig } from "./lib/config.ts"; import { ContainerConfig } from "./lib/config.ts";
import { container_paths } from "./lib/paths.ts";
import { container_command, get_container_addresses } from "./lib/nspawn.ts";
import { proxy_command } from "./bin/proxy.ts";
const log = log_from("lib");
export type CmdStatus = ReturnType<typeof new_cmd_status>; export type CmdStatus = ReturnType<typeof new_cmd_status>;
export function new_cmd_status() { export function new_cmd_status() {
@ -17,15 +24,22 @@ export function new_cmd_disable(name: string) {
return { kind: "disable" as const, name }; return { kind: "disable" as const, name };
} }
export type CmdReload = ReturnType<typeof new_cmd_reload>;
export function new_cmd_reload(name: string) {
return { kind: "reload" as const, name };
}
export type CmdStop = ReturnType<typeof new_cmd_stop>; export type CmdStop = ReturnType<typeof new_cmd_stop>;
export function new_cmd_stop() { export function new_cmd_stop() {
return { kind: "stop" as const }; return { kind: "stop" as const };
} }
export type Cmd = CmdStatus | CmdEnable | CmdDisable | CmdStop; export type Cmd = CmdStatus | CmdEnable | CmdDisable | CmdReload | CmdStop;
export function daemon_listen(sock_path: string) { export async function daemon_listen(sock_path: string) {
const server = Deno.listen({ transport: "unix", path: sock_path }); const server = Deno.listen({ transport: "unix", path: sock_path });
await Deno.chmod(sock_path, 0o775);
await run("chgrp", "wheel", sock_path);
const generator = async function* () { const generator = async function* () {
for await (const request of server) { for await (const request of server) {
const respond = async (message: string) => { const respond = async (message: string) => {
@ -53,12 +67,52 @@ export async function daemon_send(sock_path: string, command: Cmd) {
return await toText(request.readable); return await toText(request.readable);
} }
export type Runner = ReturnType<typeof start_runner>; export type Runner = Awaited<ReturnType<typeof start_runner>>;
export function start_runner(config: ContainerConfig) { export function start_runner(config: ContainerConfig) {
const { name } = config;
const paths = container_paths(name);
const command = container_command(name, paths.root, {
boot: true,
veth: true,
cmd_opts: {
stdin: "null",
stdout: "null",
},
});
const proxies = [] as LoopProcess[];
const container_loop = loop_process(command, {
on_start: () => {
log("container", name, "started");
(async () => {
await sleep(1_000);
const [address] = await get_container_addresses(name);
proxies.push(...start_proxies(address, config.redirects));
})();
},
on_stop: async () => {
log("container", name, "stopped");
for (const p of proxies) await p.kill();
await sleep(500);
proxies.splice(0, proxies.length);
},
});
return { return {
name: config.name, name,
config,
container_loop,
stop: async () => { stop: async () => {
// TODO for (const p of proxies) await p.kill();
await container_loop.kill();
}, },
}; };
} }
function start_proxies(address: string, redirects: [number, number][]) {
const redirections = redirects
.map(([from_port, to_port]) => {
return loop_process(proxy_command(from_port, address, to_port), {
on_start: () => console.log("starting proxy", from_port, "to", address, "port", to_port),
});
});
return redirections;
}

View file

@ -1,5 +1,7 @@
import { container_paths } from "./paths.ts"; import { container_paths, containers_path } from "./paths.ts";
import { exists } from "./utils.ts"; import { exists, log_from } from "./utils.ts";
const log = log_from("config");
export type ContainerConfig = ReturnType<typeof new_container_config>; export type ContainerConfig = ReturnType<typeof new_container_config>;
export function new_container_config(name: string) { export function new_container_config(name: string) {
@ -11,11 +13,27 @@ export function new_container_config(name: string) {
} }
export async function load_container_config(name: string) { export async function load_container_config(name: string) {
const config_path = container_paths(name).config; const config_path = container_paths(name).configuration;
log("loading config for", name);
if (!exists(config_path)) return null; if (!exists(config_path)) return null;
const content = await Deno.readTextFile(config_path); const content = await Deno.readTextFile(config_path);
const read = JSON.parse(content); const read = JSON.parse(content);
const default_ = new_container_config(name); const default_ = new_container_config(name);
if (read.version < default_.version) throw new Error("read conf version is outdated"); if (read.version < default_.version) throw new Error("read conf version is outdated");
return { ...default_, ...read }; return { ...default_, ...read } as ContainerConfig;
}
export async function save_container_config(config: ContainerConfig) {
log("saving config of", config.name);
const config_path = container_paths(config.name).configuration;
const serialized = JSON.stringify(config, null, 4);
await Deno.writeTextFile(config_path, serialized);
}
export async function load_all_configs() {
const names = Array.from(Deno.readDirSync(containers_path()))
.filter((e) => e.isDirectory)
.map((e) => e.name);
const results = await Promise.all(names.map((name) => load_container_config(name)));
return results.filter((item) => item != null) as ContainerConfig[];
} }

View file

@ -0,0 +1,69 @@
// import { blob } from "https://deno.land/std@0.208.0/streams/mod.ts"
import { ContainerConfig } from "./config.ts";
import { base_paths } from "./paths.ts";
import { lines, log_from } from "./utils.ts";
export type Base = (context: BaseContext) => unknown;
const log = log_from("BUILDING");
export type BaseContext = ReturnType<typeof new_container_context>;
export function new_container_context(root_dir: string, config: ContainerConfig, known_containers: ContainerConfig[]) {
return {
root_dir,
config,
sh: async (script: string) => {
log(`running '${script.replace("\n", " ").slice(0, 20)}...'`);
const process = new Deno.Command("sudo", {
args: ["sh", "-c", "set -e;" + script],
stdin: "null",
stdout: "piped",
}).spawn();
log_lines_prefixed(" | ", process.stdout);
const res = await process.status;
if (!res.success) throw new Error("process failed");
},
sh_in: async (script: string) => {
log(`inside '${root_dir}' running '${script.replace("\n", " ").slice(0, 20)}...'`);
const process = new Deno.Command("sudo", {
args: ["systemd-nspawn", `--directory=${root_dir}`, "-P"],
stdin: "piped",
stdout: "piped",
}).spawn();
write_all(process.stdin, "set -e;" + script + "\nexit 0\n");
log_lines_prefixed(" | ", process.stdout);
const res = await process.status;
if (!res.success) throw new Error("process failed");
},
redirect: (from: number, to: number) => {
log("redirects", from, "to", to);
config.redirects.push([from, to]);
},
available_port: (target: number) => {
const used_by_known_containers = known_containers.map((c) => c.redirects[0]).flat();
const used_by_self = config.redirects.map((r) => r[0]);
const used = [...used_by_known_containers, ...used_by_self];
let result = target;
while (used.includes(result)) result += 1;
return result;
},
};
}
async function log_lines_prefixed(prefix: string, stream: ReadableStream<Uint8Array>) {
for await (const line of lines(stream)) {
console.log(prefix + line);
}
}
async function write_all(stream: WritableStream<Uint8Array>, text: string) {
await stream.getWriter().write(new TextEncoder().encode(text));
}
export async function load_base(name: string) {
const paths = base_paths(name);
const module = await import(paths.module);
const build = module["build"] as (context: BaseContext) => unknown;
if (build === undefined) throw new Error("module does not contain a build procedure");
return { build };
}

View file

@ -1 +1,37 @@
// wrapper // wrapper
import { log_from, sleep } from "./utils.ts";
const log = log_from("nspawn");
export function container_command(name: string, directory: string, opts?: {
veth?: boolean;
boot?: boolean;
cmd_opts?: Deno.CommandOptions;
}) {
const args = [
`--machine=${name}`,
`--directory=${directory}`,
];
if (opts?.veth ?? false) args.push("--network-veth");
if (opts?.boot ?? false) args.push("--boot");
const command = new Deno.Command("systemd-nspawn", { ...opts?.cmd_opts, args });
return command;
}
export async function get_container_addresses(name: string) {
const command = new Deno.Command("machinectl", { args: ["status", name], stdout: "piped" });
while (true) {
await sleep(500);
log("getting address of", name);
const { code, stdout } = await command.output();
if (code != 0) continue;
const output = new TextDecoder().decode(stdout);
const [_, rest] = output.split(" Address: ");
if (rest === undefined) continue;
const [raw] = rest.split(" OS: ");
const addresses = raw.trim().split("\n").map((l) => l.trim());
if (addresses.length === 0) continue;
return addresses;
}
}

View file

@ -16,7 +16,21 @@ export function containers_path() {
export function container_paths(name: string) { export function container_paths(name: string) {
const base = containers_path() + "/" + name; const base = containers_path() + "/" + name;
const conf = base + "/config.json"; const configuration = base + "/config.json";
const root = base + "/root"; const root = base + "/root";
return { base, configuration: conf, root }; return { base, configuration, root };
}
export function state_path() {
return instance_root_path() + "/local/state.json";
}
export function bases_path() {
return instance_root_path() + "/local/bases";
}
export function base_paths(name: string) {
const base = bases_path() + "/" + name;
const module = base + "/module.ts";
return { base, module };
} }

View file

@ -1,5 +1,6 @@
import { TextLineStream } from "https://deno.land/std@0.208.0/streams/mod.ts"; import { TextLineStream } from "https://deno.land/std@0.208.0/streams/mod.ts";
export type Channel<T> = ReturnType<typeof channel<T>>;
export function channel<T>() { export function channel<T>() {
const inner = { const inner = {
queue: [] as T[], queue: [] as T[],
@ -41,3 +42,54 @@ export function lines(readable: ReadableStream<Uint8Array>) {
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream()); .pipeThrough(new TextLineStream());
} }
const async_noop = async () => {};
export type LoopProcess = ReturnType<typeof loop_process>;
export function loop_process(
command: Deno.Command,
opts?: { delay?: number; on_start?: () => unknown; on_stop?: () => unknown },
) {
const events = {
on_start: opts?.on_start ?? async_noop,
on_stop: opts?.on_stop ?? async_noop,
};
const kill_sig = channel<"kill">();
async function launch() {
while (true) {
await events.on_start();
const child_process = command.spawn();
const result = await Promise.any([kill_sig.receive(), child_process.output()]);
if (result === "kill") {
await events.on_stop();
child_process.kill();
break;
}
await events.on_stop();
await sleep(opts?.delay ?? 500);
}
}
const looping = launch();
return {
kill: async () => {
kill_sig.send("kill"), await looping;
},
events,
looping,
};
}
export function sleep(ms: number) {
return new Promise((resolver) => setTimeout(resolver, ms));
}
export async function run(command: string, ...args: string[]) {
const { code } = await new Deno.Command(command, { args }).output();
if (code != 0) throw new Error(`command ${command} failed`);
}
export function log_from(...prefixes: string[]) {
const prefix = prefixes.map((p) => `[${p}]`).join("").padStart(10);
return function (...args: unknown[]) {
console.log(prefix, ...args);
};
}

14
sync.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/sh
set -e
cd "$(dirname "$(realpath "$0")")"
if [ $# -lt 1 ]
then exit 2
fi
DEST="$1"
rsync -avzhP ./instance "$DEST"