# Copyright 2026 Zyvor · https://zyvor.dev
# SPDX-License-Identifier: Apache-2.0

name: ci

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e  # v7
        with:
          go-version: '1.27.x'
      - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a  # v9
        with:
          version: v2.13
      - name: Vulnerability scan
        run: |
          # kaironctl links helm.sh/helm/v3, which pulls containerd and oras
          # for OCI chart pulls. Those CVEs are not in the controller/node/ui
          # images, and several have no upstream fix, so they are scanned
          # separately from this gate. See docs/DEPENDENCIES.md.
          go list ./... | grep -Ev '/(internal/kaironctl|cmd/kaironctl|cmd/kubectl-kairon)($|/)' | xargs go run golang.org/x/vuln/cmd/govulncheck@latest

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e  # v7
        with:
          go-version: '1.27.x'
      - name: Format check
        run: test -z "$(gofmt -l cmd internal)"
      - name: Vet
        run: go vet ./...
      - name: Test with race detector
        run: make test-race
      - name: Coverage
        run: make cover-check
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7
        if: always()
        with:
          name: coverage
          path: coverage.out
      - name: Build and smoke test
        run: make build smoke
      - name: Validate manifests and repository
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y -qq python3-yaml
          python3 scripts/validate.py
      - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310  # v5.0.1
      - name: Helm lint and render
        run: |
          helm lint charts/kairon
          helm template kairon charts/kairon --namespace kairon-system > /tmp/kairon.yaml
          helm template kairon charts/kairon --namespace kairon-system --set migration.enabled=true > /tmp/kairon-migration.yaml
          python3 - <<'PY'
          import yaml
          for path in ['/tmp/kairon.yaml', '/tmp/kairon-migration.yaml']:
              with open(path) as f:
                  docs = [d for d in yaml.safe_load_all(f) if d]
              assert docs, path
          enabled = [d for d in yaml.safe_load_all(open('/tmp/kairon-migration.yaml')) if d]
          ds = next(d for d in enabled if d.get('kind') == 'DaemonSet')
          container = ds['spec']['template']['spec']['containers'][0]
          assert any(str(arg).startswith('--migration-ca=') for arg in container['args'])
          assert any(port.get('name') == 'migration' for port in container['ports'])
          print('helm render YAML: OK (migration disabled + enabled)')
          PY
      - name: RBAC coverage check
        run: python3 scripts/check_rbac_coverage.py
      - name: Helm render -- kairon-ui
        run: |
          # No ui.* auth configured: the chart now seeds a default admin
          # account (random password, generated once) instead of refusing
          # to render or coming up unauthenticated.
          helm template kairon charts/kairon --namespace kairon-system --set ui.enabled=true > /tmp/kairon-ui-default.yaml
          python3 - <<'PY'
          import yaml
          docs = [d for d in yaml.safe_load_all(open('/tmp/kairon-ui-default.yaml')) if d]
          kinds = {d['kind'] for d in docs if d.get('metadata', {}).get('name') == 'kairon-ui'}
          for want in ['ServiceAccount', 'ClusterRole', 'ClusterRoleBinding', 'Deployment', 'Service']:
              assert want in kinds, f"missing kairon-ui {want}"
          session_secret = next(d for d in docs if d.get('kind') == 'Secret' and d['metadata']['name'] == 'kairon-ui-session')
          assert 'defaultAdminPassword' in session_secret['stringData'], "expected a generated default admin password"
          assert 'sessionSecret' in session_secret['stringData']
          deployment = next(d for d in docs if d.get('kind') == 'Deployment' and d['metadata']['name'] == 'kairon-ui')
          env_names = {e['name'] for e in deployment['spec']['template']['spec']['containers'][0]['env']}
          assert 'KAIRON_UI_DEFAULT_ADMIN_PASSWORD' in env_names
          assert 'KAIRON_UI_USERS_JSON' not in env_names
          print('helm render YAML: OK (kairon-ui default admin seeding)')
          PY

          # ui.auth.users configured: real accounts wired in, default admin
          # seeding stops entirely.
          # shellcheck disable=SC2016  # bcrypt hash must stay single-quoted
          helm template kairon charts/kairon --namespace kairon-system --set ui.enabled=true \
            --set ui.auth.users[0].username=alice --set-string ui.auth.users[0].passwordHash='$2a$10$abcdefghijklmnopqrstuv' > /tmp/kairon-ui-users.yaml
          python3 - <<'PY'
          import yaml
          docs = [d for d in yaml.safe_load_all(open('/tmp/kairon-ui-users.yaml')) if d]
          users_secret = next(d for d in docs if d.get('kind') == 'Secret' and d['metadata']['name'] == 'kairon-ui-users')
          assert 'alice' in users_secret['stringData']['users.json']
          deployment = next(d for d in docs if d.get('kind') == 'Deployment' and d['metadata']['name'] == 'kairon-ui')
          env_names = {e['name'] for e in deployment['spec']['template']['spec']['containers'][0]['env']}
          assert 'KAIRON_UI_USERS_JSON' in env_names
          assert 'KAIRON_UI_DEFAULT_ADMIN_PASSWORD' not in env_names
          session_secret = next(d for d in docs if d.get('kind') == 'Secret' and d['metadata']['name'] == 'kairon-ui-session')
          assert 'defaultAdminPassword' not in session_secret['stringData']
          print('helm render YAML: OK (kairon-ui configured users)')
          PY

          # Legacy shared token still works unchanged.
          helm template kairon charts/kairon --namespace kairon-system --set ui.enabled=true --set ui.token=ci-test-token > /tmp/kairon-ui-token.yaml
          python3 - <<'PY'
          import yaml
          docs = [d for d in yaml.safe_load_all(open('/tmp/kairon-ui-token.yaml')) if d]
          secret = next(d for d in docs if d.get('kind') == 'Secret' and d['metadata']['name'] == 'kairon-ui-token')
          assert secret['stringData']['token'] == 'ci-test-token'
          print('helm render YAML: OK (kairon-ui legacy token)')
          PY

      - name: Helm render -- Cilium flags
        run: |
          helm template kairon charts/kairon --namespace kairon-system \
            --set network.ciliumAttach.enabled=true \
            --set network.ciliumPolicySync.enabled=true > /tmp/kairon-cilium.yaml
          python3 - <<'PY'
          import yaml
          docs = [d for d in yaml.safe_load_all(open('/tmp/kairon-cilium.yaml')) if d]
          role = next(d for d in docs if d.get('kind') == 'ClusterRole' and d['metadata']['name'] == 'kairon-controller')
          resources = set()
          for rule in role.get('rules', []):
              if 'cilium.io' in rule.get('apiGroups', []):
                  resources.update(rule.get('resources', []))
          assert 'ciliumexternalworkloads' in resources, resources
          assert 'ciliumnetworkpolicies' in resources, resources
          deploy = next(d for d in docs if d.get('kind') == 'Deployment' and d['metadata']['name'] == 'kairon-controller')
          args = deploy['spec']['template']['spec']['containers'][0]['args']
          assert '--cilium-attach=true' in args, args
          assert '--cilium-policy-sync=true' in args, args
          print('helm render YAML: OK (cilium attach + policy sync)')
          PY
          # Flags off (chart default): no cilium.io rules.
          helm template kairon charts/kairon --namespace kairon-system > /tmp/kairon-nocilium.yaml
          python3 - <<'PY'
          import yaml
          docs = [d for d in yaml.safe_load_all(open('/tmp/kairon-nocilium.yaml')) if d]
          role = next(d for d in docs if d.get('kind') == 'ClusterRole' and d['metadata']['name'] == 'kairon-controller')
          for rule in role.get('rules', []):
              assert 'cilium.io' not in rule.get('apiGroups', []), rule
          print('helm render YAML: OK (cilium flags default off)')
          PY

      - name: Helm render -- production profile
        run: |
          # values-production.yaml is the hardened install overlay (PR5).
          # Webhook ValidatingWebhookConfiguration only renders when both
          # tlsSecretName and caBundle are set — inject CI placeholders so
          # the full production surface is exercised.
          helm lint charts/kairon -f charts/kairon/values-production.yaml
          helm template kairon charts/kairon --namespace kairon-system \
            -f charts/kairon/values-production.yaml \
            --set webhook.tlsSecretName=ci-webhook-tls \
            --set webhook.caBundle=Y2ktY2EtYnVuZGxl \
            --set migration.dataplaneTlsSecretName=ci-migration-dataplane \
            > /tmp/kairon-production.yaml
          python3 - <<'PY'
          import yaml
          docs = [d for d in yaml.safe_load_all(open('/tmp/kairon-production.yaml')) if d]
          chart = yaml.safe_load(open('charts/kairon/Chart.yaml'))
          app = str(chart['appVersion']).strip('"')
          ctrl = next(d for d in docs if d.get('kind') == 'Deployment' and d['metadata']['name'] == 'kairon-controller')
          node_ds = next(d for d in docs if d.get('kind') == 'DaemonSet' and d['metadata']['name'] == 'kairon-node')
          ctrl_img = ctrl['spec']['template']['spec']['containers'][0]['image']
          node_img = node_ds['spec']['template']['spec']['containers'][0]['image']
          assert ctrl_img.endswith(':' + app), (ctrl_img, app)
          assert node_img.endswith(':' + app), (node_img, app)
          ctrl_args = ctrl['spec']['template']['spec']['containers'][0]['args']
          assert any(str(a).startswith('--webhook-addr=') for a in ctrl_args), ctrl_args
          node_args = node_ds['spec']['template']['spec']['containers'][0]['args']
          assert '--network-default-deny=true' in node_args, node_args
          assert any(str(a).startswith('--interval=5m') for a in node_args), node_args
          vwh = [d for d in docs if d.get('kind') == 'ValidatingWebhookConfiguration']
          assert vwh, 'expected ValidatingWebhookConfiguration with production webhook certs'
          # dataplaneTlsSecretName set: migration secret volume present
          vols = node_ds['spec']['template']['spec'].get('volumes') or []
          assert any(v.get('secret', {}).get('secretName') == 'ci-migration-dataplane' for v in vols), vols
          print('helm render YAML: OK (values-production.yaml)')
          PY

      - name: v0.6 foundations package tests
        run: |
          # Explicit coverage for PR1–PR3 packages (also covered by make
          # test-race above; kept here so a targeted regression is obvious
          # in the job log).
          go test -race -count=1 \
            ./internal/model/ \
            ./internal/agent/ \
            ./internal/kube/ \
            ./internal/scheduler/ \
            ./internal/controller/

  # Dry-run of release.yml release-assets: CLI binaries + checksums +
  # Helm package. Does not push OCI or create a GitHub Release.
  release-assets:
    runs-on: ubuntu-latest
    needs: [test, lint]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e  # v7
        with:
          go-version: '1.27.x'
      - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310  # v5.0.1
      - name: Build CLI binaries and checksums
        run: |
          set -euo pipefail
          mkdir -p dist
          version="ci-${GITHUB_SHA::12}"
          for os_arch in linux/amd64 linux/arm64; do
            os="${os_arch%/*}"
            arch="${os_arch#*/}"
            for cmd in kaironctl kubectl-kairon; do
              out="dist/${cmd}-${os}-${arch}"
              CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
                -ldflags "-s -w -X main.version=${version}" \
                -o "$out" "./cmd/${cmd}"
            done
          done
          (
            cd dist
            sha256sum kaironctl-* kubectl-kairon-* > SHA256SUMS
            test -s SHA256SUMS
            test "$(wc -l < SHA256SUMS | tr -d ' ')" = "4"
          )
      - name: Package Helm chart
        run: |
          set -euo pipefail
          version="0.0.0-ci.${GITHUB_SHA::12}"
          cp charts/kairon/Chart.yaml /tmp/Chart.yaml.bak
          sed -i "s/^version:.*/version: ${version}/" charts/kairon/Chart.yaml
          sed -i "s/^appVersion:.*/appVersion: \"${version}\"/" charts/kairon/Chart.yaml
          helm package charts/kairon -d dist
          test -f "dist/kairon-${version}.tgz"
          # Production overlay must still lint against the packaged chart.
          helm lint "dist/kairon-${version}.tgz" -f charts/kairon/values-production.yaml
          mv /tmp/Chart.yaml.bak charts/kairon/Chart.yaml
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7
        with:
          name: release-assets-ci
          path: |
            dist/kaironctl-*
            dist/kubectl-kairon-*
            dist/SHA256SUMS
            dist/kairon-*.tgz

  web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020  # v7
        with: {node-version: '22', cache: npm, cache-dependency-path: web/package.json}
      - run: npm --prefix web install
      - run: npm --prefix web run typecheck
      - run: npm --prefix web test
      - run: npm --prefix web run build

  website:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020  # v7
        with: {node-version: '22', cache: npm, cache-dependency-path: website/package-lock.json}
      - run: npm --prefix website ci
      - run: npm --prefix website run build

  # Builds all three images on every push/PR and scans each with Trivy, but
  # never pushes anywhere -- see .github/workflows/release.yml for the
  # tag-triggered workflow that actually publishes to ghcr.io. Kept
  # separate deliberately: this job is the fast, always-on correctness/
  # security gate; release.yml only needs to run once per release.
  container:
    runs-on: ubuntu-latest
    needs: [test, lint, web]
    strategy:
      matrix:
        target: [controller, node, ui, csi-node, csi-controller]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069  # v4
      - name: Build ${{ matrix.target }} image
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc  # v7
        with:
          context: .
          target: ${{ matrix.target }}
          push: false
          load: true
          tags: kairon-${{ matrix.target }}:ci
      - name: Scan ${{ matrix.target }} image
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25  # v0.36.0
        with:
          image-ref: kairon-${{ matrix.target }}:ci
          severity: HIGH,CRITICAL
          exit-code: '1'
          ignore-unfixed: true
