Skip to content

fix(deploy): harden Quadlet stack lifecycle and canonical routing#346

Closed
sheepdestroyer wants to merge 5 commits into
masterfrom
feat/quadlet-systemd-consolidated
Closed

fix(deploy): harden Quadlet stack lifecycle and canonical routing#346
sheepdestroyer wants to merge 5 commits into
masterfrom
feat/quadlet-systemd-consolidated

Conversation

@sheepdestroyer

@sheepdestroyer sheepdestroyer commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Consolidated Quadlet deployment hardening

Replaces closed #344 with a clean review surface after addressing all substantive review findings.

Review findings addressed

  • Render all Quadlet Environment= assignments as a single systemd-quoted KEY=value argument. This preserves whitespace, quotes, and backslashes in configuration and secrets; live generated units now show Local Development and Triage Gateway as single --env values.
  • Centralize PROXY_BASE_URL / NEXTAUTH_URL derivation in derive_external_service_urls(), shared by legacy pod.yaml and Quadlet renderers. Fallback now consistently uses ROUTING_DOMAIN.
  • Add explicit stack ownership reconciliation based on Podman's PODMAN_SYSTEMD_UNIT metadata and generated-unit load state. Active, inactive, failed, absent, and legacy pod states no longer depend on is-active inference alone.
  • Add a clear systemd-user-manager prerequisite guard before Quadlet deployment.
  • Extend URL-resolution coverage for valid canonical hosts with no explicit port.
  • Update canonical HTTPS verification and README documentation for router path URLs plus LiteLLM/Langfuse/llama subdomain URLs.

Validation

  • bash -n start-stack.sh
  • python3 -m pytest tests/ router/tests/ -x -q → 200 passed, 1 existing third-party deprecation warning
  • bash start-dev.sh --full-rebuild → completed; generated all 10 Quadlet files, started stack, MinIO bucket setup passed, and router pipeline health passed.
  • All 9 application containers became healthy; generated systemd command confirms whitespace-bearing Langfuse environment values are preserved as single arguments.
  • python3 scripts/verification/verify_canonical_endpoints.py --dev → all infrastructure, Langfuse session/leak, and all 8 canonical public checks passed, including litellm.dev.vendeuvre.lan, langfuse.dev.vendeuvre.lan, and llama.dev.vendeuvre.lan. Three model-dependent checks remain blocked by exhausted OpenRouter free-tier quota / a failing local safety-net upstream; tracked in fix(dev): restore fallback capacity for canonical E2E chat verification #345.
  • hermes chat -p dev-env-test ... independently reached the dev endpoint but its model request received upstream 504s from the same exhausted fallback chain; tracked in fix(dev): restore fallback capacity for canonical E2E chat verification #345.

Scope

This PR is limited to the reviewed Quadlet, URL-routing, verifier, regression-test, and documentation changes. It does not hide the unrelated provider-capacity defect: see #345.

Closes #344

Summary by Sourcery

Harden Quadlet-based deployment lifecycle, centralize external service URL derivation, and align canonical routing and verification with subdomain-based service URLs.

New Features:

  • Introduce Quadlet rendering and deployment flow that installs owner-only systemd units and manages the Podman stack via llm-routing-pod.service.
  • Add explicit stack ownership detection using Podman systemd metadata to distinguish Quadlet-managed, legacy, and absent stacks.

Bug Fixes:

  • Ensure Quadlet-managed stacks are reconciled via systemd, avoiding incorrect teardown or restart based solely on pod active state.
  • Fix canonical external URL resolution to support subdomain-based service hosts, preserve explicit public ports, and handle hosts with no explicit port.
  • Prevent deployment when no systemd user manager is available, avoiding failed Quadlet stack operations.

Enhancements:

  • Centralize derivation of PROXY_BASE_URL and NEXTAUTH_URL so legacy pod and Quadlet renderers share consistent service URLs.
  • Quote Quadlet Environment= assignments so secrets and configuration values with whitespace or special characters are preserved as single environment entries.
  • Extend canonical endpoint verification to cover LiteLLM, Langfuse, and llama subdomain URLs and update documentation to reflect Quadlet-managed stack behavior.
  • Update production upgrade script to sync quadlet templates alongside other runtime files.
  • Add regression tests for Quadlet templates, ownership, health behavior, and rendering contracts.

boy added 5 commits July 22, 2026 23:46
…lets

Replace 'podman play kube' with declarative Quadlet units rendered from
quadlets/*.pod + quadlets/*.container templates into
~/.config/containers/systemd/llm-routing/. systemd's podman-user-generator
turns them into llm-routing-*.service units, making systemd the single
supervisor: boot auto-start (WantedBy=default.target + linger) and crash
recovery (Restart=always) now come from systemd instead of podman's
restartPolicy.

Key points:
- Templates use the same _PLACEHOLDER convention as pod.yaml; a new
  render_quadlets() in start-stack.sh reuses the existing env-derived
  values (ports, secrets, DATA_ROOT, POD_NAME) so dev/prod parity is
  preserved with plain string replacement (bare scalars, not YAML).
- deploy_fresh_pod() now: render configs -> render quadlets ->
  daemon-reload -> start/restart llm-routing-pod.service. The bare
  'restart existing' path and safe_pod_teardown() detect systemd-managed
  stacks and route through systemctl accordingly.
- Entrypoint semantics: Quadlet Exec= only sets args (appended to the
  image entrypoint), so containers where pod.yaml used command: now set
  Entrypoint= explicitly (litellm venv python, valkey-server,
  redis-server, /bin/sh for the router). MinIO keeps its image
  entrypoint with Exec= providing the server args, matching pod.yaml
  args: behavior.
- AddHost=<POD_NAME>:127.0.0.1 on all containers replicates the
  /etc/hosts entry play kube injected, required by langfuse 3.222+
  which resolves the pod hostname at startup.
- Healthchecks ported from livenessProbe exec commands (HealthCmd,
  HealthStartPeriod maps initialDelaySeconds).

Verified on dev: all 9 containers healthy, 193/193 unit tests pass,
crash recovery (kill -> systemd auto-restart <12s), canonical HTTPS
endpoints green (model timeouts on free tier are pre-existing,
identical on prod play-kube).
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@sourcery-ai

sourcery-ai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Introduces a systemd Quadlet-based deployment for the routing stack, hardens stack lifecycle/ownership handling, centralizes derivation of external service URLs for both legacy and Quadlet paths, and updates routing, verification, tests, and docs to reflect canonical subdomain-based service URLs (LiteLLM, Langfuse, llama).

Sequence diagram for Quadlet vs legacy stack deployment in start-stack.sh

sequenceDiagram
    actor User
    participant start_stack_sh as start-stack.sh
    participant Podman
    participant SystemdUser as systemd_user

    User->>start_stack_sh: ./start-stack.sh [--replace|--full-rebuild]
    start_stack_sh->>start_stack_sh: STACK_OWNERSHIP=$(stack_ownership)

    alt STACK_OWNERSHIP == quadlet
        start_stack_sh->>SystemdUser: require_user_systemd()
        start_stack_sh->>start_stack_sh: deploy_quadlets()
        start_stack_sh->>SystemdUser: daemon-reload
        alt stack active
            start_stack_sh->>SystemdUser: restart llm-routing-pod.service
        else stack inactive/failed
            start_stack_sh->>SystemdUser: start llm-routing-pod.service
        end
    else STACK_OWNERSHIP == legacy
        alt --replace or --full-rebuild
            start_stack_sh->>Podman: safe_pod_teardown()
            start_stack_sh->>start_stack_sh: deploy_fresh_pod()
        else fast restart
            start_stack_sh->>Podman: pod restart ${POD_NAME}
        end
    else STACK_OWNERSHIP == absent
        start_stack_sh->>start_stack_sh: deploy_fresh_pod()
    end
Loading

File-Level Changes

Change Details Files
Introduce Quadlet-based systemd deployment, including template rendering, secure permissions, stack ownership detection, and systemd-driven lifecycle for the routing stack.
  • Add stack_ownership helper to classify the stack as Quadlet-owned, legacy, or absent using PODMAN_SYSTEMD_UNIT and systemd LoadState.
  • Add require_user_systemd guard to ensure a usable systemd --user manager before any Quadlet deployment or restart.
  • Implement render_quadlets to materialize Quadlet pod/container templates into ~/.config/containers/systemd/llm-routing with placeholder substitution, Environment= quoting, stale file cleanup, and owner-only permissions.
  • Implement deploy_quadlets to render Quadlets, run systemctl --user daemon-reload, and start/restart llm-routing-pod.service rather than using podman play kube.
  • Update deploy_fresh_pod and main stack startup flow to use Quadlet deployment, including ownership-aware restart behavior and diagnostic logging.
  • Add static tests to validate Quadlet inventory (9 containers + pod), healthcheck semantics, secret-free templates, upgrade syncing, ownership/permissions requirements, and Environment= quoting.
  • Add Quadlet container and pod templates for all stack services with consistent Pod membership and healthcheck settings.
  • Update upgrade-prod script to rsync quadlets/ along with other runtime directories and to include Quadlets in dry-run and confirmation output.
  • Update scripting documentation to describe Quadlet-managed stack, Quadlet template location, and systemd/journalctl-based diagnostics.
start-stack.sh
tests/test_quadlet_templates.py
quadlets/llm-routing-clickhouse.container
quadlets/llm-routing-langfuse-web.container
quadlets/llm-routing-langfuse-worker.container
quadlets/llm-routing-litellm.container
quadlets/llm-routing-minio.container
quadlets/llm-routing-postgres.container
quadlets/llm-routing-router.container
quadlets/llm-routing-valkey-cache.container
quadlets/llm-routing-valkey-lf.container
quadlets/llm-routing.pod
scripts/upgrade-prod.sh
scripts/README.md
README.md
Centralize derivation of external service URLs (LiteLLM and Langfuse) and tighten routing logic to use canonical subdomain-based hosts while preserving explicit ports.
  • Add derive_external_service_urls helper to compute PROXY_BASE_URL_DERIVED and NEXTAUTH_URL_DERIVED from PUBLIC_BASE_URL/ROUTING_DOMAIN and fall back to litellm./langfuse. subdomains.
  • Refactor render_pod_yaml and start-stack output to use the derived service URLs instead of hard-coded path-based derivations under PUBLIC_BASE_URL.
  • Change resolve_external_urls to emit canonical service subdomains (langfuse., litellm., llama.) while preserving explicit public ports, handling missing/invalid netlocs, and stripping known service/dashboard prefixes from the host base.
  • Extend resolve_external_urls tests to assert subdomain-based URLs, preserve explicit ports, and add coverage for the case where the base host has no explicit port.
  • Update canonical verification script to treat PUBLIC_BASE_URL as the router path and derive LiteLLM/Langfuse/llama URLs as scheme://service.host-based endpoints.
  • Update README canonical URL section to document the new subdomain-based endpoints and router path behavior, including test coverage counts.
start-stack.sh
router/main.py
router/tests/test_resolve_external_urls.py
scripts/verification/verify_canonical_endpoints.py
README.md
Harden stack teardown/restart semantics to reconcile Quadlet-owned pods through systemd and ensure safe lifecycle operations for both Quadlet and legacy deployments.
  • Modify safe_pod_teardown to branch on stack_ownership, stopping and resetting llm-routing-pod.service plus cleaning ports for Quadlet-owned stacks, and preserving graceful SIGTERM/force-remove semantics for legacy pods.
  • Change startup logic to detect stack ownership once (STACK_OWNERSHIP) and choose between Quadlet/systemd restart and legacy pod restart paths, while still supporting --replace and --full-rebuild flows.
  • Ensure cleanup_zombie_ports and health verification run after the appropriate restart/redeploy path, and adjust user-facing messages to clarify Quadlet vs legacy behavior.
  • Update documentation around container counts and stack lifecycle to reflect Quadlet infra container and systemd-managed units.
start-stack.sh
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions Bot added documentation Improvements or additions to documentation router scripts tests labels Jul 22, 2026
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@sheepdestroyer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 198ef3ee-7b99-4a19-8837-7e190ff7922c

📥 Commits

Reviewing files that changed from the base of the PR and between 7d42cc4 and 83fd422.

📒 Files selected for processing (19)
  • .env.dev
  • README.md
  • quadlets/llm-routing-clickhouse.container
  • quadlets/llm-routing-langfuse-web.container
  • quadlets/llm-routing-langfuse-worker.container
  • quadlets/llm-routing-litellm.container
  • quadlets/llm-routing-minio.container
  • quadlets/llm-routing-postgres.container
  • quadlets/llm-routing-router.container
  • quadlets/llm-routing-valkey-cache.container
  • quadlets/llm-routing-valkey-lf.container
  • quadlets/llm-routing.pod
  • router/main.py
  • router/tests/test_resolve_external_urls.py
  • scripts/README.md
  • scripts/upgrade-prod.sh
  • scripts/verification/verify_canonical_endpoints.py
  • start-stack.sh
  • tests/test_quadlet_templates.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quadlet-systemd-consolidated

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • In stack_ownership(), any existing pod whose infra container lacks or has a non-matching PODMAN_SYSTEMD_UNIT label is treated as legacy; consider explicitly distinguishing between an empty/missing label and a truly legacy deployment to avoid misclassifying arbitrary user pods as part of this stack.
  • The literal unit name llm-routing-pod.service is embedded in several places (stack_ownership, deploy_quadlets, systemctl calls, tests); centralizing this into a single variable or clearly documented constant would reduce the chance of drift if the unit name ever changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `stack_ownership()`, any existing pod whose infra container lacks or has a non-matching `PODMAN_SYSTEMD_UNIT` label is treated as `legacy`; consider explicitly distinguishing between an empty/missing label and a truly legacy deployment to avoid misclassifying arbitrary user pods as part of this stack.
- The literal unit name `llm-routing-pod.service` is embedded in several places (`stack_ownership`, `deploy_quadlets`, systemctl calls, tests); centralizing this into a single variable or clearly documented constant would reduce the chance of drift if the unit name ever changes.

## Individual Comments

### Comment 1
<location path="start-stack.sh" line_range="974" />
<code_context>
         echo "⚙️  Dashboard URL  : ${PUBLIC_BASE_URL}/dashboard"
         echo "🔑 Gateway API Key : gateway-pass"
-        echo "🔐 LiteLLM Admin UI: ${PUBLIC_BASE_URL}/litellm/ui"
+        echo "🔐 LiteLLM Admin UI: ${PROXY_BASE_URL_DERIVED}/ui/"
         echo "   Username: admin  |  Password: $LITELLM_MASTER_KEY"
         echo "========================================================================="
</code_context>
<issue_to_address>
**issue (bug_risk):** PROXY_BASE_URL_DERIVED may be unset in restart-only flows, leading to incorrect UI URL output.

Since `PROXY_BASE_URL_DERIVED` is only set in `derive_external_service_urls()`, which runs during render/deploy paths, it may be empty or outdated in restart-only flows (especially for stacks created before this change). In those cases, the summary can print an incorrect LiteLLM URL. Please ensure `derive_external_service_urls` (or a small wrapper) is invoked before this summary when `STACK_OWNERSHIP != "absent"`, so the LiteLLM URL always reflects the current `PUBLIC_BASE_URL`/`ROUTING_DOMAIN`.
</issue_to_address>

### Comment 2
<location path="start-stack.sh" line_range="879-880" />
<code_context>
+    sys.exit(1)
+
+# Remove stale rendered files from previous deploys (e.g. renamed containers)
+for stale in glob.glob(os.path.join(out_dir, "*.pod")) + glob.glob(os.path.join(out_dir, "*.container")):
+    os.unlink(stale)
+
+for tpl in templates:
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Deleting existing rendered quadlets before successfully generating new ones can leave the system without units on failure.

Right now we delete all existing .pod/.container files in out_dir before confirming that template rendering (including placeholder substitution) succeeds. If rendering fails (e.g., unresolved placeholder), render_quadlets exits non-zero after wiping a previously working set, and a daemon-reload can then drop the units entirely.

To avoid this, render into a temporary directory (e.g., out_dir + ".tmp"), validate there, and only on success atomically replace the old directory (rename or rsync --delete). That way a failed render preserves the existing deployment.

Suggested implementation:

```
templates = sorted(glob.glob(os.path.join(src_dir, "*.pod")) + glob.glob(os.path.join(src_dir, "*.container")))
if not templates:
    sys.stderr.write(f"Error: no quadlet templates found in {src_dir}\n")
    sys.exit(1)

# Render into a temporary directory first to avoid wiping working units on failure
tmp_out_dir = out_dir + ".tmp"
if not os.path.isdir(tmp_out_dir):
    os.makedirs(tmp_out_dir, exist_ok=True)

for tpl in templates:

```

To fully implement the "render to temp, then atomically replace" behavior, you should also:
1. Ensure all rendered quadlet files in the loop are written into `tmp_out_dir` instead of `out_dir` (e.g., when opening the output file, use `os.path.join(tmp_out_dir, os.path.basename(tpl))`).
2. After the loop completes successfully (i.e., after all placeholders are validated and before the script exits), atomically update `out_dir` from `tmp_out_dir`:
   - Import `shutil` at the top of the Python block: `import shutil`.
   - Delete stale `.pod`/`.container` files in `out_dir` *at this point*, not before rendering.
   - Copy or move the validated files from `tmp_out_dir` into `out_dir` (e.g., `shutil.copy2` or `shutil.move` for each file).
3. Optionally clean up `tmp_out_dir` after syncing to `out_dir` to avoid leaving temporary artifacts.
4. If you use a directory-level atomic replace (e.g., `os.rename(tmp_out_dir, out_dir)`), make sure `out_dir` is not required to exist beforehand and adjust any later references accordingly.
</issue_to_address>

### Comment 3
<location path="start-stack.sh" line_range="917-925" />
<code_context>
+    echo "✓ systemd units regenerated"
+    if systemctl --user is-active --quiet llm-routing-pod.service; then
+        echo "🔄 Restarting llm-routing-pod.service..."
+        systemctl --user restart llm-routing-pod.service
+    else
+        echo "🚀 Starting llm-routing-pod.service..."
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Restart/start failures for llm-routing-pod.service are not surfaced, which can hide deployment problems.

`deploy_quadlets` exits on `daemon-reload` failure but ignores the exit status of `systemctl --user restart/start`. If `llm-routing-pod.service` fails immediately (e.g., bad image, invalid quadlet, missing env), the script still continues and prints success-style output, which is misleading in automation. Please check the `systemctl` return code and either fail the script or emit a clear error (with a hint to `systemctl --user status llm-routing-pod.service`) so broken deployments don’t appear healthy.

```suggestion
    echo "✓ systemd units regenerated"
    if systemctl --user is-active --quiet llm-routing-pod.service; then
        echo "🔄 Restarting llm-routing-pod.service..."
        if ! systemctl --user restart llm-routing-pod.service; then
            echo "❌ Error: failed to restart llm-routing-pod.service" >&2
            echo "   Hint: run 'systemctl --user status llm-routing-pod.service' to inspect the failure" >&2
            exit 1
        fi
    else
        echo "🚀 Starting llm-routing-pod.service..."
        if ! systemctl --user start llm-routing-pod.service; then
            echo "❌ Error: failed to start llm-routing-pod.service" >&2
            echo "   Hint: run 'systemctl --user status llm-routing-pod.service' to inspect the failure" >&2
            exit 1
        fi
    fi
}
```
</issue_to_address>

### Comment 4
<location path="scripts/verification/verify_canonical_endpoints.py" line_range="607-612" />
<code_context>
+    if not host:
+        print("  ⚠ Canonical URLs — SKIPPED (PUBLIC_BASE_URL has no host)")
+        return 0, 0, 0
+    router_base = public.rstrip("/")
     endpoints = [
-        ("/v1/models", "router models"),
-        ("/dashboard", "dashboard"),
-        ("/metrics", "metrics"),
-        ("/visualizer", "visualizer"),
-        ("/litellm/ui/", "LiteLLM admin UI"),
-        ("/langfuse", "Langfuse web UI"),
+        (f"{router_base}/v1/models", "router models"),
+        (f"{router_base}/dashboard", "dashboard"),
+        (f"{router_base}/metrics", "metrics"),
+        (f"{router_base}/visualizer", "visualizer"),
+        (f"{scheme}://litellm.{host}/ui/", "LiteLLM admin UI"),
+        (f"{scheme}://langfuse.{host}/", "Langfuse web UI"),
</code_context>
<issue_to_address>
**issue (bug_risk):** Canonical router URLs still use PUBLIC_BASE_URL verbatim, ignoring the parsed scheme/host and potentially generating invalid URLs.

You correctly parse and validate `PUBLIC_BASE_URL`, but `router_base` still uses `public.rstrip('/')`. If `PUBLIC_BASE_URL` lacks a scheme (e.g. `example.com`), router URLs become `example.com/v1/models`, which httpx rejects as invalid, while the subdomain URLs correctly use `{scheme}://{host}`. Consider normalizing a single base, e.g. `base = f"{scheme}://{host}"`, and using that for router/dashboard/metrics/visualizer instead of `public`.
</issue_to_address>

### Comment 5
<location path="router/tests/test_resolve_external_urls.py" line_range="74-83" />
<code_context>
+    assert lm == "http://llama.sub.vendeuvre.lan:8000/"
+
+
+def test_resolve_with_valid_base_request_without_port():
+    os.environ["ROUTING_DOMAIN"] = "vendeuvre.lan"
+    os.environ["PUBLIC_BASE_URL"] = "http://[::1]:5000"
+
+    req = MockRequest(
+        base_host="sub.vendeuvre.lan",
+        base_netloc="sub.vendeuvre.lan",
+        url_scheme="https",
+        url_netloc="[::1]:8000",
+    )
+
+    lf, ll, lm = resolve_external_urls(req)
+
+    assert lf == "http://langfuse.sub.vendeuvre.lan"
+    assert ll == "http://litellm.sub.vendeuvre.lan/ui/"
+    assert lm == "http://llama.sub.vendeuvre.lan/"


</code_context>
<issue_to_address>
**issue (testing):** Avoid environment-variable leakage between tests when mutating os.environ.

This test sets `ROUTING_DOMAIN` and `PUBLIC_BASE_URL` in `os.environ` without restoring them, which can cause cross-test interference and order-dependent failures. Please use pytest’s `monkeypatch.setenv(...)` or explicitly save and restore the original values to keep tests isolated.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread start-stack.sh
echo "⚙️ Dashboard URL : ${PUBLIC_BASE_URL}/dashboard"
echo "🔑 Gateway API Key : gateway-pass"
echo "🔐 LiteLLM Admin UI: ${PUBLIC_BASE_URL}/litellm/ui"
echo "🔐 LiteLLM Admin UI: ${PROXY_BASE_URL_DERIVED}/ui/"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): PROXY_BASE_URL_DERIVED may be unset in restart-only flows, leading to incorrect UI URL output.

Since PROXY_BASE_URL_DERIVED is only set in derive_external_service_urls(), which runs during render/deploy paths, it may be empty or outdated in restart-only flows (especially for stacks created before this change). In those cases, the summary can print an incorrect LiteLLM URL. Please ensure derive_external_service_urls (or a small wrapper) is invoked before this summary when STACK_OWNERSHIP != "absent", so the LiteLLM URL always reflects the current PUBLIC_BASE_URL/ROUTING_DOMAIN.

Comment thread start-stack.sh
Comment on lines +879 to +880
for stale in glob.glob(os.path.join(out_dir, "*.pod")) + glob.glob(os.path.join(out_dir, "*.container")):
os.unlink(stale)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Deleting existing rendered quadlets before successfully generating new ones can leave the system without units on failure.

Right now we delete all existing .pod/.container files in out_dir before confirming that template rendering (including placeholder substitution) succeeds. If rendering fails (e.g., unresolved placeholder), render_quadlets exits non-zero after wiping a previously working set, and a daemon-reload can then drop the units entirely.

To avoid this, render into a temporary directory (e.g., out_dir + ".tmp"), validate there, and only on success atomically replace the old directory (rename or rsync --delete). That way a failed render preserves the existing deployment.

Suggested implementation:

templates = sorted(glob.glob(os.path.join(src_dir, "*.pod")) + glob.glob(os.path.join(src_dir, "*.container")))
if not templates:
    sys.stderr.write(f"Error: no quadlet templates found in {src_dir}\n")
    sys.exit(1)

# Render into a temporary directory first to avoid wiping working units on failure
tmp_out_dir = out_dir + ".tmp"
if not os.path.isdir(tmp_out_dir):
    os.makedirs(tmp_out_dir, exist_ok=True)

for tpl in templates:

To fully implement the "render to temp, then atomically replace" behavior, you should also:

  1. Ensure all rendered quadlet files in the loop are written into tmp_out_dir instead of out_dir (e.g., when opening the output file, use os.path.join(tmp_out_dir, os.path.basename(tpl))).
  2. After the loop completes successfully (i.e., after all placeholders are validated and before the script exits), atomically update out_dir from tmp_out_dir:
    • Import shutil at the top of the Python block: import shutil.
    • Delete stale .pod/.container files in out_dir at this point, not before rendering.
    • Copy or move the validated files from tmp_out_dir into out_dir (e.g., shutil.copy2 or shutil.move for each file).
  3. Optionally clean up tmp_out_dir after syncing to out_dir to avoid leaving temporary artifacts.
  4. If you use a directory-level atomic replace (e.g., os.rename(tmp_out_dir, out_dir)), make sure out_dir is not required to exist beforehand and adjust any later references accordingly.

Comment thread start-stack.sh
Comment on lines +917 to +925
echo "✓ systemd units regenerated"
if systemctl --user is-active --quiet llm-routing-pod.service; then
echo "🔄 Restarting llm-routing-pod.service..."
systemctl --user restart llm-routing-pod.service
else
echo "🚀 Starting llm-routing-pod.service..."
systemctl --user start llm-routing-pod.service
fi
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Restart/start failures for llm-routing-pod.service are not surfaced, which can hide deployment problems.

deploy_quadlets exits on daemon-reload failure but ignores the exit status of systemctl --user restart/start. If llm-routing-pod.service fails immediately (e.g., bad image, invalid quadlet, missing env), the script still continues and prints success-style output, which is misleading in automation. Please check the systemctl return code and either fail the script or emit a clear error (with a hint to systemctl --user status llm-routing-pod.service) so broken deployments don’t appear healthy.

Suggested change
echo "✓ systemd units regenerated"
if systemctl --user is-active --quiet llm-routing-pod.service; then
echo "🔄 Restarting llm-routing-pod.service..."
systemctl --user restart llm-routing-pod.service
else
echo "🚀 Starting llm-routing-pod.service..."
systemctl --user start llm-routing-pod.service
fi
}
echo "✓ systemd units regenerated"
if systemctl --user is-active --quiet llm-routing-pod.service; then
echo "🔄 Restarting llm-routing-pod.service..."
if ! systemctl --user restart llm-routing-pod.service; then
echo "❌ Error: failed to restart llm-routing-pod.service" >&2
echo " Hint: run 'systemctl --user status llm-routing-pod.service' to inspect the failure" >&2
exit 1
fi
else
echo "🚀 Starting llm-routing-pod.service..."
if ! systemctl --user start llm-routing-pod.service; then
echo "❌ Error: failed to start llm-routing-pod.service" >&2
echo " Hint: run 'systemctl --user status llm-routing-pod.service' to inspect the failure" >&2
exit 1
fi
fi
}

Comment on lines +607 to +612
router_base = public.rstrip("/")
endpoints = [
("/v1/models", "router models"),
("/dashboard", "dashboard"),
("/metrics", "metrics"),
("/visualizer", "visualizer"),
("/litellm/ui/", "LiteLLM admin UI"),
("/langfuse", "Langfuse web UI"),
(f"{router_base}/v1/models", "router models"),
(f"{router_base}/dashboard", "dashboard"),
(f"{router_base}/metrics", "metrics"),
(f"{router_base}/visualizer", "visualizer"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Canonical router URLs still use PUBLIC_BASE_URL verbatim, ignoring the parsed scheme/host and potentially generating invalid URLs.

You correctly parse and validate PUBLIC_BASE_URL, but router_base still uses public.rstrip('/'). If PUBLIC_BASE_URL lacks a scheme (e.g. example.com), router URLs become example.com/v1/models, which httpx rejects as invalid, while the subdomain URLs correctly use {scheme}://{host}. Consider normalizing a single base, e.g. base = f"{scheme}://{host}", and using that for router/dashboard/metrics/visualizer instead of public.

Comment on lines +74 to +83
def test_resolve_with_valid_base_request_without_port():
os.environ["ROUTING_DOMAIN"] = "vendeuvre.lan"
os.environ["PUBLIC_BASE_URL"] = "http://[::1]:5000"

req = MockRequest(
base_host="sub.vendeuvre.lan",
base_netloc="sub.vendeuvre.lan",
url_scheme="https",
url_netloc="[::1]:8000",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): Avoid environment-variable leakage between tests when mutating os.environ.

This test sets ROUTING_DOMAIN and PUBLIC_BASE_URL in os.environ without restoring them, which can cause cross-test interference and order-dependent failures. Please use pytest’s monkeypatch.setenv(...) or explicitly save and restore the original values to keep tests isolated.

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — APPROVE

Fresh global review of master...83fd422 completed.

Scope reviewed: all 19 changed files, including all nine Quadlet templates, start-stack.sh lifecycle/rendering paths, URL resolver and tests, canonical verifier, upgrade flow, and README/scripts documentation.

Verified outcomes:

  • Quadlet rendering quotes each complete Environment=KEY=value argument safely. Live generated unit and its Podman ExecStart preserve Local Development and Triage Gateway as single environment values.
  • Shared service-URL derivation now keeps legacy pod and Quadlet renderers consistent, including ROUTING_DOMAIN fallback.
  • Ownership reconciliation uses PODMAN_SYSTEMD_UNIT metadata plus unit LoadState, covering active/inactive/failed/absent Quadlet states and legacy pods.
  • The user-systemd prerequisite guard is clear and early.
  • Canonical verification and docs consistently use router paths plus LiteLLM/Langfuse/llama subdomains; no-port URL resolution is covered.
  • Full tests passed: 200 passed (one pre-existing third-party deprecation warning).
  • Live dev Quadlet rebuild completed; generated units and application containers were healthy. Canonical router, LiteLLM, Langfuse, and llama HTTPS routes passed.

The three model-dependent E2E timeouts are not introduced by this PR: logs show exhausted OpenRouter free-tier capacity and an unavailable local safety-net upstream. They are explicitly tracked in #345.

Verdict: APPROVE — no blocking correctness, security, lifecycle, routing, or documentation issues found.

@sheepdestroyer

Copy link
Copy Markdown
Owner Author

Superseded by #347, which carries every actionable review fix, regression coverage, dev deployment validation, and the local safety-net upstream correction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation router scripts tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant