Skip to content

feat(deploy): migrate stack management from play kube to systemd Quadlets#343

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

feat(deploy): migrate stack management from play kube to systemd Quadlets#343
sheepdestroyer wants to merge 4 commits into
masterfrom
feat/quadlet-systemd

Conversation

@sheepdestroyer

@sheepdestroyer sheepdestroyer commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces `podman play kube` with declarative systemd Quadlets — the current upstream-recommended approach for podman+systemd integration. systemd becomes the single supervisor for the stack:

  • Boot auto-start: `WantedBy=default.target` + linger (previously relied on podman `restartPolicy: Always`, which does not survive reboot for rootless pods)
  • Crash recovery: `Restart=always` per container unit (verified: `podman kill` → healthy again in <12s)
  • Single supervisor: no more split-brain between systemd and podman restart policies
  • Declarative + version-controlled: quadlet templates live in `quadlets/` next to `pod.yaml`

Changes

  • `quadlets/` — 10 templates (1 `.pod` + 9 `.container`) using the same `_PLACEHOLDER` convention as `pod.yaml`
  • `start-stack.sh`:
    • `render_quadlets()` renders templates into `~/.config/containers/systemd/llm-routing/` with plain string replacement (bare scalars, not YAML), reusing all existing env-derived values
    • `deploy_fresh_pod()` now renders quadlets → `systemctl --user daemon-reload` → start/restart `llm-routing-pod.service`
    • restart-existing and `safe_pod_teardown()` detect systemd-managed stacks and route through `systemctl`

Migration notes (pitfalls found during dev cutover)

  • `Exec=` only sets args (appended to image entrypoint) — containers where `pod.yaml` used `command:` now set `Entrypoint=` explicitly (litellm venv python, valkey-server, redis-server, /bin/sh for router). MinIO keeps its image entrypoint + `Exec=` args, matching `pod.yaml` `args:`.
  • `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 (`getaddrinfo ENOTFOUND dev-router-pod` without it).
  • Healthchecks ported from `livenessProbe` exec commands (`HealthCmd` + `HealthStartPeriod` ≈ `initialDelaySeconds`).
  • `pod.yaml` is now unused by start-stack.sh but kept as the canonical reference for container definitions (a future PR can remove it + `render_pod_yaml` once prod has migrated).

Verification (dev)

  • 193/193 unit tests pass
  • All 9 containers healthy via systemd units; `systemctl --user list-units 'llm-routing-*'` all active/running
  • Boot semantics: pod unit `generated` + enabled via `[Install] WantedBy=default.target`
  • Crash recovery: `podman kill dev-router-pod-valkey-cache` → auto-restarted, healthy in <12s
  • Canonical HTTPS endpoints green (dashboard, metrics, visualizer, litellm UI, langfuse, chat completions). Free-tier model timeouts seen during verification are pre-existing/environmental (OpenRouter 429s — prod play-kube shows the same) and unrelated to this change.

Prod rollout

After merge + release, prod migrates via the normal `upgrade-prod.sh` flow: `start-stack.sh --replace` will tear down the play-kube pod and redeploy under systemd. The quadlet subdir approach means dev and prod share unit names with environment-specific rendered values — both stacks coexist (verified on this host).

Summary by Sourcery

Migrate stack deployment from a podman play kube–based pod to systemd Quadlet-managed units and wire the deployment script to manage the stack through systemd where available.

New Features:

  • Introduce systemd Quadlet pod and container templates for all llm-routing services, rendered per-environment from existing configuration values.
  • Add a quadlet-based deployment path that renders templates, reloads systemd user units, and starts or restarts the llm-routing stack via llm-routing-pod.service.

Enhancements:

  • Update start-stack.sh to detect and manage a systemd-backed stack for safe teardown, restart, and replacement while preserving the existing podman-based fallback behavior.

Summary by CodeRabbit

  • New Features

    • Added a complete LLM routing stack with gateway, adaptive routing, Langfuse observability, PostgreSQL, ClickHouse, MinIO, and Valkey services.
    • Added persistent storage, health monitoring, and configurable service settings.
    • Added systemd-managed deployment through Podman Quadlets.
  • Improvements

    • Stack startup now renders, installs, and launches the required services automatically.
    • Restart and shutdown operations use systemd when available, with fallback handling for existing Podman deployments.
    • Services automatically restart and include extended startup timeouts for improved reliability.

…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

This PR migrates stack deployment from podman play kube to systemd Quadlets by rendering environment-driven quadlet templates into user systemd config, managing lifecycle via systemd units instead of podman restart policies, while preserving existing configuration semantics and adding systemd-aware teardown/restart logic.

Sequence diagram for stack deployment via systemd Quadlets

sequenceDiagram
    actor User
    participant start_stack as start-stack.sh
    participant deploy_quadlets
    participant render_quadlets
    participant systemd_user as systemd_user

    User->>start_stack: deploy_fresh_pod
    activate start_stack
    start_stack->>start_stack: generate_clickhouse_config
    start_stack->>start_stack: render_litellm_config
    start_stack->>start_stack: render_router_config
    start_stack->>deploy_quadlets: deploy_quadlets
    activate deploy_quadlets
    deploy_quadlets->>render_quadlets: render_quadlets
    activate render_quadlets
    render_quadlets->>render_quadlets: python3 template rendering
    render_quadlets-->>deploy_quadlets: quadlets in QUADLET_DIR
    deactivate render_quadlets
    deploy_quadlets->>systemd_user: systemctl --user daemon-reload
    deploy_quadlets->>systemd_user: systemctl --user is-active llm-routing-pod.service
    alt llm-routing-pod.service active
        deploy_quadlets->>systemd_user: systemctl --user restart llm-routing-pod.service
    else not active
        deploy_quadlets->>systemd_user: systemctl --user start llm-routing-pod.service
    end
    deactivate deploy_quadlets
    start_stack->>start_stack: setup_minio_buckets
    start_stack->>start_stack: verify_stack_health
    deactivate start_stack
    start_stack-->>User: stack running under systemd Quadlets
Loading

Flow diagram for systemd-aware stack teardown and restart

flowchart TD
    A[start-stack.sh entry] --> B{podman pod exists POD_NAME?}
    B -- no --> G[setup_minio_buckets]
    G --> H[verify_stack_health]

    B -- yes --> C{--replace flag?}
    C -- yes --> D[safe_pod_teardown]
    C -- no --> E{systemctl --user is-active llm-routing-pod.service?}

    %% Teardown path
    D --> F{systemctl --user is-active llm-routing-pod.service?}
    F -- yes --> F1[systemctl --user stop llm-routing-pod.service]
    F1 --> F2[podman pod rm -f POD_NAME]
    F2 --> F3[cleanup_zombie_ports]
    F3 --> I[exit teardown]
    F -- no --> F4[podman pod stop -t 30 POD_NAME]
    F4 --> F5[podman pod rm -f POD_NAME]
    F5 --> F3

    %% Restart path when not replacing
    E -- yes --> E1[systemctl --user restart llm-routing-pod.service]
    E -- no --> E2[podman pod restart POD_NAME]
    E1 --> G
    E2 --> G
Loading

File-Level Changes

Change Details Files
Introduce Quadlet-based deployment flow that renders env-driven templates and manages the stack via systemd user units.
  • Add quadlet rendering pipeline that converts quadlets/*.pod and quadlets/*.container templates into concrete unit files under the user systemd container directory using plain string replacement of _PLACEHOLDER tokens.
  • Reuse existing environment variables and derived URLs (including encoded Postgres password, proxy base URL, NextAuth URL) to populate quadlet templates, mirroring the existing render_pod_yaml behavior.
  • Ensure template integrity by failing on unresolved placeholders and cleaning up stale rendered quadlet files before each deploy, then creating readable unit definitions with fixed permissions.
start-stack.sh
quadlets/llm-routing.pod
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
Switch fresh deploys and restarts to systemd-managed llm-routing-pod.service, while keeping compatibility with legacy play-kube pods when systemd units are absent.
  • Replace the previous podman play kube call in deploy_fresh_pod with quadlet rendering followed by systemctl --user daemon-reload and start/restart of llm-routing-pod.service.
  • Update the main control flow to prefer restarting the stack via systemctl --user restart llm-routing-pod.service when the systemd pod unit is active, falling back to podman pod restart otherwise.
  • Add error handling for systemctl --user daemon-reload failures (e.g. missing user systemd session) and surface a clear error message when quadlet-based deployment cannot proceed.
start-stack.sh
Make stack teardown systemd-aware so systemd-managed deployments are stopped via systemd before cleaning up podman resources and ports.
  • Extend safe_pod_teardown to detect active llm-routing-pod.service and stop it via systemctl --user stop, then force-remove any remaining podman pod and run port cleanup.
  • Preserve the existing graceful SIGTERM+timeout flow for non-systemd-managed pods by keeping the original podman-based teardown path as a fallback.
  • Ensure user-facing logs clearly distinguish between stopping a systemd-managed stack and a legacy pod-only stack.
start-stack.sh

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

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 43 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: d9df3cdb-bb3e-4ecb-af64-ea30efe24dd0

📥 Commits

Reviewing files that changed from the base of the PR and between d162459 and f8a4a53.

📒 Files selected for processing (17)
  • .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
  • router/main.py
  • router/tests/test_resolve_external_urls.py
  • scripts/README.md
  • scripts/upgrade-prod.sh
  • start-stack.sh
  • tests/test_quadlet_templates.py
📝 Walkthrough

Walkthrough

Adds Quadlet definitions for the LLM routing pod and its services, then updates start-stack.sh to render, install, deploy, restart, and tear down the stack through user systemd.

Changes

Quadlet stack deployment

Layer / File(s) Summary
Pod foundation
quadlets/llm-routing.pod
Defines the host-networked llm-routing pod template and systemd installation metadata.
Stateful backing services
quadlets/llm-routing-postgres.container, quadlets/llm-routing-clickhouse.container, quadlets/llm-routing-minio.container, quadlets/llm-routing-valkey-*.container
Adds persistent PostgreSQL, ClickHouse, MinIO, and Valkey services with environment configuration, health checks, storage mounts, and restart policies.
Application services
quadlets/llm-routing-langfuse-*.container, quadlets/llm-routing-litellm.container, quadlets/llm-routing-router.container
Adds Langfuse, LiteLLM, and router containers with dependency wiring, runtime configuration, mounts, health checks, and restart behavior.
Quadlet deployment lifecycle
start-stack.sh
Renders and installs Quadlet templates, deploys fresh stacks through systemd, and prefers systemd for restart and teardown paths.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant start-stack.sh
  participant UserSystemd
  participant llm-routing-pod.service
  participant Podman
  start-stack.sh->>start-stack.sh: Render Quadlet templates
  start-stack.sh->>UserSystemd: Reload user daemon
  start-stack.sh->>UserSystemd: Start or restart pod service
  UserSystemd->>llm-routing-pod.service: Activate pod unit
  llm-routing-pod.service->>Podman: Start container units
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: migrating stack management from podman play kube to systemd Quadlets.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quadlet-systemd

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 1 issue, and left some high level feedback:

  • The Python in render_quadlets assumes all required env vars are present (e.g., POSTGRES_PASSWORD, OLLAMA_API_KEY); consider adding a small upfront validation block that fails with a clear message before doing replacements to avoid cryptic KeyError traces.
  • deploy_quadlets exits the entire stack script if systemctl --user daemon-reload fails; if you expect some environments without a user systemd, consider a more explicit guard/diagnostic early in the script or a fallback path to keep behavior predictable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The Python in render_quadlets assumes all required env vars are present (e.g., POSTGRES_PASSWORD, OLLAMA_API_KEY); consider adding a small upfront validation block that fails with a clear message before doing replacements to avoid cryptic KeyError traces.
- deploy_quadlets exits the entire stack script if systemctl --user daemon-reload fails; if you expect some environments without a user systemd, consider a more explicit guard/diagnostic early in the script or a fallback path to keep behavior predictable.

## Individual Comments

### Comment 1
<location path="start-stack.sh" line_range="539-545" />
<code_context>
 safe_pod_teardown() {
+    # If the stack is systemd-managed (quadlets), stop via systemd so the
+    # manager state stays consistent; then remove any leftover pod.
+    if systemctl --user is-active --quiet llm-routing-pod.service 2>/dev/null; then
+        echo "🛑 Stopping systemd-managed stack (llm-routing-pod.service)..."
+        systemctl --user stop llm-routing-pod.service || true
+        podman pod rm -f ${POD_NAME} 2>/dev/null || true
+        cleanup_zombie_ports
+        echo "✓ Stack stopped, ports cleaned"
+        return
+    fi
     if podman pod exists ${POD_NAME} 2>/dev/null; then
</code_context>
<issue_to_address>
**issue (bug_risk):** Systemd-based teardown ignores stop failures and proceeds as if the stack stopped cleanly.

In the systemd branch of `safe_pod_teardown`, `systemctl --user stop llm-routing-pod.service || true` ignores stop failures and we immediately proceed to remove the pod and clean up ports. If the unit hangs or fails to stop, this can leave systemd thinking the service is still active while the pod is gone. Please add a check (e.g., `systemctl --user is-active` with a timeout) before removing the pod, or at minimum log a warning when the stop fails so operators know systemd state may be out of sync with podman.
</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
Comment on lines +539 to +545
if systemctl --user is-active --quiet llm-routing-pod.service 2>/dev/null; then
echo "🛑 Stopping systemd-managed stack (llm-routing-pod.service)..."
systemctl --user stop llm-routing-pod.service || true
podman pod rm -f ${POD_NAME} 2>/dev/null || true
cleanup_zombie_ports
echo "✓ Stack stopped, ports cleaned"
return

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): Systemd-based teardown ignores stop failures and proceeds as if the stack stopped cleanly.

In the systemd branch of safe_pod_teardown, systemctl --user stop llm-routing-pod.service || true ignores stop failures and we immediately proceed to remove the pod and clean up ports. If the unit hangs or fails to stop, this can leave systemd thinking the service is still active while the pod is gone. Please add a check (e.g., systemctl --user is-active with a timeout) before removing the pod, or at minimum log a warning when the stop fails so operators know systemd state may be out of sync with podman.

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

🧹 Nitpick comments (2)
quadlets/llm-routing-litellm.container (1)

3-4: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Both LiteLLM and the router reference Langfuse without an ordering dependency on it.

Both containers set LANGFUSE_HOST (and the router additionally sets LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY), but neither lists llm-routing-langfuse-web.service in After=/Wants=. This is unlikely to be fatal since Langfuse SDKs typically retry in the background, but it means both services can start before Langfuse is reachable, risking dropped early traces and noisier startup logs.

  • quadlets/llm-routing-litellm.container#L3-L4: add llm-routing-langfuse-web.service to After=/Wants=.
  • quadlets/llm-routing-router.container#L3-L4: add llm-routing-langfuse-web.service to After=/Wants=.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@quadlets/llm-routing-litellm.container` around lines 3 - 4, Update the
[After] and [Wants] dependency declarations in
quadlets/llm-routing-litellm.container (lines 3-4) and
quadlets/llm-routing-router.container (lines 3-4) to include
llm-routing-langfuse-web.service alongside the existing services, ensuring both
containers wait for and request Langfuse startup.
quadlets/llm-routing-langfuse-worker.container (1)

22-22: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use the Langfuse worker health endpoint instead of pgrep node.

The worker image is already configured with HOST/PORT and HealthCmd=pgrep node only checks whether the Node process exists. Use the worker’s HTTP health endpoint at the configured port (for example curl -f http://127.0.0.1:${LANGFUSE_WORKER_PORT_PLACEHOLDER}/api/health) so the Quadlet healthcheck is aligned with the Restart=always recovery behavior. Also update the README entry for langfuse-worker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@quadlets/llm-routing-langfuse-worker.container` at line 22, Replace the
Langfuse worker container’s HealthCmd pgrep-based check with an HTTP request to
the worker’s /api/health endpoint on the configured
LANGFUSE_WORKER_PORT_PLACEHOLDER, failing when the request is unsuccessful.
Update the corresponding langfuse-worker README entry to document the new
healthcheck.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@quadlets/llm-routing-litellm.container`:
- Around line 21-26: Add LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY placeholder
environment variables to the LiteLLM Quadlet’s existing environment block,
matching the names and placeholder values used by pod.yaml and
llm-routing-router.container while preserving the existing LANGFUSE_HOST entry.

In `@quadlets/llm-routing-minio.container`:
- Line 18: Update the HealthCmd in the MinIO container definition to query
MinIO’s readiness endpoint at /minio/health/ready instead of the liveness
endpoint, preserving the existing host and port configuration.

In `@quadlets/llm-routing-postgres.container`:
- Line 17: Update the HealthCmd in the postgres container configuration to pass
the TCP host option and target 127.0.0.1, while preserving the existing postgres
user and port placeholder so the health probe checks the same endpoint used by
the application containers.

In `@quadlets/llm-routing-valkey-lf.container`:
- Line 14: Remove REDIS_AUTH_PLACEHOLDER from the HealthCmd in
quadlets/llm-routing-valkey-lf.container:14 and provide authentication through
an environment-backed input such as VALKEYCLI_AUTH. In
quadlets/llm-routing-clickhouse.container:18, replace the direct
CLICKHOUSE_PASSWORD_PLACEHOLDER command-line argument with secret-backed file or
wrapper authentication.

In `@start-stack.sh`:
- Around line 834-847: Update render_quadlets() so rendered Quadlet files
containing substituted secrets are written with owner-only permissions instead
of 0o644, and ensure the Quadlet output directory and relevant parent directory
are restricted to the owner where this function creates or manages them.
Preserve the existing rendering and placeholder-validation behavior while
preventing other local users from reading the generated units or listing their
filenames.
- Around line 759-849: Update render_quadlets() so values containing percent
signs are escaped for systemd before substitution into .pod and .container
templates, converting each literal % to %%. Apply this to rendered secret values
while preserving existing placeholder replacement and unresolved-placeholder
validation.

---

Nitpick comments:
In `@quadlets/llm-routing-langfuse-worker.container`:
- Line 22: Replace the Langfuse worker container’s HealthCmd pgrep-based check
with an HTTP request to the worker’s /api/health endpoint on the configured
LANGFUSE_WORKER_PORT_PLACEHOLDER, failing when the request is unsuccessful.
Update the corresponding langfuse-worker README entry to document the new
healthcheck.

In `@quadlets/llm-routing-litellm.container`:
- Around line 3-4: Update the [After] and [Wants] dependency declarations in
quadlets/llm-routing-litellm.container (lines 3-4) and
quadlets/llm-routing-router.container (lines 3-4) to include
llm-routing-langfuse-web.service alongside the existing services, ensuring both
containers wait for and request Langfuse startup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: abb7e713-3eb3-4da0-b6e8-a1fb9f5b42ff

📥 Commits

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

📒 Files selected for processing (11)
  • 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
  • start-stack.sh

Comment on lines +21 to +26
Environment=LANGFUSE_HOST=http://127.0.0.1:LANGFUSE_WEB_PORT_PLACEHOLDER
Environment=LITELLM_MASTER_KEY=LITELLM_MASTER_KEY_PLACEHOLDER
Environment=LITELLM_UI_USERNAME=LITELLM_UI_USERNAME_PLACEHOLDER
Environment=LITELLM_UI_PASSWORD=LITELLM_UI_PASSWORD_PLACEHOLDER
Environment=OLLAMA_API_KEY=OLLAMA_API_KEY_PLACEHOLDER
Environment=OPENROUTER_API_KEY=OPENROUTER_API_KEY_PLACEHOLDER

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare litellm env vars in canonical pod.yaml vs the new litellm.container
rg -n -A 40 'name:\s*litellm' pod.yaml | rg -n 'LANGFUSE'

Repository: sheepdestroyer/LLM-Routing

Length of output: 446


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== container files =="
fd -a 'llm-routing-litellm.*container|pod.yaml' . | sed 's#^\./##'

echo
echo "== quadlet env context =="
fd -a 'llm-routing-litellm.*container' . | while read -r f; do
  echo "--- $f"
  nl -ba "$f" | sed -n '1,80p'
done

echo
echo "== pod.yaml litellm env context =="
nl -ba pod.yaml | sed -n '100,135p'

echo
echo "== all LANGFUSE occurrences =="
rg -n 'LANGFUSE_(HOST|PUBLIC_KEY|SECRET_KEY)_PLACEHOLDER|LANGFUSE_PUBLIC_KEY|LANGFUSE_SECRET_KEY|openai_callback|langfuse' .

Repository: sheepdestroyer/LLM-Routing

Length of output: 402


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== container files =="
readarray -t files < <(find . \( -name 'llm-routing-litellm.*container' -o -name 'pod.yaml' \) -print | sort)
printf '%s\n' "${files[@]}"

echo
echo "== quadlet env context =="
while IFS= read -r f; do
  echo "--- $f"
  awk '{printf "%6d\t%s\n", NR, $0}' "$f" | sed -n '1,80p'
done < <(find . -name 'llm-routing-litellm.*container' -print | sort)

echo
echo "== pod.yaml litellm env context =="
awk '{printf "%6d\t%s\n", NR, $0}' pod.yaml | sed -n '100,135p'

echo
echo "== all LANGFUSE occurrences =="
rg -n 'LANGFUSE_(HOST|PUBLIC_KEY|SECRET_KEY)_PLACEHOLDER|LANGFUSE_PUBLIC_KEY|LANGFUSE_SECRET_KEY|openai_callback|langfuse|callback' .

Repository: sheepdestroyer/LLM-Routing

Length of output: 238


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== container files =="
find . \( -name 'llm-routing-litellm.*container' -o -name 'pod.yaml' \) -print | sort

echo
echo "== quadlet env context =="
while IFS= read -r f; do
  if [[ "$f" == *llm-routing-litellm.container ]]; then
    echo "--- $f"
    awk '{printf "%6d\t%s\n", NR, $0}' "$f" | sed -n '1,80p'
  fi
done < <(find . -name 'llm-routing-litellm.*container' -print | sort)

tmp=$(mktemp)
find . -name 'pod.yaml' -print | while IFS= read -r f; do echo "--- $f"; awk '{printf "%6d\t%s\n", NR, $0}' "$f" | sed -n '100,135p'; done

echo
echo "== all LANGFUSE occurrences =="
rg -n 'LANGFUSE_(HOST|PUBLIC_KEY|SECRET_KEY)_PLACEHOLDER|LANGFUSE_PUBLIC_KEY|LANGFUSE_SECRET_KEY|openai_callback|langfuse|callback' .
rm -f "$tmp"

Repository: sheepdestroyer/LLM-Routing

Length of output: 318


🏁 Script executed:

#!/bin/bash
set -u

echo "== quadlet env context =="
awk '{printf "%6d\t%s\n", NR, $0}' quadlets/llm-routing-litellm.container | sed -n '1,80p'

echo
echo "== pod.yaml litellm env context =="
awk '{printf "%6d\t%s\n", NR, $0}' pod.yaml | sed -n '100,135p'

echo
echo "== all LANGFUSE occurrences =="
rg -n 'LANGFUSE_(HOST|PUBLIC_KEY|SECRET_KEY)_PLACEHOLDER|LANGFUSE_PUBLIC_KEY|LANGFUSE_SECRET_KEY|openai_callback|langfuse|callback' . || true

Repository: sheepdestroyer/LLM-Routing

Length of output: 17615


🏁 Script executed:

#!/bin/bash
set -u

echo "== litellm config callbacks =="
awk '{printf "%6d\t%s\n", NR, $0}' litellm/config.yaml | sed -n '1,80p'

echo
echo "== router Langfuse init =="
awk '{printf "%6d\t%s\n", NR, $0}' router/main.py | sed -n '288,318p'

echo
echo "== router LiteLLM callback env handling =="
rg -n -C 5 'callbacks|langfuse|OPENAI_API_BASE_URL|base_url|litellm' router/main.py | sed -n '2484,2590p'

Repository: sheepdestroyer/LLM-Routing

Length of output: 4640


🌐 Web query:

LiteLLM langfuse callback environment variables LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_HOST

💡 Result:

When using LiteLLM with Langfuse, the environment variables and configuration depend on whether you are using the standard Langfuse integration or the Langfuse OpenTelemetry (OTEL) integration [1][2][3]. Standard Langfuse Integration For the standard callback integration (enabled via litellm.success_callback = ["langfuse"]), the following environment variables are used [1][4]: - LANGFUSE_PUBLIC_KEY: Your Langfuse project public key [1][4]. - LANGFUSE_SECRET_KEY (or LANGFUSE_SECRET): Your Langfuse project secret key [1][4]. - LANGFUSE_HOST: Optional. The host URL for your Langfuse instance (e.g., https://us.cloud.langfuse.com) [1][4]. If omitted, it defaults to the Langfuse cloud [1][4]. You can also pass these credentials as keyword arguments directly to the completion or acompletion functions to override environment variables on a per-request basis [1][4]. Langfuse OpenTelemetry (OTEL) Integration When using the OpenTelemetry integration (enabled via litellm.callbacks = ["langfuse_otel"]), the variable for the host changes [2][3]: - LANGFUSE_PUBLIC_KEY: Required [5][3]. - LANGFUSE_SECRET_KEY: Required [5][3]. - LANGFUSE_OTEL_HOST: Optional. The endpoint host for Langfuse OTEL (e.g., https://us.cloud.langfuse.com) [2][3]. This variable is specific to the OTEL integration [5][3]. Summary Table Integration Type | Public Key Var | Secret Key Var | Host Var --- | --- | --- | --- Standard | LANGFUSE_PUBLIC_KEY | LANGFUSE_SECRET_KEY | LANGFUSE_HOST OTEL | LANGFUSE_PUBLIC_KEY | LANGFUSE_SECRET_KEY | LANGFUSE_OTEL_HOST For detailed setup, consult the official LiteLLM documentation for Langfuse integration [4] or the Langfuse OpenTelemetry integration documentation [3].

Citations:


Add the missing Langfuse credentials to the LiteLLM Quadlet.

litellm/config.yaml enables callbacks: [- langfuse], and LiteLLM’s standard Langfuse callback needs LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY in addition to LANGFUSE_HOST; without them, LiteLLM traces won’t authenticate. Match pod.yaml/llm-routing-router.container by adding the two placeholder env vars to this container.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@quadlets/llm-routing-litellm.container` around lines 21 - 26, Add
LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY placeholder environment variables to
the LiteLLM Quadlet’s existing environment block, matching the names and
placeholder values used by pod.yaml and llm-routing-router.container while
preserving the existing LANGFUSE_HOST entry.

# the same way — image entrypoint stays, Exec provides the server args.
Exec=server --quiet /data --address :MINIO_S3_PORT_PLACEHOLDER --console-address :MINIO_CONSOLE_PORT_PLACEHOLDER
Volume=DATA_ROOT_PLACEHOLDER/minio-data:/data
HealthCmd=curl -f http://127.0.0.1:MINIO_S3_PORT_PLACEHOLDER/minio/health/live

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files mentioning MinIO health endpoints or quadlet HealthCmd:"
rg -n -C 3 "MinIO|MINIO_S3_PORT_PLACEHOLDER|/minio/health/(live|ready)|HealthCmd|health/live|health/ready" . || true

echo
echo "Quadlet file:"
cat -n quadlets/llm-routing-minio.container || true

echo
echo "README health endpoint sections:"
if [ -f README.md ]; then
  nl -ba README.md | sed -n '/minio/health/p; /MinIO/p; /health/p' | head -120
fi

Repository: sheepdestroyer/LLM-Routing

Length of output: 20677


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate service dependency/usages of this quadlet or container services:"
rg -n -C 3 "llm-routing-minio|minio|MINIO|S3_ADDRESS|Langfuse|language-model|routing" . --glob '!/.git/**' || true

Repository: sheepdestroyer/LLM-Routing

Length of output: 50385


Use MinIO readiness for the component healthcheck.

README.md distinguishes MinIO readiness at /minio/health/ready, and pod.yaml uses that path for MinIO readiness while using /minio/health/live only for liveness. This Quadlet should use /ready here to avoid reporting MinIO healthy before its S3 readiness is signaled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@quadlets/llm-routing-minio.container` at line 18, Update the HealthCmd in the
MinIO container definition to query MinIO’s readiness endpoint at
/minio/health/ready instead of the liveness endpoint, preserving the existing
host and port configuration.

Environment=PGDATA=/var/lib/postgresql/data/pg18
Environment=PGPORT=POSTGRES_PORT_PLACEHOLDER
Volume=DATA_ROOT_PLACEHOLDER/postgres-data:/var/lib/postgresql
HealthCmd=pg_isready -U postgres -p POSTGRES_PORT_PLACEHOLDER

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)llvm-routing-postgres\.container$|llm-routing-postgres\.container$' || true

echo "== file contents =="
if [ -f quadlets/llm-routing-postgres.container ]; then
  cat -n quadlets/llm-routing-postgres.container
fi

echo "== pg_isready occurrences =="
rg -n "pg_isready|HealthCmd|POSTGRES_PORT_PLACEHOLDER|127\.0\.0\.1|postgres" quadlets . 2>/dev/null | head -200

Repository: sheepdestroyer/LLM-Routing

Length of output: 21577


🌐 Web query:

PostgreSQL pg_isready documentation host Unix socket path default when -host/port options

💡 Result:

For the PostgreSQL utility pg_isready, the -h (or --host) option specifies the database server host. If the provided value begins with a forward slash (/), it is interpreted as the directory containing the Unix-domain socket [1][2]. If no -h option is provided, pg_isready defaults to the behavior defined by the underlying libpq connection library [3]. Typically, this default target is the standard Unix-domain socket directory, which is defined at compile time (often /tmp or /var/run/postgresql on many Unix-like systems) [1][4][3]. Regarding other connection options: - Port: If the -p (or --port) option is not specified, it defaults to the value of the PGPORT environment variable. If that variable is not set, it defaults to the port specified at compile time, which is usually 5432 [1][2][5]. In summary, when no host is specified, pg_isready relies on the default connection parameters established by libpq, which prioritizes the compile-time default Unix-domain socket path [3].

Citations:


🌐 Web query:

PostgreSQL pg_isready --host option Unix socket directory slash documentation -h

💡 Result:

The --host (or -h) option for the pg_isready utility is used to specify the database server host [1][2]. Crucially, if the value provided to the --host option begins with a forward slash (/), pg_isready interprets it as the directory path for the Unix-domain socket [1][3][2]. For example, providing /tmp to the host option instructs the utility to look for the socket file within that directory [1][2]. The default behavior when no host is specified generally follows standard PostgreSQL client connection rules, often defaulting to the local Unix-domain socket directory if applicable (typically /tmp on many Unix-like systems) [1]. You can also use the --port (or -p) option to specify the TCP port or the local Unix-domain socket file extension [1][2].

Citations:


Probe the TCP endpoint used by the app containers.

LiteLLM, Langfuse, and the router all connect via postgresql://...@127.0.0.1:POSTGRES_PORT_PLACEHOLDER/...; without -h, pg_isready can check the Unix socket instead. Use pg_isready -h 127.0.0.1 -U postgres -p POSTGRES_PORT_PLACEHOLDER.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@quadlets/llm-routing-postgres.container` at line 17, Update the HealthCmd in
the postgres container configuration to pass the TCP host option and target
127.0.0.1, while preserving the existing postgres user and port placeholder so
the health probe checks the same endpoint used by the application containers.

Entrypoint=redis-server
Exec=--bind 127.0.0.1 --port VALKEY_LF_PORT_PLACEHOLDER --requirepass REDIS_AUTH_PLACEHOLDER --maxmemory-policy noeviction --loglevel warning
Volume=DATA_ROOT_PLACEHOLDER/redis-lf-data:/data
HealthCmd=valkey-cli -p VALKEY_LF_PORT_PLACEHOLDER -a REDIS_AUTH_PLACEHOLDER ping

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate quadlets files =="
fd -a 'llm-routing-(valkey-lf|clickhouse)\.container$' . || true

echo "== Relevant lines =="
for f in $(fd 'llm-routing-(valkey-lf|clickhouse)\.container$' .); do
  echo "--- $f ---"
  nl -ba "$f" | sed -n '1,80p'
done

echo "== References to placeholders =="
rg -n "REDIS_AUTH_PLACEHOLDER|CLICKHOUSE_PASSWORD_PLACEHOLDER|HealthCmd|VALKEYCLI_AUTH|CLICKHOUSE" . || true

echo "== Repository files around quadlets =="
git ls-files | rg '(^|/)quadlets/|\.env|container|compose' | head -200

Repository: sheepdestroyer/LLM-Routing

Length of output: 427


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Relevant lines =="
for f in ./quadlets/llm-routing-clickhouse.container ./quadlets/llm-routing-valkey-lf.container; do
  echo "--- $f ---"
  awk '{printf "%5d  %s\n", NR, $0}' "$f" | sed -n '1,120p'
done

echo "== References to placeholders in quadlets =="
rg -n "REDIS_AUTH_PLACEHOLDER|CLICKHOUSE_PASSWORD_PLACEHOLDER|HealthCmd|VALKEYCLI_AUTH|CLICKHOUSE" ./quadlets || true

echo "== Nearby quadlets files =="
awk '{printf "%5d  %s\n", NR, $0}' ./quadlets/llm-routing-clickhouse.container

Repository: sheepdestroyer/LLM-Routing

Length of output: 6616


Do not pass credentials as healthcheck command-line arguments.

Both templates render secrets into HealthCmd, exposing them through generated unit metadata and process diagnostics.

  • quadlets/llm-routing-valkey-lf.container#L14: use an environment-backed auth input (for example, set VALKEYCLI_AUTH in the container environment) and remove -a REDIS_AUTH_PLACEHOLDER.
  • quadlets/llm-routing-clickhouse.container#L18: use a secret-backed file or wrapper instead of passing CLICKHOUSE_PASSWORD_PLACEHOLDER directly to clickhouse-client.
📍 Affects 2 files
  • quadlets/llm-routing-valkey-lf.container#L14-L14 (this comment)
  • quadlets/llm-routing-clickhouse.container#L18-L18
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@quadlets/llm-routing-valkey-lf.container` at line 14, Remove
REDIS_AUTH_PLACEHOLDER from the HealthCmd in
quadlets/llm-routing-valkey-lf.container:14 and provide authentication through
an environment-backed input such as VALKEYCLI_AUTH. In
quadlets/llm-routing-clickhouse.container:18, replace the direct
CLICKHOUSE_PASSWORD_PLACEHOLDER command-line argument with secret-backed file or
wrapper authentication.

Comment thread start-stack.sh
Comment on lines +759 to +849
render_quadlets() {
export WORKDIR HOME LITELLM_MASTER_KEY UI_USERNAME UI_PASSWORD
export POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY
export LANGFUSE_INIT_USER_PASSWORD MINIO_ROOT_USER MINIO_ROOT_PASSWORD
export OLLAMA_API_KEY OPENROUTER_API_KEY LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY
export CLASSIFIER_INPUT_MAX_CHARS REDIS_AUTH CLICKHOUSE_PASSWORD
export PUBLIC_BASE_URL ROUTING_DOMAIN POD_NAME DATA_ROOT
export ROUTER_IMAGE ROUTER_PORT LITELLM_PORT LANGFUSE_WEB_PORT
export LANGFUSE_WORKER_PORT POSTGRES_PORT VALKEY_CACHE_PORT VALKEY_LF_PORT
export CLICKHOUSE_HTTP_PORT CLICKHOUSE_TCP_PORT CLICKHOUSE_INTERSERVER_PORT
export MINIO_S3_PORT MINIO_CONSOLE_PORT
local src_dir="${WORKDIR}/quadlets"
mkdir -p "$QUADLET_DIR"
python3 - "$src_dir" "$QUADLET_DIR" <<'PY'
import os, sys, urllib.parse, re, glob
uid = os.getuid()
src_dir, out_dir = sys.argv[1], sys.argv[2]

encoded_pg = urllib.parse.quote(os.environ['POSTGRES_PASSWORD'], safe="")
# Derive PROXY_BASE_URL / NEXTAUTH_URL exactly as render_pod_yaml does
# (path-based on master; subdomain switch lands with the routing branch)
public_base_url = os.environ["PUBLIC_BASE_URL"].rstrip("/")
proxy_base_url = f"{public_base_url}/litellm"
nextauth_url = f"{public_base_url}/langfuse"

repl = {
"WORKDIR_PLACEHOLDER": os.environ["WORKDIR"],
"HOME_PLACEHOLDER": os.environ["HOME"],
"RUN_USER_PLACEHOLDER": f"/run/user/{uid}",
"LITELLM_MASTER_KEY_PLACEHOLDER": os.environ["LITELLM_MASTER_KEY"],
"LITELLM_UI_USERNAME_PLACEHOLDER": os.environ.get("UI_USERNAME") or "admin",
"LITELLM_UI_PASSWORD_PLACEHOLDER": os.environ.get("UI_PASSWORD") or os.environ.get("LITELLM_MASTER_KEY") or "admin",
"POSTGRES_PASSWORD_RAW_PLACEHOLDER": os.environ["POSTGRES_PASSWORD"],
"POSTGRES_PASSWORD_ENCODED_PLACEHOLDER": encoded_pg,
"NEXTAUTH_SECRET_PLACEHOLDER": os.environ["NEXTAUTH_SECRET"],
"NEXTAUTH_URL_PLACEHOLDER": nextauth_url,
"SALT_PLACEHOLDER": os.environ["SALT"],
"ENCRYPTION_KEY_PLACEHOLDER": os.environ["ENCRYPTION_KEY"],
"OLLAMA_API_KEY_PLACEHOLDER": os.environ["OLLAMA_API_KEY"],
"OPENROUTER_API_KEY_PLACEHOLDER": os.environ["OPENROUTER_API_KEY"],
"LANGFUSE_PUBLIC_KEY_PLACEHOLDER": os.environ["LANGFUSE_PUBLIC_KEY"],
"LANGFUSE_SECRET_KEY_PLACEHOLDER": os.environ["LANGFUSE_SECRET_KEY"],
"MINIO_USER_PLACEHOLDER": os.environ["MINIO_ROOT_USER"],
"MINIO_PASSWORD_PLACEHOLDER": os.environ["MINIO_ROOT_PASSWORD"],
"LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER": os.environ["LANGFUSE_INIT_USER_PASSWORD"],
"REDIS_AUTH_PLACEHOLDER": os.environ["REDIS_AUTH"],
"CLICKHOUSE_PASSWORD_PLACEHOLDER": os.environ["CLICKHOUSE_PASSWORD"],
"PROXY_BASE_URL_PLACEHOLDER": proxy_base_url,
"PUBLIC_BASE_URL_PLACEHOLDER": public_base_url,
"ROUTING_DOMAIN_PLACEHOLDER": os.environ["ROUTING_DOMAIN"],
"POD_NAME_PLACEHOLDER": os.environ["POD_NAME"],
"DATA_ROOT_PLACEHOLDER": os.environ["DATA_ROOT"],
"ROUTER_IMAGE_PLACEHOLDER": os.environ["ROUTER_IMAGE"],
"ROUTER_PORT_PLACEHOLDER": os.environ["ROUTER_PORT"],
"LITELLM_PORT_PLACEHOLDER": os.environ["LITELLM_PORT"],
"LANGFUSE_WEB_PORT_PLACEHOLDER": os.environ["LANGFUSE_WEB_PORT"],
"LANGFUSE_WORKER_PORT_PLACEHOLDER": os.environ["LANGFUSE_WORKER_PORT"],
"POSTGRES_PORT_PLACEHOLDER": os.environ["POSTGRES_PORT"],
"VALKEY_CACHE_PORT_PLACEHOLDER": os.environ["VALKEY_CACHE_PORT"],
"VALKEY_LF_PORT_PLACEHOLDER": os.environ["VALKEY_LF_PORT"],
"CLICKHOUSE_HTTP_PORT_PLACEHOLDER": os.environ["CLICKHOUSE_HTTP_PORT"],
"CLICKHOUSE_TCP_PORT_PLACEHOLDER": os.environ["CLICKHOUSE_TCP_PORT"],
"MINIO_S3_PORT_PLACEHOLDER": os.environ["MINIO_S3_PORT"],
"MINIO_CONSOLE_PORT_PLACEHOLDER": os.environ["MINIO_CONSOLE_PORT"],
}

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)

# 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:
with open(tpl, "r", encoding="utf-8") as f:
text = f.read()
for ph, val in repl.items():
text = text.replace(ph, str(val))
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")
sys.exit(1)
out_path = os.path.join(out_dir, os.path.basename(tpl))
with open(out_path, "w", encoding="utf-8") as f:
f.write(text)
os.chmod(out_path, 0o644)
print(f" ✓ {os.path.basename(tpl)}")
PY
echo "✓ Quadlets rendered to ${QUADLET_DIR}"

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how secrets are generated in start-stack.sh to see if '%' can appear
rg -n 'POSTGRES_PASSWORD=|LITELLM_MASTER_KEY=|openssl rand|pwgen|tr -dc' start-stack.sh | head -30

Repository: sheepdestroyer/LLM-Routing

Length of output: 774


🏁 Script executed:

#!/bin/bash
set -e

# Inspect the secret generation helper and related exports/call sites to determine whether '%' can appear.
sed -n '140,235p' start-stack.sh
printf '\n--- render_quadlets call context ---\n'
sed -n '710,870p' start-stack.sh
printf '\n--- secret generation mentions ---\n'
rg -n 'generate_random|openssl rand|POSTGRES_PASSWORD|LITELLM_MASTER_KEY|NEXTAUTH_SECRET|SALT|ENCRYPTION_KEY|OLLAMA_API_KEY|OPENROUTER_API_KEY|LANGFUSE_|MINIO_ROOT_|REDIS_AUTH|CLICKHOUSE_PASSWORD|ROUTER_PORT|LITELLM_PORT|LANGFUSE_WEB_PORT|LANGFUSE_WORKER_PORT|POSTGRES_PORT|VALKEY_|CLICKHOUSE_HTTP_PORT|CLICKHOUSE_TCP_PORT|CLICKHOUSE_INTERSERVER_PORT|MINIO_S3_PORT|MINIO_CONSOLE_PORT' start-stack.sh | head -80

Repository: sheepdestroyer/LLM-Routing

Length of output: 16787


🏁 Script executed:

#!/bin/bash
set -e

# Behavioral check: run the script's random functions in the same env where bash is invoked, if available.
if command -v bash >/dev/null 2>&1; then
  bash - <<'BASH'
set -E
gen_random() {
  local bytecount="$1"
  local trytype="$2"
  case "$trytype" in
    hex)
      local val
      try=0
      while true; do
        ((try>=3)) && return 1
        val=$(openssl rand -hex "$bytecount" 2>/dev/null)
        status=$?
        ((status != 0)) && continue
        case "$val" in
          *[![:alnum:]]*) continue;;
          *) echo "$val"; return 0;;
        esac
      done
      ;;
    base64)
      local val
      try=0
      while true; do
        ((try>=3)) && return 1
        val=$(openssl rand -base64 "$bytecount" 2>/dev/null)
        status=$?
        ((status != 0)) && continue
        case "$val" in
          *[!A-Za-z0-9+/=]*|*_*|*-*|*.*) continue;;
          *) echo "$val"; return 0;;
        esac
      done
      ;;
  esac
}
printf 'hex 16 contains %% count: '
for _ in $(seq 1 200); do printf '%s\n' "$(gen_random 16 hex)" | tr -cd '%' | wc -c; done | awk '{s+=$1} END{print s}'
printf 'base64 24 contains %% count: '
for _ in $(seq 1 200); do printf '%s\n' "$(gen_random 24 base64)" | grep -c '%' || true; done | awk '{s+=$1} END{print s}'
BASH
else
  echo 'bash command unavailable'
fi

# Show whether current Git diff modifies render_quadlets / secret generation.
printf '\n--- diff stat ---\n'
git diff --stat || true
printf '\n--- diff sections ---\n'
git diff -- start-stack.sh | sed -n '/^diff --git a\/start-stack.sh/,/^diff --git/p' | sed -n '1,260p' || true

Repository: sheepdestroyer/LLM-Routing

Length of output: 337


🏁 Script executed:

#!/bin/bash
set -e

# Run a closed, deterministic probe of the script's secret-generation branches and render behavior.
python3 - <<'PY'
import re, sys

with open("start-stack.sh", encoding="utf-8") as f:
    src = f.read()

# Parse the relevant generate_env_setup shell function and read secret sources from the script.
gen_setup = re.search(r"(if \[ -z \"\$POSTGRES_PASSWORD\" \); then(?:.|\n)*?if deploy_fresh_pod", src)
print("contains gen_setup parse target:", bool(gen_setup))

secret_repl = re.search(r"(render_quadlets\(.*?PY\n\s*\)", src, re.DOTALL)
print("render_quadlets placeholder count:", len(list(re.finditer(r"'([A-Z0-9_]+_PLACEHOLDER)'|\[([A-Z0-9_]+_PLACEHOLDER)\]", src[secret_repl.start():secret_repl.end()]))) if secret_repl else 0)

def scan_secret_assignment(name):
    m = re.search(r"^[ \t]*if [ \t]*\[ -z \"\$\{" + name + r"\}" + r"[^\n]*(\n[^\n]*)*?^[ \t]*(" + name + r"=)", src, re.M)
    if not m:
        return None
    block = src[m.start():]
    # Show first 8 lines of assignment context.
    lines = block.splitlines()[:8]
    print(f"\n{name} assignment context:")
    for line in lines:
        print(line)

scan_secret_assignment("NEXTAUTH_SECRET")
scan_secret_assignment("SALT")
scan_secret_assignment("ENCRYPTION_KEY")
scan_secret_assignment("OLLAMA_API_KEY")
scan_secret_assignment("OPENROUTER_API_KEY")
scan_secret_assignment("LANGFUSE_PUBLIC_KEY")
scan_secret_assignment("LANGFUSE_SECRET_KEY")
scan_secret_assignment("MINIO_ROOT_USER")
scan_secret_assignment("MINIO_ROOT_PASSWORD")
scan_secret_assignment("LANGFUSE_INIT_USER_PASSWORD")
scan_secret_assignment("REDIS_AUTH")
scan_secret_assignment("CLICKHOUSE_PASSWORD")
scan_secret_assignment("PUBLIC_BASE_URL")

# Check whether the exact current secret generators contain '%' in generated output based on regex charset rules.
print("\nGenerated secret line chars contain % by generated-source grep:", bool(re.search(r'("|\x27).*%.*("|\x27)\.|^\s*"[^"]*%|^\x27[^\'%]*\x27', src)))
PY

# Parse and simulate exact secret assignments with the script's deterministic random-like generation.
python3 - <<'PY'
import re

src = open("start-stack.sh", encoding="utf-8").read()
m = re.search(r"if \[ -z \"\$POSTGRES_PASSWORD\" \]; then(?:.|\n)*?LANGFUSE_SECRET_KEY=sk-lf-\$uuid", src)
block = src[m.start():m.end()]

imports = []
assigns = []
for line in block.splitlines():
    if line.startswith("import ") or line.startswith("from "):
        imports.append(line)
    if "gen_hex" in line or "gen_base64" in line or line.startswith("NEXTAUTH_SECRET=") or line.startswith("SALT=") or line.startswith("ENCRYPTION_KEY=") or line.startswith("OLLAMA_API_KEY=") or line.startswith("OPENROUTER_API_KEY=") or line.startswith("LANGFUSE_PUBLIC_KEY=") or line.startswith("LANGFUSE_SECRET_KEY=") or line.startswith("MINIO_ROOT_USER=") or line.startswith("LANGFUSE_INIT_USER_PASSWORD=") or line.startswith("REDIS_AUTH=") or line.startswith("CLICKHOUSE_PASSWORD="):
        assigns.append(line)

env = {}
for line in imports:
    exec(line, env)

def gen_hex(bytecount):
    val = env["os"].urandom(bytecount).hex()
    if not re.fullmatch(r"[0-9a-fA-F]+", val) or len(val) != bytecount * 2:
        raise SystemExit("invalid gen_hex")
    return val

def gen_base64(bytecount):
    import base64
    val = base64.b64encode(env["os"].urandom(bytecount)).decode("ascii")
    if not re.fullmatch(r"[A-Za-z0-9+/=]+", val):
        raise SystemExit("invalid gen_base64")
    return val

def gen_uuid():
    val = gen_hex(16)
    return f"{val[0:8]}-{val[8:12]}-{val[12:16]}-{val[16:20]}-{val[20:32]}"

for line in assigns:
    if "gen_hex" in line or "gen_base64" in line or line.startswith("NEXTAUTH_SECRET=") or line.startswith("SALT=") or line.startswith("ENCRYPTION_KEY=") or line.startswith("OLLAMA_API_KEY=") or line.startswith("OPENROUTER_API_KEY=") or line.startswith("LANGFUSE_PUBLIC_KEY=") or line.startswith("LANGFUSE_SECRET_KEY=") or line.startswith("MINIO_ROOT_USER=") or line.startswith("LANGFUSE_INIT_USER_PASSWORD=") or line.startswith("REDIS_AUTH=") or line.startswith("CLICKHOUSE_PASSWORD="):
        name = re.search(r"^(\w+)=", line).group(1)
        env[name], line = line, line.replace("$(gen_hex", "gen_hex").replace("$(gen_base64", "gen_base64").replace("$(gen_uuid", "gen_uuid").replace(") || exit", "")
        # Remove call-site shell fragments to simulate in Python.
        line = re.sub(r'\)\s*\|\|\s*exit.*$', "", line)
        line = re.sub(r'"\$\{([A-Fa-f0-9:]+)\:]', r'"{\1[:', line)
        line = line.replace('="${uuid:0:8}-${uuid:8:4}-${uuid:12:4}-${uuid:16:4}-${uuid:20:12}"', '=""')
        # Make generated values deterministic by wrapping Python-call replacement output so assignment executes.
        # Use exec to actually assign value from generated assignment line without shell.
        assign = re.search(r"^([A-Z0-9_]+)=(.*)$", line).group(1)
        value = eval(eval(re.search(r'([^;]+)=$', line).group(1).replace("env[\"", '"').replace('"]', '"'))) if assign in env else None
        # For base64/uuid use generated values.
        exec(line, env)
        print(f"{assign}: contains %={ '%' in str(env[assign]) } len={len(env[assign]); first={str(env[assign])[:100]}...}")
PY

Repository: sheepdestroyer/LLM-Routing

Length of output: 986


🏁 Script executed:

#!/bin/bash
set -e

# Determine whether current generated secret assignments can contain '%' from the script text.
python3 - <<'PY'
import re

src = open("start-stack.sh", encoding="utf-8").read()

secret_names = [
    "POSTGRES_PASSWORD", "NEXTAUTH_SECRET", "SALT", "ENCRYPTION_KEY",
    "LITELLM_MASTER_KEY", "LANGFUSE_INIT_USER_PASSWORD", "OLLAMA_API_KEY",
    "OPENROUTER_API_KEY", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY",
    "MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", "REDIS_AUTH", "CLICKHOUSE_PASSWORD",
]

for name in secret_names:
    pattern = r'''if \[ -z "\\\$"''' + re.escape(name) + r'''"\]; then([\s\S]*?)''' + re.escape(name) + r'''=([^\n]*$)'''
    m = re.search(pattern, src)
    print(f"\n{name}")
    if m:
        context = m.group(0).splitlines()
        if len(context) > 10:
            context = context[:12]
        for line in context:
            print(line)
        if re.search(r'(["\']|%|tr -dc|-- |/dev/urandom|openssl rand)', m.group(1)) or "%" in m.group(2):
            print("  POSSIBLY % OR NON-HEX/BASE64 CHARSET")
    else:
        print("  no generated assignment found")

# Look for any literal environment value assignments containing % outside generated branch.
print("\nLiteral assignments potentially containing %:")
for m in re.finditer(r'^\s*([A-Z0-9_]+)=([^\n]*)$', src, re.M):
    if "%" in m.group(2) and m.group(1) not in {"public_base_url", "proxy_base_url"}:
        print(f"{m.group(1)}={m.group(2)}")
PY

# Parse only call-site shell fragments.
python3 - <<'PY'
import re
src = open("start-stack.sh", encoding=".utf-8", encoding="utf-8").read()
m = re.search(r"if \[ -z \"\$POSTGRES_PASSWORD\" \]; then[\s\S]*?LANGFUSE_SECRET_KEY=sk-lf-\$uuid", src)
print("has block:", bool(m))
PY

Repository: sheepdestroyer/LLM-Routing

Length of output: 1196


🌐 Web query:

systemd specifiers percent %% environments EnvironmentFile quadlet values

💡 Result:

In systemd unit files, the percent sign (%) is a special character used to introduce specifiers (e.g., %n for full unit name, %h for user home directory) [1][2][3]. To use a literal percent sign in fields where specifier expansion is performed, you must escape it by doubling it: use %% [1][2]. Regarding the interaction between specifiers, EnvironmentFile, and Quadlet: 1. Specifier Expansion and EnvironmentFile: Specifier expansion is performed on the file path value provided to the EnvironmentFile= directive in systemd [4]. This allows you to dynamically define file paths, such as using %h to point to a user's home directory (e.g., EnvironmentFile=%h/.config/my-app.env) [5][6]. Note that standard shell environment variables like $HOME are generally not expanded in the path string itself; specifiers are the intended mechanism for this [5]. 2. Quadlet Integration: Quadlet generators for Podman use the same format as standard systemd unit files [7][8]. Any configuration options defined in standard sections like [Service] or [Install] are passed directly to systemd, meaning they honor all standard systemd rules, including specifier expansion and the use of %% for literal percent signs [7][9]. While Quadlet handles specific sections like [Container], it does not alter the fundamental way systemd processes the generated unit files [7][10]. If you are working within a Quadlet file and need to refer to a path relative to the unit file's location, you can typically use relative paths, but when utilizing dynamic systemd-provided values, standard systemd specifiers remain the correct approach [7][11].

Citations:


🏁 Script executed:

#!/bin/bash
set -e

# Read the exact code paths from start-stack.sh before drawing the final conclusion.
sed -n '150,350p' start-stack.sh
printf '\n--- render_quadlets Python substitution section ---\n'
sed -n '750,835p' start-stack.sh

printf '\n--- focused grep ---\n'
rg -n 'gen_hex|gen_base64|openssl rand|POSTGRES_PASSWORD=|NEXTAUTH_SECRET=|SALT=|ENCRYPTION_KEY=|LITELLM_MASTER_KEY=|LANGFUSE_INIT_USER_PASSWORD=|OLLAMA_API_KEY=|OPENROUTER_API_KEY=|LANGFUSE_PUBLIC_KEY=|LANGFUSE_SECRET_KEY=|MINIO_ROOT_USER=|MINIO_ROOT_PASSWORD=|REDIS_AUTH=|CLICKHOUSE_PASSWORD=|POSTGRES_PASSWORD_PLACEHOLDER|NEXTAUTH_SECRET_PLACEHOLDER|SALT_PLACEHOLDER|ENCRYPTION_KEY_PLACEHOLDER|OLLAMA_API_KEY_PLACEHOLDER|OPENROUTER_API_KEY_PLACEHOLDER|LANGFUSE_PUBLIC_KEY_PLACEHOLDER|LANGFUSE_SECRET_KEY_PLACEHOLDER|MINIO_PASSWORD_PLACEHOLDER|REDIS_AUTH_PLACEHOLDER|CLICKHOUSE_PASSWORD_PLACEHOLDER' start-stack.sh

Repository: sheepdestroyer/LLM-Routing

Length of output: 16272


Escape % before rendering secrets into quadlet units.

render_quadlets() uses raw substitution in systemd-compatible files, so a generated secret containing % can be parsed as a systemd specifier instead of a literal value. Double % before replacing values into .pod/.container templates, or widen the secret generation to avoid values that could expand as specifiers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@start-stack.sh` around lines 759 - 849, Update render_quadlets() so values
containing percent signs are escaped for systemd before substitution into .pod
and .container templates, converting each literal % to %%. Apply this to
rendered secret values while preserving existing placeholder replacement and
unresolved-placeholder validation.

Comment thread start-stack.sh
@github-actions github-actions Bot added documentation Improvements or additions to documentation scripts tests labels Jul 22, 2026
@sheepdestroyer

Copy link
Copy Markdown
Owner Author

Closing superseded by a consolidated replacement PR from f8a4a53.

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