initial commit

Change-Id: I6939ee9a344fe9ca09034b0385314e61a22f4696
This commit is contained in:
Scaffolder
2026-08-14 19:18:56 +00:00
commit e0a942d1d0
139 changed files with 28500 additions and 0 deletions

445
.gitea/workflows/sonar.yaml Normal file
View File

@@ -0,0 +1,445 @@
name: SonarQube Analysis
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
sonarqube:
name: Build, Test & Analyse
runs-on: ubuntu-latest
timeout-minutes: 15
env:
SONAR_PROJECT_KEY: demo-bofa-2
SONAR_ADMIN_TOKEN: ${{ secrets.SONAR_ADMIN_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
BACKSTAGE_CLEANUP_TOKEN: ${{ secrets.BACKSTAGE_CLEANUP_TOKEN }}
BACKSTAGE_URL: https://backstage.dev.crucible.kyndemo.live
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# This factory renovates arbitrary applications, so the analysis cannot assume one of
# them. Everything below used to be Maven unconditionally, and the very first Java step
# was `chmod +x mvnw` -- which on a Node repository fails with
# "cannot access 'mvnw': No such file or directory" and takes the whole run with it.
#
# Two paths, chosen from what is actually in the repository:
#
# maven pom.xml present -> mvnw verify + sonar-maven-plugin, with JaCoCo
# cli anything else -> sonar-scanner CLI, which analyses JS/TS, Python, Go
# and more without needing that language's build
#
# `cli` rather than `node` because the fallback is genuinely language-agnostic: a Python
# or Go repository gets analysed too, just without coverage. Only Node adds a test step,
# because it is the one runtime whose coverage format the scanner reads for free.
- name: Detect project type
run: |
set -euo pipefail
if [ -f pom.xml ]; then
KIND=maven
else
KIND=cli
fi
echo "PROJECT_KIND=${KIND}" >> $GITHUB_ENV
echo "Detected project kind: ${KIND}"
if [ "${KIND}" = "cli" ] && [ -f package.json ]; then
echo "HAS_NPM=true" >> $GITHUB_ENV
echo "package.json present — will run tests for coverage."
fi
# Deliberately NOT gated on PROJECT_KIND. Maven needs it to build, and sonar-scanner is
# itself a Java program that ships no JRE -- so on the CLI path a Node image without
# java would fail inside the scanner's wrapper script with a bare "java: not found".
# Installing it once here is cheaper than diagnosing that.
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Make Maven wrapper executable
if: env.PROJECT_KIND == 'maven'
run: chmod +x mvnw
- name: Cache Maven packages
if: env.PROJECT_KIND == 'maven'
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
restore-keys: maven-${{ runner.os }}-
# The sonar-maven-plugin caches here. The CLI scanner keeps its own cache under
# ~/.sonar too, so this is useful on both paths -- but the key must not be keyed on
# pom.xml, which does not exist on the CLI path and would collapse every non-Maven
# repository onto one shared key.
- name: Cache SonarQube analysis data
uses: actions/cache@v4
with:
path: ~/.sonar/cache
key: sonar-${{ runner.os }}-${{ hashFiles('**/pom.xml', '**/package-lock.json', '**/go.sum', '**/requirements.txt') }}
restore-keys: sonar-${{ runner.os }}-
- name: Validate required secrets
run: |
[[ -n "$SONAR_ADMIN_TOKEN" ]] || { echo "::error::SONAR_ADMIN_TOKEN is not set"; exit 1; }
[[ -n "$SONAR_HOST_URL" ]] || { echo "::error::SONAR_HOST_URL is not set"; exit 1; }
[[ -n "$SONAR_PROJECT_KEY" ]] || { echo "::error::SONAR_PROJECT_KEY is not set"; exit 1; }
SONAR_HOST_URL="${SONAR_HOST_URL%/}"
AUTH_RESPONSE=$(curl -s -o /tmp/sonar-auth-response.json -w "%{http_code}" \
-u "${SONAR_ADMIN_TOKEN}:" \
"${SONAR_HOST_URL}/api/authentication/validate")
if [[ "$AUTH_RESPONSE" != "200" ]]; then
echo "::error::SonarQube is unreachable or returned HTTP ${AUTH_RESPONSE} — check SONAR_HOST_URL"
exit 1
fi
TOKEN_VALID=$(jq -r '.valid' /tmp/sonar-auth-response.json 2>/dev/null || echo "false")
if [[ "$TOKEN_VALID" != "true" ]]; then
echo "::error::SONAR_ADMIN_TOKEN is invalid or has been revoked (SonarQube returned valid=false)"
exit 1
fi
echo "✅ SONAR_ADMIN_TOKEN verified against ${SONAR_HOST_URL}"
- name: Bootstrap SonarQube project and generate scan token
id: sonar-bootstrap
run: |
PROJECT_COUNT=$(curl -sf \
-u "${SONAR_ADMIN_TOKEN}:" \
"${SONAR_HOST_URL}/api/projects/search?projects=${SONAR_PROJECT_KEY}" \
| jq '.paging.total')
if [[ "$PROJECT_COUNT" == "0" ]]; then
echo "Project '${SONAR_PROJECT_KEY}' not found — creating it..."
curl -sf -X POST \
-u "${SONAR_ADMIN_TOKEN}:" \
"${SONAR_HOST_URL}/api/projects/create" \
--data-urlencode "name=${SONAR_PROJECT_KEY}" \
--data-urlencode "project=${SONAR_PROJECT_KEY}" \
--data-urlencode "mainBranch=main" \
--data-urlencode "visibility=private"
echo "✅ Project created."
else
echo "✅ Project '${SONAR_PROJECT_KEY}' already exists — skipping creation."
fi
# The token name MUST be unique per run. It used to be `gitea-scan-<projectKey>`,
# shared by every job analysing this project, and each job both revokes that name
# before generating and revokes it again on cleanup. Concurrent jobs therefore
# destroyed each other's credentials and every analysis failed with
# `Not authorized. Please check the user token in 'sonar.token'` -- an error that
# reads like a permissions problem and is not one. Observed on reno-rehearsal-6,
# where three overlapping jobs failed in a chain:
#
# 18:37:39 job A generates the shared name
# 18:37:40 job B revokes it, generates its own
# 18:38:16 job C revokes it, generates its own
# 18:38:53 job A analysis -> Not authorized (killed at 18:37:40)
# 18:38:53 job A cleanup revokes the name -> kills job C's live token
# 18:39:26 job C analysis -> Not authorized (killed at 18:38:53)
#
# The `concurrency:` block above does not prevent this: Gitea Actions does not
# honour it, so the overlap is real no matter what the workflow declares. Scoping
# the name to the run id removes the shared resource instead of relying on
# serialisation, and the cleanup step revokes that same per-run name so tokens
# still do not accumulate.
TOKEN_NAME="gitea-scan-${SONAR_PROJECT_KEY}-${GITHUB_RUN_ID}"
echo "TOKEN_NAME=${TOKEN_NAME}" >> "$GITHUB_ENV"
curl -sf -X POST \
-u "${SONAR_ADMIN_TOKEN}:" \
"${SONAR_HOST_URL}/api/user_tokens/revoke" \
--data-urlencode "name=${TOKEN_NAME}" \
> /dev/null 2>&1 || true
SCAN_TOKEN=$(curl -sf -X POST \
-u "${SONAR_ADMIN_TOKEN}:" \
"${SONAR_HOST_URL}/api/user_tokens/generate" \
--data-urlencode "name=${TOKEN_NAME}" \
--data-urlencode "type=PROJECT_ANALYSIS_TOKEN" \
--data-urlencode "projectKey=${SONAR_PROJECT_KEY}" \
| jq -r '.token')
# `jq -r` prints the string "null" for a missing key, which is non-empty and would
# sail past a bare -n test, then fail much later as an authorisation error.
if [[ -z "$SCAN_TOKEN" || "$SCAN_TOKEN" == "null" ]]; then
echo "::error::Failed to generate scan token '${TOKEN_NAME}'"; exit 1
fi
echo "✅ Scan token generated for project '${SONAR_PROJECT_KEY}'"
echo "::add-mask::${SCAN_TOKEN}"
echo "SCAN_TOKEN=${SCAN_TOKEN}" >> "$GITHUB_ENV"
# Spring projects carry io.spring.nohttp, a Checkstyle rule that fails the build on any
# `http://` URL anywhere under ${basedir}. The platform artifacts this factory adds
# contain one legitimately -- the in-cluster OTLP collector, which is plaintext by
# design and has no https:// form -- so the app's own quality gate rejects the
# modernisation:
#
# [ERROR] score.yaml:[7,37] (extension) NoHttp: http:// URLs are not allowed
# [ERROR] overlays/otel/kustomization.yaml:[13,81] ...
# [ERROR] overlays/otel/patches/otel-patch.yaml:[17,19] ...
#
# The two -D properties on the build below cannot fix it: the SuppressionFilter in
# nohttp-checkstyle.xml reads a FIXED path, ${config_loc}/nohttp-checkstyle-suppressions.xml,
# so the only lever is the content of that file. Hence editing it rather than passing
# a flag.
#
# Scoped deliberately: `checks="NoHttp"` only, and only the paths this platform owns.
# Every other Checkstyle rule still applies to them, and NoHttp still applies to all
# application source -- which is the rule's actual purpose.
- name: Allowlist platform artifacts for nohttp
if: env.PROJECT_KIND == 'maven'
run: |
set -euo pipefail
SUPPRESSIONS=src/checkstyle/nohttp-checkstyle-suppressions.xml
if [ ! -f "$SUPPRESSIONS" ]; then
echo "No nohttp suppressions file — project does not use nohttp, nothing to do."
exit 0
fi
if grep -q 'crucible-platform-artifacts' "$SUPPRESSIONS"; then
echo "Platform artifacts already allowlisted."
exit 0
fi
grep -q '</suppressions>' "$SUPPRESSIONS" || {
echo "::error::$SUPPRESSIONS has no closing </suppressions> tag"; exit 1; }
# awk, not python3 or sed -i: this job runs in whatever image the app's build needs,
# so only POSIX tooling is safe to assume.
awk '
/<\/suppressions>/ && !inserted {
print "\t<!-- crucible-platform-artifacts: in-cluster OTLP endpoints are http:// by design -->";
print "\t<suppress files=\"score\\.yaml\" checks=\"NoHttp\"/>";
print "\t<suppress files=\"catalog-info\\.yaml\" checks=\"NoHttp\"/>";
print "\t<suppress files=\"overlays[\\\\/].*\" checks=\"NoHttp\"/>";
print "\t<suppress files=\"\\.platform[\\\\/].*\" checks=\"NoHttp\"/>";
print "\t<suppress files=\"\\.gitea[\\\\/].*\" checks=\"NoHttp\"/>";
print "\t<suppress files=\"k6[\\\\/].*\" checks=\"NoHttp\"/>";
inserted = 1
}
{ print }
' "$SUPPRESSIONS" > "$SUPPRESSIONS.new" && mv "$SUPPRESSIONS.new" "$SUPPRESSIONS"
echo "Allowlisted platform artifacts for NoHttp:"
cat "$SUPPRESSIONS"
- name: Build and test
if: env.PROJECT_KIND == 'maven'
run: |
./mvnw -B verify \
-Dtest='!PostgresIntegrationTests,!MySqlIntegrationTests' \
-Dnohttp.checkstyle.suppressions.file=src/checkstyle/nohttp-checkstyle-suppressions.xml \
-Dcheckstyle.suppressionsFile=src/checkstyle/nohttp-checkstyle-suppressions.xml
- name: SonarQube analysis
if: env.PROJECT_KIND == 'maven'
run: |
./mvnw -B org.sonarsource.scanner.maven:sonar-maven-plugin:4.0.0.4121:sonar \
-Dsonar.projectKey="${SONAR_PROJECT_KEY}" \
-Dsonar.host.url="${SONAR_HOST_URL}" \
-Dsonar.token="${SCAN_TOKEN}" \
-Dsonar.java.source=17 \
-Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
# ── Non-Maven path ────────────────────────────────────────────────────
#
# Everything below runs only when there is no pom.xml, and feeds the SAME quality gate:
# the scan token, the gate poll, the Backstage notification and the token revoke are all
# language-agnostic and are not duplicated.
- name: Set up Node
if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
# Tests must not fail the job. A quality gate reports on what it measured; a repository
# with no test script, or one whose suite needs a database this job does not have, still
# deserves to be analysed. Coverage simply goes unreported, and the gate says so.
- name: Install and test
if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true'
continue-on-error: true
env:
# Prisma validates DATABASE_URL when the client is constructed, and construction
# happens at import time -- including in suites that mock every query and never
# open a connection. A placeholder is enough to let those run; nothing here
# connects to it. Without it the RealWorld service tests fail on
# `Environment variable not found: DATABASE_URL` (7 of 27), which is not a finding
# about the code, and coverage goes unreported for a suite that would have passed.
DATABASE_URL: postgresql://test:test@localhost:5432/test
run: |
set -uo pipefail
if [ -f package-lock.json ]; then npm ci; else npm install; fi
npm test --if-present -- --coverage 2>/dev/null || npm test --if-present || true
if [ -f coverage/lcov.info ]; then
echo "SONAR_LCOV=coverage/lcov.info" >> $GITHUB_ENV
echo "Found coverage/lcov.info — it will be reported."
else
echo "::notice::No coverage/lcov.info produced; analysing without coverage."
fi
- name: SonarQube analysis (scanner CLI)
if: env.PROJECT_KIND == 'cli'
run: |
set -euo pipefail
# Pinned. `latest` would move the analyser under a quality gate that is supposed to
# mean the same thing from one run to the next.
SCANNER_VERSION=6.2.1.4610
SCANNER_DIR="sonar-scanner-${SCANNER_VERSION}-linux-x64"
# This job runs in whatever image the application's build needs, so unzip is not a
# given -- the same reason the nohttp step above uses awk rather than python3.
command -v unzip >/dev/null 2>&1 || {
(apt-get update -qq && apt-get install -y -qq unzip) >/dev/null 2>&1 \
|| { echo "::error::unzip is unavailable and could not be installed"; exit 1; }
}
curl -sSLo /tmp/sonar-scanner.zip \
"https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SCANNER_VERSION}-linux-x64.zip"
unzip -q /tmp/sonar-scanner.zip -d /opt
export PATH="/opt/${SCANNER_DIR}/bin:${PATH}"
# The scanner is a Java program and ships no JRE. Most build images have one; if
# this repository's does not, say so plainly rather than surface a bare
# "java: not found" from inside the wrapper script.
command -v java >/dev/null 2>&1 || {
echo "::error::sonar-scanner needs a JRE and none is on PATH in this image."
exit 1
}
# Excluded because they are this platform's artifacts, not the application's code,
# and counting them would move the gate for reasons the developer cannot act on.
# Mirrors the nohttp allowlist on the Maven path.
ARGS=(
-Dsonar.projectKey="${SONAR_PROJECT_KEY}"
-Dsonar.host.url="${SONAR_HOST_URL}"
-Dsonar.token="${SCAN_TOKEN}"
-Dsonar.sources=.
-Dsonar.exclusions="**/node_modules/**,**/coverage/**,**/dist/**,**/build/**,k6/**,overlays/**,.platform/**,.gitea/**"
)
if [ -n "${SONAR_LCOV:-}" ]; then
ARGS+=(-Dsonar.javascript.lcov.reportPaths="${SONAR_LCOV}")
fi
sonar-scanner "${ARGS[@]}"
- name: Quality Gate check
id: quality-gate
run: |
echo "Waiting for SonarQube to process the analysis..."
for i in $(seq 1 24); do
RESPONSE=$(curl -sf -u "${SCAN_TOKEN}:" \
"${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}" || true)
STATUS=$(echo "$RESPONSE" | jq -r '.projectStatus.status' 2>/dev/null || echo "NONE")
if [[ "$STATUS" =~ ^(OK|ERROR|WARN)$ ]]; then break; fi
echo " Status: ${STATUS:-pending} — retrying in 5s..."
sleep 5
done
echo ""
echo "══════════════════════════════════════════"
echo " Quality Gate: $STATUS"
echo "══════════════════════════════════════════"
echo "$RESPONSE" | jq -r '
.projectStatus.conditions[] |
if .status == "ERROR" then " ❌ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))"
elif .status == "WARN" then " ⚠️ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))"
else " ✅ \(.metricKey): \(.actualValue)"
end'
echo "══════════════════════════════════════════"
FAILED=$(echo "$RESPONSE" | jq '[.projectStatus.conditions[] | select(.status == "ERROR")] | length')
if [[ "$FAILED" -gt 0 ]]; then
echo "::error::Quality Gate FAILED — $FAILED metric(s) did not meet threshold"
exit 1
fi
- name: Notify Backstage on quality gate failure
if: always() && steps.quality-gate.outcome == 'failure'
run: |
echo "--- Backstage notification debug ---"
echo "BACKSTAGE_URL: ${BACKSTAGE_URL:-<not set>}"
echo "BACKSTAGE_CLEANUP_TOKEN set: $([[ -n "$BACKSTAGE_CLEANUP_TOKEN" ]] && echo yes || echo no)"
echo "SONAR_PROJECT_KEY: ${SONAR_PROJECT_KEY:-<not set>}"
echo "GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-<not set>}"
if [[ -z "$BACKSTAGE_URL" ]]; then
echo "::error::BACKSTAGE_URL is not set — cannot send notification"
exit 0
fi
if [[ -z "$BACKSTAGE_CLEANUP_TOKEN" ]]; then
echo "::error::BACKSTAGE_CLEANUP_TOKEN is not set — cannot send notification"
exit 0
fi
# Built with jq rather than a quoted heredoc-by-hand. The previous version
# interpolated shell variables into a \"-escaped string and got one line wrong --
# `"description": "Quality gate failed for ...` was unescaped, so bash closed the
# string early and tried to run `gate` as a command. Nobody saw it because this
# step only runs when the quality gate fails. jq also escapes the branch name and
# project key, which are attacker-adjacent (a branch may contain a quote).
PAYLOAD=$(jq -nc \
--arg project "${SONAR_PROJECT_KEY}" \
--arg branch "${GITHUB_HEAD_REF}" \
--arg link "${SONAR_HOST_URL}/dashboard?id=${SONAR_PROJECT_KEY}" \
'{
recipients: { type: "entity", entityRef: "group:default/platform-engineering" },
payload: {
title: "SonarQube Quality Gate Failed",
description: "Quality gate failed for \($project) on branch \($branch).",
link: $link,
severity: "high",
topic: "sonarqube-quality-gate"
}
}')
echo "Sending notification to: ${BACKSTAGE_URL}/api/notifications"
HTTP_CODE=$(curl -s -o /tmp/bs-notify-response.json -w "%{http_code}" \
-X POST "${BACKSTAGE_URL}/api/notifications" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${BACKSTAGE_CLEANUP_TOKEN}" \
-d "${PAYLOAD}")
echo "HTTP response code: ${HTTP_CODE}"
echo "Response body:"
cat /tmp/bs-notify-response.json 2>/dev/null || echo "<empty response>"
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
echo "✅ Backstage notification sent"
else
echo "⚠️ Backstage notification failed (HTTP ${HTTP_CODE})"
fi
- name: Revoke scan token
if: always()
run: |
# Revoke THIS run's token, never the bare `gitea-scan-<projectKey>` name -- that
# name belongs to no single job, and revoking it here is what killed a concurrent
# job's live credential. TOKEN_NAME is empty if bootstrap never ran, in which case
# there is nothing to revoke and a blank name would be a no-op request at best.
if [[ -z "${TOKEN_NAME:-}" ]]; then
echo "No scan token was generated — nothing to revoke."
exit 0
fi
curl -sf -X POST \
-u "${SONAR_ADMIN_TOKEN}:" \
"${SONAR_HOST_URL}/api/user_tokens/revoke" \
--data-urlencode "name=${TOKEN_NAME}" \
&& echo "✅ Scan token revoked (${TOKEN_NAME})" \
|| echo "⚠️ Token revocation failed"