commit e868792edad90322f2a1460455a8824e564b385e Author: Scaffolder Date: Tue Mar 24 10:43:21 2026 +0000 initial commit Change-Id: Ied1b5f2137f2b5f803d5411235607bd27c5e57ba diff --git a/.gitea/actions/git-branch/action.yml b/.gitea/actions/git-branch/action.yml new file mode 100644 index 0000000..4cf1715 --- /dev/null +++ b/.gitea/actions/git-branch/action.yml @@ -0,0 +1,62 @@ +name: 'git-branch' +description: 'Create a new branch in a Gitea repository via the Gitea API' + +inputs: + branch: + description: 'Name of the new branch to create' + required: true + from: + description: 'Source branch to create from' + required: false + default: 'main' + repo-owner: + description: 'Owner (org or user) of the Gitea repository' + required: true + repo-name: + description: 'Name of the Gitea repository' + required: true + gitea-url: + description: 'Base URL of the Gitea instance' + required: false + default: 'https://gitea.kyndemo.live' + token: + description: 'Gitea API token with write access to the repository' + required: true + +outputs: + branch-name: + description: 'Name of the created branch' + value: + +runs: + using: composite + steps: + - name: Create branch from + shell: bash + env: + GITEA_TOKEN: + GITEA_URL: NaN + REPO_OWNER: NaN + REPO_NAME: NaN + NEW_BRANCH: + FROM_BRANCH: + run: | + echo "Creating branch '${NEW_BRANCH}' from '${FROM_BRANCH}' in ${REPO_OWNER}/${REPO_NAME}..." + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/branches" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"new_branch_name\": \"${NEW_BRANCH}\", \"old_branch_name\": \"${FROM_BRANCH}\"}") + + HTTP_BODY=$(echo "$RESPONSE" | head -n-1) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + + if [ "$HTTP_CODE" = "201" ]; then + echo "✓ Branch '${NEW_BRANCH}' created successfully." + elif [ "$HTTP_CODE" = "409" ]; then + echo "⚠ Branch '${NEW_BRANCH}' already exists — skipping." + else + echo "✗ Failed to create branch. HTTP ${HTTP_CODE}: ${HTTP_BODY}" >&2 + exit 1 + fi diff --git a/.gitea/actions/git-pr/action.yml b/.gitea/actions/git-pr/action.yml new file mode 100644 index 0000000..805155a --- /dev/null +++ b/.gitea/actions/git-pr/action.yml @@ -0,0 +1,89 @@ +name: 'git-pr' +description: 'Open a pull request in a Gitea repository via the Gitea API' + +inputs: + title: + description: 'Title of the pull request' + required: true + head: + description: 'Source branch for the PR (head)' + required: true + base: + description: 'Target branch for the PR (base)' + required: true + body: + description: 'Body / description of the pull request' + required: false + default: '' + auto-merge: + description: 'Whether to enable auto-merge on the PR (true/false)' + required: false + default: 'false' + repo-owner: + description: 'Owner (org or user) of the Gitea repository' + required: true + repo-name: + description: 'Name of the Gitea repository' + required: true + gitea-url: + description: 'Base URL of the Gitea instance' + required: false + default: 'https://gitea.kyndemo.live' + token: + description: 'Gitea API token with write access to the repository' + required: true + +outputs: + pr-url: + description: 'HTML URL of the opened pull request' + value: NaN + pr-number: + description: 'Number of the opened pull request' + value: NaN + +runs: + using: composite + steps: + - id: open-pr + name: Open PR → + shell: bash + env: + GITEA_TOKEN: + GITEA_URL: NaN + REPO_OWNER: NaN + REPO_NAME: NaN + PR_TITLE: + PR_HEAD: + PR_BASE: + PR_BODY: + run: | + echo "Opening PR '${PR_TITLE}': ${PR_HEAD} → ${PR_BASE} in ${REPO_OWNER}/${REPO_NAME}..." + + PAYLOAD=$(jq -n \ + --arg title "$PR_TITLE" \ + --arg head "$PR_HEAD" \ + --arg base "$PR_BASE" \ + --arg body "$PR_BODY" \ + '{title: $title, head: $head, base: $base, body: $body}') + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + + HTTP_BODY=$(echo "$RESPONSE" | head -n-1) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + + if [ "$HTTP_CODE" = "201" ]; then + PR_URL=$(echo "$HTTP_BODY" | jq -r '.html_url') + PR_NUMBER=$(echo "$HTTP_BODY" | jq -r '.number') + echo "✓ PR #${PR_NUMBER} opened: ${PR_URL}" + echo "pr-url=${PR_URL}" >> "$GITEA_OUTPUT" + echo "pr-number=${PR_NUMBER}" >> "$GITEA_OUTPUT" + elif [ "$HTTP_CODE" = "409" ]; then + echo "⚠ A PR for ${PR_HEAD} → ${PR_BASE} already exists — skipping." + else + echo "✗ Failed to open PR. HTTP ${HTTP_CODE}: ${HTTP_BODY}" >&2 + exit 1 + fi diff --git a/.gitea/actions/platform-check/action.yml b/.gitea/actions/platform-check/action.yml new file mode 100644 index 0000000..12a45b8 --- /dev/null +++ b/.gitea/actions/platform-check/action.yml @@ -0,0 +1,78 @@ +name: 'platform-check' +description: 'Validates catalog-info.yaml conformance and platform branch initialization' + +runs: + using: composite + steps: + - name: Ensure PyYAML + shell: bash + run: python3 -c "import yaml" 2>/dev/null || pip3 install pyyaml -q + + - name: Platform conformance check + shell: bash + run: | + FAIL=0 + echo "══════════════════════════════════════════════════" + echo " Platform Conformance Check" + echo "══════════════════════════════════════════════════" + + # ── Required platform files ─────────────────────────── + for f in catalog-info.yaml Dockerfile docs; do + if [[ -e "$f" ]]; then + echo " ✓ $f" + else + echo " ✗ $f MISSING" + FAIL=1 + fi + done + + # ── Platform branch model initialization ───────────────────────────────── + if [[ -f ".platform/initialized.md" ]]; then + echo " ✓ .platform/initialized.md (GitOps branch model active)" + else + echo " ✗ .platform/initialized.md MISSING" + echo " This file is committed to the dev branch by gitea:setup-branches." + echo " Its presence proves this PR originated from the platform-managed dev branch." + FAIL=1 + fi + + # ── catalog-info.yaml field validation ─────────────── + if python3 - <<'PYEOF' + import yaml, sys + try: + # catalog-info.yaml may be multi-document (Component + API); load the Component doc + docs = list(yaml.safe_load_all(open('catalog-info.yaml'))) + doc = next((d for d in docs if isinstance(d, dict) and d.get('kind') == 'Component'), docs[0] if docs else {}) + except Exception as e: + print(f" ✗ catalog-info.yaml: parse error: {e}") + sys.exit(1) + fields = [ + ('apiVersion', lambda d: d.get('apiVersion')), + ('kind', lambda d: d.get('kind')), + ('metadata.name', lambda d: (d.get('metadata') or {}).get('name')), + ('spec.type', lambda d: (d.get('spec') or {}).get('type')), + ('spec.owner', lambda d: (d.get('spec') or {}).get('owner')), + ] + fail = False + for field, getter in fields: + val = getter(doc) + if val: + print(f" ✓ catalog-info.yaml/{field}: {val}") + else: + print(f" ✗ catalog-info.yaml/{field}: missing or empty") + fail = True + sys.exit(1 if fail else 0) + PYEOF + then + echo " ✓ catalog-info.yaml is conformant" + else + FAIL=1 + fi + + echo "──────────────────────────────────────────────────" + if [[ $FAIL -eq 0 ]]; then + echo " ✓ Platform conformance: PASSED" + else + echo " ✗ Platform conformance: FAILED" + exit 1 + fi diff --git a/.gitea/workflows/build-push.yml b/.gitea/workflows/build-push.yml new file mode 100644 index 0000000..d2340da --- /dev/null +++ b/.gitea/workflows/build-push.yml @@ -0,0 +1,113 @@ +name: Build and Push to ACR + +on: + push: + branches: [ "main" ] + workflow_dispatch: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + AZURE_FEDERATED_TOKEN_FILE: /var/run/secrets/azure/tokens/azure-identity-token + +jobs: + guard: + name: Platform guard + runs-on: ubuntu-latest + outputs: + ready: + steps: + - uses: actions/checkout@v4 + - name: Check platform initialized + id: check + run: | + if [ -f ".platform/initialized.md" ]; then + echo "ready=true" >> $GITHUB_OUTPUT + echo "Proceeding: platform initialized" + else + echo "ready=false" >> $GITHUB_OUTPUT + echo "Skipping: .platform/initialized.md not on main yet - merge the dev->main PR first" + fi + + build: + name: Build and Push + needs: guard + if: needs.guard.outputs.ready == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Tidy modules + run: go mod tidy + + - name: Test + run: go test ./... + + - name: Install Azure CLI + run: | + command -v az &>/dev/null || curl -sL https://aka.ms/InstallAzureCLIDeb | bash + + - name: Install Docker CLI + run: | + command -v docker &>/dev/null || (apt-get update -qq && apt-get install -y docker.io) + docker --version + + - name: Azure login (OIDC) + run: | + az login \ + --service-principal \ + --username "$AZURE_CLIENT_ID" \ + --tenant "$AZURE_TENANT_ID" \ + --federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)" + echo "✓ Azure login successful" + + - name: Get ACR details + id: acr + run: | + ACR_NAME=$(az acr list --query "[0].name" -o tsv) + ACR_NAME="${ACR_NAME:-bstagecjotdevacr}" + echo "ACR_NAME=$ACR_NAME" >> $GITHUB_ENV + echo "ACR_LOGIN_SERVER=${ACR_NAME}.azurecr.io" >> $GITHUB_ENV + echo "✓ Using ACR: ${ACR_NAME}.azurecr.io" + + - name: ACR Login + run: | + ACR_TOKEN=$(az acr login --name "$ACR_NAME" --expose-token --output tsv --query accessToken) + docker login "$ACR_LOGIN_SERVER" \ + --username 00000000-0000-0000-0000-000000000000 \ + --password "$ACR_TOKEN" + echo "✓ ACR login successful" + + - name: Build and Push Docker image + run: | + IMAGE_TAG="${{ gitea.sha }}" + IMAGE_FULL="${ACR_LOGIN_SERVER}/tmpl-test-go:${IMAGE_TAG}" + IMAGE_LATEST="${ACR_LOGIN_SERVER}/tmpl-test-go:latest" + docker build -t "$IMAGE_FULL" -t "$IMAGE_LATEST" . + docker push "$IMAGE_FULL" + docker push "$IMAGE_LATEST" + echo "IMAGE_FULL=$IMAGE_FULL" >> $GITHUB_ENV + echo "✓ Pushed: $IMAGE_FULL" + + - name: Build Summary + run: | + echo "### ✅ Build Successful" >> $GITHUB_STEP_SUMMARY + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **Service** | tmpl-test-go |" >> $GITHUB_STEP_SUMMARY + echo "| **Runtime** | Go |" >> $GITHUB_STEP_SUMMARY + echo "| **Commit** | ${{ gitea.sha }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Image** | $IMAGE_FULL |" >> $GITHUB_STEP_SUMMARY diff --git a/.gitea/workflows/deploy-humanitec.yml b/.gitea/workflows/deploy-humanitec.yml new file mode 100644 index 0000000..6824917 --- /dev/null +++ b/.gitea/workflows/deploy-humanitec.yml @@ -0,0 +1,207 @@ +name: Deploy to Humanitec + +on: + workflow_run: + workflows: ["Build and Push to ACR"] + types: + - completed + branches: [ "main" ] + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + default: 'dev' + type: choice + options: + - dev + - staging + - prod + +env: + APP_ID: tmpl-test-go + PROJECT_ID: tmpl-test-go + AZURE_FEDERATED_TOKEN_FILE: /var/run/secrets/azure/tokens/azure-identity-token + DEFAULT_ENV_ID: dev + +jobs: + guard: + name: Platform guard + runs-on: ubuntu-latest + outputs: + ready: + steps: + - uses: actions/checkout@v4 + - name: Check platform initialized + id: check + run: | + if [ -f ".platform/initialized.md" ]; then + echo "ready=true" >> $GITHUB_OUTPUT + echo "Proceeding: platform initialized" + else + echo "ready=false" >> $GITHUB_OUTPUT + echo "Skipping: .platform/initialized.md not on main yet - merge the dev->main PR first" + fi + + deploy: + name: Deploy to Humanitec + needs: guard + runs-on: ubuntu-latest + if: needs.guard.outputs.ready == 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') + permissions: + contents: read + id-token: write + outputs: + sha: ${{ steps.get-sha.outputs.sha }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get short SHA + id: get-sha + run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - name: Install CLI tools + run: | + # jq (needed for github API call) + command -v jq &>/dev/null || (apt-get update -qq && apt-get install -y -qq jq) + # hctl (Humanitec Platform Orchestrator v2 CLI) + HCTL_VERSION=$(curl -s https://api.github.com/repos/humanitec/hctl/releases/latest | jq -r '.tag_name') + curl -fLO "https://github.com/humanitec/hctl/releases/download/${HCTL_VERSION}/hctl_${HCTL_VERSION#v}_linux_amd64.tar.gz" + tar -xzf "hctl_${HCTL_VERSION#v}_linux_amd64.tar.gz" + install -m 755 hctl /usr/local/bin/hctl + hctl --version + echo "✓ Tools installed" + + - name: Install Azure CLI + run: command -v az &>/dev/null || curl -sL https://aka.ms/InstallAzureCLIDeb | bash + + - name: Azure login (OIDC) + run: | + az login \ + --service-principal \ + --username "$AZURE_CLIENT_ID" \ + --tenant "$AZURE_TENANT_ID" \ + --federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)" + echo "✓ Azure login successful" + + - name: Get image reference + id: image + run: | + ACR_LOGIN_SERVER=$(az acr list --query "[0].loginServer" -o tsv) + SHA=${{ steps.get-sha.outputs.sha }} + echo "IMAGE_FULL=${ACR_LOGIN_SERVER}/tmpl-test-go:${SHA}" >> $GITHUB_ENV + echo "✓ Image: ${ACR_LOGIN_SERVER}/tmpl-test-go:${SHA}" + + - name: Set environment + id: set-env + run: | + if [ "${{ gitea.event_name }}" = "workflow_dispatch" ]; then + ENV_ID="${{ gitea.event.inputs.environment }}" + else + ENV_ID="$DEFAULT_ENV_ID" + fi + ENV_ID="${ENV_ID:-dev}" + echo "ENV_ID=$ENV_ID" >> $GITHUB_OUTPUT + echo "ENV_ID=$ENV_ID" >> $GITHUB_ENV + echo "✓ Target environment: $ENV_ID" + + - name: Get Humanitec credentials from Key Vault + run: | + HUMANITEC_TOKEN=$(az keyvault secret show \ + --vault-name "bstage-cjot-dev-core-kv" \ + --name "humanitec-api-token-v2" \ + --query "value" -o tsv) + [ -z "$HUMANITEC_TOKEN" ] && echo "❌ Failed to retrieve HUMANITEC_TOKEN" && exit 1 + echo "HUMANITEC_AUTH_TOKEN=$HUMANITEC_TOKEN" >> $GITHUB_ENV + + HUMANITEC_ORG=$(az keyvault secret show \ + --vault-name "bstage-cjot-dev-core-kv" \ + --name "humanitec-org-id" \ + --query "value" -o tsv) + [ -z "$HUMANITEC_ORG" ] && echo "❌ Failed to retrieve HUMANITEC_ORG" && exit 1 + echo "HUMANITEC_ORG=$HUMANITEC_ORG" >> $GITHUB_ENV + echo "✓ Credentials retrieved" + + - name: Deploy via Humanitec Score + run: | + # Pre-flight: wait for any prior deployment to finish before calling hctl. + # hctl refuses to start a new deployment while one is still executing. + echo "Pre-flight: checking for in-progress deployments..." + PREFLIGHT_WAITED=0 + while [ $PREFLIGHT_WAITED -lt 300 ]; do + PREFLIGHT_STATUS=$(curl -sf \ + -H "Authorization: Bearer $HUMANITEC_AUTH_TOKEN" \ + "https://api.humanitec.dev/orgs/$HUMANITEC_ORG/last-deployments?env_id=$ENV_ID&project_id=$PROJECT_ID&state_change_only=true" \ + | jq -r '.items[0].status // "none"' 2>/dev/null || echo "none") + if [ "$PREFLIGHT_STATUS" != "in progress" ] && [ "$PREFLIGHT_STATUS" != "pending" ] && [ "$PREFLIGHT_STATUS" != "executing" ]; then + echo "Pre-flight passed (status=$PREFLIGHT_STATUS). Proceeding." + break + fi + echo " Prior deployment still running ($PREFLIGHT_WAITED s elapsed, status=$PREFLIGHT_STATUS)..." + sleep 15 + PREFLIGHT_WAITED=$((PREFLIGHT_WAITED + 15)) + done + # First deploy — provisions all resources. On a brand-new Humanitec project the + # dns-k8s-ingress Terraform module runs before the K8s Service exists, so the + # ingress backend port falls back to 3000. A second deploy (below) corrects it + # once the Service is up, which is essential for apps not running on port 3000. + HCTL_EXIT=0 + timeout 300 hctl score deploy "$PROJECT_ID" "$ENV_ID" score.yaml \ + --no-prompt \ + --default-image "$IMAGE_FULL" || HCTL_EXIT=$? + if [ "$HCTL_EXIT" -eq 0 ]; then + echo "✓ First deployment complete to $ENV_ID" + elif [ "$HCTL_EXIT" -eq 124 ]; then + echo "✓ First deployment submitted (polling timed out — waiting for K8s to settle)" + else + echo "✗ hctl failed with exit code $HCTL_EXIT" + exit $HCTL_EXIT + fi + # Poll Humanitec API until the first deployment is no longer in-progress before + # re-deploying. A flat sleep is unreliable — Terraform DNS modules can take 4-6 min. + echo "Waiting for first deployment to finish (polling Humanitec API)..." + MAX_WAIT=360 + WAITED=0 + while [ $WAITED -lt $MAX_WAIT ]; do + DEPLOY_STATUS=$(curl -sf \ + -H "Authorization: Bearer $HUMANITEC_AUTH_TOKEN" \ + "https://api.humanitec.dev/orgs/$HUMANITEC_ORG/last-deployments?env_id=$ENV_ID&project_id=$PROJECT_ID&state_change_only=true" \ + | jq -r '.items[0].status // "unknown"' 2>/dev/null || echo "unknown") + if [ "$DEPLOY_STATUS" != "in progress" ] && [ "$DEPLOY_STATUS" != "pending" ] && [ "$DEPLOY_STATUS" != "executing" ]; then + echo "First deployment finished with status: $DEPLOY_STATUS" + break + fi + echo " Still running ($WAITED s elapsed, status=$DEPLOY_STATUS)..." + sleep 15 + WAITED=$((WAITED + 15)) + done + if [ $WAITED -ge $MAX_WAIT ]; then + echo "Warning: first deployment still running after $MAX_WAIT s — proceeding anyway" + fi + # Second deploy — dns module now reads the real K8s Service port, fixing the + # ingress backend. This is a no-op for apps already routed correctly (e.g. port 3000). + HCTL_EXIT2=0 + timeout 120 hctl score deploy "$PROJECT_ID" "$ENV_ID" score.yaml \ + --no-prompt \ + --default-image "$IMAGE_FULL" || HCTL_EXIT2=$? + if [ "$HCTL_EXIT2" -eq 0 ]; then + echo "✓ Deployment finalised to $ENV_ID" + elif [ "$HCTL_EXIT2" -eq 124 ]; then + echo "✓ Second deployment submitted (polling timed out)" + else + echo "✗ Second hctl deploy failed with exit code $HCTL_EXIT2" + exit $HCTL_EXIT2 + fi + + - name: Deployment Summary + run: | + DEPLOY_URL="https://console.humanitec.dev/orgs/${HUMANITEC_ORG}/projects/$APP_ID" + echo "### 🚀 Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **Service** | $APP_ID |" >> $GITHUB_STEP_SUMMARY + echo "| **Environment** | $ENV_ID |" >> $GITHUB_STEP_SUMMARY + echo "| **Image** | $IMAGE_FULL |" >> $GITHUB_STEP_SUMMARY + echo "| **Humanitec** | [$DEPLOY_URL]($DEPLOY_URL) |" >> $GITHUB_STEP_SUMMARY diff --git a/.gitea/workflows/integration-test.yml b/.gitea/workflows/integration-test.yml new file mode 100644 index 0000000..4a2aed6 --- /dev/null +++ b/.gitea/workflows/integration-test.yml @@ -0,0 +1,96 @@ + +name: Integration Test + +on: + pull_request: + branches: [ "main" ] + workflow_dispatch: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── Job 1: Platform Conformance ─────────────────────────────────────────── + platform-check: + name: Platform Conformance + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Platform conformance + uses: ./.gitea/actions/platform-check + + # ── Job 2: Unit Tests + Container Smoke ─────────────────────────────────── + smoke-test: + name: Unit Tests + Container Smoke + runs-on: ubuntu-latest + needs: platform-check + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Tidy modules + run: go mod tidy + + - name: Unit tests + run: go test ./... -v + + - name: Install Docker CLI + run: command -v docker &>/dev/null || (apt-get update -qq && apt-get install -y docker.io) + + - name: Build container image + run: docker build -t ci-image:test . + + - name: Start service and wait for health + run: | + docker run -d --name ci-service -p 8080:8080 ci-image:test + echo "Waiting for /health (up to 60s)..." + timeout 60 bash -c ' + until curl -sf http://localhost:8080/health >/dev/null; do + echo " still waiting..."; sleep 2 + done + ' + echo "✓ Service started" + + - name: Validate health response + run: | + curl -sf http://localhost:8080/health > /tmp/health.json + echo "Health response:" + cat /tmp/health.json + python3 - <<'PYEOF' + import json, sys + body = json.load(open('/tmp/health.json')) + status = str(body.get('status') or '').upper() + if status not in ('UP', 'OK', 'HEALTHY'): + print(f" ✗ unexpected health status: {body.get('status')!r}") + sys.exit(1) + print(f" ✓ health status: {body['status']}") + PYEOF + echo "✓ Container smoke test: PASSED" + + - name: Write job summary + if: always() + run: | + COMPONENT=$(python3 -c "import yaml; docs=list(yaml.safe_load_all(open('catalog-info.yaml'))); d=next((x for x in docs if isinstance(x,dict) and x.get('kind')=='Component'),docs[0]); print(d['metadata']['name'])" 2>/dev/null || echo "tmpl-test-go") + echo "## Integration Test: \`${COMPONENT}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Stage | Detail |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Platform conformance | catalog-info.yaml ✓ \`.platform/initialized.md\` ✓ (see platform-check job) |" >> $GITHUB_STEP_SUMMARY + echo "| Unit tests | \`go test ./... -v\` |" >> $GITHUB_STEP_SUMMARY + echo "| Container smoke | \`GET /health\` → HTTP 200, status=UP |" >> $GITHUB_STEP_SUMMARY + + - name: Cleanup + if: always() + run: docker rm -f ci-service 2>/dev/null || true + diff --git a/.gitea/workflows/techdocs.yml b/.gitea/workflows/techdocs.yml new file mode 100644 index 0000000..1c04c70 --- /dev/null +++ b/.gitea/workflows/techdocs.yml @@ -0,0 +1,108 @@ +name: Build and Publish TechDocs + +on: + workflow_run: + workflows: ["Build and Push to ACR"] + branches: [main] + types: [completed] + workflow_dispatch: {} + +env: + AZURE_FEDERATED_TOKEN_FILE: /var/run/secrets/azure/tokens/azure-identity-token + AZURE_ACCOUNT_NAME: "bstagecjotdevsttechdocs" + ENTITY_NAMESPACE: default + ENTITY_KIND: component + ENTITY_NAME: tmpl-test-go + +jobs: + guard: + name: Platform guard + runs-on: ubuntu-latest + outputs: + ready: + steps: + - uses: actions/checkout@v4 + - name: Check platform initialized + id: check + run: | + if [ -f ".platform/initialized.md" ]; then + echo "ready=true" >> $GITHUB_OUTPUT + echo "Proceeding: platform initialized" + else + echo "ready=false" >> $GITHUB_OUTPUT + echo "Skipping: .platform/initialized.md not on main yet - merge the dev->main PR first" + fi + + build-and-publish: + needs: guard + if: needs.guard.outputs.ready == 'true' && (github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Bootstrap pip + run: | + python3 --version + if python3 -m pip --version 2>/dev/null; then + echo "pip already available" + elif python3 -m ensurepip --version 2>/dev/null; then + python3 -m ensurepip --upgrade + else + apt-get update -qq + apt-get install -y python3-pip + fi + python3 -m pip install --upgrade pip + python3 -m pip --version + + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install \ + mkdocs-techdocs-core==1.* \ + mkdocs-git-revision-date-localized-plugin \ + mkdocs-awesome-pages-plugin + + npm install -g @techdocs/cli + + - name: Validate MkDocs config + run: mkdocs build --strict --site-dir /tmp/mkdocs-validate + + - name: Build TechDocs site + run: | + techdocs-cli generate \ + --source-dir . \ + --output-dir ./site \ + --no-docker \ + --verbose + + - name: Install Azure CLI + run: | + if command -v az &>/dev/null; then + echo "Azure CLI already installed: $(az version --query '"azure-cli"' -o tsv)" + else + curl -sL https://aka.ms/InstallAzureCLIDeb | bash + fi + + - name: Azure login (OIDC) + run: | + az login \ + --service-principal \ + --username "$AZURE_CLIENT_ID" \ + --tenant "$AZURE_TENANT_ID" \ + --federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)" + + echo "Azure login successful" + + - name: Publish TechDocs site + run: | + techdocs-cli publish \ + --publisher-type azureBlobStorage \ + --storage-name "techdocs" \ + --azureAccountName "$AZURE_ACCOUNT_NAME" \ + --entity "$ENTITY_NAMESPACE/$ENTITY_KIND/$ENTITY_NAME" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84089ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +*.exe +*.test +.DS_Store +vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30513c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# ---- Build stage ---- +FROM golang:1.22-alpine AS build +WORKDIR /src + +COPY go.mod go.sum* ./ +RUN go mod tidy && go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app . + +# ---- Runtime stage (distroless) ---- +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /app /app + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/app", "-health-check"] + +ENTRYPOINT ["/app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ac408d --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# tmpl-test-go + +Test scaffold for go runtime + +**Runtime**: Go 1.22 · net/http +**Owner**: platform-engineering + +## Quick Start + +```bash +go mod tidy +go run . +``` + +Service on **http://localhost:8080**. + +## Endpoints +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/items` | List items | +| POST | `/api/items` | Create item | +| GET | `/api/items/{id}` | Get by ID | +| PUT | `/api/items/{id}` | Update | +| DELETE | `/api/items/{id}` | Delete | +| GET | `/health` | `{"status":"UP"}` | +| GET | `/metrics` | Prometheus metrics | + +## Test +```bash +go test ./... +``` diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 0000000..c2381bf --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,113 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: tmpl-test-go + description: Test scaffold for go runtime + annotations: + humanitec.dev/orgId: skillful-wild-chicken-2617 + humanitec.dev/projectId: tmpl-test-go + backstage.io/techdocs-ref: dir:. + backstage.io/kubernetes-namespace: dev + backstage.io/kubernetes-namespaces: "dev" + backstage.io/kubernetes-label-selector: "app=tmpl-test-go" + gitea.kyndemo.live/repo-slug: "validate/tmpl-test-go" + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + grafana/grafana-instance: default + grafana/alert-label-selector: "app=tmpl-test-go" + grafana/dashboard-selector: "uid == 'app-tmpl-test-go'" + tags: + - microservice + - golden-path + - go + - stateless + links: + - url: https://console.humanitec.dev/orgs/skillful-wild-chicken-2617/projects/tmpl-test-go + title: Humanitec Console + icon: dashboard + - url: https://gitea.kyndemo.live/validate/tmpl-test-go + title: Source Repository + icon: github +spec: + type: service + owner: platform-engineering + + lifecycle: experimental + providesApis: + - tmpl-test-go-api +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: tmpl-test-go-api + description: REST API for tmpl-test-go + annotations: + backstage.io/techdocs-ref: dir:. +spec: + type: openapi + lifecycle: experimental + owner: platform-engineering + + definition: | + openapi: "3.0.0" + info: + title: tmpl-test-go + version: "0.1.0" + description: "Test scaffold for go runtime" + servers: + - url: https://tmpl-test-go.kyndemo.live + paths: + /api/items: + get: + summary: List all items + responses: + "200": + description: OK + post: + summary: Create item + responses: + "201": + description: Created + /api/items/{id}: + get: + summary: Get by ID + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: OK + "404": + description: Not found + put: + summary: Update item + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: OK + delete: + summary: Delete item + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Deleted + /health: + get: + summary: Health check + responses: + "200": + description: "{ status: UP }" diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..30adaca --- /dev/null +++ b/docs/api.md @@ -0,0 +1,19 @@ +# API Reference + +Base URL: `https://tmpl-test-go.kyndemo.live` + +## Items `/api/items` + +| Method | Path | Body | Response | +|--------|------|------|----------| +| GET | `/api/items` | — | Array of items | +| POST | `/api/items` | `{"name":"...","description":"..."}` | Created item (201) | +| GET | `/api/items/{id}` | — | Item or 404 | +| PUT | `/api/items/{id}` | `{"name":"...","description":"..."}` | Updated item | +| DELETE | `/api/items/{id}` | — | `{"deleted":id}` | + +## Observability +| Path | Description | +|------|-------------| +| `/health` | `{"status":"UP"}` | +| `/metrics` | Prometheus exposition format | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5f4b686 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,17 @@ +# Architecture + +`tmpl-test-go` is a Go 1.22 service using only the standard `net/http` library. + +| Component | Technology | +|-----------|-----------| +| HTTP | stdlib `net/http` | +| Metrics | `prometheus/client_golang` → `/metrics` | +| Health | `/health` | +| Image | Distroless `nonroot` (minimal attack surface) | + +## Deployment flow + +``` +Push → build-push.yml (go test + docker build → ACR) + → deploy-humanitec.yml (humctl score deploy) +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6dc8592 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +# Home + +tmpl-test-go — Test scaffold for go runtime + +- **Runtime**: Go 1.22 · stdlib `net/http` +- **Owner**: platform-engineering +- **Deployment profile**: `stateless` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95e05ff --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/kyndryl-platform/tmpl-test-go + +go 1.22 + +require ( + github.com/prometheus/client_golang v1.19.1 +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..bb029aa --- /dev/null +++ b/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// --------------------------------------------------------------------------- +// Domain model +// --------------------------------------------------------------------------- + +type Item struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type ItemRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// --------------------------------------------------------------------------- +// In-memory store +// --------------------------------------------------------------------------- + +var ( + store sync.Map + counter atomic.Int64 +) + +func nextID() int { + return int(counter.Add(1)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func itemIDFromPath(path string) (int, bool) { + parts := strings.Split(strings.TrimSuffix(path, "/"), "/") + if len(parts) < 3 { + return 0, false + } + id, err := strconv.Atoi(parts[len(parts)-1]) + return id, err == nil +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +func healthHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "UP"}) +} + +func itemsHandler(w http.ResponseWriter, r *http.Request) { + // Route: /api/items or /api/items/{id} + hasSub := strings.Count(strings.TrimSuffix(r.URL.Path, "/"), "/") >= 3 + + if hasSub { + id, ok := itemIDFromPath(r.URL.Path) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) + return + } + switch r.Method { + case http.MethodGet: + v, exists := store.Load(id) + if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + writeJSON(w, http.StatusOK, v) + case http.MethodPut: + var req ItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + existingRaw, exists := store.Load(id) + if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + existing := existingRaw.(Item) + if req.Name == "" { + req.Name = existing.Name + } + if req.Description == "" { + req.Description = existing.Description + } + updated := Item{ID: id, Name: req.Name, Description: req.Description} + store.Store(id, updated) + writeJSON(w, http.StatusOK, updated) + case http.MethodDelete: + store.Delete(id) + writeJSON(w, http.StatusOK, map[string]int{"deleted": id}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + return + } + + // /api/items — list or create + switch r.Method { + case http.MethodGet: + var items []Item + store.Range(func(_, v any) bool { + items = append(items, v.(Item)) + return true + }) + if items == nil { + items = []Item{} + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req ItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if req.Name == "" { + req.Name = "unnamed" + } + item := Item{ID: nextID(), Name: req.Name, Description: req.Description} + store.Store(item.ID, item) + writeJSON(w, http.StatusCreated, item) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +func main() { + mux := http.NewServeMux() + + mux.HandleFunc("/health", healthHandler) + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/api/items/", itemsHandler) + mux.HandleFunc("/api/items", itemsHandler) + + addr := ":8080" + fmt.Printf("tmpl-test-go listening on %s\n", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..81ce4df --- /dev/null +++ b/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealth(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + healthHandler(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..119492f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,11 @@ +site_name: tmpl-test-go +site_description: Test scaffold for go runtime +docs_dir: docs + +nav: + - Home: index.md + - Architecture: architecture.md + - API Reference: api.md + +plugins: + - techdocs-core diff --git a/score.yaml b/score.yaml new file mode 100644 index 0000000..cfaa7d8 --- /dev/null +++ b/score.yaml @@ -0,0 +1,43 @@ +apiVersion: score.dev/v1b1 +metadata: + name: tmpl-test-go + +containers: + app: + image: . + livenessProbe: + httpGet: + path: /health + port: 8080 + readinessProbe: + httpGet: + path: /health + port: 8080 + resources: + limits: + memory: "128Mi" + cpu: "200m" + requests: + memory: "32Mi" + cpu: "25m" + +service: + ports: + http: + port: 80 + targetPort: 8080 + +resources: + env: + type: environment + + dns: + type: dns + + # ----- Uncomment to add a PostgreSQL database (deployment_profile: db-only or db+cache) ----- + # db: + # type: postgres + + # ----- Uncomment to add Redis cache (deployment_profile: db+cache) ----- + # cache: + # type: redis