xgic-gitlab-cli 0.1.1__tar.gz → 0.1.3__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.
Files changed (23) hide show
  1. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/.gitignore +4 -0
  2. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/PKG-INFO +38 -16
  3. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/README.md +35 -14
  4. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/pyproject.toml +6 -2
  5. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/archive.py +350 -0
  6. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/commands/backup.py +144 -0
  7. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/commands/health.py +7 -1
  8. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/commands/restore.py +187 -0
  9. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/config.py +90 -2
  10. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/manifest.py +328 -0
  11. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/plugin.py +147 -0
  12. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/schemas/backup-manifest.schema.json +314 -0
  13. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/schemas/gitlab-cli.schema.json +140 -0
  14. xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/settings.py +90 -0
  15. xgic_gitlab_cli-0.1.1/src/xgic/cli/gitlab/commands/backup.py +0 -86
  16. xgic_gitlab_cli-0.1.1/src/xgic/cli/gitlab/commands/restore.py +0 -108
  17. xgic_gitlab_cli-0.1.1/src/xgic/cli/gitlab/plugin.py +0 -77
  18. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/LICENSE +0 -0
  19. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/NOTICE +0 -0
  20. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/__init__.py +0 -0
  21. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/commands/__init__.py +0 -0
  22. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/commands/info.py +0 -0
  23. {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/compose.py +0 -0
@@ -26,5 +26,9 @@ venv/
26
26
  .DS_Store
27
27
  Thumbs.db
28
28
 
29
+ # Lockfile: this repo is a PyPI library/CLI module, not an application.
30
+ # Applications should commit uv.lock; libraries gitignore it.
31
+ uv.lock
32
+
29
33
  # .xgic/ directory for temporary Grok Build status reports (mandatory per new standard). All reports are temporary working artifacts only and must never be committed. This is required for the mandatory session status report functionality.
30
34
  .xgic/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xgic-gitlab-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: XGIC CLI GitLab module - GitLab ops commands and helpers (xgic.cli.gitlab).
5
5
  Project-URL: Homepage, https://github.com/xgic/gitlab-cli
6
6
  Project-URL: Repository, https://github.com/xgic/gitlab-cli
@@ -22,8 +22,9 @@ Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.14
25
+ Requires-Dist: pydantic>=2.0.0
25
26
  Requires-Dist: rich>=13.7
26
- Requires-Dist: xgic-cli>=0.2.0
27
+ Requires-Dist: xgic-cli>=0.2.1
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
29
30
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
@@ -37,8 +38,10 @@ Description-Content-Type: text/markdown
37
38
  # XGIC GitLab CLI
38
39
 
39
40
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
41
+ [![PyPI](https://img.shields.io/pypi/v/xgic-gitlab-cli.svg)](https://pypi.org/project/xgic-gitlab-cli/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/xgic-gitlab-cli.svg)](https://pypi.org/project/xgic-gitlab-cli/)
43
+ [![Release](https://img.shields.io/github/v/release/xgic/gitlab-cli)](https://github.com/xgic/gitlab-cli/releases)
40
44
  [![CI](https://github.com/xgic/gitlab-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/xgic/gitlab-cli/actions/workflows/ci.yml)
41
- [![Python](https://img.shields.io/badge/python-3.14+-blue?logo=python&logoColor=white)](https://www.python.org/)
42
45
 
43
46
  **GitLab product commands for the modular [XGIC CLI](https://github.com/xgic/cli)—ops under
44
47
  `xgic gitlab …`.**
@@ -109,20 +112,20 @@ uv pip install -e ".[graphql]"
109
112
 
110
113
  ### Install (PyPI)
111
114
 
112
- PyPI publish follows the hub
115
+ [xgic-gitlab-cli 0.1.1](https://pypi.org/project/xgic-gitlab-cli/0.1.1/) is on PyPI.
116
+ Further cuts follow the hub
113
117
  [python-package-release.md](https://github.com/xgic/ai/blob/main/docs/python-package-release.md)
114
- path (RC → TestPyPI → PyPI). Until the first release is published:
118
+ path (RC → TestPyPI → PyPI).
115
119
 
116
120
  ```bash
117
- # After first PyPI release:
118
- uv pip install "xgic-cli>=0.2.0" "xgic-gitlab-cli"
121
+ uv pip install "xgic-gitlab-cli"
119
122
  xgic gitlab --help
120
123
  ```
121
124
 
122
125
  ### Requirements
123
126
 
124
127
  - Python **3.14+**
125
- - `xgic-cli` ≥ 0.2.0
128
+ - `xgic-cli` ≥ 0.2.1
126
129
  - Docker / Docker Compose for stack ops (`health` / `backup` / `restore`)
127
130
 
128
131
  ---
@@ -135,8 +138,8 @@ All product commands nest under **`xgic gitlab`**:
135
138
  |---------|---------|
136
139
  | `xgic gitlab info` | Module version and status (`--json` supported) |
137
140
  | `xgic gitlab health` | Compose service checks + optional HTTP `/-/health` |
138
- | `xgic gitlab backup` | `gitlab-backup create` via `docker compose exec` |
139
- | `xgic gitlab restore <id>` | Destructive restore (`--yes` required; prefer `--dry-run` first) |
141
+ | `xgic gitlab backup` | `gitlab-backup create`, then one `.tar.zst` pair and a `.sha256` sidecar |
142
+ | `xgic gitlab restore` | Restore the latest verified pair, or `--archive` (`--yes` required) |
140
143
 
141
144
  ### Configuration (no private host defaults)
142
145
 
@@ -158,18 +161,37 @@ Shared: `--dry-run`, `--json`.
158
161
  # From a checked-out xgic/gitlab template directory
159
162
  export GITLAB_URL=http://localhost:8929
160
163
  xgic gitlab health --json
161
- xgic gitlab backup --dry-run
162
- xgic gitlab restore 20240101_1200 --dry-run
163
- # after confirmation:
164
- xgic gitlab restore 20240101_1200 --yes
164
+ xgic gitlab backup \
165
+ --secrets-file ./gitlab-secrets.json \
166
+ --config-file ./gitlab.rb \
167
+ --dry-run
168
+ xgic gitlab restore --dry-run
169
+ xgic gitlab restore --archive gitlab.tar.zst --dry-run
170
+ # after confirmation; destinations are optional and are not written during backup:
171
+ xgic gitlab restore \
172
+ --archive gitlab.tar.zst \
173
+ --secrets-dest ./restore/gitlab-secrets.json \
174
+ --config-dest ./restore/gitlab.rb \
175
+ --yes
165
176
  ```
166
177
 
178
+ `xgic gitlab backup` reads the live secrets and config files and does not write
179
+ them back. The archive contains the GitLab backup tar, those two files, and
180
+ `backup-manifest.json`. Registry, LFS, and packages are required unless
181
+ `--allow-missing` names a component GitLab omitted. The manifest is a Pydantic
182
+ model. Its `restore_validation` list records REST and GraphQL checks for a
183
+ later validator. Backup and restore do not call those APIs. Restore checks the
184
+ sidecar before it opens the archive, then validates the manifest.
185
+
186
+ See [docs/backup.md](docs/backup.md) and [docs/configuration.md](docs/configuration.md).
187
+
188
+
167
189
  ---
168
190
 
169
191
  ## Status
170
192
 
171
- **0.1.1 — experimental.** Nested `xgic gitlab` commands: `info`, `health`, `backup`, `restore` against
172
- Docker Compose stacks (typically [xgic/gitlab](https://github.com/xgic/gitlab)).
193
+ **0.1.3.** `xgic gitlab backup` reads the flags the core CLI passes in.
194
+ TestPyPI `0.1.3rc1` passed smoke. PyPI `0.1.2` ignores those flags.
173
195
 
174
196
  ---
175
197
 
@@ -1,8 +1,10 @@
1
1
  # XGIC GitLab CLI
2
2
 
3
3
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
4
+ [![PyPI](https://img.shields.io/pypi/v/xgic-gitlab-cli.svg)](https://pypi.org/project/xgic-gitlab-cli/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/xgic-gitlab-cli.svg)](https://pypi.org/project/xgic-gitlab-cli/)
6
+ [![Release](https://img.shields.io/github/v/release/xgic/gitlab-cli)](https://github.com/xgic/gitlab-cli/releases)
4
7
  [![CI](https://github.com/xgic/gitlab-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/xgic/gitlab-cli/actions/workflows/ci.yml)
5
- [![Python](https://img.shields.io/badge/python-3.14+-blue?logo=python&logoColor=white)](https://www.python.org/)
6
8
 
7
9
  **GitLab product commands for the modular [XGIC CLI](https://github.com/xgic/cli)—ops under
8
10
  `xgic gitlab …`.**
@@ -73,20 +75,20 @@ uv pip install -e ".[graphql]"
73
75
 
74
76
  ### Install (PyPI)
75
77
 
76
- PyPI publish follows the hub
78
+ [xgic-gitlab-cli 0.1.1](https://pypi.org/project/xgic-gitlab-cli/0.1.1/) is on PyPI.
79
+ Further cuts follow the hub
77
80
  [python-package-release.md](https://github.com/xgic/ai/blob/main/docs/python-package-release.md)
78
- path (RC → TestPyPI → PyPI). Until the first release is published:
81
+ path (RC → TestPyPI → PyPI).
79
82
 
80
83
  ```bash
81
- # After first PyPI release:
82
- uv pip install "xgic-cli>=0.2.0" "xgic-gitlab-cli"
84
+ uv pip install "xgic-gitlab-cli"
83
85
  xgic gitlab --help
84
86
  ```
85
87
 
86
88
  ### Requirements
87
89
 
88
90
  - Python **3.14+**
89
- - `xgic-cli` ≥ 0.2.0
91
+ - `xgic-cli` ≥ 0.2.1
90
92
  - Docker / Docker Compose for stack ops (`health` / `backup` / `restore`)
91
93
 
92
94
  ---
@@ -99,8 +101,8 @@ All product commands nest under **`xgic gitlab`**:
99
101
  |---------|---------|
100
102
  | `xgic gitlab info` | Module version and status (`--json` supported) |
101
103
  | `xgic gitlab health` | Compose service checks + optional HTTP `/-/health` |
102
- | `xgic gitlab backup` | `gitlab-backup create` via `docker compose exec` |
103
- | `xgic gitlab restore <id>` | Destructive restore (`--yes` required; prefer `--dry-run` first) |
104
+ | `xgic gitlab backup` | `gitlab-backup create`, then one `.tar.zst` pair and a `.sha256` sidecar |
105
+ | `xgic gitlab restore` | Restore the latest verified pair, or `--archive` (`--yes` required) |
104
106
 
105
107
  ### Configuration (no private host defaults)
106
108
 
@@ -122,18 +124,37 @@ Shared: `--dry-run`, `--json`.
122
124
  # From a checked-out xgic/gitlab template directory
123
125
  export GITLAB_URL=http://localhost:8929
124
126
  xgic gitlab health --json
125
- xgic gitlab backup --dry-run
126
- xgic gitlab restore 20240101_1200 --dry-run
127
- # after confirmation:
128
- xgic gitlab restore 20240101_1200 --yes
127
+ xgic gitlab backup \
128
+ --secrets-file ./gitlab-secrets.json \
129
+ --config-file ./gitlab.rb \
130
+ --dry-run
131
+ xgic gitlab restore --dry-run
132
+ xgic gitlab restore --archive gitlab.tar.zst --dry-run
133
+ # after confirmation; destinations are optional and are not written during backup:
134
+ xgic gitlab restore \
135
+ --archive gitlab.tar.zst \
136
+ --secrets-dest ./restore/gitlab-secrets.json \
137
+ --config-dest ./restore/gitlab.rb \
138
+ --yes
129
139
  ```
130
140
 
141
+ `xgic gitlab backup` reads the live secrets and config files and does not write
142
+ them back. The archive contains the GitLab backup tar, those two files, and
143
+ `backup-manifest.json`. Registry, LFS, and packages are required unless
144
+ `--allow-missing` names a component GitLab omitted. The manifest is a Pydantic
145
+ model. Its `restore_validation` list records REST and GraphQL checks for a
146
+ later validator. Backup and restore do not call those APIs. Restore checks the
147
+ sidecar before it opens the archive, then validates the manifest.
148
+
149
+ See [docs/backup.md](docs/backup.md) and [docs/configuration.md](docs/configuration.md).
150
+
151
+
131
152
  ---
132
153
 
133
154
  ## Status
134
155
 
135
- **0.1.1 — experimental.** Nested `xgic gitlab` commands: `info`, `health`, `backup`, `restore` against
136
- Docker Compose stacks (typically [xgic/gitlab](https://github.com/xgic/gitlab)).
156
+ **0.1.3.** `xgic gitlab backup` reads the flags the core CLI passes in.
157
+ TestPyPI `0.1.3rc1` passed smoke. PyPI `0.1.2` ignores those flags.
137
158
 
138
159
  ---
139
160
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xgic-gitlab-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "XGIC CLI GitLab module - GitLab ops commands and helpers (xgic.cli.gitlab)."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -13,7 +13,8 @@ authors = [
13
13
  ]
14
14
  requires-python = ">=3.14"
15
15
  dependencies = [
16
- "xgic-cli>=0.2.0",
16
+ "pydantic>=2.0.0",
17
+ "xgic-cli>=0.2.1",
17
18
  "rich>=13.7",
18
19
  ]
19
20
  keywords = [
@@ -60,6 +61,9 @@ test = [
60
61
  [tool.hatch.build.targets.wheel]
61
62
  packages = ["src/xgic"]
62
63
 
64
+ [tool.hatch.build.targets.wheel.force-include]
65
+ "src/xgic/cli/gitlab/schemas/backup-manifest.schema.json" = "xgic/cli/gitlab/schemas/backup-manifest.schema.json"
66
+
63
67
  [tool.hatch.build.targets.sdist]
64
68
  include = [
65
69
  "src/",
@@ -0,0 +1,350 @@
1
+ """Publish and open one GitLab backup pair on the landing host.
2
+
3
+ The pair is one ``.tar.zst`` and a sibling ``.tar.zst.sha256``. The archive
4
+ holds the GitLab application tar, the live secrets file, the live
5
+ ``gitlab.rb``, and ``backup-manifest.json``. Publishing reads the live files
6
+ and does not write them back.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Protocol
18
+
19
+ from pydantic import ValidationError
20
+
21
+ from xgic.cli.gitlab.manifest import BackupManifest, ManifestError, split_members
22
+
23
+ OUTER_REQUIRED = ("gitlab-secrets.json", "gitlab.rb", "backup-manifest.json")
24
+ _SECRET_MODE = 0o600
25
+
26
+
27
+ class ArchiveError(Exception):
28
+ """The backup pair is missing a required member or fails its check."""
29
+
30
+
31
+ class CommandRunner(Protocol):
32
+ def __call__(self, args: list[str]) -> subprocess.CompletedProcess[str]:
33
+ """Run one command and return its result."""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class BackupSources:
38
+ """Files copied into the archive. Publish does not modify them."""
39
+
40
+ gitlab_tar: Path
41
+ secrets_file: Path
42
+ config_file: Path
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class OpenedPair:
47
+ """A checksum-verified archive extracted into one directory."""
48
+
49
+ archive: Path
50
+ gitlab_tar: Path
51
+ manifest: BackupManifest
52
+ secrets_file: Path
53
+ config_file: Path
54
+
55
+ def place_application_tar(self, backup_dir: Path) -> Path:
56
+ """Copy the application tar to the directory GitLab restore reads."""
57
+ target = backup_dir / self.gitlab_tar.name
58
+ if target.resolve() == self.gitlab_tar.resolve():
59
+ return target
60
+ _write_private(self.gitlab_tar, target)
61
+ return target
62
+
63
+
64
+ def sha256_file(path: Path) -> str:
65
+ digest = hashlib.sha256()
66
+ with path.open("rb") as handle:
67
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
68
+ digest.update(chunk)
69
+ return digest.hexdigest()
70
+
71
+
72
+ def sidecar_line(digest: str, archive_name: str) -> str:
73
+ return f"{digest} {archive_name}\n"
74
+
75
+
76
+ class LandingPair:
77
+ """Create and open the landing-host pair for one GitLab backup."""
78
+
79
+ def __init__(self, output_dir: Path, runner: CommandRunner | None = None) -> None:
80
+ self.output_dir = output_dir
81
+ self._runner = runner or _run
82
+
83
+ def newest_application_tar(self) -> Path:
84
+ found = sorted(
85
+ (
86
+ path
87
+ for path in self.output_dir.glob("*_gitlab_backup.tar")
88
+ if path.is_file() and not path.is_symlink()
89
+ ),
90
+ key=lambda path: (path.stat().st_mtime_ns, path.name),
91
+ )
92
+ if not found:
93
+ raise ArchiveError(f"no GitLab backup tar in {self.output_dir}")
94
+ return found[-1]
95
+
96
+ def publish(self, sources: BackupSources, manifest: BackupManifest) -> Path:
97
+ """Write the pair. The source secrets and config files are not modified."""
98
+ self._require_sources(sources)
99
+ self._require_recorded_members(sources.gitlab_tar, manifest)
100
+ if manifest.artifacts.application_tar != sources.gitlab_tar.name:
101
+ raise ArchiveError("manifest names a different application tar")
102
+ if manifest.artifacts.sha256 != sha256_file(sources.gitlab_tar):
103
+ raise ArchiveError("manifest digest does not match the application tar")
104
+ confirmed = self._validate_manifest(manifest.to_json())
105
+ archive_name = sources.gitlab_tar.name.replace("_gitlab_backup.tar", ".tar.zst")
106
+ if not archive_name.endswith(".tar.zst") or archive_name == ".tar.zst":
107
+ raise ArchiveError("archive name must end in .tar.zst")
108
+ self.output_dir.mkdir(parents=True, exist_ok=True)
109
+ archive = self.output_dir / archive_name
110
+ partial = self.output_dir / f"{archive_name}.partial"
111
+ sidecar_partial = self.output_dir / f"{archive_name}.sha256.partial"
112
+ sidecar = archive.with_name(archive.name + ".sha256")
113
+ staging = self.output_dir / f".{archive_name}.staging"
114
+ try:
115
+ self._stage(sources, confirmed, staging)
116
+ self._tar_create(partial, staging)
117
+ os.chmod(partial, _SECRET_MODE)
118
+ _write_text(sidecar_partial, sidecar_line(sha256_file(partial), archive.name))
119
+ os.replace(partial, archive)
120
+ try:
121
+ os.replace(sidecar_partial, sidecar)
122
+ except OSError:
123
+ archive.unlink(missing_ok=True)
124
+ raise
125
+ os.chmod(archive, _SECRET_MODE)
126
+ os.chmod(sidecar, _SECRET_MODE)
127
+ finally:
128
+ partial.unlink(missing_ok=True)
129
+ sidecar_partial.unlink(missing_ok=True)
130
+ shutil.rmtree(staging, ignore_errors=True)
131
+ return archive
132
+
133
+ def open(self, archive: Path, dest_dir: Path) -> OpenedPair:
134
+ """Check the sidecar, extract the archive, and validate the manifest."""
135
+ if not archive.is_file() or archive.is_symlink():
136
+ raise ArchiveError(f"missing {archive.name}")
137
+ if dest_dir.is_symlink():
138
+ raise ArchiveError("refusing to extract through a symlink")
139
+ if dest_dir.resolve() == self.output_dir.resolve():
140
+ raise ArchiveError("refusing to extract over the landing directory")
141
+ self.verify_sidecar(archive)
142
+ staging = dest_dir.with_name(dest_dir.name + ".partial")
143
+ shutil.rmtree(staging, ignore_errors=True)
144
+ staging.mkdir(parents=True, mode=0o700)
145
+ try:
146
+ self._extract(archive, staging)
147
+ opened = self._load(archive, staging)
148
+ except Exception:
149
+ shutil.rmtree(staging, ignore_errors=True)
150
+ raise
151
+ if dest_dir.exists():
152
+ shutil.rmtree(dest_dir)
153
+ os.replace(staging, dest_dir)
154
+ os.chmod(dest_dir, 0o700)
155
+ return _retarget(opened, dest_dir)
156
+
157
+ def open_latest(self, dest_dir: Path) -> OpenedPair:
158
+ """Open the newest pair that passes the sidecar, manifest, and member checks."""
159
+ last_error: ArchiveError | None = None
160
+ candidates = self._archives_newest_first()
161
+ if not candidates:
162
+ raise ArchiveError(f"no .tar.zst pair in {self.output_dir}")
163
+ for archive in candidates:
164
+ try:
165
+ return self.open(archive, dest_dir)
166
+ except ArchiveError as exc:
167
+ last_error = exc
168
+ raise last_error or ArchiveError(f"no verified .tar.zst pair in {self.output_dir}")
169
+
170
+ def manifest_of(self, archive: Path) -> BackupManifest:
171
+ """Validate the sidecar and manifest without extracting the archive."""
172
+ if not archive.is_file() or archive.is_symlink():
173
+ raise ArchiveError(f"missing {archive.name}")
174
+ self.verify_sidecar(archive)
175
+ names = self._members(archive)
176
+ _reject_unsafe(names)
177
+ manifest_name = next(
178
+ (name for name in names if Path(name).name == "backup-manifest.json"),
179
+ None,
180
+ )
181
+ if manifest_name is None:
182
+ raise ArchiveError("archive is missing backup-manifest.json")
183
+ completed = self._runner(["tar", "--zstd", "-xOf", str(archive), manifest_name])
184
+ if completed.returncode != 0:
185
+ detail = completed.stderr.strip() or "cannot read backup-manifest.json"
186
+ raise ArchiveError(detail)
187
+ return self._validate_manifest(completed.stdout)
188
+
189
+ def manifest_of_latest(self) -> tuple[Path, BackupManifest]:
190
+ """Return the newest pair whose sidecar and manifest validate."""
191
+ candidates = self._archives_newest_first()
192
+ if not candidates:
193
+ raise ArchiveError(f"no .tar.zst pair in {self.output_dir}")
194
+ last_error: ArchiveError | None = None
195
+ for archive in candidates:
196
+ try:
197
+ return archive, self.manifest_of(archive)
198
+ except ArchiveError as exc:
199
+ last_error = exc
200
+ raise last_error or ArchiveError(f"no verified .tar.zst pair in {self.output_dir}")
201
+
202
+ def verify_sidecar(self, archive: Path) -> None:
203
+ sidecar = archive.with_name(archive.name + ".sha256")
204
+ if not sidecar.is_file() or sidecar.is_symlink():
205
+ raise ArchiveError(f"missing sidecar for {archive.name}")
206
+ text = sidecar.read_text(encoding="utf-8")
207
+ prefix = f"{sha256_file(archive)} {archive.name}\n"
208
+ if text != prefix:
209
+ raise ArchiveError(f"sidecar does not match {archive.name}")
210
+
211
+ def _archives_newest_first(self) -> list[Path]:
212
+ found = [
213
+ path
214
+ for path in self.output_dir.glob("*.tar.zst")
215
+ if path.is_file() and not path.is_symlink()
216
+ ]
217
+ found.sort(key=lambda path: (path.stat().st_mtime_ns, path.name), reverse=True)
218
+ return found
219
+
220
+ def _require_sources(self, sources: BackupSources) -> None:
221
+ for path in (sources.gitlab_tar, sources.secrets_file, sources.config_file):
222
+ if not path.is_file() or path.is_symlink():
223
+ raise ArchiveError(f"missing {path.name}")
224
+
225
+ def _stage(self, sources: BackupSources, manifest: BackupManifest, staging: Path) -> None:
226
+ shutil.rmtree(staging, ignore_errors=True)
227
+ staging.mkdir(parents=True, mode=0o700)
228
+ _copy_bytes(sources.gitlab_tar, staging / sources.gitlab_tar.name)
229
+ _copy_bytes(sources.secrets_file, staging / "gitlab-secrets.json")
230
+ _copy_bytes(sources.config_file, staging / "gitlab.rb")
231
+ manifest_path = staging / "backup-manifest.json"
232
+ _write_text(manifest_path, manifest.to_json())
233
+ self._validate_manifest(manifest_path.read_text(encoding="utf-8"))
234
+
235
+ def _tar_create(self, partial: Path, staging: Path) -> None:
236
+ completed = self._runner(
237
+ ["tar", "--zstd", "-cf", str(partial), "-C", str(staging), "."]
238
+ )
239
+ if completed.returncode != 0:
240
+ raise ArchiveError(completed.stderr.strip() or "tar --zstd failed")
241
+
242
+ def _extract(self, archive: Path, dest_dir: Path) -> None:
243
+ names = self._members(archive)
244
+ _reject_unsafe(names)
245
+ completed = self._runner(
246
+ ["tar", "--zstd", "-xf", str(archive), "-C", str(dest_dir)]
247
+ )
248
+ if completed.returncode != 0:
249
+ raise ArchiveError(completed.stderr.strip() or "extract failed")
250
+
251
+ def _load(self, archive: Path, dest_dir: Path) -> OpenedPair:
252
+ for name in OUTER_REQUIRED:
253
+ if not (dest_dir / name).is_file():
254
+ raise ArchiveError(f"archive is missing {name}")
255
+ gitlab_tars = [
256
+ path
257
+ for path in dest_dir.glob("*_gitlab_backup.tar")
258
+ if path.is_file() and not path.is_symlink()
259
+ ]
260
+ if len(gitlab_tars) != 1:
261
+ raise ArchiveError("archive must contain one GitLab backup tar")
262
+ gitlab_tar = gitlab_tars[0]
263
+ manifest = self._validate_manifest(
264
+ (dest_dir / "backup-manifest.json").read_text(encoding="utf-8")
265
+ )
266
+ self._require_recorded_members(gitlab_tar, manifest)
267
+ if manifest.artifacts.application_tar != gitlab_tar.name:
268
+ raise ArchiveError("manifest names a different application tar")
269
+ if manifest.artifacts.sha256 != sha256_file(gitlab_tar):
270
+ raise ArchiveError("manifest digest does not match the application tar")
271
+ for path in (gitlab_tar, dest_dir / "gitlab-secrets.json", dest_dir / "gitlab.rb"):
272
+ os.chmod(path, _SECRET_MODE)
273
+ return OpenedPair(
274
+ archive=archive,
275
+ gitlab_tar=gitlab_tar,
276
+ manifest=manifest,
277
+ secrets_file=dest_dir / "gitlab-secrets.json",
278
+ config_file=dest_dir / "gitlab.rb",
279
+ )
280
+
281
+ def component_names(self, gitlab_tar: Path) -> set[str]:
282
+ """Return the names stored in the GitLab application tar."""
283
+ return self._members(gitlab_tar)
284
+
285
+ def _require_recorded_members(self, gitlab_tar: Path, manifest: BackupManifest) -> None:
286
+ present = {Path(name).name for name in self.component_names(gitlab_tar)}
287
+ try:
288
+ members, omitted = split_members(present, set(manifest.artifacts.omitted))
289
+ except ManifestError as exc:
290
+ raise ArchiveError(str(exc)) from exc
291
+ if members != list(manifest.artifacts.members) or omitted != list(manifest.artifacts.omitted):
292
+ raise ArchiveError("manifest members do not match the application tar")
293
+
294
+ def _members(self, path: Path) -> set[str]:
295
+ completed = self._runner(["tar", "-tf", str(path)])
296
+ if completed.returncode != 0:
297
+ raise ArchiveError(completed.stderr.strip() or f"cannot list {path.name}")
298
+ return {line.strip() for line in completed.stdout.splitlines() if line.strip()}
299
+
300
+ def _validate_manifest(self, text: str) -> BackupManifest:
301
+ try:
302
+ return BackupManifest.model_validate_json(text)
303
+ except (ValidationError, ManifestError) as exc:
304
+ raise ArchiveError(f"backup manifest is invalid: {exc}") from exc
305
+
306
+
307
+ def write_snapshot(source: Path, dest: Path) -> None:
308
+ """Write one extracted file to ``dest`` at mode 0600."""
309
+ if not source.is_file():
310
+ raise ArchiveError(f"missing {source.name}")
311
+ _write_private(source, dest)
312
+
313
+
314
+ def _copy_bytes(source: Path, dest: Path) -> None:
315
+ dest.write_bytes(source.read_bytes())
316
+ os.chmod(dest, _SECRET_MODE)
317
+
318
+
319
+ def _write_text(path: Path, text: str) -> None:
320
+ path.write_text(text, encoding="utf-8")
321
+ os.chmod(path, _SECRET_MODE)
322
+
323
+
324
+ def _write_private(source: Path, dest: Path) -> None:
325
+ dest.parent.mkdir(parents=True, exist_ok=True)
326
+ partial = dest.with_name(dest.name + ".partial")
327
+ partial.write_bytes(source.read_bytes())
328
+ os.chmod(partial, _SECRET_MODE)
329
+ os.replace(partial, dest)
330
+
331
+
332
+ def _reject_unsafe(names: set[str]) -> None:
333
+ for name in names:
334
+ path = Path(name)
335
+ if path.is_absolute() or ".." in path.parts:
336
+ raise ArchiveError(f"unsafe archive member {name}")
337
+
338
+
339
+ def _retarget(opened: OpenedPair, dest_dir: Path) -> OpenedPair:
340
+ return OpenedPair(
341
+ archive=opened.archive,
342
+ gitlab_tar=dest_dir / opened.gitlab_tar.name,
343
+ manifest=opened.manifest,
344
+ secrets_file=dest_dir / "gitlab-secrets.json",
345
+ config_file=dest_dir / "gitlab.rb",
346
+ )
347
+
348
+
349
+ def _run(args: list[str]) -> subprocess.CompletedProcess[str]:
350
+ return subprocess.run(args, check=False, capture_output=True, text=True)