diff --git a/.github/workflows/weekly-develop-to-master.yml b/.github/workflows/weekly-develop-to-master.yml new file mode 100644 index 0000000..539d038 --- /dev/null +++ b/.github/workflows/weekly-develop-to-master.yml @@ -0,0 +1,140 @@ +name: Weekly Develop to Master + +on: + schedule: + # Match F5's Monday schedule, away from the start-of-hour hot spot. + - cron: '17 12 * * 1' + workflow_dispatch: + +permissions: + actions: read + contents: write + pull-requests: write + +concurrency: + group: weekly-develop-to-master + cancel-in-progress: false + +jobs: + promote: + name: Promote Develop and Verify Publishing + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GH_REPO: ${{ github.repository }} + steps: + - name: Detect Unreleased Changes + id: changes + env: + GH_TOKEN: ${{ github.token }} + run: | + count="$(gh api "repos/$GH_REPO/compare/master...develop" --jq '.files | length')" + echo "has_changes=$([ "$count" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Open and Merge Release PR + if: steps.changes.outputs.has_changes == 'true' + id: release + env: + # As in F5, this token lets PR creation and merging trigger normal checks. + GH_TOKEN: ${{ secrets.PERSONAL_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::PERSONAL_TOKEN is required for release PR workflow events." + exit 1 + fi + # Keep strict branch protection without writing directly to develop. + behind="$(gh api "repos/$GH_REPO/compare/master...develop" --jq .behind_by)" + if [ "$behind" -gt 0 ]; then + sync_branch="feature/weekly-sync-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + develop_sha="$(gh api "repos/$GH_REPO/commits/develop" --jq .sha)" + gh api --method POST "repos/$GH_REPO/git/refs" \ + -f "ref=refs/heads/$sync_branch" -f "sha=$develop_sha" >/dev/null + sync_sha="$(gh api --method POST "repos/$GH_REPO/merges" \ + -f "base=$sync_branch" -f head=master --jq .sha)" + sync_url="$(gh pr create --base develop --head "$sync_branch" \ + --title 'Sync Develop With Production' \ + --body '## Summary + + - Bring the production history into develop before its next release.')" + required='["Test (Node 26)","Lint and audit","Helm lint and render","Build container","Open Source and Container","Analyze JavaScript"]' + ready=false + for attempt in {1..60}; do + runs="$(gh api "repos/$GH_REPO/commits/$sync_sha/check-runs?per_page=100")" + ready="$(jq --argjson required "$required" ' + .check_runs as $runs | all($required[]; . as $name | + any($runs[]; .name == $name and .app.slug == "github-actions")) + ' <<< "$runs")" + if [ "$ready" = true ]; then break; fi + sleep 10 + done + if [ "$ready" != true ]; then + echo "Required sync checks did not start: $sync_url" >&2 + exit 1 + fi + gh pr checks "$sync_url" --required --watch --fail-fast --interval 10 + gh pr merge "$sync_url" --merge --match-head-commit "$sync_sha" + fi + pr_url="$(gh pr list --base master --head develop --state open --json url --jq '.[0].url')" + if [ -z "$pr_url" ]; then + pr_url="$(gh pr create --base master --head develop \ + --title 'Promote Develop to Production' \ + --body '## Summary + + - Promote develop to production after every required check passes.')" + fi + head_sha="$(gh pr view "$pr_url" --json headRefOid --jq .headRefOid)" + # Refresh the vulnerability data even when reusing an older release PR. + check_after="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + for workflow in ci.yaml snyk.yml codeql.yml; do + gh workflow run "$workflow" --ref develop + done + required='["Test (Node 26)","Lint and audit","Helm lint and render","Build container","Open Source and Container","Analyze JavaScript"]' + missing=checks + for attempt in {1..60}; do + runs="$(gh api --paginate --slurp "repos/$GH_REPO/commits/$head_sha/check-runs?per_page=100")" + missing="$(jq -r --argjson required "$required" --arg after "$check_after" ' + [.[].check_runs[] | select(.app.slug == "github-actions")] as $runs + | $required[] as $name + | ([$runs[] | select(.name == $name)] | max_by(.id)) as $latest + | select($latest == null or $latest.status != "completed" + or $latest.conclusion != "success" or $latest.started_at < $after) + | $name + ' <<< "$runs")" + if [ -z "$missing" ]; then break; fi + echo "Waiting for fresh checks on $head_sha: $missing" + sleep 10 + done + if [ -n "$missing" ]; then + echo "Release blocked by missing or unsuccessful fresh checks: $missing" >&2 + exit 1 + fi + gh pr merge "$pr_url" --merge --auto --match-head-commit "$head_sha" + for attempt in {1..30}; do + if [ "$(gh pr view "$pr_url" --json state --jq .state)" = MERGED ]; then + echo "sha=$(gh pr view "$pr_url" --json mergeCommit --jq .mergeCommit.oid)" >> "$GITHUB_OUTPUT" + exit 0 + fi + sleep 10 + done + echo "Release PR has not merged: $pr_url" >&2 + exit 1 + + - name: Verify Gated Publishing + if: steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + run: | + for attempt in {1..60}; do + run_id="$(gh run list --workflow publish.yaml --branch master --event workflow_run \ + --limit 30 --json databaseId,headSha \ + --jq ".[] | select(.headSha == \"$RELEASE_SHA\") | .databaseId" | head -n 1)" + if [ -n "$run_id" ]; then + gh run watch "$run_id" --exit-status --interval 10 + exit 0 + fi + sleep 10 + done + echo "No Publish run found for $RELEASE_SHA." >&2 + exit 1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f6bbe80 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This is a Kubernetes HPA validation app: sustained request traffic should scale its deployment, and the UI must clearly identify the pod handling each request. The Express application lives in `app.js`; `index.js` starts the server and owns graceful shutdown. The auto-refreshing hostname display is in `views/index.ejs`; static assets are in `public/`; tests are in `__tests__/`. Container configuration is in `Dockerfile` and `docker-compose.yaml`; the Helm chart is under `chart/`. + +## Build, Test, and Development Commands + +Use Node 26 and npm 11 (see `package.json`). Install the locked dependency set with `npm ci`. + +- `npm run dev` starts the app with file watching and the Node inspector on port 9229. +- `npm start` runs the production-style server locally. +- `npm run lint` checks all JavaScript with ESLint; `npm run lint:fix` applies safe fixes. +- `npm test` runs the built-in Node test suite; `npm run test:watch` reruns it during development. +- `docker compose up --build` starts the development image with source mounts. +- `helm lint chart` validates the bundled Helm chart. + +## Coding Style & Naming Conventions + +Use CommonJS (`require`/`module.exports`), two-space indentation, semicolons, and single quotes, matching the existing source. Keep route handlers and middleware in `app.js`; expose startup or lifecycle helpers from `index.js`. Use descriptive camelCase JavaScript names and lowercase, hyphenated Kubernetes/Helm resource names. Run ESLint before committing rather than hand-formatting around its rules. + +## Testing Guidelines + +Write tests with `node:test`, `node:assert/strict`, and Supertest where HTTP behavior is involved. Place files in `__tests__/` with a `.test.js` suffix, and name tests as observable outcomes (for example, `Health check should return app status`). Preserve coverage for pod hostname headers/UI output, health checks, and shutdown behavior. Run `npm run lint && npm test` before opening a pull request. + +## Commit & Pull Request Guidelines + +Recent history uses short, imperative subjects such as `Upgrade runtime to Node 26`, `Fix Helm chart non-root runtime`, and `Harden container runtime and CI validation`; keep that style. Keep changes focused, explain the behavioral or deployment impact in the PR description, and include screenshots for UI-visible changes. Do not commit generated dependencies, credentials, or local environment files. + +## Security & Configuration + +Treat `package-lock.json` as authoritative and use `npm ci`; do not bypass engine checks. Preserve the non-root production image and `tini` signal handling. HPA is disabled by default; test scaling with an explicit Helm values override and keep its CPU request/target settings meaningful. diff --git a/public/css/index.css b/public/css/index.css index e2fc40a..49a2a0a 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -1,90 +1,253 @@ -#body { - background-color: #DEF1F9; - font-family: 'Lato', sans-serif; +:root { + --ink: #f1f8fc; + --muted: #9eb2c1; + --line: rgba(221, 240, 250, 0.16); + --blue: #2496ed; + --navy: #071b2a; } -#container { - max-width: 500px; - margin: auto; - padding-top: 20px; +* { + box-sizing: border-box; } -#logo { - display:block; - margin:auto; - padding-bottom: 5px; +html { + background: var(--navy); } -#content { - display:block; - margin:auto; - text-align: center; +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + color: var(--ink); + background: var(--navy); + font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } -table { - border-collapse: inherit; - border-spacing: 0 28px; - text-align: center; +.shell { + width: min(100%, 1120px); + min-height: 100vh; + margin: 0 auto; + padding: 0 48px; } -span { - font-size: 17px; +.topbar, +.service-status, +.request-details { + border-bottom: 1px solid var(--line); } -sub { - vertical-align: sub; - font-size: 12px; - font-weight: 900; - display: inline-block; - padding-top: 5px; +.topbar { + display: flex; + min-height: 76px; + align-items: center; + justify-content: space-between; } -.spinner { - margin: 2px auto; - width: 20px; - height: 20px; - position: relative; +.brand, +.repo-link { + display: inline-flex; + align-items: center; + color: var(--ink); + text-decoration: none; } -.cube1, .cube2 { - background-color: #333; - width: 7px; - height: 7px; - position: absolute; - top: 0; - left: 0; +.brand { + font-size: 14px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.brand-mark { + display: grid; + width: 27px; + height: 27px; + place-items: center; + margin-right: 10px; + overflow: hidden; + border-radius: 7px; + background: #fff; +} + +.brand-mark img { + width: 20px; + height: 20px; +} + +.repo-link { + gap: 8px; + color: var(--muted); + font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 12px; +} + +.repo-link:hover { + color: var(--blue); +} + +.status-dot, +.pulse { + display: inline-block; + border-radius: 50%; + background: var(--blue); + box-shadow: 0 0 0 0 rgba(36, 150, 237, 0.5); + animation: ping 2s infinite; +} + +.status-dot { + width: 7px; + height: 7px; +} + +.service-status { + display: flex; + min-height: 220px; + align-items: center; + justify-content: space-between; + gap: 40px; + padding: 50px 0; +} + +.section-label { + margin: 0; + color: var(--blue); + font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +p { + margin-top: 0; +} + +h1 { + margin: 10px 0; + font-size: clamp(2rem, 4.5vw, 3.6rem); + font-weight: 700; + letter-spacing: -0.065em; + line-height: 1; +} + +.health-state { + display: flex; + min-width: 185px; + align-items: center; + gap: 11px; + padding: 18px 0 18px 18px; + border-left: 1px solid var(--line); +} + +.pulse { + width: 10px; + height: 10px; + background: #32b96a; + box-shadow: 0 0 0 0 rgba(50, 185, 106, 0.5); + animation-name: health-ping; +} + +.health-state strong, +.health-state span { + display: block; +} - -webkit-animation: sk-cubemove 1.8s infinite ease-in-out; - animation: sk-cubemove 1.8s infinite ease-in-out; +.health-state strong { + font-size: 14px; } -.cube2 { - -webkit-animation-delay: -0.9s; - animation-delay: -0.9s; +.health-state span { + margin-top: 4px; + color: var(--muted); + font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 10px; } -@-webkit-keyframes sk-cubemove { - 25% { -webkit-transform: translateX(21px) rotate(-90deg) scale(0.5) } - 50% { -webkit-transform: translateX(21px) translateY(21px) rotate(-180deg) } - 75% { -webkit-transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5) } - 100% { -webkit-transform: rotate(-360deg) } +.request-details { + display: grid; + grid-template-columns: 210px 1fr; + gap: 55px; + padding: 34px 0; } -@keyframes sk-cubemove { - 25% { - transform: translateX(21px) rotate(-90deg) scale(0.5); - -webkit-transform: translateX(21px) rotate(-90deg) scale(0.5); - } 50% { - transform: translateX(21px) translateY(21px) rotate(-179deg); - -webkit-transform: translateX(21px) translateY(21px) rotate(-179deg); - } 50.1% { - transform: translateX(21px) translateY(21px) rotate(-180deg); - -webkit-transform: translateX(21px) translateY(21px) rotate(-180deg); - } 75% { - transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5); - -webkit-transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5); - } 100% { - transform: rotate(-360deg); - -webkit-transform: rotate(-360deg); - } -} \ No newline at end of file +dl { + margin: -10px 0; +} + +.detail-row { + display: grid; + grid-template-columns: 145px 1fr; + gap: 22px; + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.detail-row:last-child { + border-bottom: 0; +} + +dt, +dd, +footer { + font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 12px; +} + +dt { + color: var(--muted); +} + +dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + line-height: 1.45; +} + +footer { + padding: 22px 0 28px; + color: var(--muted); +} + +#refresh-countdown { + display: inline-block; + min-width: 1ch; + text-align: right; +} + +.refresh-indicator { + display: inline-block; + width: 13px; + height: 13px; + margin: 0 8px -3px 0; + border: 1px solid var(--blue); + border-left-color: transparent; + border-radius: 50%; + animation: turn 1.8s linear infinite; +} + +@keyframes turn { + to { transform: rotate(360deg); } +} + +@keyframes ping { + 0%, 100% { box-shadow: 0 0 0 0 rgba(36, 150, 237, 0.45); } + 45% { box-shadow: 0 0 0 7px rgba(36, 150, 237, 0); } +} + +@keyframes health-ping { + 0%, 100% { box-shadow: 0 0 0 0 rgba(50, 185, 106, 0.45); } + 45% { box-shadow: 0 0 0 7px rgba(50, 185, 106, 0); } +} + +@media (max-width: 680px) { + .shell { padding: 0 22px; } + .topbar { min-height: 70px; } + .service-status { min-height: 0; align-items: flex-start; flex-direction: column; gap: 28px; padding: 42px 0; } + .health-state { padding: 0; border-left: 0; } + .request-details { grid-template-columns: 1fr; gap: 30px; padding: 32px 0; } + .detail-row { grid-template-columns: 1fr; gap: 7px; padding: 13px 0; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/views/index.ejs b/views/index.ejs index 4ccca20..0f7a6f4 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -1,77 +1,67 @@ - + - - + + + + + + + + - docker-node-app - -
- - - -
- - - - - - - - - - - - - - - - -
- <% if (hostname) { %> - Hello from <%= hostname %>! - <% } %> -
- <% if (ip) { %> - <%= ip %> -
- Request IP - <% } %> -
- <% if (user_agent) { %> - <%= user_agent %> -
- User Agent - <% } %> -
- <% if (timestamp) { %> - <%= timestamp %> -
- Timestamp - <% } %> -
- This page will automatically reload and connect to new - hosts... -
-
-
-
-
-
-
+ +
+
+ + + docker-node-app + +
+ +
+
+ + <% if (hostname) { %> +

<%= hostname %>

+ <% } %> +
+
+ +
HealthyGET /healthz ยท 200 OK
+
+
+ +
+
+
+ <% if (ip) { %>
Client Address
<%= ip %>
<% } %> + <% if (user_agent) { %>
User Agent
<%= user_agent %>
<% } %> + <% if (timestamp) { %>
Rendered At
<%= timestamp %>
<% } %> +
+
+ + +
+