|
4ce269c…
|
ragelink
|
1 |
"""fossilrepo-ctl — operator CLI for the fossilrepo omnibus stack. |
|
4ce269c…
|
ragelink
|
2 |
|
|
4ce269c…
|
ragelink
|
3 |
Similar to gitlab-ctl: manages the full stack (Django, Fossil, Caddy, |
|
4ce269c…
|
ragelink
|
4 |
Litestream, Celery, Postgres, Redis) as a single unit. |
|
4ce269c…
|
ragelink
|
5 |
""" |
|
4ce269c…
|
ragelink
|
6 |
|
|
4ce269c…
|
ragelink
|
7 |
import subprocess |
|
4ce269c…
|
ragelink
|
8 |
from pathlib import Path |
|
4ce269c…
|
ragelink
|
9 |
|
|
4ce269c…
|
ragelink
|
10 |
import click |
|
4ce269c…
|
ragelink
|
11 |
from rich.console import Console |
|
4ce269c…
|
ragelink
|
12 |
|
|
4ce269c…
|
ragelink
|
13 |
console = Console() |
|
4ce269c…
|
ragelink
|
14 |
|
|
4ce269c…
|
ragelink
|
15 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent |
|
4ce269c…
|
ragelink
|
16 |
COMPOSE_FILE = PROJECT_ROOT / "docker-compose.yaml" |
|
4ce269c…
|
ragelink
|
17 |
FOSSIL_COMPOSE_FILE = PROJECT_ROOT / "docker" / "docker-compose.fossil.yml" |
|
4ce269c…
|
ragelink
|
18 |
|
|
4ce269c…
|
ragelink
|
19 |
|
|
4ce269c…
|
ragelink
|
20 |
def _compose(*args: str, fossil: bool = False) -> subprocess.CompletedProcess: |
|
4ce269c…
|
ragelink
|
21 |
"""Run a docker compose command.""" |
|
4ce269c…
|
ragelink
|
22 |
cmd = ["docker", "compose", "-f", str(COMPOSE_FILE)] |
|
4ce269c…
|
ragelink
|
23 |
if fossil: |
|
4ce269c…
|
ragelink
|
24 |
cmd.extend(["-f", str(FOSSIL_COMPOSE_FILE)]) |
|
4ce269c…
|
ragelink
|
25 |
cmd.extend(args) |
|
4ce269c…
|
ragelink
|
26 |
return subprocess.run(cmd, cwd=str(PROJECT_ROOT)) |
|
4ce269c…
|
ragelink
|
27 |
|
|
4ce269c…
|
ragelink
|
28 |
|
|
4ce269c…
|
ragelink
|
29 |
@click.group() |
|
4ce269c…
|
ragelink
|
30 |
@click.version_option(version="0.1.0") |
|
4ce269c…
|
ragelink
|
31 |
def cli() -> None: |
|
4ce269c…
|
ragelink
|
32 |
"""fossilrepo-ctl — manage the fossilrepo omnibus stack.""" |
|
4ce269c…
|
ragelink
|
33 |
|
|
4ce269c…
|
ragelink
|
34 |
|
|
4ce269c…
|
ragelink
|
35 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
36 |
# Stack commands (like gitlab-ctl start/stop/restart/status) |
|
4ce269c…
|
ragelink
|
37 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
38 |
|
|
4ce269c…
|
ragelink
|
39 |
|
|
4ce269c…
|
ragelink
|
40 |
@cli.command() |
|
4ce269c…
|
ragelink
|
41 |
@click.option("--detach/--no-detach", "-d", default=True, help="Run in background.") |
|
4ce269c…
|
ragelink
|
42 |
def start(detach: bool) -> None: |
|
4ce269c…
|
ragelink
|
43 |
"""Start the full fossilrepo stack.""" |
|
4ce269c…
|
ragelink
|
44 |
console.print("[bold green]Starting fossilrepo stack...[/bold green]") |
|
4ce269c…
|
ragelink
|
45 |
args = ["up"] |
|
4ce269c…
|
ragelink
|
46 |
if detach: |
|
4ce269c…
|
ragelink
|
47 |
args.append("-d") |
|
4ce269c…
|
ragelink
|
48 |
_compose(*args) |
|
4ce269c…
|
ragelink
|
49 |
|
|
4ce269c…
|
ragelink
|
50 |
|
|
4ce269c…
|
ragelink
|
51 |
@cli.command() |
|
4ce269c…
|
ragelink
|
52 |
def stop() -> None: |
|
4ce269c…
|
ragelink
|
53 |
"""Stop the full fossilrepo stack.""" |
|
4ce269c…
|
ragelink
|
54 |
console.print("[bold yellow]Stopping fossilrepo stack...[/bold yellow]") |
|
4ce269c…
|
ragelink
|
55 |
_compose("down") |
|
4ce269c…
|
ragelink
|
56 |
|
|
4ce269c…
|
ragelink
|
57 |
|
|
4ce269c…
|
ragelink
|
58 |
@cli.command() |
|
4ce269c…
|
ragelink
|
59 |
def restart() -> None: |
|
4ce269c…
|
ragelink
|
60 |
"""Restart the full fossilrepo stack.""" |
|
4ce269c…
|
ragelink
|
61 |
console.print("[bold yellow]Restarting fossilrepo stack...[/bold yellow]") |
|
4ce269c…
|
ragelink
|
62 |
_compose("restart") |
|
4ce269c…
|
ragelink
|
63 |
|
|
4ce269c…
|
ragelink
|
64 |
|
|
4ce269c…
|
ragelink
|
65 |
@cli.command() |
|
4ce269c…
|
ragelink
|
66 |
def status() -> None: |
|
4ce269c…
|
ragelink
|
67 |
"""Show status of all fossilrepo services.""" |
|
4ce269c…
|
ragelink
|
68 |
_compose("ps") |
|
4ce269c…
|
ragelink
|
69 |
|
|
4ce269c…
|
ragelink
|
70 |
|
|
4ce269c…
|
ragelink
|
71 |
@cli.command() |
|
4ce269c…
|
ragelink
|
72 |
@click.argument("service", required=False) |
|
4ce269c…
|
ragelink
|
73 |
@click.option("--follow/--no-follow", "-f", default=True, help="Follow log output.") |
|
4ce269c…
|
ragelink
|
74 |
@click.option("--tail", default="100", help="Number of lines to show.") |
|
4ce269c…
|
ragelink
|
75 |
def logs(service: str | None, follow: bool, tail: str) -> None: |
|
4ce269c…
|
ragelink
|
76 |
"""Tail logs from fossilrepo services.""" |
|
4ce269c…
|
ragelink
|
77 |
args = ["logs", "--tail", tail] |
|
4ce269c…
|
ragelink
|
78 |
if follow: |
|
4ce269c…
|
ragelink
|
79 |
args.append("-f") |
|
4ce269c…
|
ragelink
|
80 |
if service: |
|
4ce269c…
|
ragelink
|
81 |
args.append(service) |
|
4ce269c…
|
ragelink
|
82 |
_compose(*args) |
|
4ce269c…
|
ragelink
|
83 |
|
|
4ce269c…
|
ragelink
|
84 |
|
|
4ce269c…
|
ragelink
|
85 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
86 |
# Setup / reconfigure (like gitlab-ctl reconfigure) |
|
4ce269c…
|
ragelink
|
87 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
88 |
|
|
4ce269c…
|
ragelink
|
89 |
|
|
4ce269c…
|
ragelink
|
90 |
@cli.command() |
|
4ce269c…
|
ragelink
|
91 |
def reconfigure() -> None: |
|
4ce269c…
|
ragelink
|
92 |
"""Rebuild and reconfigure the stack (migrations, static files, etc.).""" |
|
4ce269c…
|
ragelink
|
93 |
console.print("[bold]Reconfiguring fossilrepo...[/bold]") |
|
4ce269c…
|
ragelink
|
94 |
_compose("build") |
|
4ce269c…
|
ragelink
|
95 |
_compose("up", "-d") |
|
4ce269c…
|
ragelink
|
96 |
console.print("[bold]Running migrations...[/bold]") |
|
4ce269c…
|
ragelink
|
97 |
_compose("exec", "backend", "python", "manage.py", "migrate") |
|
4ce269c…
|
ragelink
|
98 |
console.print("[bold]Collecting static files...[/bold]") |
|
4ce269c…
|
ragelink
|
99 |
_compose("exec", "backend", "python", "manage.py", "collectstatic", "--noinput") |
|
4ce269c…
|
ragelink
|
100 |
console.print("[bold green]Reconfiguration complete.[/bold green]") |
|
4ce269c…
|
ragelink
|
101 |
|
|
4ce269c…
|
ragelink
|
102 |
|
|
4ce269c…
|
ragelink
|
103 |
@cli.command() |
|
4ce269c…
|
ragelink
|
104 |
def seed() -> None: |
|
4ce269c…
|
ragelink
|
105 |
"""Load seed data (dev users, sample data).""" |
|
4ce269c…
|
ragelink
|
106 |
_compose("exec", "backend", "python", "manage.py", "seed") |
|
4ce269c…
|
ragelink
|
107 |
|
|
4ce269c…
|
ragelink
|
108 |
|
|
4ce269c…
|
ragelink
|
109 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
110 |
# Repo commands |
|
4ce269c…
|
ragelink
|
111 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
112 |
|
|
4ce269c…
|
ragelink
|
113 |
|
|
4ce269c…
|
ragelink
|
114 |
@cli.group() |
|
4ce269c…
|
ragelink
|
115 |
def repo() -> None: |
|
4ce269c…
|
ragelink
|
116 |
"""Manage Fossil repositories.""" |
|
4ce269c…
|
ragelink
|
117 |
|
|
4ce269c…
|
ragelink
|
118 |
|
|
4ce269c…
|
ragelink
|
119 |
@repo.command() |
|
4ce269c…
|
ragelink
|
120 |
@click.argument("name") |
|
4ce269c…
|
ragelink
|
121 |
def create(name: str) -> None: |
|
4ce269c…
|
ragelink
|
122 |
"""Create a new Fossil repository.""" |
|
4ce269c…
|
ragelink
|
123 |
import django |
|
4ce269c…
|
ragelink
|
124 |
|
|
4ce269c…
|
ragelink
|
125 |
django.setup() |
|
4ce269c…
|
ragelink
|
126 |
|
|
4ce269c…
|
ragelink
|
127 |
from fossil.cli import FossilCLI |
|
4ce269c…
|
ragelink
|
128 |
from fossil.models import FossilRepository |
|
4ce269c…
|
ragelink
|
129 |
from organization.models import Organization |
|
4ce269c…
|
ragelink
|
130 |
from projects.models import Project |
|
4ce269c…
|
ragelink
|
131 |
|
|
4ce269c…
|
ragelink
|
132 |
console.print(f"[bold]Creating repo:[/bold] {name}") |
|
4ce269c…
|
ragelink
|
133 |
cli = FossilCLI() |
|
4ce269c…
|
ragelink
|
134 |
if not cli.is_available(): |
|
4ce269c…
|
ragelink
|
135 |
console.print("[red]Fossil binary not found.[/red]") |
|
4ce269c…
|
ragelink
|
136 |
return |
|
4ce269c…
|
ragelink
|
137 |
|
|
4ce269c…
|
ragelink
|
138 |
org = Organization.objects.first() |
|
4ce269c…
|
ragelink
|
139 |
if not org: |
|
4ce269c…
|
ragelink
|
140 |
console.print("[red]No organization found. Run seed first.[/red]") |
|
4ce269c…
|
ragelink
|
141 |
return |
|
4ce269c…
|
ragelink
|
142 |
|
|
4ce269c…
|
ragelink
|
143 |
project, created = Project.objects.get_or_create(name=name, defaults={"organization": org, "visibility": "private"}) |
|
4ce269c…
|
ragelink
|
144 |
if created: |
|
4ce269c…
|
ragelink
|
145 |
console.print(f" Created project: [cyan]{project.slug}[/cyan]") |
|
4ce269c…
|
ragelink
|
146 |
|
|
4ce269c…
|
ragelink
|
147 |
fossil_repo = FossilRepository.objects.filter(project=project).first() |
|
4ce269c…
|
ragelink
|
148 |
if fossil_repo and fossil_repo.exists_on_disk: |
|
4ce269c…
|
ragelink
|
149 |
console.print(f" Repo already exists: [cyan]{fossil_repo.full_path}[/cyan]") |
|
4ce269c…
|
ragelink
|
150 |
elif fossil_repo: |
|
4ce269c…
|
ragelink
|
151 |
cli.init(fossil_repo.full_path) |
|
4ce269c…
|
ragelink
|
152 |
fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size |
|
4ce269c…
|
ragelink
|
153 |
fossil_repo.save(update_fields=["file_size_bytes", "updated_at", "version"]) |
|
4ce269c…
|
ragelink
|
154 |
console.print(f" Initialized: [green]{fossil_repo.full_path}[/green]") |
|
4ce269c…
|
ragelink
|
155 |
console.print("[bold green]Done.[/bold green]") |
|
4ce269c…
|
ragelink
|
156 |
|
|
4ce269c…
|
ragelink
|
157 |
|
|
4ce269c…
|
ragelink
|
158 |
@repo.command(name="list") |
|
4ce269c…
|
ragelink
|
159 |
def list_repos() -> None: |
|
4ce269c…
|
ragelink
|
160 |
"""List all Fossil repositories.""" |
|
4ce269c…
|
ragelink
|
161 |
import django |
|
4ce269c…
|
ragelink
|
162 |
|
|
4ce269c…
|
ragelink
|
163 |
django.setup() |
|
4ce269c…
|
ragelink
|
164 |
from rich.table import Table |
|
4ce269c…
|
ragelink
|
165 |
|
|
4ce269c…
|
ragelink
|
166 |
from fossil.models import FossilRepository |
|
4ce269c…
|
ragelink
|
167 |
|
|
4ce269c…
|
ragelink
|
168 |
repos = FossilRepository.objects.all() |
|
4ce269c…
|
ragelink
|
169 |
table = Table(title="Fossil Repositories") |
|
4ce269c…
|
ragelink
|
170 |
table.add_column("Project", style="cyan") |
|
4ce269c…
|
ragelink
|
171 |
table.add_column("Filename") |
|
4ce269c…
|
ragelink
|
172 |
table.add_column("Size", justify="right") |
|
4ce269c…
|
ragelink
|
173 |
table.add_column("On Disk", justify="center") |
|
4ce269c…
|
ragelink
|
174 |
for r in repos: |
|
4ce269c…
|
ragelink
|
175 |
size = f"{r.file_size_bytes / 1024:.0f} KB" if r.file_size_bytes else "—" |
|
4ce269c…
|
ragelink
|
176 |
table.add_row(r.project.name, r.filename, size, "yes" if r.exists_on_disk else "no") |
|
4ce269c…
|
ragelink
|
177 |
console.print(table) |
|
4ce269c…
|
ragelink
|
178 |
|
|
4ce269c…
|
ragelink
|
179 |
|
|
4ce269c…
|
ragelink
|
180 |
@repo.command() |
|
4ce269c…
|
ragelink
|
181 |
@click.argument("name") |
|
4ce269c…
|
ragelink
|
182 |
def delete(name: str) -> None: |
|
4ce269c…
|
ragelink
|
183 |
"""Delete a Fossil repository (soft delete).""" |
|
4ce269c…
|
ragelink
|
184 |
import django |
|
4ce269c…
|
ragelink
|
185 |
|
|
4ce269c…
|
ragelink
|
186 |
django.setup() |
|
4ce269c…
|
ragelink
|
187 |
from fossil.models import FossilRepository |
|
4ce269c…
|
ragelink
|
188 |
|
|
4ce269c…
|
ragelink
|
189 |
console.print(f"[bold]Deleting repo:[/bold] {name}") |
|
4ce269c…
|
ragelink
|
190 |
repo = FossilRepository.objects.filter(filename=f"{name}.fossil").first() |
|
4ce269c…
|
ragelink
|
191 |
if not repo: |
|
4ce269c…
|
ragelink
|
192 |
console.print(f"[red]Repo not found: {name}.fossil[/red]") |
|
4ce269c…
|
ragelink
|
193 |
return |
|
4ce269c…
|
ragelink
|
194 |
repo.soft_delete() |
|
4ce269c…
|
ragelink
|
195 |
console.print(f" Soft-deleted: [yellow]{repo.filename}[/yellow]") |
|
4ce269c…
|
ragelink
|
196 |
console.print("[bold green]Done.[/bold green]") |
|
4ce269c…
|
ragelink
|
197 |
|
|
4ce269c…
|
ragelink
|
198 |
|
|
4ce269c…
|
ragelink
|
199 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
200 |
# Sync commands |
|
4ce269c…
|
ragelink
|
201 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
202 |
|
|
4ce269c…
|
ragelink
|
203 |
|
|
4ce269c…
|
ragelink
|
204 |
@cli.group() |
|
4ce269c…
|
ragelink
|
205 |
def sync() -> None: |
|
4ce269c…
|
ragelink
|
206 |
"""Sync Fossil repos to GitHub/GitLab.""" |
|
4ce269c…
|
ragelink
|
207 |
|
|
4ce269c…
|
ragelink
|
208 |
|
|
4ce269c…
|
ragelink
|
209 |
@sync.command(name="run") |
|
4ce269c…
|
ragelink
|
210 |
@click.argument("repo_name") |
|
2eca4eb…
|
ragelink
|
211 |
@click.option("--mirror-id", type=int, help="Specific Git mirror ID to sync.") |
|
2eca4eb…
|
ragelink
|
212 |
def sync_run(repo_name: str, mirror_id: int | None = None) -> None: |
|
2eca4eb…
|
ragelink
|
213 |
"""Run Git sync for a Fossil repository.""" |
|
2eca4eb…
|
ragelink
|
214 |
import django |
|
2eca4eb…
|
ragelink
|
215 |
|
|
2eca4eb…
|
ragelink
|
216 |
django.setup() |
|
2eca4eb…
|
ragelink
|
217 |
from fossil.models import FossilRepository |
|
2eca4eb…
|
ragelink
|
218 |
from fossil.sync_models import GitMirror |
|
2eca4eb…
|
ragelink
|
219 |
from fossil.tasks import run_git_sync |
|
2eca4eb…
|
ragelink
|
220 |
|
|
2eca4eb…
|
ragelink
|
221 |
repo = FossilRepository.objects.filter(filename=f"{repo_name}.fossil").first() |
|
2eca4eb…
|
ragelink
|
222 |
if not repo: |
|
2eca4eb…
|
ragelink
|
223 |
console.print(f"[red]Repo not found: {repo_name}.fossil[/red]") |
|
2eca4eb…
|
ragelink
|
224 |
return |
|
2eca4eb…
|
ragelink
|
225 |
|
|
2eca4eb…
|
ragelink
|
226 |
mirrors = GitMirror.objects.filter(repository=repo, deleted_at__isnull=True).exclude(sync_mode="disabled") |
|
2eca4eb…
|
ragelink
|
227 |
if mirror_id: |
|
2eca4eb…
|
ragelink
|
228 |
mirrors = mirrors.filter(pk=mirror_id) |
|
2eca4eb…
|
ragelink
|
229 |
|
|
2eca4eb…
|
ragelink
|
230 |
if not mirrors.exists(): |
|
2eca4eb…
|
ragelink
|
231 |
console.print("[yellow]No Git mirrors configured for this repo.[/yellow]") |
|
2eca4eb…
|
ragelink
|
232 |
return |
|
2eca4eb…
|
ragelink
|
233 |
|
|
2eca4eb…
|
ragelink
|
234 |
for mirror in mirrors: |
|
2eca4eb…
|
ragelink
|
235 |
console.print(f"[bold]Syncing[/bold] {repo.filename} → {mirror.git_remote_url}") |
|
2eca4eb…
|
ragelink
|
236 |
run_git_sync(mirror.pk) |
|
2eca4eb…
|
ragelink
|
237 |
mirror.refresh_from_db() |
|
2eca4eb…
|
ragelink
|
238 |
if mirror.last_sync_status == "success": |
|
2eca4eb…
|
ragelink
|
239 |
console.print(f" [green]Success[/green] — {mirror.last_sync_message[:100]}") |
|
2eca4eb…
|
ragelink
|
240 |
else: |
|
2eca4eb…
|
ragelink
|
241 |
console.print(f" [red]Failed[/red] — {mirror.last_sync_message[:100]}") |
|
4ce269c…
|
ragelink
|
242 |
|
|
4ce269c…
|
ragelink
|
243 |
|
|
4ce269c…
|
ragelink
|
244 |
@sync.command(name="status") |
|
2eca4eb…
|
ragelink
|
245 |
@click.argument("repo_name", required=False) |
|
2eca4eb…
|
ragelink
|
246 |
def sync_status(repo_name: str | None = None) -> None: |
|
2eca4eb…
|
ragelink
|
247 |
"""Show sync status for repositories.""" |
|
2eca4eb…
|
ragelink
|
248 |
import django |
|
2eca4eb…
|
ragelink
|
249 |
|
|
2eca4eb…
|
ragelink
|
250 |
django.setup() |
|
2eca4eb…
|
ragelink
|
251 |
from rich.table import Table |
|
2eca4eb…
|
ragelink
|
252 |
|
|
2eca4eb…
|
ragelink
|
253 |
from fossil.sync_models import GitMirror |
|
2eca4eb…
|
ragelink
|
254 |
|
|
2eca4eb…
|
ragelink
|
255 |
mirrors = GitMirror.objects.filter(deleted_at__isnull=True) |
|
2eca4eb…
|
ragelink
|
256 |
if repo_name: |
|
2eca4eb…
|
ragelink
|
257 |
mirrors = mirrors.filter(repository__filename=f"{repo_name}.fossil") |
|
2eca4eb…
|
ragelink
|
258 |
|
|
2eca4eb…
|
ragelink
|
259 |
table = Table(title="Git Mirror Status") |
|
2eca4eb…
|
ragelink
|
260 |
table.add_column("Repo", style="cyan") |
|
2eca4eb…
|
ragelink
|
261 |
table.add_column("Remote") |
|
2eca4eb…
|
ragelink
|
262 |
table.add_column("Mode") |
|
2eca4eb…
|
ragelink
|
263 |
table.add_column("Status") |
|
2eca4eb…
|
ragelink
|
264 |
table.add_column("Last Sync") |
|
2eca4eb…
|
ragelink
|
265 |
table.add_column("Syncs", justify="right") |
|
2eca4eb…
|
ragelink
|
266 |
for m in mirrors: |
|
2eca4eb…
|
ragelink
|
267 |
status_style = "green" if m.last_sync_status == "success" else "red" if m.last_sync_status == "failed" else "yellow" |
|
2eca4eb…
|
ragelink
|
268 |
table.add_row( |
|
2eca4eb…
|
ragelink
|
269 |
m.repository.filename, |
|
2eca4eb…
|
ragelink
|
270 |
m.git_remote_url[:40], |
|
2eca4eb…
|
ragelink
|
271 |
m.get_sync_mode_display(), |
|
2eca4eb…
|
ragelink
|
272 |
f"[{status_style}]{m.last_sync_status or 'never'}[/{status_style}]", |
|
2eca4eb…
|
ragelink
|
273 |
str(m.last_sync_at.strftime("%Y-%m-%d %H:%M") if m.last_sync_at else "—"), |
|
2eca4eb…
|
ragelink
|
274 |
str(m.total_syncs), |
|
2eca4eb…
|
ragelink
|
275 |
) |
|
2eca4eb…
|
ragelink
|
276 |
console.print(table) |
|
4ce269c…
|
ragelink
|
277 |
|
|
4ce269c…
|
ragelink
|
278 |
|
|
4ce269c…
|
ragelink
|
279 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
280 |
# Backup commands |
|
4ce269c…
|
ragelink
|
281 |
# --------------------------------------------------------------------------- |
|
4ce269c…
|
ragelink
|
282 |
|
|
4ce269c…
|
ragelink
|
283 |
|
|
4ce269c…
|
ragelink
|
284 |
@cli.group() |
|
4ce269c…
|
ragelink
|
285 |
def backup() -> None: |
|
4ce269c…
|
ragelink
|
286 |
"""Backup and restore operations.""" |
|
4ce269c…
|
ragelink
|
287 |
|
|
4ce269c…
|
ragelink
|
288 |
|
|
4ce269c…
|
ragelink
|
289 |
@backup.command(name="create") |
|
4ce269c…
|
ragelink
|
290 |
def backup_create() -> None: |
|
4ce269c…
|
ragelink
|
291 |
"""Create a backup of all repos and database.""" |
|
4ce269c…
|
ragelink
|
292 |
console.print("[bold]Creating backup...[/bold]") |
|
4ce269c…
|
ragelink
|
293 |
raise NotImplementedError("Backup not yet implemented") |
|
4ce269c…
|
ragelink
|
294 |
|
|
4ce269c…
|
ragelink
|
295 |
|
|
4ce269c…
|
ragelink
|
296 |
@backup.command(name="restore") |
|
4ce269c…
|
ragelink
|
297 |
@click.argument("path") |
|
4ce269c…
|
ragelink
|
298 |
def backup_restore(path: str) -> None: |
|
4ce269c…
|
ragelink
|
299 |
"""Restore from a backup.""" |
|
4ce269c…
|
ragelink
|
300 |
console.print(f"[bold]Restoring from:[/bold] {path}") |
|
4ce269c…
|
ragelink
|
301 |
raise NotImplementedError("Restore not yet implemented") |
|
c588255…
|
ragelink
|
302 |
|
|
c588255…
|
ragelink
|
303 |
|
|
c588255…
|
ragelink
|
304 |
# --------------------------------------------------------------------------- |
|
c588255…
|
ragelink
|
305 |
# Bundle commands |
|
c588255…
|
ragelink
|
306 |
# --------------------------------------------------------------------------- |
|
c588255…
|
ragelink
|
307 |
|
|
c588255…
|
ragelink
|
308 |
|
|
c588255…
|
ragelink
|
309 |
@cli.group() |
|
c588255…
|
ragelink
|
310 |
def bundle() -> None: |
|
c588255…
|
ragelink
|
311 |
"""Export and import Fossil repository bundles.""" |
|
c588255…
|
ragelink
|
312 |
|
|
c588255…
|
ragelink
|
313 |
|
|
c588255…
|
ragelink
|
314 |
@bundle.command(name="export") |
|
c588255…
|
ragelink
|
315 |
@click.argument("project_slug") |
|
c588255…
|
ragelink
|
316 |
@click.argument("output_path") |
|
c588255…
|
ragelink
|
317 |
def bundle_export(project_slug: str, output_path: str) -> None: |
|
c588255…
|
ragelink
|
318 |
"""Export a Fossil repo as a bundle file.""" |
|
c588255…
|
ragelink
|
319 |
import django |
|
c588255…
|
ragelink
|
320 |
|
|
c588255…
|
ragelink
|
321 |
django.setup() |
|
c588255…
|
ragelink
|
322 |
|
|
c588255…
|
ragelink
|
323 |
from fossil.cli import FossilCLI |
|
c588255…
|
ragelink
|
324 |
from fossil.models import FossilRepository |
|
c588255…
|
ragelink
|
325 |
|
|
c588255…
|
ragelink
|
326 |
repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first() |
|
c588255…
|
ragelink
|
327 |
if not repo: |
|
c588255…
|
ragelink
|
328 |
console.print(f"[red]No repository found for project: {project_slug}[/red]") |
|
c588255…
|
ragelink
|
329 |
return |
|
c588255…
|
ragelink
|
330 |
|
|
c588255…
|
ragelink
|
331 |
if not repo.exists_on_disk: |
|
c588255…
|
ragelink
|
332 |
console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]") |
|
c588255…
|
ragelink
|
333 |
return |
|
c588255…
|
ragelink
|
334 |
|
|
c588255…
|
ragelink
|
335 |
fossil_cli = FossilCLI() |
|
c588255…
|
ragelink
|
336 |
if not fossil_cli.is_available(): |
|
c588255…
|
ragelink
|
337 |
console.print("[red]Fossil binary not found.[/red]") |
|
c588255…
|
ragelink
|
338 |
return |
|
c588255…
|
ragelink
|
339 |
|
|
c588255…
|
ragelink
|
340 |
output = Path(output_path) |
|
c588255…
|
ragelink
|
341 |
output.parent.mkdir(parents=True, exist_ok=True) |
|
c588255…
|
ragelink
|
342 |
|
|
c588255…
|
ragelink
|
343 |
console.print(f"[bold]Exporting bundle:[/bold] {repo.filename} -> {output}") |
|
c588255…
|
ragelink
|
344 |
try: |
|
c588255…
|
ragelink
|
345 |
result = subprocess.run( |
|
c588255…
|
ragelink
|
346 |
[fossil_cli.binary, "bundle", "export", str(output), "-R", str(repo.full_path)], |
|
c588255…
|
ragelink
|
347 |
capture_output=True, |
|
c588255…
|
ragelink
|
348 |
text=True, |
|
c588255…
|
ragelink
|
349 |
timeout=300, |
|
c588255…
|
ragelink
|
350 |
env=fossil_cli._env, |
|
c588255…
|
ragelink
|
351 |
) |
|
c588255…
|
ragelink
|
352 |
if result.returncode == 0: |
|
c588255…
|
ragelink
|
353 |
size_kb = output.stat().st_size / 1024 |
|
c588255…
|
ragelink
|
354 |
console.print(f" [green]Success[/green] — {size_kb:.0f} KB written to {output}") |
|
c588255…
|
ragelink
|
355 |
else: |
|
c588255…
|
ragelink
|
356 |
console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}") |
|
c588255…
|
ragelink
|
357 |
except subprocess.TimeoutExpired: |
|
c588255…
|
ragelink
|
358 |
console.print("[red]Export timed out after 5 minutes.[/red]") |
|
c588255…
|
ragelink
|
359 |
|
|
c588255…
|
ragelink
|
360 |
|
|
c588255…
|
ragelink
|
361 |
# --------------------------------------------------------------------------- |
|
c588255…
|
ragelink
|
362 |
# Update commands |
|
c588255…
|
ragelink
|
363 |
# --------------------------------------------------------------------------- |
|
c588255…
|
ragelink
|
364 |
|
|
c588255…
|
ragelink
|
365 |
|
|
c588255…
|
ragelink
|
366 |
@cli.command() |
|
c588255…
|
ragelink
|
367 |
@click.option("--source", type=click.Choice(["auto", "pypi", "git", "docker"]), default="auto", help="Update source.") |
|
c588255…
|
ragelink
|
368 |
def check_update(source: str) -> None: |
|
c588255…
|
ragelink
|
369 |
"""Check for available updates.""" |
|
c588255…
|
ragelink
|
370 |
import importlib.metadata |
|
c588255…
|
ragelink
|
371 |
|
|
c588255…
|
ragelink
|
372 |
import requests |
|
c588255…
|
ragelink
|
373 |
|
|
c588255…
|
ragelink
|
374 |
current = importlib.metadata.version("fossilrepo") |
|
c588255…
|
ragelink
|
375 |
console.print(f"[bold]Current version:[/bold] {current}") |
|
c588255…
|
ragelink
|
376 |
|
|
c588255…
|
ragelink
|
377 |
if source == "auto": |
|
c588255…
|
ragelink
|
378 |
# Detect install source |
|
c588255…
|
ragelink
|
379 |
if (PROJECT_ROOT / ".git").exists(): |
|
c588255…
|
ragelink
|
380 |
source = "git" |
|
c588255…
|
ragelink
|
381 |
elif COMPOSE_FILE.exists(): |
|
c588255…
|
ragelink
|
382 |
source = "docker" |
|
c588255…
|
ragelink
|
383 |
else: |
|
c588255…
|
ragelink
|
384 |
source = "pypi" |
|
c588255…
|
ragelink
|
385 |
|
|
c588255…
|
ragelink
|
386 |
latest = None |
|
c588255…
|
ragelink
|
387 |
if source == "pypi": |
|
c588255…
|
ragelink
|
388 |
console.print("[dim]Checking PyPI...[/dim]") |
|
c588255…
|
ragelink
|
389 |
try: |
|
c588255…
|
ragelink
|
390 |
resp = requests.get("https://pypi.org/pypi/fossilrepo/json", timeout=10) |
|
c588255…
|
ragelink
|
391 |
if resp.status_code == 200: |
|
c588255…
|
ragelink
|
392 |
latest = resp.json()["info"]["version"] |
|
c588255…
|
ragelink
|
393 |
except Exception: |
|
c588255…
|
ragelink
|
394 |
console.print("[yellow]Could not reach PyPI[/yellow]") |
|
c588255…
|
ragelink
|
395 |
|
|
c588255…
|
ragelink
|
396 |
elif source == "git": |
|
c588255…
|
ragelink
|
397 |
console.print("[dim]Checking GitHub releases...[/dim]") |
|
c588255…
|
ragelink
|
398 |
try: |
|
c588255…
|
ragelink
|
399 |
resp = requests.get("https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest", timeout=10) |
|
c588255…
|
ragelink
|
400 |
if resp.status_code == 200: |
|
c588255…
|
ragelink
|
401 |
latest = resp.json()["tag_name"].lstrip("v") |
|
c588255…
|
ragelink
|
402 |
except Exception: |
|
c588255…
|
ragelink
|
403 |
console.print("[yellow]Could not reach GitHub[/yellow]") |
|
c588255…
|
ragelink
|
404 |
|
|
c588255…
|
ragelink
|
405 |
elif source == "docker": |
|
c588255…
|
ragelink
|
406 |
console.print("[dim]Checking Docker Hub...[/dim]") |
|
c588255…
|
ragelink
|
407 |
try: |
|
c588255…
|
ragelink
|
408 |
resp = requests.get("https://hub.docker.com/v2/repositories/conflicthq/fossilrepo/tags/latest", timeout=10) |
|
c588255…
|
ragelink
|
409 |
if resp.status_code == 200: |
|
c588255…
|
ragelink
|
410 |
latest = resp.json().get("name", "unknown") |
|
c588255…
|
ragelink
|
411 |
except Exception: |
|
c588255…
|
ragelink
|
412 |
console.print("[yellow]Could not reach Docker Hub[/yellow]") |
|
c588255…
|
ragelink
|
413 |
|
|
c588255…
|
ragelink
|
414 |
if latest: |
|
c588255…
|
ragelink
|
415 |
if latest != current: |
|
c588255…
|
ragelink
|
416 |
console.print(f"[bold green]Update available:[/bold green] {current} → {latest} (source: {source})") |
|
c588255…
|
ragelink
|
417 |
else: |
|
c588255…
|
ragelink
|
418 |
console.print(f"[green]Up to date.[/green] ({current}, source: {source})") |
|
c588255…
|
ragelink
|
419 |
else: |
|
c588255…
|
ragelink
|
420 |
console.print("[yellow]Could not determine latest version.[/yellow]") |
|
c588255…
|
ragelink
|
421 |
|
|
c588255…
|
ragelink
|
422 |
|
|
c588255…
|
ragelink
|
423 |
@cli.command() |
|
c588255…
|
ragelink
|
424 |
@click.option("--source", type=click.Choice(["auto", "pypi", "git"]), default="auto", help="Update source.") |
|
c588255…
|
ragelink
|
425 |
@click.confirmation_option(prompt="This will update fossilrepo and restart services. Continue?") |
|
c588255…
|
ragelink
|
426 |
def update(source: str) -> None: |
|
c588255…
|
ragelink
|
427 |
"""Update fossilrepo to the latest version.""" |
|
c588255…
|
ragelink
|
428 |
if source == "auto": |
|
c588255…
|
ragelink
|
429 |
if (PROJECT_ROOT / ".git").exists(): |
|
c588255…
|
ragelink
|
430 |
source = "git" |
|
c588255…
|
ragelink
|
431 |
else: |
|
c588255…
|
ragelink
|
432 |
source = "pypi" |
|
c588255…
|
ragelink
|
433 |
|
|
c588255…
|
ragelink
|
434 |
if source == "git": |
|
c588255…
|
ragelink
|
435 |
console.print("[bold]Pulling latest from git...[/bold]") |
|
c588255…
|
ragelink
|
436 |
subprocess.run(["git", "pull", "--ff-only"], cwd=str(PROJECT_ROOT), check=True) |
|
c588255…
|
ragelink
|
437 |
console.print("[bold]Installing dependencies...[/bold]") |
|
c588255…
|
ragelink
|
438 |
subprocess.run(["pip", "install", "-e", "."], cwd=str(PROJECT_ROOT), check=True) |
|
c588255…
|
ragelink
|
439 |
elif source == "pypi": |
|
c588255…
|
ragelink
|
440 |
console.print("[bold]Upgrading from PyPI...[/bold]") |
|
c588255…
|
ragelink
|
441 |
subprocess.run(["pip", "install", "--upgrade", "fossilrepo"], check=True) |
|
c588255…
|
ragelink
|
442 |
|
|
c588255…
|
ragelink
|
443 |
console.print("[bold]Running migrations...[/bold]") |
|
c588255…
|
ragelink
|
444 |
subprocess.run(["python", "manage.py", "migrate", "--noinput"], cwd=str(PROJECT_ROOT), check=True) |
|
c588255…
|
ragelink
|
445 |
console.print("[bold]Collecting static files...[/bold]") |
|
c588255…
|
ragelink
|
446 |
subprocess.run(["python", "manage.py", "collectstatic", "--noinput"], cwd=str(PROJECT_ROOT), check=True) |
|
c588255…
|
ragelink
|
447 |
console.print("[bold green]Update complete. Restart services to apply.[/bold green]") |
|
c588255…
|
ragelink
|
448 |
|
|
c588255…
|
ragelink
|
449 |
|
|
c588255…
|
ragelink
|
450 |
@bundle.command(name="import") |
|
c588255…
|
ragelink
|
451 |
@click.argument("project_slug") |
|
c588255…
|
ragelink
|
452 |
@click.argument("input_path") |
|
c588255…
|
ragelink
|
453 |
def bundle_import(project_slug: str, input_path: str) -> None: |
|
c588255…
|
ragelink
|
454 |
"""Import a Fossil bundle into an existing repo.""" |
|
c588255…
|
ragelink
|
455 |
import django |
|
c588255…
|
ragelink
|
456 |
|
|
c588255…
|
ragelink
|
457 |
django.setup() |
|
c588255…
|
ragelink
|
458 |
|
|
c588255…
|
ragelink
|
459 |
from fossil.cli import FossilCLI |
|
c588255…
|
ragelink
|
460 |
from fossil.models import FossilRepository |
|
c588255…
|
ragelink
|
461 |
|
|
c588255…
|
ragelink
|
462 |
repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first() |
|
c588255…
|
ragelink
|
463 |
if not repo: |
|
c588255…
|
ragelink
|
464 |
console.print(f"[red]No repository found for project: {project_slug}[/red]") |
|
c588255…
|
ragelink
|
465 |
return |
|
c588255…
|
ragelink
|
466 |
|
|
c588255…
|
ragelink
|
467 |
if not repo.exists_on_disk: |
|
c588255…
|
ragelink
|
468 |
console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]") |
|
c588255…
|
ragelink
|
469 |
return |
|
c588255…
|
ragelink
|
470 |
|
|
c588255…
|
ragelink
|
471 |
input_file = Path(input_path) |
|
c588255…
|
ragelink
|
472 |
if not input_file.exists(): |
|
c588255…
|
ragelink
|
473 |
console.print(f"[red]Bundle file not found: {input_file}[/red]") |
|
c588255…
|
ragelink
|
474 |
return |
|
c588255…
|
ragelink
|
475 |
|
|
c588255…
|
ragelink
|
476 |
fossil_cli = FossilCLI() |
|
c588255…
|
ragelink
|
477 |
if not fossil_cli.is_available(): |
|
c588255…
|
ragelink
|
478 |
console.print("[red]Fossil binary not found.[/red]") |
|
c588255…
|
ragelink
|
479 |
return |
|
c588255…
|
ragelink
|
480 |
|
|
c588255…
|
ragelink
|
481 |
console.print(f"[bold]Importing bundle:[/bold] {input_file} -> {repo.filename}") |
|
c588255…
|
ragelink
|
482 |
try: |
|
c588255…
|
ragelink
|
483 |
result = subprocess.run( |
|
c588255…
|
ragelink
|
484 |
[fossil_cli.binary, "bundle", "import", str(input_file), "-R", str(repo.full_path)], |
|
c588255…
|
ragelink
|
485 |
capture_output=True, |
|
c588255…
|
ragelink
|
486 |
text=True, |
|
c588255…
|
ragelink
|
487 |
timeout=300, |
|
c588255…
|
ragelink
|
488 |
env=fossil_cli._env, |
|
c588255…
|
ragelink
|
489 |
) |
|
c588255…
|
ragelink
|
490 |
if result.returncode == 0: |
|
c588255…
|
ragelink
|
491 |
console.print(f" [green]Success[/green] — {result.stdout.strip()}") |
|
c588255…
|
ragelink
|
492 |
else: |
|
c588255…
|
ragelink
|
493 |
console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}") |
|
c588255…
|
ragelink
|
494 |
except subprocess.TimeoutExpired: |
|
c588255…
|
ragelink
|
495 |
console.print("[red]Import timed out after 5 minutes.[/red]") |