-
Notifications
You must be signed in to change notification settings - Fork 0
fix(deploy): isolate dev and prod Quadlet namespaces #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,6 +77,15 @@ if [ -n "${DEV_ENV_FILE:-}" ] && [ -f "$DEV_ENV_FILE" ]; then | |
| set +a | ||
| fi | ||
|
|
||
| # Quadlet namespace is environment-specific. This prevents dev and prod from | ||
| # sharing rendered files or generated systemd unit names. | ||
| QUADLET_NAMESPACE="${QUADLET_NAMESPACE:-llm-routing-prod}" | ||
| if [[ ! "$QUADLET_NAMESPACE" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then | ||
| echo "❌ Error: QUADLET_NAMESPACE must contain only lowercase letters, digits, and hyphens" >&2 | ||
| exit 1 | ||
| fi | ||
| export QUADLET_NAMESPACE | ||
|
|
||
| # Port assignments — read from env (set by .env or .env.dev) with prod defaults | ||
| POD_NAME="${POD_NAME:-prod-router-pod}" | ||
| ROUTER_PORT="${ROUTER_PORT:-5000}" | ||
|
|
@@ -533,7 +542,9 @@ verify_stack_health() { | |
| # ── Stack ownership and teardown ── | ||
| # Keep the generated Quadlet unit name in one place: it is used for ownership | ||
| # detection, lifecycle operations, and user-facing diagnostics. | ||
| LLM_ROUTING_POD_UNIT="llm-routing-pod.service" | ||
| LLM_ROUTING_POD_UNIT="${QUADLET_NAMESPACE}-pod.service" | ||
| LEGACY_LLM_ROUTING_POD_UNIT="llm-routing-pod.service" | ||
| QUADLET_DIR="${HOME}/.config/containers/systemd/${QUADLET_NAMESPACE}" | ||
| # Quadlet-managed pods carry PODMAN_SYSTEMD_UNIT on their infra container. | ||
| # Consult that metadata rather than inferring ownership from active state: a | ||
| # stopped or failed generated unit is still Quadlet-owned and must be reconciled | ||
|
|
@@ -542,18 +553,34 @@ stack_ownership() { | |
| local infra_unit | ||
| if podman pod exists "${POD_NAME}" 2>/dev/null; then | ||
| infra_unit=$(podman pod inspect "${POD_NAME}" --format '{{.InfraContainerID}}' 2>/dev/null | xargs -r podman inspect --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true) | ||
| if [[ "$infra_unit" == "$LLM_ROUTING_POD_UNIT" ]]; then | ||
| printf 'quadlet\n' | ||
| if [[ "$infra_unit" == "$LLM_ROUTING_POD_UNIT" || "$infra_unit" == "$LEGACY_LLM_ROUTING_POD_UNIT" ]]; then | ||
| printf 'quadlet:%s\n' "$infra_unit" | ||
| else | ||
| printf 'legacy\n' | ||
| fi | ||
| elif systemctl --user show "$LLM_ROUTING_POD_UNIT" -p LoadState --value 2>/dev/null | grep -qxv 'not-found'; then | ||
| printf 'quadlet\n' | ||
| printf 'quadlet:%s\n' "$LLM_ROUTING_POD_UNIT" | ||
| elif systemctl --user show "$LEGACY_LLM_ROUTING_POD_UNIT" -p LoadState --value 2>/dev/null | grep -qxv 'not-found'; then | ||
| printf 'quadlet:%s\n' "$LEGACY_LLM_ROUTING_POD_UNIT" | ||
| else | ||
| printf 'absent\n' | ||
| fi | ||
| } | ||
|
|
||
| stop_quadlet_units() { | ||
| systemctl --user stop "$LLM_ROUTING_POD_UNIT" 2>/dev/null || true | ||
| if [[ "$LLM_ROUTING_POD_UNIT" != "$LEGACY_LLM_ROUTING_POD_UNIT" ]]; then | ||
| systemctl --user stop "$LEGACY_LLM_ROUTING_POD_UNIT" 2>/dev/null || true | ||
| fi | ||
| } | ||
|
|
||
| reset_quadlet_units() { | ||
| systemctl --user reset-failed "$LLM_ROUTING_POD_UNIT" 2>/dev/null || true | ||
| if [[ "$LLM_ROUTING_POD_UNIT" != "$LEGACY_LLM_ROUTING_POD_UNIT" ]]; then | ||
| systemctl --user reset-failed "$LEGACY_LLM_ROUTING_POD_UNIT" 2>/dev/null || true | ||
| fi | ||
| } | ||
|
|
||
| require_user_systemd() { | ||
| if ! systemctl --user show-environment >/dev/null 2>&1; then | ||
| echo "❌ Error: the Quadlet deployment requires a reachable systemd --user manager." >&2 | ||
|
|
@@ -567,10 +594,11 @@ require_user_systemd() { | |
| safe_pod_teardown() { | ||
| local ownership | ||
| ownership=$(stack_ownership) | ||
| if [[ "$ownership" == "quadlet" ]]; then | ||
| if [[ "$ownership" == quadlet:* ]]; then | ||
| local owner_unit="${ownership#quadlet:}" | ||
| echo "🛑 Reconciling Quadlet-owned stack (unit may be active, inactive, or failed)..." | ||
| systemctl --user stop "$LLM_ROUTING_POD_UNIT" 2>/dev/null || true | ||
| systemctl --user reset-failed "$LLM_ROUTING_POD_UNIT" 2>/dev/null || true | ||
| systemctl --user stop "$owner_unit" 2>/dev/null || true | ||
| systemctl --user reset-failed "$owner_unit" 2>/dev/null || true | ||
| podman pod rm -f "${POD_NAME}" 2>/dev/null || true | ||
| cleanup_zombie_ports | ||
| echo "✓ Quadlet stack stopped, state reconciled, ports cleaned" | ||
|
|
@@ -680,8 +708,6 @@ render_router_config() { | |
| # convention as pod.yaml) into ~/.config/containers/systemd/llm-routing/ and | ||
| # lets systemd's podman-user-generator turn them into real units. | ||
| # Quadlet values are bare scalars (not YAML) so plain string replacement is used. | ||
| QUADLET_DIR="${HOME}/.config/containers/systemd/llm-routing" | ||
|
|
||
| render_quadlets() { | ||
| export WORKDIR HOME LITELLM_MASTER_KEY UI_USERNAME UI_PASSWORD | ||
| export POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY | ||
|
|
@@ -705,6 +731,7 @@ render_quadlets() { | |
| import os, sys, urllib.parse, re, glob, shutil, tempfile | ||
| uid = os.getuid() | ||
| src_dir, out_dir = sys.argv[1], sys.argv[2] | ||
| namespace = os.environ["QUADLET_NAMESPACE"] | ||
|
|
||
| encoded_pg = urllib.parse.quote(os.environ['POSTGRES_PASSWORD'], safe="") | ||
| # Derived by derive_external_service_urls(), shared with render_pod_yaml. | ||
|
|
@@ -766,6 +793,17 @@ try: | |
| text = f.read() | ||
| for ph, val in repl.items(): | ||
| text = text.replace(ph, str(val)) | ||
| # Quadlet generated unit names are global in the user systemd manager. | ||
| # Namespace both filenames and internal dependencies to isolate dev/prod. | ||
| # Namespace Quadlet identifiers only. Do not rewrite image names, URL | ||
| # paths, or other configuration values containing "llm-routing". | ||
| text = re.sub( | ||
| r"llm-routing-(?=(?:pod|clickhouse|langfuse|litellm|minio|postgres|router|valkey))", | ||
| namespace + "-", | ||
| text, | ||
| ) | ||
| text = text.replace("llm-routing.pod", namespace + ".pod") | ||
| text = text.replace("llm-routing-pod.service", namespace + "-pod.service") | ||
|
Comment on lines
+796
to
+806
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Scope namespace substitutions to Quadlet identifier fields. These substitutions run over the entire rendered file after placeholder values are inserted. For example, an image value such as 🤖 Prompt for AI Agents |
||
| unresolved = sorted(set(re.findall(r"\b[A-Z0-9_]+_PLACEHOLDER\b", text))) | ||
| if unresolved: | ||
| sys.stderr.write(f"Error: Unresolved placeholders in {os.path.basename(tpl)}: {', '.join(unresolved)}\n") | ||
|
|
@@ -777,15 +815,16 @@ try: | |
| value = match.group(1).replace("\\", "\\\\").replace('"', '\\"') | ||
| return f'Environment="{value}"' | ||
| text = re.sub(r"(?m)^Environment=(.*)$", quote_environment, text) | ||
| staged_path = os.path.join(staging_dir, os.path.basename(tpl)) | ||
| rendered_name = os.path.basename(tpl).replace("llm-routing", namespace) | ||
| staged_path = os.path.join(staging_dir, rendered_name) | ||
| with open(staged_path, "w", encoding="utf-8") as f: | ||
| f.write(text) | ||
| # Rendered units include credentials; systemd user generator can read owner-only files. | ||
| os.chmod(staged_path, 0o600) | ||
|
|
||
| # All templates are now valid. Replace individual files atomically, then | ||
| # remove stale units; a failed render above leaves the prior unit set intact. | ||
| rendered_names = {os.path.basename(tpl) for tpl in templates} | ||
| rendered_names = {os.path.basename(tpl).replace("llm-routing", namespace) for tpl in templates} | ||
| for name in rendered_names: | ||
| os.replace(os.path.join(staging_dir, name), os.path.join(out_dir, name)) | ||
| for stale in glob.glob(os.path.join(out_dir, "*.pod")) + glob.glob(os.path.join(out_dir, "*.container")): | ||
|
|
@@ -854,11 +893,11 @@ if [[ "$STACK_OWNERSHIP" != "absent" ]]; then | |
| echo "🚀 Deploying replacement pod from YAML..." | ||
| deploy_fresh_pod | ||
| else | ||
| if [[ "$STACK_OWNERSHIP" == "quadlet" ]]; then | ||
| if [[ "$STACK_OWNERSHIP" == quadlet:* ]]; then | ||
| require_user_systemd || exit 1 | ||
| owner_unit="${STACK_OWNERSHIP#quadlet:}" | ||
| echo "🔄 Restarting Quadlet-owned stack via systemd..." | ||
| systemctl --user reset-failed "$LLM_ROUTING_POD_UNIT" 2>/dev/null || true | ||
| if ! systemctl --user restart "$LLM_ROUTING_POD_UNIT"; then | ||
| if ! systemctl --user restart "$owner_unit"; then | ||
| echo "❌ Error: failed to restart ${LLM_ROUTING_POD_UNIT}" >&2 | ||
| echo " Hint: run 'systemctl --user status ${LLM_ROUTING_POD_UNIT} --no-pager' to inspect the failure" >&2 | ||
| exit 1 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Validate
QUADLET_NAMESPACEbefore using it as a path and unit-name fragment.Lines 82-83 accept arbitrary inherited values, which are later interpolated into
QUADLET_DIR, rendered filenames, systemd unit names, and a regex replacement. Values containing/or..can escape the intended namespace directory; whitespace or backslashes can also produce invalid or altered unit identifiers.Proposed validation
QUADLET_NAMESPACE="${QUADLET_NAMESPACE:-llm-routing-prod}" +if [[ ! "$QUADLET_NAMESPACE" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "❌ Error: QUADLET_NAMESPACE must contain only lowercase letters, digits, and hyphens" >&2 + exit 1 +fi export QUADLET_NAMESPACE📝 Committable suggestion
🤖 Prompt for AI Agents