Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions internal/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -1256,21 +1256,61 @@ func RemoveCycles(c component.Components) (component.Components, error) {
cfg *component.Component
)

// Repeatedly break cycles by removing outgoing dependency edges on the offending node,
// rather than removing the node entirely. This preserves all units for downstream queueing.
for range maxCycleRemovalAttempts {
if cfg, err = c.CycleCheck(); err == nil {
break
// no cycles
return c, nil
}

// Cfg should never be nil if err is not nil,
// but we do this check to avoid a nil pointer dereference
// if our assumptions change in the future.
if cfg == nil {
break
// unexpected: cycle reported but no component; return existing error
return c, err
}

c = c.RemoveByPath(cfg.Path)
// Rebuild the graph, clearing dependencies originating from the offending node.
// 1) Create new nodes indexed by path.
nodes := make(map[string]*component.Component, len(c))
for _, n := range c {
nodes[n.Path] = &component.Component{
Parsed: n.Parsed,
DiscoveryContext: n.DiscoveryContext,
Kind: n.Kind,
Path: n.Path,
Reading: append([]string(nil), n.Reading...),
External: n.External,
}
}

// 2) Reconnect dependencies, skipping edges that originate from the offending node.
for _, n := range c {
if n.Path == cfg.Path {
// Drop all outgoing edges from this node to break the cycle
continue
}

if deps := n.Dependencies(); len(deps) > 0 {
src := nodes[n.Path]
for _, dep := range deps {
if dst, ok := nodes[dep.Path]; ok {
src.AddDependency(dst)
}
}
}
}

// 3) Replace components slice with rebuilt nodes (sorted for determinism)
rebuilt := make(component.Components, 0, len(nodes))
for _, nn := range nodes {
rebuilt = append(rebuilt, nn)
}

c = rebuilt.Sort()
// loop again to ensure no remaining cycles
}

// If we exit the loop with an error, return the best-effort graph and the error
return c, err
}

Expand Down
13 changes: 11 additions & 2 deletions internal/queue/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
package queue

import (
"errors"
"slices"
"sort"
"sync"
Expand Down Expand Up @@ -287,7 +286,17 @@ func NewQueue(discovered component.Components) (*Queue, error) {
}
}

return q, errors.New("cycle detected during queue construction")
// Cycle fallback: include all entries in a dependency-agnostic order (by path) and mark them ready.
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Component.Path < entries[j].Component.Path })

for _, e := range entries {
e.Status = StatusReady
}

q.Entries = entries
q.IgnoreDependencyOrder = true

return q, nil
}

// GetReadyWithDependencies returns all entries that are ready to run and have all dependencies completed (or no dependencies).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "cache_url" {
value = "ecr-cache-url"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
terraform {
source = "."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
variable "cluster_id" {
type = string
default = ""
}

output "baseline_status" {
value = "eks-baseline-${var.cluster_id}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
terraform {
source = "."
}

# Dependencies from which we may or may not consume outputs
dependencies {
paths = try(values.dependencies, [])
}

# CRITICAL: This dependency uses values.dependency_path which points to the GENERATED unit path
# This creates a cycle because:
# - eks-baseline (in stacks/k8s) depends on eks-cluster via values.dependency_path
# - values.dependency_path.eks-cluster = "./.terragrunt-stack/k8s/.terragrunt-stack/eks-cluster"
# - But eks-baseline itself gets generated to "./.terragrunt-stack/k8s/.terragrunt-stack/eks-baseline"
# This inter-stack dependency via generated paths triggers the cycle detection bug
dependency "eks-cluster" {
config_path = values.dependency_path.eks-cluster

mock_outputs = {
cluster_id = "mock-cluster-id"
}

mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs_allowed_terraform_commands = ["init", "validate", "destroy", "plan"]
}

inputs = {
cluster_id = dependency.eks-cluster.outputs.cluster_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "cluster_id" {
value = "eks-cluster-123"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
terraform {
source = "."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
variable "cluster_id" {
type = string
default = ""
}

output "grafana_status" {
value = "grafana-baseline-${var.cluster_id}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
terraform {
source = "."
}

# Dependencies from which we may or may not consume outputs
dependencies {
paths = try(values.dependencies, [])
}

dependency "eks-cluster" {
config_path = values.dependency_path.eks-cluster

mock_outputs = {
cluster_id = "mock-cluster-id"
}

mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs_allowed_terraform_commands = ["init", "validate", "destroy", "plan"]
}

inputs = {
cluster_id = dependency.eks-cluster.outputs.cluster_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "id" {
value = "id-123"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
terraform {
source = "."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
variable "bootstrap_status" {
type = string
default = ""
}

output "baseline_status" {
value = "rancher-baseline-${var.bootstrap_status}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
terraform {
source = "."
}

# Dependencies from which we may or may not consume outputs
dependencies {
paths = try(values.dependencies, [])
}

dependency "rancher-bootstrap" {
config_path = values.dependency_path.rancher-baseline

mock_outputs = {
bootstrap_status = "mock-bootstrap-status"
}

mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs_allowed_terraform_commands = ["init", "validate", "destroy", "plan"]
}

inputs = {
bootstrap_status = dependency.rancher-bootstrap.outputs.bootstrap_status
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
variable "cluster_id" {
type = string
default = ""
}

output "bootstrap_status" {
value = "rancher-bootstrap-${var.cluster_id}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
terraform {
source = "."
}

# Dependencies from which we may or may not consume outputs
dependencies {
paths = try(values.dependencies, [])
}

dependency "eks-cluster" {
config_path = values.dependency_path.eks-cluster

mock_outputs = {
cluster_id = "mock-cluster-id"
}

mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs_allowed_terraform_commands = ["init", "validate", "destroy", "plan"]
}

inputs = {
cluster_id = dependency.eks-cluster.outputs.cluster_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
variable "vpc_id" {
type = string
default = ""
}

output "router_id" {
value = "tailscale-router-${var.vpc_id}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
terraform {
source = "."
}

dependency "vpc" {
config_path = "../vpc"

mock_outputs = {
vpc_id = "mock-vpc-id"
}
}

inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
variable "vpc_id" {
type = string
default = ""
}

output "endpoint_id" {
value = "vpc-endpoint-${var.vpc_id}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
terraform {
source = "."
}

dependency "vpc" {
config_path = "../vpc"

mock_outputs = {
vpc_id = "mock-vpc-id"
}
}

inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
variable "vpc_id" {
type = string
default = ""
}

output "nat_id" {
value = "vpc-nat-${var.vpc_id}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
terraform {
source = "."
}

dependency "vpc" {
config_path = "../vpc"

mock_outputs = {
vpc_id = "mock-vpc-id"
}
}

inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "vpc_id" {
value = "vpc-123"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
terraform {
source = "."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@

locals {
stack_root = "${get_terragrunt_dir()}/.terragrunt-stack"

dependency_path = {
id = "${local.stack_root}/id"
ecr-cache = "${local.stack_root}/ecr-cache"
}
}

unit "id" {
path = "id"
source = "${get_repo_root()}/_source/units/id"

values = {
prefix = "aio"
}
}

unit "ecr-cache" {
path = "ecr-cache"
source = "${get_repo_root()}/_source/units/ecr-cache"

values = {
dependency_path = local.dependency_path
}
}

stack "network" {
path = "network"
source = "../stacks/network"

values = {
dependency_path = local.dependency_path
}
}

stack "k8s" {
path = "k8s"
source = "../stacks/k8s"

values = {
dependencies = [
"${local.stack_root}/network/.terragrunt-stack/vpc-nat",
"${local.stack_root}/network/.terragrunt-stack/tailscale-router",
]

dependency_path = merge(
local.dependency_path,
{
vpc = "${local.stack_root}/network/.terragrunt-stack/vpc",
eks-cluster = "${local.stack_root}/k8s/.terragrunt-stack/eks-cluster",
rancher-baseline = "${local.stack_root}/k8s/.terragrunt-stack/rancher-baseline",
}
)
}
}


Loading
Loading