Files
foxhunt/.gitlab-ci.yml
jgrusewski 57e22c01a8 refactor: update K8s, CI, Docker, Prometheus, scripts, and FXT CLI for api rename
- K8s: rename api-gateway → api manifests, delete web-gateway, update network policies
- CI: rename compile/deploy jobs, delete web-gateway jobs
- Docker: rename service in compose files
- Prometheus: update scrape targets and alert rules
- Scripts: update binary references in build/test/cert scripts
- FXT CLI: rename api_gateway_url → api_url (with serde alias for compat)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:46:36 +01:00

1813 lines
65 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GitLab CI/CD — Foxhunt
# Two runners: CPU (compile/build/deploy) and GPU (training)
# CI builder images:
# ci-builder: CUDA 12.4 + Rust 1.89 + protoc + sccache (CUDA stubs for compile)
# ci-builder-cpu: Rust 1.89 + protoc + sccache, no CUDA (for service binaries)
# Service images: pushed to Scaleway Container Registry (Kaniko)
#
# Runner routing (by tags):
# kapsule,rust → CPU runner → ci-compile-cpu pool (POP2-32C-128G, 32 vCPU)
# kapsule,l40s → L40S runner → ci-training (L40S 48GB)
# kapsule,h100 → H100 runner → ci-training-h100 (H100 80GB)
# Override nodeSelector: KUBERNETES_NODE_SELECTOR_k8s.scaleway.com/pool-name: "<name>" (k=v format)
# Pools: ci-compile-cpu (POP2-32C-128G), ci-training (L40S), ci-training-h100 (H100), services, gitlab
# All compilation (services + training) runs on ci-compile-cpu. CUDA stubs in ci-builder image.
# Ensure pipeline is always created — individual job rules handle filtering.
# Without this, GitLab CE may silently drop push pipelines when changes:
# evaluation fails to match (known issue with path glob evaluation).
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "push"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "api"
- if: $CI_PIPELINE_SOURCE == "web"
stages:
- prepare
- scan
- test
- compile
- train
- deploy
variables:
SQLX_OFFLINE: "true"
CARGO_TERM_COLOR: always
# CUDA_COMPUTE_CAP is set at runtime by detect_gpu() — L4=89, H100=90
# Do NOT hardcode here; sccache is partitioned by compute cap below
# Stable JWT secret for api tests (prevents env-var race between parallel tests)
JWT_SECRET: "CiTestSecret_Kx7mP9nR2sW5vY8bC3fG6jH1kL4pQ7tZ0uN9dM5eV8xS2wT6yA4zB_64chars_min"
# Training data on block storage PVC (mounted at /mnt/training-data by runner)
FOXHUNT_DATA_DIR: "/mnt/training-data/futures-baseline"
# sccache base dir on PVC — GPU stages override to /mnt/sccache/sm_<cap>
SCCACHE_DIR: "/mnt/sccache"
RUSTC_WRAPPER: /usr/local/bin/sccache
# Build profile: "dev-release" (thin LTO, fast) is default for quick iteration
# Set DEV_RELEASE=false in pipeline vars to use full "release" (fat LTO) profile
DEV_RELEASE: "true"
CARGO_BUILD_PROFILE: dev-release
# S3 credentials for Kaniko registry auth and Dockerfile builds
AWS_ACCESS_KEY_ID: $SCW_ACCESS_KEY
AWS_SECRET_ACCESS_KEY: $SCW_SECRET_KEY
# CI builder image on Scaleway Container Registry (reachable by Kapsule nodes)
CI_BUILDER_IMAGE: rg.fr-par.scw.cloud/foxhunt-ci/ci-builder:latest
CI_BUILDER_CPU_IMAGE: rg.fr-par.scw.cloud/foxhunt-ci/ci-builder-cpu:latest
# Scaleway Container Registry for service images
REGISTRY: rg.fr-par.scw.cloud/foxhunt-ci
# IaC runner image on Scaleway Container Registry
INFRA_RUNNER_IMAGE: rg.fr-par.scw.cloud/foxhunt-ci/infra-runner:latest
# Generic runtime images for S3 binary share (service pods + training pods + node builder)
RUNTIME_IMAGE: rg.fr-par.scw.cloud/foxhunt-ci/foxhunt-runtime:latest
TRAINING_RUNTIME_IMAGE: rg.fr-par.scw.cloud/foxhunt-ci/foxhunt-training-runtime:latest
# Node builder image eliminated — web dashboard uses node:22-slim + inline rclone
# --------------------------------------------------------------------------
# Stage 0: Build CI builder image → push to Scaleway CR
# --------------------------------------------------------------------------
build-ci-builder:
stage: prepare
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
tags:
- kapsule
- docker
rules:
- if: $CI_PIPELINE_SOURCE == "push"
changes:
- infra/docker/Dockerfile.ci-builder
when: on_success
- when: manual
allow_failure: true
before_script:
- mkdir -p /kaniko/.docker
- >-
echo "{\"auths\":{
\"rg.fr-par.scw.cloud\":{\"username\":\"nologin\",\"password\":\"${SCW_SECRET_KEY}\"},
\"https://index.docker.io/v1/\":{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}
}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/infra/docker/Dockerfile.ci-builder"
--cache=true --cache-repo="${REGISTRY}/cache"
--destination "${CI_BUILDER_IMAGE}"
# --------------------------------------------------------------------------
# Stage 0a: Build CPU-only CI builder image → push to Scaleway CR
# --------------------------------------------------------------------------
build-ci-builder-cpu:
stage: prepare
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
tags:
- kapsule
- docker
rules:
- if: $CI_PIPELINE_SOURCE == "push"
changes:
- infra/docker/Dockerfile.ci-builder-cpu
when: on_success
- when: manual
allow_failure: true
before_script:
- mkdir -p /kaniko/.docker
- >-
echo "{\"auths\":{
\"rg.fr-par.scw.cloud\":{\"username\":\"nologin\",\"password\":\"${SCW_SECRET_KEY}\"},
\"https://index.docker.io/v1/\":{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}
}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/infra/docker/Dockerfile.ci-builder-cpu"
--cache=true --cache-repo="${REGISTRY}/cache"
--destination "${CI_BUILDER_CPU_IMAGE}"
# --------------------------------------------------------------------------
# Stage 0b: Build devcontainer image → push to internal GitLab registry
# --------------------------------------------------------------------------
build-devcontainer:
stage: prepare
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
tags:
- kapsule
- docker
rules:
- if: $CI_PIPELINE_SOURCE == "push"
changes:
- .devcontainer/**
when: on_success
- when: manual
allow_failure: true
before_script:
- mkdir -p /kaniko/.docker
- >-
echo "{\"auths\":{
\"rg.fr-par.scw.cloud\":{\"username\":\"nologin\",\"password\":\"${SCW_SECRET_KEY}\"},
\"https://index.docker.io/v1/\":{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}
}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/.devcontainer/Dockerfile"
--cache=true --cache-repo="${REGISTRY}/cache"
--destination "rg.fr-par.scw.cloud/foxhunt-ci/devcontainer:${CI_COMMIT_SHA}"
--destination "rg.fr-par.scw.cloud/foxhunt-ci/devcontainer:latest"
# --------------------------------------------------------------------------
# Stage 0c: Build infra-runner image → push to Scaleway CR
# --------------------------------------------------------------------------
build-infra-runner:
stage: prepare
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
tags:
- kapsule
- docker
rules:
- if: $CI_PIPELINE_SOURCE == "push"
changes:
- infra/docker/Dockerfile.infra-runner
when: on_success
- when: manual
allow_failure: true
before_script:
- mkdir -p /kaniko/.docker
- >-
echo "{\"auths\":{
\"rg.fr-par.scw.cloud\":{\"username\":\"nologin\",\"password\":\"${SCW_SECRET_KEY}\"},
\"https://index.docker.io/v1/\":{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}
}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/infra/docker/Dockerfile.infra-runner"
--cache=true --cache-repo="${REGISTRY}/cache"
--destination "${INFRA_RUNNER_IMAGE}"
# --------------------------------------------------------------------------
# Stage 0d: Build generic runtime base images → push to Scaleway CR
# --------------------------------------------------------------------------
build-foxhunt-runtime:
stage: prepare
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
tags:
- kapsule
- docker
rules:
- if: $CI_PIPELINE_SOURCE == "push"
changes:
- infra/docker/Dockerfile.foxhunt-runtime
when: on_success
- when: manual
allow_failure: true
before_script:
- mkdir -p /kaniko/.docker
- >-
echo "{\"auths\":{
\"rg.fr-par.scw.cloud\":{\"username\":\"nologin\",\"password\":\"${SCW_SECRET_KEY}\"},
\"https://index.docker.io/v1/\":{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}
}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/infra/docker/Dockerfile.foxhunt-runtime"
--cache=true --cache-repo="${REGISTRY}/cache"
--destination "${REGISTRY}/foxhunt-runtime:latest"
build-foxhunt-training-runtime:
stage: prepare
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
tags:
- kapsule
- docker
rules:
- if: $CI_PIPELINE_SOURCE == "push"
changes:
- infra/docker/Dockerfile.foxhunt-training-runtime
when: on_success
- when: manual
allow_failure: true
before_script:
- mkdir -p /kaniko/.docker
- >-
echo "{\"auths\":{
\"rg.fr-par.scw.cloud\":{\"username\":\"nologin\",\"password\":\"${SCW_SECRET_KEY}\"},
\"https://index.docker.io/v1/\":{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}
}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/infra/docker/Dockerfile.foxhunt-training-runtime"
--cache=true --cache-repo="${REGISTRY}/cache"
--destination "${REGISTRY}/foxhunt-training-runtime:latest"
# --------------------------------------------------------------------------
# Stage 0.5: Scan runtime images for vulnerabilities (Trivy)
# --------------------------------------------------------------------------
scan-runtime-images:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
tags:
- kapsule
- docker
variables:
KUBERNETES_CPU_REQUEST: "200m"
KUBERNETES_CPU_LIMIT: "500m"
KUBERNETES_MEMORY_REQUEST: "256Mi"
KUBERNETES_MEMORY_LIMIT: "512Mi"
needs:
- job: build-foxhunt-runtime
optional: true
- job: build-foxhunt-training-runtime
optional: true
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
cache:
key: trivy-db
paths:
- .trivy-cache/
before_script:
- export TRIVY_USERNAME=nologin
- export TRIVY_PASSWORD=${SCW_SECRET_KEY}
script:
- echo "Scanning runtime images for CRITICAL/HIGH vulnerabilities..."
- trivy image
--cache-dir .trivy-cache
--exit-code 1
--severity CRITICAL,HIGH
--ignore-unfixed
--no-progress
"${REGISTRY}/foxhunt-runtime:latest"
- trivy image
--cache-dir .trivy-cache
--exit-code 1
--severity CRITICAL,HIGH
--ignore-unfixed
--no-progress
"${REGISTRY}/foxhunt-training-runtime:latest"
- echo "Image scan passed — no fixable CRITICAL/HIGH vulnerabilities."
allow_failure: false
# Base template for Rust jobs — pre-baked CI builder from Scaleway CR
# Image pre-exists in SCR; rebuild via build-ci-builder when Dockerfile changes
# Runs on ci-compile-cpu pool (POP2-32C-128G: 32 vCPU, 128GB RAM) via CPU runner
# CPU budget: 14 CPUs per job for tests; per-service compile jobs use 4 CPUs each
# (Scaleway quota: 1× POP2-32C-128G, so both jobs must share)
# Base template for jobs needing CUDA builder (test stage only — compile uses .rust-base-cpu)
.rust-base:
image: ${CI_BUILDER_IMAGE}
tags:
- kapsule
- rust
variables:
CUDA_COMPUTE_CAP: "89"
CARGO_BUILD_JOBS: "14"
KUBERNETES_CPU_REQUEST: "14000m"
KUBERNETES_CPU_LIMIT: "15500m"
KUBERNETES_MEMORY_REQUEST: "24Gi"
KUBERNETES_MEMORY_LIMIT: "56Gi"
before_script:
# Mold linker fallback — .cargo/config.toml uses -fuse-ld=mold via clang.
# clang's -fuse-ld=mold searches for "ld.mold" (not "mold") in its linker paths.
# If mold isn't installed, create ld.mold wrapper that delegates to ld.lld.
- |
if ! command -v mold &>/dev/null; then
printf '#!/bin/sh\nexec ld.lld "$@"\n' > /usr/local/bin/ld.mold
chmod +x /usr/local/bin/ld.mold
echo "mold not found, falling back to lld via ld.mold wrapper"
fi
# Base template for CPU-only Rust jobs — lightweight builder without CUDA
# Uses ci-builder-cpu image (rust:1.89-slim, ~2-3GB, no CUDA toolkit)
# Per-service compile jobs override CPU/memory to 4 CPUs each (see .compile-service-base)
.rust-base-cpu:
image: ${CI_BUILDER_CPU_IMAGE}
tags:
- kapsule
- rust
variables:
# No CUDA_COMPUTE_CAP — CPU-only builds don't compile CUDA kernels
# Request 10 CPUs (burstable to 15.5) — two compile jobs + web-dashboard fit on 1 POP2-32C-128G node
CARGO_BUILD_JOBS: "10"
KUBERNETES_NODE_SELECTOR_POOL: "k8s.scaleway.com/pool-name=ci-compile-cpu"
KUBERNETES_CPU_REQUEST: "10000m"
KUBERNETES_CPU_LIMIT: "15500m"
KUBERNETES_MEMORY_REQUEST: "18Gi"
KUBERNETES_MEMORY_LIMIT: "56Gi"
before_script:
# Mold linker fallback — same as .rust-base
- |
if ! command -v mold &>/dev/null; then
printf '#!/bin/sh\nexec ld.lld "$@"\n' > /usr/local/bin/ld.mold
chmod +x /usr/local/bin/ld.mold
echo "mold not found, falling back to lld via ld.mold wrapper"
fi
# Base template for per-service compile jobs (CPU-only, no CUDA)
# Each service gets its own job with dependency-aware changes: filters.
# CARGO_BUILD_JOBS=4 (down from 10) since up to 8 jobs may run on the same node.
.compile-service-base:
extends: .rust-base-cpu
stage: compile
needs: []
interruptible: true
variables:
CARGO_BUILD_JOBS: "4"
KUBERNETES_CPU_REQUEST: "4000m"
KUBERNETES_CPU_LIMIT: "6000m"
KUBERNETES_MEMORY_REQUEST: "8Gi"
KUBERNETES_MEMORY_LIMIT: "16Gi"
script:
- mkdir -p "$SCCACHE_DIR" && sccache --zero-stats || true
- PROFILE=${DEV_RELEASE:+dev-release}; PROFILE=${PROFILE:-release}
- |
# CalVer: YYYY.MM.<pipeline_iid>
YEAR=$(date -d "$CI_COMMIT_TIMESTAMP" +%Y 2>/dev/null || date +%Y)
MONTH=$(date -d "$CI_COMMIT_TIMESTAMP" +%m 2>/dev/null || date +%m)
export FOXHUNT_BUILD_VERSION="${YEAR}.${MONTH}.${CI_PIPELINE_IID:-0}"
echo "Build version: $FOXHUNT_BUILD_VERSION"
- echo "Building ${SERVICE_PACKAGE} with --profile $PROFILE (CPU-only, no CUDA)"
- TARGET_DIR="target/$PROFILE"
- cargo build --profile $PROFILE -p ${SERVICE_PACKAGE}
- sccache --show-stats || true
- mkdir -p build-out/services
- |
cp $TARGET_DIR/${SERVICE_PACKAGE} build-out/services/
strip build-out/services/${SERVICE_PACKAGE}
- ls -lh build-out/services/
- echo "${SERVICE_PACKAGE} binary ready as CI artifact"
artifacts:
paths:
- build-out/services/
expire_in: 1 day
# --------------------------------------------------------------------------
# Stage 2: tests
# --------------------------------------------------------------------------
test:
extends: .rust-base
stage: test
needs: []
rules:
# Tests disabled by default — compile+deploy is the fast path for now.
# Trigger manually from the pipeline UI when needed.
- if: $CI_PIPELINE_SOURCE == "push"
when: manual
allow_failure: true
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
allow_failure: true
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
when: manual
allow_failure: true
services:
- name: redis:7-alpine
alias: redis
script:
# Detect GPU if available (nvidia runtime), otherwise use CUDA_COMPUTE_CAP from variables
- |
if command -v nvidia-smi &>/dev/null; then
export LD_LIBRARY_PATH=$(echo "$LD_LIBRARY_PATH" | tr ':' '\n' | grep -v stubs | tr '\n' ':' | sed 's/:$//')
export CUDA_COMPUTE_CAP=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader | head -1 | tr -d '.')
echo "GPU=$(nvidia-smi --query-gpu=name --format=csv,noheader) CUDA_COMPUTE_CAP=$CUDA_COMPUTE_CAP"
else
echo "No nvidia-smi — using CUDA stubs, CUDA_COMPUTE_CAP=$CUDA_COMPUTE_CAP"
fi
- export SCCACHE_DIR="/mnt/sccache/sm_${CUDA_COMPUTE_CAP}"
- mkdir -p "$SCCACHE_DIR"
# Wait for Redis sidecar (K8s executor doesn't wait for service readiness)
- |
for i in $(seq 1 30); do
if bash -c 'echo PING > /dev/tcp/localhost/6379' 2>/dev/null; then
echo "Redis ready"; break
fi
echo "Waiting for Redis... ($i/30)"; sleep 1
done
- sccache --zero-stats || true
- cargo test --workspace --lib -- --skip model_loader::tests
- sccache --show-stats || true
# --------------------------------------------------------------------------
# Stage 3a: Per-service compile jobs (CPU-only, no CUDA)
# Each service compiled independently with dependency-aware changes: filters.
# sccache shared across jobs — incremental crate compilation is cached.
# --------------------------------------------------------------------------
compile-api:
extends: .compile-service-base
variables:
SERVICE_PACKAGE: api
rules: &rules-api
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- services/api/**
- crates/common/**
- crates/config/**
- crates/trading_engine/**
- bin/fxt/**
- Cargo.toml
- Cargo.lock
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
compile-trading-service:
extends: .compile-service-base
variables:
SERVICE_PACKAGE: trading-service
rules: &rules-trading-service
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- services/trading_service/**
- services/api_gateway/**
- crates/common/**
- crates/config/**
- crates/data/**
- crates/database/**
- crates/ml/**
- crates/risk/**
- crates/storage/**
- crates/trading_engine/**
- Cargo.toml
- Cargo.lock
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
compile-ml-training-service:
extends: .compile-service-base
variables:
SERVICE_PACKAGE: ml-training-service
rules: &rules-ml-training-service
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- services/ml_training_service/**
- crates/common/**
- crates/config/**
- crates/data/**
- crates/ml/**
- crates/risk/**
- crates/storage/**
- crates/trading_engine/**
- Cargo.toml
- Cargo.lock
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
compile-backtesting-service:
extends: .compile-service-base
variables:
SERVICE_PACKAGE: backtesting-service
rules: &rules-backtesting-service
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- services/backtesting_service/**
- crates/common/**
- crates/config/**
- crates/data/**
- crates/ml/**
- crates/model_loader/**
- crates/risk/**
- crates/storage/**
- crates/trading_engine/**
- bin/fxt/**
- Cargo.toml
- Cargo.lock
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
compile-trading-agent-service:
extends: .compile-service-base
variables:
SERVICE_PACKAGE: trading-agent-service
rules: &rules-trading-agent-service
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- services/trading_agent_service/**
- crates/common/**
- crates/config/**
- crates/ml/**
- crates/risk/**
- Cargo.toml
- Cargo.lock
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
compile-broker-gateway:
extends: .compile-service-base
variables:
SERVICE_PACKAGE: broker-gateway
rules: &rules-broker-gateway
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- services/broker_gateway_service/**
- crates/common/**
- crates/config/**
- crates/trading_engine/**
- vendor/ctrader-openapi/**
- Cargo.toml
- Cargo.lock
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
compile-data-acquisition-service:
extends: .compile-service-base
variables:
SERVICE_PACKAGE: data-acquisition-service
rules: &rules-data-acquisition-service
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- services/data_acquisition_service/**
- crates/common/**
- crates/config/**
- crates/storage/**
- Cargo.toml
- Cargo.lock
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
# --------------------------------------------------------------------------
# Stage 3a: Compile training binaries (CPU node, CUDA stubs from ci-builder image)
# --------------------------------------------------------------------------
compile-training:
extends: .rust-base-cpu
stage: compile
image: ${CI_BUILDER_IMAGE}
needs: [] # parallel with per-service compile jobs — shares 1 POP2-32C-128G node
resource_group: compile-training
interruptible: true
variables:
# CUDA stubs from ci-builder image — compiles CUDA kernels without GPU hardware.
# L4 GPU = compute capability 8.9 → candle-kernels generates sm_89 PTX
CUDA_COMPUTE_CAP: "89"
# When all 8 service jobs fire (Cargo.toml change), the node is fully loaded.
# Increase pod scheduling timeout so training waits for service jobs to finish.
KUBERNETES_POLL_TIMEOUT: "1200"
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- Cargo.toml
- Cargo.lock
- crates/ml/**
- crates/trading_engine/**
- crates/common/**
- crates/risk/**
- crates/data/**
- crates/config/**
- crates/storage/**
- services/training_uploader/**
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
script:
- echo "CUDA_COMPUTE_CAP=$CUDA_COMPUTE_CAP"
- export SCCACHE_DIR="/mnt/sccache/sm_${CUDA_COMPUTE_CAP}"
- mkdir -p "$SCCACHE_DIR" && sccache --zero-stats || true
- PROFILE=${DEV_RELEASE:+dev-release}; PROFILE=${PROFILE:-release}
- |
# CalVer: YYYY.MM.<pipeline_iid>
YEAR=$(date -d "$CI_COMMIT_TIMESTAMP" +%Y 2>/dev/null || date +%Y)
MONTH=$(date -d "$CI_COMMIT_TIMESTAMP" +%m 2>/dev/null || date +%m)
export FOXHUNT_BUILD_VERSION="${YEAR}.${MONTH}.${CI_PIPELINE_IID:-0}"
echo "Build version: $FOXHUNT_BUILD_VERSION"
- echo "Building training binaries with --profile $PROFILE (CUDA stubs)"
- TARGET_DIR="target/$PROFILE"
- cargo build --profile $PROFILE -p ml --features ml/cuda
--example train_baseline_rl
--example train_baseline_supervised
--example evaluate_baseline
--example evaluate_supervised
--example hyperopt_baseline_rl
--example hyperopt_baseline_supervised
- cargo build --profile $PROFILE -p training_uploader
- sccache --show-stats || true
- mkdir -p build-out/training
- |
for bin in train_baseline_rl train_baseline_supervised evaluate_baseline evaluate_supervised hyperopt_baseline_rl hyperopt_baseline_supervised; do
cp $TARGET_DIR/examples/$bin build-out/training/
strip build-out/training/$bin
done
cp $TARGET_DIR/training_uploader build-out/training/
strip build-out/training/training_uploader
- ls -lh build-out/training/
- echo "Training binaries ready as CI artifacts"
artifacts:
paths:
- build-out/training/
expire_in: 3 days
# --------------------------------------------------------------------------
# Stage 3c: Build web-dashboard static assets (Vite)
# --------------------------------------------------------------------------
build-web-dashboard:
stage: compile
image: node:22-slim
needs: []
resource_group: compile-web-dashboard
interruptible: true
tags:
- kapsule
- docker
variables:
KUBERNETES_NODE_SELECTOR_POOL: "k8s.scaleway.com/pool-name=ci-compile-cpu"
KUBERNETES_CPU_REQUEST: "2000m"
KUBERNETES_CPU_LIMIT: "4000m"
KUBERNETES_MEMORY_REQUEST: "2Gi"
KUBERNETES_MEMORY_LIMIT: "4Gi"
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- web-dashboard/**
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
script:
- cd web-dashboard
- npm ci
- npm run build
- echo "Web dashboard assets ready as CI artifacts"
artifacts:
paths:
- web-dashboard/dist/
expire_in: 1 day
# --------------------------------------------------------------------------
# Stage 3c: Training — RL on L4 (CPU-heavy), Supervised on L40S (GPU-heavy)
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
# RL training base: DQN + PPO → ci-training pool (L40S)
# RL hyperopt runs parallel PSO trials — CPU+GPU bound, low VRAM.
# --------------------------------------------------------------------------
.train-rl-base:
stage: train
image: ${TRAINING_RUNTIME_IMAGE}
tags:
- kapsule
- l40s
variables:
# Route to ci-training pool (L40S GPU)
KUBERNETES_NODE_SELECTOR_POOL: "k8s.scaleway.com/pool-name=ci-training"
KUBERNETES_RUNTIME_CLASS_NAME: "nvidia"
KUBERNETES_CPU_REQUEST: "7000m"
KUBERNETES_CPU_LIMIT: "8000m"
KUBERNETES_MEMORY_REQUEST: "8Gi"
KUBERNETES_MEMORY_LIMIT: "20Gi"
CUBLAS_WORKSPACE_CONFIG: ":4096:8"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://tempo.foxhunt.svc.cluster.local:4317"
# Expose training metrics to Prometheus (binaries serve /metrics on :9094)
KUBERNETES_POD_ANNOTATIONS_1: "gitlab.com/prometheus_scrape=true"
KUBERNETES_POD_ANNOTATIONS_2: "gitlab.com/prometheus_port=9094"
KUBERNETES_POD_ANNOTATIONS_3: "gitlab.com/prometheus_path=/metrics"
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
when: manual
allow_failure: true
- if: $CI_PIPELINE_SOURCE == "schedule" && $TRAIN_VALIDATE == "true"
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
when: manual
allow_failure: true
before_script:
- export LD_LIBRARY_PATH=$(echo "$LD_LIBRARY_PATH" | tr ':' '\n' | grep -v stubs | tr '\n' ':' | sed 's/:$//')
- nvidia-smi
- mkdir -p ${CI_PROJECT_DIR}/output ${CI_PROJECT_DIR}/training-bin
# Fetch training binaries from MinIO (CI artifacts as fallback)
- |
if cp build-out/training/* ${CI_PROJECT_DIR}/training-bin/ 2>/dev/null; then
echo "Using CI artifact binaries"
else
echo "Fetching training binaries from MinIO..."
rclone copy :s3:foxhunt-binaries/training/ ${CI_PROJECT_DIR}/training-bin/ \
--s3-provider=Minio \
--s3-endpoint=https://minio.foxhunt.svc.cluster.local:9000 \
--s3-access-key-id="${MINIO_ACCESS_KEY}" \
--s3-secret-access-key="${MINIO_SECRET_KEY}" \
--s3-no-check-bucket \
--no-check-certificate
echo "Fetched $(ls ${CI_PROJECT_DIR}/training-bin/ | wc -l) training binaries from MinIO"
fi
- chmod +x ${CI_PROJECT_DIR}/training-bin/* 2>/dev/null || true
- export PATH="${CI_PROJECT_DIR}/training-bin:$PATH"
# --------------------------------------------------------------------------
# Supervised training base: TFT, Mamba2, TGGN, TLOB, Liquid, KAN, xLSTM, Diffusion → L40S
# Supervised models are GPU-bound — low CPU, high VRAM.
# --------------------------------------------------------------------------
.train-validate-base:
stage: train
image: ${TRAINING_RUNTIME_IMAGE}
tags:
- kapsule
- l40s
variables:
# nvidia runtime for GPU nodes (ci-training has L40S 48GB)
KUBERNETES_RUNTIME_CLASS_NAME: "nvidia"
# Route to L40S pool (48GB VRAM for large supervised models)
# Overrides runner default to L40S pool
KUBERNETES_NODE_SELECTOR_POOL: "k8s.scaleway.com/pool-name=ci-training"
# Supervised is GPU-bound — minimal CPU. Low request allows 3-4 concurrent jobs.
KUBERNETES_CPU_REQUEST: "1000m"
KUBERNETES_CPU_LIMIT: "2000m"
KUBERNETES_MEMORY_REQUEST: "16Gi"
KUBERNETES_MEMORY_LIMIT: "40Gi"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://tempo.foxhunt.svc.cluster.local:4317"
# Expose training metrics to Prometheus (binaries serve /metrics on :9094)
KUBERNETES_POD_ANNOTATIONS_1: "gitlab.com/prometheus_scrape=true"
KUBERNETES_POD_ANNOTATIONS_2: "gitlab.com/prometheus_port=9094"
KUBERNETES_POD_ANNOTATIONS_3: "gitlab.com/prometheus_path=/metrics"
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
when: manual
allow_failure: true
- if: $CI_PIPELINE_SOURCE == "schedule" && $TRAIN_VALIDATE == "true"
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
when: manual
allow_failure: true
before_script:
- export LD_LIBRARY_PATH=$(echo "$LD_LIBRARY_PATH" | tr ':' '\n' | grep -v stubs | tr '\n' ':' | sed 's/:$//')
- nvidia-smi
- mkdir -p ${CI_PROJECT_DIR}/output ${CI_PROJECT_DIR}/training-bin
# Fetch training binaries from MinIO (CI artifacts as fallback)
- |
if cp build-out/training/* ${CI_PROJECT_DIR}/training-bin/ 2>/dev/null; then
echo "Using CI artifact binaries"
else
echo "Fetching training binaries from MinIO..."
rclone copy :s3:foxhunt-binaries/training/ ${CI_PROJECT_DIR}/training-bin/ \
--s3-provider=Minio \
--s3-endpoint=https://minio.foxhunt.svc.cluster.local:9000 \
--s3-access-key-id="${MINIO_ACCESS_KEY}" \
--s3-secret-access-key="${MINIO_SECRET_KEY}" \
--s3-no-check-bucket \
--no-check-certificate
echo "Fetched $(ls ${CI_PROJECT_DIR}/training-bin/ | wc -l) training binaries from MinIO"
fi
- chmod +x ${CI_PROJECT_DIR}/training-bin/* 2>/dev/null || true
- export PATH="${CI_PROJECT_DIR}/training-bin:$PATH"
train-validate-rl:
extends: .train-rl-base
needs:
- job: compile-training
optional: true
script:
- |
train_baseline_rl \
--model both \
--symbol ES.FUT \
--data-dir /mnt/training-data/futures-baseline \
--output-dir ${CI_PROJECT_DIR}/output \
--max-steps-per-epoch 50 \
&& TRAIN_OK=1 || TRAIN_OK=0
echo "=== RL training exit: $TRAIN_OK ==="
echo "=== Running evaluation ==="
evaluate_baseline \
--model both \
--models-dir ${CI_PROJECT_DIR}/output \
--data-dir /mnt/training-data/futures-baseline \
--output ${CI_PROJECT_DIR}/output/rl_eval_report.json \
--symbol ES.FUT \
|| true
echo "=== Eval report ==="
cat ${CI_PROJECT_DIR}/output/rl_eval_report.json 2>/dev/null || echo "No eval report"
[ "$TRAIN_OK" = "1" ] || exit 1
artifacts:
paths:
- output/rl_eval_report.json
when: always
expire_in: 30 days
train-validate-tft:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model tft
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== TFT training complete, running evaluation ==="
- evaluate_supervised
--model tft
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/tft_eval_report.json
--symbol ES.FUT
- echo "TFT validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/tft_eval_report.json
artifacts:
paths:
- output/tft_eval_report.json
when: always
expire_in: 30 days
hyperopt-ppo:
extends: .train-rl-base
tags:
- kapsule
- h100
variables:
KUBERNETES_NODE_SELECTOR_POOL: "k8s.scaleway.com/pool-name=ci-training-h100"
KUBERNETES_MEMORY_REQUEST: "16Gi"
KUBERNETES_MEMORY_LIMIT: "48Gi"
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_rl
--model ppo
--trials 20
--n-initial 5
--epochs 8
--parallel 0
--symbol ES.FUT
--tx-cost-bps 0.1
--tick-size 0.25
--spread-ticks 1.0
--initial-capital 35000
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/ppo_hyperopt_results.json
- echo "=== PPO Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/ppo_hyperopt_results.json
- echo "=== Training PPO with best params ==="
- train_baseline_rl
--model ppo
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/ppo_hyperopt_results.json
- echo "=== Evaluating PPO with best params ==="
- evaluate_baseline
--model ppo
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/ppo_eval_report.json
--symbol ES.FUT
--hyperopt-params ${CI_PROJECT_DIR}/output/ppo_hyperopt_results.json
- cat ${CI_PROJECT_DIR}/output/ppo_eval_report.json
artifacts:
paths:
- output/ppo_hyperopt_results.json
- output/ppo_eval_report.json
when: always
expire_in: 90 days
# --- Train-validate: remaining supervised models ---
train-validate-mamba2:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model mamba2
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== Mamba2 training complete, running evaluation ==="
- evaluate_supervised
--model mamba2
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/mamba2_eval_report.json
--symbol ES.FUT
- echo "Mamba2 validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/mamba2_eval_report.json
artifacts:
paths:
- output/mamba2_eval_report.json
when: always
expire_in: 30 days
train-validate-liquid:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model liquid
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== Liquid training complete, running evaluation ==="
- evaluate_supervised
--model liquid
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/liquid_eval_report.json
--symbol ES.FUT
- echo "Liquid validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/liquid_eval_report.json
artifacts:
paths:
- output/liquid_eval_report.json
when: always
expire_in: 30 days
train-validate-tggn:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model tggn
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== TGGN training complete, running evaluation ==="
- evaluate_supervised
--model tggn
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/tggn_eval_report.json
--symbol ES.FUT
- echo "TGGN validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/tggn_eval_report.json
artifacts:
paths:
- output/tggn_eval_report.json
when: always
expire_in: 30 days
train-validate-tlob:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model tlob
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== TLOB training complete, running evaluation ==="
- evaluate_supervised
--model tlob
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/tlob_eval_report.json
--symbol ES.FUT
- echo "TLOB validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/tlob_eval_report.json
artifacts:
paths:
- output/tlob_eval_report.json
when: always
expire_in: 30 days
train-validate-kan:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model kan
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== KAN training complete, running evaluation ==="
- evaluate_supervised
--model kan
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/kan_eval_report.json
--symbol ES.FUT
- echo "KAN validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/kan_eval_report.json
artifacts:
paths:
- output/kan_eval_report.json
when: always
expire_in: 30 days
train-validate-xlstm:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model xlstm
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== xLSTM training complete, running evaluation ==="
- evaluate_supervised
--model xlstm
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/xlstm_eval_report.json
--symbol ES.FUT
- echo "xLSTM validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/xlstm_eval_report.json
artifacts:
paths:
- output/xlstm_eval_report.json
when: always
expire_in: 30 days
train-validate-diffusion:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
script:
- train_baseline_supervised
--model diffusion
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== Diffusion training complete, running evaluation ==="
- evaluate_supervised
--model diffusion
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/diffusion_eval_report.json
--symbol ES.FUT
- echo "Diffusion validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/diffusion_eval_report.json
artifacts:
paths:
- output/diffusion_eval_report.json
when: always
expire_in: 30 days
# --- Hyperopt: all models ---
hyperopt-dqn:
extends: .train-rl-base
tags:
- kapsule
- h100
variables:
KUBERNETES_NODE_SELECTOR_POOL: "k8s.scaleway.com/pool-name=ci-training-h100"
KUBERNETES_MEMORY_REQUEST: "16Gi"
KUBERNETES_MEMORY_LIMIT: "48Gi"
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- |
hyperopt_baseline_rl \
--model dqn \
--trials 20 \
--n-initial 5 \
--epochs 8 \
--parallel 0 \
--symbol ES.FUT \
--tx-cost-bps 0.1 \
--tick-size 0.25 \
--spread-ticks 1.0 \
--initial-capital 35000 \
--data-dir /mnt/training-data/futures-baseline \
--base-dir ${CI_PROJECT_DIR}/output/hyperopt \
--output ${CI_PROJECT_DIR}/output/dqn_hyperopt_results.json \
&& HYPEROPT_OK=1 || HYPEROPT_OK=0
echo "=== DQN Hyperopt exit: $HYPEROPT_OK ==="
cat ${CI_PROJECT_DIR}/output/dqn_hyperopt_results.json 2>/dev/null || echo "No hyperopt results"
echo "=== Training DQN with best params ==="
train_baseline_rl \
--model dqn \
--symbol ES.FUT \
--tx-cost-bps 0.1 \
--tick-size 0.25 \
--spread-ticks 1.0 \
--data-dir /mnt/training-data/futures-baseline \
--output-dir ${CI_PROJECT_DIR}/output \
--hyperopt-params ${CI_PROJECT_DIR}/output/dqn_hyperopt_results.json \
&& TRAIN_OK=1 || TRAIN_OK=0
echo "=== Training exit: $TRAIN_OK ==="
echo "=== Evaluating DQN ==="
evaluate_baseline \
--model dqn \
--models-dir ${CI_PROJECT_DIR}/output \
--data-dir /mnt/training-data/futures-baseline \
--output ${CI_PROJECT_DIR}/output/dqn_eval_report.json \
--symbol ES.FUT \
--tx-cost-bps 0.1 \
--tick-size 0.25 \
--spread-ticks 1.0 \
--hyperopt-params ${CI_PROJECT_DIR}/output/dqn_hyperopt_results.json \
|| true
echo "=== Eval report ==="
cat ${CI_PROJECT_DIR}/output/dqn_eval_report.json 2>/dev/null || echo "No eval report"
[ "$HYPEROPT_OK" = "1" ] && [ "$TRAIN_OK" = "1" ] || exit 1
artifacts:
paths:
- output/dqn_hyperopt_results.json
- output/dqn_eval_report.json
when: always
expire_in: 90 days
hyperopt-tft:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model tft
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/tft_hyperopt_results.json
- echo "=== TFT Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/tft_hyperopt_results.json
- echo "=== Training TFT with best params ==="
- train_baseline_supervised
--model tft
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/tft_hyperopt_results.json
- echo "=== Evaluating TFT with best params ==="
- evaluate_supervised
--model tft
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/tft_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/tft_eval_report.json
artifacts:
paths:
- output/tft_hyperopt_results.json
- output/tft_eval_report.json
when: always
expire_in: 90 days
hyperopt-mamba2:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model mamba2
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/mamba2_hyperopt_results.json
- echo "=== Mamba2 Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/mamba2_hyperopt_results.json
- echo "=== Training Mamba2 with best params ==="
- train_baseline_supervised
--model mamba2
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/mamba2_hyperopt_results.json
- echo "=== Evaluating Mamba2 with best params ==="
- evaluate_supervised
--model mamba2
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/mamba2_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/mamba2_eval_report.json
artifacts:
paths:
- output/mamba2_hyperopt_results.json
- output/mamba2_eval_report.json
when: always
expire_in: 90 days
hyperopt-liquid:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model liquid
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/liquid_hyperopt_results.json
- echo "=== Liquid Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/liquid_hyperopt_results.json
- echo "=== Training Liquid with best params ==="
- train_baseline_supervised
--model liquid
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/liquid_hyperopt_results.json
- echo "=== Evaluating Liquid with best params ==="
- evaluate_supervised
--model liquid
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/liquid_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/liquid_eval_report.json
artifacts:
paths:
- output/liquid_hyperopt_results.json
- output/liquid_eval_report.json
when: always
expire_in: 90 days
hyperopt-tggn:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model tggn
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/tggn_hyperopt_results.json
- echo "=== TGGN Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/tggn_hyperopt_results.json
- echo "=== Training TGGN with best params ==="
- train_baseline_supervised
--model tggn
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/tggn_hyperopt_results.json
- echo "=== Evaluating TGGN with best params ==="
- evaluate_supervised
--model tggn
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/tggn_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/tggn_eval_report.json
artifacts:
paths:
- output/tggn_hyperopt_results.json
- output/tggn_eval_report.json
when: always
expire_in: 90 days
hyperopt-tlob:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model tlob
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/tlob_hyperopt_results.json
- echo "=== TLOB Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/tlob_hyperopt_results.json
- echo "=== Training TLOB with best params ==="
- train_baseline_supervised
--model tlob
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/tlob_hyperopt_results.json
- echo "=== Evaluating TLOB with best params ==="
- evaluate_supervised
--model tlob
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/tlob_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/tlob_eval_report.json
artifacts:
paths:
- output/tlob_hyperopt_results.json
- output/tlob_eval_report.json
when: always
expire_in: 90 days
hyperopt-kan:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model kan
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/kan_hyperopt_results.json
- echo "=== KAN Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/kan_hyperopt_results.json
- echo "=== Training KAN with best params ==="
- train_baseline_supervised
--model kan
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/kan_hyperopt_results.json
- echo "=== Evaluating KAN with best params ==="
- evaluate_supervised
--model kan
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/kan_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/kan_eval_report.json
artifacts:
paths:
- output/kan_hyperopt_results.json
- output/kan_eval_report.json
when: always
expire_in: 90 days
hyperopt-xlstm:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model xlstm
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/xlstm_hyperopt_results.json
- echo "=== xLSTM Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/xlstm_hyperopt_results.json
- echo "=== Training xLSTM with best params ==="
- train_baseline_supervised
--model xlstm
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/xlstm_hyperopt_results.json
- echo "=== Evaluating xLSTM with best params ==="
- evaluate_supervised
--model xlstm
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/xlstm_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/xlstm_eval_report.json
artifacts:
paths:
- output/xlstm_hyperopt_results.json
- output/xlstm_eval_report.json
when: always
expire_in: 90 days
hyperopt-diffusion:
extends: .train-validate-base
needs:
- job: compile-training
optional: true
timeout: 4h
script:
- hyperopt_baseline_supervised
--model diffusion
--trials 20
--n-initial 5
--epochs 15
--data-dir /mnt/training-data/futures-baseline
--base-dir ${CI_PROJECT_DIR}/output/hyperopt
--output ${CI_PROJECT_DIR}/output/diffusion_hyperopt_results.json
- echo "=== Diffusion Hyperopt complete ==="
- cat ${CI_PROJECT_DIR}/output/diffusion_hyperopt_results.json
- echo "=== Training Diffusion with best params ==="
- train_baseline_supervised
--model diffusion
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--hyperopt-params ${CI_PROJECT_DIR}/output/diffusion_hyperopt_results.json
- echo "=== Evaluating Diffusion with best params ==="
- evaluate_supervised
--model diffusion
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/diffusion_eval_report.json
--symbol ES.FUT
- cat ${CI_PROJECT_DIR}/output/diffusion_eval_report.json
artifacts:
paths:
- output/diffusion_hyperopt_results.json
- output/diffusion_eval_report.json
when: always
expire_in: 90 days
# --------------------------------------------------------------------------
# IaC: Terragrunt plan on MR (runs on gitlab pool)
# --------------------------------------------------------------------------
infra-plan:
stage: test
image: ${INFRA_RUNNER_IMAGE}
tags:
- kapsule
- docker
needs: []
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- infra/**
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- infra/**
before_script:
- export SCW_ACCESS_KEY=${SCW_ACCESS_KEY}
- export SCW_SECRET_KEY=${SCW_SECRET_KEY}
- export SCW_DEFAULT_PROJECT_ID=${SCW_DEFAULT_PROJECT_ID}
- export AWS_ACCESS_KEY_ID=${SCW_ACCESS_KEY}
- export AWS_SECRET_ACCESS_KEY=${SCW_SECRET_KEY}
script:
- cd infra/live/production
- terragrunt run-all plan --terragrunt-non-interactive 2>&1 | tee ${CI_PROJECT_DIR}/plan-output.txt
- echo "Plan completed — review output, then trigger infra-apply manually"
artifacts:
paths:
- plan-output.txt
when: always
expire_in: 7 days
# --------------------------------------------------------------------------
# IaC: Terragrunt apply (auto on main push, manual from web/api)
# --------------------------------------------------------------------------
infra-apply:
stage: deploy
image: ${INFRA_RUNNER_IMAGE}
tags:
- kapsule
- docker
needs:
- job: infra-plan
optional: true
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- infra/**
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
when: manual
before_script:
- export SCW_ACCESS_KEY=${SCW_ACCESS_KEY}
- export SCW_SECRET_KEY=${SCW_SECRET_KEY}
- export SCW_DEFAULT_PROJECT_ID=${SCW_DEFAULT_PROJECT_ID}
- export AWS_ACCESS_KEY_ID=${SCW_ACCESS_KEY}
- export AWS_SECRET_ACCESS_KEY=${SCW_SECRET_KEY}
script:
- cd infra/live/production
- terragrunt run-all apply --terragrunt-non-interactive -auto-approve
environment:
name: production/infrastructure
# --------------------------------------------------------------------------
# IaC: Weekly drift detection (scheduled pipeline, gitlab pool)
# --------------------------------------------------------------------------
infra-drift-check:
stage: test
image: ${INFRA_RUNNER_IMAGE}
tags:
- kapsule
- docker
needs: []
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" && $DRIFT_CHECK == "true"
before_script:
- export SCW_ACCESS_KEY=${SCW_ACCESS_KEY}
- export SCW_SECRET_KEY=${SCW_SECRET_KEY}
- export SCW_DEFAULT_PROJECT_ID=${SCW_DEFAULT_PROJECT_ID}
- export AWS_ACCESS_KEY_ID=${SCW_ACCESS_KEY}
- export AWS_SECRET_ACCESS_KEY=${SCW_SECRET_KEY}
script:
- cd infra/live/production
- |
terragrunt run-all plan -detailed-exitcode --terragrunt-non-interactive \
2>&1 | tee ${CI_PROJECT_DIR}/drift-output.txt; EXIT_CODE=$?
if [ "$EXIT_CODE" -eq 2 ]; then
echo "DRIFT DETECTED — creating GitLab issue"
glab issue create \
--title "IaC Drift Detected ($(date +%Y-%m-%d))" \
--description "$(tail -100 ${CI_PROJECT_DIR}/drift-output.txt)" \
--label "infrastructure,drift"
exit 1
elif [ "$EXIT_CODE" -ne 0 ]; then
exit $EXIT_CODE
fi
echo "No drift detected"
artifacts:
paths:
- drift-output.txt
when: always
expire_in: 7 days
# --------------------------------------------------------------------------
# Stage 4: Deploy base templates
# --------------------------------------------------------------------------
# Shared kubeconfig setup for all deploy jobs
.deploy-base:
stage: deploy
image: ${INFRA_RUNNER_IMAGE}
tags:
- kapsule
- docker
variables:
KUBERNETES_CPU_REQUEST: "200m"
KUBERNETES_CPU_LIMIT: "500m"
KUBERNETES_MEMORY_REQUEST: "256Mi"
KUBERNETES_MEMORY_LIMIT: "512Mi"
before_script:
- which kubectl || (curl -fsSL "https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl)
- export SCW_ACCESS_KEY=${SCW_ACCESS_KEY}
- export SCW_SECRET_KEY=${SCW_SECRET_KEY}
- export SCW_DEFAULT_PROJECT_ID=${SCW_DEFAULT_PROJECT_ID}
- export SCW_DEFAULT_ORGANIZATION_ID=${SCW_DEFAULT_ORGANIZATION_ID:-${SCW_DEFAULT_PROJECT_ID}}
- mkdir -p ~/.kube
- echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- sed -i 's|https://.*\.api\.k8s\.fr-par\.scw\.cloud:6443|https://kubernetes.default.svc|g' ~/.kube/config
- kubectl cluster-info
environment:
name: production
kubernetes:
namespace: foxhunt
# Per-service deploy: upload binary → apply manifest → rollout restart
# Each service deploys IMMEDIATELY after its compile job finishes.
.deploy-service-base:
extends: .deploy-base
script:
- export AWS_ACCESS_KEY_ID="${MINIO_ACCESS_KEY}"
- export AWS_SECRET_ACCESS_KEY="${MINIO_SECRET_KEY}"
- S3="aws s3 --endpoint-url https://minio.foxhunt.svc.cluster.local:9000 --no-verify-ssl"
- echo "Deploying ${SERVICE_PACKAGE}..."
- $S3 cp build-out/services/${SERVICE_PACKAGE} s3://foxhunt-binaries/services/${SERVICE_PACKAGE}
- kubectl apply -f infra/k8s/services/${SERVICE_PACKAGE}.yaml
- kubectl -n foxhunt rollout restart deployment/${SERVICE_PACKAGE}
- kubectl -n foxhunt rollout status deployment/${SERVICE_PACKAGE} --timeout=300s
- echo "Deployed ${SERVICE_PACKAGE}"
# --------------------------------------------------------------------------
# Stage 4a: Per-service deploy (fires as each compile finishes)
# Rules use YAML anchors shared with compile jobs — ensures compile+deploy
# are created/skipped together.
# --------------------------------------------------------------------------
deploy-api:
extends: .deploy-service-base
needs: [{job: compile-api, artifacts: true}]
variables:
SERVICE_PACKAGE: api
rules: *rules-api
deploy-trading-service:
extends: .deploy-service-base
needs: [{job: compile-trading-service, artifacts: true}]
variables:
SERVICE_PACKAGE: trading-service
rules: *rules-trading-service
deploy-ml-training-service:
extends: .deploy-service-base
needs: [{job: compile-ml-training-service, artifacts: true}]
variables:
SERVICE_PACKAGE: ml-training-service
rules: *rules-ml-training-service
deploy-backtesting-service:
extends: .deploy-service-base
needs: [{job: compile-backtesting-service, artifacts: true}]
variables:
SERVICE_PACKAGE: backtesting-service
rules: *rules-backtesting-service
deploy-trading-agent-service:
extends: .deploy-service-base
needs: [{job: compile-trading-agent-service, artifacts: true}]
variables:
SERVICE_PACKAGE: trading-agent-service
rules: *rules-trading-agent-service
deploy-broker-gateway:
extends: .deploy-service-base
needs: [{job: compile-broker-gateway, artifacts: true}]
variables:
SERVICE_PACKAGE: broker-gateway
rules: *rules-broker-gateway
deploy-data-acquisition-service:
extends: .deploy-service-base
needs: [{job: compile-data-acquisition-service, artifacts: true}]
variables:
SERVICE_PACKAGE: data-acquisition-service
rules: *rules-data-acquisition-service
# --------------------------------------------------------------------------
# Stage 4b: Deploy training binaries to MinIO
# --------------------------------------------------------------------------
deploy-training:
extends: .deploy-base
needs:
- job: compile-training
artifacts: true
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- Cargo.toml
- Cargo.lock
- crates/ml/**
- crates/trading_engine/**
- crates/common/**
- crates/risk/**
- crates/data/**
- crates/config/**
- crates/storage/**
- services/training_uploader/**
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
script:
- export AWS_ACCESS_KEY_ID="${MINIO_ACCESS_KEY}"
- export AWS_SECRET_ACCESS_KEY="${MINIO_SECRET_KEY}"
- S3="aws s3 --endpoint-url https://minio.foxhunt.svc.cluster.local:9000 --no-verify-ssl"
- $S3 cp build-out/training/ s3://foxhunt-binaries/training/ --recursive
- echo "Training binaries uploaded to MinIO"
# --------------------------------------------------------------------------
# Stage 4c: Deploy web dashboard assets to MinIO
# --------------------------------------------------------------------------
deploy-web-dashboard:
extends: .deploy-base
needs:
- job: build-web-dashboard
artifacts: true
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
changes:
- web-dashboard/**
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
script:
- export AWS_ACCESS_KEY_ID="${MINIO_ACCESS_KEY}"
- export AWS_SECRET_ACCESS_KEY="${MINIO_SECRET_KEY}"
- S3="aws s3 --endpoint-url https://minio.foxhunt.svc.cluster.local:9000 --no-verify-ssl"
- $S3 cp web-dashboard/dist/ s3://foxhunt-binaries/static/ --recursive
- echo "Web dashboard assets uploaded to MinIO"
# --------------------------------------------------------------------------
# Stage 4d: Deploy infrastructure (migrations, monitoring, dashboards, proxy)
# Runs on every main push — no compile dependency.
# --------------------------------------------------------------------------
deploy-infra:
extends: .deploy-base
needs: []
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
script:
- echo "Deploying infrastructure ${CI_COMMIT_SHA}..."
# Apply pending database migrations
- |
echo "Applying database migrations..."
DB_POD=$(kubectl -n foxhunt get pod -l app.kubernetes.io/name=postgres -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [ -n "$DB_POD" ]; then
kubectl -n foxhunt exec "$DB_POD" -- psql -U foxhunt -d foxhunt -c \
"CREATE TABLE IF NOT EXISTS _applied_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT now())" 2>/dev/null || true
for f in $(ls migrations/*.sql | grep -v '\.down\.' | sort); do
MIGRATION_NAME=$(basename "$f")
ALREADY=$(kubectl -n foxhunt exec "$DB_POD" -- psql -U foxhunt -d foxhunt -tAc \
"SELECT 1 FROM _applied_migrations WHERE name = '${MIGRATION_NAME}'" 2>/dev/null || true)
if [ "$ALREADY" = "1" ]; then
echo " skip: $MIGRATION_NAME (already applied)"
else
echo " apply: $MIGRATION_NAME"
kubectl -n foxhunt exec -i "$DB_POD" -- psql -U foxhunt -d foxhunt < "$f"
kubectl -n foxhunt exec "$DB_POD" -- psql -U foxhunt -d foxhunt -c \
"INSERT INTO _applied_migrations (name) VALUES ('${MIGRATION_NAME}')"
fi
done
echo "Migrations complete"
else
echo "WARNING: postgres pod not found, skipping migrations"
fi
# Deploy monitoring stack
- kubectl apply -f infra/k8s/monitoring/loki.yaml -f infra/k8s/monitoring/tempo.yaml -f infra/k8s/monitoring/promtail.yaml -f infra/k8s/monitoring/node-exporter.yaml -f infra/k8s/monitoring/kube-state-metrics.yaml
# Deploy Grafana dashboards
- |
PROM_UID="PBFA97CFB590B2093"
LOKI_UID="P8E80F9AEF21F6940"
TEMPO_UID="tempo"
DASH_DIR="infra/k8s/monitoring/dashboards"
TMP_DIR=$(mktemp -d)
for f in "$DASH_DIR"/*.json; do
python3 -c "
import json, sys
with open('$f') as fh:
d = json.load(fh)
d.pop('__inputs', None)
d.pop('__requires', None)
raw = json.dumps(d)
raw = raw.replace('\${DS_PROMETHEUS}', '${PROM_UID}')
raw = raw.replace('\${DS_LOKI}', '${LOKI_UID}')
raw = raw.replace('\${DS_TEMPO}', '${TEMPO_UID}')
print(raw)
" > "$TMP_DIR/$(basename "$f")"
done
apply_dashboard_cm() {
cm_name="$1"; folder="$2"; shift 2
args=""
for df in "$@"; do
[ -f "$TMP_DIR/$df" ] && args="$args --from-file=$df=$TMP_DIR/$df"
done
if [ -n "$args" ]; then
kubectl create configmap "$cm_name" -n foxhunt $args \
--dry-run=client -o yaml | \
kubectl label -f - --dry-run=client -o yaml --local grafana_dashboard=1 | \
kubectl annotate -f - --dry-run=client -o yaml --local "grafana_folder=$folder" | \
kubectl apply -f -
echo " Applied ConfigMap: $cm_name (folder: $folder)"
fi
}
apply_dashboard_cm grafana-dashboards-foxhunt Foxhunt \
foxhunt-logs.json foxhunt-overview.json foxhunt-services.json \
foxhunt-cockpit.json foxhunt-trading-cockpit.json foxhunt-training-cockpit.json foxhunt-traces.json
apply_dashboard_cm grafana-dashboards-infra Infrastructure \
cluster-overview.json gpu-overview.json foxhunt-gpu-training.json foxhunt-infrastructure.json
apply_dashboard_cm grafana-dashboards-cicd "CI/CD" \
foxhunt-ci-pipelines.json gitlab-services.json
rm -rf "$TMP_DIR"
echo "Dashboard ConfigMaps deployed"
# Apply proxy + service manifests
- kubectl apply -f infra/k8s/gitlab/tailscale-proxy.yaml
- kubectl -n foxhunt rollout restart deployment/tailscale-gitlab-proxy
- kubectl apply -f infra/k8s/services/
- echo "Infrastructure deploy complete"