Class: Pangea::CLI::Cascade
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
-
#graph ⇒ Object
readonly
Returns the value of attribute graph.
-
#max_depth ⇒ Object
readonly
Returns the value of attribute max_depth.
-
#ordered_names ⇒ Object
readonly
Returns the value of attribute ordered_names.
-
#outcomes ⇒ Object
readonly
Returns the value of attribute outcomes.
-
#seed_name ⇒ Object
readonly
Returns the value of attribute seed_name.
-
#seed_template_file ⇒ Object
readonly
Returns the value of attribute seed_template_file.
Class Method Summary collapse
- .enabled? ⇒ Boolean
-
.filter_by_platform_scope(names, workspaces_root:, seed_name:) ⇒ Object
Intersect the cascade with the set of workspaces the current platform actually uses.
-
.for_template(template_file, max_depth: nil) ⇒ Object
Build a cascade plan from a user-provided template file path.
- .parse_int(value) ⇒ Object
-
.resolve_depth(explicit:, seed_dir:, workspaces_root:) ⇒ Object
Resolve the depth cap from CLI arg → env → workspace yml → root yml.
- .yaml_depth(path) ⇒ Object
Instance Method Summary collapse
-
#apply(namespace: nil) ⇒ Object
Apply in topological order; abort the cascade on the first failure since downstream reads assume upstream state.
-
#destroy(namespace: nil) ⇒ Object
Destroy in reverse topological order.
-
#initialize(graph:, ordered_names:, seed_name:, seed_template_file:, max_depth: nil) ⇒ Cascade
constructor
A new instance of Cascade.
-
#plan(namespace: nil) ⇒ Object
Run plan across every cascaded workspace in topological order.
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
#graph ⇒ Object (readonly)
Returns the value of attribute graph.
23 24 25 |
# File 'lib/pangea/cli/cascade.rb', line 23 def graph @graph end |
#max_depth ⇒ Object (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_names ⇒ Object (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 |
#outcomes ⇒ Object (readonly)
Returns the value of attribute outcomes.
23 24 25 |
# File 'lib/pangea/cli/cascade.rb', line 23 def outcomes @outcomes end |
#seed_name ⇒ Object (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_file ⇒ Object (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
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.('../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.(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.(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 |