FossilRepo

fossilrepo / ctl / main.py
Blame History Raw 496 lines
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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button