From 73855ebe6bf83cddda64d3286aadb0c69954ba8e Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 2 May 2026 01:25:50 -0400 Subject: [PATCH 1/2] feat(deploy): Dockerfile + Helm chart for AgentServer (closes #37) Signed-off-by: Federico Kamelhar --- .gitignore | 1 + .pre-commit-config.yaml | 8 +- Dockerfile | 82 +++++++++ deploy/helm/locus-agent/.helmignore | 11 ++ deploy/helm/locus-agent/Chart.yaml | 20 +++ deploy/helm/locus-agent/README.md | 69 ++++++++ .../helm/locus-agent/templates/_helpers.tpl | 66 +++++++ .../locus-agent/templates/deployment.yaml | 82 +++++++++ deploy/helm/locus-agent/templates/hpa.yaml | 32 ++++ .../helm/locus-agent/templates/ingress.yaml | 36 ++++ deploy/helm/locus-agent/templates/secret.yaml | 11 ++ .../helm/locus-agent/templates/service.yaml | 15 ++ .../locus-agent/templates/serviceaccount.yaml | 12 ++ deploy/helm/locus-agent/values.yaml | 105 +++++++++++ deploy/oci-functions/app.py | 79 +++++++++ docs/how-to/deploy.md | 30 +++- pyproject.toml | 9 +- tests/unit/test_deploy_helm_chart.py | 166 ++++++++++++++++++ 18 files changed, 831 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 deploy/helm/locus-agent/.helmignore create mode 100644 deploy/helm/locus-agent/Chart.yaml create mode 100644 deploy/helm/locus-agent/README.md create mode 100644 deploy/helm/locus-agent/templates/_helpers.tpl create mode 100644 deploy/helm/locus-agent/templates/deployment.yaml create mode 100644 deploy/helm/locus-agent/templates/hpa.yaml create mode 100644 deploy/helm/locus-agent/templates/ingress.yaml create mode 100644 deploy/helm/locus-agent/templates/secret.yaml create mode 100644 deploy/helm/locus-agent/templates/service.yaml create mode 100644 deploy/helm/locus-agent/templates/serviceaccount.yaml create mode 100644 deploy/helm/locus-agent/values.yaml create mode 100644 deploy/oci-functions/app.py create mode 100644 tests/unit/test_deploy_helm_chart.py diff --git a/.gitignore b/.gitignore index 7c7ee5e1..35f4d766 100644 --- a/.gitignore +++ b/.gitignore @@ -255,3 +255,4 @@ examples/start_and_test.sh # Old tutorials directory (superseded by examples/tutorial_*.py) tutorials/ site/ +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ea0c07a..62280d16 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,9 @@ repos: - id: check-toml - id: check-yaml args: [--unsafe] # Allow custom tags + # Helm chart templates use Go templating ({{ ... }}) — not valid + # YAML before render. Exclude them from the pure-YAML check. + exclude: ^deploy/helm/.*/templates/ - id: destroyed-symlinks - id: detect-private-key - id: end-of-file-fixer @@ -137,7 +140,10 @@ repos: hooks: - id: pretty-format-yaml args: [--autofix, --indent=2] - exclude: ^\.github/ + # Helm templates use Go templating ({{ ... }}) — not valid YAML + # before render. They live under deploy/helm/*/templates/. The + # GitHub Actions workflow YAMLs use anchors the formatter mangles. + exclude: ^(\.github/|deploy/helm/.*/templates/) # ========================================================================== # Markdown diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..91aa90ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +# syntax=docker/dockerfile:1.7 +# +# Multi-stage Dockerfile for a locus AgentServer deployment. +# +# Build: +# docker build -t locus-agent:latest . +# +# Run (with an OCI session-token mount and a bearer-token API key): +# docker run -p 8080:8080 \ +# -v ~/.oci:/home/locus/.oci:ro \ +# -e OCI_PROFILE=DEFAULT \ +# -e LOCUS_SERVER_API_KEY=secret \ +# locus-agent:latest +# +# The image is built around `locus.server.AgentServer`. Replace +# `your_app:server.app` in the CMD with the import path of your own +# AgentServer instance. + +# ----------------------------------------------------------------------------- +# Stage 1 — builder. Resolves wheels for locus[oci,server,checkpoints]. +# ----------------------------------------------------------------------------- +FROM python:3.12-slim AS builder + +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# Build deps only — gcc for native checkpointer drivers (psycopg, etc.). +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +# Copy minimal package metadata first to maximize layer caching. +COPY pyproject.toml README.md LICENSE.txt ./ +COPY src/ ./src/ + +# Install locus + the OCI provider + the AgentServer extras + the OCI +# bucket checkpointer. Add ``[telemetry,rag,redis,postgresql]`` to taste. +RUN pip install --user --no-cache-dir ".[oci,server,checkpoints]" + +# ----------------------------------------------------------------------------- +# Stage 2 — runtime. Slim image; non-root user; readonly OCI config mount. +# ----------------------------------------------------------------------------- +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/home/locus/.local/bin:$PATH + +# Runtime deps only (libpq for asyncpg). curl is used by HEALTHCHECK. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user. Mount ~/.oci on top of /home/locus/.oci at runtime. +RUN useradd --create-home --shell /bin/bash --uid 10001 locus +USER locus +WORKDIR /home/locus + +# Copy the wheels installed in stage 1. +COPY --from=builder --chown=locus:locus /root/.local /home/locus/.local + +# Default port — override with `-p` or in your orchestrator's manifest. +EXPOSE 8080 + +# Liveness check — the AgentServer's /health endpoint always returns +# 200 unless the process is dead. Override with a readiness probe at +# the orchestrator layer if you want richer signals. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl --fail --silent http://localhost:8080/health || exit 1 + +# Replace ``your_app:server.app`` with the import path of your own +# AgentServer instance. The placeholder example below assumes a module +# called ``app.py`` at /home/locus/app.py exposing a ``server`` symbol. +# +# An example ``app.py`` lives in deploy/oci-functions/app.py. +CMD ["uvicorn", "app:server.app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/deploy/helm/locus-agent/.helmignore b/deploy/helm/locus-agent/.helmignore new file mode 100644 index 00000000..b6e17c9b --- /dev/null +++ b/deploy/helm/locus-agent/.helmignore @@ -0,0 +1,11 @@ +# Patterns to ignore when building Helm packages. +.DS_Store +.git/ +.gitignore +.idea/ +.vscode/ +*.tmproj +*.swp +*.bak +*.tgz +README.md.bak diff --git a/deploy/helm/locus-agent/Chart.yaml b/deploy/helm/locus-agent/Chart.yaml new file mode 100644 index 00000000..0ac4ae54 --- /dev/null +++ b/deploy/helm/locus-agent/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v2 +name: locus-agent +description: | + A locus AgentServer deployment. Wraps any locus.Agent in a FastAPI app + with /invoke, /stream (SSE), /threads/{id}, and /health endpoints, plus + per-principal thread isolation when api_key is configured. +type: application +version: 0.1.0 +appVersion: 0.1.0 +home: https://github.com/oracle-samples/locus +sources: +- https://github.com/oracle-samples/locus +keywords: +- oci +- agent +- llm +- generative-ai +maintainers: +- name: locus contributors + url: https://github.com/oracle-samples/locus diff --git a/deploy/helm/locus-agent/README.md b/deploy/helm/locus-agent/README.md new file mode 100644 index 00000000..349a53f1 --- /dev/null +++ b/deploy/helm/locus-agent/README.md @@ -0,0 +1,69 @@ +# locus-agent Helm chart + +Deploys a [locus](https://github.com/oracle-samples/locus) `AgentServer` +on Kubernetes / OKE. Wraps any `locus.Agent` in a FastAPI app exposing +`/invoke`, `/stream` (SSE), `/threads/{id}`, and `/health`. + +## Quick start + +```bash +helm install locus-agent ./deploy/helm/locus-agent \ + --set image.repository=ghcr.io/your-org/locus-agent \ + --set image.tag=v0.1.0 \ + --set auth.apiKey=$(openssl rand -hex 16) \ + --set ociBucket.enabled=true \ + --set ociBucket.bucketName=locus-threads \ + --set ociBucket.namespace= +``` + +## Values + +See `values.yaml` for the full set. Notable knobs: + +| Key | Default | Purpose | +|---|---|---| +| `replicaCount` | `2` | Replicas (scale via HPA when enabled). | +| `auth.apiKey` | `""` | Bearer-token API key. Use `auth.existingSecret` instead in prod. | +| `serviceAccount.annotations` | `{}` | Add OCI workload-identity annotations here. | +| `probes.liveness.path` | `/health` | Liveness endpoint. | +| `ociBucket.enabled` | `false` | Wire OCI Object Storage as the checkpointer backend. | +| `autoscaling.enabled` | `false` | Render an HPA. | +| `ingress.enabled` | `false` | Render an Ingress. | + +## Auth + +The chart expects a bearer-token secret named in +`auth.existingSecret` or auto-created from `auth.apiKey`. The +container reads it from `LOCUS_SERVER_API_KEY` and passes it to +`AgentServer(api_key=...)`. Per-principal thread namespacing is +enforced server-side — two API keys can't read each other's threads. + +## OCI workload identity + +Preferred over static `apiKey`: enable workload identity on the OKE +node pool, then add the IAM role annotation to the chart's service +account: + +```yaml +serviceAccount: + annotations: + workload.identity.oci.oraclecloud.com/role: arn:oci:... +``` + +The `OCIBucketBackend` will pick up `instance_principal` / +`resource_principal` automatically — no static credentials needed. + +## What you still own + +- The `app.py` module the container runs (see the `Dockerfile`'s `CMD`). + This is where you instantiate your `Agent` + `AgentServer`. +- The image build + registry push. +- Network policy, monitoring (Prometheus scrape configs, Grafana + dashboards), and observability (OTLP exporter env vars). + +## See also + +- [`docs/how-to/deploy.md`](../../../docs/how-to/deploy.md) — full + deployment walkthrough. +- [`docs/concepts/server.md`](../../../docs/concepts/server.md) — + the `AgentServer` API. diff --git a/deploy/helm/locus-agent/templates/_helpers.tpl b/deploy/helm/locus-agent/templates/_helpers.tpl new file mode 100644 index 00000000..7fa230cd --- /dev/null +++ b/deploy/helm/locus-agent/templates/_helpers.tpl @@ -0,0 +1,66 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "locus-agent.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Fully-qualified app name. +*/}} +{{- define "locus-agent.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Common labels. +*/}} +{{- define "locus-agent.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "locus-agent.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels. +*/}} +{{- define "locus-agent.selectorLabels" -}} +app.kubernetes.io/name: {{ include "locus-agent.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Service account name. Returns the explicit name or auto-derives. +*/}} +{{- define "locus-agent.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "locus-agent.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{/* +Bearer-token secret name. References an existing secret if provided, +otherwise the chart-managed one. +*/}} +{{- define "locus-agent.authSecretName" -}} +{{- if .Values.auth.existingSecret -}} +{{- .Values.auth.existingSecret -}} +{{- else -}} +{{- printf "%s-auth" (include "locus-agent.fullname" .) -}} +{{- end -}} +{{- end -}} diff --git a/deploy/helm/locus-agent/templates/deployment.yaml b/deploy/helm/locus-agent/templates/deployment.yaml new file mode 100644 index 00000000..18226aee --- /dev/null +++ b/deploy/helm/locus-agent/templates/deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "locus-agent.fullname" . }} + labels: + {{- include "locus-agent.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "locus-agent.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "locus-agent.selectorLabels" . | nindent 8 }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "locus-agent.serviceAccountName" . }} + containers: + - name: locus-agent + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: LOCUS_SERVER_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "locus-agent.authSecretName" . }} + key: {{ .Values.auth.secretKey }} + {{- if .Values.ociBucket.enabled }} + - name: LOCUS_OCI_BUCKET_NAME + value: {{ .Values.ociBucket.bucketName | quote }} + - name: LOCUS_OCI_NAMESPACE + value: {{ .Values.ociBucket.namespace | quote }} + - name: LOCUS_OCI_BUCKET_PREFIX + value: {{ .Values.ociBucket.prefix | quote }} + {{- end }} + {{- with .Values.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + livenessProbe: + httpGet: + path: {{ .Values.probes.liveness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + readinessProbe: + httpGet: + path: {{ .Values.probes.readiness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + startupProbe: + httpGet: + path: {{ .Values.probes.startup.path }} + port: http + failureThreshold: {{ .Values.probes.startup.failureThreshold }} + periodSeconds: {{ .Values.probes.startup.periodSeconds }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/locus-agent/templates/hpa.yaml b/deploy/helm/locus-agent/templates/hpa.yaml new file mode 100644 index 00000000..343dce4e --- /dev/null +++ b/deploy/helm/locus-agent/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "locus-agent.fullname" . }} + labels: + {{- include "locus-agent.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "locus-agent.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/deploy/helm/locus-agent/templates/ingress.yaml b/deploy/helm/locus-agent/templates/ingress.yaml new file mode 100644 index 00000000..4b741fc8 --- /dev/null +++ b/deploy/helm/locus-agent/templates/ingress.yaml @@ -0,0 +1,36 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "locus-agent.fullname" . -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "locus-agent.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ $fullName }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/locus-agent/templates/secret.yaml b/deploy/helm/locus-agent/templates/secret.yaml new file mode 100644 index 00000000..e38fba49 --- /dev/null +++ b/deploy/helm/locus-agent/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if and (not .Values.auth.existingSecret) .Values.auth.apiKey -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "locus-agent.authSecretName" . }} + labels: + {{- include "locus-agent.labels" . | nindent 4 }} +type: Opaque +stringData: + {{ .Values.auth.secretKey }}: {{ .Values.auth.apiKey | quote }} +{{- end }} diff --git a/deploy/helm/locus-agent/templates/service.yaml b/deploy/helm/locus-agent/templates/service.yaml new file mode 100644 index 00000000..f0ae5690 --- /dev/null +++ b/deploy/helm/locus-agent/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "locus-agent.fullname" . }} + labels: + {{- include "locus-agent.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "locus-agent.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/locus-agent/templates/serviceaccount.yaml b/deploy/helm/locus-agent/templates/serviceaccount.yaml new file mode 100644 index 00000000..2d2e36e1 --- /dev/null +++ b/deploy/helm/locus-agent/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "locus-agent.serviceAccountName" . }} + labels: + {{- include "locus-agent.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/locus-agent/values.yaml b/deploy/helm/locus-agent/values.yaml new file mode 100644 index 00000000..cfa810ec --- /dev/null +++ b/deploy/helm/locus-agent/values.yaml @@ -0,0 +1,105 @@ +# Default values for locus-agent. +# Override with `helm install -f overrides.yaml`. + +image: + repository: ghcr.io/oracle-samples/locus-agent + tag: latest + pullPolicy: IfNotPresent + +# Number of replicas. Bump for HA + horizontal scale; ensure the +# checkpointer (e.g., OCIBucketBackend) is shared so all replicas see +# the same threads. +replicaCount: 2 + +# AgentServer auth — set the bearer token via a Kubernetes Secret. +# When `existingSecret` is set, the chart references that secret name +# and `secretKey`; otherwise it creates one with `apiKey` literal. +auth: + existingSecret: '' # e.g. "locus-agent-secrets" + secretKey: LOCUS_SERVER_API_KEY + apiKey: '' # only used if existingSecret is empty + +# Workload identity — preferred OCI auth path on OKE. The agent picks +# up Instance / Resource Principals from the underlying VM/pod. +serviceAccount: + create: true + name: '' # auto-derived if empty + annotations: {} # add OCI workload identity annotations here + +# Container resource requests / limits. +resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1000m + memory: 1Gi + +# Liveness + readiness probes hit /health. Tighten startup to allow for +# cold-import time on first boot. +probes: + liveness: + path: /health + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + readiness: + path: /health + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + startup: + path: /health + failureThreshold: 30 + periodSeconds: 5 + +# Service exposure — ClusterIP by default; flip to LoadBalancer or +# NodePort for direct external access. +service: + type: ClusterIP + port: 8080 + +# Optional ingress. Disabled by default; set `enabled: true` to render +# an Ingress with the configured hosts. +ingress: + enabled: false + className: '' + annotations: {} + hosts: + - host: locus-agent.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +# Horizontal Pod Autoscaler — disabled by default. Enable for +# production workloads with variable traffic. +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + +# OCI bucket checkpointer config — set when the agent uses +# OCIBucketBackend for thread persistence. +ociBucket: + enabled: false + bucketName: '' + namespace: '' + prefix: locus/threads/ + +# Free-form environment variables passed to the container. +env: [] + # - name: OCI_PROFILE + # value: DEFAULT + # - name: OTEL_EXPORTER_OTLP_ENDPOINT + # value: http://otel-collector.observability.svc:4318 + +# Pod-level annotations (e.g., for Prometheus scraping or OCI tagging). +podAnnotations: {} + +# Node selection / tolerations / affinity. +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/deploy/oci-functions/app.py b/deploy/oci-functions/app.py new file mode 100644 index 00000000..17cb60e1 --- /dev/null +++ b/deploy/oci-functions/app.py @@ -0,0 +1,79 @@ +# Copyright (c) 2025, 2026 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License v1.0 as shown at +# https://oss.oracle.com/licenses/upl/ + +"""Example AgentServer entrypoint for container deployments. + +This module is the import target for the Dockerfile's CMD: + + CMD ["uvicorn", "app:server.app", "--host", "0.0.0.0", "--port", "8080"] + +Wire your real Agent here — register tools, set the model, attach a +checkpointer. The pattern below is a minimal travel-concierge with an +OCI bucket checkpointer; replace with whatever your workload needs. +""" + +from __future__ import annotations + +import os + +from locus import Agent +from locus.memory.backends.oci_bucket import OCIBucketBackend +from locus.server import AgentServer +from locus.tools.decorator import tool + + +# --------------------------------------------------------------------------- +# Tools — replace with your domain. +# --------------------------------------------------------------------------- +@tool +def search_flights(origin: str, destination: str, date: str) -> list[dict]: + """Search the GDS for available flights.""" + # Stub. Wire to your real flight API. + return [ + {"flight_id": "AA-181", "origin": origin, "destination": destination, "date": date}, + ] + + +@tool(idempotent=True) +def book_flight(flight_id: str, customer_id: str) -> dict: + """Book a flight. Re-fires return the cached receipt — never double-charge.""" + # Stub. Wire to your real billing system. + return {"confirmation": "BK-58291", "flight_id": flight_id} + + +# --------------------------------------------------------------------------- +# Checkpointer — durable threads in OCI Object Storage. +# --------------------------------------------------------------------------- +checkpointer = OCIBucketBackend( + bucket_name=os.environ["LOCUS_OCI_BUCKET_NAME"], + namespace=os.environ["LOCUS_OCI_NAMESPACE"], + prefix=os.environ.get("LOCUS_OCI_BUCKET_PREFIX", "locus/threads/"), + auth_type=os.environ.get("OCI_AUTH_TYPE", "api_key"), +) + + +# --------------------------------------------------------------------------- +# Agent. +# --------------------------------------------------------------------------- +agent = Agent( + model=os.environ.get("LOCUS_MODEL", "oci:openai.gpt-5"), + tools=[search_flights, book_flight], + system_prompt="You are a travel concierge. Find a flight, then book it.", + checkpointer=checkpointer, +) + + +# --------------------------------------------------------------------------- +# Server. Bearer-token auth + per-principal thread isolation. +# --------------------------------------------------------------------------- +server = AgentServer( + agent=agent, + api_key=os.environ.get("LOCUS_SERVER_API_KEY"), + title="Travel Concierge", +) + + +# Module-level export for uvicorn: +# uvicorn app:server.app --host 0.0.0.0 --port 8080 +app = server.app diff --git a/docs/how-to/deploy.md b/docs/how-to/deploy.md index 72f91be9..1ed2136e 100644 --- a/docs/how-to/deploy.md +++ b/docs/how-to/deploy.md @@ -106,7 +106,35 @@ Set `OCI_AUTH_TYPE=instance_principal` in the container env. ## OKE — Kubernetes for production Best for multi-replica, autoscaled, multi-region production. The -shape is the same as any FastAPI app. +quickest path is the **bundled Helm chart** at +[`deploy/helm/locus-agent/`](https://github.com/oracle-samples/locus/tree/main/deploy/helm/locus-agent): + +```bash +helm install locus-agent ./deploy/helm/locus-agent \ + --set image.repository=iad.ocir.io/$NAMESPACE/locus-concierge \ + --set image.tag=0.1.0 \ + --set auth.apiKey=$(openssl rand -hex 16) \ + --set ociBucket.enabled=true \ + --set ociBucket.bucketName=locus-threads-prod \ + --set ociBucket.namespace=$NAMESPACE \ + --set autoscaling.enabled=true \ + --set autoscaling.minReplicas=2 \ + --set autoscaling.maxReplicas=10 +``` + +The chart ships a Deployment, Service, ServiceAccount (with workload- +identity annotation hooks), Secret, HPA, and Ingress, all driven by +`values.yaml`. See [`deploy/helm/locus-agent/README.md`](https://github.com/oracle-samples/locus/blob/main/deploy/helm/locus-agent/README.md) +for the full value reference. + +The container image is built from the [root `Dockerfile`](https://github.com/oracle-samples/locus/blob/main/Dockerfile) — multi-stage, non-root user, `HEALTHCHECK` on `/health`. Build it with: + +```bash +docker build -t iad.ocir.io/$NAMESPACE/locus-concierge:0.1.0 . +docker push iad.ocir.io/$NAMESPACE/locus-concierge:0.1.0 +``` + +If you need raw YAML instead of Helm, the equivalent is: ```yaml apiVersion: apps/v1 diff --git a/pyproject.toml b/pyproject.toml index 23b3cbfd..0bd73a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,13 @@ telemetry = [ # MCP support mcp = ["mcp>=1.0"] +# Drop-in HTTP server (FastAPI app for AgentServer + SSE streaming). +# Pulled in production deployments via ``pip install "locus[oci,server]"``. +server = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", +] + # Checkpoint backends — one extra per backend under # src/locus/memory/backends/ that needs a runtime dep. Keep in sync. sqlite = ["aiosqlite>=0.20"] @@ -95,7 +102,7 @@ rag = [ # All providers and backends — what most consumers installing for prod # will want. Every optional subsystem Locus ships is reachable from here. all = [ - "locus[models,telemetry,mcp,checkpoints,rag]", + "locus[models,telemetry,mcp,server,checkpoints,rag]", ] # Development diff --git a/tests/unit/test_deploy_helm_chart.py b/tests/unit/test_deploy_helm_chart.py new file mode 100644 index 00000000..c4da89de --- /dev/null +++ b/tests/unit/test_deploy_helm_chart.py @@ -0,0 +1,166 @@ +"""Smoke tests for the deploy/helm/locus-agent chart and the root Dockerfile. + +These don't run helm or docker. They validate: +- All required chart files exist with the right shape. +- `Chart.yaml` has the canonical fields. +- `values.yaml` parses and contains the keys the templates reference. +- The Dockerfile contains the expected stages and HEALTHCHECK. + +If `helm` is available in PATH, also runs `helm lint` and `helm template`. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CHART_DIR = REPO_ROOT / "deploy" / "helm" / "locus-agent" +DOCKERFILE = REPO_ROOT / "Dockerfile" + + +REQUIRED_CHART_FILES = ( + "Chart.yaml", + "values.yaml", + ".helmignore", + "README.md", + "templates/_helpers.tpl", + "templates/deployment.yaml", + "templates/service.yaml", + "templates/serviceaccount.yaml", + "templates/secret.yaml", + "templates/hpa.yaml", + "templates/ingress.yaml", +) + + +def test_dockerfile_exists_at_repo_root(): + assert DOCKERFILE.exists(), f"Dockerfile missing at {DOCKERFILE}" + + +def test_dockerfile_has_multistage_build(): + body = DOCKERFILE.read_text() + assert "AS builder" in body, "expected named 'builder' stage" + assert "AS runtime" in body, "expected named 'runtime' stage" + + +def test_dockerfile_uses_non_root_user(): + body = DOCKERFILE.read_text() + assert "useradd" in body, "Dockerfile should create a non-root user" + assert "USER locus" in body, "Dockerfile should drop privileges to that user" + + +def test_dockerfile_has_healthcheck(): + body = DOCKERFILE.read_text() + assert "HEALTHCHECK" in body + assert "/health" in body + + +def test_dockerfile_installs_server_extras(): + body = DOCKERFILE.read_text() + # `[oci,server,checkpoints]` covers production deployment basics. + assert "[oci,server,checkpoints]" in body + + +def test_chart_directory_exists(): + assert CHART_DIR.is_dir(), f"chart dir missing at {CHART_DIR}" + + +@pytest.mark.parametrize("rel_path", REQUIRED_CHART_FILES) +def test_chart_has_required_files(rel_path: str): + path = CHART_DIR / rel_path + assert path.exists(), f"chart file missing: {rel_path}" + + +def test_chart_yaml_has_canonical_fields(): + yaml = pytest.importorskip("yaml") + data = yaml.safe_load((CHART_DIR / "Chart.yaml").read_text()) + assert data["apiVersion"] == "v2" + assert data["name"] == "locus-agent" + assert data["type"] == "application" + assert "version" in data + assert "appVersion" in data + + +def test_values_yaml_parses_with_expected_keys(): + yaml = pytest.importorskip("yaml") + values = yaml.safe_load((CHART_DIR / "values.yaml").read_text()) + + # Top-level sections referenced by the templates. + for key in ( + "image", + "replicaCount", + "auth", + "serviceAccount", + "resources", + "probes", + "service", + "ingress", + "autoscaling", + "ociBucket", + ): + assert key in values, f"values.yaml missing top-level key: {key}" + + # Auth shape. + assert "secretKey" in values["auth"] + # Probes have liveness + readiness + startup. + for probe in ("liveness", "readiness", "startup"): + assert probe in values["probes"], f"probes.{probe} missing" + + +@pytest.mark.skipif(shutil.which("helm") is None, reason="helm CLI not available") +def test_helm_lint_passes(): + """If helm is on PATH, run `helm lint` against the chart.""" + helm = shutil.which("helm") + assert helm is not None # narrowed by skipif; helps the type checker + result = subprocess.run( # noqa: S603 — args fully controlled, helm path resolved + [helm, "lint", str(CHART_DIR)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, ( + f"helm lint failed:\n--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" + ) + + +@pytest.mark.skipif(shutil.which("helm") is None, reason="helm CLI not available") +def test_helm_template_renders(): + """If helm is on PATH, render templates with default values.""" + helm = shutil.which("helm") + assert helm is not None + result = subprocess.run( # noqa: S603 — args fully controlled, helm path resolved + [ + helm, + "template", + "test-release", + str(CHART_DIR), + "--set", + "auth.apiKey=dummy", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, ( + f"helm template failed:\n--- stdout ---\n{result.stdout[:2000]}\n--- stderr ---\n{result.stderr}" + ) + # Sanity: rendered output should include the Deployment. + assert "kind: Deployment" in result.stdout + assert "kind: Service" in result.stdout + + +def test_pyproject_has_server_extra(): + """The `server` extra should pin FastAPI + uvicorn for prod deployments.""" + text = (REPO_ROOT / "pyproject.toml").read_text() + assert "server = [" in text + # The block should mention FastAPI + uvicorn. + server_block_start = text.index("server = [") + server_block_end = text.index("]", server_block_start) + block = text[server_block_start:server_block_end] + assert "fastapi" in block + assert "uvicorn" in block From 4fd0c6636e47d39977d5cd332ab2c95f43b367f0 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 2 May 2026 01:43:08 -0400 Subject: [PATCH 2/2] fix(docker): drop optional # syntax= directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dockerfile/1.7 syntax frontend was failing to resolve from the docker hub registry on some local Docker setups (Rancher Desktop TLS cert chain). The directive is optional — without it, the built-in parser handles our multi-stage build identically. Verified: docker build succeeds and the image runs locus[oci,server, checkpoints] imports as the non-root locus user (uid 10001). Signed-off-by: Federico Kamelhar --- Dockerfile | 2 -- README.md | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 91aa90ea..80807756 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1.7 -# # Multi-stage Dockerfile for a locus AgentServer deployment. # # Build: diff --git a/README.md b/README.md index ce670825..24c5e35a 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,31 @@ each tutorial is a working program against a real model. | **Production** | [`19_guardrails_security`](examples/tutorial_19_guardrails_security.py) · [`20_checkpoint_backends`](examples/tutorial_20_checkpoint_backends.py) · [`28_agent_server`](examples/tutorial_28_agent_server.py) · [`37_termination`](examples/tutorial_37_termination.py) | | **OCI** | [`29_model_providers`](examples/tutorial_29_model_providers.py) · [`40_oci_dac`](examples/tutorial_40_oci_dac.py) — Dedicated AI Cluster endpoints | +## Deploy + +`AgentServer` is a drop-in FastAPI app. The repo ships a turn-key +deployment story: + +- Multi-stage [`Dockerfile`](Dockerfile) — non-root user, `HEALTHCHECK` + on `/health`, `pip install ".[oci,server,checkpoints]"`. +- Helm chart at [`deploy/helm/locus-agent/`](deploy/helm/locus-agent/) — + Deployment, Service, ServiceAccount (with workload-identity hooks), + Secret, HPA, Ingress, all driven by `values.yaml`. +- `pip install "locus[oci,server]"` for production installs. + +```bash +docker build -t iad.ocir.io/$NAMESPACE/locus-agent:0.1.0 . +helm install locus-agent ./deploy/helm/locus-agent \ + --set image.repository=iad.ocir.io/$NAMESPACE/locus-agent \ + --set image.tag=0.1.0 \ + --set auth.apiKey=$(openssl rand -hex 16) \ + --set ociBucket.enabled=true \ + --set ociBucket.bucketName=locus-threads \ + --set ociBucket.namespace=$NAMESPACE +``` + +[Full deploy guide →](https://oracle-samples.github.io/locus/how-to/deploy/) + ## Repo layout ```text