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