Service Onboarding SOP

service-onboarding-sop Sop

sop onboarding pal-e-services active

How a New Service Joins the Platform

  • Create Forgejo issue on the new service repo using template-issue-feature. MUST include: Dockerfile EXPOSE port, Harbor project name (matching service key), Keycloak realm + client ID (if auth needed). The issue is the spec for the scaffold agent.
  • Add to var.services in k3s.tfvars — defines repo, image, port, funnel, and optionally target_revision, source_repo, source_path. This goes through a PR on pal-e-services — create issue, branch, PR, review. No manual git push.
  • Create kustomize overlay in pal-e-deployments — create overlays/{service-name}/prod/ with kustomization.yaml and deployment-patch.yaml following the standard base. This goes through a PR on pal-e-deployments — create issue, branch, PR, review. Set source_repo and source_path in var.services to point ArgoCD at the overlay. See Convention: Kustomize Overlay for Deployments.

    ⚠️ Secrets warning: Kustomize overlays committed to git must NOT contain real secrets. A kubectl apply -k will overwrite any manually-created secrets with placeholder values from the overlay. Application secrets (DB passwords, API keys, OAuth tokens) must be created via kubectl create secret generic in the target namespace BEFORE the first kustomize apply or ArgoCD sync. The overlay should reference secrets by name (e.g. in envFrom) but must not define their data. If the overlay includes a Secret manifest, use stringData placeholders and apply secrets manually first so ArgoCD does not clobber them.
  • Update NetworkPolicy for dependent services — if the new service needs access to MinIO, Postgres, Keycloak, or any other shared platform service, add the new namespace to the relevant NetworkPolicy allowlist in pal-e-platform/terraform/network-policies.tf. This goes through a PR on pal-e-platform — create issue, branch, PR, review. Without this, the new service gets connection refused when calling shared services. Check each dependency: MinIO (minio namespace policy), Postgres (postgres namespace policy), Keycloak (keycloak namespace policy). Run tofu plan -lock=false to verify the netpol diff before apply.
  • Provision databases (if the service needs PostgreSQL) — add an entry to service_databases in k3s.tfvars. The map key becomes the PostgreSQL role name. Rails 8 apps typically need 4 databases: primary, cache, queue, cable. Example:
    westside_basketball = {
      password  = "generated-password"
      databases = ["basketball", "basketball_cache", "basketball_queue", "basketball_cable"]
    }
    This goes through a PR on pal-e-services. After merge, update the tfvars_content Woodpecker secret (base64 -w0 ~/secrets/pal-e-services/k3s.tfvars). The terraform creates the PostgreSQL role and databases via databases.tf.

    Rails apps also need a rails-env Kubernetes secret with POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, and SECRET_KEY_BASE. Add a kubernetes_secret_v1 resource in services.tf referencing the terraform-managed role and password. The app's database.yml production config must use ENV.fetch/ENV[] to read these values — never hardcode credentials.

    ⚠️ Database ownership: If databases were created manually before terraform import, table ownership may not match the terraform-managed role. Verify with SELECT tableowner FROM pg_tables and fix with REASSIGN OWNED BY if needed.
  • Configure public domain routing (if the service has a public domain like example.com) — the full chain is:
    DNS (GoDaddy) → edge-proxy Caddy (178.156.129.142) → Tailscale funnel → k8s Service → Pod
    Each link must be configured:
  • tofu plan/apply — creates ArgoCD Application, namespace, Harbor project, robot accounts, image pull secret, and Image Updater annotations. Always use -lock=false to avoid blocking CI. Requires Lucas approval before apply.
  • Scaffold repo on Forgejo — FastAPI app with Dockerfile, .woodpecker.yaml, k8s manifests. Dispatch a dev agent with the Forgejo issue from step 1.

    CI image registry convention: The .woodpecker.yaml pipeline MUST use the internal Harbor URL (harbor-core.harbor.svc.cluster.local) for image push, NOT the external URL (harbor.tail5b443a.ts.net). The external URL routes through Tailscale DERP relay which is unreliable from inside the cluster and causes intermittent CI failures. A HARBOR_INTERNAL env var may be added to the Woodpecker agent config as the canonical source for this URL. All docker push and docker tag commands in the pipeline must target the internal endpoint.

    Ruff linter configuration (required for all Python repos): The scaffold MUST include a pyproject.toml with [tool.ruff] config (line-length = 88, select = ["E", "F", "I", "W"]) and a .pre-commit-config.yaml with the ruff pre-commit hook (both ruff lint and ruff-format). The CI pipeline MUST include ruff check . and ruff format --check . as gating steps. See Convention: Python Ruff Standard for the full specification.

    Dockerfile CMD (Rails apps): Use CMD ["./bin/rails", "server"] to run Puma directly on port 3000. Do NOT use ./bin/thrust unless the thruster gem is in the Gemfile and bin/thrust binstub exists. The Dockerfile EXPOSE port must match the k8s service port.

    database.yml (Rails apps): Production config must use env vars (ENV["POSTGRES_PASSWORD"], not ENV.fetch(...) { raise }). A hard raise crashes CI because Rails evaluates all environment configs through ERB even in test mode. Use ENV.fetch("VAR", "default") for non-secret values and ENV["VAR"] for secrets.
  • Activate WoodpeckerGAP: no activate_repo tool exists in woodpecker-mcp. Currently requires manual UI activation (Woodpecker UI → Add repository). Tracked: woodpecker-sdk #6. When the MCP tool ships, this step becomes automated.
  • Add Harbor secrets to Woodpecker — use mcp__woodpecker__create_repo_secret to add harbor_username and harbor_password (from tofu output). Do NOT use the UI — the MCP tool handles this.
  • Push to trigger pipeline — merge the scaffold PR to main. Woodpecker builds, pushes to Harbor. Use mcp__woodpecker__list_pipelines to verify the pipeline succeeds.
  • ArgoCD syncs — Image Updater picks up the new image, writes newTag to the overlay kustomization.yaml, ArgoCD deploys. Verify pod is running before marking complete.

Pre-Deploy Validation Checklist

Run this checklist before tofu apply (between steps 1 and 2). The first five checks come from the mcd-tracker deployment (see deployment-lessons → Service Onboarding — Port + Registry + Realm Validation). Three additional checks (NetworkPolicy, application secrets, CI registry URL) were added 2026-03-24 after a pipeline failure investigation surfaced five deployment failures traceable to SOP gaps. Public domain and database checks added 2026-06-27 after westside-basketball sprint 5 validation. Every item caught here saves a push-wait-debug cycle.
Check What to verify Where to find it
Port consistency Dockerfile <code>EXPOSE</code> port = var.services <code>port</code> = kustomize <code>containerPort</code> = service <code>targetPort</code> = probe port = ingress backend port Dockerfile, <code>k3s.tfvars</code>, kustomize overlay
Registry path var.services key (e.g. <code>mcd-tracker-app</code>) matches Harbor project name in <code>image_repo</code> (e.g. <code>mcd-tracker-app/app</code>, NOT <code>mcd-tracker/app</code>). Pipeline pushes to the same path. <code>k3s.tfvars</code>, <code>.woodpecker.yaml</code>
Keycloak realm + client Exact realm name and client ID specified in Forgejo issue. Agent must NOT guess. Verify realm exists in Keycloak UI before deploy. Keycloak admin UI, Forgejo issue spec
First deploy tag Pipeline pushes both <code>:latest</code> AND <code>:SHA</code> tags. Kustomize overlay starts with <code>newTag: latest</code>. Image Updater switches to SHA after first sync. <code>.woodpecker.yaml</code>, kustomize overlay
Full apply required Port changes require full <code>tofu apply</code> (not <code>-target</code>). Targeted apply skips ingress recreation. Always use full apply for new services
NetworkPolicy allowlist If the service depends on MinIO, Postgres, or Keycloak, verify the new namespace is listed in the corresponding NetworkPolicy in <code>network-policies.tf</code>. Missing entry = <code>connection refused</code> at runtime. Must be merged via PR on pal-e-platform <strong>before</strong> first deploy. <code>pal-e-platform/terraform/network-policies.tf</code>
Application secrets Application secrets (DB passwords, API keys, OAuth tokens) exist in the target namespace via <code>kubectl get secrets -n {namespace}</code>. Kustomize overlay must NOT define secret data — only reference by name. Secrets must be created via <code>kubectl create secret generic</code> <strong>before</strong> first kustomize apply or ArgoCD sync, or ArgoCD will overwrite them with placeholders. <code>kubectl get secrets -n {namespace}</code>, kustomize overlay
CI registry URL <code>.woodpecker.yaml</code> uses internal Harbor URL (<code>harbor-core.harbor.svc.cluster.local</code>) for image push, NOT external URL (<code>harbor.tail5b443a.ts.net</code>). External URL routes through Tailscale DERP and is unreliable from inside the cluster. <code>.woodpecker.yaml</code>, Woodpecker agent config (<code>HARBOR_INTERNAL</code> env var)
Ruff linter config <code>pyproject.toml</code> contains <code>[tool.ruff]</code> with <code>line-length = 88</code> and <code>select = ["E", "F", "I", "W"]</code>. <code>.pre-commit-config.yaml</code> contains ruff hook with both <code>ruff</code> (lint) and <code>ruff-format</code> entries. CI pipeline includes <code>ruff check .</code> and <code>ruff format --check .</code> gates. See <a href="/notes/convention-python-ruff-standard">Convention: Python Ruff Standard</a>. <code>pyproject.toml</code>, <code>.pre-commit-config.yaml</code>, <code>.woodpecker.yaml</code>
Public domain routing If the service has a public domain: DNS A record → edge-proxy IP. Caddy site block in Salt pillar. Tailscale funnel ingress exists (either via <code>funnel = true</code> in var.services or in kustomize overlay). Funnel hostname matches what Caddy proxies to. <code>curl</code> returns 200 or 302, not 502. GoDaddy DNS, <code>ssh root@edge-proxy cat /etc/caddy/Caddyfile</code>, <code>kubectl get ingress -n {namespace}</code>
Database ownership If using <code>service_databases</code>: PostgreSQL role name matches terraform map key. All databases owned by that role (<code>SELECT datdba FROM pg_database</code>). All tables owned by that role (<code>SELECT tableowner FROM pg_tables</code>). No orphaned roles from manual provisioning. <code>psql</code> via port-forward, <code>k3s.tfvars</code>
Dockerfile CMD Rails apps: CMD uses <code>./bin/rails server</code> (not <code>./bin/thrust</code>) unless thruster gem is installed. EXPOSE port matches k8s service port. database.yml production config uses <code>ENV[]</code> for secrets (not <code>raise</code> — breaks CI). Dockerfile, Gemfile, <code>config/database.yml</code>
CI step dependencies <code>update-kustomize-tag</code> step must depend on <code>build-and-push</code> success. If build is skipped (test failure), tag update must also be skipped — otherwise ArgoCD deploys a non-existent image tag (ImagePullBackOff). <code>.woodpecker/ci.yaml</code>
Issue template requirement: Every service onboarding Forgejo issue MUST include these three values explicitly: (1) Dockerfile EXPOSE port, (2) Harbor project name (matching the service key), (3) Keycloak realm name and client ID (if auth is needed). Agents that create onboarding issues without these fields are violating SOP.

Reference

Full procedure documented in SERVICE_ONBOARDING.md in the pal-e-services repo.
Kustomize overlay pattern documented in Convention: Kustomize Overlay for Deployments.
Python linter standard documented in Convention: Python Ruff Standard. Covers pyproject.toml ruff config, .pre-commit-config.yaml hook, and CI gate requirements.

var.services Fields

Each service entry in var.services is keyed by service name (which becomes the namespace). Fields:
  • forgejo_repo (string) — e.g., "forgejo_admin/platform-validation"
  • image_repo (string) — e.g., "platform-validation/validator"
  • port (number) — container port
  • funnel (bool) — whether to create a Tailscale funnel ingress. Set false when the kustomize overlay manages its own ingress with a custom hostname (e.g., public domains that don't match the service key)
  • target_revision (string, optional) — git branch ArgoCD watches, defaults to "main"
  • source_repo (string, optional) — Forgejo repo for kustomize overlays, e.g., "forgejo_admin/pal-e-deployments". When set, terraform adds write-back-target: kustomization annotation and points ArgoCD at this repo instead of the service repo.
  • source_path (string, optional) — path within source_repo to the overlay, e.g., "overlays/pal-e-docs/prod". Defaults to "k8s" if omitted.
The map key itself serves as the service name, namespace, and domain prefix. There is no separate namespace or domain field.