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/setup.md ADDED
@@ -0,0 +1,97 @@
1
+ # Setup
2
+
3
+ ## A Matsuri Repo
4
+
5
+ The minimal skeleton:
6
+
7
+ Gemfile # pins the matsuri version (and plugins)
8
+ bin/matsuri # wrapper: pins the bundle, nothing else
9
+ config/
10
+ matsuri.rb # marks the repo base; Config overrides
11
+ platform.rb # environment definitions
12
+ initializer.rb # loads plugins and your library
13
+ lib/ # your organization's extension library
14
+ platform/ # resource definitions
15
+
16
+ `config/matsuri.rb` is the marker: matsuri finds the repo by walking up
17
+ from the working directory until it sees one. A working example repo
18
+ lives in [tests/example](../tests/example). How the pieces load — the
19
+ wrapper, the environment argument, the initializer — is covered in
20
+ [Boot](boot.md).
21
+
22
+ ## Environments and kubectl Contexts
23
+
24
+ Every kubectl command matsuri runs is scoped to a context and a
25
+ namespace:
26
+
27
+ kubectl --context=<context> --namespace=<namespace> ...
28
+
29
+ The namespace comes from the environment's platform config (default
30
+ `default`). The context comes from one of two modes.
31
+
32
+ ## Default Mode: the Environment Name Is the Context Name
33
+
34
+ By default, matsuri assumes a kubeconfig context exists with the same
35
+ name as the environment: `matsuri staging converge myapp` runs
36
+ `kubectl --context=staging ...`. Cluster tools hand out longer names —
37
+ GKE contexts look like `gke_myproject_us-central1_staging` — so matsuri
38
+ provides a verb that binds the environment's name as an alias:
39
+
40
+ ```bash
41
+ matsuri staging kubectl setup
42
+ ```
43
+
44
+ This runs `kubectl config set-context staging` with the cluster, user,
45
+ and namespace drawn from the environment's platform config:
46
+
47
+ ```ruby
48
+ # config/platform.rb
49
+ require 'map'
50
+
51
+ staging Map.new(
52
+ namespace: 'myapp',
53
+ kube_cluster: 'gke_myproject_us-central1_staging',
54
+ user: 'gke_myproject_us-central1_staging'
55
+ )
56
+ ```
57
+
58
+ `kube_cluster` and `user` default to the environment name when not
59
+ given; `namespace` defaults to `default`.
60
+
61
+ Setup is a one-time step per environment, and it only creates the alias:
62
+ the cluster and user entries it points at must already exist in your
63
+ kubeconfig. Get them from your provider first — for example
64
+ `gcloud container clusters get-credentials`, or `minikube start` /
65
+ `k3d cluster create`, which write their own entries. Local tools often
66
+ already name their context something usable (`minikube`,
67
+ `k3d-mycluster`), in which case name your environment to match and skip
68
+ setup entirely.
69
+
70
+ ## Mapped Mode: Point the Environment at an Existing Context
71
+
72
+ If you would rather not alias — letting `gcloud` own authentication and
73
+ context naming, for instance — set `map_env_to_kube_context` and name
74
+ the real context in platform config:
75
+
76
+ ```ruby
77
+ # config/matsuri.rb
78
+ map_env_to_kube_context true
79
+ ```
80
+
81
+ ```ruby
82
+ # config/platform.rb
83
+ staging Map.new(
84
+ namespace: 'myapp',
85
+ kube_context: 'gke_myproject_us-central1_staging'
86
+ )
87
+ ```
88
+
89
+ With this set, every environment must define `kube_context`;
90
+ `kubectl setup` is unnecessary in this mode.
91
+
92
+ ## Verifying
93
+
94
+ ```bash
95
+ kubectl config get-contexts # the environment (or mapped) context exists
96
+ matsuri staging status pods # matsuri reaches the cluster through it
97
+ ```
data/docs/ssa.md ADDED
@@ -0,0 +1,149 @@
1
+ # Server-Side Apply
2
+
3
+ Server-side delivery hands the merge to the apiserver: instead of the
4
+ client's three-way last-applied merge, the server tracks which manager
5
+ owns which field and merges by ownership. Choose it when an object's
6
+ fields are genuinely shared — a deployment operator rolls the image, an
7
+ HPA moves the count, an in-place autoscaler adjusts sizing — and the
8
+ repo should own only what it declares.
9
+
10
+ The posture: matsuri owns exactly the fields the definition declares and
11
+ cedes everything else. The declaration is the ownership record; matsuri
12
+ never reads live state to decide what to send. The constraints behind
13
+ this are in [docs/design/ssa.md](design/ssa.md).
14
+
15
+ ## Enabling It
16
+
17
+ Server-side apply is one value of the `apply_via` delivery axis, per
18
+ resource:
19
+
20
+ ```ruby
21
+ Matsuri.define :deployment, 'app-web' do
22
+ let(:apply_via) { :server_side_apply }
23
+ # ...
24
+ end
25
+ ```
26
+
27
+ Applies are delivered under the repo's field manager —
28
+ `Matsuri::Platform.field_manager`, default `matsuri` — overridable per
29
+ resource with `let(:field_manager)`. Delivery mode and presenter
30
+ pipeline are orthogonal: flipping a resource's `apply_via` never changes
31
+ which presenters run, so members of a stack can migrate individually.
32
+
33
+ ## Declare Only What You Manage
34
+
35
+ The ongoing payload should carry exactly the fields the repo owns.
36
+ Fields another writer rolls are omitted, not read back: a deployment
37
+ whose image is delivered by an operator declares its containers by name
38
+ without an image, and one whose count an HPA moves declares
39
+ `let(:replicas) { nil }` (Deployment's spec compacts, so nil is clean
40
+ omission). Boot values for those fields ride `:on_create` seeds — see
41
+ [Seeds](presenters.md#seeds) — so the seeded create lands the object
42
+ whole and the seeds end owned by no one.
43
+
44
+ ## Persistence Normal Form
45
+
46
+ The server does not persist empty values — it drops them on storage —
47
+ and inside an atomic struct like `.spec.selector`, a payload carrying an
48
+ empty member no longer equals the persisted value, which turns the
49
+ creation hand-off into a conflict. An empty collection is also an
50
+ ownership claim over a field the definition does not manage.
51
+
52
+ So a server-side resource's payloads declare in persistence normal form:
53
+ declare `prune_empty` on both intent regimes, after any seeds, on the
54
+ stack (or the resource):
55
+
56
+ ```ruby
57
+ Matsuri.define :stack, 'app' do
58
+ needs :deployment, 'app-web'
59
+ needs :service, 'app-web'
60
+
61
+ present :on_create, match_deployments(seed_replicas(2))
62
+ present :on_create, match_deployments(seed_image('registry.example.com/app:boot'))
63
+
64
+ present :on_create, prune_empty
65
+ present :on_apply, prune_empty
66
+ end
67
+ ```
68
+
69
+ A field that must be held empty against other writers is a genuine
70
+ assertion, not an accident of rendering — carry it in the exception list
71
+ (`prune_empty(except: [...])`). The mechanics and the exception grammar
72
+ are in [Prune Empty](presenters.md#prune-empty).
73
+
74
+ ## The First Converge
75
+
76
+ Creation under server-side delivery is a protocol, not a single verb,
77
+ and the delivery verbs run it for you: `apply` (and so converge) routes
78
+ a resource that is not yet live through it, `create` brings one into
79
+ existence through it, and `recreate` tears down and reseeds. Three
80
+ steps:
81
+
82
+ 1. Create the seeded payload as `<field_manager>-seed`.
83
+ 2. Deliver the declared payload with an ordinary server-side apply.
84
+ Equal values co-own, which is why normal form matters at this step.
85
+ 3. Cede: atomically remove the seed owner's managedFields entry. The
86
+ declared fields end solely owned by the field manager; the seeds end
87
+ unowned, free for the first writer that wants them.
88
+
89
+ A first converge looks like:
90
+
91
+ ```console
92
+ $ bin/matsuri staging converge app
93
+ deployment.apps/app-web created
94
+ deployment.apps/app-web serverside-applied
95
+ service/app-web created
96
+ ```
97
+
98
+ Every later apply is an ordinary server-side apply. `save_config` is
99
+ pinned false under this mode — a seeded last-applied is a prune trap for
100
+ any later client-side apply — so there is nothing to configure.
101
+
102
+ The protocol and its refuted alternatives are pinned by
103
+ `tests/bin/probe-ssa.sh`.
104
+
105
+ ## Conflicts
106
+
107
+ A conflict means another manager owns a field this declaration sends:
108
+
109
+ error: Apply failed with 1 conflict: conflict with "some-operator"
110
+ using apps/v1: .spec.template.spec.containers[name="app"].image
111
+
112
+ Matsuri never passes `--force-conflicts`; forcing transfers ownership
113
+ and overwrites another party's work, which is a human judgment. Two
114
+ resolutions:
115
+
116
+ - **Cede the field.** If the other manager is its rightful owner, stop
117
+ declaring it: omit it from the definition (and seed it on create if
118
+ the object needs it at creation). This is the common case and lands as
119
+ an ordinary reviewed change.
120
+ - **Take the field.** If the repo is the rightful owner, take it once
121
+ with raw kubectl under the repo's manager —
122
+ `kubectl apply --server-side --force-conflicts --field-manager=matsuri -f -`
123
+ — then the next matsuri apply is ordinary.
124
+
125
+ `diff` for a server-side resource dry-runs under the same field manager
126
+ apply uses, so a conflict surfaces in the preview as the same loud error
127
+ delivery would hit.
128
+
129
+ ## An Interrupted First Converge
130
+
131
+ A converge that dies mid-protocol strands an object still owned by
132
+ `<field_manager>-seed` — the cede never ran, so the seed owner holds its
133
+ fields forever and a retry conflicts with it. Recognize it by reading
134
+ ownership:
135
+
136
+ ```console
137
+ $ kubectl get deployment app-web --show-managed-fields -o json \
138
+ | jq '[.metadata.managedFields[].manager]'
139
+ ```
140
+
141
+ If `matsuri-seed` appears on an object whose converge is not currently
142
+ running, the recovery is to delete the object and reconverge — the
143
+ seeding protocol runs only at creation, so it must restart from a clean
144
+ create:
145
+
146
+ ```console
147
+ $ kubectl delete deployment app-web
148
+ $ bin/matsuri staging converge app
149
+ ```
data/docs/stacks.md ADDED
@@ -0,0 +1,146 @@
1
+ # Stacks
2
+
3
+ Stacks are groupings of resources that are managed together.
4
+
5
+ ## Defining Stacks
6
+
7
+ Stacks are defined in `platform/stacks` with a DSL that defines
8
+ resources that are needed. It takes an argument that references a
9
+ Matsuri definition and type.
10
+
11
+ ```ruby
12
+ # platform/stacks/app.rb
13
+ #
14
+ # One management promise: the app's bulkheads, the service fronting
15
+ # them, and the settings they share converge as one act, in this
16
+ # sequence.
17
+ Matsuri.define :stack, 'app' do
18
+ needs :config_map, 'app-settings'
19
+ needs :deployment, 'app-web'
20
+ needs :deployment, 'app-web-streams'
21
+ needs :deployment, 'app-worker-general'
22
+ needs :service, 'app-web'
23
+ end
24
+ ```
25
+
26
+ Each member stays independently addressable through the registry — the
27
+ stack adds sequence and shared bindings, not a new kind of resource.
28
+
29
+ At this time, there is no way to define dependencies. Applying the
30
+ manifest is performed in sequence.
31
+
32
+ ## Operation
33
+
34
+ matsuri <env> converge <stack name>
35
+
36
+ The converge operation applies changes in sequence as defined by the
37
+ stack.
38
+
39
+ ## Stack Config
40
+
41
+ We can pass a configuration yaml file into the stack that can be
42
+ referenced by resources. This is intended to parameterize an entire
43
+ stack. The main use-case is to support dynamic environments used within
44
+ software development lifecycle.
45
+
46
+ ```yaml
47
+ # stack-config.yaml
48
+ name: app-feat-checkout
49
+ namespace: feat-checkout
50
+ log_level: debug
51
+ expires_at: "2026-11-15"
52
+ contact: team-checkout
53
+ ```
54
+
55
+ The stack declares which acceptor admits the artifact with
56
+ `accept :config`; the acceptor owns the vocabulary and the defaults (see
57
+ [Acceptors](acceptors.md)):
58
+
59
+ ```ruby
60
+ # platform/acceptors/app.rb
61
+ Matsuri.define :acceptor, 'app' do
62
+ let(:artifact) { options || {} }
63
+
64
+ let(:instance_name) { artifact[:name] }
65
+ let(:namespace) { artifact[:namespace] }
66
+ let(:log_level) { artifact[:log_level] || 'info' }
67
+ end
68
+
69
+ # platform/stacks/app.rb
70
+ Matsuri.define :stack, 'app' do
71
+ accept :config, 'app'
72
+
73
+ needs :config_map, 'app-settings'
74
+ needs :deployment, 'app-web'
75
+ needs :service, 'app-web'
76
+ end
77
+ ```
78
+
79
+ Every member resource reads the same binding through `stack_config`:
80
+
81
+ ```ruby
82
+ # platform/config_maps/app-settings.rb
83
+ Matsuri.define :config_map, 'app-settings' do
84
+ let(:data) do
85
+ { 'LOG_LEVEL' => stack_config.log_level }
86
+ end
87
+ end
88
+ ```
89
+
90
+ The artifact rides in on the converge:
91
+
92
+ matsuri <env> converge app --stack-config stack-config.yaml
93
+
94
+ Without the flag, every read lands on the acceptor's defaults — the
95
+ unconfigured stack behaves exactly as it did before the config surface
96
+ existed. Stack-config never crosses a stack boundary: a nested stack
97
+ reads only its own declarations.
98
+
99
+ ## Server Side Apply
100
+
101
+ Stacks and resources can optionally use server side apply, in
102
+ combination with seeds.
103
+
104
+ We can also define seed presenters for stacks and resources on the
105
+ initial create. When combined with server side apply, Matsuri cedes
106
+ field management of those values. These presenters are not applied when
107
+ applying after resources are created. This allows other operators to
108
+ claim field management, while matsuri retains management over the
109
+ remaining fields.
110
+
111
+ Examples include seeded images (for deployment operators), replicas
112
+ (horizontal pod autoscaler), and resources (in-place pod autoscaling).
113
+
114
+ ```ruby
115
+ # platform/deployments/app-web.rb
116
+ Matsuri.define :deployment, 'app-web' do
117
+ let(:apply_via) { :server_side_apply } # deliver by field ownership
118
+ let(:replicas) { nil } # omitted: the autoscaler owns the count
119
+ let(:match_labels) { { 'app' => 'web' } }
120
+ let(:pod_name) { 'app' } # pod 'app' carries no image —
121
+ # a deployment operator owns the tag
122
+ end
123
+
124
+ # platform/stacks/app.rb
125
+ Matsuri.define :stack, 'app' do
126
+ needs :config_map, 'app-settings'
127
+ needs :deployment, 'app-web'
128
+ needs :service, 'app-web'
129
+
130
+ present :on_create, match_deployments(seed_replicas(2))
131
+ present :on_create, match_deployments(seed_image('registry.example.com/app:boot'))
132
+ end
133
+ ```
134
+
135
+ The first converge of a server-side resource routes through the seeding
136
+ protocol (seeded create, declared apply, cede); every later apply is an
137
+ ordinary server-side apply. Converge is sequential, so a member later in
138
+ the stack converges only after an earlier member is fully seeded and
139
+ ceded. The operator walkthrough — declaring only what you manage,
140
+ conflicts, interrupted converges — is [Server-Side Apply](ssa.md); the
141
+ full model of seeds and field managers is in the Seeds section of
142
+ [Presenters](presenters.md).
143
+
144
+ See also the seed and delivery specs:
145
+ [spec/presenters/seeds_spec.rb](../spec/presenters/seeds_spec.rb) and
146
+ [spec/tasks/stacks/seed_spec.rb](../spec/tasks/stacks/seed_spec.rb).
data/flake.lock ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "nodes": {
3
+ "flake-utils": {
4
+ "inputs": {
5
+ "systems": "systems"
6
+ },
7
+ "locked": {
8
+ "lastModified": 1791914995,
9
+ "narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
10
+ "owner": "numtide",
11
+ "repo": "flake-utils",
12
+ "rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
13
+ "type": "github"
14
+ },
15
+ "original": {
16
+ "owner": "numtide",
17
+ "repo": "flake-utils",
18
+ "type": "github"
19
+ }
20
+ },
21
+ "nixpkgs": {
22
+ "locked": {
23
+ "lastModified": 1792615934,
24
+ "narHash": "sha256-MlvRfa1O5Cqc2/a+gG+l9TSuowEoCKCmrJ1d9oAVm0I=",
25
+ "owner": "NixOS",
26
+ "repo": "nixpkgs",
27
+ "rev": "da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36",
28
+ "type": "github"
29
+ },
30
+ "original": {
31
+ "owner": "NixOS",
32
+ "ref": "nixos-26.05",
33
+ "repo": "nixpkgs",
34
+ "type": "github"
35
+ }
36
+ },
37
+ "root": {
38
+ "inputs": {
39
+ "flake-utils": "flake-utils",
40
+ "nixpkgs": "nixpkgs"
41
+ }
42
+ },
43
+ "systems": {
44
+ "locked": {
45
+ "lastModified": 1681028828,
46
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47
+ "owner": "nix-systems",
48
+ "repo": "default",
49
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50
+ "type": "github"
51
+ },
52
+ "original": {
53
+ "owner": "nix-systems",
54
+ "repo": "default",
55
+ "type": "github"
56
+ }
57
+ }
58
+ },
59
+ "root": "root",
60
+ "version": 7
61
+ }
data/flake.nix ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ description = "Matsuri dev environment";
3
+
4
+ inputs = {
5
+ nixpkgs = {
6
+ url = "github:NixOS/nixpkgs/nixos-26.05"; # Or a specific stable channel like "nixos-24.05"
7
+ };
8
+ flake-utils = {
9
+ url = "github:numtide/flake-utils";
10
+ };
11
+ };
12
+
13
+ outputs = { self, nixpkgs, flake-utils }:
14
+ flake-utils.lib.eachDefaultSystem(system:
15
+ let
16
+ pkgs = import nixpkgs {
17
+ inherit system;
18
+ # overlays = [ ];
19
+ };
20
+ in
21
+ with pkgs; {
22
+ devShell = mkShell {
23
+ buildInputs = [
24
+ ruby_4_0
25
+ pandoc
26
+
27
+ # Cluster harness for tests/ (delivery semantics probes)
28
+ minikube
29
+ kubectl
30
+ ] ++ lib.optionals stdenv.isDarwin [
31
+ vfkit # hypervisor backend for minikube on macOS
32
+ ];
33
+ };
34
+ });
35
+ }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rlet'
4
+ require 'rlet/lazy_options'
5
+
6
+ module Matsuri
7
+ # An acceptor admits a value whose vocabulary matsuri does not own, such as
8
+ # a stack-config artifact. The raw value arrives whole as options; the lets
9
+ # declared here are the accepted surface, and members read the acceptor
10
+ # instead of the raw value. Fields the acceptor does not declare are
11
+ # ignored. Reads the acceptor does not declare raise NoMethodError. There
12
+ # is no fallback to the underlying value.
13
+ #
14
+ # The empty value belongs to the acceptor: every declared read is total
15
+ # over nil, because what nil means is collection-specific. A scalar
16
+ # surface reads options whole (options || default); a hash-shaped surface
17
+ # anchors once with let(:artifact) { options || {} } and reads fields off
18
+ # the anchor.
19
+ class Acceptor
20
+ include Let
21
+ include RLet::LazyOptions
22
+
23
+ class << self
24
+ def load_path
25
+ Matsuri::Config.acceptors_path
26
+ end
27
+
28
+ def definition_module_name
29
+ 'Acceptors'
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'matsuri/acceptor'
4
+
5
+ module Matsuri
6
+ module Acceptors
7
+ # Built-in acceptor for the :image_tag collection on stacks. Maps the
8
+ # converge positional — a scalar, admitted whole — onto the declared
9
+ # surface. The 'latest' default lives here and nowhere else; a platform
10
+ # overrides it by re-declaring the collection with its own acceptor.
11
+ class ImageTag < Matsuri::Acceptor
12
+ let(:image_tag) { options || 'latest' }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'map'
4
+ require 'rlet'
5
+ require 'yaml'
6
+ require 'matsuri/core_ext'
7
+
8
+ module Matsuri
9
+ module Cmd
10
+ extend Concern
11
+
12
+ included do
13
+ private
14
+
15
+ def with_config(opts = {})
16
+ _final_options = final_options(opts)
17
+
18
+ Matsuri::Config.apply_configuration(_final_options)
19
+
20
+ yield _final_options
21
+ end
22
+
23
+ def final_options(opts = {})
24
+ options.merge(parent_options || {}).merge(opts).deep_symbolize_keys
25
+ end
26
+
27
+ # The CLI owns the boundary mapping; the built-in ImageTag acceptor
28
+ # owns the default — the same single owner the stack :image_tag
29
+ # collection uses. The positional may be nil.
30
+ def image_tag_for(positional)
31
+ Matsuri::Acceptors::ImageTag.new(positional).image_tag
32
+ end
33
+
34
+ # The CLI owns the boundary mapping: --stack-config= names a path,
35
+ # the stack's :config collection expects data. No flag is the empty
36
+ # value; a flag with a bad path fails loudly, never falls back.
37
+ def stack_config_data(opt)
38
+ path = opt[:stack_config]
39
+ return nil unless path
40
+
41
+ Matsuri.log(:fatal, "stack-config not found: #{path}") unless File.file?(path)
42
+ Map.new(YAML.safe_load_file(path, aliases: true))
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'matsuri/core_ext'
4
+
5
+ module Matsuri
6
+ module Cmds
7
+ class Apply < Thor
8
+ include Matsuri::Cmd
9
+
10
+ def self.apply_cmd_for(resource_name, image_tag: false)
11
+ unless image_tag
12
+ define_method(resource_name) do |name = :not_specified|
13
+ apply_resource { Matsuri::Registry.fetch_or_load(resource_name, name).new }
14
+ end
15
+ else
16
+ define_method(resource_name) do |name = :not_specified, image_tag = nil|
17
+ apply_resource { Matsuri::Registry.fetch_or_load(resource_name, name).new(image_tag: image_tag_for(image_tag)) }
18
+ end
19
+ end
20
+ end
21
+
22
+ desc 'imported_manifest NAME', 'apply changes to an imported manifest'
23
+ apply_cmd_for :imported_manifest
24
+
25
+ desc 'pod POD_NAME', 'apply changes to a pod'
26
+ apply_cmd_for :pod, image_tag: true
27
+
28
+ desc 'replica-set REPLICA-SET_NAME', 'apply changes to a replica-set'
29
+ apply_cmd_for :replica_set, image_tag: true
30
+ map replicaset: :replica_set
31
+ map rs: :replica_set
32
+
33
+ desc 'stateful-set STATEFUL_SET_NAME', 'apply changes for stateful_set'
34
+ apply_cmd_for :stateful_set, image_tag: true
35
+ map sts: :stateful_set
36
+
37
+ desc 'daemon-set DAEMON_SET_NAME', 'apply changes for daemon_set'
38
+ apply_cmd_for :daemon_set, image_tag: true
39
+ map ds: :daemon_set
40
+
41
+ desc 'deployment DEPLOYMENT_NAME', 'apply changes to a deployment'
42
+ apply_cmd_for :deployment, image_tag: true
43
+ map deploy: :deployment
44
+
45
+ desc 'cron-job CRONJOB_NAME', 'apply changes to a cronjob'
46
+ apply_cmd_for :cron_job, image_tag: true
47
+ map cj: :cron_job
48
+ map cronjob: :cron_job
49
+
50
+ desc 'service SERVICE_NAME', 'apply changes to a service'
51
+ apply_cmd_for :service
52
+
53
+ desc 'ingress INGRESS_NAME', 'apply changes to a ingress'
54
+ apply_cmd_for :ingress
55
+
56
+ desc 'secret SECRET_NAME', 'apply changes to a secret'
57
+ apply_cmd_for :secret
58
+
59
+ desc 'config-map CONFIG_MAP_NAME', 'apply changes to a config map'
60
+ apply_cmd_for :config_map, image_tag: true
61
+ map configmap: :config_map
62
+ map cm: :config_map
63
+
64
+ desc 'horizontal_pod_autoscaler HPA_NAME', 'apply changes to a horizontal pod autoscaler'
65
+ apply_cmd_for :horizontal_pod_autoscaler
66
+ map hpa: :horizontal_pod_autoscaler
67
+
68
+ private
69
+
70
+ def apply_resource
71
+ with_config do |opt|
72
+ resource = yield
73
+ Matsuri::Tasks::KubectlOps.apply(resource)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end