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
data/docs/local-dev.md ADDED
@@ -0,0 +1,173 @@
1
+ # Local Development
2
+
3
+ Matsuri grew up as a local development harness: build an image, point
4
+ the pod at your working tree, converge, and iterate. These workflows are
5
+ still in the gem. This page documents them as they exist, along with
6
+ where the ecosystem has since produced better tools.
7
+
8
+ ## Docker Build and Push
9
+
10
+ A stack can own its image lifecycle. The `build` and `push` verbs drive
11
+ hooks that the stack definition supplies — the gem provides the wiring,
12
+ the platform provides the meaning:
13
+
14
+ ```bash
15
+ matsuri dev build myapp --version v42 --branch main
16
+ matsuri dev push myapp --version v42
17
+ ```
18
+
19
+ ```ruby
20
+ Matsuri.define :stack, 'myapp' do
21
+ def build!(opt = {})
22
+ docker! "build -t myapp:#{opt[:version] || 'latest'} #{config.docker_path}"
23
+ end
24
+
25
+ def push!(opt = {})
26
+ docker! "push myapp:#{opt[:version] || 'latest'}"
27
+ end
28
+ end
29
+ ```
30
+
31
+ Without a definition, both hooks fail with a message saying so; there is
32
+ no default build behavior. `build` accepts `--dev`, `--version`,
33
+ `--branch`, `--github-user`, and `--repo`; `push` accepts `--dev` and
34
+ `--version`. What the options mean is up to the hook.
35
+
36
+ Conventional paths for build inputs live in `Matsuri::Config`:
37
+
38
+ - `docker_path` (`docker/`) — Dockerfiles and build contexts
39
+ - `build_path` (`build/`) — build output
40
+ - `images_path` (`platform/images`) — image definitions
41
+
42
+ ## Live Mounts
43
+
44
+ The iterate-without-rebuilding loop: mount the working tree into the pod
45
+ with a hostPath volume, so edits on the host appear in the running
46
+ container. Pod definitions get path helpers that resolve against the
47
+ platform repo:
48
+
49
+ - `src_path('myapp')` — `src/myapp` under the repo; the working tree
50
+ - `cache_path('bundle')` — `.cache/bundle`; persistent caches for gems,
51
+ node_modules, and other artifacts that should outlive the pod
52
+ - `base_path('etc/myapp.conf')` — any path under the repo
53
+ - `host_path_volume(name, path)` — the hostPath volume entry
54
+
55
+ ```ruby
56
+ Matsuri.define :pod, 'myapp-dev' do
57
+ let(:volumes) do
58
+ [
59
+ host_path_volume('src', src_path('myapp')),
60
+ host_path_volume('gems', cache_path('bundle'))
61
+ ]
62
+ end
63
+
64
+ let(:container) do
65
+ {
66
+ name: 'myapp',
67
+ image: 'myapp:latest',
68
+ volumeMounts: [
69
+ { name: 'src', mountPath: '/app' },
70
+ { name: 'gems', mountPath: '/bundle' }
71
+ ]
72
+ }
73
+ end
74
+ end
75
+ ```
76
+
77
+ This pattern assumes the kubelet can reach the host filesystem — true
78
+ for single-node local clusters where the node is your machine (or a VM
79
+ with the repo mounted into it). On Docker Desktop for Mac, hostPath file
80
+ I/O crosses the VM boundary and has historically been slow enough to
81
+ defeat the purpose; Linux with a local cluster is where this loop
82
+ shines.
83
+
84
+ ## Interactive Verbs: sh, console, status
85
+
86
+ > **Deprecation notice**: [k9s](https://k9scli.io/) is a better tool for
87
+ > all of this — live resource views, shell into any container, logs,
88
+ > port-forwards, and resource metrics in one TUI, without any stack
89
+ > definition support. These verbs may be deprecated in a future release.
90
+
91
+ `sh` and `console` shell into a stack's primary workload. Like `build!`,
92
+ they drive hooks the stack definition supplies — `sh!(opt, args)` and
93
+ `console!(opt, args)` — and fail with a message when undefined:
94
+
95
+ ```bash
96
+ matsuri dev sh myapp # shell, as the container user
97
+ matsuri dev sh myapp -p web-0 # pick a pod
98
+ matsuri dev sh! myapp # shell, as root
99
+ matsuri dev console myapp # app console (rails c, iex, etc.)
100
+ ```
101
+
102
+ `sh` accepts `--root`/`-r`, `--user`/`-u`, and `--pod`/`-p`; `sh!` is
103
+ `sh` with root; `console` accepts `--root`, `--user`, and `--pod`. The
104
+ distinction is convention: `sh` promises a shell, `console` promises the
105
+ application's REPL — what command actually runs is the hook's call.
106
+
107
+ `status` reports on resources, with per-resource subcommands and two
108
+ kubectl-top wrappers:
109
+
110
+ ```bash
111
+ matsuri dev status stack myapp
112
+ matsuri dev status pod myapp-web
113
+ matsuri dev status pods # kubectl top pods
114
+ matsuri dev status nodes # kubectl top nodes
115
+ matsuri dev top pods # top is an alias for status
116
+ ```
117
+
118
+ Resource-level `status` calls the resource's `status!`, which a
119
+ definition may implement; the Stack base class requires the definition
120
+ to supply it.
121
+
122
+ ## Deploy Verbs: reload, update, migrate
123
+
124
+ These drive stack-definition hooks the same way `build` and `push` do,
125
+ but they encode an application deploy lifecycle rather than an image
126
+ one:
127
+
128
+ ```bash
129
+ matsuri dev reload myapp # push config without recreating pods
130
+ matsuri dev update myapp v42 # migrate, then roll to VERSION
131
+ matsuri dev migrate myapp v42 # run migrations only
132
+ ```
133
+
134
+ `update` accepts `--skip-migrations`. The intended shape was for the
135
+ stack definition to supply `update!(version, opt)` as its own deploy
136
+ choreography — run database migrations, then roll pods to the new
137
+ version — with `migrate` and `reload` as the narrower pieces.
138
+
139
+ **These verbs have not been modernized to current Kubernetes flows.**
140
+ They predate Deployments: rolling to a version is now the manifest
141
+ path's job (`converge <stack> <image_tag>`), and migrations are better
142
+ expressed as invoking a Job (or a `:task` dependency in the stack's
143
+ build order) than as imperative hook choreography. The wiring also shows
144
+ its age: `migrate` routes to a `migrate!` hook that has no stub on the
145
+ Stack base class, so without a definition it fails with a raw
146
+ NoMethodError rather than a message; and the CLI invokes `reload!` with
147
+ only the options hash, so the hook's `version` parameter never receives
148
+ a version. Prefer `converge` and Job resources; these verbs remain for
149
+ stacks that already define the hooks.
150
+
151
+ ## Incomplete Features
152
+
153
+ Pieces of the local-dev story that were started and never finished, or
154
+ whose finishing era passed. They are not wired to any CLI verb; they are
155
+ kept here so their intent is on the record.
156
+
157
+ - `matsuri <env> rebuild` — prints "Rebuild not implemented yet" and
158
+ exits 1. The intended loop was `build!` then `recreate!`, which
159
+ `Stack#rebuild!` still implements for anyone who wires it up.
160
+ - `Matsuri::Tasks::Docker#kill_all!` — the cluster reset button from
161
+ when the cluster was a pile of containers on your own machine: stop
162
+ and force-remove **every container on the Docker daemon**, then
163
+ re-converge from definitions. Indiscriminate against a modern shared
164
+ daemon; do not wire this up casually.
165
+ - `Matsuri::Tasks::Docker#fix_pts!` — prints (does not run)
166
+ `sudo mount -o remount,ptmxmode=666 /dev/pts`, the boot-ritual fix for
167
+ an early-Docker bug where restrictive devpts permissions broke PTY
168
+ allocation, so `docker exec -it` — and therefore `sh` and `console` —
169
+ could not open a terminal.
170
+ - `Matsuri::Tasks::Pod#build!` and `#list!` — empty method bodies; a
171
+ per-pod image build task that never grew content.
172
+ - `Matsuri::Config.host_osx?` — platform detection left over from the
173
+ boot2docker era; currently has no callers.
@@ -0,0 +1,317 @@
1
+ # Presenters
2
+
3
+ A presenter is one manifest-shaped step in a delivery pipeline:
4
+ `Manifest -> Manifest`. Resources stay pure generators; a presenter
5
+ transforms the rendered manifest after generation, at the delivery
6
+ boundary, before the payload is serialized for kubectl.
7
+
8
+ The promises:
9
+
10
+ - Zero-config and context-free. Construction takes nothing; every
11
+ parameter a step needs is bound before `#call` ever runs.
12
+ - The input manifest is never mutated. `#call` is framework-owned: it
13
+ makes one deep copy and hands it to `#transform`, so the copy is a
14
+ constraint, not a convention an author can forget.
15
+ - The empty pipeline is the identity. A stack with no declarations sends
16
+ payloads byte-identical to its rendered manifests.
17
+ - The diff previews the delivery. One site builds the presented payload
18
+ for both `diff` and `apply`, so what diff shows is what apply sends —
19
+ through presenters, with no extra work. The client-side `--hash-diff`
20
+ reads drift against the presented declaration the same way.
21
+
22
+ ## Defining Presenters
23
+
24
+ Presenters are defined in `platform/presenters`. Authors implement
25
+ `#transform` against a manifest that is already theirs to mutate, and
26
+ return it. The Kubernetes dialect (`set_env`, `set_label`,
27
+ `append_volume`, `update_in`, and friends from
28
+ `Matsuri::Transform::Kubernetes`) is bare vocabulary inside
29
+ `#transform`.
30
+
31
+ ```ruby
32
+ Matsuri.define :presenter, 'fluentd-sidecar' do
33
+ def transform(manifest)
34
+ update_in(manifest, [:spec, :template, :spec]) do |pod_spec|
35
+ append_volume(pod_spec, name: 'varlog', emptyDir: {})
36
+ append_to(pod_spec, [:containers],
37
+ name: 'fluentd',
38
+ image: 'fluentd:v1.16',
39
+ volumeMounts: [{ name: 'varlog', mountPath: '/var/log' }])
40
+ end
41
+ end
42
+ end
43
+ ```
44
+
45
+ As with acceptors, the registry definition is the golden path, not a
46
+ requirement: any subclass of `Matsuri::Presenter` works, and anything
47
+ `#call`-able can join a pipeline.
48
+
49
+ ## Declaring Pipelines on Stacks
50
+
51
+ A stack declares its pipelines with `present`. Unlike `accept`, where a
52
+ collection names a slot and the last declaration wins, a scenario
53
+ accumulates: each `present` appends, and declaration order is member
54
+ order.
55
+
56
+ ```ruby
57
+ Matsuri.define :stack, 'app' do
58
+ needs :config_map, 'app-settings'
59
+ needs :deployment, 'app-web'
60
+ needs :service, 'app-web'
61
+
62
+ present :on_apply, match_deployments('fluentd-sidecar')
63
+ present :on_create, 'team-label'
64
+ end
65
+ ```
66
+
67
+ Members may be presenter registry names, presenter classes, `Callable`
68
+ instances, or anything `#call`-able.
69
+
70
+ ### Scenarios Are Intent Regimes
71
+
72
+ Scenario names do not name kubectl verbs. There are two regimes:
73
+
74
+ - `:on_create` — bringing a resource into existence.
75
+ - `:on_apply` — delivering declared state to something that exists.
76
+
77
+ Each delivery verb assembles the pipeline for the regime it operates in:
78
+ `create` and the create leg of `recreate` assemble `:on_create`;
79
+ `apply`, `replace`, and `diff` assemble `:on_apply`. Neither replace nor
80
+ server-side apply is a third regime — `apply_via` (`:apply`, `:replace`,
81
+ `:server_side_apply`) picks how the payload travels, and the scenario
82
+ picks what the payload says; the two axes stay orthogonal, so flipping a
83
+ resource's `apply_via` never changes which presenters run. Diff is not a
84
+ scenario either: its meaning is "what converge will send", so it
85
+ assembles `:on_apply` and the preview extends through presenters for
86
+ free.
87
+
88
+ Declarations never cross a stack boundary: a nested stack assembles from
89
+ its own declarations, the same rule stack-config follows.
90
+
91
+ ## Declaring on Resources
92
+
93
+ A resource declares its own pipelines with the same `present` grammar;
94
+ the default is none. When a resource converges through a stack, the two
95
+ declaration sites merge into one pipeline: **resource members first,
96
+ stack members last**. These are not slot semantics — both run. Position
97
+ is about who sees the completed manifest: the resource's pipeline
98
+ completes its self-knowledge (closer to generation than to delivery),
99
+ and the stack's steps sit where the server's own mutating admission sits
100
+ relative to the client's manifest — they admit the finished product,
101
+ including whatever the resource's steps produced. Delivery-context
102
+ additions mostly commute and lose nothing by running late; a step that
103
+ enforces a boundary is only enforcement if it runs last.
104
+
105
+ Direct resource verbs (no stack context) assemble the resource's own
106
+ declarations alone, so a resource behaves identically converged directly
107
+ or through an undeclared stack.
108
+
109
+ ## Selective Steps: Combinators
110
+
111
+ A stack pipeline runs across every member manifest. Combinators make a
112
+ step selective without the step knowing: `match` wraps an element and
113
+ applies it only to manifests meeting the declared
114
+ `{kind:, name:, namespace:}` criteria — the conjunction of what is
115
+ declared, the same addressing grammar imported-manifest lookup uses —
116
+ and is the identity otherwise. An unknown criteria key is an error, so a
117
+ typo cannot become match-all.
118
+
119
+ ```ruby
120
+ present :on_apply, match({ kind: 'Deployment', name: 'app-web' }, 'fluentd-sidecar')
121
+ present :on_apply, match_kind('CronJob', 'team-label')
122
+ present :on_apply, match_deployments('fluentd-sidecar')
123
+ ```
124
+
125
+ Combinators are machinery, not presenters: they are never registered or
126
+ named in the registry. Names cross the declaration boundary; machinery
127
+ does not.
128
+
129
+ ## Parameterized Steps
130
+
131
+ Presenters take no configuration, and that is a feature: a step that
132
+ needs a value binds it before the pipeline runs, in one of three ways.
133
+
134
+ A definition fills a concern's declared hole with a `let`:
135
+
136
+ ```ruby
137
+ Matsuri.define :presenter, 'app-image' do
138
+ include MyPlatform::SeedImage # declares let(:image) { fail ... }
139
+ let(:image) { "registry.example.com/app:#{Matsuri::Config.image_tag}" }
140
+ end
141
+ ```
142
+
143
+ A declaration closes over a one-off value in a labeled leaf:
144
+
145
+ ```ruby
146
+ present :on_apply, Matsuri::Transform::Callable.new(label: 'pin-replicas') { |m|
147
+ m.set([:spec, :replicas], 3)
148
+ }
149
+ ```
150
+
151
+ Or a helper manufactures an anonymous definition — the named form minus
152
+ the registry entry, with the boundary copy riding along:
153
+
154
+ ```ruby
155
+ def seed_image_presenter(image_value)
156
+ Class.new(Matsuri::Presenter) do
157
+ include MyPlatform::SeedImage
158
+ let(:image) { image_value }
159
+ end
160
+ end
161
+
162
+ present :on_create, match_deployments(seed_image_presenter('app:v12'))
163
+ ```
164
+
165
+ The leaf form receives the pipeline carrier directly — a `Map` — and no
166
+ boundary copy of its own, so prefer the presenter forms for anything
167
+ reused.
168
+
169
+ ## Seeds
170
+
171
+ A seed completes a create payload with a field's boot value — the
172
+ replica count, the boot image, initial resource settings — without ever
173
+ contesting a declaration. The model:
174
+
175
+ - **Additive on create.** Seeds ride `:on_create` declarations on stacks
176
+ and resources; the ongoing `:on_apply` payload never carries them.
177
+ - **Don't-clobber is structural.** A field only seeds when the manifest
178
+ omits it. A workload that declares `spec.replicas` has opted out — the
179
+ seed yields, nothing overwrites. (Deployment's spec compacts, so
180
+ `let(:replicas) { nil }` is clean omission.)
181
+ - **Controllers take ownership uncontested.** Because the create runs
182
+ without writing the seeded fields into last-applied (`save_config`
183
+ governs this), ongoing applies of a manifest that omits them have
184
+ nothing to say about them — an HPA that scales is never fought. The
185
+ cluster side of this is pinned by `tests/bin/probe-save-config.sh`.
186
+
187
+ This is matsuri's whole answer to externally-owned fields — there is no
188
+ read-back. Matsuri never reads live cluster state to make a declaration
189
+ decision: a field someone else rolls (an image tag updated by a deploy
190
+ operator, replicas moved by an HPA) is omitted from the ongoing payload,
191
+ not read back and re-sent. Under client-side apply the omission
192
+ guarantees non-interference (the probe harness pins it); under an
193
+ ongoing deployment operator the field belongs to that operator's field
194
+ manager via server-side apply. If you deliver tags manually, name the
195
+ tag — the invocation positional or the stack's `:image_tag` acceptor is
196
+ the source, never the cluster.
197
+
198
+ Under server-side delivery (`apply_via :server_side_apply`), ongoing
199
+ applies merge by field ownership under the repo's field manager
200
+ (`Matsuri::Platform.field_manager`, default `matsuri`), and creation is
201
+ a protocol rather than a single verb (`Tasks::Stacks::Seed`): create the
202
+ seeded payload as `<field_manager>-seed` (the seed owner), deliver the
203
+ declared payload with an ordinary server-side apply, then atomically
204
+ remove the seed owner's managedFields entry. The field manager ends sole
205
+ owner of the declared fields and the seeds end unowned — a deliverer
206
+ rolling the image, or a controller scaling, takes them uncontested. The
207
+ order is not stylistic: a server-side apply against an object with empty
208
+ managedFields attributes every pre-existing field to a ghost manager
209
+ that never relinquishes. `tests/bin/probe-ssa.sh` pins the protocol and
210
+ the refuted alternatives. The operator-facing walkthrough — declaring
211
+ only what you manage, conflicts, interrupted converges — is [Server-Side
212
+ Apply](ssa.md).
213
+
214
+ The delivery verbs honor the protocol themselves: `apply` (and so
215
+ converge) routes a server-side resource that is not yet live through
216
+ `Seed`, `create` brings one into existence through it, and `recreate`
217
+ tears down and reseeds. Every later apply is an ordinary server-side
218
+ apply. Converge is sequential, so a stack member later in `build_order`
219
+ converges only after an earlier member is fully seeded and ceded — place
220
+ whatever hands a field to another manager after the workload it manages.
221
+
222
+ Three concerns ship, each a machinery + declared-hole pair:
223
+ `Matsuri::Presenters::SeedReplicas` (`let(:replicas)`), `SeedImage`
224
+ (`let(:image)`, seeding only containers without one), and
225
+ `SeedResources` (`let(:resources)`, likewise). One seed concern per
226
+ presenter — compose seeds in the pipeline, not by stacking includes.
227
+ Each has a declaration-surface constructor closing the hole over its
228
+ argument:
229
+
230
+ ```ruby
231
+ Matsuri.define :stack, 'app' do
232
+ needs :deployment, 'app-web'
233
+ needs :deployment, 'app-worker'
234
+
235
+ present :on_create, match_deployments(seed_replicas(2))
236
+ present :on_create, match_deployments(seed_resources(
237
+ requests: { cpu: '500m', memory: '512Mi' }
238
+ ))
239
+ present :on_create, match({ kind: 'Deployment', name: 'app-worker' },
240
+ seed_replicas(1))
241
+ end
242
+ ```
243
+
244
+ Seed values close at declaration time, so they must be literals or reads
245
+ of global state (`Matsuri::Platform`, environment config) — instance
246
+ values like the converge positional do not reach class-level
247
+ declarations.
248
+
249
+ ### save_config
250
+
251
+ `save_config` feeds create/recreate's `--save-config` flag, consulted
252
+ only when ongoing delivery consults last-applied (`apply_via :apply`):
253
+ under `:replace` or `:server_side_apply` it is pinned false, because a
254
+ seeded last-applied is a prune trap for any later client-side apply. It
255
+ resolves most-specific-first: a definition's own `let(:save_config)`,
256
+ then the owning stack's `save_config false` declaration, then the
257
+ repo-wide `Matsuri::Platform.save_config` (default `true`). Like
258
+ stack-config and `present` declarations, a stack's policy never crosses
259
+ to nested stacks.
260
+
261
+ ## Prune Empty
262
+
263
+ `Matsuri::Presenters::PruneEmpty` prunes empty values — `nil`, `{}`,
264
+ `[]` — from the payload, bottom-up (a parent emptied by pruning prunes
265
+ too). Empty strings survive: `""` is a value Kubernetes assigns meaning
266
+ to. Array elements are never dropped — removing elements changes list
267
+ semantics — only their interiors prune.
268
+
269
+ Under server-side apply this is more than hygiene. An empty collection
270
+ in a payload is an ownership claim over a field the definition does not
271
+ manage, and inside an atomic struct (`.spec.selector`) it diverges from
272
+ the server's persisted normal form — the server drops empty members on
273
+ storage — so the seeding protocol's equal-values co-ownership turns into
274
+ a loud conflict. A pruned payload declares in persistence normal form:
275
+ ceding a field and saying nothing are the same act.
276
+
277
+ Declare it on both intent regimes, after any seeds, so the create's echo
278
+ equals the declaration:
279
+
280
+ ```ruby
281
+ present :on_create, prune_empty
282
+ present :on_apply, prune_empty
283
+ ```
284
+
285
+ The merge is not uniformly monoidal — for some fields an empty value is
286
+ a genuine assertion (an atomic field held empty against other writers).
287
+ `let(:exceptions)` carries those paths, each preserved even when empty;
288
+ it ships with none. Fill it through the helper or a named definition:
289
+
290
+ ```ruby
291
+ present :on_apply, prune_empty(except: [%i[spec selector matchExpressions]])
292
+
293
+ Matsuri.define :presenter, 'prune-empty' do
294
+ include Matsuri::Presenters::PruneEmpty
295
+ let(:exceptions) { [%i[spec selector matchExpressions]] }
296
+ end
297
+ ```
298
+
299
+ Paths address hash keys only; they do not descend into arrays.
300
+
301
+ ## Composition
302
+
303
+ Pipelines are `Matsuri::Transform::Callable` values: `new` is the
304
+ identity, `new(label:) { }` a leaf, `new([a, b])` a composite, and `+`
305
+ concatenates. Composition obeys the monoid laws, so bundles nest without
306
+ surprises: a composite passed as a member stays one member, and its
307
+ label rides along as trace metadata.
308
+
309
+ ## What Presenters Are Not
310
+
311
+ Presenters are not the place for a resource's own vocabulary — a
312
+ deployment that always wants a label declares it in its manifest.
313
+ Presenters carry what the *delivery context* adds: the stack-wide
314
+ sidecar, the environment stamp, the value seeded only at creation.
315
+ Generation stays in definitions, delivery policy in lets
316
+ (`converge_via`, `apply_via`, `save_config`), and presentation in the
317
+ pipeline between them.
data/docs/resources.md ADDED
@@ -0,0 +1,121 @@
1
+ # Resources
2
+
3
+ Resources are defined using `let()` extracted from `rspec` (shipped as
4
+ the `rlet` gem). Each resource has a `manifest` let that assembles the
5
+ complete resource manifest. Every value in it is constructed by a
6
+ predefined `let()` that you override with your own `let()`: the manifest
7
+ is a composition of small named values, and redefining one name changes
8
+ exactly that leaf. Values that have no sensible default — a deployment's
9
+ `replicas`, its `match_labels` — are declared holes that fail fast until
10
+ a definition fills them.
11
+
12
+ Resource definitions are Ruby classes, so inheritance can be used: a
13
+ definition can inherit from another definition and override only the
14
+ lets that differ.
15
+
16
+ You can create mixins with `Concern` that carry their own `let()`. This
17
+ allows you to include them across definitions. Matsuri comes with a few
18
+ builtin mixins:
19
+
20
+ - [`Concerns::Pods::SpreadTopology`](../lib/matsuri/concerns/pods/spread_topology.rb)
21
+ — topology spread constraint vocabulary for balancing same-labeled
22
+ pods across zones or nodes.
23
+ - [`Concerns::Pods::SpreadPods`](../lib/matsuri/concerns/pods/spread_pods.rb)
24
+ — pod anti-affinity vocabulary for excluding co-location within a node
25
+ or zone. Complements SpreadTopology: spread expresses balance,
26
+ anti-affinity expresses exclusion.
27
+ - [`Concerns::PodTemplate`](../lib/matsuri/concerns/pod_template.rb) —
28
+ embeds a pod definition as a workload's pod template; this is how
29
+ Deployment, StatefulSet, and the other workloads reference pods.
30
+ - [`Concerns::ImportManifest`](../lib/matsuri/concerns/import_manifest.rb)
31
+ — imports vendor YAML manifests as source material and transforms them
32
+ with the same algebra.
33
+
34
+ ## Defining a Resource in the Registry
35
+
36
+ Definitions live under `platform/`, one directory per resource type, one
37
+ file per definition. The registry resolves `(type, name)` to the file by
38
+ convention — `Matsuri.define :pod, 'web'` is found in
39
+ `platform/pods/web.rb`:
40
+
41
+ platform/
42
+ acceptors/app.rb
43
+ config_maps/app-settings.rb
44
+ deployments/web.rb
45
+ pods/web.rb
46
+ presenters/team-label.rb
47
+ services/web.rb
48
+ stacks/app.rb
49
+
50
+ `Matsuri.define` registers the definition and returns the class, so a
51
+ file can also refine what it defines (or define bare classes and
52
+ register them directly — the registry is the golden path, not a
53
+ requirement).
54
+
55
+ ### Environments
56
+
57
+ A definition can be specialized per environment by path: the registry
58
+ consults `platform/pods/<environment>/web.rb` before
59
+ `platform/pods/web.rb`, so an environment-specific file shadows the
60
+ shared one. The environment is the CLI's first argument
61
+ (`matsuri staging ...`); see [Setup](setup.md) for how environments bind
62
+ to kubectl contexts.
63
+
64
+ See also the rspec fixture platform, which is a small, complete example
65
+ repo: [spec/fixture/platform](../spec/fixture/platform).
66
+
67
+ ## Examples
68
+
69
+ ### Pods
70
+
71
+ The smallest definition overrides `spec` outright:
72
+
73
+ ```ruby
74
+ # platform/pods/hello.rb
75
+ Matsuri.define :pod, 'hello' do
76
+ let(:spec) do
77
+ { containers: [{ name: 'hello', image: 'nginx:1.25' }] }
78
+ end
79
+ end
80
+ ```
81
+
82
+ Most definitions override narrower lets and let the predefined `spec`
83
+ assemble around them:
84
+
85
+ ```ruby
86
+ # platform/pods/web.rb
87
+ Matsuri.define :pod, 'web' do
88
+ let(:container) { { name: 'web', image: "app:#{image_tag}" } }
89
+ let(:volumes) { [] }
90
+ end
91
+ ```
92
+
93
+ Placement vocabulary (`node_selector`, `tolerations`, `affinity`,
94
+ `topology_spread_constraints`) is predefined on Pod; the spread concerns
95
+ above supply strategies for the affinity and spread lets.
96
+
97
+ See also the pod fixtures:
98
+ [spec/fixture/platform/pods](../spec/fixture/platform/pods).
99
+
100
+ ### Deployments
101
+
102
+ A deployment embeds a pod definition as its template by naming it —
103
+ `let(:pod_name)` is the reference, and the pod's labels, annotations,
104
+ and spec ride in through `Concerns::PodTemplate`:
105
+
106
+ ```ruby
107
+ # platform/deployments/web.rb
108
+ Matsuri.define :deployment, 'web' do
109
+ let(:replicas) { 2 }
110
+ let(:match_labels) { { 'app' => 'web' } }
111
+ let(:pod_name) { 'web' } # embeds pod 'web' as the pod template
112
+ end
113
+ ```
114
+
115
+ `replicas` and `match_labels` are declared holes — a deployment that
116
+ does not define them fails at first render, not at apply time. Rollout
117
+ knobs (`strategy`, `max_unavailable`, `max_surge`, `min_ready_seconds`)
118
+ are predefined lets with conventional defaults.
119
+
120
+ See also the deployment fixtures:
121
+ [spec/fixture/platform/deployments](../spec/fixture/platform/deployments).