restic-backups 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- restic_backups-0.1.1/PKG-INFO +112 -0
- restic_backups-0.1.1/README.md +93 -0
- restic_backups-0.1.1/pyproject.toml +51 -0
- restic_backups-0.1.1/pyproject.toml.orig +52 -0
- restic_backups-0.1.1/src/restic_backups/__init__.py +1 -0
- restic_backups-0.1.1/src/restic_backups/cli.py +103 -0
- restic_backups-0.1.1/src/restic_backups/config.py +152 -0
- restic_backups-0.1.1/src/restic_backups/errors.py +5 -0
- restic_backups-0.1.1/src/restic_backups/generic/__init__.py +1 -0
- restic_backups-0.1.1/src/restic_backups/generic/cli.py +126 -0
- restic_backups-0.1.1/src/restic_backups/generic/repository.py +41 -0
- restic_backups-0.1.1/src/restic_backups/generic/restic.py +92 -0
- restic_backups-0.1.1/src/restic_backups/generic/sops.py +30 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/__init__.py +1 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/cli.py +240 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/dashboard.py +156 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/parallel.py +72 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/pipeline.py +1685 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/prompts/__init__.py +1 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/prompts/summary.md +18 -0
- restic_backups-0.1.1/src/restic_backups/voice_memos/workflow.py +90 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: restic-backups
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Restic backup CLI with optional SOPS configuration.
|
|
5
|
+
Requires-Dist: click>=8.1
|
|
6
|
+
Requires-Dist: mlx-whisper>=0.4.0 ; platform_machine == 'arm64' and sys_platform == 'darwin'
|
|
7
|
+
Requires-Dist: pyannote-audio>=3.1
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
9
|
+
Requires-Dist: questionary>=2.1.1
|
|
10
|
+
Requires-Dist: requests>=2.32
|
|
11
|
+
Requires-Dist: textual>=0.80
|
|
12
|
+
Requires-Dist: torch>=2.0
|
|
13
|
+
Requires-Dist: torchaudio>=2.0
|
|
14
|
+
Requires-Dist: typer>=0.25.1
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Project-URL: Documentation, https://jr200-labs.github.io/restic-backups/
|
|
17
|
+
Project-URL: Repository, https://github.com/jr200-labs/restic-backups
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Restic Backups
|
|
21
|
+
|
|
22
|
+
YAML configuration and a Python CLI for running multiple restic repositories,
|
|
23
|
+
with optional SOPS decryption. The package currently includes a complete macOS
|
|
24
|
+
Voice Memos workflow for backup, transcription, summarisation, and speaker
|
|
25
|
+
diarization.
|
|
26
|
+
|
|
27
|
+
Backup payloads, generated metadata, model caches, and restores are excluded
|
|
28
|
+
from Git by a default-deny `.gitignore`.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
make install-deps # Homebrew: restic, sops, uv, ffmpeg, jq, coreutils
|
|
34
|
+
make install # uv sync, including the dev group and Quarto
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`make init` loads the selected configuration, deduplicates enabled
|
|
38
|
+
stores referenced by a backup, and runs `restic init` once per repository. It
|
|
39
|
+
does not back up files and is unnecessary for an existing repository.
|
|
40
|
+
|
|
41
|
+
## CLI
|
|
42
|
+
|
|
43
|
+
Use the built-in help for available commands and options:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
uv run restic-backups --help
|
|
47
|
+
uv run restic-backups generic --help
|
|
48
|
+
uv run restic-backups voice-memos --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
Pass a plain YAML file explicitly:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
uv run restic-backups --config config.yaml check-config
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For SOPS, add `--sops`. The equivalent environment variables are
|
|
60
|
+
`RESTIC_BACKUPS_CONFIG` and `RESTIC_BACKUPS_SOPS=1`; they also configure
|
|
61
|
+
`make config-check` and `make init`.
|
|
62
|
+
|
|
63
|
+
The configuration separates:
|
|
64
|
+
|
|
65
|
+
- `credentials`: reusable S3-compatible authentication;
|
|
66
|
+
- `restic-stores`: endpoint, region, bucket, key prefix/password, and optional
|
|
67
|
+
archive policy;
|
|
68
|
+
- `backups`: jobs linked to a store by `restic-store-id`.
|
|
69
|
+
|
|
70
|
+
One credential may serve many stores, and multiple backups may share a store.
|
|
71
|
+
Disabled stores may contain `CHANGE_ME`; all placeholders must be replaced
|
|
72
|
+
before enabling one.
|
|
73
|
+
|
|
74
|
+
## Data and source paths
|
|
75
|
+
|
|
76
|
+
Managed local artifacts use:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
data/<store-id>/<bucket>/<key-prefix>/<backup-id>/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This is metadata/workspace organization, not a restriction on backup sources.
|
|
83
|
+
Restic may back up absolute paths anywhere on the machine. Resolve a managed
|
|
84
|
+
directory without exposing credentials with:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
uv run restic-backups generic data-dir voice-memos
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## AWS Glacier
|
|
91
|
+
|
|
92
|
+
Use `GLACIER_IR` with `restore: null` for normal immediate restic access. Cold
|
|
93
|
+
`GLACIER` and `DEEP_ARCHIVE` stores require a configured retrieval tier, days,
|
|
94
|
+
and timeout. Retrieval must also be acknowledged at runtime:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
ALLOW_ARCHIVE_RETRIEVAL=1 uv run restic-backups generic run \
|
|
98
|
+
--backup <backup-id> restore latest --target <dir>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Storage-class changes apply only to new objects. Use a new `key_prefix` instead
|
|
102
|
+
of mixing storage policies in one repository.
|
|
103
|
+
|
|
104
|
+
## Documentation
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
make docs # render docs/_site
|
|
108
|
+
make docs-preview # local preview server
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Start with [the Quarto guide](docs/index.qmd). Never commit decrypted SOPS
|
|
112
|
+
configuration or anything below `data/`.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Restic Backups
|
|
2
|
+
|
|
3
|
+
YAML configuration and a Python CLI for running multiple restic repositories,
|
|
4
|
+
with optional SOPS decryption. The package currently includes a complete macOS
|
|
5
|
+
Voice Memos workflow for backup, transcription, summarisation, and speaker
|
|
6
|
+
diarization.
|
|
7
|
+
|
|
8
|
+
Backup payloads, generated metadata, model caches, and restores are excluded
|
|
9
|
+
from Git by a default-deny `.gitignore`.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
make install-deps # Homebrew: restic, sops, uv, ffmpeg, jq, coreutils
|
|
15
|
+
make install # uv sync, including the dev group and Quarto
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`make init` loads the selected configuration, deduplicates enabled
|
|
19
|
+
stores referenced by a backup, and runs `restic init` once per repository. It
|
|
20
|
+
does not back up files and is unnecessary for an existing repository.
|
|
21
|
+
|
|
22
|
+
## CLI
|
|
23
|
+
|
|
24
|
+
Use the built-in help for available commands and options:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
uv run restic-backups --help
|
|
28
|
+
uv run restic-backups generic --help
|
|
29
|
+
uv run restic-backups voice-memos --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Pass a plain YAML file explicitly:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv run restic-backups --config config.yaml check-config
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For SOPS, add `--sops`. The equivalent environment variables are
|
|
41
|
+
`RESTIC_BACKUPS_CONFIG` and `RESTIC_BACKUPS_SOPS=1`; they also configure
|
|
42
|
+
`make config-check` and `make init`.
|
|
43
|
+
|
|
44
|
+
The configuration separates:
|
|
45
|
+
|
|
46
|
+
- `credentials`: reusable S3-compatible authentication;
|
|
47
|
+
- `restic-stores`: endpoint, region, bucket, key prefix/password, and optional
|
|
48
|
+
archive policy;
|
|
49
|
+
- `backups`: jobs linked to a store by `restic-store-id`.
|
|
50
|
+
|
|
51
|
+
One credential may serve many stores, and multiple backups may share a store.
|
|
52
|
+
Disabled stores may contain `CHANGE_ME`; all placeholders must be replaced
|
|
53
|
+
before enabling one.
|
|
54
|
+
|
|
55
|
+
## Data and source paths
|
|
56
|
+
|
|
57
|
+
Managed local artifacts use:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
data/<store-id>/<bucket>/<key-prefix>/<backup-id>/
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This is metadata/workspace organization, not a restriction on backup sources.
|
|
64
|
+
Restic may back up absolute paths anywhere on the machine. Resolve a managed
|
|
65
|
+
directory without exposing credentials with:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
uv run restic-backups generic data-dir voice-memos
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## AWS Glacier
|
|
72
|
+
|
|
73
|
+
Use `GLACIER_IR` with `restore: null` for normal immediate restic access. Cold
|
|
74
|
+
`GLACIER` and `DEEP_ARCHIVE` stores require a configured retrieval tier, days,
|
|
75
|
+
and timeout. Retrieval must also be acknowledged at runtime:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
ALLOW_ARCHIVE_RETRIEVAL=1 uv run restic-backups generic run \
|
|
79
|
+
--backup <backup-id> restore latest --target <dir>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Storage-class changes apply only to new objects. Use a new `key_prefix` instead
|
|
83
|
+
of mixing storage policies in one repository.
|
|
84
|
+
|
|
85
|
+
## Documentation
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
make docs # render docs/_site
|
|
89
|
+
make docs-preview # local preview server
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Start with [the Quarto guide](docs/index.qmd). Never commit decrypted SOPS
|
|
93
|
+
configuration or anything below `data/`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "restic-backups"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Restic backup CLI with optional SOPS configuration."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.1",
|
|
9
|
+
"mlx-whisper>=0.4.0; platform_system == 'Darwin' and platform_machine == 'arm64'",
|
|
10
|
+
"pyannote.audio>=3.1",
|
|
11
|
+
"pyyaml>=6.0.3",
|
|
12
|
+
"questionary>=2.1.1",
|
|
13
|
+
"requests>=2.32",
|
|
14
|
+
"textual>=0.80",
|
|
15
|
+
"torch>=2.0",
|
|
16
|
+
"torchaudio>=2.0",
|
|
17
|
+
"typer>=0.25.1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Documentation = "https://jr200-labs.github.io/restic-backups/"
|
|
22
|
+
Repository = "https://github.com/jr200-labs/restic-backups"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
restic-backups = "restic_backups.cli:app"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"mypy>=1.19.1",
|
|
30
|
+
"pytest>=9.0.2",
|
|
31
|
+
"quarto-cli>=1.9.38",
|
|
32
|
+
"ruff>=0.14.14",
|
|
33
|
+
"types-pyyaml>=6.0.12.20250915",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.uv.build-backend]
|
|
41
|
+
module-root = "src"
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
packages = ["restic_backups"]
|
|
45
|
+
|
|
46
|
+
[[tool.mypy.overrides]]
|
|
47
|
+
module = ["restic_backups.voice_memos.*"]
|
|
48
|
+
ignore_errors = true
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
extend = ".shared/ruff.toml"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "restic-backups"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Restic backup CLI with optional SOPS configuration."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.1",
|
|
9
|
+
"mlx-whisper>=0.4.0; platform_system == 'Darwin' and platform_machine == 'arm64'",
|
|
10
|
+
"pyannote.audio>=3.1",
|
|
11
|
+
"pyyaml>=6.0.3",
|
|
12
|
+
"questionary>=2.1.1",
|
|
13
|
+
"requests>=2.32",
|
|
14
|
+
"textual>=0.80",
|
|
15
|
+
"torch>=2.0",
|
|
16
|
+
"torchaudio>=2.0",
|
|
17
|
+
"typer>=0.25.1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Documentation = "https://jr200-labs.github.io/restic-backups/"
|
|
22
|
+
Repository = "https://github.com/jr200-labs/restic-backups"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"mypy>=1.19.1",
|
|
27
|
+
"pytest>=9.0.2",
|
|
28
|
+
"quarto-cli>=1.9.38",
|
|
29
|
+
"ruff>=0.14.14",
|
|
30
|
+
"types-pyyaml>=6.0.12.20250915",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
restic-backups = "restic_backups.cli:app"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.1,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.uv.build-backend]
|
|
41
|
+
module-root = "src"
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
packages = ["restic_backups"]
|
|
45
|
+
|
|
46
|
+
# ponytail: the ML/Click workflow is dynamically typed; remove as it gains annotations.
|
|
47
|
+
[[tool.mypy.overrides]]
|
|
48
|
+
module = ["restic_backups.voice_memos.*"]
|
|
49
|
+
ignore_errors = true
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
extend = ".shared/ruff.toml"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Restic backups CLI."""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Command-line interface for configured restic backups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Any, NoReturn
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from . import config as config_module
|
|
12
|
+
from .errors import BackupError
|
|
13
|
+
from .generic import cli as generic_cli
|
|
14
|
+
from .generic import repository
|
|
15
|
+
from .generic import sops as sops_module
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
help="Configured backup commands.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
app.add_typer(generic_cli.app, name="generic")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def configure(
|
|
26
|
+
config_file: Annotated[
|
|
27
|
+
Path | None,
|
|
28
|
+
typer.Option(
|
|
29
|
+
"--config",
|
|
30
|
+
envvar=config_module.CONFIG_ENV,
|
|
31
|
+
dir_okay=False,
|
|
32
|
+
help=f"YAML configuration file. Env: {config_module.CONFIG_ENV}.",
|
|
33
|
+
),
|
|
34
|
+
] = None,
|
|
35
|
+
use_sops: Annotated[
|
|
36
|
+
bool,
|
|
37
|
+
typer.Option(
|
|
38
|
+
"--sops",
|
|
39
|
+
envvar=sops_module.SOPS_ENV,
|
|
40
|
+
help=f"Decrypt the configuration with SOPS. Env: {sops_module.SOPS_ENV}.",
|
|
41
|
+
),
|
|
42
|
+
] = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Configure storage before running a command."""
|
|
45
|
+
if config_file is not None:
|
|
46
|
+
os.environ[config_module.CONFIG_ENV] = str(config_file)
|
|
47
|
+
os.environ[sops_module.SOPS_ENV] = "1" if use_sops else "0"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fail(message: str) -> NoReturn:
|
|
51
|
+
typer.echo(f"restic-backups: {message}", err=True)
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validated() -> tuple[
|
|
56
|
+
dict[str, Any],
|
|
57
|
+
dict[str, dict[str, Any]],
|
|
58
|
+
dict[str, dict[str, Any]],
|
|
59
|
+
dict[str, dict[str, Any]],
|
|
60
|
+
]:
|
|
61
|
+
try:
|
|
62
|
+
return config_module.load_validated()
|
|
63
|
+
except BackupError as exc:
|
|
64
|
+
fail(str(exc))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("check-config")
|
|
68
|
+
def check_config_command() -> None:
|
|
69
|
+
"""Validate configuration without contacting remote storage."""
|
|
70
|
+
validated()
|
|
71
|
+
typer.echo("config ok")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command(
|
|
75
|
+
"voice-memos",
|
|
76
|
+
context_settings={
|
|
77
|
+
"allow_extra_args": True,
|
|
78
|
+
"ignore_unknown_options": True,
|
|
79
|
+
"help_option_names": [],
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
def voice_memos_command(context: typer.Context) -> None:
|
|
83
|
+
"""Back up, transcribe, summarise, and diarize macOS Voice Memos."""
|
|
84
|
+
if "SUMMARIES_DIR" not in os.environ and "--help" not in context.args:
|
|
85
|
+
_, credentials, stores, backups = validated()
|
|
86
|
+
try:
|
|
87
|
+
store, _ = repository.resolve("voice-memos", credentials, stores, backups)
|
|
88
|
+
path = repository.data_dir("voice-memos", store) / "summaries"
|
|
89
|
+
except BackupError as exc:
|
|
90
|
+
fail(str(exc))
|
|
91
|
+
os.environ["SUMMARIES_DIR"] = str(path)
|
|
92
|
+
|
|
93
|
+
from .voice_memos.cli import cli
|
|
94
|
+
|
|
95
|
+
cli.main(
|
|
96
|
+
args=list(context.args),
|
|
97
|
+
prog_name="restic-backups voice-memos",
|
|
98
|
+
standalone_mode=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
app()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Load and validate backup configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, NoReturn
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .errors import BackupError
|
|
12
|
+
from .generic import sops
|
|
13
|
+
|
|
14
|
+
CONFIG_ENV = "RESTIC_BACKUPS_CONFIG"
|
|
15
|
+
PLACEHOLDER = "CHANGE_ME"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fail(message: str) -> NoReturn:
|
|
23
|
+
raise BackupError(message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_path() -> Path:
|
|
27
|
+
value = os.environ.get(CONFIG_ENV)
|
|
28
|
+
if not value:
|
|
29
|
+
fail(f"set --config or {CONFIG_ENV}")
|
|
30
|
+
return Path(value).expanduser()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config(path: Path, use_sops: bool) -> dict[str, Any]:
|
|
34
|
+
if use_sops:
|
|
35
|
+
return sops.decrypt(path)
|
|
36
|
+
try:
|
|
37
|
+
loaded = yaml.safe_load(path.read_text())
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
fail(f"config file not found: {path}")
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
fail(f"could not read {path}: {exc}")
|
|
42
|
+
except yaml.YAMLError as exc:
|
|
43
|
+
fail(f"config is not valid YAML: {exc}")
|
|
44
|
+
if not isinstance(loaded, dict):
|
|
45
|
+
fail(f"config in {path} must be a mapping")
|
|
46
|
+
return loaded
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def required_text(item: dict[str, Any], field: str, owner: str) -> str:
|
|
50
|
+
value = item.get(field)
|
|
51
|
+
if not isinstance(value, str) or not value:
|
|
52
|
+
raise ConfigError(f"{owner}.{field} must be a non-empty string")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def indexed(config: dict[str, Any], section: str) -> dict[str, dict[str, Any]]:
|
|
57
|
+
items = config.get(section)
|
|
58
|
+
if not isinstance(items, list) or not items:
|
|
59
|
+
raise ConfigError(f"{section} must be a non-empty list")
|
|
60
|
+
result: dict[str, dict[str, Any]] = {}
|
|
61
|
+
for item in items:
|
|
62
|
+
if not isinstance(item, dict):
|
|
63
|
+
raise ConfigError(f"{section} entries must be mappings")
|
|
64
|
+
item_id = required_text(item, "id", section)
|
|
65
|
+
if item_id in result:
|
|
66
|
+
raise ConfigError(f"duplicate {section} id '{item_id}'")
|
|
67
|
+
result[item_id] = item
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate(
|
|
72
|
+
config: dict[str, Any],
|
|
73
|
+
) -> tuple[
|
|
74
|
+
dict[str, dict[str, Any]],
|
|
75
|
+
dict[str, dict[str, Any]],
|
|
76
|
+
dict[str, dict[str, Any]],
|
|
77
|
+
]:
|
|
78
|
+
credentials = indexed(config, "credentials")
|
|
79
|
+
stores = indexed(config, "restic-stores")
|
|
80
|
+
backups = indexed(config, "backups")
|
|
81
|
+
|
|
82
|
+
for credential_id, credential in credentials.items():
|
|
83
|
+
for field in ("access-key-id", "secret-access-key"):
|
|
84
|
+
required_text(credential, field, credential_id)
|
|
85
|
+
|
|
86
|
+
for store_id, store in stores.items():
|
|
87
|
+
credential_id = required_text(store, "credentials-id", store_id)
|
|
88
|
+
store_credential = credentials.get(credential_id)
|
|
89
|
+
if store_credential is None:
|
|
90
|
+
raise ConfigError(
|
|
91
|
+
f"{store_id} references unknown credentials '{credential_id}'"
|
|
92
|
+
)
|
|
93
|
+
if not isinstance(store.get("enabled"), bool):
|
|
94
|
+
raise ConfigError(f"{store_id}.enabled must be true or false")
|
|
95
|
+
required = [
|
|
96
|
+
required_text(store, field, store_id)
|
|
97
|
+
for field in ("endpoint", "region", "bucket", "key_prefix", "password")
|
|
98
|
+
]
|
|
99
|
+
if store["enabled"] and PLACEHOLDER in required:
|
|
100
|
+
raise ConfigError(f"{store_id} is enabled but contains placeholders")
|
|
101
|
+
if store["enabled"] and any(
|
|
102
|
+
store_credential[field] == PLACEHOLDER
|
|
103
|
+
for field in ("access-key-id", "secret-access-key")
|
|
104
|
+
):
|
|
105
|
+
raise ConfigError(f"{store_id} uses placeholder credentials")
|
|
106
|
+
|
|
107
|
+
archive = store.get("archive")
|
|
108
|
+
if archive is None:
|
|
109
|
+
continue
|
|
110
|
+
if not isinstance(archive, dict):
|
|
111
|
+
raise ConfigError(f"{store_id}.archive must be a mapping")
|
|
112
|
+
storage_class = required_text(archive, "storage-class", f"{store_id}.archive")
|
|
113
|
+
restore = archive.get("restore")
|
|
114
|
+
if storage_class == "GLACIER_IR":
|
|
115
|
+
if restore is not None:
|
|
116
|
+
raise ConfigError(f"{store_id}: GLACIER_IR forbids a restore policy")
|
|
117
|
+
continue
|
|
118
|
+
tiers = {
|
|
119
|
+
"GLACIER": {"Standard", "Bulk", "Expedited"},
|
|
120
|
+
"DEEP_ARCHIVE": {"Standard", "Bulk"},
|
|
121
|
+
}.get(storage_class)
|
|
122
|
+
if tiers is None or not isinstance(restore, dict):
|
|
123
|
+
raise ConfigError(f"{store_id} has an invalid cold-storage policy")
|
|
124
|
+
if restore.get("tier") not in tiers:
|
|
125
|
+
raise ConfigError(f"{store_id} has an invalid retrieval tier")
|
|
126
|
+
if not isinstance(restore.get("days"), int) or restore["days"] <= 0:
|
|
127
|
+
raise ConfigError(f"{store_id}.archive.restore.days must be positive")
|
|
128
|
+
required_text(restore, "timeout", f"{store_id}.archive.restore")
|
|
129
|
+
|
|
130
|
+
for backup_id, backup in backups.items():
|
|
131
|
+
store_id = required_text(backup, "restic-store-id", backup_id)
|
|
132
|
+
if store_id not in stores:
|
|
133
|
+
raise ConfigError(
|
|
134
|
+
f"{backup_id} references unknown restic store '{store_id}'"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return credentials, stores, backups
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_validated() -> tuple[
|
|
141
|
+
dict[str, Any],
|
|
142
|
+
dict[str, dict[str, Any]],
|
|
143
|
+
dict[str, dict[str, Any]],
|
|
144
|
+
dict[str, dict[str, Any]],
|
|
145
|
+
]:
|
|
146
|
+
path = config_path()
|
|
147
|
+
loaded = load_config(path, os.environ.get(sops.SOPS_ENV) == "1")
|
|
148
|
+
try:
|
|
149
|
+
credentials, stores, backups = validate(loaded)
|
|
150
|
+
except ConfigError as exc:
|
|
151
|
+
fail(f"invalid config in {path}: {exc}")
|
|
152
|
+
return loaded, credentials, stores, backups
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generic restic repository commands."""
|