deployer: initial commit
parent
50c0592c9f
commit
4e3a8a9625
|
@ -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()
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "es2022",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"target": "es2022",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"ssh.ts",
|
||||||
|
"nix.ts",
|
||||||
|
"index.ts",
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue