-
-
Notifications
You must be signed in to change notification settings - Fork 153
Detect deleted components in affected stacks #2063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 10 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
7f9e3b7
updates
aknysh c7e193e
feat: detect deleted components and stacks in describe affected
aknysh 8adfc3d
Merge remote-tracking branch 'origin/main' into aknysh/update-describ…
aknysh 51eaf51
updates
aknysh fcc3124
updates
aknysh a1eabb2
updates
aknysh b77e27e
[autofix.ci] apply automated fixes
autofix-ci[bot] cfebbc2
Address CodeRabbit review feedback for deleted detection
aknysh 4abca0f
Address additional CodeRabbit review feedback
aknysh 4bf14c3
fix: address CodeRabbit review comments and fix CI test
aknysh 397f80e
fix: address CodeRabbit review - tests, docs, Windows compatibility
aknysh 4247d57
Merge branch 'main' into aknysh/update-describe-affected-9
aknysh e98ca79
Merge branch 'main' into aknysh/update-describe-affected-9
aknysh 43d241b
feat: add deleted component support to list affected command
aknysh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,315 @@ | ||
| package exec | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| cfg "github.com/cloudposse/atmos/pkg/config" | ||
| "github.com/cloudposse/atmos/pkg/perf" | ||
| "github.com/cloudposse/atmos/pkg/schema" | ||
| ) | ||
|
|
||
| // detectDeletedComponents detects components and stacks that exist in BASE (remoteStacks) | ||
| // but have been deleted in HEAD (currentStacks). | ||
| // This enables CI/CD pipelines to identify resources that need terraform destroy. | ||
| func detectDeletedComponents( | ||
| remoteStacks *map[string]any, | ||
| currentStacks *map[string]any, | ||
| atmosConfig *schema.AtmosConfiguration, | ||
| stackToFilter string, | ||
| ) ([]schema.Affected, error) { | ||
| defer perf.Track(atmosConfig, "exec.detectDeletedComponents")() | ||
|
|
||
| var deleted []schema.Affected | ||
|
|
||
| // Iterate over BASE stacks to find deletions. | ||
| for stackName, remoteStackSection := range *remoteStacks { | ||
| // If --stack filter is provided, skip other stacks. | ||
| if stackToFilter != "" && stackToFilter != stackName { | ||
| continue | ||
| } | ||
|
|
||
| remoteStackMap, ok := remoteStackSection.(map[string]any) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| remoteComponentsSection, ok := remoteStackMap["components"].(map[string]any) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| // Check if the stack exists in HEAD. | ||
| currentStackSection, stackExistsInHead := (*currentStacks)[stackName] | ||
|
|
||
| if !stackExistsInHead { | ||
| // Entire stack was deleted - add all non-abstract components. | ||
| stackDeleted := processDeletedStack( | ||
| stackName, | ||
| remoteComponentsSection, | ||
| atmosConfig, | ||
| ) | ||
| deleted = append(deleted, stackDeleted...) | ||
| } else { | ||
| // Stack exists but check for deleted components within. | ||
| componentDeleted, err := processDeletedComponentsInStack( | ||
| stackName, | ||
| remoteComponentsSection, | ||
| currentStackSection, | ||
| atmosConfig, | ||
| ) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| deleted = append(deleted, componentDeleted...) | ||
| } | ||
| } | ||
|
|
||
| return deleted, nil | ||
| } | ||
|
|
||
| // processDeletedStack handles the case where an entire stack was deleted. | ||
| // All non-abstract components in the stack are marked as deleted with deletion_type: "stack". | ||
| func processDeletedStack( | ||
| stackName string, | ||
| remoteComponentsSection map[string]any, | ||
| atmosConfig *schema.AtmosConfiguration, | ||
| ) []schema.Affected { | ||
| defer perf.Track(atmosConfig, "exec.processDeletedStack")() | ||
|
|
||
| return processAllComponentsAsDeleted( | ||
| stackName, | ||
| remoteComponentsSection, | ||
| atmosConfig, | ||
| affectedReasonDeletedStack, | ||
| deletionTypeStack, | ||
| ) | ||
| } | ||
|
|
||
| // processAllComponentsAsDeleted marks all non-abstract components in a stack as deleted. | ||
| // This is used both when an entire stack is deleted and when a stack exists but has no components section. | ||
| func processAllComponentsAsDeleted( | ||
| stackName string, | ||
| remoteComponentsSection map[string]any, | ||
| atmosConfig *schema.AtmosConfiguration, | ||
| affectedReason string, | ||
| deletionType string, | ||
| ) []schema.Affected { | ||
| var deleted []schema.Affected | ||
|
|
||
| // Process each component type. | ||
| for _, componentType := range []string{cfg.TerraformComponentType, cfg.HelmfileComponentType, cfg.PackerComponentType} { | ||
| componentTypeSection, ok := remoteComponentsSection[componentType].(map[string]any) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| for componentName, compSection := range componentTypeSection { | ||
| componentSection, ok := compSection.(map[string]any) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| // Skip abstract components - they are not provisioned. | ||
| if isAbstractComponent(componentSection) { | ||
| continue | ||
| } | ||
|
|
||
| affected := createDeletedAffectedItem(&deletedItemParams{ | ||
| componentName: componentName, | ||
| stackName: stackName, | ||
| componentType: componentType, | ||
| componentSection: &componentSection, | ||
| affectedReason: affectedReason, | ||
| deletionType: deletionType, | ||
| atmosConfig: atmosConfig, | ||
| }) | ||
| deleted = append(deleted, affected) | ||
| } | ||
| } | ||
|
|
||
| return deleted | ||
| } | ||
|
|
||
| // processDeletedComponentsInStack handles the case where a stack exists but some components were deleted. | ||
| // Components that exist in BASE but not in HEAD are marked as deleted with deletion_type: "component". | ||
| // | ||
| //nolint:funlen,revive // function-length: logic is straightforward, splitting would reduce readability. | ||
| func processDeletedComponentsInStack( | ||
| stackName string, | ||
| remoteComponentsSection map[string]any, | ||
| currentStackSection any, | ||
| atmosConfig *schema.AtmosConfiguration, | ||
| ) ([]schema.Affected, error) { | ||
| defer perf.Track(atmosConfig, "exec.processDeletedComponentsInStack")() | ||
|
|
||
| currentStackMap, ok := currentStackSection.(map[string]any) | ||
| if !ok { | ||
| return nil, nil | ||
| } | ||
|
|
||
| currentComponentsSection, ok := currentStackMap["components"].(map[string]any) | ||
| if !ok { | ||
| // Stack exists in HEAD but has no components section - all BASE components are deleted. | ||
| // Use deletion_type: "component" (not "stack") since the stack itself still exists. | ||
| return processAllComponentsAsDeleted( | ||
| stackName, | ||
| remoteComponentsSection, | ||
| atmosConfig, | ||
| affectedReasonDeleted, | ||
| deletionTypeComponent, | ||
| ), nil | ||
| } | ||
|
|
||
| var deleted []schema.Affected | ||
|
|
||
| // Process each component type. | ||
| for _, componentType := range []string{cfg.TerraformComponentType, cfg.HelmfileComponentType, cfg.PackerComponentType} { | ||
| remoteTypeSection, ok := remoteComponentsSection[componentType].(map[string]any) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| currentTypeSection, _ := currentComponentsSection[componentType].(map[string]any) | ||
|
|
||
| for componentName, compSection := range remoteTypeSection { | ||
| componentSection, ok := compSection.(map[string]any) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| // Skip abstract components - they are not provisioned. | ||
| if isAbstractComponent(componentSection) { | ||
| continue | ||
| } | ||
|
|
||
| // Check if component exists in HEAD. | ||
| if currentTypeSection != nil { | ||
| if _, existsInHead := currentTypeSection[componentName]; existsInHead { | ||
| // Component exists in HEAD - not deleted. | ||
| continue | ||
| } | ||
| } | ||
|
|
||
| // Component was deleted. | ||
| affected := createDeletedAffectedItem(&deletedItemParams{ | ||
| componentName: componentName, | ||
| stackName: stackName, | ||
| componentType: componentType, | ||
| componentSection: &componentSection, | ||
| affectedReason: affectedReasonDeleted, | ||
| deletionType: deletionTypeComponent, | ||
| atmosConfig: atmosConfig, | ||
| }) | ||
| deleted = append(deleted, affected) | ||
| } | ||
| } | ||
|
|
||
| return deleted, nil | ||
| } | ||
|
|
||
| // isAbstractComponent checks if a component has metadata.type = "abstract". | ||
| func isAbstractComponent(componentSection map[string]any) bool { | ||
| metadataSection, ok := componentSection[sectionNameMetadata].(map[string]any) | ||
| if !ok { | ||
| return false | ||
| } | ||
|
|
||
| metadataType, ok := metadataSection["type"].(string) | ||
| if !ok { | ||
| return false | ||
| } | ||
|
|
||
| return metadataType == "abstract" | ||
| } | ||
|
|
||
| // deletedItemParams holds parameters for creating a deleted affected item. | ||
| type deletedItemParams struct { | ||
| componentName string | ||
| stackName string | ||
| componentType string | ||
| componentSection *map[string]any | ||
| affectedReason string | ||
| deletionType string | ||
| atmosConfig *schema.AtmosConfiguration | ||
| } | ||
|
|
||
| // createDeletedAffectedItem creates an Affected item for a deleted component. | ||
| func createDeletedAffectedItem(params *deletedItemParams) schema.Affected { | ||
| affected := schema.Affected{ | ||
| Component: params.componentName, | ||
| ComponentType: params.componentType, | ||
| Stack: params.stackName, | ||
| Affected: params.affectedReason, | ||
| AffectedAll: []string{params.affectedReason}, | ||
| Deleted: true, | ||
| DeletionType: params.deletionType, | ||
| } | ||
|
|
||
| // Build component path from the BASE component section. | ||
| affected.ComponentPath = BuildComponentPath(params.atmosConfig, params.componentSection, params.componentType, params.componentName) | ||
| affected.StackSlug = fmt.Sprintf("%s-%s", params.stackName, strings.ReplaceAll(params.componentName, "/", "-")) | ||
|
|
||
| // Extract metadata from the component's vars section (same as non-deleted items). | ||
| if params.componentSection != nil { | ||
| populateDeletedItemMetadata(&affected, params) | ||
| } | ||
|
|
||
| return affected | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // populateDeletedItemMetadata extracts and populates metadata fields from the component section. | ||
| func populateDeletedItemMetadata(affected *schema.Affected, params *deletedItemParams) { | ||
| componentSection := *params.componentSection | ||
|
|
||
| // Extract vars section and use GetContextFromVars for consistency with the rest of the codebase. | ||
| varsSection, ok := componentSection[cfg.VarsSectionName].(map[string]any) | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| // Use cfg.GetContextFromVars for consistency with spacelift_utils.go and atlantis_utils.go. | ||
| context := cfg.GetContextFromVars(varsSection) | ||
|
|
||
| // Populate context metadata fields. | ||
| affected.Namespace = context.Namespace | ||
| affected.Tenant = context.Tenant | ||
| affected.Environment = context.Environment | ||
| affected.Stage = context.Stage | ||
|
|
||
| // For Terraform components, also populate Spacelift stack and Atlantis project names. | ||
| if params.componentType == cfg.TerraformComponentType { | ||
| populateDeletedItemIntegrations(affected, params, varsSection, componentSection) | ||
| } | ||
| } | ||
|
|
||
| // populateDeletedItemIntegrations populates Spacelift and Atlantis names for deleted Terraform components. | ||
| func populateDeletedItemIntegrations( | ||
| affected *schema.Affected, | ||
| params *deletedItemParams, | ||
| varsSection map[string]any, | ||
| componentSection map[string]any, | ||
| ) { | ||
| settingsSection, _ := componentSection[cfg.SettingsSectionName].(map[string]any) | ||
|
|
||
| configAndStacksInfo := schema.ConfigAndStacksInfo{ | ||
| ComponentFromArg: params.componentName, | ||
| Stack: params.stackName, | ||
| ComponentVarsSection: varsSection, | ||
| ComponentSettingsSection: settingsSection, | ||
| ComponentSection: map[string]any{ | ||
| cfg.VarsSectionName: varsSection, | ||
| cfg.SettingsSectionName: settingsSection, | ||
| }, | ||
| } | ||
|
|
||
| // Build Spacelift stack name (ignore errors - field is optional). | ||
| if spaceliftStackName, err := BuildSpaceliftStackNameFromComponentConfig(params.atmosConfig, configAndStacksInfo); err == nil { | ||
| affected.SpaceliftStack = spaceliftStackName | ||
| } | ||
|
|
||
| // Build Atlantis project name (ignore errors - field is optional). | ||
| if atlantisProjectName, err := BuildAtlantisProjectNameFromComponentConfig(params.atmosConfig, configAndStacksInfo); err == nil { | ||
| affected.AtlantisProject = atlantisProjectName | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.