From 7c11ba18fd602b8a1dc68477dc5684b60c106999 Mon Sep 17 00:00:00 2001 From: Niccolo Borgioli Date: Sun, 9 Jul 2023 17:49:33 +0200 Subject: [PATCH] initial 2 commit --- .gitignore | 2 + bun.lockb | Bin 0 -> 20936 bytes migration.md | 14 ++ package.json | 23 +++ schema/config.json | 252 ++++++++++++++++++++++++++++++++ scripts/generateSchema.ts | 17 +++ src/cmd/backup.ts | 14 ++ src/cmd/check.ts | 25 ++++ src/config/env/file.test.ts | 21 +++ src/config/env/file.ts | 55 +++++++ src/config/index.ts | 29 ++++ src/config/resolution.ts | 23 +++ src/config/schema/common.ts | 20 +++ src/config/schema/config.ts | 69 +++++++++ src/config/schema/hooks.ts | 14 ++ src/config/schema/location.ts | 28 ++++ src/config/schema/options.ts | 15 ++ src/config/schema/repository.ts | 18 +++ src/errors/index.ts | 47 ++++++ src/index.ts | 138 +++++++++++++++++ src/lock/index.test.ts | 20 +++ src/lock/index.ts | 78 ++++++++++ src/lock/schema.ts | 13 ++ src/logger.ts | 37 +++++ src/models/context.ts | 25 ++++ src/models/location.ts | 28 ++++ src/models/repository.ts | 66 +++++++++ src/restic/index.ts | 44 ++++++ src/utils/array.test.ts | 12 ++ src/utils/array.ts | 7 + src/utils/path.test.ts | 33 +++++ src/utils/path.ts | 24 +++ src/utils/time.ts | 3 + tsconfig.json | 8 + 34 files changed, 1222 insertions(+) create mode 100644 .gitignore create mode 100755 bun.lockb create mode 100644 migration.md create mode 100644 package.json create mode 100644 schema/config.json create mode 100644 scripts/generateSchema.ts create mode 100644 src/cmd/backup.ts create mode 100644 src/cmd/check.ts create mode 100644 src/config/env/file.test.ts create mode 100644 src/config/env/file.ts create mode 100644 src/config/index.ts create mode 100644 src/config/resolution.ts create mode 100644 src/config/schema/common.ts create mode 100644 src/config/schema/config.ts create mode 100644 src/config/schema/hooks.ts create mode 100644 src/config/schema/location.ts create mode 100644 src/config/schema/options.ts create mode 100644 src/config/schema/repository.ts create mode 100644 src/errors/index.ts create mode 100644 src/index.ts create mode 100644 src/lock/index.test.ts create mode 100644 src/lock/index.ts create mode 100644 src/lock/schema.ts create mode 100644 src/logger.ts create mode 100644 src/models/context.ts create mode 100644 src/models/location.ts create mode 100644 src/models/repository.ts create mode 100644 src/restic/index.ts create mode 100644 src/utils/array.test.ts create mode 100644 src/utils/array.ts create mode 100644 src/utils/path.test.ts create mode 100644 src/utils/path.ts create mode 100644 src/utils/time.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..417d081 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +/autorestic diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..e4da522b1a60016925ac8baa57dc95089016dc60 GIT binary patch literal 20936 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 literal 0 HcmV?d00001 diff --git a/migration.md b/migration.md new file mode 100644 index 0000000..706dad9 --- /dev/null +++ b/migration.md @@ -0,0 +1,14 @@ +# Rename backend to repository + +# Env variables + +AUTORESTIC_BB_B2_ACCOUNT_ID=123 -> AUTORESTIC_BACKENDS_BB_ENV_B2**ACCOUNT**ID=123 + +- All fields can be configured by env now +- To escape `_` replace it with double underscore `__` + +# Rest property on backend config + +No rest property anymore, can be used in string extrapolation + +# Every string is now replaceable with env variables diff --git a/package.json b/package.json new file mode 100644 index 0000000..1bae615 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "name": "autorestic", + "module": "src/index.ts", + "type": "module", + "scripts": { + "schema:gen": "bun run ./scripts/generateSchema.ts", + "bin": "bun build ./src/index.ts --compile --outfile autorestic" + }, + "devDependencies": { + "bun-types": "^0.6.0", + "typescript": "^5.0.0", + "zod-to-json-schema": "^3.21.2" + }, + "dependencies": { + "@commander-js/extra-typings": "^11.0.0", + "commander": "^11.0.0", + "pino": "^8.14.1", + "pino-pretty": "^10.0.0", + "yaml": "^2.3.1", + "zod": "^3.21.4" + } +} diff --git a/schema/config.json b/schema/config.json new file mode 100644 index 0000000..16bae68 --- /dev/null +++ b/schema/config.json @@ -0,0 +1,252 @@ +{ + "$ref": "#/definitions/mySchema", + "definitions": { + "mySchema": { + "type": "object", + "properties": { + "version": { + "type": "number", + "description": "version number" + }, + "repos": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "local", + "sftp", + "rest", + "swift", + "s3", + "b2", + "azure", + "gs", + "rclone" + ], + "description": "type of repository" + }, + "path": { + "type": "string", + "minLength": 1, + "description": "restic path" + }, + "key": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "encryption key for the repository" + }, + "env": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "value of the environment variable" + }, + "description": "environment variables" + }, + "options": { + "type": "object", + "properties": { + "all": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "boolean", + "const": true, + "description": "boolean flag" + }, + { + "anyOf": [ + { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "non-empty string that can extrapolate env variables inside it" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/options/properties/all/additionalProperties/anyOf/1/anyOf/0" + }, + "minItems": 1 + } + ] + } + ], + "description": "value of option" + } + }, + "backup": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/options/properties/all" + }, + "forget": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/options/properties/all" + } + }, + "additionalProperties": false, + "description": "options" + } + }, + "required": [ + "type", + "path", + "key" + ], + "additionalProperties": false + }, + "description": "available repositories" + }, + "locations": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "from": { + "anyOf": [ + { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "local path to backup" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/from/anyOf/0" + }, + "minItems": 1 + } + ] + }, + "to": { + "anyOf": [ + { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "repository to backup to" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/to/anyOf/0" + }, + "minItems": 1 + } + ] + }, + "copy": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "destination repository" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/copy/additionalProperties/anyOf/0" + }, + "minItems": 1 + } + ] + } + }, + "cron": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "execute backups for the given cron job" + }, + "hooks": { + "type": "object", + "properties": { + "before": { + "anyOf": [ + { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/path", + "description": "command to be executed" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/hooks/properties/before/anyOf/0" + }, + "minItems": 1 + } + ], + "description": "list of commands" + }, + "after": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/hooks/properties/before", + "description": "list of commands" + }, + "failure": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/hooks/properties/before", + "description": "list of commands" + }, + "success": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/hooks/properties/before", + "description": "list of commands" + } + }, + "additionalProperties": false, + "description": "hooks to be executed" + }, + "options": { + "type": "object", + "properties": { + "all": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/options/properties/all" + }, + "backup": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/options/properties/backup" + }, + "forget": { + "$ref": "#/definitions/mySchema/properties/repos/additionalProperties/properties/options/properties/forget" + } + }, + "additionalProperties": false, + "description": "native restic options" + }, + "forget": { + "anyOf": [ + { + "type": "boolean", + "description": "automatically run \"forget\" when backing up" + }, + { + "type": "string", + "const": "prune", + "description": "also prune when forgetting" + } + ] + } + }, + "required": [ + "from", + "to" + ], + "additionalProperties": false, + "description": "Location" + }, + "description": "available locations" + }, + "global": { + "type": "object", + "properties": { + "options": { + "$ref": "#/definitions/mySchema/properties/locations/additionalProperties/properties/options", + "description": "native restic options" + } + }, + "additionalProperties": false, + "description": "global configuration" + }, + "extras": {} + }, + "required": [ + "version", + "repos", + "locations" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/scripts/generateSchema.ts b/scripts/generateSchema.ts new file mode 100644 index 0000000..50a8e3c --- /dev/null +++ b/scripts/generateSchema.ts @@ -0,0 +1,17 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { zodToJsonSchema } from 'zod-to-json-schema' +import { ConfigSchema } from '../src/config/schema/config' + +const OUTPUT = './schema' + +await rm(OUTPUT, { recursive: true, force: true }) +await mkdir(OUTPUT, { recursive: true }) + +const Schemas = { + config: ConfigSchema, +} + +for (const [name, schema] of Object.entries(Schemas)) { + const jsonSchema = zodToJsonSchema(schema, 'mySchema') + await writeFile(`${OUTPUT}/${name}.json`, JSON.stringify(jsonSchema, null, 2), { encoding: 'utf-8' }) +} diff --git a/src/cmd/backup.ts b/src/cmd/backup.ts new file mode 100644 index 0000000..f7d0e9e --- /dev/null +++ b/src/cmd/backup.ts @@ -0,0 +1,14 @@ +import { Log } from '../logger' +import { Context } from '../models/context' + +export async function backup(ctx: Context) { + const log = Log.child({ cmd: 'check' }) + log.trace('starting') + + // Locations + for (const location of ctx.locations) { + await location.backup() + } + + log.trace('done') +} diff --git a/src/cmd/check.ts b/src/cmd/check.ts new file mode 100644 index 0000000..edb2e65 --- /dev/null +++ b/src/cmd/check.ts @@ -0,0 +1,25 @@ +import { unlockRepo, waitForRepo } from '../lock' +import { Log } from '../logger' +import { Context } from '../models/context' +import { isResticAvailable } from '../restic' + +export async function check(ctx: Context) { + const l = Log.child({ cmd: 'check' }) + l.trace('starting') + + // Restic + isResticAvailable() + + // Repos + for (const repo of ctx.repos) { + await waitForRepo(ctx, repo.name) + try { + await repo.init() + await repo.check() + } finally { + unlockRepo(ctx, repo.name) + } + } + + l.trace('done') +} diff --git a/src/config/env/file.test.ts b/src/config/env/file.test.ts new file mode 100644 index 0000000..44ea81f --- /dev/null +++ b/src/config/env/file.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { InvalidEnvFileLine } from '../../errors' +import { parseFile } from './file' + +describe('env file', () => { + test('simple', () => { + expect(parseFile(`test_foo=ok`)).toEqual({ test_foo: 'ok' }) + }) + + test('multiple values', () => { + expect(parseFile(`test_foo=ok\n \n spacing = foo \n`)).toEqual({ test_foo: 'ok', spacing: 'foo' }) + }) + + test('invalid: key', () => { + expect(() => parseFile(`a=123\na f=ok`)).toThrow(new InvalidEnvFileLine('a f=ok')) + }) + + test('invalid: missing =', () => { + expect(() => parseFile(`a=123\na ok`)).toThrow(new InvalidEnvFileLine('a ok')) + }) +}) diff --git a/src/config/env/file.ts b/src/config/env/file.ts new file mode 100644 index 0000000..f323904 --- /dev/null +++ b/src/config/env/file.ts @@ -0,0 +1,55 @@ +import { exists, readFile } from 'node:fs/promises' +import { InvalidEnvFileLine } from '../../errors' +import { setByPath } from '../../utils/path' +import { relativePath } from '../resolution' + +export function parseFile(contents: string) { + const variables: Record = {} + const lines = contents + .trim() + .split('\n') + .map((l) => l.trim()) + const matcher = /^\s*(?\w+)\s*=(?.*)$/ + for (const line of lines) { + if (!line) continue + const match = matcher.exec(line) + if (!match) throw new InvalidEnvFileLine(line) + variables[match.groups!.variable] = match.groups!.value.trim() + } + return variables +} + +const PREFIX = 'AUTORESTIC_' + +function envVariableToObjectPath(env: string): string { + if (env.startsWith(PREFIX)) env = env.replace(PREFIX, '') + return ( + env + // Convert to object path + .replaceAll('_', '.') + // Escape the double unterscore. __ -> .. -> _ + .replaceAll('..', '_') + .toLowerCase() + ) +} + +/** + * Fill the config file with the env file variables. + * These take precedence before the config file itself. + */ +export async function enrichConfig(rawConfig: any, path: string) { + const envFilePath = relativePath(path, '.autorestic.env') + let variables: Record = {} + + if (await exists(envFilePath)) { + const envFile = parseFile(await readFile(envFilePath, 'utf-8')) + Object.assign(variables, envFile) + } + + Object.assign(variables, process.env) + + for (const [key, value] of Object.entries(variables)) { + if (!key.startsWith(PREFIX)) continue + setByPath(rawConfig, envVariableToObjectPath(key), value) + } +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..5b45968 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,29 @@ +import { exists, readFile } from 'node:fs/promises' +import yaml from 'yaml' +import { ConfigFileNotFound, CustomError, InvalidConfigFile } from '../errors' +import { enrichConfig } from './env/file' +import { autoLocateConfig } from './resolution' +import { Config, ConfigWithMetaSchema } from './schema/config' +import { basename } from 'node:path' + +export async function loadConfig(customPath?: string): Promise { + let path: string + if (customPath) { + path = customPath + if (!(await exists(path))) throw new ConfigFileNotFound([path]) + } else { + path = await autoLocateConfig() + } + + const rawConfig = await readFile(path, 'utf-8') + const config = yaml.parse(rawConfig) + await enrichConfig(config, path) + config.meta = { path: basename(path) } + const parsed = ConfigWithMetaSchema.safeParse(config) + if (!parsed.success) + throw new InvalidConfigFile(parsed.error.errors.map((e) => `${e.path.join(' > ')}: ${e.message}`)) + + // Check for semantics + + return parsed.data +} diff --git a/src/config/resolution.ts b/src/config/resolution.ts new file mode 100644 index 0000000..0dc742f --- /dev/null +++ b/src/config/resolution.ts @@ -0,0 +1,23 @@ +import { exists } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { ConfigFileNotFound } from '../errors' + +const DEFAULT_DIRS = ['./', '~/', '~/.config/autorestic'] +const FILENAMES = ['.autorestic.yaml', '.autorestic.yml', '.autorestic.json'] + +export async function autoLocateConfig(): Promise { + const paths = DEFAULT_DIRS + const xdgHome = process.env['XDG_CONFIG_HOME'] + if (xdgHome) paths.push(xdgHome) + for (const path in paths) { + for (const filename in FILENAMES) { + const file = join(path, filename) + if (await exists(file)) return file + } + } + throw new ConfigFileNotFound(paths) +} + +export function relativePath(base: string, path: string): string { + return isAbsolute(path) ? path : resolve(base, path) +} diff --git a/src/config/schema/common.ts b/src/config/schema/common.ts new file mode 100644 index 0000000..d0a6f01 --- /dev/null +++ b/src/config/schema/common.ts @@ -0,0 +1,20 @@ +import { ZodTypeAny, z } from 'zod' +import { Log } from '../../logger' + +export const NonEmptyString = z + .string() + .min(1) + // Extrapolate env variables from a string + .transform((s) => { + return s.replaceAll(/\$(\w+)|\${(\w+)}/g, (_, g0, g1) => { + const variable = g0 || g1 + const value = process.env[variable] ?? '' + if (!value) Log.error(`cannot find environment variable "${variable}" to replace in ${s}`) + return value + }) + }) + .describe('non-empty string that can extrapolate env variables inside it') + +export function OptionallyArray(type: T) { + return z.union([type, z.array(type).min(1)]) +} diff --git a/src/config/schema/config.ts b/src/config/schema/config.ts new file mode 100644 index 0000000..3c86e9a --- /dev/null +++ b/src/config/schema/config.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import { asArray } from '../../utils/array' +import { RepositorySchema } from './repository' +import { NonEmptyString } from './common' +import { LocationSchema } from './location' +import { OptionsSchema } from './options' + +export const ConfigSchema = z.strictObject({ + version: z.number().describe('version number'), + repos: z.record(NonEmptyString.describe('repository name'), RepositorySchema).describe('available repositories'), + locations: z.record(NonEmptyString.describe('location name'), LocationSchema).describe('available locations'), + global: z + .strictObject({ + options: OptionsSchema.optional(), + }) + .describe('global configuration') + .optional(), + extras: z.any().optional(), +}) + +const ConfigMeta = z + .strictObject({ + path: NonEmptyString.describe('The path of the loaded config'), + }) + .describe('Meta information about the config') + +export const ConfigWithMetaSchema = ConfigSchema.extend({ + meta: ConfigMeta, +}).superRefine((config, ctx) => { + const availableRepos = Object.keys(config.repos) + for (const [name, location] of Object.entries(config.locations)) { + const locationPath = [...ctx.path, 'locations', name] + const toRepos = asArray(location.to) + // Check if all target repos are valid + for (const to of toRepos) { + if (!availableRepos.includes(to)) { + const message = `location "${name}" has an invalid repository "${to}"` + ctx.addIssue({ message, code: 'custom', path: [...locationPath, 'to'] }) + } + } + // Check copy field + if (!location.copy) continue + for (const [source, destinations] of Object.entries(location.copy)) { + const path = [...locationPath, 'copy', source] + if (!toRepos.includes(source)) + ctx.addIssue({ + code: 'custom', + path, + message: `copy source "${source}" must be also a backup target`, + }) + for (const destination of asArray(destinations)) { + if (destination === source) + ctx.addIssue({ + code: 'custom', + path: [...path, destination], + message: `destination repository "${destination}" cannot be also the source in copy field`, + }) + if (!availableRepos.includes(destination)) + ctx.addIssue({ + code: 'custom', + path: [...path, destination], + message: `destination repository "${destination}" does not exist`, + }) + } + } + } +}) + +export type Config = z.infer diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts new file mode 100644 index 0000000..9d5d98a --- /dev/null +++ b/src/config/schema/hooks.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { NonEmptyString, OptionallyArray } from './common' + +const Command = NonEmptyString.describe('command to be executed') +const Commands = OptionallyArray(Command).describe('list of commands') + +export const HooksSchema = z + .strictObject({ + before: Commands.optional(), + after: Commands.optional(), + failure: Commands.optional(), + success: Commands.optional(), + }) + .describe('hooks to be executed') diff --git a/src/config/schema/location.ts b/src/config/schema/location.ts new file mode 100644 index 0000000..dc99893 --- /dev/null +++ b/src/config/schema/location.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { NonEmptyString, OptionallyArray } from './common' +import { HooksSchema } from './hooks' +import { OptionsSchema } from './options' + +export const LocationSchema = z + .strictObject({ + from: OptionallyArray(NonEmptyString.describe('local path to backup')), + to: OptionallyArray(NonEmptyString.describe('repository to backup to')), + copy: z + .record( + NonEmptyString.describe('source repository from which to copy from'), + OptionallyArray(NonEmptyString.describe('destination repository')) + ) + .optional(), + + // adapter: + cron: NonEmptyString.describe('execute backups for the given cron job').optional(), + hooks: HooksSchema.optional(), + options: OptionsSchema.optional(), + forget: z + .union([ + z.boolean().describe('automatically run "forget" when backing up'), + z.literal('prune').describe('also prune when forgetting'), + ]) + .optional(), + }) + .describe('Location') diff --git a/src/config/schema/options.ts b/src/config/schema/options.ts new file mode 100644 index 0000000..17710f4 --- /dev/null +++ b/src/config/schema/options.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { NonEmptyString, OptionallyArray } from './common' + +const OptionSchema = z.record( + NonEmptyString.describe('native restic option'), + z.union([z.literal(true).describe('boolean flag'), OptionallyArray(NonEmptyString)]).describe('value of option') +) + +export const OptionsSchema = z + .strictObject({ + all: OptionSchema.optional(), + backup: OptionSchema.optional(), + forget: OptionSchema.optional(), + }) + .describe('native restic options') diff --git a/src/config/schema/repository.ts b/src/config/schema/repository.ts new file mode 100644 index 0000000..5f3a684 --- /dev/null +++ b/src/config/schema/repository.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { NonEmptyString } from './common' +import { OptionsSchema } from './options' + +export const RepositorySchema = z.strictObject({ + type: z.enum(['local', 'sftp', 'rest', 'swift', 's3', 'b2', 'azure', 'gs', 'rclone']).describe('type of repository'), + path: NonEmptyString.describe('restic path'), + key: NonEmptyString.describe('encryption key for the repository'), + env: z + .record( + NonEmptyString.describe('environment variable'), + NonEmptyString.describe('value of the environment variable') + ) + .transform((env) => Object.fromEntries(Object.entries(env).map(([key, value]) => [key.toUpperCase(), value]))) + .describe('environment variables') + .optional(), + options: OptionsSchema.describe('options').optional(), +}) diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..3f683fd --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,47 @@ +function formatLines(lines: string[]) { + return lines.map((p) => ` ▶ ${p}`).join('\n') +} + +export class CustomError extends Error {} + +export class InvalidEnvFileLine extends CustomError { + constructor(line: string) { + super(`invalid env file line: "${line}"`) + } +} + +export class NotImplemented extends CustomError { + constructor(functionality: string) { + super(`not implemented: ${functionality}`) + } +} + +export class ConfigFileNotFound extends CustomError { + constructor(paths: string[]) { + super(`could not locate config file.\nthe following paths were tried:\n${formatLines(paths)}`) + } +} + +export class InvalidConfigFile extends CustomError { + constructor(errors: string[]) { + super(`could not parse the config file.\n${formatLines(errors)}`) + } +} + +export class BinaryNotAvailable extends CustomError { + constructor(binary: string) { + super(`binary "${binary}" is not available in $PATH`) + } +} + +export class ResticError extends CustomError { + constructor(errors: string[]) { + super(`internal restic error.\n${formatLines(errors)}`) + } +} + +export class LockfileAlreadyLocked extends CustomError { + constructor(repo: string) { + super(`cannot acquire lock for repository "${repo}", already in use`) + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b9cf7cd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,138 @@ +import { Command, Help, Option, program } from '@commander-js/extra-typings' +import { loadConfig } from './config' +import { CustomError, NotImplemented } from './errors' +import { Log, LogLevel, setLevelFromFlag } from './logger' +import { check } from './cmd/check' +import { Context } from './models/context' +import { backup } from './cmd/backup' + +export const helpConfig: Partial = { + showGlobalOptions: true, + sortOptions: true, + sortSubcommands: true, + helpWidth: 1, +} + +program + .name('autorestic') + .description('configuration manager and runner for restic') + .version('2.0.0-alpha.0') + .configureHelp(helpConfig) + .allowExcessArguments(false) + .allowUnknownOption(false) + +// Global options +program.option('-c, --config ', 'specify custom configuration file') +program.option('-v', 'verbosity', (_, previous) => previous + 1, 1) +program.addOption(new Option('--ci', 'CI mode').env('CI').default(false)) + +// Common Options +const specificLocation = new Option('-l, --location ', 'location name, multiple possible') +specificLocation.variadic = true +const allLocations = new Option('-a, --all', 'all locations') +const specificRepo = new Option('-r, --repository ', 'repository name, multiple possible') +specificLocation.variadic = true +const allRepos = new Option('-a, --all', 'all repositories') + +function mergeOptions(local: T, p: Command) { + const globals = p.optsWithGlobals() as { config?: string; verbosity: number; ci: boolean } + return { + ...globals, + ...local, + } +} + +program.hook('preAction', (command) => { + // @ts-ignore + const v: number = command.opts().v + setLevelFromFlag(v) +}) + +program + .command('check') + .description('check if the config is valid and sets up the repositories') + .configureHelp(helpConfig) + .action(async (options, p) => { + const merged = mergeOptions(options, p) + const config = await loadConfig(merged.config) + const ctx = new Context(config) + await check(ctx) + }) + +program + .command('backup') + .description('create backups') + .configureHelp(helpConfig) + .addOption(specificLocation) + .addOption(allLocations) + .action(async (options, p) => { + // throw new NotImplemented('backup') + const merged = mergeOptions(options, p) + const config = await loadConfig(merged.config) + const ctx = new Context(config) + await backup(ctx) + }) + +program + .command('exec') + .description('execute arbitrary native restic commands for given repositories') + .configureHelp(helpConfig) + .addOption(specificRepo) + .addOption(allRepos) + .allowExcessArguments(true) + .action((options, p) => { + throw new NotImplemented('exec') + }) + +program + .command('forget') + .description('forget snapshots according to the specified policies') + .configureHelp(helpConfig) + .addOption(specificLocation) + .addOption(allLocations) + // Pass natively + // .option('--dry-run', 'do not write changes, show what would be affected') + // .option('--prune', 'also prune repository') + .action((options) => { + throw new NotImplemented('backup') + }) + +program + .command('restore') + .description('restore a snapshot to a given location') + .option('--force', 'overwrite target folder') + .option('--from ', 'repository from which to restore') + .option('--to ', 'path where to restore the data') + .option('-l, --location ', 'location to be restored') + .argument('[snapshot-id]', 'snapshot to be restored. if empty latest will be taken') + .action(() => { + throw new NotImplemented('restore') + }) + +const self = new Command('self').description('utility commands for managing autorestic').configureHelp(helpConfig) +self.command('install').action(() => { + throw new NotImplemented('install') +}) +self.command('uninstall').action(() => { + throw new NotImplemented('uninstall') +}) +self.command('upgrade').action(() => { + throw new NotImplemented('upgrade') +}) +self.command('completion').action(() => { + throw new NotImplemented('completion') +}) +program.addCommand(self) + +try { + await program.parseAsync() +} catch (e) { + if (e instanceof CustomError) { + Log.fatal(e.message) + } else if (e instanceof Error) { + Log.fatal(`unknown error: ${e.message}`) + } + process.exit(1) +} finally { + // TODO: Unlock +} diff --git a/src/lock/index.test.ts b/src/lock/index.test.ts new file mode 100644 index 0000000..0884e54 --- /dev/null +++ b/src/lock/index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, mock, test, beforeEach } from 'bun:test' +import { lockRepo } from '.' +import { Context } from '../models/context' +import { mkdir, rm } from 'node:fs/promises' + +const mockPath = './test/' +const mockContext: Context = { config: { meta: { path: mockPath } } } as any + +describe('lock', () => { + beforeEach(async () => { + // Cleanup lock file + await rm(mockPath, { recursive: true, force: true }) + await mkdir(mockPath, { recursive: true }) + }) + + test('lock', () => { + lockRepo(mockContext, 'foo') + // lockRepo(mockContext, 'foo') + }) +}) diff --git a/src/lock/index.ts b/src/lock/index.ts new file mode 100644 index 0000000..42da55d --- /dev/null +++ b/src/lock/index.ts @@ -0,0 +1,78 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import yaml from 'yaml' +import { relativePath } from '../config/resolution' +import { LockfileAlreadyLocked } from '../errors' +import { Log } from '../logger' +import { Context } from '../models/context' +import { Lockfile, LockfileSchema } from './schema' +import { wait } from '../utils/time' + +const LOCKFILE = '.autorestic.lock' +const VERSION = 2 +const l = Log.child({ command: 'lock' }) + +function load(ctx: Context): Lockfile { + const defaultLockfile = { version: VERSION, cron: {}, running: {} } + try { + const path = relativePath(ctx.config.meta.path, LOCKFILE) + l.trace('looking for lock file', { path }) + // throw new Error(path) + const rawConfig = readFileSync(path, 'utf-8') + const config = yaml.parse(rawConfig) + const parsed = LockfileSchema.safeParse(config) + if (!parsed.success) return defaultLockfile + if (parsed.data.version < VERSION) { + l.debug('lockfile is old and will be overwritten') + return defaultLockfile + } + return parsed.data + } catch { + return defaultLockfile + } +} + +function write(ctx: Context, lockfile: Lockfile) { + const path = relativePath(ctx.config.meta.path, LOCKFILE) + writeFileSync(path, yaml.stringify(lockfile), 'utf-8') +} + +export function lockRepo(ctx: Context, repo: string) { + const lock = load(ctx) + l.trace('trying to lock repository', { repo }) + if (lock.running[repo]) throw new LockfileAlreadyLocked(repo) + lock.running[repo] = true + write(ctx, lock) +} + +/** + * Waits for a repo to become unlocked, and errors if it does not succeed in the given timeout. + * + * @param [timeout=10] max seconds to wait for repo to become unlocked + */ +export async function waitForRepo(ctx: Context, repo: string, timeout = 10) { + const now = Date.now() + while (Date.now() - now < timeout * 1_000) { + try { + lockRepo(ctx, repo) + l.trace('repo is free again', { repo }) + break + } catch { + l.trace('waiting for repo to be unlocked', { repo }) + await wait(0.1) // Wait for 100ms + } + } + throw new LockfileAlreadyLocked(repo) +} + +export function updateLastRun(ctx: Context, location: string) { + const lock = load(ctx) + lock.cron[location] = Date.now() + write(ctx, lock) +} + +export function unlockRepo(ctx: Context, repo: string) { + l.trace('unlocking repository', { repo }) + const lock = load(ctx) + lock.running[repo] = false + write(ctx, lock) +} diff --git a/src/lock/schema.ts b/src/lock/schema.ts new file mode 100644 index 0000000..8bcc6ce --- /dev/null +++ b/src/lock/schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const LockfileSchema = z.strictObject({ + version: z.number().min(0).describe('lockfile version'), + running: z + .record(z.string().describe('repository'), z.boolean().describe('whether repository is running')) + .describe('running information for each repository'), + cron: z + .record(z.string().describe('location'), z.number().describe('timestamp of last backup')) + .describe('information about last run for a given location. in milliseconds'), +}) + +export type Lockfile = z.infer diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..53306ad --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,37 @@ +import Pino, { type LoggerOptions } from 'pino' +import Pretty from 'pino-pretty' + +// https://getpino.io/#/docs/api?id=loggerlevel-string-gettersetter +export enum LogLevel { + Trace = 'trace', + Debug = 'debug', + Info = 'info', + Warn = 'warn', + Error = 'error', + Fatal = 'fatal', + Silent = 'silent', +} + +const pretty = !process.env.CI +const options: LoggerOptions = { + base: undefined, + level: LogLevel.Info, +} + +export const Log = pretty ? Pino(options, Pretty({ colorize: true })) : Pino(options) + +export function setLevelFromFlag(flag: number) { + switch (flag) { + case 1: + Log.level = LogLevel.Info + break + case 2: + Log.level = LogLevel.Debug + break + case 3: + Log.level = LogLevel.Trace + break + default: + Log.error('invalid logging level') + } +} diff --git a/src/models/context.ts b/src/models/context.ts new file mode 100644 index 0000000..5c31235 --- /dev/null +++ b/src/models/context.ts @@ -0,0 +1,25 @@ +import { Config } from '../config/schema/config' +import { CustomError } from '../errors' +import { Location } from './location' +import { Repository } from './repository' + +export class Context { + repos: Repository[] + locations: Location[] + + constructor(public config: Config) { + this.repos = Object.entries(config.repos).map(([name, r]) => new Repository(this, name, r)) + this.locations = Object.entries(config.locations).map(([name, l]) => new Location(this, name, l)) + } + + getRepo(name: string) { + const repo = this.repos.find((r) => r.name === name) + if (!repo) throw new CustomError(`could not find backend "${name}"`) + return repo + } + getLocation(name: string) { + const location = this.locations.find((l) => l.name === name) + if (!location) throw new CustomError(`could not find location "${name}"`) + return location + } +} diff --git a/src/models/location.ts b/src/models/location.ts new file mode 100644 index 0000000..acbafcb --- /dev/null +++ b/src/models/location.ts @@ -0,0 +1,28 @@ +import { Logger } from 'pino' +import { z } from 'zod' +import { LocationSchema } from '../config/schema/location' +import { Log } from '../logger' +import { asArray } from '../utils/array' +import { Context } from './context' +import { execute } from '../restic' + +export class Location { + l: Logger + + constructor(public ctx: Context, public name: string, public data: z.infer) { + this.l = Log.child({ location: name }) + } + + async backup() { + this.l.trace('backing up location') + for (const name of asArray(this.data.to)) { + const repo = this.ctx.getRepo(name) + this.l.debug(repo.name) + await execute({ + command: 'restic', + args: ['backup', '--dry-run'], + env: repo.env, + }) + } + } +} diff --git a/src/models/repository.ts b/src/models/repository.ts new file mode 100644 index 0000000..ea20f49 --- /dev/null +++ b/src/models/repository.ts @@ -0,0 +1,66 @@ +import { Logger } from 'pino' +import { z } from 'zod' +import { relativePath } from '../config/resolution' +import { Config } from '../config/schema/config' +import { RepositorySchema } from '../config/schema/repository' +import { ResticError } from '../errors' +import { Log } from '../logger' +import { execute } from '../restic' +import { Context } from './context' + +export class Repository { + l: Logger + + constructor(public ctx: Context, public name: string, public data: z.infer) { + this.l = Log.child({ repository: this.name }) + } + + get repository(): string { + switch (this.data.type) { + case 'local': + return relativePath(this.ctx.config.meta.path, this.data.path) + case 'b2': + case 'azure': + case 'gs': + case 's3': + case 'sftp': + case 'rclone': + case 'swift': + case 'rest': + return `${this.data.type}:${this.data.path}` + break + } + } + + get env() { + return { + ...this.data.env, + RESTIC_PASSWORD: this.data.key, + RESTIC_REPOSITORY: this.repository, + } + } + + /** + * true if initialized + * false if already initialized + */ + async init(): Promise { + this.l.trace('initializing') + const output = await execute({ command: 'restic', args: ['init'], env: this.env }) + if (!output.ok) { + if (output.stderr.includes('config file already exists')) { + this.l.debug('already initialized') + return false + } + throw new ResticError([output.stderr]) + } + this.l.debug('initialized repository') + return true + } + + async check() { + this.l.trace('checking') + const output = await execute({ command: 'restic', args: ['check'], env: this.env }) + if (!output.ok) throw new ResticError(['could not check repository', output.stderr]) + } +} diff --git a/src/restic/index.ts b/src/restic/index.ts new file mode 100644 index 0000000..bf8dd53 --- /dev/null +++ b/src/restic/index.ts @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process' +import { Log } from '../logger' +import { BinaryNotAvailable } from '../errors' + +export type ExecutionContext = { + command: string + args?: string[] + env?: Record +} +export async function execute({ + env, + args, + command, +}: ExecutionContext): Promise<{ code: number; stderr: string; stdout: string; ok: boolean }> { + return new Promise((resolve) => { + execFile(command, args ?? [], { env }, (err, stdout, stderr) => { + const code = err?.code ?? 0 + resolve({ + code, + ok: code === 0, + stderr, + stdout, + }) + }) + }) +} + +export async function isBinaryAvailable(command: string): Promise { + const l = Log.child({ command }) + try { + l.trace('checking if command is installed') + const result = await execute({ command }) + return result.ok + } catch { + l.trace('not installed') + return false + } +} + +export async function isResticAvailable() { + const bin = 'restic' + const installed = await isBinaryAvailable(bin) + if (!installed) throw new BinaryNotAvailable(bin) +} diff --git a/src/utils/array.test.ts b/src/utils/array.test.ts new file mode 100644 index 0000000..0105bf3 --- /dev/null +++ b/src/utils/array.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test' +import { isSubset } from './array' + +describe('set theory', () => { + test('subset', () => { + expect(isSubset([1], [1, 2])).toBe(true) + expect(isSubset([1], [2])).toBe(false) + expect(isSubset([], [])).toBe(true) + expect(isSubset([1, 2, 3], [1, 2])).toBe(false) + expect(isSubset([1, 2], [1, 2])).toBe(true) + }) +}) diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..5fef293 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,7 @@ +export function asArray(singleOrArray: T | T[]): T[] { + return Array.isArray(singleOrArray) ? singleOrArray : [singleOrArray] +} + +export function isSubset(subset: T[], set: T[]): boolean { + return subset.every((v) => set.includes(v)) +} diff --git a/src/utils/path.test.ts b/src/utils/path.test.ts new file mode 100644 index 0000000..1762de7 --- /dev/null +++ b/src/utils/path.test.ts @@ -0,0 +1,33 @@ +import { expect, test, describe } from 'bun:test' +import { setByPath } from './path' + +describe('set by path', () => { + test('simple', () => { + expect(setByPath({}, 'a', true)).toEqual({ a: true }) + expect(setByPath({}, 'f', { ok: true })).toEqual({ f: { ok: true } }) + expect(setByPath([], '0', true)).toEqual([true]) + expect(setByPath([], '2', false)).toEqual([undefined, undefined, false]) + }) + + test('object', () => { + expect(setByPath({}, 'a.b', true)).toEqual({ a: { b: true } }) + expect(setByPath({}, 'a.b.c', true)).toEqual({ a: { b: { c: true } } }) + expect(setByPath({ a: true }, 'b', false)).toEqual({ a: true, b: false }) + expect(setByPath({ a: { b: true } }, 'a.c', false)).toEqual({ a: { b: true, c: false } }) + + expect(() => setByPath({ a: 'foo' }, 'a.b', true)).toThrow() + expect(setByPath({ a: 'foo' }, 'a', true)).toEqual({ a: true }) + }) + + test('array', () => { + expect(() => setByPath([], 'a', true)).toThrow() + expect(setByPath([], '0', true)).toEqual([true]) + expect(setByPath([], '0.0.0', true)).toEqual([[[true]]]) + expect(setByPath([], '0.1.2', true)).toEqual([[undefined, [undefined, undefined, true]]]) + }) + + test('mixed', () => { + expect(setByPath({ items: [] }, 'items.0.name', 'John')).toEqual({ items: [{ name: 'John' }] }) + expect(setByPath([], '0.name', 'John')).toEqual([{ name: 'John' }]) + }) +}) diff --git a/src/utils/path.ts b/src/utils/path.ts new file mode 100644 index 0000000..64528e3 --- /dev/null +++ b/src/utils/path.ts @@ -0,0 +1,24 @@ +function parseKey(key: any) { + const asNumber = parseInt(key) + const isString = isNaN(asNumber) + return [isString ? key : asNumber, isString] +} + +export function setByPath(source: object, path: string, value: unknown) { + const segments = path.split('.') + const last = segments.length - 1 + let node: any = source + for (const [i, segment] of segments.entries()) { + const [key, isString] = parseKey(segment) + if (Array.isArray(node) && isString) throw new Error(`array require a numeric index`) + if (typeof node !== 'object') throw new Error(`could not set path "${segment}" on ${node}.`) + if (i === last) { + node[key] = value + } else { + const [_, isNextString] = parseKey(segments[i + 1]) + if (node[key] === undefined) node[key] = isNextString ? {} : [] + node = node[key] + } + } + return source +} diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..e6cadb3 --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,3 @@ +export function wait(seconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8b61f2b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/bun-types/tsconfig.json", + "compilerOptions": { + "strict": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true + } +}