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
faden_cli/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""CLI entry point — Click root group and subcommand registration.
|
|
2
|
+
|
|
3
|
+
This module defines the ``faden`` command group and wires up all
|
|
4
|
+
subcommands. Run ``faden --help`` for the full command tree.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from faden_cli import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AliasedGroup(click.Group):
|
|
17
|
+
"""Click group that supports command abbreviations.
|
|
18
|
+
|
|
19
|
+
For instance ``faden st`` resolves to ``faden status`` if no
|
|
20
|
+
other command starts with ``st``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
24
|
+
rv = click.Group.get_command(self, ctx, cmd_name)
|
|
25
|
+
if rv is not None:
|
|
26
|
+
return rv
|
|
27
|
+
matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
|
|
28
|
+
if not matches:
|
|
29
|
+
return None
|
|
30
|
+
if len(matches) == 1:
|
|
31
|
+
return click.Group.get_command(self, ctx, matches[0])
|
|
32
|
+
ctx.fail(f"Ambiguous command '{cmd_name}'. Did you mean: {', '.join(sorted(matches))}?")
|
|
33
|
+
return None # unreachable, but satisfies type checker
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.group(cls=AliasedGroup, invoke_without_command=True)
|
|
37
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.")
|
|
38
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress informational output.")
|
|
39
|
+
@click.version_option(version=__version__, prog_name="faden")
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def cli(ctx: click.Context, *, verbose: bool, quiet: bool) -> None:
|
|
42
|
+
"""faden — CLI installer and management tool for fadenstack."""
|
|
43
|
+
from faden_cli.core.detect import ensure_tool_path
|
|
44
|
+
|
|
45
|
+
# Make tools installed to ~/.local/bin / ~/.cargo/bin visible to
|
|
46
|
+
# every child subprocess, even when the current shell PATH lacks them
|
|
47
|
+
# (typical for fresh SSH sessions and GUI-launched terminals).
|
|
48
|
+
ensure_tool_path()
|
|
49
|
+
|
|
50
|
+
from faden_cli.core.docker_env import ensure_docker_host
|
|
51
|
+
|
|
52
|
+
# If the configured Docker endpoint is dead but Rancher Desktop's
|
|
53
|
+
# answers, export DOCKER_HOST so every docker subprocess (detect,
|
|
54
|
+
# compose, logs, …) reaches the live daemon. No-op otherwise.
|
|
55
|
+
ensure_docker_host(verbose=verbose, quiet=quiet)
|
|
56
|
+
|
|
57
|
+
ctx.ensure_object(dict)
|
|
58
|
+
ctx.obj["verbose"] = verbose
|
|
59
|
+
ctx.obj["quiet"] = quiet
|
|
60
|
+
|
|
61
|
+
from faden_cli.core import update_check
|
|
62
|
+
|
|
63
|
+
# A newer release: checked while the command runs, said at its end.
|
|
64
|
+
update_check.start(ctx, quiet=quiet)
|
|
65
|
+
|
|
66
|
+
if ctx.invoked_subcommand is None:
|
|
67
|
+
click.echo(ctx.get_help())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Register subcommands ──────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def register_core_commands(group: click.Group | None = None) -> None:
|
|
73
|
+
"""Import and register all core subcommand groups and commands.
|
|
74
|
+
|
|
75
|
+
Accepts an optional *group* parameter so the EE CLI can call this
|
|
76
|
+
with its own root group. Defaults to ``cli`` when called internally.
|
|
77
|
+
"""
|
|
78
|
+
target = group or cli
|
|
79
|
+
|
|
80
|
+
from faden_cli.commands.version import version_cmd
|
|
81
|
+
from faden_cli.commands.doctor import doctor_cmd
|
|
82
|
+
from faden_cli.commands.status import status_cmd
|
|
83
|
+
from faden_cli.commands.up import up_cmd
|
|
84
|
+
from faden_cli.commands.down import down_cmd
|
|
85
|
+
from faden_cli.commands.logs_cmd import logs_cmd
|
|
86
|
+
from faden_cli.commands.config import config_group
|
|
87
|
+
from faden_cli.commands.module import module_group
|
|
88
|
+
from faden_cli.commands.tune import tune_cmd
|
|
89
|
+
from faden_cli.commands.deploy import deploy_cmd
|
|
90
|
+
from faden_cli.commands.rebuild import rebuild_cmd
|
|
91
|
+
from faden_cli.commands.backup import backup_cmd
|
|
92
|
+
from faden_cli.commands.restore import restore_cmd
|
|
93
|
+
from faden_cli.commands.upgrade import upgrade_cmd
|
|
94
|
+
from faden_cli.commands.dev.dev_group import dev_group
|
|
95
|
+
from faden_cli.commands.admin import admin_group
|
|
96
|
+
from faden_cli.commands.observe import observe_group
|
|
97
|
+
from faden_cli.commands.runtime_images import runtime_images_cmd
|
|
98
|
+
from faden_cli.commands.tls import tls_group
|
|
99
|
+
from faden_cli.commands.ports import ports_cmd
|
|
100
|
+
from faden_cli.commands.maintenance import maintenance_group
|
|
101
|
+
|
|
102
|
+
target.add_command(version_cmd, "version")
|
|
103
|
+
target.add_command(doctor_cmd, "doctor")
|
|
104
|
+
target.add_command(status_cmd, "status")
|
|
105
|
+
target.add_command(up_cmd, "up")
|
|
106
|
+
target.add_command(down_cmd, "down")
|
|
107
|
+
target.add_command(logs_cmd, "logs")
|
|
108
|
+
target.add_command(config_group, "config")
|
|
109
|
+
target.add_command(module_group, "module")
|
|
110
|
+
target.add_command(tune_cmd, "tune")
|
|
111
|
+
target.add_command(deploy_cmd, "deploy")
|
|
112
|
+
target.add_command(rebuild_cmd, "rebuild")
|
|
113
|
+
target.add_command(backup_cmd, "backup")
|
|
114
|
+
target.add_command(restore_cmd, "restore")
|
|
115
|
+
target.add_command(upgrade_cmd, "upgrade")
|
|
116
|
+
target.add_command(dev_group, "dev")
|
|
117
|
+
target.add_command(admin_group, "admin")
|
|
118
|
+
target.add_command(observe_group, "observe")
|
|
119
|
+
target.add_command(runtime_images_cmd, "runtime-images")
|
|
120
|
+
target.add_command(tls_group, "tls")
|
|
121
|
+
target.add_command(ports_cmd, "ports")
|
|
122
|
+
target.add_command(maintenance_group, "maintenance")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
register_core_commands()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _use_utf8_output() -> None:
|
|
129
|
+
"""Make the console accept the output this CLI actually produces.
|
|
130
|
+
|
|
131
|
+
Windows consoles default to cp1252, and this CLI writes arrows, box rules
|
|
132
|
+
and check marks -- some 17,000 non-ASCII characters across its sources.
|
|
133
|
+
Any one of them reaching a cp1252 stream raises UnicodeEncodeError, so
|
|
134
|
+
``faden dev up --help`` ended in a traceback instead of printing help,
|
|
135
|
+
and redirecting any command to a file did the same.
|
|
136
|
+
|
|
137
|
+
``errors="replace"`` rather than a hard switch: where a terminal genuinely
|
|
138
|
+
cannot represent a glyph, a degraded character is a far better outcome
|
|
139
|
+
than a stack trace standing in for the output.
|
|
140
|
+
"""
|
|
141
|
+
for stream in (sys.stdout, sys.stderr):
|
|
142
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
143
|
+
if reconfigure is None: # pytest capture, or a plain wrapper
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
147
|
+
except (OSError, ValueError): # pragma: no cover - already detached
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> None:
|
|
152
|
+
"""Package entry point."""
|
|
153
|
+
_use_utf8_output()
|
|
154
|
+
cli()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command implementations for the faden CLI."""
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""``faden admin`` — administrative commands for a running deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil
|
|
8
|
+
import string
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from faden_cli.core.console import console, error, info, success, warning
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _generate_password(length: int = 24) -> str:
|
|
18
|
+
"""Generate a random password (letters + digits + punctuation subset)."""
|
|
19
|
+
alphabet = string.ascii_letters + string.digits + "!@#$%&*-_=+"
|
|
20
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Minimal email validation — rejects obvious injection attempts.
|
|
24
|
+
_EMAIL_RE = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-.]+$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group("admin")
|
|
28
|
+
def admin_group() -> None:
|
|
29
|
+
"""Administrative commands for a running Fadenstack deployment."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@admin_group.command("reset-password")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--email",
|
|
35
|
+
default="admin@localhost",
|
|
36
|
+
show_default=True,
|
|
37
|
+
help="Email address of the user whose password will be reset.",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--password",
|
|
41
|
+
default=None,
|
|
42
|
+
help="New password. Auto-generated if omitted.",
|
|
43
|
+
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"-y", "--yes",
|
|
46
|
+
is_flag=True,
|
|
47
|
+
help="Skip confirmation prompt.",
|
|
48
|
+
)
|
|
49
|
+
def reset_password_cmd(*, email: str, password: str | None, yes: bool) -> None:
|
|
50
|
+
"""Reset a user's password directly via the database.
|
|
51
|
+
|
|
52
|
+
This bypasses the API and sets the password hash in PostgreSQL.
|
|
53
|
+
The backend container must be running (needed for password hashing).
|
|
54
|
+
"""
|
|
55
|
+
# Validate email early to prevent any injection
|
|
56
|
+
if not _EMAIL_RE.match(email):
|
|
57
|
+
error(f"Invalid email address: {email}")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
# The backend's rule for a new password (faden_backend/services/password_policy.py).
|
|
61
|
+
from faden_cli.core.bootstrap import MIN_PASSWORD_LENGTH # noqa: PLC0415
|
|
62
|
+
|
|
63
|
+
if password is not None and len(password) < MIN_PASSWORD_LENGTH:
|
|
64
|
+
error(f"The password needs at least {MIN_PASSWORD_LENGTH} characters (or leave out --password).")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
docker = shutil.which("docker")
|
|
68
|
+
if not docker:
|
|
69
|
+
error("docker not found on PATH")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
new_password = password or _generate_password()
|
|
73
|
+
|
|
74
|
+
if not yes:
|
|
75
|
+
click.confirm(
|
|
76
|
+
f"Reset password for '{email}'?",
|
|
77
|
+
abort=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# ── 1. Hash the password inside the backend container ─────
|
|
81
|
+
info("Hashing new password…")
|
|
82
|
+
hash_script = (
|
|
83
|
+
"from fastapi_users.password import PasswordHelper; "
|
|
84
|
+
"print(PasswordHelper().hash(input()))"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
hash_result = subprocess.run( # noqa: S603
|
|
88
|
+
[
|
|
89
|
+
docker, "exec", "-i", "faden-backend",
|
|
90
|
+
"python", "-c", hash_script,
|
|
91
|
+
],
|
|
92
|
+
input=new_password,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
check=False,
|
|
96
|
+
)
|
|
97
|
+
if hash_result.returncode != 0:
|
|
98
|
+
error("Failed to hash password — is the backend container running?")
|
|
99
|
+
if hash_result.stderr.strip():
|
|
100
|
+
error(f" {hash_result.stderr.strip()}")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
hashed = hash_result.stdout.strip()
|
|
104
|
+
if not hashed:
|
|
105
|
+
error("Password hashing returned empty result.")
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
# ── 2. Update the password in PostgreSQL ──────────────────
|
|
109
|
+
info("Updating password in database…")
|
|
110
|
+
|
|
111
|
+
# Both `hashed` (bcrypt output we generated) and `email` (validated
|
|
112
|
+
# above by regex) are safe for SQL string interpolation here.
|
|
113
|
+
sql = (
|
|
114
|
+
f"UPDATE \"user\" SET hashed_password = '{hashed}' "
|
|
115
|
+
f"WHERE email = '{email}';"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
pg_result = subprocess.run( # noqa: S603
|
|
119
|
+
[
|
|
120
|
+
docker, "exec", "faden-postgres",
|
|
121
|
+
"psql", "-U", "postgres", "-d", "faden_backend",
|
|
122
|
+
"-c", sql,
|
|
123
|
+
],
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
check=False,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if pg_result.returncode != 0:
|
|
130
|
+
error("Database update failed — is the postgres container running?")
|
|
131
|
+
if pg_result.stderr.strip():
|
|
132
|
+
error(f" {pg_result.stderr.strip()}")
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
# Check if a row was actually updated
|
|
136
|
+
if "UPDATE 0" in pg_result.stdout:
|
|
137
|
+
warning(f"No user found with email '{email}'.")
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
success(f"Password reset for '{email}'.")
|
|
141
|
+
|
|
142
|
+
# ── 3. Display credentials ────────────────────────────────
|
|
143
|
+
from rich.panel import Panel
|
|
144
|
+
from rich.text import Text
|
|
145
|
+
|
|
146
|
+
lines = Text()
|
|
147
|
+
lines.append(" Email: ", style="bold")
|
|
148
|
+
lines.append(email, style="cyan")
|
|
149
|
+
lines.append("\n Password: ", style="bold")
|
|
150
|
+
lines.append(new_password, style="cyan")
|
|
151
|
+
lines.append("\n\n Store this in a password manager.", style="dim")
|
|
152
|
+
|
|
153
|
+
console.print()
|
|
154
|
+
console.print(
|
|
155
|
+
Panel(
|
|
156
|
+
lines,
|
|
157
|
+
title="[bold yellow]New Credentials[/bold yellow]",
|
|
158
|
+
border_style="yellow",
|
|
159
|
+
padding=(1, 2),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""``faden backup`` — create a backup of all databases and config.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
faden backup # backup to ./backups
|
|
5
|
+
faden backup --output-dir /mnt/bak # custom output directory
|
|
6
|
+
faden backup --include-volumes # also snapshot Docker volumes
|
|
7
|
+
faden backup --db-only # databases only, skip .env
|
|
8
|
+
faden backup --retain 3 # keep only 3 most recent
|
|
9
|
+
faden backup schedule # every night at 03:00, keep 7
|
|
10
|
+
faden backup schedule --off # stop the nightly backup
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from faden_cli.core.backup import create_backup
|
|
21
|
+
from faden_cli.core.compose import build_context_from_config
|
|
22
|
+
from faden_cli.core.console import console, error, info, success, warning
|
|
23
|
+
from faden_cli.core.private_files import private_dir
|
|
24
|
+
from faden_cli.core.settings import load_config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group("backup", invoke_without_command=True)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--output-dir",
|
|
30
|
+
type=click.Path(file_okay=False),
|
|
31
|
+
default="backups",
|
|
32
|
+
show_default=True,
|
|
33
|
+
help="Directory to store backups in.",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--include-volumes",
|
|
37
|
+
is_flag=True,
|
|
38
|
+
default=False,
|
|
39
|
+
help="Also snapshot the install's Docker volumes: knowledge-base uploads, MinIO, logs, traces, Grafana"
|
|
40
|
+
" (not Postgres's data, whose dumps are the backup, nor the models).",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--retain",
|
|
44
|
+
type=int,
|
|
45
|
+
default=5,
|
|
46
|
+
show_default=True,
|
|
47
|
+
help="Keep only the N most recent backups.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--db-only",
|
|
51
|
+
is_flag=True,
|
|
52
|
+
default=False,
|
|
53
|
+
help="Dump databases only (skip .env and volumes).",
|
|
54
|
+
)
|
|
55
|
+
@click.option("-y", "--yes", is_flag=True, default=False, help="Skip confirmation prompt.")
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def backup_cmd(
|
|
58
|
+
click_ctx: click.Context,
|
|
59
|
+
*,
|
|
60
|
+
output_dir: str,
|
|
61
|
+
include_volumes: bool,
|
|
62
|
+
retain: int,
|
|
63
|
+
db_only: bool,
|
|
64
|
+
yes: bool,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Create a backup of Fadenstack databases, config, and optionally volumes."""
|
|
67
|
+
if click_ctx.invoked_subcommand is not None:
|
|
68
|
+
return
|
|
69
|
+
console.print("\n[bold magenta]fadenstack — Backup[/bold magenta]\n")
|
|
70
|
+
|
|
71
|
+
cfg = load_config()
|
|
72
|
+
ctx = build_context_from_config(cfg)
|
|
73
|
+
|
|
74
|
+
output_path = Path(output_dir)
|
|
75
|
+
if not output_path.is_absolute():
|
|
76
|
+
output_path = cfg.install_path / output_path
|
|
77
|
+
|
|
78
|
+
if not yes:
|
|
79
|
+
click.confirm(
|
|
80
|
+
f"Create backup in {output_path}?",
|
|
81
|
+
default=True,
|
|
82
|
+
abort=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
result = create_backup(
|
|
86
|
+
ctx,
|
|
87
|
+
output_dir=output_path,
|
|
88
|
+
include_volumes=include_volumes,
|
|
89
|
+
db_only=db_only,
|
|
90
|
+
retain=retain,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if not result.ok:
|
|
94
|
+
for msg in result.errors:
|
|
95
|
+
error(msg)
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
|
|
98
|
+
console.print()
|
|
99
|
+
success(f"Backup created: {result.backup_dir}")
|
|
100
|
+
console.print(f" Databases: {', '.join(result.databases)}")
|
|
101
|
+
if result.env_snapshot:
|
|
102
|
+
console.print(f" .env snapshot: {result.env_snapshot}")
|
|
103
|
+
if result.volumes:
|
|
104
|
+
console.print(f" Volumes: {', '.join(result.volumes)}")
|
|
105
|
+
if result.tls_material:
|
|
106
|
+
console.print(
|
|
107
|
+
f" Certificate material: {len(result.tls_material)} file(s), private keys among them;"
|
|
108
|
+
" keep the backup as private as .env"
|
|
109
|
+
)
|
|
110
|
+
if result.manifest_path:
|
|
111
|
+
console.print(f" Manifest: {result.manifest_path.name}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@backup_cmd.command("schedule")
|
|
115
|
+
@click.option("--at", "at", default="03:00", show_default=True, help="Time of day, HH:MM (server time).")
|
|
116
|
+
@click.option("--retain", type=int, default=7, show_default=True, help="Keep the N most recent backups.")
|
|
117
|
+
@click.option("--off", is_flag=True, default=False, help="Stop the nightly backup.")
|
|
118
|
+
def schedule_cmd(*, at: str, retain: int, off: bool) -> None:
|
|
119
|
+
"""Back up every night, keeping the most recent few.
|
|
120
|
+
|
|
121
|
+
The backups land in <install_dir>/backups/, on the same disk as the
|
|
122
|
+
data: copy them elsewhere too (or pass --output-dir to a mounted disk
|
|
123
|
+
with `faden backup` from your own scheduler).
|
|
124
|
+
"""
|
|
125
|
+
from faden_cli.core import schedule # noqa: PLC0415
|
|
126
|
+
|
|
127
|
+
if not schedule.available():
|
|
128
|
+
error("No systemd user manager or cron here: schedule `faden backup -y` with your own scheduler.")
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
if off:
|
|
131
|
+
if schedule.remove():
|
|
132
|
+
success("Nightly backup stopped.")
|
|
133
|
+
else:
|
|
134
|
+
info("No nightly backup was scheduled.")
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
hour, minute = (int(part) for part in at.split(":", 1))
|
|
138
|
+
if not (0 <= hour < 24 and 0 <= minute < 60): # noqa: PLR2004
|
|
139
|
+
raise ValueError
|
|
140
|
+
except ValueError:
|
|
141
|
+
error(f"--at wants HH:MM, got {at!r}.")
|
|
142
|
+
sys.exit(2)
|
|
143
|
+
if retain < 1:
|
|
144
|
+
error("--retain must keep at least one backup.")
|
|
145
|
+
sys.exit(2)
|
|
146
|
+
cfg = load_config()
|
|
147
|
+
if not cfg.install_dir:
|
|
148
|
+
error("No install to back up: run `faden deploy` first.")
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
backups = private_dir(cfg.install_path / "backups")
|
|
151
|
+
try:
|
|
152
|
+
how, warnings = schedule.install(hour=hour, minute=minute, retain=retain, log=backups / "backup.log")
|
|
153
|
+
except (RuntimeError, OSError) as exc:
|
|
154
|
+
error(f"Could not schedule the backup: {exc}")
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
success(f"Backing up every night at {hour:02d}:{minute:02d}, keeping {retain}: {backups} ({how})")
|
|
157
|
+
for message in warnings:
|
|
158
|
+
warning(message)
|
|
159
|
+
warning("The backups are on the same disk as the data; copy them elsewhere too.")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""``faden config`` — view and manage configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import yaml
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
|
|
12
|
+
from faden_cli.core.console import console, success, error
|
|
13
|
+
from faden_cli.core.settings import load_config, save_config, config_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group("config")
|
|
17
|
+
def config_group() -> None:
|
|
18
|
+
"""Manage faden configuration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@config_group.command("show")
|
|
22
|
+
def config_show() -> None:
|
|
23
|
+
"""Display the current configuration."""
|
|
24
|
+
cfg_path = config_path()
|
|
25
|
+
|
|
26
|
+
if not cfg_path.exists():
|
|
27
|
+
console.print(f"[dim]No config file found at {cfg_path}[/dim]")
|
|
28
|
+
console.print("[dim]Using default configuration.[/dim]")
|
|
29
|
+
cfg = load_config()
|
|
30
|
+
import dataclasses
|
|
31
|
+
|
|
32
|
+
raw = yaml.dump(dataclasses.asdict(cfg), default_flow_style=False, sort_keys=False)
|
|
33
|
+
console.print(Panel(Syntax(raw, "yaml", theme="monokai"), title="defaults"))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
raw = cfg_path.read_text(encoding="utf-8")
|
|
37
|
+
console.print(
|
|
38
|
+
Panel(
|
|
39
|
+
Syntax(raw, "yaml", theme="monokai"),
|
|
40
|
+
title=str(cfg_path),
|
|
41
|
+
border_style="cyan",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@config_group.command("set")
|
|
47
|
+
@click.argument("key")
|
|
48
|
+
@click.argument("value")
|
|
49
|
+
def config_set(key: str, value: str) -> None:
|
|
50
|
+
"""Set a configuration value (dot-notation supported).
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
|
|
54
|
+
faden config set install_dir /opt/faden
|
|
55
|
+
|
|
56
|
+
faden config set dev.branch main
|
|
57
|
+
"""
|
|
58
|
+
cfg = load_config()
|
|
59
|
+
import dataclasses
|
|
60
|
+
|
|
61
|
+
cfg_dict = dataclasses.asdict(cfg)
|
|
62
|
+
|
|
63
|
+
# Support dot-notation for nested keys (e.g., dev.branch)
|
|
64
|
+
parts = key.split(".")
|
|
65
|
+
target = cfg_dict
|
|
66
|
+
for part in parts[:-1]:
|
|
67
|
+
if part not in target or not isinstance(target[part], dict):
|
|
68
|
+
error(f"Unknown config section: {part}")
|
|
69
|
+
return
|
|
70
|
+
target = target[part]
|
|
71
|
+
|
|
72
|
+
final_key = parts[-1]
|
|
73
|
+
if final_key not in target:
|
|
74
|
+
error(f"Unknown config key: {key}")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Coerce value types
|
|
78
|
+
old_val = target[final_key]
|
|
79
|
+
if isinstance(old_val, bool):
|
|
80
|
+
value_typed: str | bool | int | float | list[str] = value.lower() in ("true", "1", "yes")
|
|
81
|
+
elif isinstance(old_val, int):
|
|
82
|
+
value_typed = int(value)
|
|
83
|
+
elif isinstance(old_val, float):
|
|
84
|
+
value_typed = float(value)
|
|
85
|
+
elif isinstance(old_val, list):
|
|
86
|
+
value_typed = [v.strip() for v in value.split(",")]
|
|
87
|
+
else:
|
|
88
|
+
value_typed = value
|
|
89
|
+
|
|
90
|
+
target[final_key] = value_typed
|
|
91
|
+
|
|
92
|
+
# Rebuild config
|
|
93
|
+
from faden_cli.core.settings import FadenConfig, DevConfig
|
|
94
|
+
|
|
95
|
+
dev_data = cfg_dict.pop("dev", {}) or {}
|
|
96
|
+
dev_cfg = DevConfig(**{k: v for k, v in dev_data.items() if k in DevConfig.__dataclass_fields__})
|
|
97
|
+
new_cfg = FadenConfig(**cfg_dict, dev=dev_cfg)
|
|
98
|
+
|
|
99
|
+
save_config(new_cfg)
|
|
100
|
+
success(f"Set {key} = {value_typed}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@config_group.command("path")
|
|
104
|
+
def config_path_cmd() -> None:
|
|
105
|
+
"""Print the config file path."""
|
|
106
|
+
click.echo(str(config_path()))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@config_group.command("init")
|
|
110
|
+
@click.option("--force", is_flag=True, help="Overwrite existing config file.")
|
|
111
|
+
def config_init(*, force: bool) -> None:
|
|
112
|
+
"""Create a default configuration file."""
|
|
113
|
+
cfg_file = config_path()
|
|
114
|
+
|
|
115
|
+
if cfg_file.exists() and not force:
|
|
116
|
+
error(f"Config file already exists at {cfg_file}. Use --force to overwrite.")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
cfg = load_config()
|
|
120
|
+
save_config(cfg)
|
|
121
|
+
success(f"Configuration written to {cfg_file}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@config_group.command("edit")
|
|
125
|
+
def config_edit() -> None:
|
|
126
|
+
"""Open the config file in $EDITOR."""
|
|
127
|
+
import os
|
|
128
|
+
import subprocess
|
|
129
|
+
|
|
130
|
+
cfg_file = config_path()
|
|
131
|
+
if not cfg_file.exists():
|
|
132
|
+
# Create default first
|
|
133
|
+
cfg = load_config()
|
|
134
|
+
save_config(cfg)
|
|
135
|
+
|
|
136
|
+
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
|
|
137
|
+
if not editor:
|
|
138
|
+
# Fallback based on platform
|
|
139
|
+
import platform
|
|
140
|
+
|
|
141
|
+
if platform.system() == "Windows":
|
|
142
|
+
editor = "notepad"
|
|
143
|
+
else:
|
|
144
|
+
editor = "nano"
|
|
145
|
+
|
|
146
|
+
subprocess.run([editor, str(cfg_file)], check=False)
|