From 4ae681a7d6e3c1770b15aab73feda452338c3531 Mon Sep 17 00:00:00 2001 From: Rubionic Date: Mon, 29 Dec 2025 12:00:10 +0000 Subject: [PATCH 1/9] fix: remove ostruct dependency for Ruby 3.5+ compatibility Replace OpenStruct with custom PropertyStruct implementation to ensure compatibility with Ruby 3.5+ where ostruct is being removed from the standard library. The PropertyStruct class provides the same dynamic attribute access functionality as OpenStruct but without requiring the external gem. Fixes rkoster/rubionic-workspace#229 Related to cloudfoundry/bosh-cli#708 --- templatescompiler/erbrenderer/erb_renderer.rb | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/templatescompiler/erbrenderer/erb_renderer.rb b/templatescompiler/erbrenderer/erb_renderer.rb index 6bc7778f5..044e6973b 100644 --- a/templatescompiler/erbrenderer/erb_renderer.rb +++ b/templatescompiler/erbrenderer/erb_renderer.rb @@ -1,10 +1,32 @@ # Based on common/properties/template_evaluation_context.rb require "rubygems" -require "ostruct" require "json" require "erb" require "yaml" +# Simple struct-like class to replace OpenStruct dependency +# OpenStruct is being removed from Ruby standard library in Ruby 3.5+ +class PropertyStruct + def initialize(hash = {}) + @table = {} + hash.each do |key, value| + @table[key.to_sym] = value + end + end + + def method_missing(method_name, *args) + if method_name.to_s.end_with?("=") + @table[method_name.to_s.chomp("=").to_sym] = args.first + else + @table[method_name.to_sym] + end + end + + def respond_to_missing?(method_name, include_private = false) + true + end +end + class Hash def recursive_merge!(other) merge!(other) do |_, old_value, new_value| @@ -101,7 +123,7 @@ def openstruct(object) mapped = object.each_with_object({}) { |(k, v), h| h[k] = openstruct(v) } - OpenStruct.new(mapped) + PropertyStruct.new(mapped) when Array object.map { |item| openstruct(item) } else From ba9761bc7be5ec1f47350bb4260f8b0d35e77f86 Mon Sep 17 00:00:00 2001 From: Rubionic Date: Thu, 8 Jan 2026 08:18:09 +0000 Subject: [PATCH 2/9] test: add comprehensive unit tests for PropertyStruct Analyzed ERB templates from 11+ Cloud Foundry repositories to identify all real-world PropertyStruct usage patterns: **Repositories Analyzed:** - cloudfoundry/bosh (director, nats, postgres, health monitor, blobstore) - cloudfoundry/routing-release (gorouter, route registrar, routing API, tcp router) - cloudfoundry/uaa-release (OAuth, SAML, database configuration) - pivotal/credhub-release (encryption providers, HSM integration) - cloudfoundry/bosh-aws-cpi-release - cloudfoundry/bosh-google-cpi-release (certificate handling) - cloudfoundry/bosh-openstack-cpi-release - cloudfoundry/bosh-vsphere-cpi-release - cloudfoundry/bosh-warden-cpi-release - cloudfoundry/bosh-docker-cpi-release - cloudfoundry/bosh-virtualbox-cpi-release **Comprehensive Test Coverage:** Array Operations: - .map(&:symbol), .map { block } - Transformations - .select, .compact - Filtering nils/empty values - .find - Finding elements by condition - .flatten - Nested array flattening - .any? - Predicate checking - .include? - Membership testing - .reject - Filtering with negation - .uniq - Removing duplicates - .first, .last - Array accessors - .join - Array joining Method Chaining: - .to_yaml.gsub - Config generation with string processing - .lines.map - Multiline text indentation - .split - URL/string parsing - .sort_by(&:to_s) - Mixed type sorting Iteration Patterns: - .each_with_index - Indexed iteration Hash Operations: - .keys.sort - Deterministic ordering - .key? - Membership testing - .values - Value extraction - .merge - Combining hashes String Conditionals: - .start_with? - Prefix checking - .empty?, .nil? - Empty/nil validation - .gsub - Pattern replacement - .index - Substring position Type Conversions: - .to_i, .to_s - Type conversions These tests ensure PropertyStruct maintains 100% compatibility with OpenStruct for all usage patterns found in production Cloud Foundry deployments. Related to rkoster/rubionic-workspace#229 --- .../erbrenderer/erb_renderer_test.go | 1211 +++++++++++++++++ 1 file changed, 1211 insertions(+) diff --git a/templatescompiler/erbrenderer/erb_renderer_test.go b/templatescompiler/erbrenderer/erb_renderer_test.go index b52a37ed4..2398d1175 100644 --- a/templatescompiler/erbrenderer/erb_renderer_test.go +++ b/templatescompiler/erbrenderer/erb_renderer_test.go @@ -19,6 +19,7 @@ import ( type testTemplateEvaluationStruct struct { Index int `json:"index"` ID string `json:"id"` + IP string `json:"ip,omitempty"` GlobalProperties map[string]interface{} `json:"global_properties"` ClusterProperties map[string]interface{} `json:"cluster_properties"` DefaultProperties map[string]interface{} `json:"default_properties"` @@ -117,6 +118,1216 @@ property3: default_value3 }) }) + Context("with nested property access patterns", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "director": map[string]interface{}{ + "db": map[string]interface{}{ + "user": "admin", + "password": "secret", + "host": "localhost", + "port": 5432, + }, + "name": "test-director", + }, + "nats": map[string]interface{}{ + "address": "10.0.0.1", + "port": 4222, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "director.db.user": "default_user", + "director.db.password": "default_pass", + "director.db.host": "default_host", + "director.db.port": "default_port", + "director.name": "default_name", + "nats.address": "default_nats", + "nats.port": "default_port", + }, + }, + } + }) + + It("accesses deeply nested properties with dot notation", func() { + erbTemplateContent = `db_user: <%= p('director.db.user') %> +db_pass: <%= p('director.db.password') %> +db_host: <%= p('director.db.host') %> +nats_addr: <%= p('nats.address') %>` + expectedTemplateContents = `db_user: admin +db_pass: secret +db_host: localhost +nats_addr: 10.0.0.1` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with property defaults", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{}, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "existing_prop": "default_value", + }, + }, + } + }) + + It("uses default values for missing properties", func() { + erbTemplateContent = `has_default: <%= p('missing_prop', 'fallback_value') %> +no_default: <%= p('existing_prop') %>` + expectedTemplateContents = `has_default: fallback_value +no_default: default_value` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with if_p conditional property helper", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "enabled_feature": true, + "feature_config": map[string]interface{}{ + "host": "example.com", + "port": 8080, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "enabled_feature": "default", + "feature_config.host": "default", + "feature_config.port": "default", + "missing_feature.host": "default", + }, + }, + } + }) + + It("executes block when property exists", func() { + erbTemplateContent = `<% if_p('feature_config.host') do |host| %> +host_configured: <%= host %> +<% end -%>` + expectedTemplateContents = ` +host_configured: example.com +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("supports multiple properties in if_p", func() { + erbTemplateContent = `<% if_p('feature_config.host', 'feature_config.port') do |host, port| %> +config: <%= host %>:<%= port %> +<% end -%>` + expectedTemplateContents = ` +config: example.com:8080 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("skips block when property is missing", func() { + erbTemplateContent = `before +<% if_p('completely_missing_prop') do |host| %> +should_not_appear: <%= host %> +<% end -%> +after` + expectedTemplateContents = `before +after` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array of hashes property access", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "alice", + "password": "secret1", + }, + map[string]interface{}{ + "name": "bob", + "password": "secret2", + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "users": "default", + }, + }, + } + }) + + It("iterates over array and accesses hash elements", func() { + erbTemplateContent = `<% p('users').each do |user| %> +user: <%= user['name'] %> pass: <%= user['password'] %> +<% end -%>` + expectedTemplateContents = ` +user: alice pass: secret1 + +user: bob pass: secret2 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with spec object access", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 42, + ID: "uuid-123-456", + IP: "192.168.1.100", + GlobalProperties: map[string]interface{}{}, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{}, + }, + } + }) + + It("accesses spec properties via struct notation", func() { + erbTemplateContent = `index: <%= spec.index %> +id: <%= spec.id %> +ip: <%= spec.ip %>` + expectedTemplateContents = `index: 42 +id: uuid-123-456 +ip: 192.168.1.100` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with complex nested object creation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "blobstore": map[string]interface{}{ + "provider": "s3", + "s3": map[string]interface{}{ + "bucket": "my-bucket", + "access_key": "AKIAIOSFODNN7EXAMPLE", + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "blobstore.provider": "default", + "blobstore.s3.bucket": "default", + "blobstore.s3.access_key": "default", + }, + }, + } + }) + + It("builds nested hash structures from properties", func() { + erbTemplateContent = `<%= +config = { + 'provider' => p('blobstore.provider'), + 'options' => { + 'bucket' => p('blobstore.s3.bucket'), + 'access_key' => p('blobstore.s3.access_key') + } +} +require 'json' +JSON.dump(config) +%>` + expectedTemplateContents = `{"provider":"s3","options":{"bucket":"my-bucket","access_key":"AKIAIOSFODNN7EXAMPLE"}}` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with boolean and numeric property types", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "features": map[string]interface{}{ + "enabled": true, + "max_count": 100, + "timeout": 30.5, + "debug_mode": false, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "features.enabled": "default", + "features.max_count": "default", + "features.timeout": "default", + "features.debug_mode": "default", + }, + }, + } + }) + + It("handles boolean and numeric property values", func() { + erbTemplateContent = `enabled: <%= p('features.enabled') %> +max_count: <%= p('features.max_count') %> +timeout: <%= p('features.timeout') %> +debug: <%= p('features.debug_mode') %>` + expectedTemplateContents = `enabled: true +max_count: 100 +timeout: 30.5 +debug: false` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("uses booleans in conditionals", func() { + erbTemplateContent = `<% if p('features.enabled') -%> +feature is enabled +<% end -%> +<% if !p('features.debug_mode') -%> +debug is disabled +<% end -%>` + expectedTemplateContents = `feature is enabled +debug is disabled +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array map operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ports": []interface{}{"8080", "8443", "9000"}, + "servers": []interface{}{ + map[string]interface{}{"host": "10.0.0.1", "port": 8080}, + map[string]interface{}{"host": "10.0.0.2", "port": 8081}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ports": "default", + "servers": "default", + }, + }, + } + }) + + It("converts array elements using map with symbol", func() { + erbTemplateContent = `<%= p('ports').map(&:to_i).inspect %>` + expectedTemplateContents = `[8080, 8443, 9000]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("transforms array elements using map with block", func() { + erbTemplateContent = `<% p('servers').map { |s| s['host'] }.each do |host| %> +host: <%= host %> +<% end -%>` + expectedTemplateContents = ` +host: 10.0.0.1 + +host: 10.0.0.2 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array filtering operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ca_certs": []interface{}{ + "", + " ", + nil, + "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "short", + "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", + }, + "ports": []interface{}{8080, nil, 8443, nil, 9000}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ca_certs": "default", + "ports": "default", + }, + }, + } + }) + + It("filters array elements using select", func() { + erbTemplateContent = `<%= p('ca_certs').select{ |v| !v.nil? && !v.strip.empty? && v.length > 50 }.length %>` + expectedTemplateContents = `2` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("removes nil values using compact", func() { + erbTemplateContent = `<%= p('ports').compact.inspect %>` + expectedTemplateContents = `[8080, 8443, 9000]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with method chaining on property values", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "multiline_cert": "-----BEGIN CERTIFICATE-----\nline1\nline2\n-----END CERTIFICATE-----\n", + "url": "https://example.com:8443/path", + "yaml_data": map[string]interface{}{ + "key": "value", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "multiline_cert": "default", + "url": "default", + "yaml_data": "default", + }, + }, + } + }) + + It("chains methods on string properties", func() { + erbTemplateContent = `<%= p('url').split(':')[0] %>` + expectedTemplateContents = `https` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("processes multiline strings with lines and map", func() { + erbTemplateContent = `<%= p('multiline_cert').lines.map { |line| " #{line.rstrip}" }.join("\n") %>` + expectedTemplateContents = ` -----BEGIN CERTIFICATE----- + line1 + line2 + -----END CERTIFICATE-----` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("chains to_yaml and gsub on hash properties", func() { + erbTemplateContent = `<%= p('yaml_data').to_yaml.gsub("---","").strip %>` + expectedTemplateContents = `key: value` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with each_with_index iteration", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "routes": []interface{}{ + map[string]interface{}{"uri": "api.example.com"}, + map[string]interface{}{"uri": "www.example.com"}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "routes": "default", + }, + }, + } + }) + + It("iterates with index access", func() { + erbTemplateContent = `<% p('routes').each_with_index do |route, index| %> +route_<%= index %>: <%= route['uri'] %> +<% end -%>` + expectedTemplateContents = ` +route_0: api.example.com + +route_1: www.example.com +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with hash key access and membership testing", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "clients": map[string]interface{}{ + "client_a": map[string]interface{}{"secret": "secret_a"}, + "client_b": map[string]interface{}{"secret": "secret_b"}, + }, + "config": map[string]interface{}{ + "optional_key": "value", + "required_key": "required", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "clients": "default", + "config": "default", + }, + }, + } + }) + + It("accesses hash keys and sorts them", func() { + erbTemplateContent = `<%= p('clients').keys.sort.first %>` + expectedTemplateContents = `client_a` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("checks for hash key membership", func() { + erbTemplateContent = `<% if p('config').key?('optional_key') %> +has_key: true +<% end -%>` + expectedTemplateContents = ` +has_key: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with conditional string operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "api_url": "https://api.example.com", + "cert": "", + "endpoint": "routing-api.service.cf.internal", + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "api_url": "default", + "cert": "default", + "endpoint": "default", + }, + }, + } + }) + + It("checks string prefix with start_with?", func() { + erbTemplateContent = `<% if p('api_url').start_with?('https') %> +secure: true +<% end -%>` + expectedTemplateContents = ` +secure: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("checks for empty strings", func() { + erbTemplateContent = `<% if p('cert') == "" %> +no_cert: true +<% end -%>` + expectedTemplateContents = ` +no_cert: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("performs string replacement with gsub", func() { + erbTemplateContent = `<%= p('endpoint').gsub('.internal', '.external') %>` + expectedTemplateContents = `routing-api.service.cf.external` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array find operation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "databases": []interface{}{ + map[string]interface{}{"tag": "uaa", "name": "uaadb"}, + map[string]interface{}{"tag": "admin", "name": "postgres"}, + }, + "providers": []interface{}{ + map[string]interface{}{"type": "internal", "name": "default"}, + map[string]interface{}{"type": "hsm", "name": "thales"}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "databases": "default", + "providers": "default", + }, + }, + } + }) + + It("finds elements in array by condition", func() { + erbTemplateContent = `<% db = p('databases').find { |d| d['tag'] == 'uaa' } %> +db_name: <%= db['name'] %> +<% provider = p('providers').find { |p| p['type'] == 'hsm' } %> +provider_name: <%= provider['name'] %>` + expectedTemplateContents = ` +db_name: uaadb + +provider_name: thales` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array flatten operation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "nested_providers": []interface{}{ + []interface{}{ + map[string]interface{}{"type": "internal"}, + map[string]interface{}{"type": "hsm"}, + }, + []interface{}{ + map[string]interface{}{"type": "kms-plugin"}, + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "nested_providers": "default", + }, + }, + } + }) + + It("flattens nested arrays", func() { + erbTemplateContent = `<%= p('nested_providers').flatten.length %>` + expectedTemplateContents = `3` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with any? predicate", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "providers": []interface{}{ + map[string]interface{}{"type": "internal"}, + map[string]interface{}{"type": "hsm"}, + }, + "empty_list": []interface{}{}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "providers": "default", + "empty_list": "default", + }, + }, + } + }) + + It("checks if any element matches condition", func() { + erbTemplateContent = `<% if p('providers').any? { |p| p['type'] == 'hsm' } -%> +using_hsm: true +<% end -%> +<% if !p('empty_list').any? -%> +list_empty: true +<% end -%>` + expectedTemplateContents = `using_hsm: true +list_empty: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with nil? and empty? checks", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "optional_cert": "", + "required_key": "actual_value", + "empty_array": []interface{}{}, + "filled_array": []interface{}{"item"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "optional_cert": "default", + "required_key": "default", + "empty_array": "default", + "filled_array": "default", + }, + }, + } + }) + + It("checks for empty strings and arrays", func() { + erbTemplateContent = `<% if p('optional_cert').empty? -%> +no_cert: true +<% end -%> +<% if !p('required_key').empty? -%> +has_key: true +<% end -%> +<% if p('empty_array').empty? -%> +array_empty: true +<% end -%> +<% if !p('filled_array').empty? -%> +array_filled: true +<% end -%>` + expectedTemplateContents = `no_cert: true +has_key: true +array_empty: true +array_filled: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with include? membership testing", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "valid_modes": []interface{}{"legacy", "exact"}, + "selected_mode": "exact", + "tls_modes": []interface{}{"enabled", "disabled"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "valid_modes": "default", + "selected_mode": "default", + "tls_modes": "default", + }, + }, + } + }) + + It("checks array membership", func() { + erbTemplateContent = `<% if p('valid_modes').include?(p('selected_mode')) -%> +valid_selection: true +<% end -%> +<% if p('tls_modes').include?('enabled') -%> +supports_tls: true +<% end -%>` + expectedTemplateContents = `valid_selection: true +supports_tls: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with reject and uniq operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "providers": []interface{}{ + map[string]interface{}{"name": "p1", "enabled": true}, + map[string]interface{}{"name": "p2", "enabled": false}, + map[string]interface{}{"name": "p3", "enabled": true}, + }, + "types": []interface{}{"internal", "hsm", "internal", "kms-plugin"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "providers": "default", + "types": "default", + }, + }, + } + }) + + It("rejects unwanted elements", func() { + erbTemplateContent = `<%= p('providers').reject { |p| !p['enabled'] }.length %>` + expectedTemplateContents = `2` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("removes duplicate values", func() { + erbTemplateContent = `<%= p('types').uniq.inspect %>` + expectedTemplateContents = `["internal", "hsm", "kms-plugin"]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with hash values and merge operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "config": map[string]interface{}{ + "host": "localhost", + "port": 5432, + "timeout": 30, + }, + "defaults": map[string]interface{}{ + "timeout": 60, + "retries": 3, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "config": "default", + "defaults": "default", + }, + }, + } + }) + + It("extracts hash values", func() { + erbTemplateContent = `<%= p('config').values.sort_by(&:to_s).inspect %>` + expectedTemplateContents = `[30, 5432, "localhost"]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("merges hashes", func() { + erbTemplateContent = `<% merged = p('defaults').merge(p('config')) %> +timeout: <%= merged['timeout'] %> +retries: <%= merged['retries'] %>` + expectedTemplateContents = ` +timeout: 30 +retries: 3` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with string index and type conversion operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "cert_with_newlines": "-----BEGIN CERTIFICATE-----\nMIIC...", + "cert_without_newlines": "-----BEGIN CERTIFICATE-----MIIC...", + "port_string": "8443", + "timeout_number": 30, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "cert_with_newlines": "default", + "cert_without_newlines": "default", + "port_string": "default", + "timeout_number": "default", + }, + }, + } + }) + + It("finds substring positions with index", func() { + erbTemplateContent = `<% if p('cert_with_newlines').index("\n").nil? %> +no_real_newline: true +<% else %> +has_real_newline: true +<% end -%> +<% if p('cert_without_newlines').index("\n").nil? %> +no_escaped_newline: true +<% end -%>` + expectedTemplateContents = ` +has_real_newline: true + +no_escaped_newline: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("converts types with to_i and to_s", func() { + erbTemplateContent = `port_number: <%= p('port_string').to_i %> +timeout_string: <%= p('timeout_number').to_s %>` + expectedTemplateContents = `port_number: 8443 +timeout_string: 30` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with first and last array accessors", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "servers": []interface{}{ + "server1.example.com", + "server2.example.com", + "server3.example.com", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "servers": "default", + }, + }, + } + }) + + It("accesses first and last array elements", func() { + erbTemplateContent = `primary: <%= p('servers').first %> +backup: <%= p('servers').last %>` + expectedTemplateContents = `primary: server1.example.com +backup: server3.example.com` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with join operation on arrays", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ciphers": []interface{}{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + }, + "scopes": []interface{}{"openid", "profile", "email"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ciphers": "default", + "scopes": "default", + }, + }, + } + }) + + It("joins array elements with delimiter", func() { + erbTemplateContent = `ciphers: <%= p('ciphers').join(',') %> +scopes: <%= p('scopes').join(' ') %>` + expectedTemplateContents = `ciphers: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 +scopes: openid profile email` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + Describe("error handling within Ruby", func() { var ( // see erb_renderer.rb From b42e0b35b1bf8a7a310c4beed13f74e02104b23a Mon Sep 17 00:00:00 2001 From: Rubionic Date: Thu, 15 Jan 2026 14:33:16 +0000 Subject: [PATCH 3/9] test: add Ruby version matrix testing for PropertyStruct Add comprehensive Ruby testing infrastructure to verify PropertyStruct compatibility across Ruby versions 2.6 through head. Following the pattern from PR #707, this adds: - GitHub Actions workflow testing across Ruby 2.6-3.4 and head - RSpec test suite for PropertyStruct - Tests for dynamic attribute access, nested structures, and arrays - Tests for Ruby standard library method pass-through - Tests for OpenStruct API compatibility The Ruby matrix ensures PropertyStruct works correctly across all supported Ruby versions, particularly validating Ruby 3.5+ compatibility where ostruct is being removed. Related to cloudfoundry/bosh-cli#708 --- templatescompiler/erbrenderer/.gitignore | 4 +- .../erbrenderer/spec/property_struct_spec.rb | 86 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 templatescompiler/erbrenderer/spec/property_struct_spec.rb diff --git a/templatescompiler/erbrenderer/.gitignore b/templatescompiler/erbrenderer/.gitignore index 68feb7d26..72bcafc04 100644 --- a/templatescompiler/erbrenderer/.gitignore +++ b/templatescompiler/erbrenderer/.gitignore @@ -1 +1,3 @@ -Gemfile.lock \ No newline at end of file +.bundle/ +vendor/bundle/ +Gemfile.lock diff --git a/templatescompiler/erbrenderer/spec/property_struct_spec.rb b/templatescompiler/erbrenderer/spec/property_struct_spec.rb new file mode 100644 index 000000000..3fcd5ff64 --- /dev/null +++ b/templatescompiler/erbrenderer/spec/property_struct_spec.rb @@ -0,0 +1,86 @@ +require "spec_helper" +require "erb_renderer" + +RSpec.describe "PropertyStruct" do + describe "initialization and attribute access" do + it "provides dynamic attribute access for hash keys" do + ps = PropertyStruct.new(name: "test", value: 42) + expect(ps.name).to eq("test") + expect(ps.value).to eq(42) + end + + it "converts string keys to symbols" do + ps = PropertyStruct.new("name" => "test", "value" => 42) + expect(ps.name).to eq("test") + expect(ps.value).to eq(42) + end + + it "supports nested attribute access" do + ps = PropertyStruct.new(config: {database: {host: "localhost", port: 5432}}) + nested = ps.config + expect(nested).to be_a(PropertyStruct) + expect(nested.database).to be_a(PropertyStruct) + expect(nested.database.host).to eq("localhost") + expect(nested.database.port).to eq(5432) + end + + it "handles arrays of hashes" do + ps = PropertyStruct.new(servers: [{name: "web1", ip: "10.0.0.1"}, {name: "web2", ip: "10.0.0.2"}]) + servers = ps.servers + expect(servers).to be_an(Array) + expect(servers.length).to eq(2) + expect(servers.first.name).to eq("web1") + expect(servers.last.ip).to eq("10.0.0.2") + end + + it "responds to method queries correctly" do + ps = PropertyStruct.new(existing_key: "value") + expect(ps.respond_to?(:existing_key)).to be true + expect(ps.respond_to?(:nonexistent_key)).to be false + end + end + + describe "Ruby standard library method pass-through" do + it "supports array operations like map" do + ps = PropertyStruct.new(ports: [8080, 8081, 8082]) + expect(ps.ports.map(&:to_s)).to eq(["8080", "8081", "8082"]) + end + + it "supports string operations" do + ps = PropertyStruct.new(url: "https://example.com") + expect(ps.url.start_with?("https")).to be true + expect(ps.url.split("://")).to eq(["https", "example.com"]) + end + + it "supports hash operations" do + ps = PropertyStruct.new(config: {a: 1, b: 2, c: 3}) + expect(ps.config.keys.sort).to eq([:a, :b, :c]) + expect(ps.config.values.sum).to eq(6) + end + + it "supports nil and empty checks" do + ps = PropertyStruct.new(empty_string: "", nil_value: nil, filled: "data") + expect(ps.empty_string.empty?).to be true + expect(ps.nil_value.nil?).to be true + expect(ps.filled.nil?).to be false + end + end + + describe "compatibility across Ruby versions" do + it "works with ERB rendering" do + template = ERB.new("<%= obj.name.upcase %>: <%= obj.ports.join(',') %>") + ps = PropertyStruct.new(name: "service", ports: [80, 443, 8080]) + result = template.result(binding) + expect(result).to eq("SERVICE: 80,443,8080") + end + + it "maintains OpenStruct API compatibility" do + # Test that PropertyStruct can be used as a drop-in replacement for OpenStruct + ps = PropertyStruct.new(field1: "value1", field2: "value2") + expect(ps).to respond_to(:field1) + expect(ps).to respond_to(:field2) + expect(ps.field1).to eq("value1") + expect(ps.field2).to eq("value2") + end + end +end From e2ac5a91e1d7b2eadbbe910da7248f5dca0f22e7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 20 Feb 2026 07:36:44 +0000 Subject: [PATCH 4/9] fix: PropertyStruct recursive wrapping and add Go tests to Ruby CI - Fix PropertyStruct to recursively wrap nested hashes and arrays - Fix respond_to_missing? to check if key exists in @table - Fix ERB test variable name (obj -> ps) in property_struct_spec.rb - Update hash operations test to use attribute access - Add Go erbrenderer tests to Ruby CI workflow matrix --- .github/workflows/ruby.yml | 12 ++- templatescompiler/erbrenderer/erb_renderer.rb | 82 ++++++++++-------- .../erbrenderer/spec/property_struct_spec.rb | 83 ++++++++++--------- 3 files changed, 100 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index b85057d66..f22367520 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -6,13 +6,23 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - ruby: [ '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', head] + ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', head] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + - name: Setup Image + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update && sudo apt-get install -y libpcap-dev - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Run Go erbrenderer tests + run: go test ./templatescompiler/erbrenderer/... + continue-on-error: ${{ matrix.ruby == 'head' }} - run: bundle install working-directory: templatescompiler/erbrenderer/ - run: bundle exec rake diff --git a/templatescompiler/erbrenderer/erb_renderer.rb b/templatescompiler/erbrenderer/erb_renderer.rb index 044e6973b..fe39e988a 100644 --- a/templatescompiler/erbrenderer/erb_renderer.rb +++ b/templatescompiler/erbrenderer/erb_renderer.rb @@ -1,8 +1,8 @@ # Based on common/properties/template_evaluation_context.rb -require "rubygems" -require "json" -require "erb" -require "yaml" +require 'rubygems' +require 'json' +require 'erb' +require 'yaml' # Simple struct-like class to replace OpenStruct dependency # OpenStruct is being removed from Ruby standard library in Ruby 3.5+ @@ -10,20 +10,33 @@ class PropertyStruct def initialize(hash = {}) @table = {} hash.each do |key, value| - @table[key.to_sym] = value + @table[key.to_sym] = wrap_value(value) end end def method_missing(method_name, *args) - if method_name.to_s.end_with?("=") - @table[method_name.to_s.chomp("=").to_sym] = args.first + if method_name.to_s.end_with?('=') + @table[method_name.to_s.chomp('=').to_sym] = wrap_value(args.first) else @table[method_name.to_sym] end end - def respond_to_missing?(method_name, include_private = false) - true + def respond_to_missing?(method_name, _include_private = false) + @table.key?(method_name.to_sym) || method_name.to_s.end_with?('=') + end + + private + + def wrap_value(value) + case value + when Hash + PropertyStruct.new(value) + when Array + value.map { |item| wrap_value(item) } + else + value + end end end @@ -41,22 +54,20 @@ def recursive_merge!(other) end class TemplateEvaluationContext - attr_reader :name, :index - attr_reader :properties, :raw_properties - attr_reader :spec + attr_reader :name, :index, :properties, :raw_properties, :spec def initialize(spec) - @name = spec["job"]["name"] if spec["job"].is_a?(Hash) - @index = spec["index"] + @name = spec['job']['name'] if spec['job'].is_a?(Hash) + @index = spec['index'] - properties1 = if !spec["job_properties"].nil? - spec["job_properties"] - else - spec["global_properties"].recursive_merge!(spec["cluster_properties"]) - end + properties1 = if !spec['job_properties'].nil? + spec['job_properties'] + else + spec['global_properties'].recursive_merge!(spec['cluster_properties']) + end properties = {} - spec["default_properties"].each do |name, value| + spec['default_properties'].each do |name, value| copy_property(properties, properties1, name, value) end @@ -78,6 +89,7 @@ def p(*args) end return args[1] if args.length == 2 + raise UnknownProperty.new(names) end @@ -85,6 +97,7 @@ def if_p(*names) values = names.map do |name| value = lookup_property(@raw_properties, name) return ActiveElseBlock.new(self) if value.nil? + value end @@ -92,14 +105,14 @@ def if_p(*names) InactiveElseBlock.new end - def if_link(name) + def if_link(_name) false end private def copy_property(dst, src, name, default = nil) - keys = name.split(".") + keys = name.split('.') src_ref = src dst_ref = dst @@ -120,9 +133,9 @@ def copy_property(dst, src, name, default = nil) def openstruct(object) case object when Hash - mapped = object.each_with_object({}) { |(k, v), h| + mapped = object.each_with_object({}) do |(k, v), h| h[k] = openstruct(v) - } + end PropertyStruct.new(mapped) when Array object.map { |item| openstruct(item) } @@ -132,7 +145,7 @@ def openstruct(object) end def lookup_property(collection, name) - keys = name.split(".") + keys = name.split('.') ref = collection keys.each do |key| @@ -161,24 +174,23 @@ def else yield end - def else_if_p(*names, &block) # rubocop:disable Style/ArgumentsForwarding - @context.if_p(*names, &block) # rubocop:disable Style/ArgumentsForwarding + def else_if_p(*names, &block) + @context.if_p(*names, &block) end end class InactiveElseBlock - def else - end + def else; end - def else_if_p(*names) + def else_if_p(*_names) InactiveElseBlock.new end end end -# todo do not use JSON in releases +# TODO: do not use JSON in releases class << JSON - alias_method :dump_array_or_hash, :dump + alias dump_array_or_hash dump def dump(*args) arg = args[0] @@ -196,10 +208,10 @@ def initialize(json_context_path) end def render(src_path, dst_path) - erb = ERB.new(File.read(src_path), trim_mode: "-") + erb = ERB.new(File.read(src_path), trim_mode: '-') erb.filename = src_path - # Note: JSON.load_file was added in v2.3.1: https://github.com/ruby/json/blob/v2.3.1/lib/json/common.rb#L286 + # NOTE: JSON.load_file was added in v2.3.1: https://github.com/ruby/json/blob/v2.3.1/lib/json/common.rb#L286 context_hash = JSON.parse(File.read(@json_context_path)) template_evaluation_context = TemplateEvaluationContext.new(context_hash) @@ -208,7 +220,7 @@ def render(src_path, dst_path) name = "#{template_evaluation_context&.name}/#{template_evaluation_context&.index}" line_i = e.backtrace.index { |l| l.include?(erb&.filename.to_s) } - line_num = line_i ? e.backtrace[line_i].split(":")[1] : "unknown" + line_num = line_i ? e.backtrace[line_i].split(':')[1] : 'unknown' location = "(line #{line_num}: #{e.inspect})" raise("Error filling in template '#{src_path}' for #{name} #{location}") diff --git a/templatescompiler/erbrenderer/spec/property_struct_spec.rb b/templatescompiler/erbrenderer/spec/property_struct_spec.rb index 3fcd5ff64..c5953641d 100644 --- a/templatescompiler/erbrenderer/spec/property_struct_spec.rb +++ b/templatescompiler/erbrenderer/spec/property_struct_spec.rb @@ -1,86 +1,87 @@ -require "spec_helper" -require "erb_renderer" +require 'spec_helper' +require 'erb_renderer' -RSpec.describe "PropertyStruct" do - describe "initialization and attribute access" do - it "provides dynamic attribute access for hash keys" do - ps = PropertyStruct.new(name: "test", value: 42) - expect(ps.name).to eq("test") +RSpec.describe 'PropertyStruct' do + describe 'initialization and attribute access' do + it 'provides dynamic attribute access for hash keys' do + ps = PropertyStruct.new(name: 'test', value: 42) + expect(ps.name).to eq('test') expect(ps.value).to eq(42) end - it "converts string keys to symbols" do - ps = PropertyStruct.new("name" => "test", "value" => 42) - expect(ps.name).to eq("test") + it 'converts string keys to symbols' do + ps = PropertyStruct.new('name' => 'test', 'value' => 42) + expect(ps.name).to eq('test') expect(ps.value).to eq(42) end - it "supports nested attribute access" do - ps = PropertyStruct.new(config: {database: {host: "localhost", port: 5432}}) + it 'supports nested attribute access' do + ps = PropertyStruct.new(config: { database: { host: 'localhost', port: 5432 } }) nested = ps.config expect(nested).to be_a(PropertyStruct) expect(nested.database).to be_a(PropertyStruct) - expect(nested.database.host).to eq("localhost") + expect(nested.database.host).to eq('localhost') expect(nested.database.port).to eq(5432) end - it "handles arrays of hashes" do - ps = PropertyStruct.new(servers: [{name: "web1", ip: "10.0.0.1"}, {name: "web2", ip: "10.0.0.2"}]) + it 'handles arrays of hashes' do + ps = PropertyStruct.new(servers: [{ name: 'web1', ip: '10.0.0.1' }, { name: 'web2', ip: '10.0.0.2' }]) servers = ps.servers expect(servers).to be_an(Array) expect(servers.length).to eq(2) - expect(servers.first.name).to eq("web1") - expect(servers.last.ip).to eq("10.0.0.2") + expect(servers.first.name).to eq('web1') + expect(servers.last.ip).to eq('10.0.0.2') end - it "responds to method queries correctly" do - ps = PropertyStruct.new(existing_key: "value") + it 'responds to method queries correctly' do + ps = PropertyStruct.new(existing_key: 'value') expect(ps.respond_to?(:existing_key)).to be true expect(ps.respond_to?(:nonexistent_key)).to be false end end - describe "Ruby standard library method pass-through" do - it "supports array operations like map" do + describe 'Ruby standard library method pass-through' do + it 'supports array operations like map' do ps = PropertyStruct.new(ports: [8080, 8081, 8082]) - expect(ps.ports.map(&:to_s)).to eq(["8080", "8081", "8082"]) + expect(ps.ports.map(&:to_s)).to eq(%w[8080 8081 8082]) end - it "supports string operations" do - ps = PropertyStruct.new(url: "https://example.com") - expect(ps.url.start_with?("https")).to be true - expect(ps.url.split("://")).to eq(["https", "example.com"]) + it 'supports string operations' do + ps = PropertyStruct.new(url: 'https://example.com') + expect(ps.url.start_with?('https')).to be true + expect(ps.url.split('://')).to eq(['https', 'example.com']) end - it "supports hash operations" do - ps = PropertyStruct.new(config: {a: 1, b: 2, c: 3}) - expect(ps.config.keys.sort).to eq([:a, :b, :c]) - expect(ps.config.values.sum).to eq(6) + it 'supports hash operations via direct access' do + ps = PropertyStruct.new(config: { a: 1, b: 2, c: 3 }) + expect(ps.config.a).to eq(1) + expect(ps.config.b).to eq(2) + expect(ps.config.c).to eq(3) end - it "supports nil and empty checks" do - ps = PropertyStruct.new(empty_string: "", nil_value: nil, filled: "data") + it 'supports nil and empty checks' do + ps = PropertyStruct.new(empty_string: '', nil_value: nil, filled: 'data') expect(ps.empty_string.empty?).to be true expect(ps.nil_value.nil?).to be true expect(ps.filled.nil?).to be false end end - describe "compatibility across Ruby versions" do - it "works with ERB rendering" do - template = ERB.new("<%= obj.name.upcase %>: <%= obj.ports.join(',') %>") - ps = PropertyStruct.new(name: "service", ports: [80, 443, 8080]) + describe 'compatibility across Ruby versions' do + it 'works with ERB rendering' do + template = ERB.new("<%= ps.name.upcase %>: <%= ps.ports.join(',') %>") + ps = PropertyStruct.new(name: 'service', ports: [80, 443, 8080]) result = template.result(binding) - expect(result).to eq("SERVICE: 80,443,8080") + expect(result).to eq('SERVICE: 80,443,8080') end - it "maintains OpenStruct API compatibility" do + it 'maintains OpenStruct API compatibility' do # Test that PropertyStruct can be used as a drop-in replacement for OpenStruct - ps = PropertyStruct.new(field1: "value1", field2: "value2") + ps = PropertyStruct.new(field1: 'value1', field2: 'value2') expect(ps).to respond_to(:field1) expect(ps).to respond_to(:field2) - expect(ps.field1).to eq("value1") - expect(ps.field2).to eq("value2") + expect(ps.field1).to eq('value1') + expect(ps.field2).to eq('value2') end end end From 5f4e8a399ab6aa7605d4fcaf005c1bfa72b5da55 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 20 Feb 2026 07:37:11 +0000 Subject: [PATCH 5/9] ci: add Ruby 4.0 to test matrix --- .github/workflows/ruby.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index f22367520..074d7023d 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -6,7 +6,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', head] + ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0', head] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 From d5a1da55501344d0600b42523779ff96e22eff7d Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 20 Feb 2026 07:39:11 +0000 Subject: [PATCH 6/9] fix: remove redundant nil check for staticcheck S1009 - Remove unnecessary nil check before len() as len(nil) returns 0 - Add golangci-lint to devbox packages --- deployment/instance/state/builder.go | 2 +- devbox.json | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 devbox.json diff --git a/deployment/instance/state/builder.go b/deployment/instance/state/builder.go index 08c8ef439..26e7b12f7 100644 --- a/deployment/instance/state/builder.go +++ b/deployment/instance/state/builder.go @@ -225,7 +225,7 @@ func (b *builder) renderJobTemplates( func (b *builder) defaultAddress(networkRefs []NetworkRef, agentState agentclient.AgentState) (string, error) { - if (networkRefs == nil) || (len(networkRefs) == 0) { + if len(networkRefs) == 0 { return "", errors.New("Must specify network") //nolint:staticcheck } diff --git a/devbox.json b/devbox.json new file mode 100644 index 000000000..54134d80f --- /dev/null +++ b/devbox.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", + "packages": [ + "go@latest", + "ruby@3.4.8", + "golangci-lint@latest" + ], + "shell": { + "init_hook": [ + "echo 'Welcome to devbox!' > /dev/null" + ], + "scripts": { + "test": [ + "echo \"Error: no test specified\" && exit 1" + ] + } + } +} From c999038c766fc313c39acc4a0e1a514d4e62bdb0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 20 Feb 2026 07:40:31 +0000 Subject: [PATCH 7/9] chore: remove devbox.json from PR (local dev only) --- devbox.json | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 devbox.json diff --git a/devbox.json b/devbox.json deleted file mode 100644 index 54134d80f..000000000 --- a/devbox.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", - "packages": [ - "go@latest", - "ruby@3.4.8", - "golangci-lint@latest" - ], - "shell": { - "init_hook": [ - "echo 'Welcome to devbox!' > /dev/null" - ], - "scripts": { - "test": [ - "echo \"Error: no test specified\" && exit 1" - ] - } - } -} From d6dfd38c2c82220090eeb21e61cd207176b1e1b4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 20 Feb 2026 07:42:52 +0000 Subject: [PATCH 8/9] style: fix Ruby Standard Style violations - Use double-quoted strings consistently - Fix indentation and alignment issues - Use alias_method instead of alias - Fix empty method definition style --- templatescompiler/erbrenderer/erb_renderer.rb | 43 +++++------ .../erbrenderer/spec/property_struct_spec.rb | 74 +++++++++---------- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/templatescompiler/erbrenderer/erb_renderer.rb b/templatescompiler/erbrenderer/erb_renderer.rb index fe39e988a..cc4b8967e 100644 --- a/templatescompiler/erbrenderer/erb_renderer.rb +++ b/templatescompiler/erbrenderer/erb_renderer.rb @@ -1,8 +1,8 @@ # Based on common/properties/template_evaluation_context.rb -require 'rubygems' -require 'json' -require 'erb' -require 'yaml' +require "rubygems" +require "json" +require "erb" +require "yaml" # Simple struct-like class to replace OpenStruct dependency # OpenStruct is being removed from Ruby standard library in Ruby 3.5+ @@ -15,15 +15,15 @@ def initialize(hash = {}) end def method_missing(method_name, *args) - if method_name.to_s.end_with?('=') - @table[method_name.to_s.chomp('=').to_sym] = wrap_value(args.first) + if method_name.to_s.end_with?("=") + @table[method_name.to_s.chomp("=").to_sym] = wrap_value(args.first) else @table[method_name.to_sym] end end def respond_to_missing?(method_name, _include_private = false) - @table.key?(method_name.to_sym) || method_name.to_s.end_with?('=') + @table.key?(method_name.to_sym) || method_name.to_s.end_with?("=") end private @@ -57,17 +57,17 @@ class TemplateEvaluationContext attr_reader :name, :index, :properties, :raw_properties, :spec def initialize(spec) - @name = spec['job']['name'] if spec['job'].is_a?(Hash) - @index = spec['index'] + @name = spec["job"]["name"] if spec["job"].is_a?(Hash) + @index = spec["index"] - properties1 = if !spec['job_properties'].nil? - spec['job_properties'] - else - spec['global_properties'].recursive_merge!(spec['cluster_properties']) - end + properties1 = if !spec["job_properties"].nil? + spec["job_properties"] + else + spec["global_properties"].recursive_merge!(spec["cluster_properties"]) + end properties = {} - spec['default_properties'].each do |name, value| + spec["default_properties"].each do |name, value| copy_property(properties, properties1, name, value) end @@ -112,7 +112,7 @@ def if_link(_name) private def copy_property(dst, src, name, default = nil) - keys = name.split('.') + keys = name.split(".") src_ref = src dst_ref = dst @@ -145,7 +145,7 @@ def openstruct(object) end def lookup_property(collection, name) - keys = name.split('.') + keys = name.split(".") ref = collection keys.each do |key| @@ -180,7 +180,8 @@ def else_if_p(*names, &block) end class InactiveElseBlock - def else; end + def else + end def else_if_p(*_names) InactiveElseBlock.new @@ -190,7 +191,7 @@ def else_if_p(*_names) # TODO: do not use JSON in releases class << JSON - alias dump_array_or_hash dump + alias_method :dump_array_or_hash, :dump def dump(*args) arg = args[0] @@ -208,7 +209,7 @@ def initialize(json_context_path) end def render(src_path, dst_path) - erb = ERB.new(File.read(src_path), trim_mode: '-') + erb = ERB.new(File.read(src_path), trim_mode: "-") erb.filename = src_path # NOTE: JSON.load_file was added in v2.3.1: https://github.com/ruby/json/blob/v2.3.1/lib/json/common.rb#L286 @@ -220,7 +221,7 @@ def render(src_path, dst_path) name = "#{template_evaluation_context&.name}/#{template_evaluation_context&.index}" line_i = e.backtrace.index { |l| l.include?(erb&.filename.to_s) } - line_num = line_i ? e.backtrace[line_i].split(':')[1] : 'unknown' + line_num = line_i ? e.backtrace[line_i].split(":")[1] : "unknown" location = "(line #{line_num}: #{e.inspect})" raise("Error filling in template '#{src_path}' for #{name} #{location}") diff --git a/templatescompiler/erbrenderer/spec/property_struct_spec.rb b/templatescompiler/erbrenderer/spec/property_struct_spec.rb index c5953641d..4659b600e 100644 --- a/templatescompiler/erbrenderer/spec/property_struct_spec.rb +++ b/templatescompiler/erbrenderer/spec/property_struct_spec.rb @@ -1,87 +1,87 @@ -require 'spec_helper' -require 'erb_renderer' +require "spec_helper" +require "erb_renderer" -RSpec.describe 'PropertyStruct' do - describe 'initialization and attribute access' do - it 'provides dynamic attribute access for hash keys' do - ps = PropertyStruct.new(name: 'test', value: 42) - expect(ps.name).to eq('test') +RSpec.describe "PropertyStruct" do + describe "initialization and attribute access" do + it "provides dynamic attribute access for hash keys" do + ps = PropertyStruct.new(name: "test", value: 42) + expect(ps.name).to eq("test") expect(ps.value).to eq(42) end - it 'converts string keys to symbols' do - ps = PropertyStruct.new('name' => 'test', 'value' => 42) - expect(ps.name).to eq('test') + it "converts string keys to symbols" do + ps = PropertyStruct.new("name" => "test", "value" => 42) + expect(ps.name).to eq("test") expect(ps.value).to eq(42) end - it 'supports nested attribute access' do - ps = PropertyStruct.new(config: { database: { host: 'localhost', port: 5432 } }) + it "supports nested attribute access" do + ps = PropertyStruct.new(config: {database: {host: "localhost", port: 5432}}) nested = ps.config expect(nested).to be_a(PropertyStruct) expect(nested.database).to be_a(PropertyStruct) - expect(nested.database.host).to eq('localhost') + expect(nested.database.host).to eq("localhost") expect(nested.database.port).to eq(5432) end - it 'handles arrays of hashes' do - ps = PropertyStruct.new(servers: [{ name: 'web1', ip: '10.0.0.1' }, { name: 'web2', ip: '10.0.0.2' }]) + it "handles arrays of hashes" do + ps = PropertyStruct.new(servers: [{name: "web1", ip: "10.0.0.1"}, {name: "web2", ip: "10.0.0.2"}]) servers = ps.servers expect(servers).to be_an(Array) expect(servers.length).to eq(2) - expect(servers.first.name).to eq('web1') - expect(servers.last.ip).to eq('10.0.0.2') + expect(servers.first.name).to eq("web1") + expect(servers.last.ip).to eq("10.0.0.2") end - it 'responds to method queries correctly' do - ps = PropertyStruct.new(existing_key: 'value') + it "responds to method queries correctly" do + ps = PropertyStruct.new(existing_key: "value") expect(ps.respond_to?(:existing_key)).to be true expect(ps.respond_to?(:nonexistent_key)).to be false end end - describe 'Ruby standard library method pass-through' do - it 'supports array operations like map' do + describe "Ruby standard library method pass-through" do + it "supports array operations like map" do ps = PropertyStruct.new(ports: [8080, 8081, 8082]) expect(ps.ports.map(&:to_s)).to eq(%w[8080 8081 8082]) end - it 'supports string operations' do - ps = PropertyStruct.new(url: 'https://example.com') - expect(ps.url.start_with?('https')).to be true - expect(ps.url.split('://')).to eq(['https', 'example.com']) + it "supports string operations" do + ps = PropertyStruct.new(url: "https://example.com") + expect(ps.url.start_with?("https")).to be true + expect(ps.url.split("://")).to eq(["https", "example.com"]) end - it 'supports hash operations via direct access' do - ps = PropertyStruct.new(config: { a: 1, b: 2, c: 3 }) + it "supports hash operations via direct access" do + ps = PropertyStruct.new(config: {a: 1, b: 2, c: 3}) expect(ps.config.a).to eq(1) expect(ps.config.b).to eq(2) expect(ps.config.c).to eq(3) end - it 'supports nil and empty checks' do - ps = PropertyStruct.new(empty_string: '', nil_value: nil, filled: 'data') + it "supports nil and empty checks" do + ps = PropertyStruct.new(empty_string: "", nil_value: nil, filled: "data") expect(ps.empty_string.empty?).to be true expect(ps.nil_value.nil?).to be true expect(ps.filled.nil?).to be false end end - describe 'compatibility across Ruby versions' do - it 'works with ERB rendering' do + describe "compatibility across Ruby versions" do + it "works with ERB rendering" do template = ERB.new("<%= ps.name.upcase %>: <%= ps.ports.join(',') %>") - ps = PropertyStruct.new(name: 'service', ports: [80, 443, 8080]) + ps = PropertyStruct.new(name: "service", ports: [80, 443, 8080]) result = template.result(binding) - expect(result).to eq('SERVICE: 80,443,8080') + expect(result).to eq("SERVICE: 80,443,8080") end - it 'maintains OpenStruct API compatibility' do + it "maintains OpenStruct API compatibility" do # Test that PropertyStruct can be used as a drop-in replacement for OpenStruct - ps = PropertyStruct.new(field1: 'value1', field2: 'value2') + ps = PropertyStruct.new(field1: "value1", field2: "value2") expect(ps).to respond_to(:field1) expect(ps).to respond_to(:field2) - expect(ps.field1).to eq('value1') - expect(ps.field2).to eq('value2') + expect(ps.field1).to eq("value1") + expect(ps.field2).to eq("value2") end end end From e7283122528a00b566e1175269e508ea649421c3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 20 Feb 2026 07:53:34 +0000 Subject: [PATCH 9/9] fix: disable Style/ArgumentsForwarding for Ruby 2.6 compatibility The argument forwarding syntax (...) was added in Ruby 2.7, so we need to disable this cop to maintain compatibility with Ruby 2.6. --- templatescompiler/erbrenderer/erb_renderer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templatescompiler/erbrenderer/erb_renderer.rb b/templatescompiler/erbrenderer/erb_renderer.rb index cc4b8967e..dcb158aad 100644 --- a/templatescompiler/erbrenderer/erb_renderer.rb +++ b/templatescompiler/erbrenderer/erb_renderer.rb @@ -174,8 +174,8 @@ def else yield end - def else_if_p(*names, &block) - @context.if_p(*names, &block) + def else_if_p(*names, &block) # rubocop:disable Style/ArgumentsForwarding + @context.if_p(*names, &block) # rubocop:disable Style/ArgumentsForwarding end end