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,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 "")
|