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,206 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Probes the creation protocol the seeds+SSA posture rests on, before any
5
+ # SSA code lands. Hard expectations (FAIL) pin the invariants the design
6
+ # assumes; REPORT legs are open questions whose answers shape the design.
7
+ #
8
+ # The seed that matters is the IMAGE: the ongoing declaration omits it
9
+ # (the container entry rides on its name key alone) and another writer
10
+ # delivers it. Replicas is the second seed; a declared env var is the
11
+ # ongoing manager's own changeable field.
12
+ #
13
+ # F. seed via create under the seed owner (a disposable manager derived
14
+ # from the ongoing one), then the ordinary declared SSA apply, then
15
+ # remove the seed owner's managedFields entry with an atomic
16
+ # test+remove JSON patch.
17
+ # managedFields is never empty when an apply arrives (so the server
18
+ # never invents before-first-apply), the seeds end unowned, and the
19
+ # ongoing applier ends sole owner of the declared fields.
20
+ # F1 EXPECT create --field-manager=<seed owner> lands both seeds
21
+ # F2 EXPECT the declared apply is accepted with the image absent
22
+ # from the container entry (keyed-list partial), seeds
23
+ # untouched, and the ongoing manager never owns image
24
+ # F3 EXPECT test+remove of the seed owner's entry applies (<=3
25
+ # attempts; the test op makes a stale index a loud
26
+ # retry, not a wrong removal)
27
+ # F4 EXPECT values intact; seed owner entry gone; image AND replicas
28
+ # unowned; raw managedFields dumped for verification
29
+ # F5 EXPECT changing a declared value is clean -- the leg every
30
+ # refuted path failed -- and both seeds are untouched
31
+ # F6 EXPECT image delivery via kubectl set image (update-family)
32
+ # is clean, and the next omitting apply leaves the
33
+ # delivered image alone
34
+ # F7 EXPECT after kubectl scale, an omitting apply leaves
35
+ # replicas alone
36
+ # F8 EXPECT a different manager changing an owned field conflicts
37
+ # F9 REPORT diff --server-side surfaces that conflict (exit 2)
38
+ # F10 EXPECT re-running the seeded create is loud (AlreadyExists)
39
+ # F11 REPORT last-applied state; EXPECT it never carries a seed
40
+ # (the CSA prune trap)
41
+ #
42
+ # Ownership claims are live reads: owners() runs a fresh get
43
+ # --show-managed-fields per call and scans every entry's fieldsV1 for
44
+ # the field's path; the raw dumps in F4/F6/F7 print the entries
45
+ # verbatim (status/metadata pruned) so "unowned" is checkable against
46
+ # the record, not the filter.
47
+ #
48
+ # Refuted alternatives, pruned from the probe after earlier runs:
49
+ # - create then SSA directly: the create manager's Update entry co-owns every
50
+ # seeded field and never relinquishes; the first changed-value apply
51
+ # conflicts permanently.
52
+ # - create under the SAME name the applies use: an Update entry and an
53
+ # Apply entry are distinct managers; the conflict remains.
54
+ # - seed on the first SSA apply: the next apply that omits the seed
55
+ # relinquishes the field and the server resets it.
56
+ # - wipe managedFields BEFORE the first apply (patch or embedded in the
57
+ # create body): an apply against empty managedFields attributes every
58
+ # existing field to the synthetic manager before-first-apply, which
59
+ # never relinquishes; the same dead end under another name.
60
+ #
61
+ # Start the cluster with bin/minikube-start.sh first. kubectl dials the
62
+ # VM: on macOS run from a Local Network-granted context (not tmux).
63
+
64
+ TESTS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
65
+ CONTEXT="${CONTEXT:-matsuri}"
66
+ MANAGER="${MANAGER:-matsuri}"
67
+ OTHER_MANAGER="${OTHER_MANAGER:-other}"
68
+ # Disposable but deliberate: the F3 removal predicate keys on this name,
69
+ # and a converge that dies before F3 strands an object whose managedFields
70
+ # should read as ours. The seed owner is derived from the ongoing manager
71
+ # (<manager>-seed), matching Tasks::Stacks::Seed.
72
+ CREATE_MANAGER="${CREATE_MANAGER:-${MANAGER}-seed}"
73
+ K8S="${TESTS_DIR}/k8s/ssa"
74
+ KUBECTL=(kubectl --context "${CONTEXT}" --namespace matsuri-probes)
75
+
76
+ fail() { echo "FAIL: $*" >&2; exit 1; }
77
+ report() { echo "REPORT $*"; }
78
+
79
+ ssa() { "${KUBECTL[@]}" apply --server-side --field-manager="${MANAGER}" -f "$1"; }
80
+
81
+ replicas() { "${KUBECTL[@]}" get deployment "$1" -o jsonpath='{.spec.replicas}'; }
82
+ image() { "${KUBECTL[@]}" get deployment "$1" -o jsonpath='{.spec.template.spec.containers[0].image}'; }
83
+ declared() { "${KUBECTL[@]}" get deployment "$1" -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="DECLARED")].value}'; }
84
+
85
+ # every manager/operation pair on the object
86
+ managers() { # managers <deployment>
87
+ "${KUBECTL[@]}" get deployment "$1" --show-managed-fields -o json |
88
+ jq -r '[.metadata.managedFields[]? | "\(.manager)/\(.operation)"] | join(" ")'
89
+ }
90
+
91
+ # manager/operation pairs owning the given field, from a fresh read
92
+ owners() { # owners <deployment> replicas|image|env
93
+ local path
94
+ case "$2" in
95
+ replicas) path='["fieldsV1","f:spec","f:replicas"]' ;;
96
+ image) path='["fieldsV1","f:spec","f:template","f:spec","f:containers","k:{\"name\":\"pause\"}","f:image"]' ;;
97
+ env) path='["fieldsV1","f:spec","f:template","f:spec","f:containers","k:{\"name\":\"pause\"}","f:env","k:{\"name\":\"DECLARED\"}"]' ;;
98
+ esac
99
+ "${KUBECTL[@]}" get deployment "$1" --show-managed-fields -o json |
100
+ jq -r --argjson p "${path}" '.metadata.managedFields[] | select(getpath($p) != null) | "\(.manager)/\(.operation)"' |
101
+ paste -sd ' ' -
102
+ }
103
+
104
+ # the managedFields entries verbatim, status/metadata pruned for reading
105
+ managed_fields_raw() { # managed_fields_raw <deployment>
106
+ "${KUBECTL[@]}" get deployment "$1" --show-managed-fields -o json |
107
+ jq -c '.metadata.managedFields[]? | {manager, operation, subresource, fieldsV1: (.fieldsV1 | del(."f:status", ."f:metadata"))}' |
108
+ sed 's/^/ /'
109
+ }
110
+
111
+ report "versions: $(kubectl --context "${CONTEXT}" version -o json 2>/dev/null | jq -r '"client \(.clientVersion.gitVersion) / server \(.serverVersion.gitVersion)"')"
112
+
113
+ kubectl --context "${CONTEXT}" apply -f "${K8S}/namespace.yaml"
114
+ "${KUBECTL[@]}" delete deployment seed-via-create --ignore-not-found --wait
115
+
116
+ echo '--- Path F: seed via create, declared apply, then remove the seed owner entry ---'
117
+
118
+ "${KUBECTL[@]}" create --field-manager="${CREATE_MANAGER}" -f "${K8S}/seed-via-create.yaml"
119
+ [ "$(replicas seed-via-create)" = "3" ] || fail "F1 create did not land the seeded replicas (replicas=$(replicas seed-via-create))"
120
+ [ "$(image seed-via-create)" = "registry.k8s.io/pause:3.9" ] || fail "F1 create did not land the seeded image (image=$(image seed-via-create))"
121
+ report "F1 managers at birth: [$(managers seed-via-create)]"
122
+
123
+ ssa "${K8S}/declared.yaml" || fail "F2 the declared apply was rejected -- a container entry cannot omit its image"
124
+ [ "$(replicas seed-via-create)" = "3" ] || fail "F2 declared apply disturbed the seeded replicas (replicas=$(replicas seed-via-create))"
125
+ [ "$(image seed-via-create)" = "registry.k8s.io/pause:3.9" ] || fail "F2 declared apply disturbed the seeded image (image=$(image seed-via-create))"
126
+ case " $(owners seed-via-create image) " in
127
+ *" ${MANAGER}/"*) fail "F2 the ongoing manager owns the image it never sent" ;;
128
+ esac
129
+ report "F2 after declared apply: image owned by [$(owners seed-via-create image)], env owned by [$(owners seed-via-create env)]"
130
+
131
+ patched=""
132
+ for attempt in 1 2 3; do
133
+ idx="$("${KUBECTL[@]}" get deployment seed-via-create --show-managed-fields -o json |
134
+ jq --arg m "${CREATE_MANAGER}" '[.metadata.managedFields[].manager] | index($m)')"
135
+ [ "${idx}" != "null" ] || fail "F3 no ${CREATE_MANAGER} entry to remove"
136
+ if "${KUBECTL[@]}" patch deployment seed-via-create --type=json -p "[
137
+ {\"op\":\"test\",\"path\":\"/metadata/managedFields/${idx}/manager\",\"value\":\"${CREATE_MANAGER}\"},
138
+ {\"op\":\"remove\",\"path\":\"/metadata/managedFields/${idx}\"}
139
+ ]"; then
140
+ patched="yes"
141
+ [ "${attempt}" = "1" ] || report "F3 removal needed ${attempt} attempts (object changed under the read)"
142
+ break
143
+ fi
144
+ done
145
+ [ -n "${patched}" ] || fail "F3 test+remove lost the race 3 times"
146
+ report "F3 managers after removal: [$(managers seed-via-create)]"
147
+
148
+ [ "$(replicas seed-via-create)" = "3" ] || fail "F4 the removal disturbed the seeded replicas (replicas=$(replicas seed-via-create))"
149
+ [ "$(image seed-via-create)" = "registry.k8s.io/pause:3.9" ] || fail "F4 the removal disturbed the seeded image (image=$(image seed-via-create))"
150
+ case " $(managers seed-via-create) " in
151
+ *" ${CREATE_MANAGER}/"*) fail "F4 the ${CREATE_MANAGER} entry survived the removal" ;;
152
+ esac
153
+ [ -z "$(owners seed-via-create image)" ] || fail "F4 image still owned by [$(owners seed-via-create image)] -- expected unowned"
154
+ [ -z "$(owners seed-via-create replicas)" ] || fail "F4 replicas still owned by [$(owners seed-via-create replicas)] -- expected unowned"
155
+ report "F4 seeds unowned, env owned by [$(owners seed-via-create env)]; managedFields verbatim:"
156
+ managed_fields_raw seed-via-create
157
+
158
+ ssa "${K8S}/declared-bumped.yaml" || fail "F5 changing a declared value conflicted -- the dead-end survives the seed-owner removal"
159
+ [ "$(declared seed-via-create)" = "two" ] || fail "F5 the declared change did not land (DECLARED=$(declared seed-via-create))"
160
+ [ "$(replicas seed-via-create)" = "3" ] || fail "F5 the declared change disturbed the unowned replicas (replicas=$(replicas seed-via-create)) -- a ghost manager came back"
161
+ [ "$(image seed-via-create)" = "registry.k8s.io/pause:3.9" ] || fail "F5 the declared change disturbed the unowned image (image=$(image seed-via-create)) -- a ghost manager came back"
162
+ echo "F5 changed-declared apply clean -- the ongoing-delivery dead-end is gone"
163
+ report "F5 env owned by [$(owners seed-via-create env)]"
164
+
165
+ "${KUBECTL[@]}" set image deployment/seed-via-create pause=registry.k8s.io/pause:3.10
166
+ [ "$(image seed-via-create)" = "registry.k8s.io/pause:3.10" ] || fail "F6 image delivery did not land (image=$(image seed-via-create))"
167
+ ssa "${K8S}/declared-bumped.yaml"
168
+ [ "$(image seed-via-create)" = "registry.k8s.io/pause:3.10" ] || fail "F6 omitting apply disturbed the delivered image (image=$(image seed-via-create))"
169
+ report "F6 delivered image stands; image owned by [$(owners seed-via-create image)]; managedFields verbatim:"
170
+ managed_fields_raw seed-via-create
171
+
172
+ "${KUBECTL[@]}" scale deployment seed-via-create --replicas=5
173
+ ssa "${K8S}/declared-bumped.yaml"
174
+ [ "$(replicas seed-via-create)" = "5" ] || fail "F7 omitting apply disturbed scaled replicas (replicas=$(replicas seed-via-create))"
175
+ report "F7 after scale: replicas owned by [$(owners seed-via-create replicas)]; managedFields verbatim:"
176
+ managed_fields_raw seed-via-create
177
+
178
+ if out="$("${KUBECTL[@]}" apply --server-side --field-manager="${OTHER_MANAGER}" -f "${K8S}/declared.yaml" 2>&1)"; then
179
+ fail "F8 a different manager changed an owned field without conflict"
180
+ fi
181
+ echo "F8 cross-manager change conflicts (frontier intact)"
182
+
183
+ set +e
184
+ out="$("${KUBECTL[@]}" diff --server-side --field-manager="${OTHER_MANAGER}" -f "${K8S}/declared.yaml" 2>&1)"
185
+ rc=$?
186
+ set -e
187
+ report "F9 diff --server-side of the conflicting payload: exit=${rc} (2 = conflict surfaced)"
188
+ echo "${out}" | head -5 | sed 's/^/ /'
189
+
190
+ if out="$("${KUBECTL[@]}" create --field-manager="${CREATE_MANAGER}" -f "${K8S}/seed-via-create.yaml" 2>&1)"; then
191
+ fail "F10 re-running the seeded create silently succeeded"
192
+ fi
193
+ echo "F10 re-running the seeded create is loud:"
194
+ echo "${out}" | sed 's/^/ /'
195
+
196
+ if la="$("${KUBECTL[@]}" apply view-last-applied deployment seed-via-create 2>/dev/null)"; then
197
+ report "F11 last-applied content:"
198
+ echo "${la}" | sed 's/^/ /'
199
+ if echo "${la}" | grep -Eq 'replicas|image'; then
200
+ fail "F11 last-applied carries a seed -- the CSA prune trap is loaded"
201
+ fi
202
+ else
203
+ report "F11 no last-applied annotation"
204
+ fi
205
+
206
+ echo "PASS: the creation protocol held; read the REPORT lines for ownership detail"
@@ -0,0 +1 @@
1
+ Gemfile.lock
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # A real platform repo pins a version: gem 'matsuri', '~> 0.3'
6
+ # This example runs against the working tree.
7
+ gem 'matsuri', path: '../..'
@@ -0,0 +1,91 @@
1
+ # Example Matsuri Repo
2
+
3
+ A working Matsuri platform repo. It boots against the minikube test
4
+ cluster, and it showcases the layout a real repo follows (see
5
+ docs/setup.md in the matsuri repo): a Gemfile pinning the matsuri
6
+ version, a `bin/matsuri` wrapper, config, an org extension library
7
+ (`lib/acme`), and resource definitions under `platform/`.
8
+
9
+ Two stacks demonstrate the two delivery modes:
10
+
11
+ - **billing** — classic client-side delivery. Matsuri owns every
12
+ field: replicas are declared, the image tracks the converge
13
+ positional.
14
+ - **checkout** — server-side delivery. The deployment applies by field
15
+ ownership; matsuri omits the image, replicas, and resources, seeds
16
+ them at creation, and cedes them to the operators that will own them
17
+ (deployment operator, HPA, in-place autoscaler).
18
+
19
+ ## Prerequisites
20
+
21
+ The minikube test cluster must be running under the `matsuri` profile:
22
+
23
+ ```bash
24
+ # from the matsuri repo root
25
+ tests/bin/minikube-start.sh
26
+ ```
27
+
28
+ The script also applies `platform/namespaces/` — raw manifests, outside
29
+ matsuri, because namespaces precede anything matsuri converges.
30
+
31
+ **macOS gotcha:** kubectl and minikube dial the VM over the local
32
+ network, which macOS gates behind a Local Network permission. Run from
33
+ a terminal that has the grant (System Settings → Privacy & Security →
34
+ Local Network). tmux counts as its own responsible process and cannot
35
+ be granted from the CLI — `no route to host` from inside tmux while the
36
+ cluster is fine is this, not the cluster.
37
+
38
+ Then, from this directory:
39
+
40
+ ```bash
41
+ bundle install
42
+ ```
43
+
44
+ ## Creating the Stacks
45
+
46
+ The first converge creates everything a stack needs:
47
+
48
+ ```bash
49
+ bin/matsuri local_billing_dev converge billing
50
+ bin/matsuri local_checkout_dev converge checkout
51
+ ```
52
+
53
+ Each stack has its own local environment, pointed at the minikube
54
+ cluster with its own namespace (`billing-dev`, `checkout-dev`); see
55
+ `config/platform.rb`. Checkout's first converge routes through the
56
+ seeding protocol: a seeded create completes the boot payload, the
57
+ declared server-side apply takes ownership of matsuri's fields, and the
58
+ seed owner cedes the rest.
59
+
60
+ ## Applying Changes
61
+
62
+ Edit a definition, preview, then converge the stack:
63
+
64
+ ```bash
65
+ bin/matsuri local_billing_dev diff stack billing # what would change
66
+ bin/matsuri local_billing_dev converge billing # apply it
67
+ ```
68
+
69
+ `show stack <name>` renders the full manifests without touching the
70
+ cluster.
71
+
72
+ For **billing**, a new image rides the converge positional:
73
+
74
+ ```bash
75
+ bin/matsuri local_billing_dev converge billing v1.2.3
76
+ ```
77
+
78
+ For **checkout**, matsuri never carries the image: converge reapplies
79
+ only the declared fields, and the seeded trio stays with whatever
80
+ manager owns it. To play the deployment operator locally:
81
+
82
+ ```bash
83
+ kubectl --context matsuri -n checkout-dev \
84
+ set image deployment/checkout-web checkout=registry.acme.io/checkout:v2
85
+ bin/matsuri local_checkout_dev converge checkout # does not claw it back
86
+ ```
87
+
88
+ The staging environments (`billing_staging`, `checkout_staging`) are
89
+ illustrative — they name a cluster that does not exist — and add the
90
+ node-selection vocabulary the local environments leave out (see the
91
+ `case Matsuri.environment` in `platform/pods/`).
@@ -0,0 +1,14 @@
1
+ #!/bin/bash -e
2
+
3
+ # Pin the bundle to this repo's Gemfile and exec. That is the wrapper's
4
+ # entire job: the Gemfile owns which matsuri runs (see docs/design/boot.md
5
+ # in the matsuri repo). Invoke from the repo base.
6
+ #
7
+ # A per-environment entry point is argument injection, in a sibling script:
8
+ # exec "$(dirname "$0")/matsuri" staging "$@"
9
+ SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
10
+ PLATFORM_DIR="${SCRIPT_DIR}/.."
11
+
12
+ export BUNDLE_GEMFILE="${PLATFORM_DIR}/Gemfile"
13
+
14
+ exec bundle exec matsuri "$@"
@@ -0,0 +1,12 @@
1
+ # Initializer for this repo
2
+ # This is where you load plugins and libraries specific to this repository
3
+
4
+ # Plugins
5
+ # require 'matsuri/traefik'
6
+ # require 'matsuri/helm'
7
+
8
+ # Local extensions
9
+ # Add into the local library. You can name it after
10
+ # your organization. Here, we'll use Acme
11
+
12
+ require 'acme'
@@ -0,0 +1,9 @@
1
+ # Overrides Matsuri::Config
2
+
3
+ # Environment names are not kubectl context names here; each
4
+ # environment names its context in config/platform.rb
5
+ map_env_to_kube_context true
6
+
7
+ # Should not be storing secrets locally
8
+ # This is for demonstration only
9
+ default(:config_secrets_path) { File.join base_path, 'etc', 'secrets' }
@@ -0,0 +1,39 @@
1
+ # This sets up different Matsuri environments
2
+ #
3
+ # Environment names map to kubectl contexts via kube_context
4
+ # (map_env_to_kube_context is set in config/matsuri.rb). The local
5
+ # environments all point at the minikube "matsuri" profile, one
6
+ # namespace per stack.
7
+
8
+ config_context :local_app_dev do
9
+ default(:kube_context) { 'matsuri' }
10
+ default(:region) { 'local' }
11
+ default(:namespace) { 'app-dev' }
12
+ end
13
+
14
+ config_context :local_billing_dev do
15
+ default(:kube_context) { 'matsuri' }
16
+ default(:region) { 'local' }
17
+ default(:namespace) { 'billing-dev' }
18
+ end
19
+
20
+ config_context :local_checkout_dev do
21
+ default(:kube_context) { 'matsuri' }
22
+ default(:region) { 'local' }
23
+ default(:namespace) { 'checkout-dev' }
24
+ end
25
+
26
+ # Staging contexts are illustrative: a real repo names the context the
27
+ # provider hands out (for GKE, let gcloud own authentication and
28
+ # naming).
29
+ config_context :billing_staging do
30
+ default(:kube_context) { 'gke_acme-platform_us-central1_staging' }
31
+ default(:region) { 'us-central1' }
32
+ default(:namespace) { 'billing' }
33
+ end
34
+
35
+ config_context :checkout_staging do
36
+ default(:kube_context) { 'gke_acme-platform_us-central1_staging' }
37
+ default(:region) { 'us-central1' }
38
+ default(:namespace) { 'checkout' }
39
+ end
@@ -0,0 +1,26 @@
1
+ # Acme's Elixir workloads can efficiently use up the entire node.
2
+ # This mixins adds helpers for scheduling workloads on nodepools
3
+ # set aside for that
4
+
5
+ module Acme
6
+ module Nodes
7
+ module Dedicated
8
+ extend Concern
9
+
10
+ included do
11
+ let(:dedicated_key) { fail Matsuri::UndefinedLet, "Must define let(:dedicated_key)" }
12
+
13
+ let(:dedicated_node_selector) { { 'dedicated' => dedicated_key } }
14
+ let(:dedicated_toleration) do
15
+ tolerate \
16
+ key: 'dedicated',
17
+ operator: 'Equal',
18
+ value: dedicated_key,
19
+ effect: 'NoSchedule'
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+
@@ -0,0 +1,17 @@
1
+ # Acme set aside nodegroups that are preemptible
2
+ module Acme
3
+ module Nodes
4
+ module Preemptible
5
+ extend Concern
6
+
7
+ included do
8
+ let(:preemptible_toleration) do
9
+ tolerate \
10
+ key: 'preemptible',
11
+ operator: 'Exists',
12
+ effect: 'NoSchedule'
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # Acme's Elixir workloads can efficiently use up the entire node.
2
+ # This mixins adds helpers for scheduling workloads on nodepools
3
+ # set aside for that
4
+
5
+ module Acme
6
+ module Nodes
7
+ module RequiredCores
8
+ extend Concern
9
+
10
+ included do
11
+ let(:required_cores) { fail Matsuri::UndefinedLet, "Must define let(:required_cores)" }
12
+
13
+ let(:cores_node_selector) { { 'cores' => required_cores } }
14
+ let(:cores_toleration) do
15
+ tolerate \
16
+ key: 'cores',
17
+ operator: 'Equal',
18
+ value: required_cores,
19
+ effect: 'NoSchedule'
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+
@@ -0,0 +1,26 @@
1
+ # Acme's Elixir workloads can efficiently use up the entire node.
2
+ # This mixins adds helpers for scheduling workloads on nodepools
3
+ # set aside for that
4
+
5
+ module Acme
6
+ module Nodes
7
+ module ResourceClass
8
+ extend Concern
9
+
10
+ included do
11
+ let(:big_disk_toleration) { tolerate_resource_class('big-disk') }
12
+ let(:stateful) { tolerate_resource_class('stateful') }
13
+ let(:autopilot) { tolerate_resource_class('autopilot') }
14
+ end
15
+
16
+ def tolerate_resource_class(resource_class_key)
17
+ tolerate \
18
+ key: 'k8s.acme.io/resource-class',
19
+ operator: 'Equal',
20
+ value: resource_class_key,
21
+ effect: 'NoSchedule'
22
+ end
23
+ end
24
+ end
25
+ end
26
+
@@ -0,0 +1,15 @@
1
+ # Acme CACerts
2
+ # Mount CACerts into a well-known directory
3
+ module Acme
4
+ module Pods
5
+ module CACerts
6
+ extend Concern
7
+
8
+ included do
9
+ let(:acme_cacerts_dir) { '/opt/acme/cacerts' }
10
+ let(:cacerts_mount) { mount('cacerts-volume', acme_cacerts_dir, read_only: true) }
11
+ let(:cacerts_volume) { config_map_volume('cacerts-volume', config_map_name: 'cacerts') }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ module Acme
2
+ module Pods
3
+ # Acme's standardized healthcheck for workloads
4
+ # Override specific let() for legacy workloads or workloads
5
+ # whose needs don't fit
6
+ #
7
+ # Usage:
8
+ # # in a pod definition, add:
9
+ # include Acme::Pods::Healthcheck
10
+ module Healthcheck
11
+ extend Concern
12
+
13
+ included do
14
+ let(:healthcheck_path) { '/healthcheck' }
15
+ # Standard Acme port. We can be fancier and read from Acme's standard web port
16
+ let(:healthcheck_port) { 8000 }
17
+ let(:healthcheck_initial_delay_seconds) { 7 }
18
+ let(:healthcheck_timeout_seconds) { 1 }
19
+
20
+ let(:readiness_probe) { healthcheck_probe }
21
+ let(:healthcheck_probe) do
22
+ {
23
+ httpGet: { path: healthcheck_path, port: healthcheck_port },
24
+ initialDelaySeconds: healthcheck_initial_delay_seconds,
25
+ timeoutSeconds: healthcheck_timeout_seconds,
26
+ }
27
+ end
28
+
29
+ # Acme's Elixir apps don't need liveness probes
30
+ # However, you can add a command liveness probe to check if it
31
+ # can still send commands to the running app
32
+ let(:liveness_probe) { nil }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # Example local library
2
+ # This is the entry point for local concerns and customizations
3
+ # for Matsuri. You can also use this register your own
4
+ # custom resources, such as Istio VirtualService, or
5
+ # CloudNativePG resources
6
+
7
+ module Acme
8
+ module Pods
9
+ # It is better for teams to standardize on healthchecks. These would
10
+ # be promises (promise theory) that workloads make. The use of let()
11
+ # allows for overrides for specific workloads
12
+ autoload :Healthcheck, 'acme/pods/healthcheck'
13
+ autoload :CACerts, 'acme/pods/cacerts'
14
+
15
+ end
16
+
17
+ module Nodes
18
+ # Scheduling
19
+ autoload :Dedicated, 'acme/nodes/dedicated'
20
+ autoload :Preemptible, 'acme/nodes/preemptible'
21
+ autoload :RequiredCores, 'acme/nodes/required_cores'
22
+ autoload :ResourceClass, 'acme/nodes/resource_class'
23
+ end
24
+
25
+ end
26
+
@@ -0,0 +1,8 @@
1
+ Matsuri.define :config_map, 'billing-settings' do
2
+ let(:data) do
3
+ {
4
+ 'LOG_LEVEL' => 'info',
5
+ 'INVOICE_BUCKET' => 'acme-invoices'
6
+ }
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # Acme's internal CA bundle, mounted by Acme::Pods::CACerts. A real
2
+ # repo would source this from the org's certificate distribution.
3
+ Matsuri.define :config_map, 'cacerts' do
4
+ let(:data) do
5
+ {
6
+ 'acme-root.pem' => "-----BEGIN CERTIFICATE-----\nEXAMPLE\n-----END CERTIFICATE-----\n"
7
+ }
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ Matsuri.define :deployment, 'billing-web' do
2
+ let(:replicas) { 2 }
3
+ let(:match_labels) { { 'app' => 'billing' } }
4
+ let(:pod_name) { 'billing' }
5
+ end
@@ -0,0 +1,6 @@
1
+ Matsuri.define :deployment, 'checkout-web' do
2
+ let(:apply_via) { :server_side_apply } # deliver by field ownership
3
+ let(:replicas) { nil } # omitted: the autoscaler owns the count
4
+ let(:match_labels) { { 'app' => 'checkout' } }
5
+ let(:pod_name) { 'checkout' }
6
+ end
@@ -0,0 +1,7 @@
1
+ # Raw manifest, outside matsuri: namespaces precede everything matsuri
2
+ # converges, so they are applied directly.
3
+ # kubectl --context matsuri apply -f platform/namespaces/
4
+ apiVersion: v1
5
+ kind: Namespace
6
+ metadata:
7
+ name: billing-dev
@@ -0,0 +1,7 @@
1
+ # Raw manifest, outside matsuri: namespaces precede everything matsuri
2
+ # converges, so they are applied directly.
3
+ # kubectl --context matsuri apply -f platform/namespaces/
4
+ apiVersion: v1
5
+ kind: Namespace
6
+ metadata:
7
+ name: checkout-dev
@@ -0,0 +1,31 @@
1
+ # Billing is stateless and reschedules cheaply, so it rides the
2
+ # preemptible pool where one exists. The image tracks the converge
3
+ # positional (default: the image_tag acceptor's default).
4
+ Matsuri.define :pod, 'billing' do
5
+ include Acme::Pods::Healthcheck
6
+ include Acme::Pods::CACerts
7
+
8
+ let(:labels) { { 'app' => 'billing' } }
9
+
10
+ # Node selection is per-environment: local clusters have no
11
+ # preemptible pool, so the scheduling vocabulary stays out entirely.
12
+ case Matsuri.environment
13
+ when 'billing_staging'
14
+ include Acme::Nodes::Preemptible
15
+ let(:tolerations) { [preemptible_toleration] }
16
+ end
17
+
18
+ let(:cpu_limit) { '500m' }
19
+ let(:mem_limit) { '512Mi' }
20
+
21
+ let(:volumes) { [cacerts_volume] }
22
+ let(:container) do
23
+ {
24
+ name: 'billing',
25
+ image: "registry.acme.io/billing:#{image_tag}",
26
+ readinessProbe: readiness_probe,
27
+ resources: resources,
28
+ volumeMounts: [cacerts_mount]
29
+ }
30
+ end
31
+ end