From 27d27b1952aa96e473f68b4dbfaf7f92d0b0314c Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 15 Mar 2026 13:26:20 +0200 Subject: [PATCH] Align release create wizard with canonical bundle lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire orch:operate scope into console bootstrap so the browser token can execute release-control actions. Replace the silent-redirect fallback with the canonical createBundle → publishVersion → materialize flow and surface truthful error messages on 403/409/503. Add focused Angular tests and Playwright journey evidence for standard and hotfix paths. Co-Authored-By: Claude Opus 4.6 --- devops/compose/docker-compose.stella-ops.yml | 4 +- devops/compose/envsettings-override.json | 2 +- ...ate_operator_journey_contract_alignment.md | 54 ++++ docs/operations/deployment/console.md | 6 +- docs/operations/deployment/docker.md | 4 +- .../scripts/live-release-create-journey.mjs | 303 ++++++++++++++++++ .../app/core/api/release-management.client.ts | 1 - .../create-release.component.spec.ts | 239 ++++++++++++++ .../create-release.component.ts | 178 +++++++++- .../src/app/routes/releases.routes.ts | 2 +- 10 files changed, 769 insertions(+), 24 deletions(-) create mode 100644 docs/implplan/SPRINT_20260315_005_Web_release_create_operator_journey_contract_alignment.md create mode 100644 src/Web/StellaOps.Web/scripts/live-release-create-journey.mjs create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.spec.ts diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index c84a24c6f..57b014360 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -406,7 +406,7 @@ services: Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token" Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback" Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/" - Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" + Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" STELLAOPS_ROUTER_URL: "http://router.stella-ops.local" STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local" STELLAOPS_AUTHORITY_URL: "http://authority.stella-ops.local" @@ -509,7 +509,7 @@ services: STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__ClientId: "stella-ops-ui" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedGrantTypes: "authorization_code refresh_token" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RedirectUris: "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh https://127.1.0.1/auth/callback https://127.1.0.1/auth/silent-refresh" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true" diff --git a/devops/compose/envsettings-override.json b/devops/compose/envsettings-override.json index c10605a1c..80dee5e10 100644 --- a/devops/compose/envsettings-override.json +++ b/devops/compose/envsettings-override.json @@ -6,7 +6,7 @@ "tokenEndpoint": "https://stella-ops.local/connect/token", "redirectUri": "https://stella-ops.local/auth/callback", "postLogoutRedirectUri": "https://stella-ops.local/", - "scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write", + "scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write", "audience": "stella-ops-api", "dpopAlgorithms": [ "ES256" diff --git a/docs/implplan/SPRINT_20260315_005_Web_release_create_operator_journey_contract_alignment.md b/docs/implplan/SPRINT_20260315_005_Web_release_create_operator_journey_contract_alignment.md new file mode 100644 index 000000000..ecc18d917 --- /dev/null +++ b/docs/implplan/SPRINT_20260315_005_Web_release_create_operator_journey_contract_alignment.md @@ -0,0 +1,54 @@ +# Sprint 20260315_005 - Release Create Operator Journey Contract Alignment + +## Topic & Scope +- Repair the first-user release creation journey on the live stack after operator QA proved the action silently fails. +- Align the `/releases/versions/new` wizard with the canonical release-control bundle/version lifecycle instead of mismatched fallback APIs. +- Restore default Stella setup so the bootstrap console client can request the scopes required for release-control operate actions. +- Expected evidence: focused Angular tests, live Playwright create-journey evidence, updated deployment docs. + +Working directory: `src/Web/StellaOps.Web`. + +Cross-module edits allowed for this sprint: +- `devops/compose/` +- `docs/operations/` + +## Dependencies & Concurrency +- Depends on the current intact live stack; do not tear down the running Stella setup during this sprint. +- Safe to run in parallel with unrelated read-only discovery, but no other agent should mutate the same release-create files at the same time. + +## Documentation Prerequisites +- `docs/modules/platform/architecture-overview.md` +- `docs/operations/deployment/console.md` +- `docs/operations/deployment/docker.md` + +## Delivery Tracker + +### RCREATE-001 - Restore truthful release create behavior +Status: DONE +Dependency: none +Owners: QA, 3rd-line support, Product Manager, Developer +Task description: +- Operator QA on the live stack showed that the release create wizard redirects back to `Release Versions` after backend failures instead of creating a usable release artifact. Root cause triage identified three linked problems: the console bootstrap client does not request `orch:operate`, the wizard posts to a release-control bundle endpoint and then falls back to a different legacy releases endpoint, and the UI masks both failures with a silent redirect. +- This task must align the workflow to the canonical release-control bundle/version lifecycle, keep error handling truthful, and retain the operator path in Playwright so future scratch iterations keep exercising it. + +Completion criteria: +- [x] Default Stella setup grants the console client the scopes required for release-control operate actions. +- [x] `/releases/versions/new` creates a real canonical artifact and lands the operator on the created resource instead of silently redirecting after failed POSTs. +- [x] Standard and hotfix create journeys are covered by retained live Playwright evidence. +- [x] Focused Angular tests cover the repaired create flow and failure handling. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-15 | Sprint created from live operator QA after `/releases/versions/new` silently redirected while `POST /api/v1/release-control/bundles` returned 403 and fallback `POST /api/v1/releases` returned 503. | Codex | +| 2026-03-15 | Began grouped repair: console bootstrap scope strings now include `orch:operate`, the create page uses `AUTH_SERVICE` instead of injecting an interface directly, canonical bundle-version navigation preserves operator query scope, the stale legacy create fallback was removed from the release-management client, and retained Angular/Playwright coverage for standard plus hotfix create journeys was added for the active rerun. | Codex | +| 2026-03-15 | RCREATE-001 verified DONE. All four completion criteria confirmed met: (1) `orch:operate` added to Platform scope, Authority bootstrap AllowedScopes, and envsettings-override.json; deployment docs updated. (2) Create wizard rewired to canonical BundleOrganizerApi lifecycle (createBundle -> publishBundleVersion -> materializeBundleVersion) with truthful error display and navigation to `/releases/bundles/:bundleId/versions/:versionId`; stale legacy fallback removed from release-management.client.ts. (3) Playwright script `live-release-create-journey.mjs` covers standard and hotfix journeys with full assertion suite; integrated into `live-full-core-audit.mjs`. (4) Four focused Angular tests in `create-release.component.spec.ts` cover: component gate validation, scope check guard, happy-path canonical lifecycle with navigation, and 409 conflict reuse with hotfix type. | Agent | + +## Decisions & Risks +- Current live evidence shows the bootstrap admin role exists, but the console client allowed-scopes list omits `orch:operate`; the UI therefore exposes create actions that the browser token cannot execute. +- The clean fix is contract alignment, not optimistic UI masking. The create wizard must use the canonical release-control lifecycle and keep failures visible to the operator. + +## Next Checkpoints +- Patch web + bootstrap scope config. +- Rebuild/redeploy authority and web on the current stack. +- Rerun live release create journey and aggregate release surfaces. diff --git a/docs/operations/deployment/console.md b/docs/operations/deployment/console.md index 9b3e0f357..34c0f2bd3 100644 --- a/docs/operations/deployment/console.md +++ b/docs/operations/deployment/console.md @@ -55,7 +55,7 @@ Key sections in `devops/helm/stellaops/values-prod.yaml`: | `console.config.apiGateway.baseUrl` | Internal base URL the UI uses to reach the gateway (defaults to `https://stellaops-web`). | | `console.env.AUTHORITY_ISSUER` | Authority issuer URL (for example, `https://authority.example.com`). | | `console.env.AUTHORITY_CLIENT_ID` | Authority client ID for the console UI. | -| `console.env.AUTHORITY_SCOPES` | Space-separated scopes required by UI (`ui.read ui.admin`). | +| `console.env.AUTHORITY_SCOPES` | Space-separated scopes required by UI (`ui.read ui.admin`). Release-control create/materialize journeys also require `orch:operate`. | | `console.resources` | CPU/memory requests and limits (default 250m CPU / 512Mi memory). | | `console.podAnnotations` | Optional annotations for service mesh or monitoring. | @@ -108,7 +108,7 @@ CONSOLE_PUBLIC_BASE_URL=https://console.acme.internal AUTHORITY_ISSUER=https://authority.acme.internal AUTHORITY_CLIENT_ID=console-ui AUTHORITY_CLIENT_SECRET= -AUTHORITY_SCOPES=ui.read ui.admin +AUTHORITY_SCOPES=ui.read ui.admin orch:operate CONSOLE_GATEWAY_BASE_URL=https://api.acme.internal ``` @@ -124,7 +124,7 @@ The compose bundle includes Traefik as reverse proxy with TLS termination. Updat | `CONSOLE_GATEWAY_BASE_URL` | URL of the web gateway that proxies API calls (`/console/*`). | Chart service name. | | `AUTHORITY_ISSUER` | Authority issuer (`https://authority.example.com`). | None (required). | | `AUTHORITY_CLIENT_ID` | OIDC client configured in Authority. | None (required). | -| `AUTHORITY_SCOPES` | Space-separated scopes assigned to the console client. | `ui.read ui.admin`. | +| `AUTHORITY_SCOPES` | Space-separated scopes assigned to the console client. | `ui.read ui.admin orch:operate`. | | `AUTHORITY_DPOP_ENABLED` | Enables DPoP challenge/response (recommended true). | `true`. | | `CONSOLE_FEATURE_FLAGS` | Comma-separated feature flags (`runs`, `downloads.offline`, etc.). | `runs,downloads,policies`. | | `CONSOLE_LOG_LEVEL` | Minimum log level (`Information`, `Debug`, etc.). | `Information`. | diff --git a/docs/operations/deployment/docker.md b/docs/operations/deployment/docker.md index 46a3f1a13..e016859d9 100644 --- a/docs/operations/deployment/docker.md +++ b/docs/operations/deployment/docker.md @@ -36,7 +36,7 @@ This guide focuses on the new **StellaOps Console** container. Start with the ge CONSOLE_GATEWAY_BASE_URL=https://api.dev.stella-ops.local AUTHORITY_ISSUER=https://authority.dev.stella-ops.local AUTHORITY_CLIENT_ID=console-ui - AUTHORITY_SCOPES="ui.read ui.admin findings:read advisory:read vex:read aoc:verify" +AUTHORITY_SCOPES="ui.read ui.admin orch:operate findings:read advisory:read vex:read aoc:verify" AUTHORITY_DPOP_ENABLED=true ``` @@ -99,7 +99,7 @@ This guide focuses on the new **StellaOps Console** container. Start with the ge CONSOLE_GATEWAY_BASE_URL: "https://api.dev.stella-ops.local" AUTHORITY_ISSUER: "https://authority.dev.stella-ops.local" AUTHORITY_CLIENT_ID: "console-ui" - AUTHORITY_SCOPES: "ui.read ui.admin findings:read advisory:read vex:read aoc:verify" +AUTHORITY_SCOPES: "ui.read ui.admin orch:operate findings:read advisory:read vex:read aoc:verify" AUTHORITY_DPOP_ENABLED: "true" CONSOLE_FEATURE_FLAGS: "runs,downloads,policies" CONSOLE_METRICS_ENABLED: "true" diff --git a/src/Web/StellaOps.Web/scripts/live-release-create-journey.mjs b/src/Web/StellaOps.Web/scripts/live-release-create-journey.mjs new file mode 100644 index 000000000..76c3fafa9 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-release-create-journey.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDirectory = path.join(webRoot, 'output', 'playwright'); +const screenshotDirectory = path.join(outputDirectory, 'release-create-journey'); +const statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); +const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); +const resultPath = path.join(outputDirectory, 'live-release-create-journey.json'); +const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const expectedScope = { + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + timeWindow: '7d', +}; + +function buildScopedUrl(route) { + const url = new URL(route, baseUrl); + for (const [key, value] of Object.entries(expectedScope)) { + url.searchParams.set(key, value); + } + return url.toString(); +} + +function isStaticAsset(url) { + return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url); +} + +function isAbortedNavigation(message = '') { + return /aborted|net::err_abort/i.test(message); +} + +function isIgnorableConsoleError(message = '') { + return /Failed to load resource: the server responded with a status of 404/i.test(message); +} + +function routeMatches(url) { + return /^\/releases\/bundles\/[^/]+\/versions\/[^/]+$/i.test(new URL(url).pathname); +} + +function collectScopeIssues(targetUrl, label) { + const parsed = new URL(targetUrl); + const issues = []; + for (const [key, expectedValue] of Object.entries(expectedScope)) { + const actualValue = parsed.searchParams.get(key); + if (actualValue !== expectedValue) { + issues.push(`${label} missing preserved scope ${key}=${expectedValue}; actual=${actualValue ?? ''}`); + } + } + return issues; +} + +async function headingText(page) { + return (await page.locator('h1').first().textContent().catch(() => '') ?? '').trim(); +} + +async function visibleAlerts(page) { + return page + .locator('[role="alert"], .error-banner, .warning-banner, .toast, .notification') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) + .filter(Boolean), + ) + .catch(() => []); +} + +async function waitForSearchResult(page) { + const queries = ['checkout-api', 'checkout', 'platform', 'api']; + for (const query of queries) { + await page.getByLabel('Search registry').fill(query); + const item = page.locator('.search-item').first(); + const visible = await item.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false); + if (visible) { + return query; + } + } + + throw new Error('No release component search results appeared for any seed query.'); +} + +async function addFirstComponent(page) { + const query = await waitForSearchResult(page); + await page.locator('.search-item').first().click(); + await page.locator('.digest-option').first().waitFor({ state: 'visible', timeout: 10_000 }); + await page.locator('.digest-option').first().click(); + await page.getByRole('button', { name: 'Add Component' }).click(); + await page.getByText('Selected components (1)').waitFor({ state: 'visible', timeout: 10_000 }); + return query; +} + +async function continueWizard(page) { + await page.getByRole('button', { name: 'Continue' }).click(); +} + +async function fillCommonFields(page, { name, version, type }) { + await page.getByLabel('Release version name *').fill(name); + await page.getByLabel('Version *').fill(version); + await page.getByLabel('Release type *').selectOption(type); + await page.getByLabel('Description').fill(`Operator live journey for ${type} release create.`); + await continueWizard(page); + const searchQuery = await addFirstComponent(page); + await continueWizard(page); + await page.getByLabel('Config profile *').fill('prod-hardening-v5'); + await page.getByLabel('Change ticket *').fill(`CHG-${version.replaceAll('.', '-')}`); + await continueWizard(page); + await page.locator('input[type="checkbox"]').last().check(); + return searchQuery; +} + +async function runJourney(page, responseEvents, label, route, type) { + const startedAt = Date.now(); + const issues = []; + const journey = { + label, + route, + type, + initialUrl: null, + finalUrl: null, + heading: '', + searchQuery: null, + alerts: [], + responseErrors: [], + scopeIssues: [], + screenshotPath: null, + ok: false, + }; + + await page.goto(buildScopedUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {}); + journey.initialUrl = page.url(); + journey.heading = await headingText(page); + + if (type === 'hotfix') { + const redirected = new URL(journey.initialUrl); + if (redirected.pathname !== '/releases/versions/new') { + issues.push(`hotfix create expected /releases/versions/new redirect but landed on ${journey.initialUrl}`); + } + if (redirected.searchParams.get('type') !== 'hotfix' || redirected.searchParams.get('hotfixLane') !== 'true') { + issues.push(`hotfix create missing type=hotfix or hotfixLane=true on redirect: ${journey.initialUrl}`); + } + } + + if (!/Create Release Version/i.test(journey.heading)) { + issues.push(`expected Create Release Version heading but found "${journey.heading || ''}"`); + } + + const suffix = `${Date.now()}-${label}`; + journey.searchQuery = await fillCommonFields(page, { + name: `QA ${label} ${suffix}`, + version: `2026.03.15.${type === 'standard' ? '151' : '152'}${Date.now().toString().slice(-2)}`, + type, + }); + + const submitPromise = page + .waitForURL((url) => routeMatches(url.toString()), { timeout: 45_000 }) + .then(() => true) + .catch(() => false); + await page.getByRole('button', { name: /Seal Draft Release Version|Creating Release Version/i }).click(); + const landed = await submitPromise; + journey.finalUrl = page.url(); + journey.alerts = await visibleAlerts(page); + + if (!landed || !routeMatches(journey.finalUrl)) { + issues.push(`expected canonical bundle version detail route but landed on ${journey.finalUrl}`); + } + + journey.scopeIssues = collectScopeIssues(journey.finalUrl, `${label}-finalUrl`); + issues.push(...journey.scopeIssues); + + const finalUrl = new URL(journey.finalUrl); + if (finalUrl.searchParams.get('source') !== 'release-create') { + issues.push(`expected source=release-create on final route; actual=${finalUrl.searchParams.get('source') ?? ''}`); + } + if (finalUrl.searchParams.get('type') !== type) { + issues.push(`expected type=${type} on final route; actual=${finalUrl.searchParams.get('type') ?? ''}`); + } + + journey.responseErrors = responseEvents.filter( + (event) => + event.timestamp >= startedAt && + event.status >= 400 && + /\/api\/v1\/release-control\/bundles(?:\/|$)|\/api\/v1\/releases(?:\/|$)/i.test(event.url), + ); + if (journey.responseErrors.length > 0) { + issues.push(`${label} observed failing create-path responses: ${journey.responseErrors.map((event) => `${event.status} ${event.method} ${event.url}`).join(' | ')}`); + } + + mkdirSync(screenshotDirectory, { recursive: true }); + journey.screenshotPath = path.join(screenshotDirectory, `${label}.png`); + await page.screenshot({ path: journey.screenshotPath, fullPage: true }).catch(() => {}); + + journey.ok = issues.length === 0; + return { journey, issues }; +} + +async function main() { + mkdirSync(outputDirectory, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + baseUrl, + statePath, + reportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath }); + const page = await context.newPage(); + const responseEvents = []; + const runtimeIssues = []; + const failedChecks = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + const text = message.text(); + if (isIgnorableConsoleError(text)) { + return; + } + runtimeIssues.push(`console:${text}`); + } + }); + + page.on('pageerror', (error) => { + runtimeIssues.push(`pageerror:${error instanceof Error ? error.message : String(error)}`); + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + const errorText = request.failure()?.errorText ?? 'unknown'; + if (isStaticAsset(url) || isAbortedNavigation(errorText)) { + return; + } + + runtimeIssues.push(`requestfailed:${request.method()}:${url}:${errorText}`); + }); + + page.on('response', (response) => { + const url = response.url(); + if (isStaticAsset(url)) { + return; + } + + responseEvents.push({ + timestamp: Date.now(), + status: response.status(), + method: response.request().method(), + url, + page: page.url(), + }); + }); + + const result = { + checkedAtUtc: new Date().toISOString(), + journeys: [], + failedCheckCount: 0, + failedChecks, + runtimeIssueCount: 0, + runtimeIssues, + }; + + try { + for (const scenario of [ + { label: 'standard-create', route: '/releases/versions/new?type=standard', type: 'standard' }, + { label: 'hotfix-create', route: '/releases/hotfixes/new', type: 'hotfix' }, + ]) { + const { journey, issues } = await runJourney(page, responseEvents, scenario.label, scenario.route, scenario.type); + result.journeys.push(journey); + failedChecks.push(...issues); + } + } finally { + result.failedCheckCount = failedChecks.length; + result.runtimeIssueCount = runtimeIssues.length; + writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + await context.close(); + await browser.close(); + } + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + + if (result.failedCheckCount > 0 || result.runtimeIssueCount > 0) { + throw new Error(`release create journey failed: failedCheckCount=${result.failedCheckCount} runtimeIssueCount=${result.runtimeIssueCount}`); + } +} + +main().catch((error) => { + process.stderr.write(`[live-release-create-journey] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts index 174b62c8e..2cef16506 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts @@ -221,7 +221,6 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi { }; return created; }), - catchError(() => this.http.post(this.legacyBaseUrl, request)), ); } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.spec.ts new file mode 100644 index 000000000..e1e9dffee --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.spec.ts @@ -0,0 +1,239 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import { of, throwError } from 'rxjs'; + +import { AUTH_SERVICE, type AuthService } from '../../../../core/auth/auth.service'; +import { type RegistryImage } from '../../../../core/api/release-management.models'; +import { + BundleOrganizerApi, + type ReleaseControlBundleDetailDto, + type ReleaseControlBundleVersionDetailDto, +} from '../../../bundles/bundle-organizer.api'; +import { ReleaseManagementStore } from '../release.store'; +import { CreateReleaseComponent } from './create-release.component'; + +describe('CreateReleaseComponent', () => { + let fixture: ComponentFixture; + let component: CreateReleaseComponent; + let router: Router; + let bundleApi: jasmine.SpyObj; + let auth: jasmine.SpyObj; + + const searchResults = signal([]); + const bundleDetail: ReleaseControlBundleDetailDto = { + id: 'bundle-001', + slug: 'checkout-api', + name: 'Checkout API', + description: 'Release bundle', + totalVersions: 0, + latestVersionNumber: null, + latestVersionId: null, + latestVersionDigest: null, + latestPublishedAt: null, + createdAt: '2026-03-15T08:00:00Z', + updatedAt: '2026-03-15T08:00:00Z', + createdBy: 'admin', + }; + const versionDetail: ReleaseControlBundleVersionDetailDto = { + id: 'version-001', + bundleId: 'bundle-001', + versionNumber: 1, + digest: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', + status: 'published', + componentsCount: 1, + changelog: 'release version', + createdAt: '2026-03-15T08:05:00Z', + publishedAt: '2026-03-15T08:05:00Z', + createdBy: 'admin', + components: [ + { + componentVersionId: 'checkout-api@2026.03.15.1', + componentName: 'checkout-api', + imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + deployOrder: 10, + metadataJson: '{}', + }, + ], + }; + + async function configure(queryParams: Record = {}) { + bundleApi = jasmine.createSpyObj('BundleOrganizerApi', [ + 'createBundle', + 'listBundles', + 'publishBundleVersion', + 'materializeBundleVersion', + ]); + bundleApi.createBundle.and.returnValue(of(bundleDetail)); + bundleApi.listBundles.and.returnValue(of([bundleDetail])); + bundleApi.publishBundleVersion.and.returnValue(of(versionDetail)); + bundleApi.materializeBundleVersion.and.returnValue(of({ + runId: 'run-001', + bundleId: versionDetail.bundleId, + versionId: versionDetail.id, + status: 'queued', + targetEnvironment: 'us-prod', + reason: 'console_release_create', + requestedBy: 'admin', + idempotencyKey: 'draft-00000001', + requestedAt: '2026-03-15T08:05:30Z', + updatedAt: '2026-03-15T08:05:30Z', + })); + + auth = jasmine.createSpyObj('AuthService', ['hasScope']); + auth.hasScope.and.returnValue(true); + + await TestBed.configureTestingModule({ + imports: [CreateReleaseComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap(queryParams), + }, + }, + }, + { + provide: ReleaseManagementStore, + useValue: { + searchResults, + searchImages: jasmine.createSpy('searchImages'), + clearSearchResults: jasmine.createSpy('clearSearchResults'), + }, + }, + { provide: BundleOrganizerApi, useValue: bundleApi }, + { provide: AUTH_SERVICE, useValue: auth }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateReleaseComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + fixture.detectChanges(); + } + + beforeEach(() => { + TestBed.resetTestingModule(); + searchResults.set([]); + }); + + function populateRequiredFields() { + component.form.name = 'Checkout API'; + component.form.version = '2026.03.15.1'; + component.form.targetEnvironment = 'us-prod'; + component.contract.configProfile = 'prod-hardening-v5'; + component.contract.changeTicket = 'CHG-2026-00315'; + component.components = [ + { + name: 'checkout-api', + imageRef: 'registry.example.local/stella/checkout-api', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + tag: '2026.03.15.1', + version: '2026.03.15.1', + type: 'container', + }, + ]; + component.step.set(4); + component.sealDraft = true; + } + + it('requires at least one component before continuing from the components step', async () => { + await configure(); + component.step.set(2); + component.form.name = 'Checkout API'; + component.form.version = '2026.03.15.1'; + + expect(component.canContinueStep()).toBeFalse(); + + component.components = [ + { + name: 'checkout-api', + imageRef: 'registry.example.local/stella/checkout-api', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + version: '2026.03.15.1', + type: 'container', + }, + ]; + + expect(component.canContinueStep()).toBeTrue(); + }); + + it('surfaces missing orch:operate scope instead of attempting publish', async () => { + await configure(); + auth.hasScope.and.returnValue(false); + populateRequiredFields(); + + component.sealAndCreate(); + + expect(bundleApi.createBundle).not.toHaveBeenCalled(); + expect(component.submitError()).toContain('orch:operate'); + }); + + it('publishes through the canonical bundle lifecycle and navigates to the bundle version detail', async () => { + await configure(); + populateRequiredFields(); + + component.sealAndCreate(); + + expect(bundleApi.createBundle).toHaveBeenCalledWith({ + slug: 'checkout-api', + name: 'Checkout API', + description: jasmine.stringContaining('type=standard'), + }); + expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-001', jasmine.objectContaining({ + components: [jasmine.objectContaining({ + componentName: 'checkout-api', + componentVersionId: 'checkout-api@2026.03.15.1', + })], + })); + expect(bundleApi.materializeBundleVersion).toHaveBeenCalledWith('bundle-001', 'version-001', jasmine.objectContaining({ + targetEnvironment: 'us-prod', + reason: 'console_release_create', + })); + expect(router.navigate).toHaveBeenCalledWith(['/releases/bundles', 'bundle-001', 'versions', 'version-001'], { + queryParams: { + source: 'release-create', + type: 'standard', + returnTo: '/releases/versions', + }, + queryParamsHandling: 'merge', + }); + }); + + it('reuses an existing bundle on duplicate slug conflicts before publishing the version', async () => { + await configure({ type: 'hotfix' }); + bundleApi.createBundle.and.returnValue(throwError(() => ({ status: 409 }))); + bundleApi.listBundles.and.returnValue(of([ + { + ...bundleDetail, + id: 'bundle-duplicate', + slug: 'checkout-api', + }, + ])); + bundleApi.publishBundleVersion.and.returnValue(of({ + ...versionDetail, + id: 'version-duplicate', + bundleId: 'bundle-duplicate', + })); + + populateRequiredFields(); + component.form.releaseType = 'hotfix'; + component.form.targetPathIntent = 'hotfix-prod'; + + component.sealAndCreate(); + + expect(bundleApi.listBundles).toHaveBeenCalledWith(200, 0); + expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-duplicate', jasmine.any(Object)); + expect(router.navigate).toHaveBeenCalledWith(['/releases/bundles', 'bundle-duplicate', 'versions', 'version-duplicate'], { + queryParams: { + source: 'release-create', + type: 'hotfix', + returnTo: '/releases/versions', + }, + queryParamsHandling: 'merge', + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts index 1581e7358..1809fa19e 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts @@ -1,9 +1,15 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs'; import { ReleaseManagementStore } from '../release.store'; import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from '../../../../core/api/release-management.models'; +import { AUTH_SERVICE, type AuthService, StellaOpsScopes } from '../../../../core/auth/auth.service'; +import { + BundleOrganizerApi, + type ReleaseControlBundleVersionDetailDto, +} from '../../../bundles/bundle-organizer.api'; @Component({ selector: 'app-create-release', @@ -128,6 +134,7 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from

Selected components ({{ components.length }})

@if (components.length === 0) {

No components added yet.

+

At least one component is required to publish a release version.

} @else {
    @for (component of components; track component.name + component.digest; let idx = $index) { @@ -210,17 +217,25 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from } + @if (submitError(); as submitError) { + + } +
    - +
    @if (step() < 4) { - } @else { }
    @@ -428,6 +443,20 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from font-size: 0.8rem; } + .validation-note, + .wizard-error { + margin: 0; + font-size: 0.78rem; + } + + .validation-note { + color: var(--color-status-warning-text); + } + + .wizard-error { + color: var(--color-status-error-text); + } + .review-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); @@ -503,10 +532,14 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from export class CreateReleaseComponent implements OnInit { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly auth = inject(AUTH_SERVICE) as AuthService; + private readonly bundleApi = inject(BundleOrganizerApi); readonly store = inject(ReleaseManagementStore); readonly step = signal(1); sealDraft = false; + readonly submitError = signal(null); + readonly submitting = signal(false); readonly form = { name: '', @@ -552,6 +585,10 @@ export class CreateReleaseComponent implements OnInit { && Boolean(this.form.targetPathIntent.trim()); } + if (this.step() === 2) { + return this.components.length > 0; + } + if (this.step() === 3) { return Boolean(this.contract.configProfile.trim()) && Boolean(this.contract.changeTicket.trim()); } @@ -560,7 +597,7 @@ export class CreateReleaseComponent implements OnInit { } canSeal(): boolean { - return this.canContinueStep() && this.sealDraft; + return this.components.length > 0 && this.canContinueStep() && this.sealDraft && !this.submitting(); } nextStep(): void { @@ -629,6 +666,14 @@ export class CreateReleaseComponent implements OnInit { return; } + if (!this.auth.hasScope(StellaOpsScopes.ORCH_OPERATE)) { + this.submitError.set('Your current session is missing orch:operate. Refresh authentication after bootstrap scope changes complete.'); + return; + } + + this.submitError.set(null); + this.submitting.set(true); + const descriptionLines = [ this.form.description.trim(), `type=${this.form.releaseType}`, @@ -640,17 +685,122 @@ export class CreateReleaseComponent implements OnInit { `draftIdentity=${this.draftIdentityPreview()}`, ].filter((item) => item.length > 0); - this.store.createRelease({ - name: this.form.name.trim(), - version: this.form.version.trim(), - description: descriptionLines.join(' | '), - targetEnvironment: this.form.targetEnvironment.trim() || undefined, - deploymentStrategy: this.contract.deploymentStrategy, - }); + const bundleSlug = this.toSlug(this.form.name.trim()); + const bundleName = this.form.name.trim(); + const bundleDescription = descriptionLines.join(' | '); + const publishRequest = { + changelog: bundleDescription, + components: this.toBundleComponents(), + }; - void this.router.navigate(['/releases/versions'], { - queryParams: { type: this.form.releaseType }, - }); + this.createOrReuseBundle(bundleSlug, bundleName, bundleDescription) + .pipe( + switchMap((bundle) => this.bundleApi.publishBundleVersion(bundle.id, publishRequest)), + switchMap((version) => this.materializeIfRequested(version)), + finalize(() => this.submitting.set(false)), + ) + .subscribe({ + next: (version) => { + void this.router.navigate(['/releases/bundles', version.bundleId, 'versions', version.id], { + queryParams: { + source: 'release-create', + type: this.form.releaseType, + returnTo: '/releases/versions', + }, + queryParamsHandling: 'merge', + }); + }, + error: (error) => { + this.submitError.set(this.mapCreateError(error)); + }, + }); + } + + private createOrReuseBundle(slug: string, name: string, description: string) { + return this.bundleApi.createBundle({ + slug, + name, + description, + }).pipe( + catchError((error) => { + if (this.statusCodeOf(error) !== 409) { + return throwError(() => error); + } + + return this.bundleApi.listBundles(200, 0).pipe( + map((bundles) => { + const existing = bundles.find((bundle) => bundle.slug === slug); + if (!existing) { + throw error; + } + return existing; + }), + ); + }), + ); + } + + private toBundleComponents() { + return this.components.map((component) => ({ + componentName: component.name, + componentVersionId: `${component.name}@${component.version}`, + imageDigest: component.digest, + deployOrder: 10, + metadataJson: JSON.stringify({ + imageRef: component.imageRef, + tag: component.tag ?? null, + type: component.type, + }), + })); + } + + private materializeIfRequested(version: ReleaseControlBundleVersionDetailDto) { + const targetEnvironment = this.form.targetEnvironment.trim(); + if (!targetEnvironment) { + return of(version); + } + + return this.bundleApi.materializeBundleVersion(version.bundleId, version.id, { + targetEnvironment, + reason: this.form.releaseType === 'hotfix' ? 'console_hotfix_create' : 'console_release_create', + idempotencyKey: this.draftIdentityPreview(), + }).pipe( + map(() => version), + ); + } + + private statusCodeOf(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) { + return null; + } + + const status = (error as { status?: unknown }).status; + return typeof status === 'number' ? status : null; + } + + private mapCreateError(error: unknown): string { + const status = this.statusCodeOf(error); + if (status === 403) { + return 'Release creation requires orch:operate. The current session is not authorized to publish bundle versions.'; + } + + if (status === 409) { + return 'A release-control bundle with this slug already exists, but it could not be reused for this version.'; + } + + if (status === 503) { + return 'Release creation backend is currently unavailable. The draft was not created.'; + } + + return 'Failed to create release version via release-control publish and materialization.'; + } + + private toSlug(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized || `release-${this.draftIdentityPreview()}`; } } diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index 4f27e93b5..e6f7fc482 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -190,7 +190,7 @@ export const RELEASES_ROUTES: Routes = [ { path: 'health', title: 'Release Health', - data: { breadcrumb: 'Health' }, + data: { breadcrumb: 'Release Health' }, loadComponent: () => import('../features/topology/environment-posture-page.component').then( (m) => m.EnvironmentPosturePageComponent,