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