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