matsuri 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. checksums.yaml +5 -5
  2. data/.envrc +1 -0
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +9 -0
  5. data/AGENTS.md +18 -0
  6. data/CHANGELOG.md +642 -0
  7. data/Gemfile +2 -0
  8. data/README.md +86 -8
  9. data/Rakefile +20 -0
  10. data/bin/console +1 -0
  11. data/bin/matsuri +6 -0
  12. data/bin/setup +2 -0
  13. data/docs/acceptors.md +181 -0
  14. data/docs/boot.md +101 -0
  15. data/docs/design/acceptors.md +157 -0
  16. data/docs/design/boot.md +131 -0
  17. data/docs/design/ssa.md +142 -0
  18. data/docs/design/stacks.md +153 -0
  19. data/docs/diff.md +85 -0
  20. data/docs/known-issues.md +19 -0
  21. data/docs/local-dev.md +173 -0
  22. data/docs/presenters.md +317 -0
  23. data/docs/resources.md +121 -0
  24. data/docs/setup.md +97 -0
  25. data/docs/ssa.md +149 -0
  26. data/docs/stacks.md +146 -0
  27. data/flake.lock +61 -0
  28. data/flake.nix +35 -0
  29. data/lib/matsuri/acceptor.rb +33 -0
  30. data/lib/matsuri/acceptors/image_tag.rb +15 -0
  31. data/lib/matsuri/cmd.rb +46 -0
  32. data/lib/matsuri/cmds/apply.rb +78 -0
  33. data/lib/matsuri/cmds/cli.rb +147 -0
  34. data/lib/matsuri/cmds/create.rb +87 -0
  35. data/lib/matsuri/cmds/delete.rb +86 -0
  36. data/lib/matsuri/cmds/diff.rb +105 -0
  37. data/lib/matsuri/cmds/generate.rb +24 -0
  38. data/lib/matsuri/cmds/kubectl.rb +16 -0
  39. data/lib/matsuri/cmds/recreate.rb +65 -0
  40. data/lib/matsuri/cmds/scale.rb +37 -0
  41. data/lib/matsuri/cmds/show.rb +123 -0
  42. data/lib/matsuri/cmds/status.rb +67 -0
  43. data/lib/matsuri/concerns/awaiting.rb +24 -0
  44. data/lib/matsuri/concerns/diffable.rb +135 -0
  45. data/lib/matsuri/concerns/export_manifest.rb +56 -0
  46. data/lib/matsuri/concerns/import_manifest.rb +64 -0
  47. data/lib/matsuri/concerns/pod_template.rb +37 -0
  48. data/lib/matsuri/concerns/pods/spread_pods.rb +47 -0
  49. data/lib/matsuri/concerns/pods/spread_topology.rb +63 -0
  50. data/lib/matsuri/concerns/registry_helpers.rb +59 -0
  51. data/lib/matsuri/config.rb +101 -0
  52. data/lib/matsuri/core_ext.rb +107 -0
  53. data/lib/matsuri/dsl/cluster/aggregated_cluster_role.rb +75 -0
  54. data/lib/matsuri/dsl/cluster/binding.rb +118 -0
  55. data/lib/matsuri/dsl/cluster/cluster_role.rb +41 -0
  56. data/lib/matsuri/dsl/cluster/role.rb +49 -0
  57. data/lib/matsuri/dsl/cluster/scope.rb +245 -0
  58. data/lib/matsuri/dsl/cluster/service_account.rb +76 -0
  59. data/lib/matsuri/dsl/concerns/default_binding.rb +34 -0
  60. data/lib/matsuri/dsl/concerns/manifest_set.rb +21 -0
  61. data/lib/matsuri/dsl/concerns/metadata.rb +56 -0
  62. data/lib/matsuri/dsl/concerns/rbac_rules.rb +39 -0
  63. data/lib/matsuri/initializer.rb +41 -0
  64. data/lib/matsuri/kubernetes/base.rb +107 -0
  65. data/lib/matsuri/kubernetes/config_map.rb +100 -0
  66. data/lib/matsuri/kubernetes/cron_job.rb +115 -0
  67. data/lib/matsuri/kubernetes/daemon_set.rb +62 -0
  68. data/lib/matsuri/kubernetes/deployment.rb +65 -0
  69. data/lib/matsuri/kubernetes/endpoints.rb +57 -0
  70. data/lib/matsuri/kubernetes/horizontal_pod_autoscaler.rb +116 -0
  71. data/lib/matsuri/kubernetes/imported_manifest.rb +61 -0
  72. data/lib/matsuri/kubernetes/ingress.rb +66 -0
  73. data/lib/matsuri/kubernetes/persistent_volume.rb +56 -0
  74. data/lib/matsuri/kubernetes/persistent_volume_claim.rb +41 -0
  75. data/lib/matsuri/kubernetes/pod.rb +355 -0
  76. data/lib/matsuri/kubernetes/replica_set.rb +46 -0
  77. data/lib/matsuri/kubernetes/secret.rb +86 -0
  78. data/lib/matsuri/kubernetes/service.rb +49 -0
  79. data/lib/matsuri/kubernetes/stateful_set.rb +75 -0
  80. data/lib/matsuri/kubernetes/storage_class.rb +45 -0
  81. data/lib/matsuri/platform.rb +38 -0
  82. data/lib/matsuri/presenter.rb +47 -0
  83. data/lib/matsuri/presenters/helpers.rb +39 -0
  84. data/lib/matsuri/presenters/prune_empty.rb +59 -0
  85. data/lib/matsuri/presenters/seed_image.rb +28 -0
  86. data/lib/matsuri/presenters/seed_replicas.rb +25 -0
  87. data/lib/matsuri/presenters/seed_resources.rb +29 -0
  88. data/lib/matsuri/registry.rb +267 -0
  89. data/lib/matsuri/shell_out.rb +119 -0
  90. data/lib/matsuri/stack.rb +276 -0
  91. data/lib/matsuri/task.rb +20 -0
  92. data/lib/matsuri/tasks/cluster.rb +79 -0
  93. data/lib/matsuri/tasks/docker.rb +25 -0
  94. data/lib/matsuri/tasks/hash_diff.rb +61 -0
  95. data/lib/matsuri/tasks/kubectl.rb +21 -0
  96. data/lib/matsuri/tasks/kubectl_ops/apply.rb +24 -0
  97. data/lib/matsuri/tasks/kubectl_ops/create.rb +27 -0
  98. data/lib/matsuri/tasks/kubectl_ops/delete.rb +21 -0
  99. data/lib/matsuri/tasks/kubectl_ops/diff.rb +47 -0
  100. data/lib/matsuri/tasks/kubectl_ops/get.rb +36 -0
  101. data/lib/matsuri/tasks/kubectl_ops/recreate.rb +35 -0
  102. data/lib/matsuri/tasks/kubectl_ops/replace.rb +29 -0
  103. data/lib/matsuri/tasks/kubectl_ops/rollout_status.rb +22 -0
  104. data/lib/matsuri/tasks/kubectl_ops/scale.rb +22 -0
  105. data/lib/matsuri/tasks/kubectl_ops/server_side_apply.rb +32 -0
  106. data/lib/matsuri/tasks/kubectl_ops/status.rb +20 -0
  107. data/lib/matsuri/tasks/kubectl_ops.rb +218 -0
  108. data/lib/matsuri/tasks/pod.rb +56 -0
  109. data/lib/matsuri/tasks/stacks/seed.rb +93 -0
  110. data/lib/matsuri/tasks/stacks.rb +31 -0
  111. data/lib/matsuri/transform/algebra.rb +52 -0
  112. data/lib/matsuri/transform/callable.rb +54 -0
  113. data/lib/matsuri/transform/combinators.rb +61 -0
  114. data/lib/matsuri/transform/env.rb +43 -0
  115. data/lib/matsuri/transform/kubernetes.rb +89 -0
  116. data/lib/matsuri/transform.rb +20 -0
  117. data/lib/matsuri/version.rb +3 -1
  118. data/lib/matsuri.rb +158 -2
  119. data/matsuri.gemspec +20 -7
  120. data/tests/bin/minikube-start.sh +27 -0
  121. data/tests/bin/probe-save-config.sh +46 -0
  122. data/tests/bin/probe-ssa.sh +206 -0
  123. data/tests/example/.gitignore +1 -0
  124. data/tests/example/Gemfile +7 -0
  125. data/tests/example/README.md +91 -0
  126. data/tests/example/bin/matsuri +14 -0
  127. data/tests/example/config/initializer.rb +12 -0
  128. data/tests/example/config/matsuri.rb +9 -0
  129. data/tests/example/config/platform.rb +39 -0
  130. data/tests/example/lib/acme/nodes/dedicated.rb +26 -0
  131. data/tests/example/lib/acme/nodes/preemptible.rb +17 -0
  132. data/tests/example/lib/acme/nodes/required_cores.rb +26 -0
  133. data/tests/example/lib/acme/nodes/resource_class.rb +26 -0
  134. data/tests/example/lib/acme/pods/cacerts.rb +15 -0
  135. data/tests/example/lib/acme/pods/healthcheck.rb +36 -0
  136. data/tests/example/lib/acme.rb +26 -0
  137. data/tests/example/platform/config_maps/billing-settings.rb +8 -0
  138. data/tests/example/platform/config_maps/cacerts.rb +9 -0
  139. data/tests/example/platform/deployments/billing-web.rb +5 -0
  140. data/tests/example/platform/deployments/checkout-web.rb +6 -0
  141. data/tests/example/platform/namespaces/billing-dev.yaml +7 -0
  142. data/tests/example/platform/namespaces/checkout-dev.yaml +7 -0
  143. data/tests/example/platform/pods/billing.rb +31 -0
  144. data/tests/example/platform/pods/checkout.rb +27 -0
  145. data/tests/example/platform/services/billing-web.rb +6 -0
  146. data/tests/example/platform/services/checkout-web.rb +4 -0
  147. data/tests/example/platform/stacks/billing.rb +8 -0
  148. data/tests/example/platform/stacks/checkout.rb +19 -0
  149. data/tests/k8s/save-config/deployment-declared.yaml +19 -0
  150. data/tests/k8s/save-config/deployment-seeded.yaml +20 -0
  151. data/tests/k8s/save-config/namespace.yaml +4 -0
  152. data/tests/k8s/ssa/declared-bumped.yaml +22 -0
  153. data/tests/k8s/ssa/declared.yaml +22 -0
  154. data/tests/k8s/ssa/namespace.yaml +4 -0
  155. data/tests/k8s/ssa/seed-via-create.yaml +24 -0
  156. metadata +288 -20
  157. data/.travis.yml +0 -4
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ module KubectlOps
6
+ # Full replacement of a live object with a rendered payload.
7
+ #
8
+ # One value of the apply_via delivery axis, mutually exclusive with
9
+ # apply and server-side apply: a resource chooses it with
10
+ # `let(:apply_via) { :replace }`. No built-in
11
+ # declares it; the cost of choosing it is that replace clobbers
12
+ # fields other writers own and never consults
13
+ # last-applied-configuration — so apply is the default for a reason.
14
+ class Replace
15
+ include Matsuri::Task
16
+
17
+ let(:input) { options.fetch(:input) }
18
+ let(:namespace) { options.fetch(:namespace) }
19
+ let(:description) { options.fetch(:description) }
20
+
21
+ def run!
22
+ puts "Replacing #{description}".color(:yellow).bright if verbose
23
+ puts input if debug
24
+ kubectl! 'replace -f -', input: input
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ module KubectlOps
6
+ # Watches a workload's rollout to completion (kubectl rollout
7
+ # status) — the post-apply progress watch for manually delivered
8
+ # changes. Blocks until the rollout finishes or fails.
9
+ class RolloutStatus
10
+ include Matsuri::Task
11
+
12
+ let(:resource_type) { options.fetch(:resource_type) }
13
+ let(:name) { options.fetch(:name) }
14
+ let(:namespace) { options.fetch(:namespace) }
15
+
16
+ def run!
17
+ kubectl! "rollout status #{resource_type}/#{name}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ module KubectlOps
6
+ # Scales a live workload addressed by type/name.
7
+ class Scale
8
+ include Matsuri::Task
9
+
10
+ let(:resource_type) { options.fetch(:resource_type) }
11
+ let(:name) { options.fetch(:name) }
12
+ let(:namespace) { options.fetch(:namespace) }
13
+ let(:replicas) { options.fetch(:replicas) }
14
+
15
+ def run!
16
+ Matsuri.log :info, "Scaling #{resource_type}/#{name} to #{replicas}".color(:yellow).bright
17
+ kubectl! "scale --replicas=#{replicas} #{resource_type}/#{name}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ module KubectlOps
6
+ # Server-side apply of a rendered manifest payload: the apiserver
7
+ # merges by field ownership under the given field manager instead
8
+ # of the client's three-way last-applied merge.
9
+ #
10
+ # One value of the apply_via delivery axis
11
+ # (`let(:apply_via) { :server_side_apply }`). Never passes
12
+ # --force-conflicts: a conflict marks the frontier where another
13
+ # manager owns a field this declaration sends, and forcing through
14
+ # it is an incident-time human judgment — the escape hatch is raw
15
+ # kubectl, not a flag here.
16
+ class ServerSideApply
17
+ include Matsuri::Task
18
+
19
+ let(:input) { options.fetch(:input) }
20
+ let(:namespace) { options.fetch(:namespace) }
21
+ let(:description) { options.fetch(:description) }
22
+ let(:field_manager) { options.fetch(:field_manager) }
23
+
24
+ def run!
25
+ puts "Applying (server-side) #{description}".color(:yellow).bright if verbose
26
+ puts input if debug
27
+ kubectl! "apply --server-side --field-manager=#{field_manager} -f -", input: input
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ module KubectlOps
6
+ # Displays the live state of an object addressed by type/name.
7
+ class Status
8
+ include Matsuri::Task
9
+
10
+ let(:resource_type) { options.fetch(:resource_type) }
11
+ let(:name) { options.fetch(:name) }
12
+ let(:namespace) { options.fetch(:namespace) }
13
+
14
+ def run!
15
+ kubectl! "get #{resource_type}/#{name} -o wide"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ # Delivery mechanism, scoped to kubectl shell-outs. One task per
6
+ # verb+scenario; resources stay pure generators and the CLI selects
7
+ # the task. A different delivery mechanism (direct API calls) would
8
+ # be a sibling namespace, not a branch inside these.
9
+ #
10
+ # The module-level functions are the boundary: the single site that
11
+ # derives task inputs from a rendered resource and reads its delivery
12
+ # policy lets (apply_via, converge_via, save_config). Present
13
+ # declarations ride in as pure data (scenario => member list) and the
14
+ # verb assembles its own scenario's pipeline between the resource and
15
+ # the task: create-regime verbs assemble :on_create, ongoing-delivery
16
+ # verbs assemble :on_apply. The empty pipeline is the identity, so
17
+ # payloads without declarations are byte-identical to the rendered
18
+ # manifest.
19
+ module KubectlOps
20
+ autoload :Apply, 'matsuri/tasks/kubectl_ops/apply'
21
+ autoload :Create, 'matsuri/tasks/kubectl_ops/create'
22
+ autoload :Replace, 'matsuri/tasks/kubectl_ops/replace'
23
+ autoload :ServerSideApply, 'matsuri/tasks/kubectl_ops/server_side_apply'
24
+ autoload :Delete, 'matsuri/tasks/kubectl_ops/delete'
25
+ autoload :Recreate, 'matsuri/tasks/kubectl_ops/recreate'
26
+ autoload :Scale, 'matsuri/tasks/kubectl_ops/scale'
27
+ autoload :Status, 'matsuri/tasks/kubectl_ops/status'
28
+ autoload :RolloutStatus, 'matsuri/tasks/kubectl_ops/rollout_status'
29
+ autoload :Get, 'matsuri/tasks/kubectl_ops/get'
30
+ autoload :Diff, 'matsuri/tasks/kubectl_ops/diff'
31
+
32
+ module_function
33
+
34
+ # Under server-side delivery, a resource that is not yet live
35
+ # routes through the seeding protocol instead: an ordinary apply
36
+ # cannot land seeds, and nothing we send may ever own them. Every
37
+ # later apply is the ordinary mechanism. field_manager rides to
38
+ # every mechanism; only the server-side task reads it.
39
+ def apply(resource, presenters: {})
40
+ return Stacks.seed(resource, presenters: presenters) if server_side?(resource.apply_via) && !exists?(resource)
41
+
42
+ apply_task_for(resource.apply_via)
43
+ .new(input: apply_payload(resource, presenters),
44
+ namespace: resource.namespace,
45
+ description: description_for(resource),
46
+ field_manager: resource.field_manager).run!
47
+ end
48
+
49
+ # Routing for the apply_via delivery axis. Unknown values are
50
+ # fatal — a silent fall-through to client-side apply would deliver
51
+ # under the wrong merge semantics.
52
+ def apply_task_for(apply_via)
53
+ case apply_via
54
+ when :apply then Apply
55
+ when :replace then Replace
56
+ when :server_side_apply then ServerSideApply
57
+ else fail ArgumentError, "unknown apply_via: #{apply_via.inspect}"
58
+ end
59
+ end
60
+
61
+ def server_side?(apply_via)
62
+ apply_task_for(apply_via) == ServerSideApply
63
+ end
64
+
65
+ # Whether ongoing delivery under this apply_via consults the
66
+ # last-applied-configuration annotation. Gates create/recreate's
67
+ # --save-config: seeding last-applied when nothing consults it arms
68
+ # a prune trap — a later client-side apply would three-way-merge
69
+ # against the create payload, seeds included, and prune the seeds.
70
+ def consults_last_applied?(apply_via)
71
+ case apply_via
72
+ when :apply then true
73
+ when :replace, :server_side_apply then false
74
+ else fail ArgumentError, "unknown apply_via: #{apply_via.inspect}"
75
+ end
76
+ end
77
+
78
+ def create(resource, presenters: {})
79
+ creation_task_for(resource, presenters).run!
80
+ end
81
+
82
+ # Bringing into existence: plain create for client-side delivery,
83
+ # the seeding protocol for server-side. Returned unrun so recreate
84
+ # can sequence it after teardown.
85
+ def creation_task_for(resource, presenters)
86
+ if server_side?(resource.apply_via)
87
+ Stacks.seed_task(resource, presenters: presenters)
88
+ else
89
+ Create.new(input: payload(resource.manifest_for_export, presenters_for(resource, presenters), :on_create),
90
+ namespace: resource.namespace,
91
+ description: description_for(resource),
92
+ save_config: consults_last_applied?(resource.apply_via) && resource.save_config)
93
+ end
94
+ end
95
+
96
+ def delete(resource)
97
+ Delete.new(identity_for(resource)).run!
98
+ end
99
+
100
+ def recreate(resource, presenters: {})
101
+ Recreate.new(identity_for(resource)
102
+ .merge(description: description_for(resource),
103
+ create: creation_task_for(resource, presenters))).run!
104
+ end
105
+
106
+ def scale(resource, replicas)
107
+ Scale.new(identity_for(resource).merge(replicas: replicas)).run!
108
+ end
109
+
110
+ def status(resource)
111
+ Status.new(identity_for(resource)).run!
112
+ end
113
+
114
+ def rollout_status(resource)
115
+ RolloutStatus.new(identity_for(resource)).run!
116
+ end
117
+
118
+ def exists?(resource)
119
+ Get.new(identity_for(resource)).exists?
120
+ end
121
+
122
+ # Current live manifest as a plain parsed hash; nil when the resource
123
+ # is not on the cluster.
124
+ def manifest(resource)
125
+ Get.new(identity_for(resource)).manifest
126
+ end
127
+
128
+ # Server-side diff of the same payload apply would send: apply_payload
129
+ # is one site, so the diff can never drift from the delivery — the
130
+ # invariant extends through presenters. A server-side resource diffs
131
+ # --server-side under the same field manager apply uses — a different
132
+ # manager would preview a different conflict frontier. Conflicts
133
+ # surface as errors (exit 2) and stay loud.
134
+ def diff(resource, presenters: {})
135
+ Diff.new(input: apply_payload(resource, presenters),
136
+ namespace: resource.namespace,
137
+ description: description_for(resource),
138
+ server_side: server_side?(resource.apply_via),
139
+ field_manager: resource.field_manager).run!
140
+ end
141
+
142
+ # Lifecycle selection: the resource's converge_via policy picks the
143
+ # task; the conditional lives here once, not as behavior flags
144
+ # inside a task.
145
+ def converge(resource, opts = {}, presenters: {})
146
+ verbose = Matsuri::Config.verbose
147
+
148
+ case resource.converge_via
149
+ when :recreate
150
+ puts "Converging #{description_for(resource)} via recreate".color(:yellow) if verbose
151
+ puts 'Rebuild not implemented. Recreating instead.'.color(:red).bright if opts[:rebuild]
152
+
153
+ if opts[:restart] || opts[:rebuild]
154
+ recreate(resource, presenters: presenters)
155
+ else
156
+ create(resource, presenters: presenters) unless exists?(resource)
157
+ end
158
+ else
159
+ puts "Converging #{description_for(resource)} via apply".color(:yellow) if verbose
160
+ puts 'Rebuild not implemented. Applying instead.'.color(:red).bright if opts[:rebuild]
161
+ apply(resource, presenters: presenters)
162
+ end
163
+ end
164
+
165
+ def apply_payload(resource, presenters)
166
+ payload(resource.applied_manifest_for_export, presenters_for(resource, presenters), :on_apply)
167
+ end
168
+
169
+ # Single merge, both declaration sites: the resource's own members
170
+ # first (its pipeline completes its self-knowledge), stack members
171
+ # appended (the boundary's steps admit the finished manifest).
172
+ # Direct verbs arrive with no stack declarations, so a resource
173
+ # behaves identically converged directly or through an undeclared
174
+ # stack.
175
+ def presenters_for(resource, presenters)
176
+ own = resource.class.respond_to?(:presenters) ? resource.class.presenters || {} : {}
177
+ (own.keys + presenters.keys).map(&:to_sym).uniq.each_with_object({}) do |scenario, merged|
178
+ merged[scenario] = Array(own[scenario]) + Array(presenters[scenario])
179
+ end
180
+ end
181
+
182
+ def payload(manifest, presenters, scenario)
183
+ serialize(presentation(manifest, presenters, scenario))
184
+ end
185
+
186
+ # The scenario's pipeline over the rendered manifest. Multi-doc
187
+ # manifests (arrays) present per document, so match criteria select
188
+ # documents with the same addressing grammar import lookup uses.
189
+ # Presented output re-normalizes through deep_stringify_keys — the
190
+ # same rule *_for_export already applied — so Maps and symbol keys
191
+ # introduced by transforms never reach the payload.
192
+ def presentation(manifest, presenters, scenario)
193
+ pipeline = Matsuri::Transform::Callable.new(Array(presenters[scenario]))
194
+ return manifest if pipeline.members.empty?
195
+
196
+ if manifest.is_a?(Array)
197
+ manifest.map { |doc| pipeline.call(Map.new(doc)).deep_stringify_keys }
198
+ else
199
+ pipeline.call(Map.new(manifest)).deep_stringify_keys
200
+ end
201
+ end
202
+
203
+ # Multi-doc manifests serialize as concatenated YAML documents,
204
+ # matching the imported-manifest export rule.
205
+ def serialize(manifest)
206
+ manifest.is_a?(Array) ? manifest.map(&:to_yaml).join('') : manifest.to_yaml
207
+ end
208
+
209
+ def description_for(resource)
210
+ "#{resource.resource_type}/#{resource.name}"
211
+ end
212
+
213
+ def identity_for(resource)
214
+ { resource_type: resource.resource_type, name: resource.name, namespace: resource.namespace }
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ # Pod-scoped cluster reads for the local-dev lifecycle. Resources are
6
+ # pure generators and carry no verbs or cluster reads; this task takes
7
+ # identity and selectors as parameters instead.
8
+ class Pod
9
+ include Matsuri::Task
10
+
11
+ let(:name) { options.fetch(:name) }
12
+ let(:namespace) { options.fetch(:namespace) }
13
+ let(:selector) { options.fetch(:selector) }
14
+
15
+ # The flat label selector addressing a resource's pods: workloads
16
+ # (Deployment, StatefulSet, ReplicaSet, DaemonSet) declare
17
+ # match_labels; Service declares selector. Order matters — a workload
18
+ # also answers selector, with the nested matchLabels shape.
19
+ def self.pod_selector_for(resource)
20
+ return resource.match_labels if resource.respond_to?(:match_labels)
21
+ return resource.selector if resource.respond_to?(:selector)
22
+
23
+ raise ArgumentError, "#{resource.class} does not select pods"
24
+ end
25
+
26
+ # Running, with every container ready. Reads live state by name.
27
+ def up?
28
+ cmd = kubectl "get pod/#{name} -o json", echo_level: :debug, no_stdout: true
29
+ Matsuri.log :fatal, "Unable to get status for pod/#{name}" unless cmd.status.success?
30
+ pod_json = JSON.parse(cmd.stdout)
31
+ Matsuri.log :debug, pod_json['status']
32
+ return false unless pod_json['status']['phase'] == 'Running'
33
+ return false unless pod_json['status']['containerStatuses']&.any?
34
+
35
+ pod_json['status']['containerStatuses'].all? { |c| c['ready'] == true }
36
+ end
37
+
38
+ # Live pods matching the label selector (a flat labels hash).
39
+ def selected_pods_json
40
+ sel = selector.to_a.map { |(k, v)| "#{k}=#{v}" }.join(',')
41
+ cmd = kubectl "get pods -l #{sel} -o json", echo_level: :debug, no_stdout: true
42
+ JSON.parse(cmd.stdout)
43
+ end
44
+
45
+ def selected_pods
46
+ selected_pods_json['items']
47
+ end
48
+
49
+ def build!
50
+ end
51
+
52
+ def list!
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ module Stacks
6
+ # Creation protocol for resources delivered by server-side apply.
7
+ #
8
+ # A seeded field (the boot image, an initial replica count) must
9
+ # exist at birth but never rides the ongoing declaration — the
10
+ # declared payload omits it, so delivery never contests whoever
11
+ # rolls it. Server-side apply cannot create such an object safely
12
+ # on its own: an apply against empty managedFields attributes every
13
+ # pre-existing field to the synthetic manager before-first-apply,
14
+ # which never applies again and so never relinquishes — the first
15
+ # changed-value apply conflicts forever. The protocol below is the
16
+ # surviving creation path; it and the refuted alternatives are
17
+ # pinned by tests/bin/probe-ssa.sh.
18
+ #
19
+ # Three steps, each loud on failure:
20
+ # 1. create as <field_manager>-seed (the seed owner) with the
21
+ # seeded payload. The create stamps a manager regardless, so
22
+ # inject a known one: a converge that dies mid-protocol strands
23
+ # an object whose managedFields still read as ours. No
24
+ # --save-config — a seeded last-applied is a prune trap for any
25
+ # later client-side apply.
26
+ # 2. Ordinary declared server-side apply as the field manager.
27
+ # Equal values co-own; managedFields is never empty, so no
28
+ # before-first-apply ghost.
29
+ # 3. Cede: remove the seed owner's managedFields entry with an
30
+ # atomic test+remove JSON patch. Ownership IS the managedFields
31
+ # content — deleting the entry is the transfer. The field
32
+ # manager ends sole owner of the declared fields; seeds end
33
+ # unowned until a controller or deliverer takes them.
34
+ class Seed
35
+ include Matsuri::Task
36
+
37
+ REMOVAL_ATTEMPTS = 3
38
+
39
+ let(:seeded) { options.fetch(:seeded) }
40
+ let(:declared) { options.fetch(:declared) }
41
+ let(:resource_type) { options.fetch(:resource_type) }
42
+ let(:name) { options.fetch(:name) }
43
+ let(:namespace) { options.fetch(:namespace) }
44
+ let(:description) { options.fetch(:description, "#{resource_type}/#{name}") }
45
+ let(:field_manager) { options.fetch(:field_manager) }
46
+ let(:seed_owner) { "#{field_manager}-seed" }
47
+
48
+ def run!
49
+ puts "Seeding #{description}".color(:yellow).bright if verbose
50
+ create!
51
+ apply!
52
+ cede!
53
+ end
54
+
55
+ def create!
56
+ puts seeded if debug
57
+ kubectl! "create --field-manager=#{seed_owner} -f -", input: seeded
58
+ end
59
+
60
+ def apply!
61
+ KubectlOps::ServerSideApply.new(input: declared,
62
+ namespace: namespace,
63
+ description: description,
64
+ field_manager: field_manager).run!
65
+ end
66
+
67
+ def cede!
68
+ last = nil
69
+ REMOVAL_ATTEMPTS.times do
70
+ idx = seed_owner_index
71
+ Matsuri.log(:fatal, "#{description}: no managedFields entry for #{seed_owner}") if idx.nil?
72
+ last = kubectl "patch #{resource_type}/#{name} --type=json -p '#{removal_patch(idx)}'", no_stdout: true
73
+ return if last.status.success?
74
+ end
75
+ Matsuri.log :fatal, "#{description}: could not remove #{seed_owner} after #{REMOVAL_ATTEMPTS} attempts\n#{last.stderr}"
76
+ end
77
+
78
+ # Fresh read per attempt: entry order is not stable, and the test
79
+ # op in the patch turns a stale index into a rejection, never a
80
+ # wrong removal.
81
+ def seed_owner_index
82
+ cmd = kubectl! "get #{resource_type}/#{name} --show-managed-fields -o json", echo_level: :debug, no_stdout: true
83
+ JSON.parse(cmd.stdout)['metadata'].fetch('managedFields', []).index { |entry| entry['manager'] == seed_owner }
84
+ end
85
+
86
+ def removal_patch(idx)
87
+ [{ op: 'test', path: "/metadata/managedFields/#{idx}/manager", value: seed_owner },
88
+ { op: 'remove', path: "/metadata/managedFields/#{idx}" }].to_json
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Tasks
5
+ # Stack-scope delivery acts: protocols that sequence the kubectl
6
+ # verbs into one intent. KubectlOps maps one verb to one task; these
7
+ # compose them.
8
+ module Stacks
9
+ autoload :Seed, 'matsuri/tasks/stacks/seed'
10
+
11
+ module_function
12
+
13
+ # Creation protocol for a server-side-applied resource: seeded
14
+ # create, declared apply, cede. See Seed.
15
+ def seed(resource, presenters: {})
16
+ seed_task(resource, presenters: presenters).run!
17
+ end
18
+
19
+ # The constructed, unrun task — recreate sequences it after
20
+ # teardown.
21
+ def seed_task(resource, presenters: {})
22
+ ops = Matsuri::Tasks::KubectlOps
23
+ Seed.new(ops.identity_for(resource).merge(
24
+ seeded: ops.payload(resource.manifest_for_export, ops.presenters_for(resource, presenters), :on_create),
25
+ declared: ops.apply_payload(resource, presenters),
26
+ description: ops.description_for(resource),
27
+ field_manager: resource.field_manager))
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Transform
5
+ # Path-addressed ops over manifest data. A contribution here is a value:
6
+ # each op takes the carrier and returns it, and pipelines compose by +
7
+ # over those values — never by super()-chained deltas (let-bound
8
+ # definitions cannot call super structurally, so the chained style does
9
+ # not exist here). Ops write through Map#set; the single defensive copy
10
+ # belongs to the Presenter boundary, not to this algebra.
11
+ module Algebra
12
+ extend self
13
+
14
+ # Find a deeply nested key, and update it with the return
15
+ # value of the transform function
16
+ def update_in(map, path, &f)
17
+ map.set(path, f.(map.get(path)))
18
+ end
19
+
20
+ def append_to(map, path, entry)
21
+ existing = map.get(path)
22
+ raise PathNotFound, "nothing at #{Array(path).join('.')} to append to" if existing.nil?
23
+
24
+ map.set(path, existing + [Map.new(entry)])
25
+ end
26
+
27
+ # Given an array of hashes, update values in a path within
28
+ # each element of the array using the transform function
29
+ def update_each(arr, path, &f)
30
+ arr.map { |term| update_in(term, path, &f) }
31
+ end
32
+
33
+ # Given an array of arrays, append a term to each of the arrays within
34
+ # the array
35
+ def append_each(a, term)
36
+ a.map { |l| l + [term] }
37
+ end
38
+
39
+ def index_by_name(a, name)
40
+ a.index { |entry| entry['name'] == name }
41
+ end
42
+
43
+ def index_by_name!(a, name)
44
+ index_by_name(a, name) || raise(EntryNotFound, "no entry named #{name.inspect}")
45
+ end
46
+
47
+ def except_entry_by_name(arr, name:)
48
+ arr.reject { |x| x['name'] == name }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matsuri
4
+ module Transform
5
+ # The free monoid over manifest transforms. A Callable holds an ordered
6
+ # member list and #call reduces the manifest through it. new = identity,
7
+ # new(label:) { ... } = leaf, new([a, b]) = composite. + concatenates the
8
+ # operands' member lists into a new instance, so identity and
9
+ # associativity hold on the nose. Members are never flattened: a
10
+ # composite passed as a member stays one member, and label rides along
11
+ # as trace metadata.
12
+ #
13
+ # Coercion happens at construction and fails fast: anything #call-able is
14
+ # taken as-is; a Symbol or String names a registered presenter.
15
+ class Callable
16
+ attr_reader :members, :label
17
+
18
+ def initialize(members = nil, label: nil, &blk)
19
+ raise ArgumentError, 'pass members or a block, not both' if blk && members
20
+
21
+ @label = label
22
+ @members =
23
+ if blk
24
+ [blk]
25
+ elsif members.nil?
26
+ []
27
+ else
28
+ Array(members).map { |m| coerce(m) }
29
+ end
30
+ end
31
+
32
+ def +(other)
33
+ other_members = other.is_a?(Callable) ? other.members : [coerce(other)]
34
+ self.class.new(members + other_members)
35
+ end
36
+
37
+ def call(manifest)
38
+ members.reduce(manifest) { |m, member| member.call(m) }
39
+ end
40
+
41
+ private
42
+
43
+ def coerce(member)
44
+ case member
45
+ when Symbol, String then Matsuri::Registry.presenter(member)
46
+ else
47
+ return member if member.respond_to?(:call)
48
+
49
+ raise ArgumentError, "cannot compose #{member.inspect}: not #call-able or a presenter name"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end