Skip to content

Craft Kubernetes Integration Tests #7567

Craft Kubernetes Integration Tests

Craft Kubernetes Integration Tests #7567

# Runs the Craft Kubernetes integration tests against a Helm-installed kind
# cluster with the real API, Celery workers, sandbox proxy, and sandbox pods.
#
# Each test file owns a module-scoped sandbox pod and the single-node kind
# cluster fits one sandbox pod at a time, so the lane shards per test file, each
# shard on its own kind cluster (dynamic `fail-fast: false` matrix).
#
# Images are built once and pushed to the shared ECR repo, then each shard
# mirrors them into a local `localhost:5001` registry that kind pulls from
# unauthenticated — sandbox pods have no `imagePullSecrets`, so the image must
# come from an unauthenticated registry. (kind pulls from the local registry
# rather than `kind load`, which writes a multi-GB tar to tmpfs /tmp.)
# https://kind.sigs.k8s.io/docs/user/local-registry/
name: Craft Kubernetes Integration Tests
concurrency:
group: Craft-K8s-Tests-${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
on:
schedule:
- cron: "0 7 * * *" # 07:00 UTC nightly
merge_group:
pull_request:
branches: [main]
# NOTE: Intentionally no `paths:` filter. Trigger-level `paths:` is ignored
# for `merge_group`, so we always trigger and let the `changes` job below
# decide whether the real test job runs (matches the docker-compose lane
# cadence; this lane is expensive and must not run on every PR).
push:
tags:
- "v*.*.*"
workflow_dispatch:
permissions:
contents: read
env:
S3_ENDPOINT_URL: "http://127.0.0.1:9004"
S3_FILE_STORE_BUCKET_NAME: "onyx-file-store-bucket"
S3_AWS_ACCESS_KEY_ID: "minioadmin"
S3_AWS_SECRET_ACCESS_KEY: "minioadmin"
SANDBOX_BACKEND: "kubernetes"
SANDBOX_NAMESPACE: "onyx-sandboxes"
SANDBOX_SERVICE_ACCOUNT_NAME: "sandbox"
SANDBOX_CONTAINER_IMAGE: "localhost:5001/onyx-sandbox:ci"
# Sandbox pod resource requests live in values-ci.yaml (configMap.SANDBOX_POD_*).
ONYX_SERVER_URL: "http://onyx-api-service.onyx.svc.cluster.local:8080"
# Proxy lives in release namespace `onyx`. SANDBOX_PROXY_HOST is set
# later to the proxy Service ClusterIP — the runner has no cluster DNS,
# so a Service FQDN here would fail _resolve_proxy_ip in the manager.
HELM_RELEASE_NAMESPACE: "onyx"
SANDBOX_PROXY_PORT: "8080"
SANDBOX_PROXY_CA_SECRET: "sandbox-proxy-ca"
SANDBOX_PROXY_CA_CONFIGMAP: "sandbox-proxy-ca-bundle"
BACKEND_IMAGE: "localhost:5001/onyx-backend:ci"
# Fail fast on opencode hangs (default is 900s) so a stuck turn surfaces logs
# well within the job timeout instead of being killed with zero signal.
SANDBOX_TURN_TIMEOUT_SECONDS: "120"
KIND_REGISTRY_NAME: "kind-registry"
KIND_REGISTRY_PORT: "5001"
KIND_VERSION: "v0.31.0"
KUBECTL_VERSION: "v1.35.0"
CRAFT_ASSETS_ARTIFACT: "craft-k8s-assets"
# The pytest process runs on the runner, so it reaches chart-managed
# Postgres/Redis through kubectl port-forwards.
POSTGRES_HOST: "127.0.0.1"
POSTGRES_PORT: "5432"
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "password"
POSTGRES_DB: "postgres"
REDIS_HOST: "127.0.0.1"
REDIS_PORT: "6379"
REDIS_PASSWORD: "password"
API_SERVER_HOST: "127.0.0.1"
API_SERVER_PORT: "8080"
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
jobs:
changes:
# Gates the expensive test job. On pull_request / merge_group we use
# paths-filter; on schedule / push (tags) / workflow_dispatch the filter is
# skipped and the output defaults to `true` so the lane runs.
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
craft_k8s: ${{ steps.filter.outputs.craft_k8s || 'true' }}
steps:
- name: Checkout code
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706
id: filter
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
with:
filters: |
craft_k8s:
- 'backend/Dockerfile'
- 'backend/onyx/sandbox_proxy/**'
- 'backend/onyx/server/features/build/**'
- 'backend/onyx/server/features/skill/**'
- 'backend/onyx/skills/**'
- 'backend/shared_configs/**'
# Only the sandbox/craft-specific chart bits gate this lane. Generic
# chart changes (workers, deps, version bumps) are install-tested by
# the `Helm - Lint and Test Charts` lane (`ct install` into kind), so
# they don't need the full sandbox suite here.
- 'deployment/helm/charts/onyx/templates/sandbox-*'
- 'deployment/helm/charts/onyx/templates/sandbox-proxy/**'
- 'deployment/helm/charts/onyx/templates/network-policy-sandbox-*'
- 'deployment/helm/charts/onyx/templates/craft-*'
- 'deployment/helm/charts/onyx/templates/celery-worker-scheduled-tasks*'
- 'deployment/helm/charts/onyx/charts/code-interpreter-*'
- 'deployment/helm/charts/onyx/values-ci.yaml'
- 'backend/tests/integration/tests/craft/*.py'
- 'backend/tests/integration/tests/craft/k8s/**'
- 'backend/tests/common/craft/**'
- 'backend/tests/integration/conftest.py'
- 'backend/tests/integration/common_utils/**'
- '.github/workflows/pr-craft-k8s-tests.yml'
- '.github/actions/setup-python-and-install-dependencies/**'
- '.github/actions/setup-test-license/**'
- '.github/actions/login-ecr-pullthrough-cache/**'
discover-test-files:
# One shard per craft k8s test file (module-scoped pool fixtures keep a file on one shard).
needs: changes
if: needs.changes.outputs.craft_k8s == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
test-files: ${{ steps.set-matrix.outputs.test-files }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v6
with:
persist-credentials: false
- name: Discover craft k8s test files
id: set-matrix
run: |
set -eo pipefail
# `name` = log-friendly shard id; `path` = repo-root-relative pytest target.
entries=""
for f in $(find backend/tests/integration/tests/craft/k8s \
-maxdepth 1 -name 'test_*.py' -type f | sort); do
base=$(basename "$f" .py)
shard="${base#test_}"
entries="${entries}{\"path\":\"${f}\",\"name\":\"${shard}\"},"
done
if [ -z "${entries}" ]; then
echo "::error::no craft k8s test files discovered"
exit 1
fi
echo "test-files=[${entries%,}]" >> "$GITHUB_OUTPUT"
prepare-craft-assets:
name: Prepare Craft test assets
needs: changes
if: needs.changes.outputs.craft_k8s == 'true'
runs-on:
- runs-on
- runner=2cpu-linux-x64
- spot=false
- ${{ format('run-id={0}-craft-k8s-tools', github.run_id) }}
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: runs-on/action@4e5f72399b6b17f2e79c511c1b38a315a64d22dc
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v6
with:
persist-credentials: false
- name: Download and verify kind tools
run: |
set -euo pipefail
download() {
curl --fail --location --silent --show-error \
--retry 5 --retry-delay 2 --retry-all-errors \
--output "$2" "$1"
}
cache_dir="${RUNNER_TOOL_CACHE}/kind/${KIND_VERSION}/amd64"
kind_dir="${cache_dir}/kind/bin"
kubectl_dir="${cache_dir}/kubectl/bin"
mkdir -p "${kind_dir}" "${kubectl_dir}"
kind_filename="kind-linux-amd64"
kind_url="https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}"
download "${kind_url}/${kind_filename}" "${kind_dir}/${kind_filename}"
download "${kind_url}/${kind_filename}.sha256sum" "${kind_dir}/${kind_filename}.sha256sum"
(
cd "${kind_dir}"
grep "${kind_filename}" "${kind_filename}.sha256sum" | sha256sum --check -
mv "${kind_filename}" kind
rm "${kind_filename}.sha256sum"
chmod +x kind
)
kubectl_url="https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64"
download "${kubectl_url}/kubectl" "${kubectl_dir}/kubectl"
download "${kubectl_url}/kubectl.sha256" "${kubectl_dir}/kubectl.sha256"
(
cd "${kubectl_dir}"
echo "$(cat kubectl.sha256) kubectl" | sha256sum --check -
rm kubectl.sha256
chmod +x kubectl
)
- name: Download Helm chart dependencies
run: |
set -euo pipefail
retry() {
local attempt
for attempt in 1 2 3 4 5; do
if "$@"; then
return 0
fi
if [ "${attempt}" -eq 5 ]; then
return 1
fi
echo "Command failed (attempt ${attempt}/5); retrying ..."
sleep $((attempt * 5))
done
}
retry helm repo add --force-update ingress-nginx https://kubernetes.github.io/ingress-nginx
retry helm repo add --force-update opensearch https://opensearch-project.github.io/helm-charts
retry helm repo add --force-update cloudnative-pg https://cloudnative-pg.github.io/charts
retry helm repo add --force-update ot-container-kit https://ot-container-kit.github.io/helm-charts
retry helm repo add --force-update minio https://charts.min.io/
retry helm repo add --force-update code-interpreter https://onyx-dot-app.github.io/python-sandbox/
retry helm repo update
if ! retry helm dependency build --skip-refresh deployment/helm/charts/onyx; then
echo "helm dependency build failed; pulling disabled code-interpreter dependency directly"
code_interpreter_version=$(awk '
$1 == "-" && $2 == "name:" && $3 == "code-interpreter" { found = 1 }
found && $1 == "version:" { print $2; exit }
' deployment/helm/charts/onyx/Chart.yaml)
test -n "${code_interpreter_version}"
retry helm pull code-interpreter/code-interpreter \
--version "${code_interpreter_version}" \
--destination deployment/helm/charts/onyx/charts
fi
helm dependency list deployment/helm/charts/onyx
helm dependency list deployment/helm/charts/onyx \
| awk 'NR > 1 && NF && $4 != "ok" { bad = 1 } END { exit bad }'
# Tar preserves executable permissions for kind and kubectl.
- name: Package Craft test assets
run: |
set -euo pipefail
assets_dir="${RUNNER_TEMP}/craft-assets"
mkdir -p "${assets_dir}"
tar -C "${RUNNER_TOOL_CACHE}/kind/${KIND_VERSION}/amd64" \
-czf "${assets_dir}/kind-tools.tar.gz" .
tar -C deployment/helm/charts/onyx/charts \
-czf "${assets_dir}/helm-dependencies.tar.gz" .
- name: Upload Craft test assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: ${{ env.CRAFT_ASSETS_ARTIFACT }}
path: ${{ runner.temp }}/craft-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
build-images:
# Build both images in parallel (one matrix leg each) and push to the shared
# ECR repo so each test shard pulls prebuilt images instead of cold-building.
name: build-image (${{ matrix.image }})
needs: changes
if: needs.changes.outputs.craft_k8s == 'true'
strategy:
fail-fast: false
matrix:
include:
- image: sandbox
context: ./backend/onyx/server/features/build/sandbox/image
file: ./backend/onyx/server/features/build/sandbox/image/Dockerfile
# CI tests don't exercise skill runtime deps (soffice/pdftoppm/pptxgenjs),
# so skip LibreOffice et al. to keep the CI image lean. Browser stays
# on (ENABLE_BROWSER defaults true) — chromium is lighter and the
# browser integration test needs it.
extra_build_args: "ENABLE_SKILLS=false"
target: ""
- image: backend
context: ./backend
file: ./backend/Dockerfile
extra_build_args: ""
# The production image; the backend Dockerfile's default (last) stage is the
# dev variant.
target: runtime
runs-on:
- runs-on
- runner=8cpu-linux-x64
- spot=false
- volume=100gb
- ${{ format('run-id={0}-craft-k8s-build-{1}', github.run_id, matrix.image) }}
- extras=ecr-cache
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: runs-on/action@4e5f72399b6b17f2e79c511c1b38a315a64d22dc
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v6
with:
persist-credentials: false
- name: Log in to ECR pull-through cache
uses: ./.github/actions/login-ecr-pullthrough-cache
with:
ecr-registry: ${{ vars.ECR_REGISTRY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # ratchet:docker/setup-buildx-action@v4
- name: Build and push ${{ matrix.image }} image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}
# Empty target means the Dockerfile's default (last) stage.
target: ${{ matrix.target }}
platforms: linux/amd64
build-args: |
BASE_IMAGE_REGISTRY=${{ env.BASE_IMAGE_REGISTRY }}
${{ matrix.extra_build_args }}
tags: ${{ env.RUNS_ON_ECR_CACHE }}:craft-k8s-${{ matrix.image }}-${{ github.run_id }}
push: true
# Attestations attach as ECR referrers to the image digest, which is
# stable across runs and caps out at 100 per subject.
provenance: false
sbom: false
cache-from: type=gha,scope=craft-${{ matrix.image }}
cache-to: type=gha,scope=craft-${{ matrix.image }},mode=max
craft-k8s-tests:
name: craft-k8s (${{ matrix.test-file.name }})
needs: [changes, discover-test-files, prepare-craft-assets, build-images]
if: needs.changes.outputs.craft_k8s == 'true'
# spot=false: this is a long lane (full kind cluster per shard); on-demand
# avoids mid-run spot reclamation. Matches the compose lane.
runs-on:
- runs-on
- runner=8cpu-linux-x64
- spot=false
- volume=100gb
- ${{ format('run-id={0}-craft-k8s-tests-{1}', github.run_id, matrix.test-file.name) }}
- extras=ecr-cache
timeout-minutes: 40
strategy:
# fail-fast off so one shard's failure doesn't cancel the others.
fail-fast: false
matrix:
test-file: ${{ fromJson(needs.discover-test-files.outputs.test-files) }}
# id-token: OIDC for the setup-test-license step.
permissions:
contents: read
id-token: write
env:
PYTHONPATH: ./backend
MODEL_SERVER_HOST: "disabled"
DISABLE_TELEMETRY: "true"
DISABLE_VECTOR_DB: "false"
steps:
- uses: runs-on/action@4e5f72399b6b17f2e79c511c1b38a315a64d22dc
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v6
with:
persist-credentials: false
- name: Setup Python and Install Dependencies
uses: ./.github/actions/setup-python-and-install-dependencies
with:
requirements: |
backend/requirements/default.txt
backend/requirements/dev.txt
backend/requirements/ee.txt
- name: Download Craft test assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: ${{ env.CRAFT_ASSETS_ARTIFACT }}
path: ${{ runner.temp }}/craft-assets
- name: Install Craft test assets
run: |
set -euo pipefail
kind_cache_dir="${RUNNER_TOOL_CACHE}/kind/${KIND_VERSION}/amd64"
charts_dir="deployment/helm/charts/onyx/charts"
mkdir -p "${kind_cache_dir}" "${charts_dir}"
tar -C "${kind_cache_dir}" \
-xzf "${RUNNER_TEMP}/craft-assets/kind-tools.tar.gz"
tar -C "${charts_dir}" \
-xzf "${RUNNER_TEMP}/craft-assets/helm-dependencies.tar.gz"
- name: Log in to ECR pull-through cache
uses: ./.github/actions/login-ecr-pullthrough-cache
with:
ecr-registry: ${{ vars.ECR_REGISTRY }}
- name: Start local image registry
run: |
docker run -d --restart=always \
-p "127.0.0.1:${KIND_REGISTRY_PORT}:5000" \
--name "${KIND_REGISTRY_NAME}" \
"${BASE_IMAGE_REGISTRY:-docker.io}/library/registry:2"
# Re-push the prebuilt ECR images into this shard's local registry under the
# tags kind pulls (unauthenticated); the blob copy is cheap vs a cold build.
- name: Mirror prebuilt images into local registry
env:
ECR_CACHE: ${{ env.RUNS_ON_ECR_CACHE }}
RUN_ID: ${{ github.run_id }}
run: |
set -eo pipefail
mirror() {
local src="$1" dst="$2"
docker pull "$src"
docker tag "$src" "$dst"
docker push "$dst"
}
mirror "${ECR_CACHE}:craft-k8s-sandbox-${RUN_ID}" "${SANDBOX_CONTAINER_IMAGE}"
mirror "${ECR_CACHE}:craft-k8s-backend-${RUN_ID}" "${BACKEND_IMAGE}"
- name: Write kind config
run: |
cat > "${RUNNER_TEMP}/kind-config.yaml" <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
EOF
- name: Create kind cluster
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # ratchet:helm/kind-action@v1.14.0
with:
version: ${{ env.KIND_VERSION }}
kubectl_version: ${{ env.KUBECTL_VERSION }}
cluster_name: onyx-craft-ci
node_image: kindest/node:v1.33.1
config: ${{ runner.temp }}/kind-config.yaml
- name: Connect local registry to kind network
run: |
docker network connect "kind" "${KIND_REGISTRY_NAME}" || true
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${KIND_REGISTRY_PORT}"
docker exec onyx-craft-ci-control-plane mkdir -p "${REGISTRY_DIR}"
docker exec onyx-craft-ci-control-plane bash -c "cat > ${REGISTRY_DIR}/hosts.toml <<EOF
[host.\"http://${KIND_REGISTRY_NAME}:5000\"]
EOF"
# Pod spec sets nodeSelector onyx.app/workload=sandbox
# (kubernetes_sandbox_manager.py). In prod this matches a dedicated
# node pool; in kind we just label the single control-plane node.
- name: Label kind node for sandbox workload
run: kubectl label node onyx-craft-ci-control-plane onyx.app/workload=sandbox
- name: Validate Helm chart dependencies
run: |
helm dependency list deployment/helm/charts/onyx
helm dependency list deployment/helm/charts/onyx \
| awk 'NR > 1 && NF && $4 != "ok" { bad = 1 } END { exit bad }'
- name: Generate sandbox push key
run: |
python - <<'PY' >> "$GITHUB_ENV"
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.hazmat.primitives.serialization import NoEncryption
from cryptography.hazmat.primitives.serialization import PrivateFormat
key = Ed25519PrivateKey.generate()
raw = key.private_bytes(
encoding=Encoding.Raw,
format=PrivateFormat.Raw,
encryption_algorithm=NoEncryption(),
)
print(f"ONYX_SANDBOX_PUSH_PRIVATE_KEY={base64.b64encode(raw).decode('ascii')}")
PY
- name: Install Onyx chart into kind
run: |
set -eo pipefail
kubectl create namespace "${HELM_RELEASE_NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl config set-context --current --namespace="${HELM_RELEASE_NAMESPACE}"
for attempt in 1 2 3; do
if helm upgrade --install onyx deployment/helm/charts/onyx \
-n "${HELM_RELEASE_NAMESPACE}" \
-f deployment/helm/charts/onyx/values-ci.yaml \
--set "api.image.repository=localhost:5001/onyx-backend" \
--set "api.image.tag=ci" \
--set "celery_shared.image.repository=localhost:5001/onyx-backend" \
--set "celery_shared.image.tag=ci" \
--set-string "auth.sandboxPushSecret.values.private_key=${ONYX_SANDBOX_PUSH_PRIVATE_KEY}" \
--timeout 10m; then
exit 0
fi
if [ "$attempt" -lt 3 ]; then
echo "helm install failed (attempt ${attempt}/3); waiting before retry ..."
kubectl -n "${HELM_RELEASE_NAMESPACE}" get pods || true
sleep 20
fi
done
helm status onyx -n "${HELM_RELEASE_NAMESPACE}" || true
kubectl get pods -A || true
exit 1
- name: Wait for chart Postgres ready
run: |
set -eo pipefail
kubectl -n "${HELM_RELEASE_NAMESPACE}" rollout status \
deployment/onyx-cloudnative-pg --timeout=180s
# Don't add a `pod -l cnpg.io/cluster` wait — it also matches the
# transient initdb pod (deleted mid-wait → NotFound).
kubectl -n "${HELM_RELEASE_NAMESPACE}" wait \
--for=condition=Ready cluster/onyx-pg \
--timeout=300s
- name: Forward chart Postgres to runner
run: |
set -eo pipefail
# setsid survives the step-boundary process-group cleanup; the loop
# survives kubectl port-forward's mid-run drops.
setsid bash -c "while true; do kubectl -n \"$HELM_RELEASE_NAMESPACE\" port-forward svc/onyx-pg-rw \"$POSTGRES_PORT\":5432; sleep 1; done" \
> "${RUNNER_TEMP}/postgres-port-forward.log" 2>&1 &
echo "$!" > "${RUNNER_TEMP}/postgres-port-forward.pid"
for _ in $(seq 1 60); do
if python -c 'import os, psycopg2; conn = psycopg2.connect(dbname=os.environ["POSTGRES_DB"], user=os.environ["POSTGRES_USER"], password=os.environ["POSTGRES_PASSWORD"], host=os.environ["POSTGRES_HOST"], port=os.environ["POSTGRES_PORT"], connect_timeout=2); conn.close()'; then
echo "chart Postgres is reachable at ${POSTGRES_HOST}:${POSTGRES_PORT}"
exit 0
fi
sleep 2
done
cat "${RUNNER_TEMP}/postgres-port-forward.log" || true
exit 1
- name: Wait for API startup migrations
run: |
kubectl -n "${HELM_RELEASE_NAMESPACE}" rollout status \
deployment/onyx-api-server --timeout=360s
kubectl -n "${HELM_RELEASE_NAMESPACE}" exec deployment/onyx-api-server -- \
alembic current --check-heads
- name: Wait for chart OpenSearch ready
run: |
kubectl -n "${HELM_RELEASE_NAMESPACE}" rollout status \
statefulset/onyx-opensearch-master --timeout=420s
- name: Wait for chart Redis ready
run: |
set -eo pipefail
kubectl -n "${HELM_RELEASE_NAMESPACE}" rollout status \
deployment/redis-operator --timeout=180s
for _ in $(seq 1 120); do
endpoints=$(kubectl -n "${HELM_RELEASE_NAMESPACE}" get endpoints onyx \
-o jsonpath='{.subsets[0].addresses[0].ip}' 2>/dev/null || true)
if [ -n "${endpoints}" ]; then
exit 0
fi
sleep 2
done
kubectl -n "${HELM_RELEASE_NAMESPACE}" get redis,pods,svc || true
exit 1
- name: Forward chart Redis to runner
run: |
set -eo pipefail
setsid bash -c "while true; do kubectl -n \"$HELM_RELEASE_NAMESPACE\" port-forward svc/onyx \"$REDIS_PORT\":6379; sleep 1; done" \
> "${RUNNER_TEMP}/redis-port-forward.log" 2>&1 &
echo "$!" > "${RUNNER_TEMP}/redis-port-forward.pid"
for _ in $(seq 1 60); do
if python -c 'import os; from redis import Redis; Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), password=os.environ["REDIS_PASSWORD"], socket_connect_timeout=2).ping()'; then
echo "chart Redis is reachable at ${REDIS_HOST}:${REDIS_PORT}"
exit 0
fi
sleep 2
done
cat "${RUNNER_TEMP}/redis-port-forward.log" || true
exit 1
- name: Wait for chart MinIO ready
run: |
kubectl -n "${HELM_RELEASE_NAMESPACE}" rollout status \
deployment/onyx-minio --timeout=180s
if kubectl -n "${HELM_RELEASE_NAMESPACE}" get job/onyx-minio-post-job >/dev/null 2>&1; then
kubectl -n "${HELM_RELEASE_NAMESPACE}" wait \
--for=condition=complete job/onyx-minio-post-job \
--timeout=180s
else
echo "MinIO post-install hook job already completed and was deleted by Helm"
fi
- name: Forward chart MinIO to runner
run: |
set -eo pipefail
setsid bash -c "while true; do kubectl -n \"$HELM_RELEASE_NAMESPACE\" port-forward svc/onyx-minio 9004:9000; sleep 1; done" \
> "${RUNNER_TEMP}/minio-port-forward.log" 2>&1 &
echo "$!" > "${RUNNER_TEMP}/minio-port-forward.pid"
for _ in $(seq 1 30); do
if curl -fsS "${S3_ENDPOINT_URL}/minio/health/ready"; then
echo "chart MinIO is reachable at ${S3_ENDPOINT_URL}"
exit 0
fi
sleep 1
done
cat "${RUNNER_TEMP}/minio-port-forward.log" || true
exit 1
- name: Wait for chart app runtime ready
run: |
set -eo pipefail
for deployment in \
onyx-sandbox-proxy \
onyx-api-server \
onyx-celery-beat \
onyx-celery-worker-primary \
onyx-celery-worker-light \
onyx-celery-worker-heavy \
onyx-celery-worker-scheduled-tasks; do
kubectl -n "${HELM_RELEASE_NAMESPACE}" rollout status \
"deployment/${deployment}" --timeout=360s
done
# Tier-gated EE routes (test_skill_push user-groups) need a license.
- name: Fetch dev license
uses: ./.github/actions/setup-test-license
with:
aws-oidc-role-arn: ${{ secrets.AWS_OIDC_ROLE_ARN }}
- name: Seed dev license
# The api-server can briefly restart if Postgres/cnpg flaps right after
# rollout, so re-assert readiness and retry the exec.
run: |
set -eo pipefail
for attempt in $(seq 1 5); do
kubectl -n "${HELM_RELEASE_NAMESPACE}" rollout status \
deployment/onyx-api-server --timeout=120s || true
if kubectl -n "${HELM_RELEASE_NAMESPACE}" exec deployment/onyx-api-server -- \
env ONYX_DEV_LICENSE="${ONYX_DEV_LICENSE}" python -m scripts.seed_dev_license; then
echo "seeded dev license (attempt ${attempt})"
exit 0
fi
echo "seed dev license failed (attempt ${attempt}); retrying ..."
sleep 10
done
exit 1
- name: Forward chart API to runner
run: |
set -eo pipefail
setsid bash -c "while true; do kubectl -n \"$HELM_RELEASE_NAMESPACE\" port-forward svc/onyx-api-service \"$API_SERVER_PORT\":8080; sleep 1; done" \
> "${RUNNER_TEMP}/api-port-forward.log" 2>&1 &
echo "$!" > "${RUNNER_TEMP}/api-port-forward.pid"
for _ in $(seq 1 60); do
if curl -fsS "http://${API_SERVER_HOST}:${API_SERVER_PORT}/health"; then
echo "chart API reachable at ${API_SERVER_HOST}:${API_SERVER_PORT}"
exit 0
fi
sleep 2
done
cat "${RUNNER_TEMP}/api-port-forward.log" || true
exit 1
# The test process runs on the CI runner, which has no cluster DNS.
# `socket.gethostbyname(IP)` returns the IP unchanged, so resolving
# the Service ClusterIP here lets _resolve_proxy_ip in the manager
# succeed without any DNS resolution. host_aliases on sandbox pods
# then pins `sandbox-proxy` to this same IP.
- name: Resolve proxy Service IP and export to env
run: |
PROXY_IP=$(kubectl -n "${HELM_RELEASE_NAMESPACE}" get svc \
onyx-sandbox-proxy -o jsonpath='{.spec.clusterIP}')
echo "SANDBOX_PROXY_HOST=${PROXY_IP}" >> "$GITHUB_ENV"
echo "resolved proxy ClusterIP: ${PROXY_IP}"
# Sandbox pods are torn down on failure before `kind export logs` runs,
# so kubelet GCs their opencode-serve logs. Stream them to disk live (one
# follower per pod, retrying the attach) so failures leave a trail.
- name: Start sandbox pod log capture
run: |
mkdir -p sandbox-logs
# The inner `bash -c` body is single-quoted on purpose: its $vars
# expand at runtime inside that shell, not here.
# shellcheck disable=SC2016
nohup bash -c '
while true; do
for name in $(kubectl -n onyx-sandboxes get pods \
-o jsonpath="{.items[*].metadata.name}" 2>/dev/null); do
if mkdir "sandbox-logs/.lock-${name}" 2>/dev/null; then
(
for _ in $(seq 1 120); do
kubectl -n onyx-sandboxes logs --all-containers=true \
--prefix=true -f "$name" \
>> "sandbox-logs/${name}.log" 2>&1 && break
kubectl -n onyx-sandboxes get pod "$name" \
>/dev/null 2>&1 || break
sleep 1
done
) &
fi
done
sleep 1
done
' > sandbox-logs/_watcher.log 2>&1 &
echo "$!" > "${RUNNER_TEMP}/sandbox-log-watcher.pid"
disown || true
echo "sandbox pod log capture started (pid $!)"
- name: Run Craft K8s tests (${{ matrix.test-file.name }})
shell: script -q -e -c "bash --noprofile --norc -eo pipefail {0}"
run: |
py.test \
--durations=8 \
-o junit_family=xunit2 \
-v \
--ff \
-m "not nightly" \
"${{ matrix.test-file.path }}"
- name: Collect diagnostics on failure
if: failure()
run: |
mkdir -p kind-logs
echo "::group::sandbox pod logs"
if compgen -G "sandbox-logs/*.log" > /dev/null; then
for f in sandbox-logs/*.log; do echo "--- ${f} ---"; cat "${f}" || true; done
else
echo "no sandbox pod logs were captured"
fi
echo "::endgroup::"
echo "::group::disk and docker status"
df -h || true; docker system df || true; docker images || true; ls -lah /tmp || true
echo "::endgroup::"
echo "::group::MinIO diagnostics"
kubectl -n "${HELM_RELEASE_NAMESPACE}" get pods,svc,job -l release=onyx || true
kubectl -n "${HELM_RELEASE_NAMESPACE}" logs deployment/onyx-minio --tail=500 || true
kubectl -n "${HELM_RELEASE_NAMESPACE}" logs job/onyx-minio-post-job --all-containers --tail=500 || true
python - <<'PY' || true
import os, boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url=os.environ.get("S3_ENDPOINT_URL"),
aws_access_key_id=os.environ.get("S3_AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.environ.get("S3_AWS_SECRET_ACCESS_KEY"),
region_name=os.environ.get("AWS_REGION_NAME") or "us-east-1",
config=Config(s3={"addressing_style": "path"}),
)
print("buckets:", [b["Name"] for b in s3.list_buckets().get("Buckets", [])])
for b in s3.list_buckets().get("Buckets", []):
objs = s3.list_objects_v2(Bucket=b["Name"]).get("Contents", [])
print(b["Name"], "->", [o["Key"] for o in objs][:20])
PY
echo "::endgroup::"
echo "::group::chart resources and app logs"
helm status onyx -n "${HELM_RELEASE_NAMESPACE}" || true
for dep in onyx-api-server onyx-celery-worker-primary \
onyx-celery-worker-heavy onyx-celery-worker-scheduled-tasks \
onyx-cloudnative-pg redis-operator; do
kubectl -n "${HELM_RELEASE_NAMESPACE}" logs "deployment/${dep}" --all-containers --tail=500 || true
done
kubectl -n "${HELM_RELEASE_NAMESPACE}" logs statefulset/onyx-opensearch-master --all-containers --tail=500 || true
kubectl -n "${HELM_RELEASE_NAMESPACE}" get cluster,redis,statefulset,pods,svc,pvc || true
echo "::endgroup::"
echo "::group::port-forward logs"
for f in postgres redis minio api; do
echo "--- ${f} ---"; cat "${RUNNER_TEMP}/${f}-port-forward.log" 2>/dev/null || true
done
echo "::endgroup::"
kind export logs ./kind-logs --name onyx-craft-ci || true
kubectl get pods -A -o wide > kind-logs/pods.txt 2>&1 || true
kubectl get svc -A -o wide > kind-logs/services.txt 2>&1 || true
kubectl describe pods -n onyx-sandboxes > kind-logs/sandbox-pods-describe.txt 2>&1 || true
kubectl describe pods -n "${HELM_RELEASE_NAMESPACE}" > kind-logs/release-ns-pods-describe.txt 2>&1 || true
kubectl -n "${HELM_RELEASE_NAMESPACE}" logs -l app.kubernetes.io/component=sandbox-proxy --tail=500 > kind-logs/sandbox-proxy.log 2>&1 || true
cp "${RUNNER_TEMP}"/*-port-forward.log kind-logs/ 2>/dev/null || true
- name: Upload logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: craft-k8s-logs-${{ matrix.test-file.name }}
path: |
kind-logs/
sandbox-logs/
retention-days: 7
- name: Cleanup kind cluster and port-forwards
if: always()
run: |
set +e
for pid_file in \
"${RUNNER_TEMP}/postgres-port-forward.pid" \
"${RUNNER_TEMP}/redis-port-forward.pid" \
"${RUNNER_TEMP}/minio-port-forward.pid" \
"${RUNNER_TEMP}/api-port-forward.pid" \
"${RUNNER_TEMP}/sandbox-log-watcher.pid"; do
if [ -f "${pid_file}" ]; then
kill "$(cat "${pid_file}")" 2>/dev/null || true
fi
done
kind delete cluster --name onyx-craft-ci || true
docker network disconnect kind "${KIND_REGISTRY_NAME}" 2>/dev/null || true
docker rm -f "${KIND_REGISTRY_NAME}" 2>/dev/null || true
craft-k8s-tests-required:
# Single required status check for the craft-k8s suite. Always runs so
# branch protection has a stable target, and passes cleanly when `changes`
# reports no relevant paths changed (i.e. the test job was legitimately
# skipped).
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [changes, discover-test-files, build-images, craft-k8s-tests]
if: ${{ always() }}
steps:
- name: Check job status
env:
CHANGES_RESULT: ${{ needs.changes.result }}
RUN_TESTS: ${{ needs.changes.outputs.craft_k8s }}
DISCOVER_RESULT: ${{ needs.discover-test-files.result }}
BUILD_RESULT: ${{ needs.build-images.result }}
TEST_RESULT: ${{ needs.craft-k8s-tests.result }}
run: |
# Fail closed if `changes` didn't succeed. Otherwise an empty
# RUN_TESTS (which is what we'd see when `changes` failed/cancelled)
# would be indistinguishable from "no relevant paths changed" and we
# would incorrectly pass the required check.
if [ "${CHANGES_RESULT}" != "success" ]; then
echo "changes job did not succeed (result: ${CHANGES_RESULT})"
exit 1
fi
if [ "${RUN_TESTS}" != "true" ]; then
echo "No relevant paths changed -- required check passes."
exit 0
fi
if [ "${DISCOVER_RESULT}" != "success" ] || [ "${BUILD_RESULT}" != "success" ]; then
echo "Setup results: discover-test-files=${DISCOVER_RESULT}, build-images=${BUILD_RESULT}"
exit 1
fi
if [ "${TEST_RESULT}" != "success" ]; then
echo "Test result: ${TEST_RESULT}"
exit 1
fi
echo "All tests passed."