fadenstack 0.4.2__py3-none-any.whl
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.
- faden_cli/__init__.py +16 -0
- faden_cli/__main__.py +9 -0
- faden_cli/bundle/shared/.gitignore +25 -0
- faden_cli/bundle/shared/LICENSE +187 -0
- faden_cli/bundle/shared/NOTICE +15 -0
- faden_cli/bundle/shared/OFFICIAL.md +9 -0
- faden_cli/bundle/shared/README.md +16 -0
- faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
- faden_cli/bundle/shared/alloy/config.alloy +40 -0
- faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
- faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
- faden_cli/bundle/shared/docker-compose.yaml +998 -0
- faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
- faden_cli/bundle/shared/initdb/01-init.sql +32 -0
- faden_cli/bundle/shared/loki/local-config.yaml +32 -0
- faden_cli/bundle/shared/nginx/locations.conf +245 -0
- faden_cli/bundle/shared/nginx/maintenance.html +239 -0
- faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
- faden_cli/bundle/shared/nginx/nginx.conf +44 -0
- faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
- faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
- faden_cli/bundle/shared/nginx/select-config.sh +37 -0
- faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
- faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
- faden_cli/bundle/shared/tls/README.md +30 -0
- faden_cli/cli.py +158 -0
- faden_cli/commands/__init__.py +1 -0
- faden_cli/commands/admin.py +161 -0
- faden_cli/commands/backup.py +159 -0
- faden_cli/commands/config.py +146 -0
- faden_cli/commands/deploy.py +949 -0
- faden_cli/commands/dev/__init__.py +1 -0
- faden_cli/commands/dev/dev_doctor.py +46 -0
- faden_cli/commands/dev/dev_down.py +241 -0
- faden_cli/commands/dev/dev_group.py +24 -0
- faden_cli/commands/dev/dev_init.py +710 -0
- faden_cli/commands/dev/dev_pull.py +297 -0
- faden_cli/commands/dev/dev_status.py +151 -0
- faden_cli/commands/dev/dev_up.py +1392 -0
- faden_cli/commands/doctor.py +225 -0
- faden_cli/commands/down.py +75 -0
- faden_cli/commands/logs_cmd.py +41 -0
- faden_cli/commands/maintenance.py +74 -0
- faden_cli/commands/module.py +113 -0
- faden_cli/commands/observe.py +95 -0
- faden_cli/commands/ports.py +91 -0
- faden_cli/commands/rebuild.py +158 -0
- faden_cli/commands/restore.py +139 -0
- faden_cli/commands/runtime_images.py +174 -0
- faden_cli/commands/status.py +71 -0
- faden_cli/commands/tls.py +722 -0
- faden_cli/commands/tune.py +37 -0
- faden_cli/commands/up.py +64 -0
- faden_cli/commands/upgrade.py +470 -0
- faden_cli/commands/version.py +78 -0
- faden_cli/core/__init__.py +1 -0
- faden_cli/core/api_client.py +77 -0
- faden_cli/core/backup.py +717 -0
- faden_cli/core/bootstrap.py +291 -0
- faden_cli/core/bundle.py +110 -0
- faden_cli/core/clickhouse.py +61 -0
- faden_cli/core/compose.py +484 -0
- faden_cli/core/console.py +106 -0
- faden_cli/core/detect.py +580 -0
- faden_cli/core/dev_modules.py +149 -0
- faden_cli/core/docker_env.py +147 -0
- faden_cli/core/edge.py +94 -0
- faden_cli/core/env_gen.py +598 -0
- faden_cli/core/git.py +219 -0
- faden_cli/core/install.py +288 -0
- faden_cli/core/installation.py +186 -0
- faden_cli/core/local_node.py +719 -0
- faden_cli/core/maintenance.py +82 -0
- faden_cli/core/private_files.py +133 -0
- faden_cli/core/registry.py +574 -0
- faden_cli/core/release_feed.py +342 -0
- faden_cli/core/rmq.py +135 -0
- faden_cli/core/runtime_images.py +309 -0
- faden_cli/core/schedule.py +278 -0
- faden_cli/core/settings.py +159 -0
- faden_cli/core/sysinfo.py +303 -0
- faden_cli/core/tls.py +790 -0
- faden_cli/core/tune.py +154 -0
- faden_cli/core/update_check.py +254 -0
- faden_cli/core/workspace.py +155 -0
- faden_cli/templates/env.j2 +33 -0
- faden_cli/tui/__init__.py +1 -0
- faden_cli/tui/widgets/__init__.py +1 -0
- faden_cli/tui/wizard/__init__.py +1 -0
- fadenstack-0.4.2.dist-info/METADATA +304 -0
- fadenstack-0.4.2.dist-info/RECORD +99 -0
- fadenstack-0.4.2.dist-info/WHEEL +4 -0
- fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
- fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
- fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``faden ports`` — the host ports the console, the API and /v1 are served on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from faden_cli.core.console import console, error, success, warning
|
|
11
|
+
from faden_cli.core.edge import HTTP_PORT_KEY, HTTPS_PORT_KEY, check_ports, ports
|
|
12
|
+
from faden_cli.core.env_gen import read_env_file, write_env_file
|
|
13
|
+
from faden_cli.core.settings import load_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def port_conflicts(http: int, https: int, *, current: tuple[int, int]) -> list[str]:
|
|
17
|
+
"""Which of the wanted ports something other than this install holds."""
|
|
18
|
+
from faden_cli.core.detect import _ports_held_by_faden, check_port # noqa: PLC0415
|
|
19
|
+
|
|
20
|
+
ours = _ports_held_by_faden()
|
|
21
|
+
problems = []
|
|
22
|
+
for label, port in (("HTTP", http), ("HTTPS", https)):
|
|
23
|
+
if port in current or port in ours:
|
|
24
|
+
continue
|
|
25
|
+
if check_port(port).in_use:
|
|
26
|
+
problems.append(f"{label} port {port} is in use by something else.")
|
|
27
|
+
return problems
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_ports(env_path: Path, *, http: int | None, https: int | None) -> tuple[int, int] | None:
|
|
31
|
+
"""Write the wanted ports into *env_path*; the new pair, or ``None`` when nothing changed."""
|
|
32
|
+
env = read_env_file(env_path)
|
|
33
|
+
current = ports(env)
|
|
34
|
+
wanted = (http if http is not None else current[0], https if https is not None else current[1])
|
|
35
|
+
if wanted == current:
|
|
36
|
+
return None
|
|
37
|
+
problem = check_ports(*wanted)
|
|
38
|
+
if problem:
|
|
39
|
+
raise click.UsageError(problem)
|
|
40
|
+
env[HTTP_PORT_KEY] = str(wanted[0])
|
|
41
|
+
env[HTTPS_PORT_KEY] = str(wanted[1])
|
|
42
|
+
write_env_file(env_path, env)
|
|
43
|
+
return wanted
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.command("ports")
|
|
47
|
+
@click.option("--http", "http", type=int, default=None, help="Host port for plain HTTP (default 80).")
|
|
48
|
+
@click.option("--https", "https", type=int, default=None, help="Host port for HTTPS (default 443).")
|
|
49
|
+
@click.option(
|
|
50
|
+
"--no-restart", is_flag=True, help="Write .env only; the proxy moves when it is next recreated."
|
|
51
|
+
)
|
|
52
|
+
def ports_cmd(*, http: int | None, https: int | None, no_restart: bool) -> None:
|
|
53
|
+
"""Show or change the ports the console, the API and /v1 are served on.
|
|
54
|
+
|
|
55
|
+
\b
|
|
56
|
+
faden ports show them
|
|
57
|
+
faden ports --http 8080 --https 8443
|
|
58
|
+
"""
|
|
59
|
+
from faden_cli.commands.tls import addresses, apply_to_proxy # noqa: PLC0415
|
|
60
|
+
|
|
61
|
+
cfg = load_config()
|
|
62
|
+
if not cfg.install_dir:
|
|
63
|
+
error("No install found. Run faden deploy first.")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
install_dir = Path(cfg.install_dir)
|
|
66
|
+
env_path = install_dir / ".env"
|
|
67
|
+
current = ports(read_env_file(env_path))
|
|
68
|
+
|
|
69
|
+
if http is None and https is None:
|
|
70
|
+
console.print(f"HTTP on port [bold]{current[0]}[/bold], HTTPS on port [bold]{current[1]}[/bold].")
|
|
71
|
+
console.print(" " + " ".join(addresses(install_dir)))
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
wanted = (http if http is not None else current[0], https if https is not None else current[1])
|
|
75
|
+
problem = check_ports(*wanted)
|
|
76
|
+
if problem:
|
|
77
|
+
error(problem)
|
|
78
|
+
sys.exit(2)
|
|
79
|
+
for conflict in port_conflicts(*wanted, current=current):
|
|
80
|
+
warning(conflict + " The proxy cannot start on it until it is free.")
|
|
81
|
+
|
|
82
|
+
if set_ports(env_path, http=http, https=https) is None:
|
|
83
|
+
console.print("Nothing to change.")
|
|
84
|
+
return
|
|
85
|
+
success(f"HTTP on port {wanted[0]}, HTTPS on port {wanted[1]} (written to {env_path}).")
|
|
86
|
+
if not no_restart and not apply_to_proxy(install_dir, recreate=True):
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
from faden_cli.commands.tls import refresh_edge_info # noqa: PLC0415
|
|
89
|
+
|
|
90
|
+
refresh_edge_info(install_dir)
|
|
91
|
+
console.print(" " + " ".join(addresses(install_dir)))
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""``faden rebuild`` — rebuild and redeploy individual services.
|
|
2
|
+
|
|
3
|
+
Rebuild one or more services without touching the rest of the stack.
|
|
4
|
+
Useful for developers who changed frontend code, backend code, etc.
|
|
5
|
+
and want a quick turnaround without a full ``faden deploy``.
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
faden rebuild frontend # rebuild + restart frontend only
|
|
9
|
+
faden rebuild backend api # rebuild backend and api
|
|
10
|
+
faden rebuild --all # rebuild all app services
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from faden_cli.core.compose import (
|
|
20
|
+
ComposeContext,
|
|
21
|
+
build as compose_build,
|
|
22
|
+
build_context_from_config,
|
|
23
|
+
up as compose_up,
|
|
24
|
+
)
|
|
25
|
+
from faden_cli.core.console import console, error, info, success, warning
|
|
26
|
+
from faden_cli.core.settings import load_config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Map friendly names → compose service names.
|
|
30
|
+
# Keeps the CLI developer-friendly (``faden rebuild frontend``)
|
|
31
|
+
# while resolving to the actual compose service name.
|
|
32
|
+
SERVICE_ALIASES: dict[str, list[str]] = {
|
|
33
|
+
"frontend": ["faden-frontend"],
|
|
34
|
+
"backend": ["faden-backend-migrator", "faden-backend", "faden-backend-worker"],
|
|
35
|
+
"gateway": ["faden-gateway-migrator", "faden-gateway"],
|
|
36
|
+
"mcp": ["faden-mcp"],
|
|
37
|
+
"skills": ["faden-skills"],
|
|
38
|
+
"pii": ["faden-pii", "faden-pii-worker"],
|
|
39
|
+
"rag": ["faden-rag"],
|
|
40
|
+
"auth": ["faden-auth"],
|
|
41
|
+
"mailer": ["faden-mailer"],
|
|
42
|
+
"docling": ["faden-docling"],
|
|
43
|
+
"nginx": ["faden-nginx"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Services that don't have a build context (they reuse another service's image).
|
|
47
|
+
# compose_build() skips these; they just need their container recreated.
|
|
48
|
+
_NO_BUILD_SERVICES: frozenset[str] = frozenset({
|
|
49
|
+
"faden-backend-migrator",
|
|
50
|
+
"faden-gateway-migrator",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_services(names: tuple[str, ...]) -> list[str]:
|
|
55
|
+
"""Resolve friendly names to compose service names."""
|
|
56
|
+
resolved: list[str] = []
|
|
57
|
+
for name in names:
|
|
58
|
+
name = name.lower().strip()
|
|
59
|
+
if name in SERVICE_ALIASES:
|
|
60
|
+
resolved.extend(SERVICE_ALIASES[name])
|
|
61
|
+
elif name.startswith("faden-"):
|
|
62
|
+
resolved.append(name)
|
|
63
|
+
else:
|
|
64
|
+
# Try with prefix
|
|
65
|
+
prefixed = f"faden-{name}"
|
|
66
|
+
resolved.append(prefixed)
|
|
67
|
+
return list(dict.fromkeys(resolved)) # dedupe, preserve order
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@click.command("rebuild")
|
|
71
|
+
@click.argument("services", nargs=-1)
|
|
72
|
+
@click.option("--all", "rebuild_all", is_flag=True, help="Rebuild all application services.")
|
|
73
|
+
@click.option("--no-cache", is_flag=True, help="Build without Docker cache.")
|
|
74
|
+
@click.option("--no-deps", is_flag=True, default=True, help="Don't restart dependent services (default: true).")
|
|
75
|
+
@click.option("--with-deps", is_flag=True, help="Also restart dependent services.")
|
|
76
|
+
def rebuild_cmd(
|
|
77
|
+
services: tuple[str, ...],
|
|
78
|
+
*,
|
|
79
|
+
rebuild_all: bool,
|
|
80
|
+
no_cache: bool,
|
|
81
|
+
no_deps: bool,
|
|
82
|
+
with_deps: bool,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Rebuild and redeploy specific services.
|
|
85
|
+
|
|
86
|
+
Pass one or more service names (e.g. frontend, backend, api, mcp).
|
|
87
|
+
The service image is rebuilt from source and the container is
|
|
88
|
+
recreated with the latest .env values — no full redeploy needed.
|
|
89
|
+
|
|
90
|
+
\b
|
|
91
|
+
Examples:
|
|
92
|
+
faden rebuild frontend # just the React UI
|
|
93
|
+
faden rebuild backend # backend + worker
|
|
94
|
+
faden rebuild backend api # multiple services
|
|
95
|
+
faden rebuild --all # all app images
|
|
96
|
+
faden rebuild frontend --no-cache # clean build
|
|
97
|
+
"""
|
|
98
|
+
if not services and not rebuild_all:
|
|
99
|
+
# Show available aliases
|
|
100
|
+
console.print("[bold cyan]Available services:[/bold cyan]")
|
|
101
|
+
for alias, targets in sorted(SERVICE_ALIASES.items()):
|
|
102
|
+
console.print(f" [bold]{alias:12s}[/bold] → {', '.join(targets)}")
|
|
103
|
+
console.print("\n[dim]Usage: faden rebuild <service> [service...][/dim]")
|
|
104
|
+
console.print("[dim] faden rebuild --all[/dim]")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
cfg = load_config()
|
|
108
|
+
ctx = build_context_from_config(cfg)
|
|
109
|
+
|
|
110
|
+
if rebuild_all:
|
|
111
|
+
# Build all services that have a build context
|
|
112
|
+
target_services = None # compose build with no args = build all
|
|
113
|
+
info("Rebuilding all application images…")
|
|
114
|
+
else:
|
|
115
|
+
target_services = _resolve_services(services)
|
|
116
|
+
info(f"Rebuilding: {', '.join(target_services)}")
|
|
117
|
+
|
|
118
|
+
# Step 1: Build (skip migrators — they share the parent service image)
|
|
119
|
+
build_services = (
|
|
120
|
+
[s for s in target_services if s not in _NO_BUILD_SERVICES]
|
|
121
|
+
if target_services
|
|
122
|
+
else None
|
|
123
|
+
)
|
|
124
|
+
console.print("\n[bold cyan]Building images…[/bold cyan]")
|
|
125
|
+
rc = compose_build(ctx, services=build_services or None, no_cache=no_cache)
|
|
126
|
+
if rc != 0:
|
|
127
|
+
error(f"Build failed (exit code {rc}).")
|
|
128
|
+
sys.exit(rc)
|
|
129
|
+
success("Images built.")
|
|
130
|
+
|
|
131
|
+
# Step 1b: Sync Postgres password if .env credentials differ from
|
|
132
|
+
# what the running Postgres volume was initialised with (e.g. after
|
|
133
|
+
# deploy-remote.sh updated the .env or a manual edit).
|
|
134
|
+
env_path = ctx.env_file
|
|
135
|
+
if env_path and env_path.exists():
|
|
136
|
+
from faden_cli.commands.deploy import _sync_postgres_password # noqa: PLC0415
|
|
137
|
+
|
|
138
|
+
_sync_postgres_password(ctx, env_path)
|
|
139
|
+
|
|
140
|
+
# Step 2: Recreate containers (picks up new image + new .env)
|
|
141
|
+
console.print("\n[bold cyan]Recreating containers…[/bold cyan]")
|
|
142
|
+
|
|
143
|
+
cmd = ctx.base_cmd() + ["up", "-d", "--force-recreate"]
|
|
144
|
+
if no_deps and not with_deps:
|
|
145
|
+
cmd.append("--no-deps")
|
|
146
|
+
if target_services:
|
|
147
|
+
cmd.extend(target_services)
|
|
148
|
+
|
|
149
|
+
import subprocess
|
|
150
|
+
result = subprocess.run(cmd, check=False) # noqa: S603
|
|
151
|
+
if result.returncode != 0:
|
|
152
|
+
error(f"Container recreation failed (exit code {result.returncode}).")
|
|
153
|
+
sys.exit(result.returncode)
|
|
154
|
+
|
|
155
|
+
success("Services rebuilt and redeployed.")
|
|
156
|
+
if target_services:
|
|
157
|
+
console.print(f" [dim]Rebuilt: {', '.join(target_services)}[/dim]")
|
|
158
|
+
console.print(" [dim]Tip: use 'faden logs <service>' to follow logs.[/dim]")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""``faden restore`` — restore from a backup directory.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
faden restore backups/20250115T120000Z
|
|
5
|
+
faden restore backups/20250115T120000Z --verify-only
|
|
6
|
+
faden restore backups/20250115T120000Z --db-only
|
|
7
|
+
faden restore backups/20250115T120000Z --skip-env
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from faden_cli.core.backup import (
|
|
18
|
+
read_manifest,
|
|
19
|
+
restore_backup,
|
|
20
|
+
verify_backup,
|
|
21
|
+
)
|
|
22
|
+
from faden_cli.core.compose import (
|
|
23
|
+
build_context_from_config,
|
|
24
|
+
down as compose_down,
|
|
25
|
+
up as compose_up,
|
|
26
|
+
)
|
|
27
|
+
from faden_cli.core.console import console, error, info, success, warning
|
|
28
|
+
from faden_cli.core.settings import load_config
|
|
29
|
+
from faden_cli.commands.deploy import _sync_postgres_password
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command("restore")
|
|
33
|
+
@click.argument("backup_dir", type=click.Path(exists=True, file_okay=False))
|
|
34
|
+
@click.option("--db-only", is_flag=True, default=False, help="Restore databases only.")
|
|
35
|
+
@click.option("--skip-env", is_flag=True, default=False, help="Don't overwrite the current .env.")
|
|
36
|
+
@click.option(
|
|
37
|
+
"--verify-only",
|
|
38
|
+
is_flag=True,
|
|
39
|
+
default=False,
|
|
40
|
+
help="Validate checksums without restoring.",
|
|
41
|
+
)
|
|
42
|
+
@click.option("-y", "--yes", is_flag=True, default=False, help="Skip confirmation prompt.")
|
|
43
|
+
def restore_cmd(
|
|
44
|
+
backup_dir: str,
|
|
45
|
+
*,
|
|
46
|
+
db_only: bool,
|
|
47
|
+
skip_env: bool,
|
|
48
|
+
verify_only: bool,
|
|
49
|
+
yes: bool,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Restore Fadenstack from a backup directory.
|
|
52
|
+
|
|
53
|
+
BACKUP_DIR is the path to a timestamped backup folder containing
|
|
54
|
+
a manifest.json file created by ``faden backup``.
|
|
55
|
+
"""
|
|
56
|
+
console.print("\n[bold magenta]fadenstack — Restore[/bold magenta]\n")
|
|
57
|
+
|
|
58
|
+
backup_path = Path(backup_dir).resolve()
|
|
59
|
+
manifest = read_manifest(backup_path)
|
|
60
|
+
if manifest is None:
|
|
61
|
+
error(f"No manifest.json found in {backup_path}")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
# ── Show backup info ──────────────────────────────────────
|
|
65
|
+
console.print(f" Backup date: {manifest.get('created_at', 'unknown')}")
|
|
66
|
+
console.print(f" faden version: {manifest.get('faden_version', 'unknown')}")
|
|
67
|
+
db_names = list(manifest.get("databases", {}).keys())
|
|
68
|
+
console.print(f" Databases: {', '.join(db_names)}")
|
|
69
|
+
if manifest.get("env_snapshot"):
|
|
70
|
+
console.print(f" .env snapshot: {manifest['env_snapshot']}")
|
|
71
|
+
if manifest.get("volumes"):
|
|
72
|
+
console.print(f" Volumes: {', '.join(manifest['volumes'])}")
|
|
73
|
+
console.print()
|
|
74
|
+
|
|
75
|
+
# ── Verify checksums ──────────────────────────────────────
|
|
76
|
+
info("Verifying backup checksums…")
|
|
77
|
+
check_errors = verify_backup(backup_path, manifest)
|
|
78
|
+
if check_errors:
|
|
79
|
+
for msg in check_errors:
|
|
80
|
+
error(f" {msg}")
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
success("Checksums OK.")
|
|
83
|
+
|
|
84
|
+
if verify_only:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# ── Confirm ───────────────────────────────────────────────
|
|
88
|
+
if not yes:
|
|
89
|
+
console.print()
|
|
90
|
+
warning("This will overwrite current databases with the backup data.")
|
|
91
|
+
click.confirm("Proceed with restore?", default=False, abort=True)
|
|
92
|
+
|
|
93
|
+
cfg = load_config()
|
|
94
|
+
ctx = build_context_from_config(cfg)
|
|
95
|
+
|
|
96
|
+
# ── Stop application services (keep postgres) ─────────────
|
|
97
|
+
console.print("\n[bold cyan]Stopping application services…[/bold cyan]")
|
|
98
|
+
compose_down(ctx, remove_orphans=False)
|
|
99
|
+
# Bring postgres back up for the restore
|
|
100
|
+
compose_up(ctx, services=["postgres"], detach=True, wait=True, timeout=60)
|
|
101
|
+
|
|
102
|
+
# ── Restore ───────────────────────────────────────────────
|
|
103
|
+
result = restore_backup(
|
|
104
|
+
ctx,
|
|
105
|
+
backup_dir=backup_path,
|
|
106
|
+
db_only=db_only,
|
|
107
|
+
skip_env=skip_env,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not result.ok:
|
|
111
|
+
for msg in result.errors:
|
|
112
|
+
error(msg)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
# ── Sync Postgres password with restored .env ─────────────
|
|
116
|
+
if result.env_restored:
|
|
117
|
+
env_path = ctx.env_file or Path(ctx.project_dir) / ".env"
|
|
118
|
+
# A .env backed up before a release required it may lack a key the compose file now requires.
|
|
119
|
+
from faden_cli.core.env_gen import fill_required_env # noqa: PLC0415
|
|
120
|
+
|
|
121
|
+
for note in fill_required_env(env_path, profiles=list(cfg.profiles)):
|
|
122
|
+
warning(note)
|
|
123
|
+
info("Syncing Postgres password with restored .env…")
|
|
124
|
+
_sync_postgres_password(ctx, env_path)
|
|
125
|
+
|
|
126
|
+
# ── Restart all services ──────────────────────────────────
|
|
127
|
+
console.print("\n[bold cyan]Restarting all services…[/bold cyan]")
|
|
128
|
+
compose_up(ctx, detach=True, wait=True, timeout=120)
|
|
129
|
+
|
|
130
|
+
console.print()
|
|
131
|
+
success("Restore complete.")
|
|
132
|
+
if result.databases_restored:
|
|
133
|
+
console.print(f" Databases: {', '.join(result.databases_restored)}")
|
|
134
|
+
if result.env_restored:
|
|
135
|
+
console.print(" .env: restored")
|
|
136
|
+
if result.volumes_restored:
|
|
137
|
+
console.print(f" Volumes: {', '.join(result.volumes_restored)}")
|
|
138
|
+
if result.tls_restored:
|
|
139
|
+
console.print(f" Certificate material: {len(result.tls_restored)} file(s); the machines still trust it")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""``faden runtime-images``: the runtime images this server holds for its machines.
|
|
2
|
+
|
|
3
|
+
The same step runs at the end of ``deploy`` and ``upgrade``; this command runs
|
|
4
|
+
it on its own -- to retry a pull that failed, to fetch an architecture chosen
|
|
5
|
+
later, or (``--check``) to see what is here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from faden_cli.core import runtime_images as rt
|
|
16
|
+
from faden_cli.core.console import console, info, success, warning
|
|
17
|
+
from faden_cli.core.settings import FadenConfig, load_config, save_config
|
|
18
|
+
|
|
19
|
+
_HOW_TO_RETRY = "faden runtime-images"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def choose(cfg: FadenConfig, value: str | None) -> set[str] | None:
|
|
23
|
+
"""The architectures to fetch: *value* when given (and remembered), else the saved choice.
|
|
24
|
+
|
|
25
|
+
Returns None, after saying why, when *value* is not a valid choice.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
chosen = rt.parse_selection(value if value is not None else cfg.runtime_images)
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
warning(f"--runtime-images: {exc}")
|
|
31
|
+
return None
|
|
32
|
+
if value is not None and value != cfg.runtime_images:
|
|
33
|
+
cfg.runtime_images = value
|
|
34
|
+
save_config(cfg)
|
|
35
|
+
return chosen
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_step(cfg: FadenConfig, *, value: str | None = None) -> bool:
|
|
39
|
+
"""Fetch this release's runtime images onto the server and say what happened.
|
|
40
|
+
|
|
41
|
+
Never stops the caller: every service is already running without them,
|
|
42
|
+
and a machine can still be served by a cluster peer that has its image.
|
|
43
|
+
Returns whether everything wanted is here.
|
|
44
|
+
"""
|
|
45
|
+
chosen = choose(cfg, value)
|
|
46
|
+
if chosen is None:
|
|
47
|
+
return False
|
|
48
|
+
if not chosen:
|
|
49
|
+
console.print(" [dim]Skipping runtime images (runtime_images: none).[/dim]")
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
image = rt.backend_image(cfg.env_path)
|
|
53
|
+
info(f"Runtime images pinned by {image} ({', '.join(sorted(chosen))}):")
|
|
54
|
+
console.print(
|
|
55
|
+
" [dim]Machines download their runtime image from this server. The first pull is large"
|
|
56
|
+
" (about 10 GB per image); later upgrades fetch only what changed.[/dim]"
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
report = rt.fetch(image, architectures=chosen)
|
|
60
|
+
except RuntimeError as exc: # docker missing
|
|
61
|
+
warning(f"Could not fetch runtime images: {exc}")
|
|
62
|
+
return False
|
|
63
|
+
if report.error:
|
|
64
|
+
warning(f"Could not fetch runtime images: {report.error}")
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
for outcome in report.outcomes:
|
|
68
|
+
_say(outcome)
|
|
69
|
+
return _model_tls_images(chosen) and report.ok
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _model_tls_images(chosen: set[str]) -> bool:
|
|
73
|
+
"""The machines' model-TLS proxy (nginx), per architecture: small, and needed by air-gapped machines."""
|
|
74
|
+
info("Model-TLS proxy image for the machines (nginx):")
|
|
75
|
+
try:
|
|
76
|
+
outcomes = rt.fetch_model_tls(chosen)
|
|
77
|
+
except RuntimeError as exc:
|
|
78
|
+
warning(f"Could not fetch the model-TLS proxy image: {exc}")
|
|
79
|
+
return False
|
|
80
|
+
ok = True
|
|
81
|
+
for tag, arch, outcome in outcomes:
|
|
82
|
+
what = f"{tag} ({arch})"
|
|
83
|
+
if outcome == "present":
|
|
84
|
+
success(f"{what}: already on this server.")
|
|
85
|
+
elif outcome == "pulled":
|
|
86
|
+
success(f"{what}: pulled.")
|
|
87
|
+
elif outcome == "skipped":
|
|
88
|
+
console.print(f" [dim]{what}: skipped, not chosen (--runtime-images).[/dim]")
|
|
89
|
+
else:
|
|
90
|
+
ok = False
|
|
91
|
+
reason = "docker pull failed"
|
|
92
|
+
if outcome == "mismatch":
|
|
93
|
+
reason = "the registry's image is not the pinned build"
|
|
94
|
+
warning(
|
|
95
|
+
f"{what}: {reason}. Machines with internet access pull it themselves;"
|
|
96
|
+
f" retry with {_HOW_TO_RETRY}."
|
|
97
|
+
)
|
|
98
|
+
return ok
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _say(outcome: rt.Outcome) -> None:
|
|
102
|
+
"""One line (or two) per image, in the operator's terms."""
|
|
103
|
+
what = f"{outcome.image.ref} ({outcome.image.arch or 'any architecture'})"
|
|
104
|
+
if outcome.status == "present":
|
|
105
|
+
success(f"{what}: already on this server.")
|
|
106
|
+
elif outcome.status == "pulled":
|
|
107
|
+
success(f"{what}: pulled.")
|
|
108
|
+
elif outcome.status == "skipped":
|
|
109
|
+
console.print(f" [dim]{what}: skipped, not chosen (--runtime-images).[/dim]")
|
|
110
|
+
elif outcome.status == "not_published":
|
|
111
|
+
warning(
|
|
112
|
+
f"{what}: this release names a build that is on no registry.\n"
|
|
113
|
+
" Machines of this kind can get it from a cluster peer that has it;"
|
|
114
|
+
" or load it here with: docker load -i <file>"
|
|
115
|
+
)
|
|
116
|
+
elif outcome.status == "mismatch":
|
|
117
|
+
warning(f"{what}: {outcome.detail}; machines would refuse it.")
|
|
118
|
+
else:
|
|
119
|
+
warning(
|
|
120
|
+
f"{what}: pull failed. Retry with: {_HOW_TO_RETRY}\n"
|
|
121
|
+
" If the registry refused it: the package must be public, or run docker login ghcr.io first."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check(cfg: FadenConfig) -> bool:
|
|
126
|
+
image = rt.backend_image(cfg.env_path)
|
|
127
|
+
try:
|
|
128
|
+
pinned = rt.pinned(image)
|
|
129
|
+
except RuntimeError as exc:
|
|
130
|
+
warning(str(exc))
|
|
131
|
+
return False
|
|
132
|
+
table = Table(title=f"Runtime images pinned by {image}", header_style="bold cyan")
|
|
133
|
+
table.add_column("Architecture")
|
|
134
|
+
table.add_column("Image")
|
|
135
|
+
table.add_column("On this server")
|
|
136
|
+
everything = True
|
|
137
|
+
for runtime in pinned:
|
|
138
|
+
here = rt.local_layers(runtime.ref)
|
|
139
|
+
if here is None:
|
|
140
|
+
state = "[yellow]no[/yellow]"
|
|
141
|
+
everything = False
|
|
142
|
+
elif here == runtime.rootfs_layers:
|
|
143
|
+
state = "[green]yes[/green]"
|
|
144
|
+
else:
|
|
145
|
+
state = "[red]a different build[/red]"
|
|
146
|
+
everything = False
|
|
147
|
+
table.add_row(runtime.arch or "-", runtime.ref, state)
|
|
148
|
+
console.print(table)
|
|
149
|
+
return everything
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@click.command("runtime-images")
|
|
153
|
+
@click.option(
|
|
154
|
+
"--arch",
|
|
155
|
+
"arch",
|
|
156
|
+
default=None,
|
|
157
|
+
help="Which to fetch: all, none, or architectures such as x86_64,aarch64 (remembered for upgrades).",
|
|
158
|
+
)
|
|
159
|
+
@click.option("--check", is_flag=True, default=False, help="Only show what this server holds; fetch nothing.")
|
|
160
|
+
def runtime_images_cmd(*, arch: str | None, check: bool) -> None:
|
|
161
|
+
r"""Fetch the runtime images machines download from this server.
|
|
162
|
+
|
|
163
|
+
\b
|
|
164
|
+
Examples:
|
|
165
|
+
faden runtime-images # fetch what this release pins
|
|
166
|
+
faden runtime-images --arch x86_64 # only for x86_64 machines, from now on
|
|
167
|
+
faden runtime-images --check # what is here, what is missing
|
|
168
|
+
"""
|
|
169
|
+
cfg = load_config()
|
|
170
|
+
if not cfg.install_dir:
|
|
171
|
+
warning("No install directory configured. Run 'faden deploy' first.")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
ok = _check(cfg) if check else run_step(cfg, value=arch)
|
|
174
|
+
sys.exit(0 if ok else 1)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``faden status`` — show running service status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from faden_cli.core.compose import build_context_from_config, ps as compose_ps
|
|
9
|
+
from faden_cli.core.console import console
|
|
10
|
+
from faden_cli.core.settings import load_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _health_style(health: str) -> str:
|
|
14
|
+
mapping = {
|
|
15
|
+
"healthy": "[green]healthy[/green]",
|
|
16
|
+
"unhealthy": "[red]unhealthy[/red]",
|
|
17
|
+
"starting": "[yellow]starting[/yellow]",
|
|
18
|
+
}
|
|
19
|
+
return mapping.get(health.lower(), health) if health else "—"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _state_style(state: str) -> str:
|
|
23
|
+
mapping = {
|
|
24
|
+
"running": "[green]running[/green]",
|
|
25
|
+
"exited": "[red]exited[/red]",
|
|
26
|
+
"restarting": "[yellow]restarting[/yellow]",
|
|
27
|
+
"created": "[dim]created[/dim]",
|
|
28
|
+
"dead": "[red]dead[/red]",
|
|
29
|
+
}
|
|
30
|
+
return mapping.get(state.lower(), state) if state else "—"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.command("status")
|
|
34
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON from docker compose.")
|
|
35
|
+
def status_cmd(*, as_json: bool) -> None:
|
|
36
|
+
"""Show the status of Fadenstack services."""
|
|
37
|
+
cfg = load_config()
|
|
38
|
+
ctx = build_context_from_config(cfg)
|
|
39
|
+
|
|
40
|
+
services = compose_ps(ctx)
|
|
41
|
+
|
|
42
|
+
if as_json:
|
|
43
|
+
import json
|
|
44
|
+
|
|
45
|
+
click.echo(json.dumps([s.__dict__ for s in services], indent=2))
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if not services:
|
|
49
|
+
console.print("[dim]No running services found.[/dim]")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
table = Table(
|
|
53
|
+
title="Fadenstack Services",
|
|
54
|
+
show_header=True,
|
|
55
|
+
header_style="bold cyan",
|
|
56
|
+
)
|
|
57
|
+
table.add_column("Service", style="bold")
|
|
58
|
+
table.add_column("State")
|
|
59
|
+
table.add_column("Health")
|
|
60
|
+
table.add_column("Ports")
|
|
61
|
+
|
|
62
|
+
for svc in sorted(services, key=lambda s: s.service):
|
|
63
|
+
table.add_row(
|
|
64
|
+
svc.service,
|
|
65
|
+
_state_style(svc.state),
|
|
66
|
+
_health_style(svc.health),
|
|
67
|
+
svc.ports or "—",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
console.print(table)
|
|
71
|
+
console.print(f"\n[dim]{len(services)} service(s)[/dim]")
|