# `shoalcheck gate` on Azure DevOps -- the job that reads the cluster and fails on what changed.
#
# THE CONTRACT. Exit 0 the policy is satisfied, 2 it fired, 1 the run or the comparison could not be made, 4 the
# licence does not cover the gate -- and a comparison the gates refuse is never a pass. The findings land in the
# Tests tab (JUnit), the build summary (a short Markdown verdict) and the artifacts (the report, the reading, SARIF
# under CodeAnalysisLogs for the SARIF SAST Scans Tab).
#
# WHAT IT NEEDS. Pipeline variables: SHOALCHECK_VERSION, the release to pin without the leading v (e.g.
# 0.1.0-rc.12) -- a pipeline should not change under you, so this never asks for "latest"; SHOALCHECK_LICENCE, a
# SECRET variable holding a Team licence key (the gate is a Team command). A secure file named `kubeconfig`
# (Pipelines > Library > Secure files) with the read-only access `shoalcheck audit --show-permissions` prints, whose
# context is the one named below. The first run asks you to permit the pipeline to use the secure file.
#
# TWO RUNS FROM ONE FILE. The schedule reads the cluster against the committed baseline every weekday: drift, with
# nobody having opened anything. A pull request reads the cluster as it is, against the baseline on the branch it
# merges into, and judges the request's rendered manifests laid over that reading in memory (--with) -- before
# anything is applied. Nothing is applied by the gate.
#
# A RUN WITHOUT THE LICENCE DOES NOT GATE, AND SAYS SO. Azure DevOps gives a pull request from a fork no secrets and
# no secure files unless you allow it. Such a run reads nothing, compares nothing, warns "shoalcheck did not gate
# this run" and ends as partially succeeded -- not exit 4, and not a pass.
#
# THE BASELINE IS A READING OF THIS SAME CLUSTER. shoalcheck refuses to compare readings of two different clusters --
# it knows a cluster by its kube-system namespace, not by a context name a rebuilt cluster can reuse -- and a refused
# comparison fails the job with exit 1. Record the baseline once from the cluster this job reads, with
# --update-baseline, and commit it. A cluster made new for every run records its baseline in the same run: apply the
# manifests the change starts from, run the gate with --update-baseline, apply the change, run the gate again.

trigger: none

pr:
  branches:
    include: [main]
  paths:
    include: [deploy/*, charts/*, k8s/*]

schedules:
  - cron: '17 6 * * 1-5'   # weekday mornings, before anyone deploys
    displayName: weekday drift reading
    branches:
      include: [main]
    always: true           # the cluster drifts without a commit, so the reading runs without one

pool:
  vmImage: ubuntu-24.04

variables:
  # The store lives here so the run leaves nothing in the agent's home.
  SHOALCHECK_HOME: $(Agent.TempDirectory)/shoalcheck

steps:
  - checkout: self
    fetchDepth: 1

  # A RUN WITHOUT THE LICENCE DOES NOT GATE: every step below is skipped, the run warns that it did not gate and ends
  # as partially succeeded. A secret that is not defined reaches a script as the literal text "$(NAME)".
  - bash: |
      case "${SHOALCHECK_LICENCE:-}" in
        ''|'$('*)
          echo "##vso[task.logissue type=warning]shoalcheck did not gate this run: it has no licence (a pull request from a fork is given no secrets). Nothing was read and nothing was compared, so this is not a pass."
          echo "##vso[task.complete result=SucceededWithIssues;]not gated" ;;
        *) echo "##vso[task.setvariable variable=SHOALCHECK_GATED]true" ;;
      esac
    displayName: Licence present
    env:
      SHOALCHECK_LICENCE: $(SHOALCHECK_LICENCE)

  # CHECKED THE WAY IT IS SIGNED. cosign itself is pinned and checked against the sha256 its release publishes
  # (cosign_checksums.txt, v2.5.2). Then the signature over checksums.txt with the release key, then the archive
  # against its checksum: a binary that is not the one that was signed has no business reading a production
  # cluster. The key is written here, not downloaded: a key fetched from the release it vouches for proves nothing.
  # It is the cosign.pub the releases repository publishes.
  - bash: |
      set -euo pipefail
      : "${SHOALCHECK_VERSION:?set the pipeline variable SHOALCHECK_VERSION to the release to pin, without the leading v}"
      bin="$(Agent.TempDirectory)/bin"
      mkdir -p "$bin"
      curl -fsSL -o "$bin/cosign" https://github.com/sigstore/cosign/releases/download/v2.5.2/cosign-linux-amd64
      echo "bcfeae05557a9f313ee4392d2f335d0ff69ebbfd232019e3736fb04999fe1734  $bin/cosign" | sha256sum -c -
      chmod +x "$bin/cosign"
      export PATH="$bin:$PATH"
      base="https://github.com/Shoalcheck/shoalcheck/releases/download/v${SHOALCHECK_VERSION}"
      archive="shoalcheck_${SHOALCHECK_VERSION}_linux_amd64.tar.gz"
      for f in "$archive" checksums.txt checksums.txt.sigstore.json; do
        curl -fsSLO "$base/$f"
      done
      cat > cosign.pub <<'KEY'
      -----BEGIN PUBLIC KEY-----
      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE69u2ja9r2k/Vy8lSibowBdCnCD3P
      OVzmcbjHVIvMgoOfkEzEwOY/b8kyjTVJcwrJ89+qC5bjW4XzLqz1S/AryQ==
      -----END PUBLIC KEY-----
      KEY
      cosign verify-blob --key cosign.pub --bundle checksums.txt.sigstore.json checksums.txt
      grep " $archive\$" checksums.txt | sha256sum -c -
      tar xz -C "$bin" -f "$archive" shoalcheck
      rm -f "$archive" checksums.txt checksums.txt.sigstore.json cosign.pub
      echo "##vso[task.prependpath]$bin"
      "$bin/shoalcheck" version
    displayName: Install shoalcheck
    condition: and(succeeded(), eq(variables['SHOALCHECK_GATED'], 'true'))

  # THE BASELINE COMES FROM THE BRANCH BEING MERGED INTO, never from the pull request: a pull request that edits
  # .shoalcheck/baseline.json could otherwise record its own finding as already known and pass its own gate.
  - bash: |
      set -euo pipefail
      git fetch --no-tags --depth=1 origin "$(System.PullRequest.TargetBranch)"
      mkdir -p .shoalcheck
      if git show "FETCH_HEAD:.shoalcheck/baseline.json" > .shoalcheck/baseline.target 2>/dev/null; then
        mv .shoalcheck/baseline.target .shoalcheck/baseline.json
      else
        rm -f .shoalcheck/baseline.target .shoalcheck/baseline.json
        echo "the target branch has no .shoalcheck/baseline.json yet, so there is nothing to compare with"
      fi
    displayName: Baseline from the target branch
    condition: and(succeeded(), eq(variables['SHOALCHECK_GATED'], 'true'), eq(variables['Build.Reason'], 'PullRequest'))

  - task: DownloadSecureFile@1
    name: kubeconfig
    displayName: Cluster credentials
    condition: and(succeeded(), eq(variables['SHOALCHECK_GATED'], 'true'))
    inputs:
      secureFile: kubeconfig

  # The verdict, in the job log and the build summary, and the exit code the step ends on. --json keeps the reading
  # the gate judged; every output after it is rendered from that file, so the test results, the summary and the red
  # build cannot disagree.
  - bash: |
      set -uo pipefail
      mkdir -p .shoalcheck "$(Build.ArtifactStagingDirectory)/shoalcheck" "$(Build.ArtifactStagingDirectory)/CodeAnalysisLogs"
      out="$(Build.ArtifactStagingDirectory)/shoalcheck"
      # THE PULL REQUEST, BEFORE IT LANDS: its rendered manifests go to the gate with --with, which lays them over the
      # reading in memory and judges them -- nothing is applied. not-evaluated is in its policy, so an object the gate
      # could not judge (a kind it does not read, a Secret, a ConfigMap) fails the run rather than passing unexamined;
      # drop it if your changes carry ConfigMaps you accept as unjudged. A binding at an API version the gate does not
      # judge is refused (exit 1).
      code=0
      if [ "${BUILD_REASON:-}" = "PullRequest" ]; then
        # THE CHANGE, RENDERED: --with judges what would be applied, so it takes the rendered manifests, never the
        # sources. Swap in your renderer -- for a chart: helm template <release> charts/<chart> -f values.yaml.
        # The image's kubectl when it has one; otherwise a pinned release, checked against its published sha256.
        change="$(mktemp -d)"
        k="$(command -v kubectl || true)"
        if [ -z "$k" ]; then
          k="$(mktemp -d)/kubectl"
          curl -fsSLo "$k" https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl
          echo "$(curl -fsSL https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl.sha256)  $k" | sha256sum -c -
          chmod +x "$k"
        fi
        "$k" kustomize deploy/ > "$change/rendered.yaml"
        shoalcheck gate \
          --context staging \
          --baseline .shoalcheck/baseline.json \
          --with "$change" \
          --fail-on new:high,regressed,unreadable,not-evaluated \
          --output "$out/report.html" \
          --json "$out/reading.json" || code=$?
      else
        shoalcheck gate \
          --context staging \
          --baseline .shoalcheck/baseline.json \
          --fail-on new:high,regressed,unreadable \
          --output "$out/report.html" \
          --json "$out/reading.json" || code=$?
      fi
      if [ -s "$out/reading.json" ]; then
        shoalcheck findings --input "$out/reading.json" -o junit > shoalcheck-junit.xml
        shoalcheck findings --input "$out/reading.json" -o md-summary --baseline .shoalcheck/baseline.json > "$out/summary.md"
        shoalcheck findings --input "$out/reading.json" -o sarif > "$(Build.ArtifactStagingDirectory)/CodeAnalysisLogs/shoalcheck.sarif"
        echo "##vso[task.uploadsummary]$out/summary.md"
        echo "##vso[task.setvariable variable=SHOALCHECK_READ]true"
      fi
      exit $code
    displayName: Gate
    condition: and(succeeded(), eq(variables['SHOALCHECK_GATED'], 'true'))
    env:
      KUBECONFIG: $(kubeconfig.secureFilePath)
      SHOALCHECK_LICENCE: $(SHOALCHECK_LICENCE)

  # Every finding as a test: a failure at high or above, a skipped test below it (never a pass), and a skipped test
  # per kind the run could not read. The gate's exit code is the verdict; this is what the team reads beside it.
  - task: PublishTestResults@2
    displayName: Findings as test results
    condition: and(succeededOrFailed(), eq(variables['SHOALCHECK_READ'], 'true'))
    inputs:
      testResultsFormat: JUnit
      testResultsFiles: shoalcheck-junit.xml
      testRunTitle: shoalcheck
      failTaskOnFailedTests: false

  # The report is the artifact a human opens when the gate fails: a single file with no external requests, so it
  # renders from the download exactly as it renders locally.
  - task: PublishPipelineArtifact@1
    displayName: Keep the report
    condition: and(succeededOrFailed(), eq(variables['SHOALCHECK_READ'], 'true'))
    inputs:
      targetPath: $(Build.ArtifactStagingDirectory)/shoalcheck
      artifact: shoalcheck

  - task: PublishPipelineArtifact@1
    displayName: SARIF for the Scans tab
    condition: and(succeededOrFailed(), eq(variables['SHOALCHECK_READ'], 'true'))
    inputs:
      targetPath: $(Build.ArtifactStagingDirectory)/CodeAnalysisLogs
      artifact: CodeAnalysisLogs

# Recording a baseline the first time (or after a deliberate change):
#   shoalcheck gate --context staging --baseline .shoalcheck/baseline.json --update-baseline
# It compares first and writes afterwards, so the run that records still reports what moved.
