include missing files

Signed-off-by: Travis Glenn Hansen <travisghansen@yahoo.com>
This commit is contained in:
Travis Glenn Hansen 2024-03-18 10:56:22 -06:00
parent 7911bc9200
commit d7919e766d
3 changed files with 880 additions and 0 deletions

37
docker/kopia-installer.sh Executable file
View File

@ -0,0 +1,37 @@
#!/bin/bash
set -e
set -x
PLATFORM_TYPE=${1}
if [[ "${PLATFORM_TYPE}" == "build" ]]; then
PLATFORM=$BUILDPLATFORM
else
PLATFORM=$TARGETPLATFORM
fi
if [[ "x${PLATFORM}" == "x" ]]; then
PLATFORM="linux/amd64"
fi
# these come from the --platform option of buildx, indirectly from DOCKER_BUILD_PLATFORM in main.yaml
if [ "$PLATFORM" = "linux/amd64" ]; then
export PLATFORM_ARCH="amd64"
elif [ "$PLATFORM" = "linux/arm64" ]; then
export PLATFORM_ARCH="arm64"
elif [ "$PLATFORM" = "linux/arm/v7" ]; then
export PLATFORM_ARCH="armhv"
else
echo "unsupported/unknown kopia PLATFORM ${PLATFORM}"
exit 0
fi
export DEB_FILE="kopia.deb"
echo "I am installing kopia $KOPIA_VERSION"
wget -O "${DEB_FILE}" "https://github.com/kopia/kopia/releases/download/v${KOPIA_VERSION}/kopia_${KOPIA_VERSION}_linux_${PLATFORM_ARCH}.deb"
dpkg -i "${DEB_FILE}"
rm "${DEB_FILE}"

349
src/utils/kopia.js Normal file
View File

@ -0,0 +1,349 @@
const _ = require("lodash");
const cp = require("child_process");
const uuidv4 = require("uuid").v4;
const DEFAULT_TIMEOUT = process.env.KOPIA_DEFAULT_TIMEOUT || 90000;
/**
* https://kopia.io/
*/
class Kopia {
constructor(options = {}) {
const kopia = this;
kopia.options = options;
kopia.client_intance_uuid = uuidv4();
options.paths = options.paths || {};
if (!options.paths.kopia) {
options.paths.kopia = "kopia";
}
if (!options.paths.sudo) {
options.paths.sudo = "/usr/bin/sudo";
}
if (!options.paths.chroot) {
options.paths.chroot = "/usr/sbin/chroot";
}
if (!options.env) {
options.env = {};
}
options.env[
"KOPIA_CONFIG_PATH"
] = `/tmp/kopia/${kopia.client_intance_uuid}/repository.config`;
options.env["KOPIA_CHECK_FOR_UPDATES"] = "false";
options.env[
"KOPIA_CACHE_DIRECTORY"
] = `/tmp/kopia/${kopia.client_intance_uuid}/cache`;
options.env[
"KOPIA_LOG_DIR"
] = `/tmp/kopia/${kopia.client_intance_uuid}/log`;
if (!options.executor) {
options.executor = {
spawn: cp.spawn,
};
}
if (!options.logger) {
options.logger = console;
}
options.logger.info(
`kopia client instantiated with client_instance_uuid: ${kopia.client_intance_uuid}`
);
if (!options.global_flags) {
options.global_flags = [];
}
}
/**
* kopia repository connect
*
* https://kopia.io/docs/reference/command-line/common/repository-connect-from-config/
*
* --override-hostname
* --override-username
*
* @param {*} options
*/
async repositoryConnect(options = []) {
const kopia = this;
let args = ["repository", "connect"];
args = args.concat(kopia.options.global_flags);
args = args.concat(options);
try {
await kopia.exec(kopia.options.paths.kopia, args);
return;
} catch (err) {
throw err;
}
}
/**
* kopia repository status
*
* @param {*} options
*/
async repositoryStatus(options = []) {
const kopia = this;
let args = ["repository", "status", "--json"];
args = args.concat(kopia.options.global_flags);
args = args.concat(options);
let result;
try {
result = await kopia.exec(kopia.options.paths.kopia, args);
return result;
} catch (err) {
throw err;
}
}
/**
* kopia snapshot list
*
* @param {*} options
*/
async snapshotList(options = []) {
const kopia = this;
let args = [];
args = args.concat(["snapshot", "list", "--json"]);
args = args.concat(kopia.options.global_flags);
args = args.concat(options);
let result;
try {
result = await kopia.exec(kopia.options.paths.kopia, args, {
operation: "snapshot-list",
});
return result.parsed;
} catch (err) {
throw err;
}
}
/**
* kopia snapshot list
*
* @param {*} snapshot_id
*/
async snapshotGet(snapshot_id) {
const kopia = this;
let args = [];
args = args.concat(["snapshot", "list", "--json", "--all"]);
args = args.concat(kopia.options.global_flags);
let result;
try {
result = await kopia.exec(kopia.options.paths.kopia, args, {
operation: "snapshot-list",
});
return result.parsed.find((item) => {
return item.id == snapshot_id;
});
} catch (err) {
throw err;
}
}
/**
* kopia snapshot create
*
* @param {*} options
*/
async snapshotCreate(options = []) {
const kopia = this;
let args = [];
args = args.concat(["snapshot", "create", "--json"]);
args = args.concat(kopia.options.global_flags);
args = args.concat(options);
let result;
try {
result = await kopia.exec(kopia.options.paths.kopia, args, {
operation: "snapshot-create",
});
return result.parsed;
} catch (err) {
throw err;
}
}
/**
* kopia snapshot delete <id>
*
* @param {*} options
*/
async snapshotDelete(options = []) {
const kopia = this;
let args = [];
args = args.concat(["snapshot", "delete", "--delete"]);
args = args.concat(kopia.options.global_flags);
args = args.concat(options);
let result;
try {
result = await kopia.exec(kopia.options.paths.kopia, args, {
operation: "snapshot-delete",
});
return result;
} catch (err) {
if (
err.code == 1 &&
(err.stderr.includes("no snapshots matched") ||
err.stderr.includes("invalid content hash"))
) {
return;
}
throw err;
}
}
/**
* kopia snapshot restore <snapshot_id[/sub/path]> /path/to/restore/to
*
* @param {*} options
*/
async snapshotRestore(options = []) {
const kopia = this;
let args = [];
args = args.concat(["snapshot", "restore"]);
args = args.concat(kopia.options.global_flags);
args = args.concat(options);
let result;
try {
result = await kopia.exec(kopia.options.paths.kopia, args, {
operation: "snapshot-restore",
});
return result;
} catch (err) {
if (
err.code == 1 &&
(err.stderr.includes("no snapshots matched") ||
err.stderr.includes("invalid content hash"))
) {
return;
}
throw err;
}
}
exec(command, args, options = {}) {
if (!options.hasOwnProperty("timeout")) {
options.timeout = DEFAULT_TIMEOUT;
}
const kopia = this;
args = args || [];
if (kopia.options.sudo) {
args.unshift(command);
command = kopia.options.paths.sudo;
}
options.env = {
...{},
...process.env,
...kopia.options.env,
...options.env,
};
let tokenIndex = args.findIndex((value) => {
return value.trim() == "--token";
});
let cleansedArgs = [...args];
if (tokenIndex >= 0) {
cleansedArgs[tokenIndex + 1] = "redacted";
}
const cleansedLog = `${command} ${cleansedArgs.join(" ")}`;
console.log("executing kopia command: %s", cleansedLog);
return new Promise((resolve, reject) => {
let stdin;
if (options.stdin) {
stdin = options.stdin;
delete options.stdin;
}
const child = kopia.options.executor.spawn(command, args, options);
if (stdin) {
child.stdin.write(stdin);
}
let stdout = "";
let stderr = "";
const log_progress_output = _.debounce(
(data) => {
const lines = data.split("\n");
/**
* get last line, remove spinner, etc
*/
const line = lines
.slice(-1)[0]
.trim()
.replace(/^[\/\\\-\|] /gi, "");
kopia.options.logger.info(
`kopia ${options.operation} progress: ${line.trim()}`
);
},
250,
{ leading: true, trailing: true, maxWait: 5000 }
);
child.stdout.on("data", function (data) {
data = String(data);
stdout += data;
});
child.stderr.on("data", function (data) {
data = String(data);
stderr += data;
switch (options.operation) {
case "snapshot-create":
log_progress_output(data);
break;
default:
break;
}
});
child.on("close", function (code) {
const result = { code, stdout, stderr, timeout: false };
if (!result.parsed) {
try {
result.parsed = JSON.parse(result.stdout);
} catch (err) {}
}
// timeout scenario
if (code === null) {
result.timeout = true;
reject(result);
}
if (code) {
reject(result);
} else {
resolve(result);
}
});
});
}
}
module.exports.Kopia = Kopia;

494
src/utils/restic.js Normal file
View File

@ -0,0 +1,494 @@
const _ = require("lodash");
const cp = require("child_process");
const DEFAULT_TIMEOUT = process.env.RESTIC_DEFAULT_TIMEOUT || 90000;
/**
* https://restic.net/
*/
class Restic {
constructor(options = {}) {
const restic = this;
restic.options = options;
options.paths = options.paths || {};
if (!options.paths.restic) {
options.paths.restic = "restic";
}
if (!options.paths.sudo) {
options.paths.sudo = "/usr/bin/sudo";
}
if (!options.paths.chroot) {
options.paths.chroot = "/usr/sbin/chroot";
}
if (!options.env) {
options.env = {};
}
if (!options.executor) {
options.executor = {
spawn: cp.spawn,
};
}
if (!options.logger) {
options.logger = console;
}
if (!options.global_flags) {
options.global_flags = [];
}
}
/**
* restic init
*
* @param {*} options
*/
async init(options = []) {
const restic = this;
let args = ["init", "--json"];
args = args.concat(restic.options.global_flags);
args = args.concat(options);
try {
await restic.exec(restic.options.paths.restic, args);
return;
} catch (err) {
if (err.code == 1 && err.stderr.includes("already")) {
return;
}
throw err;
}
}
/**
* restic unlock
*
* @param {*} options
*/
async unlock(options = []) {
const restic = this;
let args = ["unlock", "--json"];
args = args.concat(restic.options.global_flags);
args = args.concat(options);
try {
await restic.exec(restic.options.paths.restic, args);
return;
} catch (err) {
throw err;
}
}
/**
* restic backup
*
* @param {*} path
* @param {*} options
*/
async backup(path, options = []) {
const restic = this;
let args = [];
args = args.concat(["backup", "--json"]);
args = args.concat(restic.options.global_flags);
args = args.concat(options);
args = args.concat([path]);
let result;
try {
result = await restic.exec(restic.options.paths.restic, args, {
operation: "backup",
timeout: 0,
});
return result;
} catch (err) {
throw err;
}
}
/**
* restic tag
*
* @param {*} options
*/
async tag(options = []) {
const restic = this;
let args = [];
args = args.concat(["tag", "--json"]);
args = args.concat(restic.options.global_flags);
args = args.concat(options);
let result;
try {
result = await restic.exec(restic.options.paths.restic, args, {
operation: "tag",
});
return result;
} catch (err) {
throw err;
}
}
/**
* restic snapshots
*
* @param {*} options
*/
async snapshots(options = []) {
const restic = this;
let args = [];
args = args.concat(["snapshots", "--json", "--no-lock"]);
args = args.concat(restic.options.global_flags);
args = args.concat(options);
restic.parseTagsFromArgs(args);
let result;
try {
result = await restic.exec(restic.options.paths.restic, args, {
operation: "snapshots",
});
let snapshots = [];
result.parsed.forEach((item) => {
if (item.id) {
snapshots.push(item);
}
if (item.snapshots) {
snapshots.push(...item.snapshots);
}
});
return snapshots;
} catch (err) {
throw err;
}
}
/**
* restic snapshots
*
* @param {*} options
*/
async snapshot_exists(snapshot_id) {
const restic = this;
const snapshots = await restic.snapshots([snapshot_id]);
return snapshots.length > 0;
}
/**
* restic forget
*
* @param {*} options
*/
async forget(options = []) {
const restic = this;
let args = [];
args = args.concat(["forget", "--json"]);
args = args.concat(restic.options.global_flags);
args = args.concat(options);
let result;
try {
result = await restic.exec(restic.options.paths.restic, args, {
operation: "forget",
});
return result.parsed;
} catch (err) {
if (err.code == 1 && err.stderr.includes("no such file or directory")) {
return [];
}
throw err;
}
}
/**
* restic stats
*
* @param {*} options
*/
async stats(options = []) {
const restic = this;
let args = [];
args = args.concat(["stats", "--json", "--no-lock"]);
args = args.concat(restic.options.global_flags);
args = args.concat(options);
let result;
try {
result = await restic.exec(restic.options.paths.restic, args, {
operation: "stats",
timeout: 0, // can take a very long time to gather up details
});
return result.parsed;
} catch (err) {
throw err;
}
}
/**
* restic restore
*
* note that restore does not do any delete operations (ie: not like rsync --delete)
*
* @param {*} options
*/
async restore(options = []) {
const restic = this;
let args = ["restore", "--json", "--no-lock"];
args = args.concat(restic.options.global_flags);
args = args.concat(options);
let result;
try {
result = await restic.exec(restic.options.paths.restic, args, {
operation: "restore",
timeout: 0,
});
return result.parsed;
} catch (err) {
if (err.code == 1 && err.stderr.includes("Fatal:")) {
const lines = err.stderr.split("\n").filter((item) => {
return Boolean(String(item).trim());
});
const last_line = lines[lines.length - 1];
const ingored_count = (err.stderr.match(/ignoring error/g) || [])
.length;
restic.options.logger.info(
`restic ignored error count: ${ingored_count}`
);
restic.options.logger.info(`restic stderr last line: ${last_line}`);
// if ignored count matches total count move on
// "Fatal: There were 2484 errors"
if (last_line.includes(String(ingored_count))) {
return err;
}
}
throw err;
}
}
trimResultData(result, options = {}) {
const trim_output_limt = options.max_entries || 50;
// trim stdout/stderr/parsed lines to X number
if (result.parsed && Array.isArray(result.parsed)) {
result.parsed = result.parsed.slice(trim_output_limt * -1);
}
result.stderr = result.stderr
.split("\n")
.slice(trim_output_limt * -1)
.join("\n");
result.stdout = result.stdout
.split("\n")
.slice(trim_output_limt * -1)
.join("\n");
return result;
}
parseTagsFromArgs(args) {
let tag_value_index;
let tags = args.filter((value, index) => {
if (String(value) == "--tag") {
tag_value_index = index + 1;
}
return tag_value_index == index;
});
tags = tags
.map((value) => {
if (value.includes(",")) {
return value.split(",");
}
return [value];
})
.flat();
return tags;
}
exec(command, args, options = {}) {
if (!options.hasOwnProperty("timeout")) {
options.timeout = DEFAULT_TIMEOUT;
}
const restic = this;
args = args || [];
if (restic.options.sudo) {
args.unshift(command);
command = restic.options.paths.sudo;
}
options.env = {
...{},
...process.env,
...restic.options.env,
...options.env,
};
const cleansedLog = `${command} ${args.join(" ")}`;
console.log("executing restic command: %s", cleansedLog);
return new Promise((resolve, reject) => {
let stdin;
if (options.stdin) {
stdin = options.stdin;
delete options.stdin;
}
const child = restic.options.executor.spawn(command, args, options);
if (stdin) {
child.stdin.write(stdin);
}
let stdout = "";
let stderr = "";
let code_override;
const log_progress_output = _.debounce(
(data) => {
let snapshot_id;
let path;
switch (options.operation) {
case "backup":
snapshot_id = `unknown_creating_new_snapshot_in_progress`;
path = args[args.length - 1];
break;
case "restore":
snapshot_id = args
.find((value) => {
return String(value).includes(":");
})
.split(":")[0];
let path_index;
path = args.find((value, index) => {
if (String(value) == "--target") {
path_index = index + 1;
}
return path_index == index;
});
break;
default:
return;
}
if (data.message_type == "status") {
delete data.current_files;
restic.options.logger.info(
`restic ${options.operation} progress: snapshot_id=${snapshot_id}, path=${path}`,
data
);
}
if (data.message_type == "summary") {
restic.options.logger.info(
`restic ${options.operation} summary: snapshot_id=${snapshot_id}, path=${path}`,
data
);
}
},
250,
{ leading: true, trailing: true, maxWait: 5000 }
);
child.stdout.on("data", function (data) {
data = String(data);
stdout += data;
switch (options.operation) {
case "backup":
case "restore":
try {
let parsed = JSON.parse(data);
log_progress_output(parsed);
} catch (err) {}
break;
}
});
child.stderr.on("data", function (data) {
data = String(data);
stderr += data;
if (
["forget", "snapshots"].includes(options.operation) &&
stderr.includes("no such file or directory")
) {
// short-circut the operation vs waiting for all the retries
// https://github.com/restic/restic/pull/2515
switch (options.operation) {
case "forget":
code_override = 1;
break;
case "snapshots":
code_override = 0;
break;
}
child.kill();
}
});
child.on("close", function (code) {
const result = { code, stdout, stderr, timeout: false };
if (!result.parsed) {
try {
result.parsed = JSON.parse(result.stdout);
} catch (err) {}
}
if (!result.parsed) {
try {
const lines = result.stdout.split("\n");
const parsed = [];
lines.forEach((line) => {
if (!line) {
return;
}
parsed.push(JSON.parse(line.trim()));
});
result.parsed = parsed;
} catch (err) {}
}
/**
* normalize array responses in scenarios where not enough came through
* to add newlines
*/
if (result.parsed && options.operation == "backup") {
if (!Array.isArray(result.parsed)) {
result.parsed = [result.parsed];
}
}
if (code == null && code_override != null) {
code = code_override;
}
// timeout scenario
if (code === null) {
result.timeout = true;
reject(result);
}
if (code) {
reject(result);
} else {
resolve(result);
}
});
});
}
}
module.exports.Restic = Restic;