kube_cluster 1.7.0 → 1.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c57aae5fbe1903b9bc6bd63073ed4dd9d5731102b524077c4969051593b84b86
4
- data.tar.gz: 19a1be373f25d1faee87bf83072c45987ecac39cc384c932168e4afefc42a7ac
3
+ metadata.gz: 63bb454e7b06393a663b744754a36bbd4bf3e411a7b062e42f1a1db3cca08ec3
4
+ data.tar.gz: 38f4c283662fc9aab31e22efb373188ec8c9b0cf7ae1fa46c1f49e8013599edc
5
5
  SHA512:
6
- metadata.gz: db92c45266952b68584a01029cd2502caa5ab1ea2e9d010e91bf70b9385227578378d36be82c3c83eb3cf3966b04b2547732d63bd706555cd3fef6e14c28c869
7
- data.tar.gz: a53b5a9d8165400643e0693259940f5cf2695d97afd12a258e20dc7ecbd9bfd04aed135803e1d402ec2c3215bb5e17e64d98da8725cb45175a3d208b08c419b1
6
+ metadata.gz: 85b42c1eb2f29f6789af473465fbbfd45d4e21ea167a2bef10697b1d13f1801e4b88ac397523b20e1bc6dc672c439113609bdcc87fcb8d7af41df69c2bb2ca41
7
+ data.tar.gz: 371b122e930400db8ec2400f138902ee6fe454427955bb547ad4db318b4c908ed3953c7b4be0e6ecf6f581a9b3c8c46641564a83a8080b49136cfb44de9f3b02
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.8.0] - 2026-09-17
9
+
10
+ ### Added
11
+ - `Standard::RbacRules` — the rules-shorthand builder behind
12
+ `Standard::Role`, now a shared module. The spec grammar handles
13
+ `"resource"`, `"resource/subresource"`, `"group/resource"` and
14
+ `"group/resource/subresource"`: the first segment is an API group when
15
+ `Kube::Schema.api_groups` knows it (`batch`, `apps`, `argoproj.io`, ...) or
16
+ when it contains a dot (API groups are DNS subdomains, which covers CRD
17
+ groups no schema has registered). Verbs are stringified, so symbols work.
18
+ - `Standard::ClusterRole` — the rules shorthand, cluster-wide.
19
+ - `Standard::ClusterRoleBinding` — wires a ClusterRole to a ServiceAccount,
20
+ mirroring `Standard::RoleBinding`.
21
+ - `Middleware::SetNamespace` now fills blank ServiceAccount subject
22
+ namespaces on ClusterRoleBinding as well as RoleBinding. ClusterRoleBinding
23
+ is cluster-scoped, so there is no metadata.namespace to copy from — the
24
+ middleware fills the subjects directly.
25
+ - `ClusterRole` and `ClusterRoleBinding` pinned in the resolve table.
26
+
27
+ ### Changed
28
+ - Requires kube_schema `~> 1.11.0` for `Kube::Schema.api_groups`.
29
+
30
+ ### Fixed
31
+ - The rules shorthand parsed `"pods/exec"` as `apiGroups: ["pods"],
32
+ resources: ["exec"]` — a rule matching nothing. Core subresources now
33
+ render as `apiGroups: [""], resources: ["pods/exec"]`.
34
+
8
35
  ## [1.7.0] - 2026-09-17
9
36
 
10
37
  ### Added
data/Gemfile.lock CHANGED
@@ -1,10 +1,10 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kube_cluster (1.7.0)
4
+ kube_cluster (1.8.0)
5
5
  activesupport (~> 8.0)
6
6
  kube_kubectl (~> 2.0)
7
- kube_schema (~> 1.10.0)
7
+ kube_schema (~> 1.11.0)
8
8
 
9
9
  GEM
10
10
  remote: https://rubygems.org/
@@ -132,7 +132,7 @@ GEM
132
132
  rubyshell (~> 1.5)
133
133
  shellwords (~> 0.2.2)
134
134
  string_builder (~> 1.2.2)
135
- kube_schema (1.10.0)
135
+ kube_schema (1.11.0)
136
136
  json_schemer (~> 2.5.0)
137
137
  rubyshell (~> 1.5.0)
138
138
  lefthook (2.1.10)
data/kube_cluster.gemspec CHANGED
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
  spec.add_development_dependency "scampi", "~> 1.0"
33
33
  spec.add_development_dependency "lefthook", "~> 2.1"
34
34
 
35
- spec.add_dependency "kube_schema", "~> 1.10.0"
35
+ spec.add_dependency "kube_schema", "~> 1.11.0"
36
36
  spec.add_dependency "kube_kubectl", "~> 2.0"
37
37
  spec.add_dependency "activesupport", "~> 8.0"
38
38
  end
@@ -21,6 +21,29 @@ module Kube
21
21
  def call(manifest)
22
22
  manifest.resources.map! { |resource|
23
23
  filter(resource) {
24
+ # A binding's ServiceAccount subjects need an explicit
25
+ # namespace; fill in any left blank with the target namespace so
26
+ # same-namespace bindings (e.g. ServiceAccountWithRole) resolve.
27
+ # ClusterRoleBinding is cluster-scoped, so this runs before the
28
+ # scope check below.
29
+ if ['RoleBinding', 'ClusterRoleBinding'].include?(resource.kind)
30
+ h = resource.to_h
31
+ if h[:subjects].is_a?(Array)
32
+ filled = h[:subjects].map { |subject|
33
+ if subject[:kind] == 'ServiceAccount' &&
34
+ (subject[:namespace].nil? || subject[:namespace].to_s.empty?)
35
+ subject.merge(namespace: @namespace)
36
+ else
37
+ subject
38
+ end
39
+ }
40
+ if filled != h[:subjects]
41
+ h[:subjects] = filled
42
+ resource = resource.rebuild(h)
43
+ end
44
+ end
45
+ end
46
+
24
47
  next resource if resource.cluster_scoped?
25
48
 
26
49
  h = resource.to_h
@@ -30,20 +53,6 @@ module Kube
30
53
  h[:metadata][:namespace] = @namespace
31
54
  end
32
55
 
33
- # A RoleBinding's ServiceAccount subjects need an explicit
34
- # namespace; fill in any left blank with the target namespace so
35
- # same-namespace bindings (e.g. ServiceAccountWithRole) resolve.
36
- if h[:kind] == 'RoleBinding' && h[:subjects].is_a?(Array)
37
- h[:subjects] = h[:subjects].map { |subject|
38
- if subject[:kind] == 'ServiceAccount' &&
39
- (subject[:namespace].nil? || subject[:namespace].to_s.empty?)
40
- subject.merge(namespace: @namespace)
41
- else
42
- subject
43
- end
44
- }
45
- end
46
-
47
56
  resource.rebuild(h)
48
57
  }
49
58
  }
@@ -52,3 +61,63 @@ module Kube
52
61
  end
53
62
  end
54
63
  end
64
+
65
+ __END__
66
+
67
+ Middleware = Kube::Cluster::Middleware
68
+
69
+ it "sets the namespace on namespaced resources" do
70
+ m = manifest(Kube::Cluster["ConfigMap"].new { metadata.name = "test" })
71
+
72
+ Middleware::SetNamespace.new("production").call(m)
73
+
74
+ m.resources.first.to_h.dig(:metadata, :namespace).should == "production"
75
+ end
76
+
77
+ it "fills blank ServiceAccount subject namespaces on RoleBinding" do
78
+ m = manifest(Kube::Cluster["RoleBinding"].new {
79
+ metadata.name = "rb"
80
+ self.roleRef = { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "r" }
81
+ self.subjects = [{ kind: "ServiceAccount", name: "sa" }]
82
+ })
83
+
84
+ Middleware::SetNamespace.new("production").call(m)
85
+ rb = m.resources.first.to_h
86
+
87
+ rb.dig(:subjects, 0, :namespace).should == "production"
88
+ rb.dig(:metadata, :namespace).should == "production"
89
+ end
90
+
91
+ it "fills blank ServiceAccount subject namespaces on cluster-scoped ClusterRoleBinding" do
92
+ m = manifest(Kube::Cluster["ClusterRoleBinding"].new {
93
+ metadata.name = "crb"
94
+ self.roleRef = { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: "cr" }
95
+ self.subjects = [{ kind: "ServiceAccount", name: "sa" }]
96
+ })
97
+
98
+ Middleware::SetNamespace.new("production").call(m)
99
+ crb = m.resources.first.to_h
100
+
101
+ crb.dig(:subjects, 0, :namespace).should == "production"
102
+ crb.dig(:metadata, :namespace).should.be.nil
103
+ end
104
+
105
+ it "leaves pre-set subject namespaces alone" do
106
+ m = manifest(Kube::Cluster["ClusterRoleBinding"].new {
107
+ metadata.name = "crb"
108
+ self.roleRef = { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: "cr" }
109
+ self.subjects = [{ kind: "ServiceAccount", name: "sa", namespace: "other" }]
110
+ })
111
+
112
+ Middleware::SetNamespace.new("production").call(m)
113
+
114
+ m.resources.first.to_h.dig(:subjects, 0, :namespace).should == "other"
115
+ end
116
+
117
+ private
118
+
119
+ def manifest(*resources)
120
+ m = Kube::Cluster::Manifest.new
121
+ resources.each { |r| m << r }
122
+ m
123
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "kube/cluster"
5
+ require "kube/cluster/standard/rbac_rules"
6
+
7
+ module Kube
8
+ module Cluster
9
+ module Standard
10
+ # A ClusterRole with the same rules shorthand as Role (see RbacRules):
11
+ #
12
+ # ClusterRole.new(name: "node-reader", rules: {
13
+ # "nodes" => %w[get list],
14
+ # "nodes/proxy" => %w[get],
15
+ # "argoproj.io/workflows" => %w[get list],
16
+ # })
17
+ #
18
+ class ClusterRole < Kube::Cluster["ClusterRole"]
19
+ # The original rules shorthand, so a named copy can be rebuilt from it
20
+ # (rebuild downgrades the class; reconstructing keeps it a
21
+ # Standard::ClusterRole).
22
+ attr_reader :rules_input
23
+
24
+ def initialize(rules:, name: nil, &block)
25
+ @rules_input = rules
26
+
27
+ super() do
28
+ metadata.name = name if name
29
+ self.rules = RbacRules.build(rules)
30
+ instance_exec(&block) if block
31
+ end
32
+ end
33
+
34
+ def name
35
+ to_h.dig(:metadata, :name)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ __END__
43
+
44
+ describe "ClusterRole" do
45
+ it "expands the rules shorthand" do
46
+ yaml = Kube::Cluster::Standard::ClusterRole
47
+ .new(name: "r", rules: {
48
+ "namespaces" => %w[get list],
49
+ "pods/log" => %w[get],
50
+ "argoproj.io/workflows" => %w[get list],
51
+ "batch/cronjobs" => %w[get],
52
+ })
53
+ .to_yaml
54
+
55
+ yaml.include?("kind: ClusterRole").should == true
56
+ yaml.include?("- namespaces").should == true
57
+ yaml.include?("- pods/log").should == true
58
+ yaml.include?("- argoproj.io").should == true
59
+ yaml.include?("- batch").should == true
60
+ end
61
+
62
+ it "renders core subresources in the core group (not as apiGroups: [pods])" do
63
+ rules = Kube::Cluster::Standard::ClusterRole
64
+ .new(name: "r", rules: { "pods/exec" => %w[create] })
65
+ .to_h[:rules]
66
+
67
+ rules.should == [{ apiGroups: [""], resources: ["pods/exec"], verbs: %w[create] }]
68
+ end
69
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "kube/cluster"
5
+
6
+ module Kube
7
+ module Cluster
8
+ module Standard
9
+ # A ClusterRoleBinding that wires a ClusterRole to a ServiceAccount.
10
+ # Mirrors RoleBinding:
11
+ #
12
+ # ClusterRoleBinding.new(role: MyClusterRole, service_account: MyServiceAccount)
13
+ #
14
+ # The subject namespace comes from the ServiceAccount when it has one,
15
+ # and is otherwise left blank for the SetNamespace middleware to fill --
16
+ # ClusterRoleBinding is cluster-scoped, so there is no metadata.namespace
17
+ # to copy from, but the middleware fills binding subjects directly.
18
+ class ClusterRoleBinding < Kube::Cluster["ClusterRoleBinding"]
19
+ def initialize(role:, service_account:, name: nil, &block)
20
+ name ||= role.name || service_account.name
21
+ role_name = role.name || name
22
+
23
+ subject = { kind: "ServiceAccount", name: service_account.name }
24
+ subject[:namespace] = service_account.namespace if service_account.namespace
25
+
26
+ super() do
27
+ metadata.name = name
28
+ self.roleRef = {
29
+ apiGroup: "rbac.authorization.k8s.io",
30
+ kind: "ClusterRole",
31
+ name: role_name,
32
+ }
33
+ self.subjects = [subject]
34
+ instance_exec(&block) if block
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ __END__
43
+
44
+ describe "ClusterRoleBinding" do
45
+ it "references the cluster role and service account" do
46
+ yaml = Kube::Cluster::Standard::ClusterRoleBinding.new(
47
+ role: Kube::Cluster::Standard::ClusterRole.new(name: "cr", rules: { "nodes" => %w[get] }),
48
+ service_account: Kube::Cluster::Standard::ServiceAccount.new(name: "sa"),
49
+ ).to_yaml
50
+
51
+ yaml.include?("kind: ClusterRoleBinding").should == true
52
+ yaml.include?("kind: ClusterRole").should == true
53
+ yaml.include?("name: cr").should == true
54
+ yaml.include?("name: sa").should == true
55
+ end
56
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "kube/cluster"
5
+
6
+ module Kube
7
+ module Cluster
8
+ module Standard
9
+ # Builds Kubernetes RBAC PolicyRule arrays from a shorthand hash of
10
+ # resource-spec => verbs. The spec is "resource" (core API group),
11
+ # "resource/subresource" (core), "group/resource", or
12
+ # "group/resource/subresource":
13
+ #
14
+ # RbacRules.build(
15
+ # "secrets" => %w[get list],
16
+ # "pods/log" => %w[get],
17
+ # "batch/cronjobs" => %w[get],
18
+ # "argoproj.io/workflows" => %w[get list],
19
+ # "subresources.kubevirt.io/virtualmachines/restart" => %w[create],
20
+ # )
21
+ #
22
+ # The first path segment is an API group when the schema knows it as
23
+ # one (Kube::Schema.api_groups -- "batch", "apps", "argoproj.io", ...)
24
+ # or when it contains a dot: API groups are DNS subdomains, so a dotted
25
+ # segment is a group even when no schema has registered it (e.g. a CRD
26
+ # group local to one cluster). Anything else is a core resource,
27
+ # subresource included.
28
+ module RbacRules
29
+ def self.build(rules)
30
+ entries = rules.is_a?(Hash) ? [rules] : Array(rules)
31
+
32
+ entries.flat_map do |entry|
33
+ entry.map do |spec, verbs|
34
+ group, resource = split_spec(spec.to_s)
35
+ { apiGroups: [group], resources: [resource], verbs: Array(verbs).map(&:to_s) }
36
+ end
37
+ end
38
+ end
39
+
40
+ def self.split_spec(spec)
41
+ return ["", spec] unless spec.include?("/")
42
+
43
+ first, rest = spec.split("/", 2)
44
+ if Kube::Schema.api_groups.include?(first) || first.include?(".")
45
+ [first, rest]
46
+ else
47
+ ["", spec]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ __END__
56
+
57
+ describe "RbacRules" do
58
+ it "expands core resources into the empty group" do
59
+ Kube::Cluster::Standard::RbacRules
60
+ .build("secrets" => %w[get list])
61
+ .should == [{ apiGroups: [""], resources: ["secrets"], verbs: %w[get list] }]
62
+ end
63
+
64
+ it "keeps core subresources with their resource" do
65
+ Kube::Cluster::Standard::RbacRules
66
+ .build("pods/log" => %w[get])
67
+ .should == [{ apiGroups: [""], resources: ["pods/log"], verbs: %w[get] }]
68
+ end
69
+
70
+ it "splits groups the schema knows even without a dot (apps, batch)" do
71
+ Kube::Cluster::Standard::RbacRules
72
+ .build("batch/cronjobs" => %w[get], "apps/deployments" => %w[get])
73
+ .should == [
74
+ { apiGroups: ["batch"], resources: ["cronjobs"], verbs: %w[get] },
75
+ { apiGroups: ["apps"], resources: ["deployments"], verbs: %w[get] },
76
+ ]
77
+ end
78
+
79
+ it "splits dotted groups" do
80
+ Kube::Cluster::Standard::RbacRules
81
+ .build("argoproj.io/workflows" => %w[get list])
82
+ .should == [{ apiGroups: ["argoproj.io"], resources: ["workflows"], verbs: %w[get list] }]
83
+ end
84
+
85
+ it "keeps the subresource on grouped resources" do
86
+ Kube::Cluster::Standard::RbacRules
87
+ .build("subresources.kubevirt.io/virtualmachines/restart" => %w[create])
88
+ .should == [{ apiGroups: ["subresources.kubevirt.io"], resources: ["virtualmachines/restart"], verbs: %w[create] }]
89
+ end
90
+
91
+ it "treats an unregistered dotted group as a group" do
92
+ Kube::Cluster::Standard::RbacRules
93
+ .build("tradeportal.ai/tenantvmclaims" => %w[get])
94
+ .should == [{ apiGroups: ["tradeportal.ai"], resources: ["tenantvmclaims"], verbs: %w[get] }]
95
+ end
96
+
97
+ it "stringifies symbol verbs" do
98
+ Kube::Cluster::Standard::RbacRules
99
+ .build("secrets" => [:get, :list])
100
+ .should == [{ apiGroups: [""], resources: ["secrets"], verbs: %w[get list] }]
101
+ end
102
+
103
+ it "accepts an array of hashes as well as a single hash" do
104
+ Kube::Cluster::Standard::RbacRules
105
+ .build([{ "secrets" => %w[get] }, { "pods" => %w[list] }])
106
+ .should == [
107
+ { apiGroups: [""], resources: ["secrets"], verbs: %w[get] },
108
+ { apiGroups: [""], resources: ["pods"], verbs: %w[list] },
109
+ ]
110
+ end
111
+ end
@@ -2,16 +2,18 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "kube/cluster"
5
+ require "kube/cluster/standard/rbac_rules"
5
6
 
6
7
  module Kube
7
8
  module Cluster
8
9
  module Standard
9
10
  # A Role with an ergonomic rules shorthand. Each rule maps a resource
10
- # spec to its verbs; the spec is "resource" (core API group) or
11
- # "group/resource":
11
+ # spec to its verbs; the spec grammar ("resource", "resource/subresource",
12
+ # "group/resource", "group/resource/subresource") is RbacRules':
12
13
  #
13
14
  # Role.new(rules: [
14
15
  # "secrets" => %w[get list],
16
+ # "pods/log" => %w[get],
15
17
  # "batch/cronjobs" => %w[get],
16
18
  # ])
17
19
  #
@@ -25,7 +27,7 @@ module Kube
25
27
 
26
28
  super() do
27
29
  metadata.name = name if name
28
- self.rules = Role.build_rules(rules)
30
+ self.rules = RbacRules.build(rules)
29
31
  instance_exec(&block) if block
30
32
  end
31
33
  end
@@ -35,14 +37,7 @@ module Kube
35
37
  end
36
38
 
37
39
  def self.build_rules(rules)
38
- entries = rules.is_a?(Hash) ? [rules] : Array(rules)
39
-
40
- entries.flat_map do |entry|
41
- entry.map do |spec, verbs|
42
- group, resource = spec.include?("/") ? spec.split("/", 2) : ["", spec]
43
- { apiGroups: [group], resources: [resource], verbs: Array(verbs) }
44
- end
45
- end
40
+ RbacRules.build(rules)
46
41
  end
47
42
  end
48
43
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kube
4
4
  module Cluster
5
- VERSION = "1.7.0"
5
+ VERSION = "1.8.0"
6
6
  end
7
7
  end
data/lib/kube/cluster.rb CHANGED
@@ -103,6 +103,8 @@ Kube::Cluster.config do
103
103
  resolve "Job", to: "batch/v1/Job"
104
104
  resolve "Role", to: "rbac.authorization.k8s.io/v1/Role"
105
105
  resolve "RoleBinding", to: "rbac.authorization.k8s.io/v1/RoleBinding"
106
+ resolve "ClusterRole", to: "rbac.authorization.k8s.io/v1/ClusterRole"
107
+ resolve "ClusterRoleBinding", to: "rbac.authorization.k8s.io/v1/ClusterRoleBinding"
106
108
  resolve "CustomResourceDefinition", to: "apiextensions.k8s.io/v1/CustomResourceDefinition"
107
109
 
108
110
  # Metacontroller.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kube_cluster
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan K
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: 1.10.0
60
+ version: 1.11.0
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: 1.10.0
67
+ version: 1.11.0
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: kube_kubectl
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -219,6 +219,8 @@ files:
219
219
  - lib/kube/cluster/standard/cloud_native_pg/database_with_external_secret.rb
220
220
  - lib/kube/cluster/standard/cloud_native_pg/external_secret.rb
221
221
  - lib/kube/cluster/standard/cloud_native_pg/helm.rb
222
+ - lib/kube/cluster/standard/cluster_role.rb
223
+ - lib/kube/cluster/standard/cluster_role_binding.rb
222
224
  - lib/kube/cluster/standard/config_map.rb
223
225
  - lib/kube/cluster/standard/cron_job.rb
224
226
  - lib/kube/cluster/standard/cron_job_with_service_account.rb
@@ -239,6 +241,7 @@ files:
239
241
  - lib/kube/cluster/standard/perses/perses.rb
240
242
  - lib/kube/cluster/standard/perses/perses_datasource.rb
241
243
  - lib/kube/cluster/standard/persistent_volume_claim.rb
244
+ - lib/kube/cluster/standard/rbac_rules.rb
242
245
  - lib/kube/cluster/standard/role.rb
243
246
  - lib/kube/cluster/standard/role_binding.rb
244
247
  - lib/kube/cluster/standard/secret.rb