diff --git a/docs/components/AWS.mdx b/docs/components/AWS.mdx index a885241775..1b1958be66 100644 --- a/docs/components/AWS.mdx +++ b/docs/components/AWS.mdx @@ -28,6 +28,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + @@ -690,6 +691,65 @@ Emits the full pipeline definition including: } ``` + + +## CodePipeline • Get Pipeline Execution + +The Get Pipeline Execution component retrieves the details of a specific AWS CodePipeline execution. + +### Use Cases + +- **Execution inspection**: Fetch the status, trigger, and artifact revisions of a pipeline run +- **Post-deploy checks**: After a RunPipeline component, fetch details of that execution for logging +- **Workflow branching**: Route workflow based on execution status or trigger type +- **Audit and compliance**: Retrieve execution details for auditing purposes + +### Configuration + +- **Region**: AWS region where the pipeline exists +- **Pipeline**: Pipeline name +- **Execution ID**: The ID of the specific execution to retrieve + +### Output + +Emits the full pipeline execution details including: +- Execution ID, status, and status summary +- Pipeline name and version +- Trigger type and detail +- Artifact revisions (source code revisions involved) +- Execution mode and type + +### Example Output + +```json +{ + "data": { + "artifactRevisions": [ + { + "name": "SourceArtifact", + "revisionChangeIdentifier": "abc123def456789", + "revisionId": "abc123def456789", + "revisionSummary": "Merge pull request #42 from feature/add-auth", + "revisionUrl": "https://github.com/example/repo/commit/abc123def456789" + } + ], + "executionMode": "SUPERSEDED", + "executionType": "STANDARD", + "pipelineExecutionId": "a1b2c3d4-5678-90ab-cdef-111122223333", + "pipelineName": "my-deploy-pipeline", + "pipelineVersion": 3, + "status": "Succeeded", + "statusSummary": "Pipeline completed successfully", + "trigger": { + "triggerDetail": "arn:aws:iam::123456789012:user/developer", + "triggerType": "StartPipelineExecution" + } + }, + "timestamp": "2026-02-23T10:00:00.000000000Z", + "type": "aws.codepipeline.pipeline.execution" +} +``` + ## CodePipeline • Run Pipeline diff --git a/pkg/integrations/aws/aws.go b/pkg/integrations/aws/aws.go index a90bbb42f8..e79d2ee83d 100644 --- a/pkg/integrations/aws/aws.go +++ b/pkg/integrations/aws/aws.go @@ -145,6 +145,7 @@ func (a *AWS) Components() []core.Component { &codeartifact.GetPackageVersion{}, &codeartifact.UpdatePackageVersionsStatus{}, &codepipeline.GetPipeline{}, + &codepipeline.GetPipelineExecution{}, &codepipeline.RunPipeline{}, &ecs.CreateService{}, &ecs.DescribeService{}, diff --git a/pkg/integrations/aws/codepipeline/client.go b/pkg/integrations/aws/codepipeline/client.go index 84ef5f17cf..e2feb24f69 100644 --- a/pkg/integrations/aws/codepipeline/client.go +++ b/pkg/integrations/aws/codepipeline/client.go @@ -101,6 +101,24 @@ func (c *Client) GetPipelineExecution(pipelineName, executionID string) (*Pipeli return &response.PipelineExecution, nil } +type GetPipelineExecutionDetailsResponse struct { + PipelineExecution map[string]any `json:"pipelineExecution"` +} + +func (c *Client) GetPipelineExecutionDetails(pipelineName, executionID string) (*GetPipelineExecutionDetailsResponse, error) { + payload := map[string]any{ + "pipelineName": pipelineName, + "pipelineExecutionId": executionID, + } + + var response GetPipelineExecutionDetailsResponse + if err := c.postJSON("GetPipelineExecution", payload, &response); err != nil { + return nil, err + } + + return &response, nil +} + func (c *Client) StopPipelineExecution(pipelineName, executionID, reason string, abandon bool) error { payload := map[string]any{ "pipelineName": pipelineName, diff --git a/pkg/integrations/aws/codepipeline/example.go b/pkg/integrations/aws/codepipeline/example.go index a16c2e4101..f93400edaf 100644 --- a/pkg/integrations/aws/codepipeline/example.go +++ b/pkg/integrations/aws/codepipeline/example.go @@ -34,3 +34,17 @@ func (c *GetPipeline) ExampleOutput() map[string]any { &exampleOutputGetPipeline, ) } + +//go:embed example_output_get_pipeline_execution.json +var exampleOutputGetPipelineExecutionBytes []byte + +var exampleOutputGetPipelineExecutionOnce sync.Once +var exampleOutputGetPipelineExecution map[string]any + +func (c *GetPipelineExecution) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON( + &exampleOutputGetPipelineExecutionOnce, + exampleOutputGetPipelineExecutionBytes, + &exampleOutputGetPipelineExecution, + ) +} diff --git a/pkg/integrations/aws/codepipeline/example_output_get_pipeline_execution.json b/pkg/integrations/aws/codepipeline/example_output_get_pipeline_execution.json new file mode 100644 index 0000000000..1b9314dc8b --- /dev/null +++ b/pkg/integrations/aws/codepipeline/example_output_get_pipeline_execution.json @@ -0,0 +1,26 @@ +{ + "data": { + "pipelineExecutionId": "a1b2c3d4-5678-90ab-cdef-111122223333", + "pipelineName": "my-deploy-pipeline", + "pipelineVersion": 3, + "status": "Succeeded", + "statusSummary": "Pipeline completed successfully", + "artifactRevisions": [ + { + "name": "SourceArtifact", + "revisionId": "abc123def456789", + "revisionChangeIdentifier": "abc123def456789", + "revisionSummary": "Merge pull request #42 from feature/add-auth", + "revisionUrl": "https://github.com/example/repo/commit/abc123def456789" + } + ], + "trigger": { + "triggerType": "StartPipelineExecution", + "triggerDetail": "arn:aws:iam::123456789012:user/developer" + }, + "executionMode": "SUPERSEDED", + "executionType": "STANDARD" + }, + "timestamp": "2026-02-23T10:00:00.000000000Z", + "type": "aws.codepipeline.pipeline.execution" +} diff --git a/pkg/integrations/aws/codepipeline/get_pipeline_execution.go b/pkg/integrations/aws/codepipeline/get_pipeline_execution.go new file mode 100644 index 0000000000..02d0aa5627 --- /dev/null +++ b/pkg/integrations/aws/codepipeline/get_pipeline_execution.go @@ -0,0 +1,191 @@ +package codepipeline + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/aws/common" +) + +type GetPipelineExecution struct{} + +type GetPipelineExecutionSpec struct { + Region string `json:"region" mapstructure:"region"` + Pipeline string `json:"pipeline" mapstructure:"pipeline"` + ExecutionID string `json:"executionId" mapstructure:"executionId"` +} + +func (c *GetPipelineExecution) Name() string { + return "aws.codepipeline.getPipelineExecution" +} + +func (c *GetPipelineExecution) Label() string { + return "CodePipeline • Get Pipeline Execution" +} + +func (c *GetPipelineExecution) Description() string { + return "Retrieve the status and details of an AWS CodePipeline execution" +} + +func (c *GetPipelineExecution) Documentation() string { + return `The Get Pipeline Execution component retrieves the details of a specific AWS CodePipeline execution. + +## Use Cases + +- **Execution inspection**: Fetch the status, trigger, and artifact revisions of a pipeline run +- **Post-deploy checks**: After a RunPipeline component, fetch details of that execution for logging +- **Workflow branching**: Route workflow based on execution status or trigger type +- **Audit and compliance**: Retrieve execution details for auditing purposes + +## Configuration + +- **Region**: AWS region where the pipeline exists +- **Pipeline**: Pipeline name +- **Execution ID**: The ID of the specific execution to retrieve + +## Output + +Emits the full pipeline execution details including: +- Execution ID, status, and status summary +- Pipeline name and version +- Trigger type and detail +- Artifact revisions (source code revisions involved) +- Execution mode and type` +} + +func (c *GetPipelineExecution) Icon() string { + return "aws" +} + +func (c *GetPipelineExecution) Color() string { + return "orange" +} + +func (c *GetPipelineExecution) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "region", + Label: "Region", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "us-east-1", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: common.AllRegions, + }, + }, + }, + { + Name: "pipeline", + Label: "Pipeline", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "CodePipeline pipeline to query", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "codepipeline.pipeline", + Parameters: []configuration.ParameterRef{ + { + Name: "region", + ValueFrom: &configuration.ParameterValueFrom{ + Field: "region", + }, + }, + }, + }, + }, + VisibilityConditions: []configuration.VisibilityCondition{ + { + Field: "region", + Values: []string{"*"}, + }, + }, + }, + { + Name: "executionId", + Label: "Execution ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "Pipeline execution ID to retrieve (supports expressions)", + }, + } +} + +func (c *GetPipelineExecution) Setup(ctx core.SetupContext) error { + spec := GetPipelineExecutionSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if strings.TrimSpace(spec.Region) == "" { + return fmt.Errorf("region is required") + } + + if strings.TrimSpace(spec.Pipeline) == "" { + return fmt.Errorf("pipeline is required") + } + + if strings.TrimSpace(spec.ExecutionID) == "" { + return fmt.Errorf("execution ID is required") + } + + return nil +} + +func (c *GetPipelineExecution) Execute(ctx core.ExecutionContext) error { + spec := GetPipelineExecutionSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + credentials, err := common.CredentialsFromInstallation(ctx.Integration) + if err != nil { + return fmt.Errorf("failed to get AWS credentials: %w", err) + } + + client := NewClient(ctx.HTTP, credentials, strings.TrimSpace(spec.Region)) + + response, err := client.GetPipelineExecutionDetails(strings.TrimSpace(spec.Pipeline), strings.TrimSpace(spec.ExecutionID)) + if err != nil { + return fmt.Errorf("failed to get pipeline execution: %w", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.codepipeline.pipeline.execution", + []any{response.PipelineExecution}, + ) +} + +func (c *GetPipelineExecution) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *GetPipelineExecution) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *GetPipelineExecution) Actions() []core.Action { + return []core.Action{} +} + +func (c *GetPipelineExecution) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *GetPipelineExecution) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *GetPipelineExecution) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *GetPipelineExecution) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/aws/codepipeline/get_pipeline_execution_test.go b/pkg/integrations/aws/codepipeline/get_pipeline_execution_test.go new file mode 100644 index 0000000000..45501c3c8f --- /dev/null +++ b/pkg/integrations/aws/codepipeline/get_pipeline_execution_test.go @@ -0,0 +1,149 @@ +package codepipeline + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__GetPipelineExecution__Setup(t *testing.T) { + component := &GetPipelineExecution{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: "invalid", + }) + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing region -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "region": " ", + "pipeline": "my-pipeline", + "executionId": "abc-123", + }, + }) + require.ErrorContains(t, err, "region is required") + }) + + t.Run("missing pipeline -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "region": "us-east-1", + "executionId": "abc-123", + }, + }) + require.ErrorContains(t, err, "pipeline is required") + }) + + t.Run("missing executionId -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "region": "us-east-1", + "pipeline": "my-pipeline", + }, + }) + require.ErrorContains(t, err, "execution ID is required") + }) + + t.Run("valid configuration -> ok", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "region": "us-east-1", + "pipeline": "my-pipeline", + "executionId": "a1b2c3d4-5678-90ab-cdef-111122223333", + }, + }) + require.NoError(t, err) + }) +} + +func Test__GetPipelineExecution__Execute(t *testing.T) { + component := &GetPipelineExecution{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: "invalid", + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + }) + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing credentials -> error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "region": "us-east-1", + "pipeline": "my-pipeline", + "executionId": "a1b2c3d4-5678-90ab-cdef-111122223333", + }, + Integration: &contexts.IntegrationContext{Secrets: map[string]core.IntegrationSecret{}}, + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + }) + require.ErrorContains(t, err, "AWS session credentials are missing") + }) + + t.Run("valid request -> emits pipeline execution details", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "pipelineExecution": { + "pipelineExecutionId": "a1b2c3d4-5678-90ab-cdef-111122223333", + "pipelineName": "my-pipeline", + "pipelineVersion": 3, + "status": "Succeeded", + "statusSummary": "Pipeline completed successfully", + "artifactRevisions": [ + { + "name": "SourceArtifact", + "revisionId": "abc123def456", + "revisionSummary": "Merge pull request #42" + } + ], + "trigger": { + "triggerType": "StartPipelineExecution", + "triggerDetail": "arn:aws:iam::123456789012:user/developer" + }, + "executionMode": "SUPERSEDED" + } + }`)), + }, + }, + } + + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "region": "us-east-1", + "pipeline": "my-pipeline", + "executionId": "a1b2c3d4-5678-90ab-cdef-111122223333", + }, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + "accessKeyId": {Name: "accessKeyId", Value: []byte("key")}, + "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")}, + "sessionToken": {Name: "sessionToken", Value: []byte("token")}, + }, + }, + }) + + require.NoError(t, err) + require.Len(t, execState.Payloads, 1) + + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, + "https://codepipeline.us-east-1.amazonaws.com/", + httpContext.Requests[0].URL.String(), + ) + }) +} diff --git a/web_src/src/pages/workflowv2/mappers/aws/codepipeline/get_pipeline_execution.ts b/web_src/src/pages/workflowv2/mappers/aws/codepipeline/get_pipeline_execution.ts new file mode 100644 index 0000000000..8851c4a078 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/aws/codepipeline/get_pipeline_execution.ts @@ -0,0 +1,130 @@ +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../../types"; +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getBackgroundColorClass, getColorClass } from "@/utils/colors"; +import { getState, getStateMap, getTriggerRenderer } from "../.."; +import { MetadataItem } from "@/ui/metadataList"; +import { formatTimeAgo } from "@/utils/date"; +import { stringOrDash } from "../../utils"; +import awsCodePipelineIcon from "@/assets/icons/integrations/aws.codepipeline.svg"; + +interface GetPipelineExecutionConfiguration { + region?: string; + pipeline?: string; + executionId?: string; +} + +interface GetPipelineExecutionOutput { + pipelineExecutionId?: string; + pipelineName?: string; + pipelineVersion?: number; + status?: string; + statusSummary?: string; + artifactRevisions?: unknown[]; + trigger?: { + triggerType?: string; + triggerDetail?: string; + }; + executionMode?: string; + executionType?: string; +} + +export const getPipelineExecutionMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + title: context.node.name || context.componentDefinition.label || "Unnamed component", + iconSrc: awsCodePipelineIcon, + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? getEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: getMetadataList(context.node), + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as GetPipelineExecutionOutput | undefined; + + const timestamp = context.execution.updatedAt + ? new Date(context.execution.updatedAt).toLocaleString() + : context.execution.createdAt + ? new Date(context.execution.createdAt).toLocaleString() + : "-"; + + const details: Record = { + "Retrieved At": timestamp, + }; + + if (result) { + details["Pipeline"] = stringOrDash(result.pipelineName); + details["Execution ID"] = stringOrDash(result.pipelineExecutionId); + details["Status"] = stringOrDash(result.status); + details["Pipeline Version"] = result.pipelineVersion ? String(result.pipelineVersion) : "-"; + + if (result.trigger?.triggerType) { + details["Trigger Type"] = result.trigger.triggerType; + } + + if (result.executionMode) { + details["Execution Mode"] = result.executionMode; + } + + if (result.artifactRevisions && result.artifactRevisions.length > 0) { + details["Artifact Revisions"] = String(result.artifactRevisions.length); + } + } + + return details; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) { + return ""; + } + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function getMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as GetPipelineExecutionConfiguration | undefined; + + if (configuration?.pipeline) { + metadata.push({ icon: "file-text", label: configuration.pipeline }); + } + + if (configuration?.region) { + metadata.push({ icon: "globe", label: configuration.region }); + } + + return metadata; +} + +function getEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt ?? 0), + eventTitle: title, + eventSubtitle: formatTimeAgo(new Date(execution.createdAt ?? 0)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent?.id ?? "", + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/aws/index.ts b/web_src/src/pages/workflowv2/mappers/aws/index.ts index 2eb8c00d5f..40138ddc76 100644 --- a/web_src/src/pages/workflowv2/mappers/aws/index.ts +++ b/web_src/src/pages/workflowv2/mappers/aws/index.ts @@ -31,6 +31,7 @@ import { deleteTopicMapper } from "./sns/delete_topic"; import { getSubscriptionMapper } from "./sns/get_subscription"; import { getTopicMapper } from "./sns/get_topic"; import { publishMessageMapper } from "./sns/publish_message"; +import { getPipelineExecutionMapper } from "./codepipeline/get_pipeline_execution"; import { RUN_PIPELINE_STATE_REGISTRY, runPipelineMapper } from "./codepipeline/run_pipeline"; import { getPipelineMapper } from "./codepipeline/get_pipeline"; import { onImageTriggerRenderer } from "./ec2/on_image"; @@ -45,6 +46,7 @@ import { disableImageDeprecationMapper } from "./ec2/disable_image_deprecation"; export const componentMappers: Record = { "codepipeline.getPipeline": getPipelineMapper, + "codepipeline.getPipelineExecution": getPipelineExecutionMapper, "codepipeline.runPipeline": runPipelineMapper, "lambda.runFunction": runFunctionMapper, "ecs.createService": createServiceMapper, @@ -97,6 +99,7 @@ export const triggerRenderers: Record = { export const eventStateRegistry: Record = { "codepipeline.getPipeline": buildActionStateRegistry("retrieved"), + "codepipeline.getPipelineExecution": buildActionStateRegistry("retrieved"), "codepipeline.runPipeline": RUN_PIPELINE_STATE_REGISTRY, "ecs.createService": buildActionStateRegistry("created"), "ecs.describeService": buildActionStateRegistry("described"),