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.
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/.gitignore +4 -0
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/PKG-INFO +38 -16
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/README.md +35 -14
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/pyproject.toml +6 -2
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/archive.py +350 -0
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/commands/backup.py +144 -0
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/commands/health.py +7 -1
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/commands/restore.py +187 -0
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/config.py +90 -2
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/manifest.py +328 -0
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/plugin.py +147 -0
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/schemas/backup-manifest.schema.json +314 -0
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/schemas/gitlab-cli.schema.json +140 -0
- xgic_gitlab_cli-0.1.3/src/xgic/cli/gitlab/settings.py +90 -0
- xgic_gitlab_cli-0.1.1/src/xgic/cli/gitlab/commands/backup.py +0 -86
- xgic_gitlab_cli-0.1.1/src/xgic/cli/gitlab/commands/restore.py +0 -108
- xgic_gitlab_cli-0.1.1/src/xgic/cli/gitlab/plugin.py +0 -77
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/LICENSE +0 -0
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/NOTICE +0 -0
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/__init__.py +0 -0
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/commands/__init__.py +0 -0
- {xgic_gitlab_cli-0.1.1 → xgic_gitlab_cli-0.1.3}/src/xgic/cli/gitlab/commands/info.py +0 -0
- {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.
|
|
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.
|
|
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)
|
|
41
|
+
[](https://pypi.org/project/xgic-gitlab-cli/)
|
|
42
|
+
[](https://pypi.org/project/xgic-gitlab-cli/)
|
|
43
|
+
[](https://github.com/xgic/gitlab-cli/releases)
|
|
40
44
|
[](https://github.com/xgic/gitlab-cli/actions/workflows/ci.yml)
|
|
41
|
-
[](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
|
-
|
|
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).
|
|
118
|
+
path (RC → TestPyPI → PyPI).
|
|
115
119
|
|
|
116
120
|
```bash
|
|
117
|
-
|
|
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.
|
|
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
|
|
139
|
-
| `xgic gitlab restore
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
172
|
-
|
|
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)
|
|
4
|
+
[](https://pypi.org/project/xgic-gitlab-cli/)
|
|
5
|
+
[](https://pypi.org/project/xgic-gitlab-cli/)
|
|
6
|
+
[](https://github.com/xgic/gitlab-cli/releases)
|
|
4
7
|
[](https://github.com/xgic/gitlab-cli/actions/workflows/ci.yml)
|
|
5
|
-
[](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
|
-
|
|
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).
|
|
81
|
+
path (RC → TestPyPI → PyPI).
|
|
79
82
|
|
|
80
83
|
```bash
|
|
81
|
-
|
|
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.
|
|
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
|
|
103
|
-
| `xgic gitlab restore
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
136
|
-
|
|
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.
|
|
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
|
-
"
|
|
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)
|