Align release create wizard with canonical bundle lifecycle
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 <noreply@anthropic.com>
This commit is contained in:
@@ -406,7 +406,7 @@ services:
|
|||||||
Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token"
|
Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token"
|
||||||
Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback"
|
Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback"
|
||||||
Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/"
|
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_ROUTER_URL: "http://router.stella-ops.local"
|
||||||
STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local"
|
STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local"
|
||||||
STELLAOPS_AUTHORITY_URL: "http://authority.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__ClientId: "stella-ops-ui"
|
||||||
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console"
|
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__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__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__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/"
|
||||||
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true"
|
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"tokenEndpoint": "https://stella-ops.local/connect/token",
|
"tokenEndpoint": "https://stella-ops.local/connect/token",
|
||||||
"redirectUri": "https://stella-ops.local/auth/callback",
|
"redirectUri": "https://stella-ops.local/auth/callback",
|
||||||
"postLogoutRedirectUri": "https://stella-ops.local/",
|
"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",
|
"audience": "stella-ops-api",
|
||||||
"dpopAlgorithms": [
|
"dpopAlgorithms": [
|
||||||
"ES256"
|
"ES256"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.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_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_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.resources` | CPU/memory requests and limits (default 250m CPU / 512Mi memory). |
|
||||||
| `console.podAnnotations` | Optional annotations for service mesh or monitoring. |
|
| `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_ISSUER=https://authority.acme.internal
|
||||||
AUTHORITY_CLIENT_ID=console-ui
|
AUTHORITY_CLIENT_ID=console-ui
|
||||||
AUTHORITY_CLIENT_SECRET=<if using confidential client>
|
AUTHORITY_CLIENT_SECRET=<if using confidential client>
|
||||||
AUTHORITY_SCOPES=ui.read ui.admin
|
AUTHORITY_SCOPES=ui.read ui.admin orch:operate
|
||||||
CONSOLE_GATEWAY_BASE_URL=https://api.acme.internal
|
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. |
|
| `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_ISSUER` | Authority issuer (`https://authority.example.com`). | None (required). |
|
||||||
| `AUTHORITY_CLIENT_ID` | OIDC client configured in Authority. | 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`. |
|
| `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_FEATURE_FLAGS` | Comma-separated feature flags (`runs`, `downloads.offline`, etc.). | `runs,downloads,policies`. |
|
||||||
| `CONSOLE_LOG_LEVEL` | Minimum log level (`Information`, `Debug`, etc.). | `Information`. |
|
| `CONSOLE_LOG_LEVEL` | Minimum log level (`Information`, `Debug`, etc.). | `Information`. |
|
||||||
|
|||||||
@@ -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
|
CONSOLE_GATEWAY_BASE_URL=https://api.dev.stella-ops.local
|
||||||
AUTHORITY_ISSUER=https://authority.dev.stella-ops.local
|
AUTHORITY_ISSUER=https://authority.dev.stella-ops.local
|
||||||
AUTHORITY_CLIENT_ID=console-ui
|
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
|
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"
|
CONSOLE_GATEWAY_BASE_URL: "https://api.dev.stella-ops.local"
|
||||||
AUTHORITY_ISSUER: "https://authority.dev.stella-ops.local"
|
AUTHORITY_ISSUER: "https://authority.dev.stella-ops.local"
|
||||||
AUTHORITY_CLIENT_ID: "console-ui"
|
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"
|
AUTHORITY_DPOP_ENABLED: "true"
|
||||||
CONSOLE_FEATURE_FLAGS: "runs,downloads,policies"
|
CONSOLE_FEATURE_FLAGS: "runs,downloads,policies"
|
||||||
CONSOLE_METRICS_ENABLED: "true"
|
CONSOLE_METRICS_ENABLED: "true"
|
||||||
|
|||||||
303
src/Web/StellaOps.Web/scripts/live-release-create-journey.mjs
Normal file
303
src/Web/StellaOps.Web/scripts/live-release-create-journey.mjs
Normal file
@@ -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 ?? '<null>'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 || '<empty>'}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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') ?? '<null>'}`);
|
||||||
|
}
|
||||||
|
if (finalUrl.searchParams.get('type') !== type) {
|
||||||
|
issues.push(`expected type=${type} on final route; actual=${finalUrl.searchParams.get('type') ?? '<null>'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -221,7 +221,6 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
|
|||||||
};
|
};
|
||||||
return created;
|
return created;
|
||||||
}),
|
}),
|
||||||
catchError(() => this.http.post<ManagedRelease>(this.legacyBaseUrl, request)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<CreateReleaseComponent>;
|
||||||
|
let component: CreateReleaseComponent;
|
||||||
|
let router: Router;
|
||||||
|
let bundleApi: jasmine.SpyObj<BundleOrganizerApi>;
|
||||||
|
let auth: jasmine.SpyObj<AuthService>;
|
||||||
|
|
||||||
|
const searchResults = signal<RegistryImage[]>([]);
|
||||||
|
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<string, string> = {}) {
|
||||||
|
bundleApi = jasmine.createSpyObj<BundleOrganizerApi>('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>('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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs';
|
||||||
|
|
||||||
import { ReleaseManagementStore } from '../release.store';
|
import { ReleaseManagementStore } from '../release.store';
|
||||||
import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from '../../../../core/api/release-management.models';
|
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({
|
@Component({
|
||||||
selector: 'app-create-release',
|
selector: 'app-create-release',
|
||||||
@@ -128,6 +134,7 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
|
|||||||
<h3>Selected components ({{ components.length }})</h3>
|
<h3>Selected components ({{ components.length }})</h3>
|
||||||
@if (components.length === 0) {
|
@if (components.length === 0) {
|
||||||
<p class="empty">No components added yet.</p>
|
<p class="empty">No components added yet.</p>
|
||||||
|
<p class="validation-note">At least one component is required to publish a release version.</p>
|
||||||
} @else {
|
} @else {
|
||||||
<ul class="component-list">
|
<ul class="component-list">
|
||||||
@for (component of components; track component.name + component.digest; let idx = $index) {
|
@for (component of components; track component.name + component.digest; let idx = $index) {
|
||||||
@@ -210,17 +217,25 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (submitError(); as submitError) {
|
||||||
|
<p class="wizard-error" role="alert">{{ submitError }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
<footer class="wizard-actions">
|
<footer class="wizard-actions">
|
||||||
<button type="button" class="btn-ghost" (click)="prevStep()" [disabled]="step() === 1">Back</button>
|
<button type="button" class="btn-ghost" (click)="prevStep()" [disabled]="step() === 1 || submitting()">Back</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
|
||||||
@if (step() < 4) {
|
@if (step() < 4) {
|
||||||
<button type="button" class="btn-primary" (click)="nextStep()" [disabled]="!canContinueStep()">
|
<button type="button" class="btn-primary" (click)="nextStep()" [disabled]="!canContinueStep() || submitting()">
|
||||||
Continue
|
Continue
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button type="button" class="btn-primary" (click)="sealAndCreate()" [disabled]="!canSeal()">
|
<button type="button" class="btn-primary" (click)="sealAndCreate()" [disabled]="!canSeal()">
|
||||||
Seal Draft Release Version
|
@if (submitting()) {
|
||||||
|
Creating Release Version...
|
||||||
|
} @else {
|
||||||
|
Seal Draft Release Version
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</footer>
|
</footer>
|
||||||
@@ -428,6 +443,20 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
|
|||||||
font-size: 0.8rem;
|
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 {
|
.review-card {
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -503,10 +532,14 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
|
|||||||
export class CreateReleaseComponent implements OnInit {
|
export class CreateReleaseComponent implements OnInit {
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly auth = inject(AUTH_SERVICE) as AuthService;
|
||||||
|
private readonly bundleApi = inject(BundleOrganizerApi);
|
||||||
readonly store = inject(ReleaseManagementStore);
|
readonly store = inject(ReleaseManagementStore);
|
||||||
|
|
||||||
readonly step = signal(1);
|
readonly step = signal(1);
|
||||||
sealDraft = false;
|
sealDraft = false;
|
||||||
|
readonly submitError = signal<string | null>(null);
|
||||||
|
readonly submitting = signal(false);
|
||||||
|
|
||||||
readonly form = {
|
readonly form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -552,6 +585,10 @@ export class CreateReleaseComponent implements OnInit {
|
|||||||
&& Boolean(this.form.targetPathIntent.trim());
|
&& Boolean(this.form.targetPathIntent.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.step() === 2) {
|
||||||
|
return this.components.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.step() === 3) {
|
if (this.step() === 3) {
|
||||||
return Boolean(this.contract.configProfile.trim()) && Boolean(this.contract.changeTicket.trim());
|
return Boolean(this.contract.configProfile.trim()) && Boolean(this.contract.changeTicket.trim());
|
||||||
}
|
}
|
||||||
@@ -560,7 +597,7 @@ export class CreateReleaseComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canSeal(): boolean {
|
canSeal(): boolean {
|
||||||
return this.canContinueStep() && this.sealDraft;
|
return this.components.length > 0 && this.canContinueStep() && this.sealDraft && !this.submitting();
|
||||||
}
|
}
|
||||||
|
|
||||||
nextStep(): void {
|
nextStep(): void {
|
||||||
@@ -629,6 +666,14 @@ export class CreateReleaseComponent implements OnInit {
|
|||||||
return;
|
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 = [
|
const descriptionLines = [
|
||||||
this.form.description.trim(),
|
this.form.description.trim(),
|
||||||
`type=${this.form.releaseType}`,
|
`type=${this.form.releaseType}`,
|
||||||
@@ -640,17 +685,122 @@ export class CreateReleaseComponent implements OnInit {
|
|||||||
`draftIdentity=${this.draftIdentityPreview()}`,
|
`draftIdentity=${this.draftIdentityPreview()}`,
|
||||||
].filter((item) => item.length > 0);
|
].filter((item) => item.length > 0);
|
||||||
|
|
||||||
this.store.createRelease({
|
const bundleSlug = this.toSlug(this.form.name.trim());
|
||||||
name: this.form.name.trim(),
|
const bundleName = this.form.name.trim();
|
||||||
version: this.form.version.trim(),
|
const bundleDescription = descriptionLines.join(' | ');
|
||||||
description: descriptionLines.join(' | '),
|
const publishRequest = {
|
||||||
targetEnvironment: this.form.targetEnvironment.trim() || undefined,
|
changelog: bundleDescription,
|
||||||
deploymentStrategy: this.contract.deploymentStrategy,
|
components: this.toBundleComponents(),
|
||||||
});
|
};
|
||||||
|
|
||||||
void this.router.navigate(['/releases/versions'], {
|
this.createOrReuseBundle(bundleSlug, bundleName, bundleDescription)
|
||||||
queryParams: { type: this.form.releaseType },
|
.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()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export const RELEASES_ROUTES: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'health',
|
path: 'health',
|
||||||
title: 'Release Health',
|
title: 'Release Health',
|
||||||
data: { breadcrumb: 'Health' },
|
data: { breadcrumb: 'Release Health' },
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../features/topology/environment-posture-page.component').then(
|
import('../features/topology/environment-posture-page.component').then(
|
||||||
(m) => m.EnvironmentPosturePageComponent,
|
(m) => m.EnvironmentPosturePageComponent,
|
||||||
|
|||||||
Reference in New Issue
Block a user