# Dagster B0 — the lifecycle orchestrator (combined-book asset graph) on the cluster.
#
# ONE pod, three containers (webserver + daemon + tailscale userspace sidecar) sharing the RWO
# `fxhnt-surfer-data` PVC (warehouse.duckdb + forward state live there). RWO = single-node, so webserver
# and daemon MUST co-locate; the daemon runs the daily schedule, runs execute in-process (DefaultRunLauncher).
# Storage (runs/events/schedules) is the existing cockpit Postgres, separate `dagster` DB. UI is tailnet-
# private via `tailscale serve` (CGNAT-safe userspace mode, mirrors fxhnt-dashboard) → fxhnt-dagster.<tailnet>.ts.net
#
# Cutover (RWO-forced): the deterministic engine here fully replaced the legacy fxhnt-forward CronJob
# (now removed). Revert = `kubectl -n foxhunt scale deploy/dagster --replicas=0` (releases the PVC).
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: dagster-instance
  namespace: foxhunt
data:
  dagster.yaml: |
    storage:
      postgres:
        postgres_db:
          username: foxhunt
          password:
            env: PGPASSWORD
          hostname: postgres.foxhunt.svc.cluster.local
          db_name: dagster
          port: 5432
    telemetry:
      enabled: false
    # K8sRunLauncher: each Dagster run launches as its OWN K8s Job (own memory) instead of running in-process in
    # the 4Gi daemon — so the heavy bybit_book_precompute asset (op_tags request 6Gi/limit 8Gi) never OOMs the
    # daemon (which previously exit-137'd and skipped the forward-nav step). The launched Job git-syncs the SAME
    # code the daemon runs (initContainer, same gitea+key) into an emptyDir /code; it needs NO PVC (the compute
    # reads Postgres only — the unlock calendar is DB-first, so the RWO fxhnt-surfer-data PVC is NOT mounted,
    # avoiding a deadlock with the daemon that holds it). Per-op memory comes from the asset's dagster-k8s/config
    # op_tags; this block sets the launcher defaults + the shared pod scaffolding (git-sync, env, code volume).
    run_launcher:
      module: dagster_k8s
      class: K8sRunLauncher
      config:
        service_account_name: tailscale-dagster
        job_namespace: foxhunt
        job_image: rg.fr-par.scw.cloud/bizworx/fxhnt-cockpit:latest
        image_pull_policy: Always   # :latest -> Always-pull so a dep-bump build reaches launched run Jobs (CI review C1)
        image_pull_secrets:
          - name: scw-registry
        load_incluster_config: true
        instance_config_map: dagster-instance
        dagster_home: /dagster-home
        env_config_maps: []
        env_secrets: []
        env_vars:
          - "PYTHONPATH=/code/src"
          - "FXHNT_FEATURE_STORE=postgres"
          - "FXHNT_SURFER_DATA_DIR=/data/surfer"
          - "FXHNT_OPERATIONAL_DSN=postgresql+psycopg://foxhunt@postgres.foxhunt.svc.cluster.local:5432/fxhnt"
          - "FXHNT_ALLOCATION_UCITS_VOLUME_SOURCE=ibkr"
          - "FXHNT_DATABENTO_MAX_COST_USD=40.0"
          - "DAGSTER_HOME=/dagster-home"
        resources:
          requests: { cpu: "100m", memory: "1Gi" }
          limits: { cpu: "2", memory: "3Gi" }
        run_k8s_config:
          pod_spec_config:
            nodeSelector: { k8s.scaleway.com/pool-name: large }
            initContainers:
              - name: git-sync
                image: alpine/git:latest
                command: ["/bin/sh", "-c"]
                args:
                  - |
                    mkdir -p /root/.ssh && cp /etc/git-ssh/ssh-privatekey /root/.ssh/id_ed25519 && chmod 600 /root/.ssh/id_ed25519
                    printf 'Host *\n  StrictHostKeyChecking no\n  UserKnownHostsFile /dev/null\n' > /root/.ssh/config
                    rm -rf /code/src
                    git clone --depth 1 --branch master --filter=blob:none --sparse \
                      "ssh://git@gitea-ssh.gitea.svc.cluster.local:22/gitadmin/fxhnt.git" /tmp/repo \
                      && (cd /tmp/repo && git sparse-checkout set src) \
                      && cp -a /tmp/repo/src /code/src \
                      && echo "run git-sync OK: $(cd /tmp/repo && git rev-parse --short HEAD)"
                volumeMounts:
                  - { name: code, mountPath: /code }
                  - { name: git-ssh-key, mountPath: /etc/git-ssh, readOnly: true }
            volumes:
              - { name: code, emptyDir: {} }
              - { name: git-ssh-key, secret: { secretName: argo-git-ssh-key, defaultMode: 256 } }
          container_config:
            env:
              - name: PGPASSWORD
                valueFrom: { secretKeyRef: { name: db-credentials, key: password } }
              - name: DATABENTO_API_KEY
                valueFrom: { secretKeyRef: { name: databento-credentials, key: api-key } }
              - name: TIINGO_API_KEY
                valueFrom: { secretKeyRef: { name: tiingo-api, key: key } }
            volumeMounts:
              - { name: code, mountPath: /code, readOnly: true }
  workspace.yaml: |
    load_from:
      - python_module:
          module_name: fxhnt.adapters.orchestration.definitions
          working_directory: /code/src   # git-sync target + PYTHONPATH; /app was the baked-image dir (mismatch -> code-server couldn't heartbeat -> 23:30 schedule never fired)
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: fxhnt-dagster-ts-serve
  namespace: foxhunt
data:
  serve.json: |
    {
      "TCP": { "443": { "HTTPS": true } },
      "Web": {
        "${TS_CERT_DOMAIN}:443": {
          "Handlers": { "/": { "Proxy": "http://127.0.0.1:3000" } }
        }
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dagster
  namespace: foxhunt
  labels: { app.kubernetes.io/name: dagster, app.kubernetes.io/part-of: foxhunt }
spec:
  replicas: 1
  strategy: { type: Recreate }          # RWO PVC: never run two pods holding it at once
  selector: { matchLabels: { app.kubernetes.io/name: dagster } }
  template:
    metadata:
      labels: { app.kubernetes.io/name: dagster, app.kubernetes.io/part-of: foxhunt }   # part-of -> postgres ingress + base egress
    spec:
      serviceAccountName: tailscale-dagster
      nodeSelector: { k8s.scaleway.com/pool-name: large }
      imagePullSecrets: [{ name: scw-registry }]
      initContainers:
        - name: dagster-home-init                       # DAGSTER_HOME must be writable; seed it from the configmap
          image: busybox:1.36
          command: ["sh", "-c", "cp /cfg/dagster.yaml /cfg/workspace.yaml /dagster-home/"]
          volumeMounts:
            - { name: dagster-home, mountPath: /dagster-home }
            - { name: dagster-cfg, mountPath: /cfg, readOnly: true }
        # git-sync: clone the latest master src into /code so the app runs it via PYTHONPATH=/code/src,
        # making CODE-ONLY deploys a fast `rollout restart` (re-pull) instead of a full image rebuild. Deps
        # still come from the baked image (a pyproject/uv.lock change must rebuild — the sensor routes those).
        # Mirrors the build's clone (same argo-git-ssh-key + gitea-sshd). Fail-closed: if the clone fails the
        # pod won't start (so a broken sync never serves stale/empty code) — the baked image is the fallback
        # via a re-apply that drops this initContainer if ever needed.
        - name: git-sync
          image: alpine/git:latest
          command: [/bin/sh, -c]
          args:
            # BEST-EFFORT (always exits 0): on success /code/src holds the latest master src and the app runs
            # it via PYTHONPATH=/code/src; on failure/timeout /code/src is absent and the app falls back to the
            # BAKED package in site-packages (fxhnt is pip-installed) — so a sync hiccup never causes an outage.
            # Sparse-checkout only src/ (the sole thing PYTHONPATH needs) → tiny + fast. timeout 120 → fails fast.
            - |
              mkdir -p /root/.ssh && cp /etc/git-ssh/ssh-privatekey /root/.ssh/id_ed25519 && chmod 600 /root/.ssh/id_ed25519
              printf 'Host *\n  StrictHostKeyChecking no\n  UserKnownHostsFile /dev/null\n' > /root/.ssh/config
              rm -rf /code/src
              if timeout 120 git clone --depth 1 --branch master --filter=blob:none --sparse \
                   "ssh://git@gitea-ssh.gitea.svc.cluster.local:22/gitadmin/fxhnt.git" /tmp/repo \
                 && (cd /tmp/repo && git sparse-checkout set src) && [ -d /tmp/repo/src ]; then
                cp -a /tmp/repo/src /code/src && echo "git-sync OK: $(cd /tmp/repo && git rev-parse --short HEAD)"
              else
                echo "git-sync FAILED/timed out — falling back to baked image (site-packages)"; rm -rf /code/src
              fi
              exit 0
          resources: { requests: { cpu: 50m, memory: 64Mi }, limits: { cpu: 500m, memory: 256Mi } }
          volumeMounts:
            - { name: code, mountPath: /code }
            - { name: git-ssh-key, mountPath: /etc/git-ssh, readOnly: true }
      containers:
        - name: webserver
          image: rg.fr-par.scw.cloud/bizworx/fxhnt-cockpit:latest
          command: ["dagster-webserver", "-h", "0.0.0.0", "-p", "3000",
                    "-w", "/dagster-home/workspace.yaml"]
          env: &dagster_env
            - { name: DAGSTER_HOME, value: /dagster-home }
            - { name: PYTHONPATH, value: /code/src }   # git-sync'd code wins over the baked image (fast code deploys)
            - { name: FXHNT_WAREHOUSE_PATH, value: /data/surfer/warehouse.duckdb }   # legacy DuckDB (rollback)
            - { name: FXHNT_FEATURE_STORE, value: "postgres" }   # warehouse reads/writes -> TimescaleDB (networked). Unset/=duckdb to roll back.
            - { name: FXHNT_SURFER_DATA_DIR, value: /data/surfer }
            - { name: FXHNT_OPERATIONAL_DSN, value: "postgresql+psycopg://foxhunt@postgres.foxhunt.svc.cluster.local:5432/fxhnt" }
            # UCITS-regime ADV from the REAL IBKR on-exchange volume (fxhnt-ucits-volume-ibkr CronJob writes
            # ibkr_pit_volume). Authoritative for the exact lines we trade; Yahoo's LSE feed lacks CBU0.L (the
            # book dropped under yahoo, sized $1M-ceiling/Sharpe 1.30 under ibkr — validated 2026-07-16).
            - { name: FXHNT_ALLOCATION_UCITS_VOLUME_SOURCE, value: "ibkr" }
            - { name: FXHNT_DATABENTO_MAX_COST_USD, value: "40.0" }
            - name: PGPASSWORD
              valueFrom: { secretKeyRef: { name: db-credentials, key: password } }
            - name: DATABENTO_API_KEY
              valueFrom: { secretKeyRef: { name: databento-credentials, key: api-key } }
            - name: TIINGO_API_KEY                          # equity sleeves (sixtyforty/multistrat/gd) fetch Tiingo adjClose
              valueFrom: { secretKeyRef: { name: tiingo-api, key: key } }
          readinessProbe: { tcpSocket: { port: 3000 }, initialDelaySeconds: 10, periodSeconds: 15 }
          volumeMounts:
            - { name: dagster-home, mountPath: /dagster-home }
            - { name: surfer-data, mountPath: /data }
            - { name: code, mountPath: /code, readOnly: true }   # git-sync'd src (PYTHONPATH=/code/src)
          resources: { requests: { cpu: 100m, memory: 512Mi }, limits: { cpu: "1", memory: 1Gi } }
        - name: daemon
          image: rg.fr-par.scw.cloud/bizworx/fxhnt-cockpit:latest
          command: ["dagster-daemon", "run", "-w", "/dagster-home/workspace.yaml"]
          env: *dagster_env
          volumeMounts:
            - { name: dagster-home, mountPath: /dagster-home }
            - { name: surfer-data, mountPath: /data }
            - { name: code, mountPath: /code, readOnly: true }   # git-sync'd src (PYTHONPATH=/code/src)
          resources: { requests: { cpu: 100m, memory: 512Mi }, limits: { cpu: "2", memory: 4Gi } }   # 4Gi: headroom for in-daemon batch (warehouse-migrate etc.); daemon baseline ~1.8Gi left no room at 2Gi. Request stays 512Mi (schedules fine; node has physical RAM for the burst)
        - name: tailscale
          image: ghcr.io/tailscale/tailscale:latest
          env:
            - name: TS_AUTHKEY
              valueFrom: { secretKeyRef: { name: tailscale-auth, key: TS_AUTHKEY } }
            - { name: TS_KUBE_SECRET, value: fxhnt-dagster-ts-state }
            - { name: TS_USERSPACE, value: "true" }
            - { name: TS_HOSTNAME, value: "fxhnt-dagster" }
            - { name: TS_SERVE_CONFIG, value: /etc/ts/serve.json }
          volumeMounts:
            - { name: ts-serve, mountPath: /etc/ts, readOnly: true }
          resources: { requests: { cpu: 25m, memory: 64Mi }, limits: { cpu: 200m, memory: 128Mi } }
      volumes:
        - { name: dagster-home, emptyDir: {} }
        - { name: dagster-cfg, configMap: { name: dagster-instance } }
        - { name: ts-serve, configMap: { name: fxhnt-dagster-ts-serve } }
        - { name: surfer-data, persistentVolumeClaim: { claimName: fxhnt-surfer-data } }
        - { name: code, emptyDir: {} }                                          # git-sync target
        - { name: git-ssh-key, secret: { secretName: argo-git-ssh-key, defaultMode: 256 } }
---
apiVersion: v1
kind: Service
metadata: { name: dagster, namespace: foxhunt }
spec:
  selector: { app.kubernetes.io/name: dagster }
  ports: [{ port: 80, targetPort: 3000, name: http }]
---
# Egress: postgres (storage + cockpit ingest) + DNS + public HTTPS (Binance/Databento fetch in the assets)
# + tailscale coordination/DERP. part-of=foxhunt already grants base egress + satisfies postgres ingress.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: dagster, namespace: foxhunt, labels: { app.kubernetes.io/part-of: foxhunt } }
spec:
  podSelector: { matchLabels: { app.kubernetes.io/name: dagster } }
  policyTypes: [Ingress, Egress]
  ingress:
    - from: [{ podSelector: { matchLabels: { app.kubernetes.io/name: fxhnt-ingress } } }]   # dagster.fxhnt.ai via the bizworx fxhnt-ingress proxy (was tailscale-gitlab-proxy on foxhunt)
      ports: [{ port: 3000, protocol: TCP }]
  egress:
    - to: [{ podSelector: { matchLabels: { app.kubernetes.io/name: postgres } } }]
      ports: [{ port: 5432, protocol: TCP }]
    - to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: gitea } }, podSelector: { matchLabels: { app.kubernetes.io/name: gitea } } }]   # git-sync clones from the bizworx gitea in ns gitea (cross-namespace)
      ports: [{ port: 2222, protocol: TCP }, { port: 22, protocol: TCP }]
    - ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }]
    - to: [{ ipBlock: { cidr: 172.16.0.11/32 } }]   # kube-apiserver REAL endpoint (kube-proxy DNATs kubernetes.default.svc 10.32.0.1:443 -> this before NP eval, and 172.16.0.0/16 is excepted below). The tailscale sidecar reads/writes its ts-state Secret via the apiserver.
      ports: [{ port: 6443, protocol: TCP }]
    - to: [{ ipBlock: { cidr: 0.0.0.0/0, except: [10.32.0.0/16, 172.16.0.0/16] } }]   # Binance/Databento + tailscale
      ports: [{ port: 443, protocol: TCP }, { port: 3478, protocol: UDP }, { port: 41641, protocol: UDP }]
