diff --git a/.gitea/workflows/build-push.yml b/.gitea/workflows/build-push.yml index 7c6e9ca..4a3da35 100644 --- a/.gitea/workflows/build-push.yml +++ b/.gitea/workflows/build-push.yml @@ -2,7 +2,7 @@ name: Build and Push to ACR on: push: - branches: [ dev ] + branches: [ "dev" ] workflow_dispatch: {} concurrency: @@ -16,11 +16,6 @@ jobs: build: name: Build and Push runs-on: ubuntu-latest - if: >- - github.ref != 'refs/heads/main' && ( - github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000') - ) permissions: contents: read id-token: write @@ -28,16 +23,41 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + # cache: 'npm' omitted — requires package-lock.json which may not exist yet + - name: Install dependencies + run: npm install + - name: Build project + run: npm run build || echo "No build script configured" + - name: Run tests + run: npm test || echo "No tests configured" + + - name: Security Scan - Trivy + continue-on-error: true + run: | + # Download release tarball directly — avoids install.sh which calls + # api.github.com/releases/tags/... and fails in network-restricted runners. + TRIVY_VERSION="0.57.1" + TRIVY_BIN="/tmp/trivy-bin/trivy" + if ! command -v trivy &>/dev/null; then + mkdir -p /tmp/trivy-bin + curl -sfLo /tmp/trivy.tar.gz "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" + tar -xzf /tmp/trivy.tar.gz -C /tmp/trivy-bin trivy + chmod +x "${TRIVY_BIN}" + else + TRIVY_BIN="$(command -v trivy)" + fi + # Filesystem scan — exit-code 0 so findings are reported but never block the build + "${TRIVY_BIN}" fs --severity HIGH,CRITICAL --exit-code 0 --format table --skip-db-update --offline-scan . || "${TRIVY_BIN}" fs --severity HIGH,CRITICAL --exit-code 0 --format table . - 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 - + if ! command -v az &>/dev/null; then + curl -sL https://aka.ms/InstallAzureCLIDeb | bash + fi - name: Azure login (OIDC) run: | az login \ @@ -45,40 +65,13 @@ jobs: --username "$AZURE_CLIENT_ID" \ --tenant "$AZURE_TENANT_ID" \ --federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)" - echo "✓ Azure login successful" - - - name: Get ACR details + - name: Build and push via ACR Tasks 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}/reno-realworld-5:${IMAGE_TAG}" - IMAGE_LATEST="${ACR_LOGIN_SERVER}/reno-realworld-5: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** | reno-realworld-5 |" >> $GITHUB_STEP_SUMMARY - echo "| **Commit** | ${{ gitea.sha }} |" >> $GITHUB_STEP_SUMMARY - echo "| **Image** | $IMAGE_FULL |" >> $GITHUB_STEP_SUMMARY + SHORT_SHA=$(echo "${{ gitea.sha }}" | cut -c1-7) + az acr build \ + --registry bstagecjotdevacr \ + --image reno-realworld-5:$SHORT_SHA \ + --image reno-realworld-5:latest \ + --file Dockerfile \ + . + echo "✓ Pushed: bstagecjotdevacr.azurecr.io/reno-realworld-5:$SHORT_SHA" diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 280ce24..5878c78 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to Orchestrator +name: Deploy to Humanitec v2 on: workflow_run: @@ -12,175 +12,104 @@ on: required: true default: 'dev' type: choice - options: - - dev - - staging - - prod + options: [dev, staging, production] env: - PO_API_URL: https://api.dev.orchestrator.crucible.kyndemo.live - PO_ORG_ID: crucible - PO_AUTH_TOKEN: ${{ secrets.PO_AUTH_TOKEN }} - # ONE ORCHESTRATOR PROJECT PER APPLICATION. - # - # This used to be the shared `apps-cluster` project with one environment per app, which - # made every app a peer of every other: the Orchestrator tab on any component listed the - # entire estate, and an app had exactly one environment named after itself, so there was - # nowhere for dev/staging/prod to live. - # - # That shape existed to avoid a Terraform pull request against config/projects.tf for every - # scaffolded app. That constraint turned out not to be real -- `octl create project`, - # `octl create runner-rule` and `octl create environment` are all runtime operations, so - # the workflow below builds the whole thing on first deploy and needs no repository change. + HUMANITEC_ORG: skillful-wild-chicken-2617 + HUMANITEC_AUTH_TOKEN: ${{ secrets.HUMANITEC_TOKEN }} PROJECT_ID: reno-realworld-5 - # The runner a project's workloads execute on. This is NOT cosmetic: the Kubernetes and - # Helm providers are ambient, so a workload lands in whichever cluster its runner lives in, - # and Terraform state is keyed per runner. A project bound to the wrong runner deploys to - # the wrong cluster, and repointing it afterwards orphans the state it already owns. - PO_RUNNER_ID: crucible-orchestrator-dev-apps-dev-runner - OCTL_VERSION: 1.0.0 - IMAGE: bstagecjotdevacr.azurecr.io/reno-realworld-5 + DEFAULT_ENV_ID: dev + ACR_REGISTRY: bstagecjotdevacr.azurecr.io jobs: - guard: - name: Platform guard + deploy: + name: Deploy to Humanitec v2 runs-on: ubuntu-latest - outputs: - ready: ${{ steps.check.outputs.ready }} + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' steps: - uses: actions/checkout@v4 - - name: Check platform initialized - id: check + - name: Install dependencies + run: apt-get update -qq && apt-get install -y jq + - name: Install hctl CLI run: | - if [ -f ".platform/initialized.md" ]; then - echo "ready=true" >> $GITHUB_OUTPUT - else - echo "ready=false" >> $GITHUB_OUTPUT - echo "Skipping: .platform/initialized.md not found" - fi - - deploy: - name: Deploy to Orchestrator - needs: guard - if: >- - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && needs.guard.outputs.ready == 'true') || - (github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install octl + HCTL_VERSION=$(curl -s https://api.github.com/repos/humanitec/hctl/releases/latest | jq -r '.tag_name') + mkdir -p /tmp/hctl-install + curl -sLo /tmp/hctl-install/hctl.tar.gz "https://github.com/humanitec/hctl/releases/download/${HCTL_VERSION}/hctl_${HCTL_VERSION#v}_linux_amd64.tar.gz" + tar -xzf /tmp/hctl-install/hctl.tar.gz -C /tmp/hctl-install + install -m 755 /tmp/hctl-install/hctl /usr/local/bin/hctl + - name: Ensure Humanitec project and environment exist + env: + HUMANITEC_AUTH_TOKEN: ${{ secrets.HUMANITEC_TOKEN }} run: | - set -euo pipefail - curl -fsSLo /tmp/octl.tar.gz \ - "https://github.com/stellwerk-labs/platform-orchestrator-cli/releases/download/v${OCTL_VERSION}/platform-orchestrator-cli_${OCTL_VERSION}_linux_amd64.tar.gz" - tar xzf /tmp/octl.tar.gz -C /tmp - install -m 755 /tmp/octl /usr/local/bin/octl - octl --version - - - name: Derive environment - run: | - # The environment is now a STAGE of this application -- dev, staging, prod -- because - # the project is the application. It used to be the component id, which was the only - # option while every app shared one project and had to be distinguishable inside it. DISPATCH_ENV="${{ github.event.inputs.environment }}" - if [ -n "$DISPATCH_ENV" ]; then - ENV_ID="$DISPATCH_ENV" + ENV_ID="${DISPATCH_ENV:-$DEFAULT_ENV_ID}" + # Create project if it doesn't exist (hctl exits 0 if already exists) + hctl create project "$PROJECT_ID" --set display_name="reno-realworld-5" 2>&1 | grep -v "already exists" || true + # Create environment if it doesn't exist + hctl create environment "$PROJECT_ID" "$ENV_ID" --set env_type_id=development --set display_name="Development" 2>&1 | grep -v "already exists" || true + echo "✓ Project $PROJECT_ID / env $ENV_ID ready" + - name: Deploy with Score + env: + HUMANITEC_AUTH_TOKEN: ${{ secrets.HUMANITEC_TOKEN }} + run: | + DISPATCH_ENV="${{ github.event.inputs.environment }}" + ENV_ID="${DISPATCH_ENV:-$DEFAULT_ENV_ID}" + DEFAULT_IMAGE="$ACR_REGISTRY/reno-realworld-5:latest" + # Pre-flight: wait for any deployment from a prior run 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..." + MAX_PREFLIGHT=420 + PREFLIGHT_WAITED=0 + while [ $PREFLIGHT_WAITED -lt $MAX_PREFLIGHT ]; 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 Java/Python apps on port 8080. + HCTL_EXIT=0 + timeout 300 hctl score deploy "$PROJECT_ID" "$ENV_ID" score.yaml --no-prompt --default-image "$DEFAULT_IMAGE" || HCTL_EXIT=$? + if [ "$HCTL_EXIT" -eq 0 ]; then + echo "✓ First deployment complete for reno-realworld-5 to $ENV_ID" + elif [ "$HCTL_EXIT" -eq 124 ]; then + echo "✓ First deployment submitted (polling timed out — waiting for K8s to settle)" else - # On a workflow_run the branch is the triggering run's, not this job's checkout. - BRANCH="${{ github.event.workflow_run.head_branch }}" - BRANCH="${BRANCH:-${GITHUB_REF_NAME}}" - case "$BRANCH" in - staging) ENV_ID=staging ;; - prod|main|master) ENV_ID=prod ;; - *) ENV_ID=dev ;; - esac - echo "Branch '$BRANCH' maps to environment '$ENV_ID'" + echo "✗ hctl failed with exit code $HCTL_EXIT" + exit $HCTL_EXIT fi - echo "ENV_ID=$ENV_ID" >> $GITHUB_ENV - echo "Deploying $PROJECT_ID to environment: $ENV_ID" - - - name: Ensure the project and its runner binding exist - run: | - set -euo pipefail - # Created on first deploy rather than by a pull request against config/projects.tf. - # None of this needs a repository change: project, runner rule and environment are - # all runtime objects. - # - # Neither create is idempotent, so both fall through to a read on the second run. - octl create project "$PROJECT_ID" \ - --set display_name='reno-realworld-5' || \ - octl get project "$PROJECT_ID" - - # The runner rule is what routes this project's deployments to the apps cluster. - # Creating it twice would leave two rules matching the same project, so it is - # created only when absent -- `create` would happily add a duplicate. - if octl get runner-rules -o json 2>/dev/null | grep -q "\"project_id\": *\"$PROJECT_ID\""; then - echo "Runner rule for '$PROJECT_ID' already exists." + # 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 + HCTL_EXIT2=0 + timeout 120 hctl score deploy "$PROJECT_ID" "$ENV_ID" score.yaml --no-prompt --default-image "$DEFAULT_IMAGE" || HCTL_EXIT2=$? + if [ "$HCTL_EXIT2" -eq 0 ]; then + echo "✓ Deployment finalised for reno-realworld-5 to $ENV_ID" + elif [ "$HCTL_EXIT2" -eq 124 ]; then + echo "✓ Second deployment submitted for reno-realworld-5 to $ENV_ID (polling timed out)" else - octl create runner-rule \ - --set project_id="$PROJECT_ID" \ - --set runner_id="$PO_RUNNER_ID" \ - --no-prompt + echo "✗ Second hctl deploy failed with exit code $HCTL_EXIT2" + exit $HCTL_EXIT2 fi - - - name: Ensure the environment exists - run: | - set -euo pipefail - # Project and environment ids are POSITIONAL; only env_type_id and display_name go - # through --set. `dev` and `stable` are the only environment TYPES that exist, so - # staging rides on the dev type -- the type governs policy, the id governs identity. - case "$ENV_ID" in - prod) ENV_TYPE=stable ;; - *) ENV_TYPE=dev ;; - esac - octl create environment "$PROJECT_ID" "$ENV_ID" \ - --set env_type_id="$ENV_TYPE" \ - --set display_name="$ENV_ID" || \ - octl get environment "$PROJECT_ID" "$ENV_ID" - - - name: Deploy the Score workload - run: | - set -euo pipefail - # `octl score deploy` is ADDITIVE — it adds or updates a workload in the manifest and - # never removes one. That is the opposite of `octl deploy`, where omission is - # deletion. Do not substitute one for the other. - # No --show-logs: octl 1.0.0 has no such flag and exits 1 with `unknown flag` - # BEFORE contacting the orchestrator, so the whole deploy dies on an argument - # typo. Its nearest relatives are --runner-logs-level (default `info`, already - # what we want) and --skip-logs (which suppresses storage). Neither streams the - # runner's logs into this job, so there is nothing to substitute -- the runner - # logs are read from the orchestrator, not from here. - # The tag must be the commit the BUILD built, and it must be the WHOLE sha. - # - # build-push.yml tags with `` -- all 40 characters -- so the - # 7-character `${GITHUB_SHA:0:7}` this used to pass named a tag that has never - # existed in the registry. - # - # And on a workflow_run, GITHUB_SHA is the DEFAULT branch's head, while the build - # that produced the image ran on `dev`. They coincide only while the branches are - # level. `workflow_run.head_sha` is the triggering run's own commit, which is by - # definition the one that was built; `github.sha` covers the workflow_dispatch case, - # where there is no triggering run. - IMAGE_TAG="${{ github.event.workflow_run.head_sha || github.sha }}" - echo "Deploying ${IMAGE}:${IMAGE_TAG}" - - octl score deploy "$PROJECT_ID" "$ENV_ID" score.yaml \ - --default-image "${IMAGE}:${IMAGE_TAG}" \ - --no-prompt - - - name: Deployment summary - if: always() - run: | - # Same commit the deploy step resolved, abbreviated for reading only -- the - # deployed tag is the full sha. - DEPLOYED_SHA="${{ github.event.workflow_run.head_sha || github.sha }}" - SHORT_SHA="${DEPLOYED_SHA:0:7}" - echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|---|---|" >> $GITHUB_STEP_SUMMARY - echo "| Project | \`$PROJECT_ID\` |" >> $GITHUB_STEP_SUMMARY - echo "| Environment | \`$ENV_ID\` |" >> $GITHUB_STEP_SUMMARY - echo "| Commit | \`$SHORT_SHA\` |" >> $GITHUB_STEP_SUMMARY - echo "[View in Orchestrator Console](https://console.dev.orchestrator.crucible.kyndemo.live/orgs/$PO_ORG_ID/projects/$PROJECT_ID/environments/$ENV_ID)" >> $GITHUB_STEP_SUMMARY diff --git a/.gitea/workflows/techdocs.yml b/.gitea/workflows/techdocs.yml new file mode 100644 index 0000000..59b8aa0 --- /dev/null +++ b/.gitea/workflows/techdocs.yml @@ -0,0 +1,52 @@ +name: Build and Publish TechDocs + +on: + push: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + - "catalog-info.yaml" + 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: reno-realworld-5 + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install dependencies + run: | + apt-get update -qq && apt-get install -y python3-pip + pip3 install mkdocs-techdocs-core==1.* + npm install -g @techdocs/cli + - 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 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)" + - 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/Dockerfile b/Dockerfile index 175f958..1a841cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,39 @@ -# This file is generated by Nx. -# -# Build the docker image with `npx nx docker-build api`. -# Tip: Modify "docker-build" options in project.json to change docker build args. -# -# Run the container with `docker run -p 3000:3000 -t api`. -FROM docker.io/node:lts-alpine - -ENV HOST=0.0.0.0 -ENV PORT=3000 - +# Build stage - compile TypeScript with Nx esbuild +FROM node:20-bullseye-slim AS build WORKDIR /app -RUN addgroup --system api && \ - adduser --system -G api api +COPY package*.json ./ +RUN npm install + +COPY nx.json project.json tsconfig*.json ./ +COPY src ./src + +RUN DATABASE_URL=postgresql://x:x@localhost/placeholder npx prisma generate --schema=src/prisma/schema.prisma +RUN npx nx build api --configuration=production + +# Runtime stage — Debian bullseye-slim ships OpenSSL 1.1 (required by Prisma 4.x) +FROM node:20-bullseye-slim +WORKDIR /app + +RUN groupadd --system api && \ + useradd --system -g api api + +COPY --from=build /app/dist/api/ ./ + +# Copy Prisma generated native client (Debian OpenSSL 1.1 binary) +COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma +# Copy @prisma/client package +COPY --from=build /app/node_modules/@prisma ./node_modules/@prisma +# Copy Prisma CLI so migrate/push can run +COPY --from=build /app/node_modules/prisma ./node_modules/prisma +COPY --from=build /app/node_modules/.bin/prisma ./node_modules/.bin/prisma +# Copy schema so Prisma can run migrations at startup +COPY --from=build /app/src/prisma ./src/prisma +RUN npm install --omit=dev --ignore-scripts 2>/dev/null || npm install --omit=dev -COPY dist/api api RUN chown -R api:api . +USER api -# You can remove this install step if you build with `--bundle` option. -# The bundled output will include external dependencies. -RUN npm --prefix api --omit=dev -f install +EXPOSE 3000 -CMD [ "node", "api" ] +CMD ["sh", "-c", "n=0; until node_modules/.bin/prisma migrate deploy --schema=src/prisma/schema.prisma; do n=$((n+1)); if [ $n -ge 30 ]; then echo migrate: giving up after $n attempts; exit 1; fi; echo migrate: database not ready, attempt $n of 30, retrying in 5s; sleep 5; done; exec node main.js"] diff --git a/catalog-info.yaml b/catalog-info.yaml index e765609..f7381d1 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -1,67 +1,46 @@ - apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: reno-realworld-5 - description: 'reno-realworld-5 — renovated onto the crucible platform orchestrator' annotations: - gitea.kyndemo.live/project-slug: validate/reno-realworld-5 - gitea.kyndemo.live/repo-slug: validate/reno-realworld-5 - # The orchestrator names each environment's namespace ns-xxxxx at deploy time, so a - # namespace can never be known here -- pinning one only hides the workload. Omitted - # deliberately: the Kubernetes plugin then searches every namespace it is given. - # - # kubernetes-id matches the label the score-workload module propagates from the Score - # metadata.labels block. The previous selector, app.humanitec.io/name=..., was a - # Humanitec SaaS label that nothing on this platform has ever set. backstage.io/kubernetes-id: reno-realworld-5 backstage.io/techdocs-ref: dir:. - # Vestigial key names, live values. These drive the Orchestrator tab, which reads - # humanitec.dev/orgId and humanitec.dev/projectId; renaming the keys would break every - # entity already in the catalog, so the names stay and the values point at crucible. - humanitec.dev/orgId: crucible - # THE PROJECT IS THE APPLICATION. This was `apps-cluster`, a single project shared by - # every renovated app, which is why the Orchestrator tab on any one component listed the - # whole estate -- the tab shows a project's environments, and every app's environment - # lived in that one project. Now each app owns a project, so the tab shows this app's - # stages and nothing else. The deploy workflow creates the project, its runner rule and - # its environments on first deploy. - humanitec.dev/projectId: reno-realworld-5 - # The environments are now STAGES of this application -- dev, staging, prod -- rather - # than one environment named after the component. - humanitec.dev/appId: reno-realworld-5 cjot.io/target-domain: apps + gitea.kyndemo.live/project-slug: validate/reno-realworld-5 + gitea.kyndemo.live/repo-slug: validate/reno-realworld-5 + grafana.com/dashboard-url: https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=reno-realworld-5 + grafana/alert-label-selector: app=reno-realworld-5 + grafana/dashboard-selector: uid == 'otel-app-observability-v2' + grafana/grafana-instance: default + humanitec.dev/appId: reno-realworld-5 + humanitec.dev/orgId: crucible + humanitec.dev/projectId: reno-realworld-5 sonarqube.org/project-key: reno-realworld-5 - grafana/grafana-instance: "default" - grafana/alert-label-selector: "app=reno-realworld-5" - grafana/dashboard-selector: "uid == 'otel-app-observability-v2'" - grafana.com/dashboard-url: "https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=reno-realworld-5" - tags: - - platform-orchestrator - - renovation - - postgresql + description: "reno-realworld-5 \u2014 renovated onto the crucible platform orchestrator" links: - # console.humanitec.dev is the dead SaaS. This is the live crucible console, deep-linked - # to the per-application environment the deploy workflow creates. - - url: https://console.dev.orchestrator.crucible.kyndemo.live/orgs/crucible/projects/reno-realworld-5/environments/dev - title: Orchestrator Console - icon: dashboard - - url: https://reno-realworld-5.apps.dev.crucible.kyndemo.live - title: Live Application - icon: web - - url: https://gitea.kyndemo.live/validate/reno-realworld-5 - title: Source Repository - icon: github - - url: https://gitea.kyndemo.live/validate/reno-realworld-5/actions - title: CI/CD Pipelines - icon: code - - url: https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=reno-realworld-5 - title: Grafana Dashboard - icon: dashboard + - icon: dashboard + title: Orchestrator Console + url: https://console.dev.orchestrator.crucible.kyndemo.live/orgs/crucible/projects/reno-realworld-5/environments/dev + - icon: web + title: Live Application + url: https://reno-realworld-5.apps.dev.crucible.kyndemo.live + - icon: github + title: Source Repository + url: https://gitea.kyndemo.live/validate/reno-realworld-5 + - icon: code + title: CI/CD Pipelines + url: https://gitea.kyndemo.live/validate/reno-realworld-5/actions + - icon: dashboard + title: Grafana Dashboard + url: https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=reno-realworld-5 + name: reno-realworld-5 + tags: + - platform-orchestrator + - renovation + - postgresql spec: - type: service + dependsOn: + - resource:default/cjot-aks + - resource:default/ho-v2-postgresql lifecycle: experimental owner: platform-engineering - dependsOn: - - resource:default/cjot-aks - - resource:default/ho-v2-postgresql + type: service diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..71db9bf --- /dev/null +++ b/docs/api.md @@ -0,0 +1,25 @@ +# API Reference + +## Endpoints + +### Health Check + +``` +GET /health +``` + +**Response:** +```json +{"status": "UP", "service": "reno-realworld-5"} +``` + +### Root + +``` +GET / +``` + +**Response:** +```json +{"service": "reno-realworld-5", "description": "Modernized reno-realworld-5 service", "version": "1.0.0"} +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..213cd90 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,15 @@ +# Architecture + +## Service Design + +reno-realworld-5 is a microservice following cloud-native patterns. + +## Technology Stack + +- **Runtime**: Node.js Express +- **Deployment**: Humanitec Platform Orchestrator +- **CI/CD**: Gitea Actions → ACR → Humanitec + +## Dependencies + +See `score.yaml` for external resource dependencies. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6e18ec4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,32 @@ +# reno-realworld-5 + +Modernized reno-realworld-5 service + +## Overview + +This service is built with **Node.js Express** and follows the Golden Path architecture patterns. + +### Key Features + +- 🚀 Production-ready configuration +- 📊 Prometheus metrics exposed +- 🏥 Health check endpoints +- 🔒 Security scanning in CI/CD +- 📦 Containerized deployment + +## Quick Start + +```bash +git clone https://gitea.kyndemo.live/kyndryl-demos/reno-realworld-5.git +cd reno-realworld-5 +``` + +## Monitoring + +- **Metrics**: Prometheus metrics at `/metrics` +- **Health**: `/health` +- **Grafana**: [View Dashboard](https://grafana.kyndemo.live/d/app-reno-realworld-5) + +## Support + +Contact the Platform Engineering team. diff --git a/docs/migration-plan.md b/docs/migration-plan.md new file mode 100644 index 0000000..a0a78e2 --- /dev/null +++ b/docs/migration-plan.md @@ -0,0 +1,45 @@ +# Modernization Plan for reno-realworld-5 + +## Application Type +Nodejs Application + +## Selected Modernization Strategy +- **Migration Approach**: containerize-optimize +- **Target Platform**: orchestrator +- **Observability**: ENABLED (Prometheus metrics, health checks, tracing) +- **Security Scanning**: ENABLED (Trivy vulnerability scanning) + +## Discovery Summary +### Discovery Report + +#### Application Overview +- **Type**: Node.js application +- **Language**: TypeScript +- **Directory Structure**: Includes TypeScript configuration files (`tsconfig.json`), Node.js package files (`package.json`, `package-lock.json`), and testing configurations (`jest.config.ts`). + +#### Technology Stack +- **Framework**: Express.js (detected via `express` dependency) +- **Other Libraries**: + - `@ngneat/falso`: Utility for generating fake data + - `@prisma/client`: Database ORM +... + +## Generated Artifacts +1. **Dockerfile**: Optimized with health checks and metrics endpoints +2. **score.yaml**: Platform intent with service ports and DNS resource +3. **CI Workflow**: Automated build/push to ACR with Trivy security scanning + +## Next Steps +1. Review and customize generated artifacts +2. Test container build and run +3. Deploy to development environment using score.yaml +4. Validate application functionality +5. Promote to staging/production via Humanitec + +## Migration Strategy Details + +### Containerize Optimize +Add cloud-native patterns: health checks, metrics, optimized base images. + +### Platform: orchestrator +score.yaml optimized for Azure Container Apps with managed scaling and Azure-specific configuration. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..615ccf6 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,13 @@ +site_name: reno-realworld-5 +site_description: Modernized reno-realworld-5 service + +nav: + - Home: index.md + - Architecture: architecture.md + - API Reference: api.md + +plugins: + - techdocs-core + +theme: + name: material diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..116b6a9 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,350 @@ +openapi: 3.0.3 +info: + title: reno-realworld-5 + description: Modernized reno-realworld-5 service + version: 1.0.0 +servers: +- url: https://reno-realworld-5.kyndemo.live + description: Production +- url: http://localhost:3000 + description: Local development +paths: + /health: + get: + summary: Health check + operationId: getHealth + tags: + - System + responses: + '200': + description: Healthy + /api/articles: + get: + summary: GET /api/articles + operationId: getApi_articles + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + post: + summary: POST /api/articles + operationId: postApi_articles + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + requestBody: + required: true + content: + application/json: + schema: + type: object + /api/articles/feed: + get: + summary: GET /api/articles/feed + operationId: getApi_articles_feed + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + /api/articles/{slug}: + get: + summary: GET /api/articles/{slug} + operationId: getApi_articles_slug + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + put: + summary: PUT /api/articles/{slug} + operationId: putApi_articles_slug + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + delete: + summary: DELETE /api/articles/{slug} + operationId: deleteApi_articles_slug + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + /api/articles/{slug}/comments: + get: + summary: GET /api/articles/{slug}/comments + operationId: getApi_articles_slug_comments + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + post: + summary: POST /api/articles/{slug}/comments + operationId: postApi_articles_slug_comments + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + /api/articles/{slug}/comments/{id}: + delete: + summary: DELETE /api/articles/{slug}/comments/{id} + operationId: deleteApi_articles_slug_comments_id + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + /api/articles/{slug}/favorite: + post: + summary: POST /api/articles/{slug}/favorite + operationId: postApi_articles_slug_favorite + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + delete: + summary: DELETE /api/articles/{slug}/favorite + operationId: deleteApi_articles_slug_favorite + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: slug + in: path + required: true + schema: + type: string + /api/profiles/{username}: + get: + summary: GET /api/profiles/{username} + operationId: getApi_profiles_username + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: username + in: path + required: true + schema: + type: string + /api/profiles/{username}/follow: + post: + summary: POST /api/profiles/{username}/follow + operationId: postApi_profiles_username_follow + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: username + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + delete: + summary: DELETE /api/profiles/{username}/follow + operationId: deleteApi_profiles_username_follow + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + parameters: + - name: username + in: path + required: true + schema: + type: string + /api/tags: + get: + summary: GET /api/tags + operationId: getApi_tags + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + /api/user: + get: + summary: GET /api/user + operationId: getApi_user + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + put: + summary: PUT /api/user + operationId: putApi_user + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + requestBody: + required: true + content: + application/json: + schema: + type: object + /api/users: + post: + summary: POST /api/users + operationId: postApi_users + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + requestBody: + required: true + content: + application/json: + schema: + type: object + /api/users/login: + post: + summary: POST /api/users/login + operationId: postApi_users_login + responses: + '200': + description: Success + '400': + description: Bad request + '401': + description: Unauthorized + requestBody: + required: true + content: + application/json: + schema: + type: object + /metrics: + get: + summary: Prometheus metrics + operationId: getMetrics + tags: + - System + responses: + '200': + description: text/plain; Prometheus exposition format diff --git a/score.yaml b/score.yaml index 73fa955..b5d7ff7 100644 --- a/score.yaml +++ b/score.yaml @@ -1,58 +1,41 @@ apiVersion: score.dev/v1b1 -metadata: - name: reno-realworld-5 - labels: - app: reno-realworld-5 - containers: - main: + reno-realworld-5: image: . variables: - # The Watcher's OTel work lands in overlays/otel/, which only ArgoCD reads. On the - # orchestrator path nothing consumes that overlay, so without these variables the - # renovated app emits no telemetry at all and never appears in Grafana. - OTEL_SERVICE_NAME: "reno-realworld-5" - OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.monitoring.svc.cluster.local:4318" - OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" - OTEL_RESOURCE_ATTRIBUTES: "service.name=reno-realworld-5" - OTEL_METRICS_EXPORTER: "otlp" - OTEL_TRACES_EXPORTER: "otlp" - OTEL_LOGS_EXPORTER: "none" - DATABASE_URL: "postgresql://${resources.db.username}:${resources.db.password}@${resources.db.host}:${resources.db.port}/${resources.db.name}" - -service: - ports: - web: - port: 80 - targetPort: 8080 - + DATABASE_URL: postgresql://${resources.db.username}:${resources.db.password}@${resources.db.hostname}:${resources.db.port}/${resources.db.database} + NODE_ENV: production + PORT: '3000' + OTEL_SERVICE_NAME: reno-realworld-5 + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector.monitoring.svc.cluster.local:4318 + OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf + OTEL_RESOURCE_ATTRIBUTES: service.name=reno-realworld-5,app=reno-realworld-5 + OTEL_METRICS_EXPORTER: otlp + OTEL_TRACES_EXPORTER: otlp + OTEL_LOGS_EXPORTER: none +metadata: + annotations: + prometheus.io/path: /metrics + prometheus.io/port: '3000' + prometheus.io/scrape: 'true' + labels: + app: reno-realworld-5 + backstage.io/kubernetes-id: reno-realworld-5 + name: reno-realworld-5 resources: - env: - type: environment - - # Gives the renovated workload a public HTTPS URL. Without this the deploy still goes - # green and the pod still runs -- there is simply no Ingress, no certificate and no - # hostname, so the demo's payoff (curl the renovated app) has nothing to hit. - # - # hostname is hardcoded to the apps domain deliberately: it is the only domain the apps - # cluster serves, and both the wildcard A record (*.apps.dev) and the cert-manager - # ClusterIssuer are scoped to exactly it. - # - # service_port is the Score `service.ports.web.port` below (80), NOT the container port. - # An Ingress naming a Service or port that does not exist fails at neither plan nor - # apply -- it surfaces only as nginx answering 503. - # - # CAVEAT: modernization-factory's generate_score_yaml rewrites BOTH `port` and - # `targetPort` to the detected application port whenever that port is not 8080. An app - # on 3000 therefore ends up with a Service on 3000 and this Ingress pointing at a port - # that no longer exists. Apps already listening on 8080 (Spring PetClinic among them) - # skip that patch entirely and are unaffected. - ingress: - type: workload-ingress - params: - name: reno-realworld-5 - hostname: reno-realworld-5.apps.dev.crucible.kyndemo.live - service_name: reno-realworld-5 - service_port: 80 db: type: postgres + env: + type: environment + ingress: + params: + hostname: reno-realworld-5.apps.dev.crucible.kyndemo.live + name: reno-realworld-5 + service_name: reno-realworld-5 + service_port: 3000 + type: workload-ingress +service: + ports: + http: + port: 3000 + targetPort: 3000