Class: Pangea::CLI::Cascade

Inherits:
Object show all
Defined in:
lib/pangea/cli/cascade.rb

Overview

Cascade — when the user runs plan/apply/destroy on a template that participates in reactive relationships (declared in pangea.yml’s reactivity.asks block), automatically include every transitively reachable workspace, in the right order:

- plan  : topological (upstream first, downstream after)
- apply : topological (upstream must exist before downstream reads)
- destroy: reverse topological (tear down leaves before roots)

Each workspace’s phase emits a themed section divider plus a structured summary line. Set PANGEA_NO_CASCADE=1 to fall back to single-workspace behavior even when a cascade would otherwise fire.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(graph:, ordered_names:, seed_name:, seed_template_file:, max_depth: nil) ⇒ Cascade

Returns a new instance of Cascade.



142
143
144
145
146
147
148
149
# File 'lib/pangea/cli/cascade.rb', line 142

def initialize(graph:, ordered_names:, seed_name:, seed_template_file:, max_depth: nil)
  @graph = graph
  @ordered_names = ordered_names
  @seed_name = seed_name
  @seed_template_file = seed_template_file
  @max_depth = max_depth
  @outcomes = []
end

Instance Attribute Details

#graphObject (readonly)

Returns the value of attribute graph.



23
24
25
# File 'lib/pangea/cli/cascade.rb', line 23

def graph
  @graph
end

#max_depthObject (readonly)

Returns the value of attribute max_depth.



23
24
25
# File 'lib/pangea/cli/cascade.rb', line 23

def max_depth
  @max_depth
end

#ordered_namesObject (readonly)

Returns the value of attribute ordered_names.



23
24
25
# File 'lib/pangea/cli/cascade.rb', line 23

def ordered_names
  @ordered_names
end

#outcomesObject (readonly)

Returns the value of attribute outcomes.



23
24
25
# File 'lib/pangea/cli/cascade.rb', line 23

def outcomes
  @outcomes
end

#seed_nameObject (readonly)

Returns the value of attribute seed_name.



23
24
25
# File 'lib/pangea/cli/cascade.rb', line 23

def seed_name
  @seed_name
end

#seed_template_fileObject (readonly)

Returns the value of attribute seed_template_file.



23
24
25
# File 'lib/pangea/cli/cascade.rb', line 23

def seed_template_file
  @seed_template_file
end

Class Method Details

.enabled?Boolean

Returns:

  • (Boolean)


26
27
28
# File 'lib/pangea/cli/cascade.rb', line 26

def self.enabled?
  ENV['PANGEA_NO_CASCADE'] != '1'
end

.filter_by_platform_scope(names, workspaces_root:, seed_name:) ⇒ Object

Intersect the cascade with the set of workspaces the current platform actually uses. Keeps the seed even if the platform doesn’t declare it (defensive — the user explicitly asked to plan/apply that workspace).



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/pangea/cli/cascade.rb', line 90

def self.filter_by_platform_scope(names, workspaces_root:, seed_name:)
  platform_name = ENV['PLATFORM']
  return names if platform_name.nil? || platform_name.empty?

  platforms_dir = File.expand_path('../platforms', workspaces_root)
  yml_path = File.join(platforms_dir, "#{platform_name}.yaml")
  return names unless File.exist?(yml_path)

  platform = YAML.safe_load(File.read(yml_path)) || {}
  layers = Array(platform['layers']).map(&:to_s)
  return names if layers.empty?

  allowed = layers.map { |layer| "platform-#{layer}" }.to_set
  allowed << seed_name
  Set.new(names.select { |n| allowed.include?(n) })
rescue StandardError
  names
end

.for_template(template_file, max_depth: nil) ⇒ Object

Build a cascade plan from a user-provided template file path. Returns nil if:

- cascade is disabled (PANGEA_NO_CASCADE=1)
- the template is not in a scannable constellation
- the resolved cascade has only the seed itself (depth 0, no
  reactive neighbors in range, or platform scope filters everyone
  but the seed)

Depth resolution (first match wins):

1. explicit `max_depth:` argument (CLI --depth N)
2. ENV['PANGEA_CASCADE_DEPTH']
3. workspace or root pangea.yml `cascade.default_depth`
4. nil (unlimited — the full transitive closure)

Platform scope: when ENV is set AND ‘constellation_root/platforms/platform.yaml` declares a `layers:` list, the cascade is intersected with that list so that workspaces the platform does not use are skipped. The seed is always included. Absent platform / file / layers key → no filter (full closure).



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/pangea/cli/cascade.rb', line 50

def self.for_template(template_file, max_depth: nil)
  return nil unless enabled?

  workspaces_root = Reactivity::Graph.workspaces_root_for(template_file)
  return nil unless workspaces_root

  graph = Reactivity::Graph.scan(workspaces_root)
  seed_dir = File.dirname(File.expand_path(template_file))
  seed_name = File.basename(seed_dir)

  return nil unless graph.include?(seed_name)

  depth = resolve_depth(
    explicit: max_depth,
    seed_dir: seed_dir,
    workspaces_root: workspaces_root,
  )

  cascade_names = graph.cascade_set(seed_name, max_depth: depth)
  cascade_names = filter_by_platform_scope(
    cascade_names,
    workspaces_root: workspaces_root,
    seed_name: seed_name,
  )
  return nil if cascade_names.size <= 1

  ordered = graph.topo_sort(cascade_names)
  new(
    graph: graph,
    ordered_names: ordered,
    seed_name: seed_name,
    seed_template_file: File.expand_path(template_file),
    max_depth: depth,
  )
end

.parse_int(value) ⇒ Object



122
123
124
125
126
127
128
129
130
131
# File 'lib/pangea/cli/cascade.rb', line 122

def self.parse_int(value)
  return nil if value.nil?
  return value.negative? ? nil : value if value.is_a?(Integer)
  return nil if value.to_s.strip.empty?

  i = Integer(value.to_s, 10)
  i.negative? ? nil : i
rescue ArgumentError, TypeError
  nil
end

.resolve_depth(explicit:, seed_dir:, workspaces_root:) ⇒ Object

Resolve the depth cap from CLI arg → env → workspace yml → root yml. Returns nil for unlimited. A non-numeric / negative value falls through to the next source.



112
113
114
115
116
117
118
119
120
# File 'lib/pangea/cli/cascade.rb', line 112

def self.resolve_depth(explicit:, seed_dir:, workspaces_root:)
  candidates = [
    explicit,
    parse_int(ENV['PANGEA_CASCADE_DEPTH']),
    yaml_depth(File.join(seed_dir, 'pangea.yml')),
    yaml_depth(File.join(File.dirname(workspaces_root), 'pangea.yml')),
  ]
  candidates.find { |d| !d.nil? }
end

.yaml_depth(path) ⇒ Object



133
134
135
136
137
138
139
140
# File 'lib/pangea/cli/cascade.rb', line 133

def self.yaml_depth(path)
  return nil unless File.exist?(path)

  config = YAML.safe_load(File.read(path)) || {}
  parse_int(config.dig('cascade', 'default_depth'))
rescue StandardError
  nil
end

Instance Method Details

#apply(namespace: nil) ⇒ Object

Apply in topological order; abort the cascade on the first failure since downstream reads assume upstream state.



160
161
162
163
# File 'lib/pangea/cli/cascade.rb', line 160

def apply(namespace: nil)
  announce(:apply)
  each_stage(ordered_names, primary: :apply, abort_on_failure: true) { |ops| ops.apply }
end

#destroy(namespace: nil) ⇒ Object

Destroy in reverse topological order.



166
167
168
169
# File 'lib/pangea/cli/cascade.rb', line 166

def destroy(namespace: nil)
  announce(:destroy)
  each_stage(ordered_names.reverse, primary: :destroy) { |ops| ops.destroy }
end

#plan(namespace: nil) ⇒ Object

Run plan across every cascaded workspace in topological order. Upstream fails → we continue anyway so the user sees what else drifts.



153
154
155
156
# File 'lib/pangea/cli/cascade.rb', line 153

def plan(namespace: nil)
  announce(:plan)
  each_stage(ordered_names, primary: :plan) { |ops| ops.plan }
end