deployer: initial commit

master
Yorick van Pelt 2023-01-26 19:24:29 +01:00
parent 50c0592c9f
commit 4e3a8a9625
Signed by: yorick
GPG Key ID: A36E70F9DC014A15
6 changed files with 2044 additions and 0 deletions

188
deployer/index.ts Normal file
View File

@ -0,0 +1,188 @@
#!/usr/bin/env -S tsx
import { ssh, SSH } from './ssh.js'
import { Expression, BuiltOutput } from './nix.js'
class Cmd {
registry: {[index: string]: (() => Promise<void>)} = {};
constructor() {
}
register(obj: string, action: string, fn: () => Promise<void>) {
this.registry[`${obj}.${action}`] = fn
}
async run() {
const opt = argv._[0]
if (opt && this.registry[opt]) await this.registry[opt]()
else {
console.log("Possible options: \n")
for (const k of Object.keys(this.registry)) {
console.log("-", k)
}
}
}
}
const action = new Cmd()
async function ping(hostname: string) {
return await $`ping -Anqc3 ${hostname}`.exitCode == 0
}
class Machine {
name: string;
hasHome: boolean = false;
hostname?: string;
constructor(args: {name?: string, hasHome?: boolean, hostname?: string}) {
Object.assign(this, args)
// todo
this.name = ""
}
isLocal() {
return os.hostname() == this.name
}
get attr(): Expression {
return new Expression(`.#nixosConfigurations.${this.name}.toplevel`)
}
private async sshTarget() {
if (this.isLocal()) return "localhost"
if (this.hostname && await ping(this.hostname)) {
return this.hostname
} else {
// todo: directify
return `${name}.vpn.yori.cc`
}
}
async findIP(): Promise<typeof Machine.prototype.ssh> {
const host = await this.sshTarget()
return <R>(user?: string, cb?: () => Promise<R>): Promise<SSH|R> => {
const sshTarget = user ? `${user}@${host}` : host
if (cb !== undefined) return ssh(host, cb)
return ssh(host)
}
}
ssh(user?: string): Promise<SSH>
ssh<R>(user?: string, cb?: () => Promise<R>): Promise<R>
async ssh<R>(user?: string, cb?: () => Promise<R>) {
return (await this.findIP())(user, cb)
}
get targets() {
if (this.hasHome) return [this.attr, Home]
else return [this.attr]
}
}
const machines = {
frumar: new Machine({hostname: "frumar.home.yori.cc"}),
pennyworth: new Machine({hostname: "pennyworth.yori.cc"}),
blackadder: new Machine({hasHome: true}),
jarvis: new Machine({hasHome: true}),
smithers: new Machine({hasHome: true}),
}
for (const [name, machine] of Object.entries(machines))
machine.name = name
for (const machine of Object.values(machines)) {
action.register(machine.name, "ssh", async function() {
(await machine.ssh()).interactive()
})
action.register(machine.name, "gc", async () => {
machine.ssh("root", async () => {
await $`nix-collect-garbage -d`
})
})
action.register(machine.name, "eval", async () => {
await Promise.all(machine.targets.map(x => x.derive()))
})
action.register(machine.name, "build", async () => {
await Promise.all(machine.targets.map(x => x.build()))
})
action.register(machine.name, "copy", async () => {
const machineSSH = await machine.findIP()
// todo merge builds
const outputs = (await Promise.all(machine.targets.map(x => x.build()))).flat()
if (machine.isLocal()) {
console.log("skipping copy, is localhost")
return
}
const conn = await machineSSH()
await Promise.all(outputs.map(x => x.copy(conn)))
// machine.toplevel.buildAndCopy(machine.ssh)
// Home.buildAndCopy(machine.ssh)
})
action.register(machine.name, "boot-deploy", async () => {
const machineSSH = await machine.findIP()
const path = (await machine.attr.build()).out
if (!machine.isLocal()) {
const conn = await machineSSH()
await path.copy(conn)
}
// machine.toplevel.buildAndCopy(machine.ssh)
// machine.ssh.within(machine.toplevel.activate("boot"))
await machineSSH("root", async () => {
await $`nix-env -p /nix/var/nix/profiles/system --set ${path}`
await $`${path}/bin/switch-to-configuration boot`
})
})
action.register(machine.name, "switch", async () => {
const machineSSH = await machine.findIP()
const path = (await machine.attr.build()).out
if (!machine.isLocal()) {
const conn = await machineSSH()
await path.copy(conn)
}
const new_kernel = (await $`readlink ${path.path}/kernel`).stdout
await machineSSH("root", async () => {
const old_kernel = (await $`readlink /run/booted-system/kernel`).stdout
if (new_kernel !== old_kernel) {
console.error(`[${machine.name}] requires reboot because of kernel update`)
process.exit(1)
}
await $`nix-env -p /nix/var/nix/profiles/system --set ${path.path}`
await $`${path}/bin/switch-to-configuration switch`
})
})
if (machine.hasHome) {
action.register(machine.name, "update-home", async () => {
const new_path = (await Home.build()).out
if (machine.isLocal()) {
await $`${new_path.path}/activate`
} else {
const conn = await machine.ssh()
await new_path.copy(conn)
await conn.within(() => $`${new_path.path}/activate`)
}
})
}
action.register(machine.name, "status", async () => {
await machine.ssh(undefined, async () => {
await $`systemctl is-system-running`
await $`zpool status -x`
await $`realpath /run/current-system /nix/var/nix/profiles/system`
})
})
}
action.register("all", "build", async () => {
console.log(await (new Expression(".#")).build())
})
const Home = new Expression(".#yorick-home")
action.register("home", "build", async () => {
console.log(await Home.build())
})
action.register("home", "eval", async () => {
console.log(await Home.derive())
})
action.register("local", "boot-deploy", async () => {
await action.registry[`${os.hostname()}.boot-deploy`]()
})
action.register("local", "status", async () => {
await action.registry[`${os.hostname()}.status`]()
})
await action.run()

84
deployer/nix.ts Normal file
View File

@ -0,0 +1,84 @@
import 'zx/globals';
import { SSH } from './ssh.js'
type DrvPath = string
type OutPath = string
type NixPath = DrvPath | OutPath
type ShownDerivation = {
args: Array<string>,
builder: string,
env: { [index: string]: string } ,
inputDrvs: { [index: DrvPath]: Array<String> },
inputSrcs: Array<OutPath>,
outputs: { [index: string]: { path: OutPath }},
system: "x86-64_linux"
};
type ShowDerivationOutput = {
[index: DrvPath]: ShownDerivation
}
export class Expression {
expr: string
constructor(expr: string) {
this.expr = expr
}
async derive(): Promise<Derivation> {
const drvPath = await nix.eval(this.expr)
const drv = await nix.showDerivation(drvPath)
return new Derivation(drvPath, drv[drvPath])
}
async build(): Promise<{out: BuiltOutput} & Array<BuiltOutput>> {
const outputs = await nix.build(this.expr)
const drvMetaJson = await nix.showDerivation(outputs[0])
const [drvPath, drvMeta] = Object.entries(drvMetaJson)[0]
const drv = new Derivation(drvPath, drvMeta)
const ret = outputs.map(x => new BuiltOutput(x, drv))
return Object.assign(ret, { out: new BuiltOutput(drv.meta.outputs.out.path, drv) })
}
}
export class Derivation {
path: string
//outputs?: Array<BuiltOutput>
meta: ShownDerivation
constructor(path: string, meta: ShownDerivation) {
this.path = path
this.meta = meta
}
async build(): Promise<{out: BuiltOutput} & Array<BuiltOutput>> {
const outputs = await nix.build(this.path)
const ret = outputs.map(x => new BuiltOutput(x, this))
return Object.assign(ret, { out: new BuiltOutput(this.meta.outputs.out.path, this) })
}
}
export class BuiltOutput {
path: string
drv: Derivation
constructor(path: string, drv: Derivation) {
this.path = path
this.drv = drv
}
async copy(target: SSH): Promise<void> {
return nix.copy(this.path, target)
}
}
export const nix = {
async build(attr: string | Array<string>, name="result"): Promise<Array<OutPath>> {
const tmp = (await $`mktemp -d`).stdout.trim()
process.on('exit', () => fs.removeSync(tmp))
await $`nom build ${attr} -o ${tmp}/${name}`
const files = await fs.readdir(`${tmp}`)
return Promise.all(files.map(f => fs.readlink(`${tmp}/${f}`)))
},
async showDerivation(path: NixPath): Promise<ShowDerivationOutput> {
return JSON.parse((await $`nix show-derivation ${path}`.quiet()).stdout)
},
async eval(attr: string) : Promise<DrvPath> {
return JSON.parse((await $`nix eval ${attr}.drvPath --json`).stdout)
},
async copy(attrs: string | Array<string>, target: SSH): Promise<void> {
process.env.NIX_SSHOPTS = "-o compression=no";
await $`nix copy ${attrs} --to ssh://${target.host}`
}
}

1655
deployer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
deployer/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "deployer",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"test": "test"
},
"type": "module",
"scripts": {
"watch": "exec tsc --noEmit -w",
"start": "exec tsx ./index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.yori.cc/yorick/dotfiles"
},
"author": "",
"license": "ISC",
"dependencies": {
"tsx": "^3.12.2",
"typescript": "^4.9.4",
"zx": "^7.1.1"
}
}

76
deployer/ssh.ts Normal file
View File

@ -0,0 +1,76 @@
import 'zx/globals';
import { retry } from 'zx/experimental'
import { spawn, SpawnOptions, ChildProcess } from 'child_process'
import { Socket } from 'node:net'
export function ssh(host: string): Promise<SSH>
export function ssh<R>(host: string, cb: () => Promise<R>): Promise<R>
export async function ssh<R>(host: string, cb?: () => Promise<R>) {
const ret = new SSH(host)
try {
await ($`ssh ${host} -O check`).quiet()
} catch (p) {
if (p instanceof ProcessOutput && p.exitCode == 255) {
console.log("Spawning ssh master")
let x = $`ssh ${host} -M -N -o compression=no`
await sleep(1000)
await retry(20, '1s', () => $`ssh ${host} -O check`.quiet())
console.log("SSH connected")
if (x.child) {
x.child.unref()
if (x.child.stderr instanceof Socket) x.child.stderr.unref()
if (x.child.stdout instanceof Socket) x.child.stdout.unref()
ret.child = x.child
process.on('beforeExit', () => {
ret.stop()
})
} else {
console.warn("Failed to spawn SSH master, but someone else did")
}
} else {
throw p
}
}
if (cb !== undefined) return ret.within(cb)
else return ret
}
export class SSH {
host: string;
child?: ChildProcess | null;
constructor(host: string) { this.host = host }
within<R>(cb: () => Promise<R>): Promise<R> {
return within(async () => {
$.spawn = (command: string, options: any): any => {
const stdio = ["pipe", options.stdio[1], options.stdio[2]]
const proc: ChildProcess = spawn("ssh", [this.host, options.shell],
Object.assign({}, options, {shell: false, stdio}))
// todo: type safety
if (!proc.stdin) throw new Error("Failed to spawn input pipe")
proc.stdin.write(command + "; exit $?\n")
if (options.stdio[0] == 'inherit') process.stdin.pipe(proc.stdin)
return proc
}
$.log = (entry) => {
switch(entry.kind) {
case 'cmd':
if (entry.verbose) process.stderr.write(`[${this.host}] `)
break;
}
log(entry)
}
return cb()
})
}
interactive() {
$`ssh ${this.host}`
}
stop() {
if (this.child) {
$`ssh ${this.host} -O stop`
this.child = null
}
}
}

14
deployer/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "es2022",
"allowSyntheticDefaultImports": true,
"moduleResolution": "nodenext",
"target": "es2022",
"strict": true
},
"files": [
"ssh.ts",
"nix.ts",
"index.ts",
]
}