diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs index 0155899f2..ee33a3fe7 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs @@ -389,8 +389,11 @@ public static class ReleaseEndpoints ApprovalEndpoints.SeedData.Approvals.Add(approval); - // Start the release-promotion workflow (fire-and-forget — workflow runs async) - _ = workflowClient.StartWorkflowAsync("release-promotion", new Dictionary + // Start the release's pipeline workflow (fire-and-forget — workflow runs async) + // If the release has a custom workflow definition embedded in its description, + // use that workflow name; otherwise fall back to the generic release-promotion workflow. + var workflowName = ExtractWorkflowName(release.Description) ?? "release-promotion"; + _ = workflowClient.StartWorkflowAsync(workflowName, new Dictionary { ["releaseId"] = release.Id, ["targetEnvironment"] = targetEnvironment, @@ -800,6 +803,45 @@ public static class ReleaseEndpoints return Results.Ok(new { suggestedVersion = BumpVersion(baseVersion, true), source = baseVersion }); } + /// + /// Extracts the workflow name from a release description that contains a workflowDefinition metadata line. + /// Format: workflowDefinition={"workflowName":"release-platform-release-1-3-1",...} + /// + private static string? ExtractWorkflowName(string description) + { + if (string.IsNullOrWhiteSpace(description)) + { + return null; + } + + // Look for workflowDefinition= in the pipe-delimited description + foreach (var segment in description.Split('|', StringSplitOptions.TrimEntries)) + { + if (!segment.StartsWith("workflowDefinition=", StringComparison.Ordinal)) + { + continue; + } + + var json = segment["workflowDefinition=".Length..]; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("workflowName", out var nameElement)) + { + return nameElement.GetString(); + } + } + catch + { + // Malformed JSON — fall through to default + } + + break; + } + + return null; + } + private static string BumpVersion(string version, bool isStable) { // Handle prerelease: 1.3.0-rc1 → 1.3.0-rc2 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 07dcf8955..06bc5a9af 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 @@ -15,6 +15,7 @@ import { PlatformContextStore } from '../../../../core/context/platform-context. import { ScriptPickerComponent } from '../../../../shared/components/script-picker/script-picker.component'; import { ReleasePipelineEditorComponent } from './release-pipeline-editor.component'; import { type ReleasePipeline, type ReleaseDefaults, createDefaultPipeline } from './release-pipeline.models'; +import { PipelineToWorkflowService } from './pipeline-to-workflow.service'; import type { Script } from '../../../../core/api/scripts.models'; @Component({ @@ -1910,6 +1911,7 @@ export class CreateReleaseComponent implements OnInit { private readonly bundleApi = inject(BundleOrganizerApi); readonly store = inject(ReleaseManagementStore); readonly platformCtx = inject(PlatformContextStore); + private readonly pipelineToWorkflow = inject(PipelineToWorkflowService); readonly step = signal(1); sealDraft = false; @@ -2344,6 +2346,13 @@ export class CreateReleaseComponent implements OnInit { this.submitError.set(null); this.submitting.set(true); + // Generate canonical workflow definition from the pipeline + const workflowDefinition = this.pipelineToWorkflow.generate( + this.releasePipeline(), + this.form.name.trim(), + this.form.version.trim(), + ); + const descriptionLines = [ this.form.description.trim(), `type=${this.form.releaseType}`, @@ -2357,6 +2366,7 @@ export class CreateReleaseComponent implements OnInit { `targetStage=${this.targetStage || 'none'}`, `draftIdentity=${this.draftIdentityPreview()}`, `strategyConfig=${this.getActiveStrategyConfigJson()}`, + `workflowDefinition=${JSON.stringify(workflowDefinition)}`, this.hasAnyLifecycleHook() ? `lifecycleHooks=${JSON.stringify({ preDeploy: this.lifecycleHooks.preDeployScriptId, postDeploy: this.lifecycleHooks.postDeployScriptId, diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/pipeline-to-workflow.service.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/pipeline-to-workflow.service.ts new file mode 100644 index 000000000..141bd5ebc --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/pipeline-to-workflow.service.ts @@ -0,0 +1,517 @@ +import { Injectable } from '@angular/core'; +import type { + ReleasePipeline, PipelinePhase, DeployConfig, TestConfig, ApprovalConfig, GateConfig, + CanaryDeployConfig, RollingDeployConfig, BlueGreenDeployConfig, AbDeployConfig, +} from './release-pipeline.models'; + +/** + * Generates a canonical stellaops.workflow.definition/v1 JSON from a ReleasePipeline. + * + * The mapping: + * - preflight → call-transport to scanner verify + * - gate → call-transport to policy-engine + * - approval → activate-task + * - deploy → strategy-specific steps (repeat + call-transport + decision for multi-batch) + * - test → call-transport to test-runner + decision on result + * - seal → call-transport to attestor + signer + * + * Fallback branches become whenFailure on call-transport steps. + */ +@Injectable({ providedIn: 'root' }) +export class PipelineToWorkflowService { + + generate(pipeline: ReleasePipeline, releaseName: string, version: string): object { + const steps: object[] = []; + + // Initialize state + steps.push({ + $type: 'set-state', + stateKey: 'pipelineStatus', + valueExpression: str('running'), + }); + + for (const phase of pipeline.phases) { + if (!phase.enabled) continue; + steps.push(...this.phaseToSteps(phase)); + } + + // Final complete + steps.push({ + $type: 'set-state', + stateKey: 'pipelineStatus', + valueExpression: str('completed'), + }); + steps.push({ $type: 'complete' }); + + const workflowName = `release-${slugify(releaseName)}-${slugify(version)}`; + + return { + schemaVersion: 'stellaops.workflow.definition/v1', + workflowName, + workflowVersion: '1.0.0', + displayName: `${releaseName} ${version}`, + startRequest: { + contractName: 'StellaOps.ReleaseOrchestrator.Contracts.ReleasePipelineRequest', + schema: { + type: 'object', + properties: { + releaseId: { type: 'string' }, + targetEnvironment: { type: 'string' }, + requestedBy: { type: 'string' }, + }, + required: ['releaseId', 'targetEnvironment', 'requestedBy'], + }, + allowAdditionalProperties: true, + }, + workflowRoles: ['release-approver', 'release-operator'], + businessReference: { + keyExpression: path('start.releaseId'), + parts: [ + { name: 'releaseId', expression: path('start.releaseId') }, + { name: 'environment', expression: path('start.targetEnvironment') }, + ], + }, + start: { + initializeStateExpression: { + $type: 'object', + properties: [ + { name: 'releaseId', expression: path('start.releaseId') }, + { name: 'releaseName', expression: str(releaseName) }, + { name: 'releaseVersion', expression: str(version) }, + { name: 'targetEnvironment', expression: path('start.targetEnvironment') }, + { name: 'requestedBy', expression: path('start.requestedBy') }, + { name: 'pipelineStatus', expression: str('pending') }, + ], + }, + initialSequence: { steps }, + }, + tasks: this.collectTasks(pipeline), + requiredModules: [], + }; + } + + private phaseToSteps(phase: PipelinePhase): object[] { + switch (phase.type) { + case 'preflight': return this.preflightSteps(phase); + case 'gate': return this.gateSteps(phase); + case 'approval': return this.approvalSteps(phase); + case 'deploy': return this.deploySteps(phase); + case 'test': return this.testSteps(phase); + case 'seal': return this.sealSteps(phase); + default: return []; + } + } + + private preflightSteps(_phase: PipelinePhase): object[] { + return [ + setState('pipelineStatus', str('preflight')), + { + $type: 'call-transport', + stepName: 'preflight-verify', + invocation: { + address: { $type: 'microservice', microserviceName: 'scanner', command: 'verify-digests' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + ]), + }, + resultKey: 'preflightResult', + }, + ]; + } + + private gateSteps(phase: PipelinePhase): object[] { + const config = phase.config as GateConfig; + const gateIds = config.gates?.filter(g => g.required).map(g => g.id) ?? []; + + return [ + setState('pipelineStatus', str('evaluating-gates')), + { + $type: 'call-transport', + stepName: 'evaluate-gates', + invocation: { + address: { $type: 'microservice', microserviceName: 'policy-engine', command: 'evaluate-release-gates' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + { name: 'requiredGates', expression: { $type: 'array', items: gateIds.map(id => str(id)) } }, + ]), + }, + resultKey: 'gateResult', + }, + { + $type: 'decision', + decisionName: 'gates-check', + conditionExpression: binary('==', path('state.gateResult.passed'), bool(true)), + whenTrue: { steps: [] }, + whenElse: { + steps: [ + setState('pipelineStatus', str('gates-failed')), + { $type: 'complete' }, + ], + }, + }, + ]; + } + + private approvalSteps(phase: PipelinePhase): object[] { + const config = phase.config as ApprovalConfig; + return [ + setState('pipelineStatus', str('awaiting-approval')), + { + $type: 'activate-task', + taskName: 'Approve Release', + runtimeRolesExpression: { $type: 'array', items: (config.roles ?? ['release-approver']).map(r => str(r)) }, + timeoutSeconds: (config.timeoutHours ?? 48) * 3600, + }, + ]; + } + + private deploySteps(phase: PipelinePhase): object[] { + const config = phase.config as DeployConfig; + const steps: object[] = [ + setState('pipelineStatus', str('deploying')), + ]; + + const failureHandler = phase.fallback?.autoRollback + ? { steps: [setState('pipelineStatus', str('rolling-back')), this.rollbackTransportStep(), setState('pipelineStatus', str('rolled-back')), { $type: 'complete' }] } + : undefined; + + switch (config.strategy) { + case 'rolling': + steps.push(this.rollingDeployStep(config.config as RollingDeployConfig, failureHandler)); + break; + case 'canary': + steps.push(...this.canaryDeploySteps(config.config as CanaryDeployConfig, failureHandler)); + break; + case 'blue_green': + steps.push(...this.blueGreenDeploySteps(config.config as BlueGreenDeployConfig, failureHandler)); + break; + case 'ab-release': + steps.push(...this.abDeploySteps(config.config as AbDeployConfig, failureHandler)); + break; + default: + steps.push({ + $type: 'call-transport', + stepName: 'deploy-all', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-deployment' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + { name: 'strategy', expression: str('all-at-once') }, + ]), + }, + resultKey: 'deployResult', + ...(failureHandler ? { whenFailure: failureHandler } : {}), + }); + } + + return steps; + } + + private rollingDeployStep(config: RollingDeployConfig, failureHandler?: object): object { + return { + $type: 'call-transport', + stepName: 'rolling-deploy', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-deployment' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + { name: 'strategy', expression: str('rolling') }, + { name: 'batchSize', expression: num(config.batchSize) }, + { name: 'batchSizeType', expression: str(config.batchSizeType) }, + { name: 'stabilizationTime', expression: num(config.stabilizationTime) }, + { name: 'maxFailedBatches', expression: num(config.maxFailedBatches) }, + { name: 'healthCheckType', expression: str(config.healthCheckType) }, + ]), + }, + resultKey: 'deployResult', + ...(failureHandler ? { whenFailure: failureHandler } : {}), + }; + } + + private canaryDeploySteps(config: CanaryDeployConfig, failureHandler?: object): object[] { + const steps: object[] = []; + for (let i = 0; i < config.stages.length; i++) { + const stage = config.stages[i]; + steps.push({ + $type: 'call-transport', + stepName: `canary-stage-${i + 1}`, + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-canary-stage' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + { name: 'trafficPercent', expression: num(stage.trafficPercent) }, + { name: 'stageIndex', expression: num(i) }, + ]), + }, + resultKey: `canaryStage${i + 1}Result`, + ...(failureHandler ? { whenFailure: failureHandler } : {}), + }); + + // Metrics window timer + check + if (i < config.stages.length - 1) { + steps.push({ + $type: 'timer', + stepName: `canary-metrics-window-${i + 1}`, + delayExpression: str(`PT${stage.durationMinutes}M`), + }); + steps.push({ + $type: 'decision', + decisionName: `canary-metrics-check-${i + 1}`, + conditionExpression: binary('<=', + path(`state.canaryStage${i + 1}Result.errorRate`), + num(config.errorRateThreshold / 100)), + whenTrue: { steps: [] }, + whenElse: failureHandler ?? { steps: [setState('pipelineStatus', str('canary-failed')), { $type: 'complete' }] }, + }); + } + } + + // Full rollout + steps.push({ + $type: 'call-transport', + stepName: 'canary-full-rollout', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-canary-stage' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + { name: 'trafficPercent', expression: num(100) }, + { name: 'stageIndex', expression: num(config.stages.length) }, + ]), + }, + resultKey: 'canaryFullResult', + }); + + return steps; + } + + private blueGreenDeploySteps(config: BlueGreenDeployConfig, failureHandler?: object): object[] { + return [ + { + $type: 'call-transport', + stepName: 'deploy-green', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'deploy-green-environment' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + ]), + }, + resultKey: 'greenDeployResult', + ...(failureHandler ? { whenFailure: failureHandler } : {}), + }, + { + $type: 'timer', + stepName: 'green-warmup', + delayExpression: str(`PT${config.warmupPeriodSeconds}S`), + }, + { + $type: 'call-transport', + stepName: 'switch-traffic', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'switch-traffic' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + { name: 'mode', expression: str(config.switchoverMode) }, + ]), + }, + resultKey: 'switchResult', + ...(failureHandler ? { whenFailure: failureHandler } : {}), + }, + { + $type: 'timer', + stepName: 'blue-keepalive', + delayExpression: str(`PT${config.blueKeepaliveMinutes}M`), + }, + { + $type: 'call-transport', + stepName: 'teardown-blue', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'teardown-old-environment' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + ]), + }, + resultKey: 'teardownResult', + }, + ]; + } + + private abDeploySteps(config: AbDeployConfig, failureHandler?: object): object[] { + return [ + { + $type: 'fork', + stepName: 'deploy-ab-variants', + branches: [ + { + steps: [{ + $type: 'call-transport', + stepName: 'deploy-variant-a', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'deploy-variant' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'variant', expression: str('A') }, + ]), + }, + resultKey: 'variantAResult', + }], + }, + { + steps: [{ + $type: 'call-transport', + stepName: 'deploy-variant-b', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'deploy-variant' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'variant', expression: str('B') }, + ]), + }, + resultKey: 'variantBResult', + }], + }, + ], + }, + { + $type: 'timer', + stepName: 'ab-metrics-collection', + delayExpression: str(`PT${config.metricsCollectionHours}H`), + }, + { + $type: 'call-transport', + stepName: 'evaluate-ab-winner', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'evaluate-ab-results' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + ]), + }, + resultKey: 'abEvaluationResult', + }, + ]; + } + + private rollbackTransportStep(): object { + return { + $type: 'call-transport', + stepName: 'execute-rollback', + invocation: { + address: { $type: 'microservice', microserviceName: 'release-orchestrator', command: 'execute-rollback' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + ]), + }, + resultKey: 'rollbackResult', + }; + } + + private testSteps(phase: PipelinePhase): object[] { + const config = phase.config as TestConfig; + const failureHandler = phase.fallback?.autoRollback + ? { steps: [setState('pipelineStatus', str('test-failed')), this.rollbackTransportStep(), { $type: 'complete' }] } + : undefined; + + return [ + setState('pipelineStatus', str(`testing-${config.testType}`)), + { + $type: 'call-transport', + stepName: `test-${config.testType}`, + invocation: { + address: { $type: 'microservice', microserviceName: 'taskrunner', command: `execute-${config.testType}-test` }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + { name: 'testType', expression: str(config.testType) }, + ...(config.scriptId ? [{ name: 'scriptId', expression: str(config.scriptId) }] : []), + ]), + }, + resultKey: 'testResult', + timeoutSeconds: config.timeoutSeconds, + ...(failureHandler ? { whenFailure: failureHandler } : {}), + }, + { + $type: 'decision', + decisionName: `test-${config.testType}-check`, + conditionExpression: binary('==', path('state.testResult.passed'), bool(true)), + whenTrue: { steps: [] }, + whenElse: failureHandler ?? { steps: [setState('pipelineStatus', str('test-failed')), { $type: 'complete' }] }, + }, + ]; + } + + private sealSteps(_phase: PipelinePhase): object[] { + return [ + setState('pipelineStatus', str('sealing-evidence')), + { + $type: 'call-transport', + stepName: 'generate-attestation', + invocation: { + address: { $type: 'microservice', microserviceName: 'attestor', command: 'generate-release-attestation' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'environment', expression: path('state.targetEnvironment') }, + ]), + }, + resultKey: 'attestationResult', + }, + { + $type: 'call-transport', + stepName: 'sign-evidence', + invocation: { + address: { $type: 'microservice', microserviceName: 'signer', command: 'sign-release-evidence' }, + payloadExpression: obj([ + { name: 'releaseId', expression: path('state.releaseId') }, + { name: 'attestationId', expression: path('state.attestationResult.attestationId') }, + ]), + }, + resultKey: 'signResult', + }, + ]; + } + + private collectTasks(pipeline: ReleasePipeline): object[] { + const tasks: object[] = []; + for (const phase of pipeline.phases) { + if (!phase.enabled || phase.type !== 'approval') continue; + const config = phase.config as ApprovalConfig; + tasks.push({ + taskName: 'Approve Release', + taskType: 'approval', + routeExpression: str('release-approval'), + payloadExpression: path('state'), + taskRoles: config.roles ?? ['release-approver'], + onComplete: { + steps: [ + { + $type: 'decision', + decisionName: 'approval-decision', + conditionExpression: binary('==', path('task.result.decision'), str('approved')), + whenTrue: { steps: [setState('pipelineStatus', str('approved'))] }, + whenElse: { steps: [setState('pipelineStatus', str('rejected')), { $type: 'complete' }] }, + }, + ], + }, + }); + } + return tasks; + } +} + +// ── Expression helpers ── + +function str(value: string): object { return { $type: 'string', value }; } +function num(value: number): object { return { $type: 'number', value: String(value) }; } +function bool(value: boolean): object { return { $type: 'boolean', value }; } +function path(p: string): object { return { $type: 'path', path: p }; } +function obj(properties: Array<{ name: string; expression: object }>): object { return { $type: 'object', properties }; } +function binary(operator: string, left: object, right: object): object { return { $type: 'binary', operator, left, right }; } +function setState(key: string, value: object): object { return { $type: 'set-state', stateKey: key, valueExpression: value }; } +function slugify(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'release'; }