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.
Files changed (99) hide show
  1. faden_cli/__init__.py +16 -0
  2. faden_cli/__main__.py +9 -0
  3. faden_cli/bundle/shared/.gitignore +25 -0
  4. faden_cli/bundle/shared/LICENSE +187 -0
  5. faden_cli/bundle/shared/NOTICE +15 -0
  6. faden_cli/bundle/shared/OFFICIAL.md +9 -0
  7. faden_cli/bundle/shared/README.md +16 -0
  8. faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
  9. faden_cli/bundle/shared/alloy/config.alloy +40 -0
  10. faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
  11. faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
  12. faden_cli/bundle/shared/docker-compose.yaml +998 -0
  13. faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
  14. faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
  15. faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
  16. faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
  17. faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
  18. faden_cli/bundle/shared/initdb/01-init.sql +32 -0
  19. faden_cli/bundle/shared/loki/local-config.yaml +32 -0
  20. faden_cli/bundle/shared/nginx/locations.conf +245 -0
  21. faden_cli/bundle/shared/nginx/maintenance.html +239 -0
  22. faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
  23. faden_cli/bundle/shared/nginx/nginx.conf +44 -0
  24. faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
  25. faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
  26. faden_cli/bundle/shared/nginx/select-config.sh +37 -0
  27. faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
  28. faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
  29. faden_cli/bundle/shared/tls/README.md +30 -0
  30. faden_cli/cli.py +158 -0
  31. faden_cli/commands/__init__.py +1 -0
  32. faden_cli/commands/admin.py +161 -0
  33. faden_cli/commands/backup.py +159 -0
  34. faden_cli/commands/config.py +146 -0
  35. faden_cli/commands/deploy.py +949 -0
  36. faden_cli/commands/dev/__init__.py +1 -0
  37. faden_cli/commands/dev/dev_doctor.py +46 -0
  38. faden_cli/commands/dev/dev_down.py +241 -0
  39. faden_cli/commands/dev/dev_group.py +24 -0
  40. faden_cli/commands/dev/dev_init.py +710 -0
  41. faden_cli/commands/dev/dev_pull.py +297 -0
  42. faden_cli/commands/dev/dev_status.py +151 -0
  43. faden_cli/commands/dev/dev_up.py +1392 -0
  44. faden_cli/commands/doctor.py +225 -0
  45. faden_cli/commands/down.py +75 -0
  46. faden_cli/commands/logs_cmd.py +41 -0
  47. faden_cli/commands/maintenance.py +74 -0
  48. faden_cli/commands/module.py +113 -0
  49. faden_cli/commands/observe.py +95 -0
  50. faden_cli/commands/ports.py +91 -0
  51. faden_cli/commands/rebuild.py +158 -0
  52. faden_cli/commands/restore.py +139 -0
  53. faden_cli/commands/runtime_images.py +174 -0
  54. faden_cli/commands/status.py +71 -0
  55. faden_cli/commands/tls.py +722 -0
  56. faden_cli/commands/tune.py +37 -0
  57. faden_cli/commands/up.py +64 -0
  58. faden_cli/commands/upgrade.py +470 -0
  59. faden_cli/commands/version.py +78 -0
  60. faden_cli/core/__init__.py +1 -0
  61. faden_cli/core/api_client.py +77 -0
  62. faden_cli/core/backup.py +717 -0
  63. faden_cli/core/bootstrap.py +291 -0
  64. faden_cli/core/bundle.py +110 -0
  65. faden_cli/core/clickhouse.py +61 -0
  66. faden_cli/core/compose.py +484 -0
  67. faden_cli/core/console.py +106 -0
  68. faden_cli/core/detect.py +580 -0
  69. faden_cli/core/dev_modules.py +149 -0
  70. faden_cli/core/docker_env.py +147 -0
  71. faden_cli/core/edge.py +94 -0
  72. faden_cli/core/env_gen.py +598 -0
  73. faden_cli/core/git.py +219 -0
  74. faden_cli/core/install.py +288 -0
  75. faden_cli/core/installation.py +186 -0
  76. faden_cli/core/local_node.py +719 -0
  77. faden_cli/core/maintenance.py +82 -0
  78. faden_cli/core/private_files.py +133 -0
  79. faden_cli/core/registry.py +574 -0
  80. faden_cli/core/release_feed.py +342 -0
  81. faden_cli/core/rmq.py +135 -0
  82. faden_cli/core/runtime_images.py +309 -0
  83. faden_cli/core/schedule.py +278 -0
  84. faden_cli/core/settings.py +159 -0
  85. faden_cli/core/sysinfo.py +303 -0
  86. faden_cli/core/tls.py +790 -0
  87. faden_cli/core/tune.py +154 -0
  88. faden_cli/core/update_check.py +254 -0
  89. faden_cli/core/workspace.py +155 -0
  90. faden_cli/templates/env.j2 +33 -0
  91. faden_cli/tui/__init__.py +1 -0
  92. faden_cli/tui/widgets/__init__.py +1 -0
  93. faden_cli/tui/wizard/__init__.py +1 -0
  94. fadenstack-0.4.2.dist-info/METADATA +304 -0
  95. fadenstack-0.4.2.dist-info/RECORD +99 -0
  96. fadenstack-0.4.2.dist-info/WHEEL +4 -0
  97. fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
  98. fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
  99. fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,149 @@
1
+ """The optional modules -- PII, MCP, skills -- in the dev environment.
2
+
3
+ ``faden dev up`` runs the backend and gateway on the host. The modules were
4
+ only ever wired for the container stack: each was addressed by its Docker
5
+ name (``http://faden-pii:8000``) and published no port, so a gateway on
6
+ the host could not reach one even when it was started by hand, and
7
+ ``dev up`` had no way to start one at all. The backend's own defaults already
8
+ expected them on the host (``http://127.0.0.1:8003/api`` for PII), on the
9
+ ports the CLI reserves for them.
10
+
11
+ So they run the way the backend does: from their service directory, bound to
12
+ 127.0.0.1 (only the gateway and backend call them), each on its reserved
13
+ port. This module works out what that takes -- which modules, their own
14
+ settings, and what the gateway and backend need to reach them -- as plain
15
+ dicts, so it can be tested without starting anything.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from faden_cli.core.registry import MODULES, ModuleInfo
21
+
22
+ #: Where the host-run modules listen and where the shared infra is reached.
23
+ #: Never "localhost": see ``registry.INFRA_LOOPBACK``.
24
+ _HOST = "127.0.0.1"
25
+
26
+
27
+ class UnknownModuleError(ValueError):
28
+ """A module name ``dev up`` does not know how to run."""
29
+
30
+
31
+ def dev_modules() -> list[ModuleInfo]:
32
+ """Every module dev mode can run, in registry order."""
33
+ return [m for m in MODULES.values() if m.dev_dir]
34
+
35
+
36
+ def select_modules(requested: str | None, profiles: list[str] | None) -> list[ModuleInfo]:
37
+ """The modules to run.
38
+
39
+ *requested* is ``--modules``: a comma-separated list, or ``none``. Without
40
+ it, the modules switched on with ``faden module enable`` (the config's
41
+ profiles) are the ones that run.
42
+ """
43
+ runnable = {m.name: m for m in dev_modules()}
44
+ if requested is None:
45
+ wanted = [m.name for m in runnable.values() if m.profile in set(profiles or [])]
46
+ else:
47
+ names = [n.strip().lower() for n in requested.split(",") if n.strip()]
48
+ if names in ([], ["none"]):
49
+ return []
50
+ unknown = [n for n in names if n not in runnable]
51
+ if unknown:
52
+ raise UnknownModuleError(
53
+ f"Unknown module(s): {', '.join(unknown)}. "
54
+ f"Available: {', '.join(runnable)}.",
55
+ )
56
+ wanted = names
57
+ return [m for m in runnable.values() if m.name in wanted]
58
+
59
+
60
+ def dev_url(module: ModuleInfo) -> str:
61
+ """The root URL a host-run module answers on."""
62
+ return f"http://{_HOST}:{module.port}"
63
+
64
+
65
+ def service_token(module: ModuleInfo, shared: dict[str, str]) -> str:
66
+ """The token the gateway and backend present to *module*.
67
+
68
+ One value for all three sides, or the calls are refused. The shared
69
+ ``.env`` wins, so a workspace that set its own keeps it.
70
+ """
71
+ return shared.get(f"FADEN_{module.name.upper()}_SERVICE_TOKEN") or module.dev_service_token
72
+
73
+
74
+ def module_env(
75
+ module: ModuleInfo,
76
+ *,
77
+ shared: dict[str, str],
78
+ running: list[ModuleInfo],
79
+ ) -> dict[str, str]:
80
+ """The settings *module* runs with, for its own ``.env``."""
81
+ p = f"FADEN_{module.name.upper()}_"
82
+ env = {
83
+ p + "HOST": _HOST,
84
+ p + "PORT": str(module.port),
85
+ p + "ENVIRONMENT": "dev",
86
+ # One worker: PII loads its spaCy model per worker (~1 GB each), and
87
+ # MCP keeps its stdio connections per worker.
88
+ p + "WORKERS_COUNT": "1",
89
+ # Not reloaded: in this environment they are what the backend and
90
+ # gateway call, not the code being edited, and a reload of PII reloads
91
+ # its model.
92
+ p + "RELOAD": "false",
93
+ }
94
+ if module.database:
95
+ env.update({
96
+ p + "DB_HOST": _HOST,
97
+ p + "DB_PORT": "5432",
98
+ p + "DB_USER": shared.get("POSTGRES_USER", "postgres"),
99
+ p + "DB_PASS": shared.get("POSTGRES_PASSWORD", "postgres"),
100
+ p + "DB_BASE": module.database,
101
+ })
102
+ if module.redis_base is not None:
103
+ env.update({
104
+ p + "REDIS_HOST": _HOST,
105
+ p + "REDIS_PORT": "6379",
106
+ p + "REDIS_BASE": str(module.redis_base),
107
+ })
108
+ redis_pass = shared.get("REDIS_AUTH") or shared.get("REDIS_PASSWORD")
109
+ if redis_pass:
110
+ env[p + "REDIS_PASS"] = redis_pass
111
+ if module.dev_service_token:
112
+ env[p + "SERVICE_TOKEN"] = service_token(module, shared)
113
+ for key, value in module.dev_settings:
114
+ env[p + key] = shared.get(p + key) or value
115
+ by_name = {m.name: m for m in running}
116
+ for key, other in module.dev_links:
117
+ # Empty when the other one is not running: a URL to nothing would
118
+ # make every call wait out a connect timeout instead of skipping.
119
+ env[p + key] = dev_url(by_name[other]) if other in by_name else ""
120
+ return env
121
+
122
+
123
+ def caller_env(
124
+ service: str,
125
+ *,
126
+ shared: dict[str, str],
127
+ running: list[ModuleInfo],
128
+ ) -> dict[str, str]:
129
+ """What the gateway (``GATEWAY``) or backend (``BACKEND``) needs to reach them.
130
+
131
+ Every module dev mode knows about gets its switch: on with its URL (and
132
+ token) when it runs, off when it does not. Left on while nothing listens,
133
+ the gateway sends each request to it anyway -- and under a PII policy
134
+ that blocks when the service is down, every chat then fails with 502.
135
+ """
136
+ on = {m.name for m in running}
137
+ env: dict[str, str] = {}
138
+ for module in dev_modules():
139
+ p = f"FADEN_{service}_{module.name.upper()}_"
140
+ env[p + "ENABLED"] = "true" if module.name in on else "false"
141
+ if module.name not in on:
142
+ continue
143
+ url = dev_url(module)
144
+ if service == "BACKEND":
145
+ url += module.backend_url_suffix
146
+ env[p + "SERVICE_URL"] = url
147
+ if module.dev_service_token:
148
+ env[p + "SERVICE_TOKEN"] = service_token(module, shared)
149
+ return env
@@ -0,0 +1,147 @@
1
+ """Docker endpoint selection — Docker Desktop / Rancher Desktop fallback.
2
+
3
+ On Windows the docker CLI talks to a container runtime over a named pipe.
4
+ Docker Desktop and Rancher Desktop install their own endpoints and often
5
+ point the default docker context (or ``DOCKER_HOST``) at the *other*
6
+ runtime's pipe. When that pipe is down — e.g. Docker Desktop is not
7
+ running but Rancher Desktop is — every docker call fails with::
8
+
9
+ failed to connect to the docker API at
10
+ npipe:////./pipe/dockerDesktopLinuxEngine: The system cannot find
11
+ the file specified.
12
+
13
+ This module centralises the resolution logic:
14
+
15
+ * :func:`rancher_endpoints` — known Rancher Desktop daemon endpoints per
16
+ platform. On Windows Rancher Desktop's ``wsl-helper`` proxy listens
17
+ on the classic ``//./pipe/docker_engine`` pipe (see
18
+ ``serve_windows.go`` in the rancher-desktop source:
19
+ ``DefaultEndpoint = "npipe:////./pipe/docker_engine"``).
20
+ * :func:`resolve_docker_host` — probe the current endpoint first, then
21
+ each Rancher Desktop fallback; return the first ``docker info`` that
22
+ answers, or ``None``.
23
+ * :func:`ensure_docker_host` — one-shot guard: if the current endpoint
24
+ is dead and a Rancher Desktop endpoint answers, export
25
+ ``DOCKER_HOST`` (cached in a module global) so *every* docker
26
+ subprocess the CLI spawns (detect, compose, logs, …) reaches the live
27
+ daemon. No side effects when the active endpoint already works.
28
+
29
+ Only ``os`` and ``subprocess`` are used — no Docker SDK, consistent with
30
+ the rest of the CLI.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import platform
37
+ import shutil
38
+ import subprocess
39
+ from pathlib import Path
40
+
41
+
42
+ def is_windows() -> bool:
43
+ """True when the daemon endpoints are Windows named pipes."""
44
+ return platform.system() == "Windows"
45
+
46
+
47
+ def rancher_endpoints() -> list[str]:
48
+ """Known rancher-desktop daemon endpoints for this platform.
49
+
50
+ * Windows — the WSL2/Hyper-V backend's host-side proxy pipe
51
+ (``rancher-desktop/run/docker.sock`` inside the VM).
52
+ * macOS / Linux — the socket the app publishes on the host,
53
+ optionally forwarded to the conventional ``/var/run/docker.sock``
54
+ location when ``application.adminAccess`` is enabled.
55
+ """
56
+ if is_windows():
57
+ return ["npipe:////./pipe/docker_engine"]
58
+ app_dir = Path.home() / "Library" / "Application Support" / "rancher-desktop"
59
+ return [
60
+ f"unix://{app_dir / 'docker.sock'}",
61
+ "unix:///var/run/docker.sock",
62
+ ]
63
+
64
+
65
+ def _probe(endpoint: str, timeout: int = 10) -> bool:
66
+ """Return True if ``docker info`` answers at *endpoint*.
67
+
68
+ ``endpoint`` may be the docker CLI's special value ``"default"``
69
+ (leave the configuration untouched and let the CLI use its current
70
+ docker context).
71
+ """
72
+ docker_bin = shutil.which("docker")
73
+ if not docker_bin:
74
+ return False
75
+ env = dict(os.environ)
76
+ if endpoint == "default":
77
+ env.pop("DOCKER_HOST", None)
78
+ else:
79
+ env["DOCKER_HOST"] = endpoint
80
+ try:
81
+ return (
82
+ subprocess.run( # noqa: S603
83
+ [docker_bin, "info"],
84
+ env=env,
85
+ capture_output=True,
86
+ check=False,
87
+ timeout=timeout,
88
+ ).returncode
89
+ == 0
90
+ )
91
+ except Exception: # a probe must never raise
92
+ return False
93
+
94
+
95
+ def resolve_docker_host() -> str | None:
96
+ """Return the first reachable docker endpoint, or ``None``.
97
+
98
+ Order: the endpoint the docker CLI is currently configured for
99
+ (``"default"`` when ``DOCKER_HOST`` is unset, i.e. its active
100
+ docker context, or the ``DOCKER_HOST`` value itself), then the
101
+ Rancher Desktop fallback endpoints.
102
+ """
103
+ current = os.environ.get("DOCKER_HOST") or "default"
104
+ if _probe(current):
105
+ return current
106
+ for candidate in rancher_endpoints():
107
+ if candidate != current and _probe(candidate):
108
+ return candidate
109
+ return None
110
+
111
+
112
+ def ensure_docker_host(verbose: bool = False, quiet: bool = False) -> str | None:
113
+ """Make every docker subprocess talk to a *running* daemon.
114
+
115
+ Called once from the CLI root callback, before any command runs:
116
+
117
+ * current endpoint already works → no-op;
118
+ * current endpoint dead but a Rancher Desktop endpoint answers →
119
+ export ``DOCKER_HOST`` and print a short notice, so detect /
120
+ compose / logs all hit the live daemon;
121
+ * nothing answers → no change (the usual "Docker daemon is not
122
+ running" errors from ``detect_docker`` stay in effect).
123
+
124
+ Safe on every platform: on a Linux host without any container
125
+ daemon the probes simply fail fast. Returns the selected endpoint
126
+ or ``None``.
127
+ """
128
+ if shutil.which("docker") is None:
129
+ return None
130
+
131
+ current = os.environ.get("DOCKER_HOST") or "default"
132
+ host = resolve_docker_host()
133
+ if host is None:
134
+ if verbose:
135
+ from faden_cli.core.console import warning # noqa: PLC0415 — late import avoids cycle
136
+ warning("No reachable Docker endpoint (Docker Desktop or Rancher Desktop).")
137
+ return None
138
+
139
+ if host != current:
140
+ os.environ["DOCKER_HOST"] = host
141
+ if not quiet:
142
+ from faden_cli.core.console import info # noqa: PLC0415 — late import avoids cycle
143
+ if is_windows() and host == "npipe:////./pipe/docker_engine":
144
+ info("Docker Desktop endpoint not found — using Rancher Desktop.")
145
+ else:
146
+ info(f"Docker endpoint → {host}")
147
+ return host
faden_cli/core/edge.py ADDED
@@ -0,0 +1,94 @@
1
+ """The install's front door: its ports, and whether it answers over HTTPS.
2
+
3
+ nginx serves HTTPS whenever ``<install>/tls`` holds ``edge.crt`` and
4
+ ``edge.key``, and sends plain HTTP there (shared/nginx/select-config.sh).
5
+ Everything the CLI does against the console after ``docker compose up`` --
6
+ the health gate, the first-admin bootstrap, a login for an enrollment token
7
+ -- has to follow, or it talks to a redirect.
8
+
9
+ The two host ports nginx is published on are ``FADEN_HTTP_PORT`` and
10
+ ``FADEN_HTTPS_PORT`` in the install's ``.env``; docker-compose.yaml
11
+ publishes both, the HTTPS one even while nothing listens on it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ipaddress
17
+ from collections.abc import Mapping
18
+ from pathlib import Path
19
+ from urllib.parse import urlsplit
20
+
21
+ HTTP_PORT_KEY = "FADEN_HTTP_PORT"
22
+ HTTPS_PORT_KEY = "FADEN_HTTPS_PORT"
23
+ DEFAULT_HTTP_PORT = 80
24
+ DEFAULT_HTTPS_PORT = 443
25
+
26
+
27
+ def edge_material_present(install_dir: Path) -> bool:
28
+ """Whether the install holds the certificate and key nginx serves HTTPS with."""
29
+ tls = Path(install_dir) / "tls"
30
+ return (tls / "edge.crt").is_file() and (tls / "edge.key").is_file()
31
+
32
+
33
+ def _port(env: Mapping[str, str], key: str, default: int) -> int:
34
+ raw = (env.get(key) or "").strip().strip('"').strip("'")
35
+ try:
36
+ return int(raw) if raw else default
37
+ except ValueError:
38
+ return default
39
+
40
+
41
+ def ports(env: Mapping[str, str]) -> tuple[int, int]:
42
+ """``(http, https)``: the host ports nginx is published on, from the install's ``.env``."""
43
+ return _port(env, HTTP_PORT_KEY, DEFAULT_HTTP_PORT), _port(env, HTTPS_PORT_KEY, DEFAULT_HTTPS_PORT)
44
+
45
+
46
+ def check_ports(http: int, https: int) -> str | None:
47
+ """Why this pair of ports cannot be used, or ``None`` when it can."""
48
+ for label, value in (("HTTP", http), ("HTTPS", https)):
49
+ if not 1 <= value <= 65535: # noqa: PLR2004
50
+ return f"The {label} port must be between 1 and 65535, not {value}."
51
+ if http == https:
52
+ return f"HTTP and HTTPS cannot share port {http}."
53
+ return None
54
+
55
+
56
+ def url_for(host: str, *, https: bool, env: Mapping[str, str]) -> str:
57
+ """``http[s]://host[:port]`` for *host*, leaving out the scheme's default port."""
58
+ http_port, https_port = ports(env)
59
+ scheme, port, default = ("https", https_port, 443) if https else ("http", http_port, 80)
60
+ if ":" in host and not host.startswith("["):
61
+ host = f"[{host}]" # an IPv6 address
62
+ return f"{scheme}://{host}" if port == default else f"{scheme}://{host}:{port}"
63
+
64
+
65
+ def console_url(install_dir: Path, env: Mapping[str, str]) -> str:
66
+ """``https://localhost[:port]`` with edge material in place, else ``http://localhost[:port]``.
67
+
68
+ *env* is the install's ``.env``; the ports follow ``FADEN_HTTPS_PORT``
69
+ and ``FADEN_HTTP_PORT`` the way docker-compose.yaml publishes them.
70
+ """
71
+ return url_for("localhost", https=edge_material_present(install_dir), env=env)
72
+
73
+
74
+ def _is_this_machine(host: str) -> bool:
75
+ if host.lower() == "localhost":
76
+ return True
77
+ try:
78
+ return ipaddress.ip_address(host).is_loopback
79
+ except ValueError:
80
+ return False
81
+
82
+
83
+ def verify_for(url: str) -> bool:
84
+ """TLS verification for a console URL: off only when it is HTTPS to this machine.
85
+
86
+ That certificate is usually made here, or issued for a name this machine
87
+ does not call itself by; what the CLI needs to know is whether the
88
+ services came up, and the box it runs on answers that. Any other host
89
+ is verified: a password may be about to go to it.
90
+ """
91
+ parts = urlsplit(url)
92
+ if parts.scheme != "https":
93
+ return True
94
+ return not _is_this_machine(parts.hostname or "")