scroll-backup
Version:
Configurable backup system with rsync and restic backends
376 lines (354 loc) • 12.9 kB
JavaScript
/* scroll backup system ____ __ ____ __
by 0E9B061F <0E9B061F@protonmail.com> | \| | \| |
This Source Code Form is subject to the terms of ====\ \=====\ \====
the Mozilla Public License, v. 2.0. If a copy of |__|\____|__|\____|
the MPL was not distributed with this file, You 2020 - 2024
can obtain one at https://mozilla.org/MPL/2.0/.
*/
import os from "node:os"
import init from "./init.mjs"
import { BackendError } from "./util.mjs"
const plat = os.platform()
export const mkcmds =(conf)=> {
conf.cmds.make("help:h", {
sig: "[COMMAND]",
info: "Print help information. If no COMMAND is given, print all help information.",
}, (conf, cmd) => {
conf.cmds.help(cmd)
})
conf.cmds.make("list:ls", {
sig: "[TARGET] [REPO]",
info: "List snapshots for the given target and repo, or all repos and targets if these are not given.",
}, async (conf, tname, rname, ...args) => {
const tags = conf.util.consume(tname)
try {
await conf.util.eachrepo(rname, "snapshots", `--host=${conf.rc.host}`, ...tags, ...args)
} catch(e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("list")
process.exit(1)
} else throw e
}
})
conf.cmds.make("plan:p", {
sig: "[NAME]",
info: "Run a plan by NAME. If no name is given, run the default plan.",
}, async (conf, name) => {
name ||= conf.plan
const plan = conf.plans[name]
for (let n = 0; n < plan.lines.length; n++) {
const line = plan.lines[n].split(" ")
await conf.cmds.run(...line)
}
})
conf.cmds.make("backup:b", {
sig: "[TARGET] [REPO] [TAG...]",
info: "Backup TARGET to REPO, with optional TAGs.",
}, async (conf, tname, rname, ...tags) => {
const tnames = conf.util.resolve("targets", tname)
const rnames = conf.util.resolve("backends", rname)
const gtags = [...conf.apptags, ...conf.rc.tags.global, ...tags]
if (!conf.util.iswild(conf.rc.subhost)) gtags.push(conf.rc.subhost)
if (!gtags.includes(conf.autoword)) {
if (conf.rc.auto) {
gtags.push(conf.autoword)
} else {
gtags.push(conf.userword)
}
}
let targn, target, repo, name
for (let tn = 0; tn < tnames.length; tn++) {
targn = tnames[tn]
target = conf.registry.targets[targn]
const utags = conf.rc.tags.targets[targn] || []
const ttags = [
...gtags,
`${targn}-backup`,
...utags,
]
const tags = conf.util.tagify(ttags)
const path = target.path
const exclude = target.exclude.map(e => `--exclude=${e}`)
const opts = []
if (!conf.rc.pre15) {
opts.push("--no-scan")
}
if (conf.iswin) opts.push("--use-fs-snapshot")
else opts.push("--one-file-system")
for (let rn = 0; rn < rnames.length; rn++) {
name = rnames[rn]
repo = conf.registry.backends[name]
if (repo.mode == "snap") {
await conf.util.snap(repo, "backup", ...opts, ...tags, ...path, ...exclude)
}
else if (repo.mode == "sync") {
if (plat == "win32") {
await conf.util.robo(repo, target, ...path)
} else {
await conf.util.sync(repo, target, ...path)
}
}
else throw new Error(`Unknown mode: ${repo.mode}`)
}
}
})
conf.cmds.make("freeze", {
sig: "TARGET REPO TITLE [TAG...]",
info: "Take frozen snapshot of TARGET to REPO, with a TITLE and optional TAGs. Frozen snapshots are ignored by the trim command.",
}, async (conf, tname, rname, title, ...tags) => {
if (!tname) {
conf.logger.err("must specify a TARGET to freeze", { indent: 1 })
conf.cmds.help("freeze")
} else if (!rname) {
conf.logger.err("must specify a REPO to freeze to", { indent: 1 })
conf.cmds.help("freeze")
} else if (!title) {
conf.logger.err("must specify a TITLE for the frozen snapshot", { indent: 1 })
conf.cmds.help("freeze")
} else {
rname = conf.util.forcebackend("snap", rname)
if (rname) {
rname = rname.join(",")
await conf.cmds.run("backup", tname, rname, conf.snapword, `${conf.snapabbr}-${title}`, ...tags)
} else {
conf.logger.err("can only freeze to repo backends", { indent: 1 })
conf.cmds.help("freeze")
process.exit(1)
}
}
})
conf.cmds.make("frozen", {
sig: "[TARGET] [REPO]",
info: "List frozen snapshots for the given target and repo.",
}, async (conf, tname, rname, ...args) => {
const tags = conf.util.consume(tname)
try {
await conf.util.eachrepo(rname, "snapshots", `--host=${conf.rc.host}`, ...tags, `--tag=${conf.snapword}`, ...args)
} catch(e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("frozen")
process.exit(1)
} else throw e
}
})
conf.cmds.make("init", {
sig: "",
info: "Perform initial setup.",
}, async (conf, tname, rname, ...args) => {
await init()
})
conf.cmds.make("trim:t", {
sig: "[TARGET] [REPO]",
info: "Forget and prune snapshots for TARGET in REPO.",
}, async (conf, tname, rname, ...args) => {
const tnames = conf.util.resolve("targets", tname)
const targets = tnames.map(n=> conf.registry.targets[n]).filter(t=> t.policy)
const policies = {}
targets.forEach(target => {
const policy = target.policy
if (!policies[policy]) policies[policy] = []
policies[policy].push(target)
})
let policy
const keys = Object.keys(policies)
for (let p = 0; p < keys.length; p++) {
policy = keys[p]
const subtargets = policies[policy]
let tags = subtargets.map(t => `--tag=${t.name}-backup`)
if (!conf.util.iswild(conf.subhost)) tags = tags.map(t => `${t},${conf.subhost}`)
policy = policy.split(" ")
try {
await conf.util.eachrepo(rname, "forget", "--prune", `--host=${conf.host}`, ...tags, ...policy, "--keep-tag", conf.snapword, ...args)
} catch(e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("trim")
process.exit(1)
} else throw e
}
}
})
conf.cmds.make("find:f", {
sig: "TARGET REPO [PATH...]",
info: "Search for snapshots containing one or more PATHs in the given TARGET and REPO.",
}, async (conf, tname, rname, ...paths) => {
const tags = conf.util.consume(tname)
try {
await conf.util.eachrepo(rname, "find", `--host=${conf.rc.host}`, ...tags, ...paths)
} catch(e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("find")
process.exit(1)
} else throw e
}
})
conf.cmds.make("restore:r", {
sig: "TARGET REPO PATH",
info: "Restore the given TARGET from REPO to PATH.",
}, async (conf, tname, rname, path, ...args) => {
if (!tname) {
conf.logger.err("must specify a TARGET to restore", {indent: 1})
conf.cmds.help("restore")
} else if (conf.util.ismulti(rname)) {
conf.logger.err("must specify a single REPO to restore from", {indent: 1})
conf.cmds.help("restore")
} else if (!conf.registry.backends[rname]) {
conf.logger.err("backend doesn't exist", {indent: 1})
conf.cmds.help("restore")
} else if (!path) {
conf.logger.err("must specify a PATH to restore to", {indent: 1})
conf.cmds.help("restore")
} else {
const be = conf.registry.backends[rname]
if (be.mode == "snap") {
const tags = conf.util.consume(tname)
await conf.util.snap(be, "restore", `--host=${conf.rc.host}`, `--target=${path}`, ...tags, "latest", ...args)
} else if (be.mode == "sync") {
const tnames = conf.util.resolve("targets", tname)
const targets = tnames.map(tn=> conf.registry.targets[tn])
let paths
if (plat == "win32") {
paths = targets.map(t=> be.syncpath(conf, t))
await conf.util.roborestore(be, path, ...paths)
} else {
if (be.proto == "ssh:") {
paths = targets.map(t=> be.sshhost(conf, t))
} else {
paths = targets.map(t=> be.syncpath(conf, t))
}
await conf.util.syncrestore(be, path, ...paths)
}
}
}
})
conf.cmds.make("unlock:u", {
sig: "[REPO]",
info: "Unlock REPO, or all repos.",
}, async (conf, rname, ...args) => {
try {
await conf.util.eachrepo(rname, "unlock", ...args)
} catch(e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("unlock")
process.exit(1)
} else throw e
}
})
conf.cmds.make("clean", {
sig: "[REPO]",
info: "Clean REPO, or all repos.",
}, async (conf, rname, ...args) => {
try {
await conf.util.eachrepo(rname, "cache", "--cleanup", ...args)
} catch (e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("clean")
process.exit(1)
} else throw e
}
})
conf.cmds.make("check:c", {
sig: "[REPO] [ARG...]",
info: "Check the integrity of REPO, or all repos.",
}, async (conf, rname, ...args) => {
try {
await conf.util.eachrepo(rname, "check", ...args)
} catch(e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("check")
process.exit(1)
} else throw e
}
})
conf.cmds.make("x", {
sig: "[REPO] [ARG...]",
info: "Execute arbitrary restic commands for the given REPO.",
}, async (conf, rname, ...args) => {
try {
await conf.util.eachrepo(rname, ...args)
} catch(e) {
if (e instanceof BackendError) {
conf.logger.err("Invalid backend given. This command only works with repos.", { indent: 1 })
conf.cmds.help("x")
process.exit(1)
} else throw e
}
})
conf.cmds.make("backends", {
sig: "",
info: "List all configured backends.",
}, (conf) => {
const str = Object.entries(conf.registry.backends).map(be=> {
return `${be[0]}: ${be[1].path}\n`
}).join("")
console.log(str)
})
conf.cmds.make("targets", {
sig: "",
info: "List all configured targets.",
}, (conf) => {
const str = Object.entries(conf.registry.targets).map(trgt=> {
const paths = trgt[1].path.map(p=> ` ${p}`).join("\n")
return `${trgt[0]}:\n${paths}\n`
}).join("")
console.log(str)
})
conf.cmds.make("show", {
sig: "",
info: "Show configuration details.",
}, async (conf) => {
console.log("BACKENDS:")
await conf.cmds.run("backends")
console.log("TARGETS:")
await conf.cmds.run("targets")
})
conf.cmds.make("report", {
sig: "[LONG]",
info: "Send a report if mailing is configured.",
}, async (conf, long) => {
if (!conf.rc.mail) {
conf.logger.err("email is not configured", {indent: 1})
console.log("ERROR: email is not configured")
} else {
const tags = conf.util.consume(conf.wild)
let list = await conf.util.eachrepo(conf.wild, "snapshots", `--host=${conf.rc.host}`, ...tags)
list = list.map(i => i.full).join("\n")
const date = new Date().toISOString()
if (long) {
let check = await conf.util.eachrepo(conf.wild, "check")
check = check.map(i=> i.full).join("\n")
const buffer = await conf.logger.dump("longbuf")
await conf.util.email(
{
boths: [
{filename: "long-buffer.log", content: buffer},
{filename: "snapshots.txt", content: list},
{filename: "check.txt", content: check},
],
},
`Weekly Report`,
`Scroll backup system long-report for ${date} on host ${os.hostname()}.\nSee attached data.`,
)
} else {
const buffer = await conf.logger.dump("shortbuf")
await conf.util.email(
{
boths: [
{ filename: "short-buffer.log", content: buffer },
{ filename: "snapshots.txt", content: list },
],
},
`Daily Report`,
`Scroll backup system short-report for ${date} on host ${os.hostname()}.\nSee attached data.`,
)
}
}
})
return conf
}