kube_cluster 0.3.2 → 0.3.4

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/Gemfile.lock +20 -12
  4. data/examples/01-basic-redis-pod/Gemfile +5 -0
  5. data/examples/01-basic-redis-pod/README.md +30 -0
  6. data/examples/01-basic-redis-pod/bin/dev +8 -0
  7. data/examples/01-basic-redis-pod/docker-compose.yml +14 -0
  8. data/examples/01-basic-redis-pod/manifest.rb +1 -1
  9. data/examples/01-basic-redis-pod/redis.conf +2 -0
  10. data/examples/02-manifest-with-middleware/Dockerfile +19 -0
  11. data/examples/02-manifest-with-middleware/Gemfile +5 -0
  12. data/examples/02-manifest-with-middleware/README.md +34 -0
  13. data/examples/02-manifest-with-middleware/bin/dev +9 -0
  14. data/examples/02-manifest-with-middleware/config.ru +3 -0
  15. data/examples/02-manifest-with-middleware/docker-compose.yml +25 -0
  16. data/examples/02-manifest-with-middleware/falcon.rb +11 -0
  17. data/examples/02-manifest-with-middleware/manifest.rb +1 -1
  18. data/examples/02-manifest-with-middleware/registries.yaml +4 -0
  19. data/examples/03-app-with-database/Gemfile +5 -0
  20. data/examples/04-pod-with-ingress/Dockerfile +20 -0
  21. data/examples/04-pod-with-ingress/Gemfile +10 -0
  22. data/examples/04-pod-with-ingress/README.md +42 -0
  23. data/examples/04-pod-with-ingress/bin/dev +10 -0
  24. data/examples/04-pod-with-ingress/config.ru +3 -0
  25. data/examples/04-pod-with-ingress/docker-compose.yml +35 -0
  26. data/examples/04-pod-with-ingress/falcon.rb +11 -0
  27. data/examples/04-pod-with-ingress/manifest.rb +15 -0
  28. data/examples/04-pod-with-ingress/pod_with_ingress.rb +60 -0
  29. data/examples/04-pod-with-ingress/registries.yaml +4 -0
  30. data/examples/05-helm-chart-to-manifest/Gemfile +5 -0
  31. data/examples/05-helm-chart-to-manifest/README.md +44 -0
  32. data/examples/05-helm-chart-to-manifest/bin/dev +6 -0
  33. data/examples/05-helm-chart-to-manifest/docker-compose.yml +16 -0
  34. data/examples/05-helm-chart-to-manifest/manifest.rb +18 -0
  35. data/examples/06-nginx-with-cert-manager/Gemfile +5 -0
  36. data/examples/06-nginx-with-cert-manager/README.md +57 -0
  37. data/examples/06-nginx-with-cert-manager/bin/dev +6 -0
  38. data/examples/06-nginx-with-cert-manager/docker-compose.yml +16 -0
  39. data/examples/06-nginx-with-cert-manager/manifest.rb +45 -0
  40. data/examples/06-nginx-with-cert-manager/resources/namespace.rb +7 -0
  41. data/examples/06-nginx-with-cert-manager/resources/nginx.rb +156 -0
  42. data/examples/06-nginx-with-cert-manager/resources/self_signed_issuer.rb +11 -0
  43. data/examples/README.md +3 -0
  44. data/kube_cluster.gemspec +0 -1
  45. data/lib/kube/cluster/connection.rb +2 -1
  46. data/lib/kube/cluster/resource/extensions/README.md +15 -0
  47. data/lib/kube/cluster/resource/extensions/custom_resource_definition.rb +35 -0
  48. data/lib/kube/cluster/resource/persistence.rb +16 -3
  49. data/lib/kube/cluster/resource.rb +9 -0
  50. data/lib/kube/cluster/version.rb +1 -1
  51. data/lib/kube/cluster.rb +7 -4
  52. data/lib/kube/helm/chart.rb +203 -0
  53. data/lib/kube/helm/endpoint.rb +75 -0
  54. data/lib/kube/helm/repo.rb +121 -0
  55. metadata +44 -15
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+
3
+ docker compose up -d
4
+
5
+ bundle exec ruby manifest.rb
6
+ kubectl apply -f manifest.yaml
@@ -0,0 +1,16 @@
1
+ services:
2
+ cluster:
3
+ image: "rancher/k3s:latest"
4
+ command: server
5
+ privileged: true
6
+ restart: always
7
+ environment:
8
+ - K3S_TOKEN=change-me-or-face-the-consequences
9
+ - K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yaml
10
+ - K3S_KUBECONFIG_MODE=666
11
+ volumes:
12
+ - .:/output
13
+ ports:
14
+ - 6443:6443
15
+ - 80:80
16
+ - 443:443
@@ -0,0 +1,45 @@
1
+ require "bundler/setup"
2
+ require "kube/cluster"
3
+
4
+ CertManager = Kube::Helm::Repo
5
+ .new("jetstack", url: "https://charts.jetstack.io")
6
+ .fetch("cert-manager", version: "1.17.2")
7
+
8
+ CertManager.crds.each do |crd|
9
+ crd.to_json_schema.then do |s|
10
+ Kube::Schema.register(
11
+ s[:kind],
12
+ schema: s[:schema],
13
+ api_version: s[:api_version]
14
+ )
15
+ end
16
+ end
17
+
18
+ require_relative "resources/namespace"
19
+ require_relative "resources/nginx"
20
+ require_relative "resources/self_signed_issuer"
21
+
22
+ manifest =
23
+ Kube::Cluster::Manifest.new(
24
+ CertManager.apply_values(
25
+ {
26
+ "installCRDs" => true,
27
+ "replicaCount" => 2,
28
+ "resources" => {
29
+ "requests" => { "cpu" => "50m", "memory" => "64Mi" },
30
+ "limits" => { "cpu" => "200m", "memory" => "128Mi" },
31
+ },
32
+ "webhook" => {
33
+ "replicaCount" => 2,
34
+ },
35
+ },
36
+ release: "cert-manager",
37
+ namespace: "cert-manager",
38
+ ),
39
+
40
+ Namespace.new(name: "cert-manager"),
41
+ Nginx.new(name: "nginx-app", host: "app.example.com"),
42
+ SelfSignedIssuer.new,
43
+ )
44
+
45
+ puts manifest.to_yaml
@@ -0,0 +1,7 @@
1
+ class Namespace < Kube::Cluster["Namespace"]
2
+ def initialize(name:, **options, &block)
3
+ super {
4
+ metadata.name = name
5
+ }
6
+ end
7
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Nginx < Kube::Cluster::Manifest
4
+ def initialize(name:, host:, &block)
5
+ ns = name
6
+ match_labels = { app: name }
7
+
8
+ super(
9
+ Kube::Cluster["Namespace"].new {
10
+ metadata.name = ns
11
+ metadata.labels = match_labels
12
+ },
13
+
14
+ Kube::Cluster["ConfigMap"].new {
15
+ metadata.name = "#{name}-config"
16
+ metadata.namespace = ns
17
+ metadata.labels = match_labels
18
+
19
+ self.data = {
20
+ "default.conf" => <<~NGINX,
21
+ server {
22
+ listen 80;
23
+ server_name _;
24
+
25
+ location / {
26
+ root /usr/share/nginx/html;
27
+ index index.html;
28
+ }
29
+
30
+ location /healthz {
31
+ access_log off;
32
+ return 200 "ok\\n";
33
+ add_header Content-Type text/plain;
34
+ }
35
+ }
36
+ NGINX
37
+
38
+ "index.html" => <<~HTML,
39
+ <!DOCTYPE html>
40
+ <html>
41
+ <head><title>Hello from Nginx</title></head>
42
+ <body>
43
+ <h1>Hello from Nginx with TLS!</h1>
44
+ <p>Certificates managed by cert-manager.</p>
45
+ </body>
46
+ </html>
47
+ HTML
48
+ }
49
+ },
50
+
51
+ Kube::Cluster["Deployment"].new {
52
+ metadata.name = name
53
+ metadata.namespace = ns
54
+ metadata.labels = match_labels
55
+
56
+ spec.replicas = 2
57
+ spec.selector.matchLabels = match_labels
58
+
59
+ spec.template.metadata.labels = match_labels
60
+ spec.template.spec.containers = [
61
+ {
62
+ name: name,
63
+ image: "nginx:1.27-alpine",
64
+ ports: [
65
+ { name: "http", containerPort: 80, protocol: "TCP" },
66
+ ],
67
+ resources: {
68
+ requests: { cpu: "50m", memory: "64Mi" },
69
+ limits: { cpu: "200m", memory: "128Mi" },
70
+ },
71
+ volumeMounts: [
72
+ { name: "nginx-config", mountPath: "/etc/nginx/conf.d", readOnly: true },
73
+ { name: "nginx-html", mountPath: "/usr/share/nginx/html", readOnly: true },
74
+ ],
75
+ livenessProbe: {
76
+ httpGet: { path: "/healthz", port: "http" },
77
+ initialDelaySeconds: 5,
78
+ periodSeconds: 10,
79
+ },
80
+ readinessProbe: {
81
+ httpGet: { path: "/healthz", port: "http" },
82
+ initialDelaySeconds: 2,
83
+ periodSeconds: 5,
84
+ },
85
+ },
86
+ ]
87
+
88
+ spec.template.spec.volumes = [
89
+ {
90
+ name: "nginx-config",
91
+ configMap: {
92
+ name: "#{name}-config",
93
+ items: [{ key: "default.conf", path: "default.conf" }],
94
+ },
95
+ },
96
+ {
97
+ name: "nginx-html",
98
+ configMap: {
99
+ name: "#{name}-config",
100
+ items: [{ key: "index.html", path: "index.html" }],
101
+ },
102
+ },
103
+ ]
104
+ },
105
+
106
+ Kube::Cluster["Service"].new {
107
+ metadata.name = name
108
+ metadata.namespace = ns
109
+ metadata.labels = match_labels
110
+
111
+ spec.selector = match_labels
112
+ spec.ports = [
113
+ { name: "http", port: 80, targetPort: "http", protocol: "TCP" },
114
+ ]
115
+ },
116
+
117
+ Kube::Cluster["Ingress"].new {
118
+ metadata.name = name
119
+ metadata.namespace = ns
120
+ metadata.labels = match_labels
121
+ metadata.annotations = {
122
+ "cert-manager.io/cluster-issuer": "selfsigned",
123
+ }
124
+
125
+ spec.ingressClassName = "traefik"
126
+ spec.tls = [
127
+ {
128
+ hosts: [host],
129
+ secretName: "#{name}-tls",
130
+ },
131
+ ]
132
+ spec.rules = [
133
+ {
134
+ host: host,
135
+ http: {
136
+ paths: [
137
+ {
138
+ path: "/",
139
+ pathType: "Prefix",
140
+ backend: {
141
+ service: {
142
+ name: name,
143
+ port: { name: "http" },
144
+ },
145
+ },
146
+ },
147
+ ],
148
+ },
149
+ },
150
+ ]
151
+ },
152
+ )
153
+
154
+ instance_exec(&block) if block
155
+ end
156
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SelfSignedIssuer < Kube::Cluster["ClusterIssuer"]
4
+ def initialize
5
+ super {
6
+ metadata.name = "selfsigned"
7
+
8
+ spec.selfSigned = {}
9
+ }
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ UNDER DEVELOPMENT: Most of these are unfinished. The API is still in active development, and these are filled in as experiments happen.
2
+
3
+ Feel free to contribute your own experiments.
data/kube_cluster.gemspec CHANGED
@@ -33,6 +33,5 @@ Gem::Specification.new do |spec|
33
33
  spec.add_development_dependency "rubocop", "~> 1.21"
34
34
 
35
35
  spec.add_dependency "kube_schema", "~> 1.3.0"
36
- spec.add_dependency "kube_kit", "> 0"
37
36
  spec.add_dependency "kube_kubectl", "~> 2.0.0"
38
37
  end
@@ -3,11 +3,12 @@
3
3
  module Kube
4
4
  module Cluster
5
5
  class Connection
6
- attr_reader :kubeconfig, :ctl
6
+ attr_reader :kubeconfig, :ctl, :helm
7
7
 
8
8
  def initialize(kubeconfig:)
9
9
  @kubeconfig = kubeconfig
10
10
  @ctl = Kube::Ctl::Instance.new(kubeconfig: kubeconfig)
11
+ @helm = Kube::Helm::Instance.new(kubeconfig: kubeconfig)
11
12
  end
12
13
 
13
14
  def inspect
@@ -0,0 +1,15 @@
1
+ # Resource Extensions
2
+
3
+ Modules in this directory are dynamically mixed into `Kube::Cluster::Resource` instances at initialization time (via `extend`).
4
+
5
+ ## Why modules instead of subclasses?
6
+
7
+ `Kube::Cluster["Deployment"]` returns a versioned schema class generated at runtime. Because these classes are versioned and generated dynamically, we can't subclass them directly -- there's no stable class to inherit from.
8
+
9
+ Instead, we define extension modules here and dynamically insert them when the resource is instantiated. In `Resource#initialize`, after the object is built, the matching extension module is applied:
10
+
11
+ ```ruby
12
+ extend Extensions.const_get(kind) if Extensions.const_defined?(kind)
13
+ ```
14
+
15
+ This gives us a way to add kind-specific behaviour to resource instances without coupling to any particular schema version.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kube
4
+ module Cluster
5
+ class Resource < Kube::Schema::Resource
6
+ module Extensions
7
+ module CustomResourceDefinition
8
+ def to_json_schema
9
+ h = to_h
10
+ kind = h.dig(:spec, :names, :kind)
11
+ group = h.dig(:spec, :group)
12
+ versions = h.dig(:spec, :versions) || []
13
+
14
+ version = versions.find { |v| v[:storage] } ||
15
+ versions.find { |v| v.dig(:schema, :openAPIV3Schema) } ||
16
+ versions.first
17
+
18
+ raise ArgumentError, "CRD has no versions" unless version
19
+
20
+ version_name = version[:name]
21
+ schema = version.dig(:schema, :openAPIV3Schema)
22
+
23
+ raise ArgumentError, "CRD version #{version_name} has no openAPIV3Schema" unless schema
24
+
25
+ {
26
+ kind: kind,
27
+ schema: deep_stringify_keys(schema),
28
+ api_version: "#{group}/#{version_name}",
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -7,6 +7,14 @@ module Kube
7
7
  module Cluster
8
8
  class Resource < Kube::Schema::Resource
9
9
  module Persistence
10
+ def name
11
+ to_h.dig(:metadata, :name)&.to_s
12
+ end
13
+
14
+ def persisted?
15
+ !name.nil? && !name.empty?
16
+ end
17
+
10
18
  def apply
11
19
  JSON.generate(deep_stringify_keys(to_h)).then do |json|
12
20
  kubectl("apply", "-f", "-", stdin: json)
@@ -23,7 +31,7 @@ module Kube
23
31
  false
24
32
  else
25
33
  json = JSON.generate(deep_stringify_keys(diff))
26
- kubectl("patch", resource_type, name, *ns_flags, "--type", type, "-p", json)
34
+ kubectl("patch", kind.downcase, name, *namespace_flags, "--type", type, "-p", json)
27
35
  reload
28
36
  true
29
37
  end
@@ -34,7 +42,7 @@ module Kube
34
42
 
35
43
  def delete
36
44
  if persisted?
37
- kubectl("delete", resource_type, name, *ns_flags)
45
+ kubectl("delete", kind.downcase, name, *namespace_flags)
38
46
  true
39
47
  else
40
48
  raise Kube::CommandError, "cannot delete a resource without a name"
@@ -44,7 +52,7 @@ module Kube
44
52
  def reload
45
53
  if persisted?
46
54
  tap do
47
- kubectl("get", resource_type, name, *ns_flags, "-o", "json").then do |json|
55
+ kubectl("get", kind.downcase, name, *namespace_flags, "-o", "json").then do |json|
48
56
  JSON.parse(json).then do |hash|
49
57
  @data = deep_symbolize_keys(hash)
50
58
  snapshot!
@@ -58,6 +66,11 @@ module Kube
58
66
 
59
67
  private
60
68
 
69
+ def namespace_flags
70
+ ns = to_h.dig(:metadata, :namespace)
71
+ ns ? ["--namespace", ns.to_s] : []
72
+ end
73
+
61
74
  def kubectl(*args)
62
75
  @cluster.connection.ctl.run(args.join(" "))
63
76
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "resource/dirty_tracking"
4
4
  require_relative "resource/persistence"
5
+ require_relative "resource/extensions/custom_resource_definition"
5
6
 
6
7
  module Kube
7
8
  module Cluster
@@ -39,6 +40,14 @@ module Kube
39
40
  @cluster = hash.delete(:cluster)
40
41
  super
41
42
  snapshot!
43
+
44
+ begin
45
+ extend Object.const_get(
46
+ "Kube::Cluster::Resource::Extensions::#{kind}"
47
+ )
48
+ rescue
49
+ nil
50
+ end
42
51
  end
43
52
 
44
53
  # Build a new resource of the same schema subclass from a hash.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kube
4
4
  module Cluster
5
- VERSION = "0.3.2"
5
+ VERSION = "0.3.4"
6
6
  end
7
7
  end
data/lib/kube/cluster.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "cluster/resource"
9
9
  require_relative "cluster/manifest"
10
10
  require_relative "cluster/middleware"
11
11
  require 'kube/ctl'
12
+ require_relative 'helm/repo'
12
13
 
13
14
  module Kube
14
15
  def self.cluster
@@ -31,11 +32,13 @@ module Kube
31
32
  @resource_classes[kind] ||= begin
32
33
  schema_class = Kube::Schema[kind]
33
34
  Class.new(Resource) do
34
- @schema = schema_class.schema
35
- @defaults = schema_class.defaults
35
+ @schema = schema_class.schema
36
+ @defaults = schema_class.defaults
37
+ @schema_properties = schema_class.schema_properties
36
38
 
37
- def self.schema = @schema || superclass.schema
38
- def self.defaults = @defaults || superclass.defaults
39
+ def self.schema = @schema || superclass.schema
40
+ def self.defaults = @defaults || superclass.defaults
41
+ def self.schema_properties = @schema_properties || superclass.schema_properties
39
42
  end
40
43
  end
41
44
  end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "yaml"
5
+
6
+ module Kube
7
+ module Helm
8
+ # Represents a Helm Chart.yaml as a Ruby object.
9
+ #
10
+ # The Chart holds metadata from Chart.yaml and can render resources
11
+ # via #apply_values. It can be backed by either a local directory
12
+ # (with templates on disk) or a remote chart reference.
13
+ #
14
+ # # Virtual chart (just metadata, no templates)
15
+ # chart = Kube::Helm::Chart.new {
16
+ # name = "my-app"
17
+ # version = "1.0.0"
18
+ # appVersion = "3.4.5"
19
+ # }
20
+ #
21
+ # # Load from a local chart directory
22
+ # chart = Kube::Helm::Chart.open("./charts/my-app")
23
+ # manifest = chart.apply_values({ "replicaCount" => 3 })
24
+ #
25
+ # # From a remote repo
26
+ # manifest = Kube::Helm::Repo
27
+ # .new("bitnami", url: "https://charts.bitnami.com/bitnami")
28
+ # .fetch("nginx", version: "18.1.0")
29
+ # .apply_values({ "replicaCount" => 3 })
30
+ #
31
+ class Chart
32
+ attr_reader :path, :ref, :cluster
33
+
34
+ # Open a chart from a local directory. Reads Chart.yaml and stores
35
+ # the path so that helm commands run against the local chart.
36
+ #
37
+ # @param path [String] path to the chart directory
38
+ # @param cluster [Kube::Cluster::Instance, nil] optional cluster connection
39
+ # @return [Chart]
40
+ def self.open(path, cluster: nil)
41
+ chart_file = File.join(path, "Chart.yaml")
42
+ raise Kube::Error, "No Chart.yaml found at #{path}" unless File.exist?(chart_file)
43
+
44
+ yaml = YAML.safe_load_file(chart_file)
45
+ new(yaml, path: path, cluster: cluster)
46
+ end
47
+
48
+ # @param data [Hash] Chart.yaml content (string or symbol keys)
49
+ # @param path [String, nil] filesystem path to chart directory (local charts)
50
+ # @param ref [String, nil] chart reference for helm commands (remote charts)
51
+ # @param cluster [Kube::Cluster::Instance, nil] optional cluster connection
52
+ def initialize(data = {}, path: nil, ref: nil, cluster: nil, &block)
53
+ @data = deep_symbolize_keys(data)
54
+ @path = path
55
+ @ref = ref
56
+ @cluster = cluster
57
+ @data.instance_exec(&block) if block_given?
58
+ end
59
+
60
+ def name = @data[:name]
61
+ def version = @data[:version]
62
+ def app_version = @data[:appVersion]
63
+ def description = @data[:description]
64
+ def type = @data[:type]
65
+ def dependencies = @data[:dependencies] || []
66
+
67
+ # Render the chart templates with values applied.
68
+ #
69
+ # Shells out to `helm template` and returns a Manifest of typed
70
+ # Resource objects.
71
+ #
72
+ # @param values [Hash] values to apply to the chart templates
73
+ # @param release [String, nil] release name (defaults to chart name)
74
+ # @param namespace [String, nil] namespace for rendered resources
75
+ # @return [Kube::Schema::Manifest]
76
+ def apply_values(values, release: nil, namespace: nil)
77
+ raise Kube::Error, "No chart source" unless source
78
+
79
+ release_name = release || name
80
+ source_ref = source
81
+ ver = version_flag
82
+
83
+ cmd = helm.call { template.(release_name).(source_ref) }
84
+ cmd = cmd.version(ver) if ver
85
+ cmd = cmd.namespace(namespace) if namespace
86
+
87
+ if values.is_a?(Hash) && values.any?
88
+ tmpfile = write_values_tempfile(values)
89
+ cmd = cmd.f(tmpfile.path)
90
+ end
91
+
92
+ Kube::Schema::Manifest.parse(helm.run(cmd.to_s))
93
+ end
94
+
95
+ # Show the chart's default values.
96
+ #
97
+ # @return [Hash] the default values as a Ruby hash
98
+ def show_values
99
+ raise Kube::Error, "No chart source" unless source
100
+
101
+ source_ref = source
102
+ ver = version_flag
103
+ cmd = helm.call { show.values.(source_ref) }
104
+ cmd = cmd.version(ver) if ver
105
+
106
+ YAML.safe_load(helm.run(cmd.to_s), permitted_classes: [Symbol]) || {}
107
+ end
108
+
109
+ # Return the chart's CRDs as Kube::Cluster::CustomResourceDefinition objects.
110
+ #
111
+ # First tries `helm show crds`. If that returns nothing (some charts
112
+ # ship CRDs in templates rather than the crds/ directory), falls back
113
+ # to rendering via `helm template --set installCRDs=true` and filtering
114
+ # for CustomResourceDefinition resources.
115
+ #
116
+ # chart.crds.each do |crd|
117
+ # s = crd.to_json_schema
118
+ # Kube::Schema.register(s[:kind], schema: s[:schema], api_version: s[:api_version])
119
+ # end
120
+ #
121
+ # @return [Array<Kube::Cluster::CustomResourceDefinition>]
122
+ def crds
123
+ raise Kube::Error, "No chart source" unless source
124
+
125
+ results = crds_from_show
126
+ results = crds_from_template if results.empty?
127
+ results
128
+ end
129
+
130
+ def to_s
131
+ version ? "#{name}:#{version}" : name.to_s
132
+ end
133
+
134
+ private
135
+
136
+ def crds_from_show
137
+ source_ref = source
138
+ ver = version_flag
139
+ cmd = helm.call { show.crds.(source_ref) }
140
+ cmd = cmd.version(ver) if ver
141
+
142
+ yaml_output = helm.run(cmd.to_s)
143
+ parse_crds(yaml_output)
144
+ end
145
+
146
+ def crds_from_template
147
+ source_ref = source
148
+ ver = version_flag
149
+ release_name = name || "crds"
150
+
151
+ cmd = helm.call { template.(release_name).(source_ref) }
152
+ cmd = cmd.version(ver) if ver
153
+ cmd = cmd.set("installCRDs=true")
154
+
155
+ yaml_output = helm.run(cmd.to_s)
156
+ parse_crds(yaml_output)
157
+ end
158
+
159
+ def parse_crds(yaml_output)
160
+ return [] if yaml_output.nil? || yaml_output.strip.empty?
161
+
162
+ docs = YAML.safe_load_stream(yaml_output, permitted_classes: [Symbol])
163
+ docs.compact
164
+ .select { |doc| doc.is_a?(Hash) && doc["kind"] == "CustomResourceDefinition" }
165
+ .map { |doc| Kube::Cluster["CustomResourceDefinition"].new(doc) }
166
+ end
167
+
168
+ # The chart source for helm commands — either a local path or a remote ref.
169
+ def source
170
+ @path || @ref
171
+ end
172
+
173
+ # Version flag for remote charts. Local charts don't need --version.
174
+ def version_flag
175
+ @ref ? version : nil
176
+ end
177
+
178
+ def helm
179
+ @cluster&.connection&.helm || Kube::Helm::Instance.new
180
+ end
181
+
182
+ def write_values_tempfile(values)
183
+ tmpfile = Tempfile.new(["helm-values-", ".yaml"])
184
+ tmpfile.write(values.to_yaml)
185
+ tmpfile.flush
186
+ tmpfile
187
+ end
188
+
189
+ def deep_symbolize_keys(obj)
190
+ case obj
191
+ when Hash
192
+ obj.each_with_object({}) do |(k, v), result|
193
+ result[k.to_sym] = deep_symbolize_keys(v)
194
+ end
195
+ when Array
196
+ obj.map { |v| deep_symbolize_keys(v) }
197
+ else
198
+ obj
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end