diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..882745b05 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added + +- **Interpolation Functions**: New string manipulation functions for use in data values and hierarchy paths. + - `substring(string, start[, count])`: Extract a portion of a string using 0-based indexing. + - `%{substring('abcdef', 2)}` returns `'cdef'` + - `%{substring('abcdef', 2, 3)}` returns `'cde'` + - `match(string, pattern)`: Test if a string matches a regex or contains a substring. + - `%{match($role, '/^web/')}` returns `'true'` or `'false'` + - `%{match($hostname, 'db')}` returns `'true'` if hostname contains 'db' + - Regex flags supported: `i` (case-insensitive), `m` (multiline), `x` (extended) + - `grep_captures(string, '/regex/'[, separator][, [groups]])`: Extract capture groups from a regex match. + - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/')}` returns `'abc123'` (all groups concatenated) + - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/', '-')}` returns `'abc-123'` (all groups with separator) + - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/', [1])}` returns `'abc'` (specific groups) + - `%{grep_captures('abc-123', '/([a-z]+)-(\d+)/', '_', [1,2])}` returns `'abc_123'` + - Defaults: empty separator `''`, all capture groups + - Third argument auto-detected: array = groups, quoted string = separator + - `version_gt(a, b)`, `version_gte(a, b)`, `version_lt(a, b)`, `version_lte(a, b)`: Compare semantic versions. + - `%{version_gte($app_version, '2.0.0')}` returns `'true'` or `'false'` + - Uses `Gem::Version` for proper semantic version comparison + - Handles `1.10.0 > 1.9.0` and prerelease versions correctly + +### Changed + +- `RX_METHOD_AND_ARG` regex updated to support function arguments containing parentheses (needed for regex capture groups). +- Argument parser now handles array literals `[1,2,3]` for specifying capture group indices. + +### Fixed + +- Regex literals with escaped slashes (e.g., `/a\/b/`) are now parsed correctly. +- Empty quoted strings (`''` or `""`) are now handled correctly as function arguments. + +### Notes + +- Single-quoted regex patterns (`'/pattern/'`) are recommended: + - Support curly brace quantifiers: `'/\d{3,5}/'` + - No YAML double-escaping needed: `'/\d+/'` instead of `/\\d+/` diff --git a/README.md b/README.md index 6e0a35a3b..20f17237d 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,96 @@ other.key: 'hiera data %{hiera("a.''b.c''.d")}' Note that two single quotes are used to escape a single quote inside a single quoted string (that's YAML syntax, not Hiera) and that the quoted key must be quoted in turn. +### Interpolation Functions + +Hiera supports interpolation functions that can manipulate strings and perform pattern matching. +These functions can be used in both data values and hierarchy paths in `hiera.yaml`. + +#### substring + +Extract a portion of a string: + +
+# substring(string, start)        - from start index to end
+# substring(string, start, count) - from start index, count characters
+
+path: "nodes/%{substring($certname, 0, 4)}"
+value: "%{substring('abcdef', 2, 3)}"  # returns 'cde'
+
+ +Arguments can be: +- Quoted literals: `'string'` or `"string"` +- Scope variables: `$varname` or `varname` +- Integers: `0`, `5`, etc. + +Indexing is 0-based. Negative indices are supported (e.g., `-1` for last character). + +#### match + +Test if a string matches a pattern: + +
+# match(string, /regex/)  - returns 'true' or 'false'
+# match(string, 'needle') - returns 'true' if string contains needle
+
+is_web: "%{match($role, '/^web/')}"
+has_db: "%{match($hostname, 'db')}"
+
+ +Regex literals support flags: +- `i` - case insensitive +- `m` - multiline +- `x` - extended (ignore whitespace) + +
+# Case-insensitive match
+match_result: "%{match('HELLO', '/hello/i')}"  # returns 'true'
+
+ +#### grep_captures + +Extract capture groups from a regex match and join them with a separator: + +
+# grep_captures(string, '/regex/')                      - returns all capture groups concatenated
+# grep_captures(string, '/regex/', separator)           - returns all groups joined by separator
+# grep_captures(string, '/regex/', [groups])            - returns specified groups concatenated
+# grep_captures(string, '/regex/', separator, [groups]) - returns specified groups joined by separator
+
+# Extract all capture groups (default: concatenated)
+all: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/')}"  # returns 'abc123def'
+
+# Extract all groups with separator
+parts: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '-')}"  # returns 'abc-123-def'
+
+# Extract specific groups (concatenated)
+selected: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', [1,3])}"  # returns 'abcdef'
+
+# Extract specific groups with custom separator
+custom: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '_', [1,3])}"  # returns 'abc_def'
+
+ +**Tip:** Wrap regex patterns in single quotes (`'/pattern/'`) for cleaner syntax: +- Curly brace quantifiers work: `'/\d{3,5}/'` +- No double-escaping needed: `'/\d+/'` instead of `/\\d+/` + +#### Version Comparison Functions + +Compare semantic versions using `Gem::Version` comparison: + +
+# version_gt(a, b)  - returns 'true' if a > b
+# version_gte(a, b) - returns 'true' if a >= b
+# version_lt(a, b)  - returns 'true' if a < b
+# version_lte(a, b) - returns 'true' if a <= b
+
+is_newer: "%{version_gt($app_version, '2.0.0')}"      # true if app_version > 2.0.0
+is_compatible: "%{version_gte($ruby_version, '2.7.0')}"  # true if ruby >= 2.7.0
+needs_upgrade: "%{version_lt($os_version, '8.0')}"    # true if os < 8.0
+
+ +Handles semantic versioning correctly (`1.10.0 > 1.9.0`) and prerelease versions (`1.0.0.alpha < 1.0.0`). + ## Future Enhancements * More backends should be created diff --git a/docs/tutorials/interpolation_functions.md b/docs/tutorials/interpolation_functions.md new file mode 100644 index 000000000..181d0d769 --- /dev/null +++ b/docs/tutorials/interpolation_functions.md @@ -0,0 +1,332 @@ +# Interpolation Functions + +Hiera supports interpolation functions that allow you to manipulate strings and perform pattern matching within your data. These functions can be used in both data values and hierarchy paths in `hiera.yaml`. + +## Overview + +Interpolation functions use the syntax `%{function_name(arg1, arg2, ...)}`. Arguments can be: + +- **Quoted literals**: `'string'` or `"string"` +- **Scope variables**: `$varname` or just `varname` +- **Integers**: `0`, `5`, `-1`, etc. +- **Regex literals**: `/pattern/` with optional flags + +## Available Functions + +### substring + +Extract a portion of a string using 0-based indexing. + +**Syntax:** +``` +%{substring(string, start)} +%{substring(string, start, count)} +``` + +**Parameters:** +- `string` - The source string (quoted literal or scope variable) +- `start` - Starting index (0-based, can be negative) +- `count` - Optional number of characters to extract + +**Examples:** + +```yaml +# In data files +--- +# Extract from index 2 to end +slice_to_end: "%{substring('abcdef', 2)}" # Returns 'cdef' + +# Extract 3 characters starting at index 2 +slice_with_count: "%{substring('abcdef', 2, 3)}" # Returns 'cde' + +# Using scope variables +node_prefix: "%{substring($certname, 0, 4)}" + +# Negative indices (from end of string) +last_three: "%{substring('abcdef', -3)}" # Returns 'def' +``` + +**In hiera.yaml hierarchy:** + +```yaml +:hierarchy: + - name: "node certname prefix" + path: "nodes/%{substring($certname, 0, 4)}.yaml" + - name: "environment" + path: "env/%{environment}.yaml" + - name: "common" + path: "common.yaml" +``` + +This would allow grouping nodes by the first 4 characters of their certname. + +### match + +Test if a string matches a regular expression or contains a substring. + +**Syntax:** +``` +%{match(string, '/regex/')} +%{match(string, '/regex/flags')} +%{match(string, 'substring')} +%{match(string, $pattern_var)} +``` + +**Parameters:** +- `string` - The string to test (quoted literal or scope variable) +- `pattern` - A regex literal (`/pattern/`) or a string to search for + +**Returns:** `'true'` or `'false'` (as strings) + +**Regex Flags:** +- `i` - Case insensitive matching +- `m` - Multiline mode (`.` matches newlines) +- `x` - Extended mode (ignore whitespace in pattern) + +**Examples:** + +```yaml +# In data files +--- +# Simple regex match +is_webserver: "%{match($role, '/^web/')}" + +# Case-insensitive match +matches_hello: "%{match('HELLO WORLD', '/hello/i')}" # Returns 'true' + +# Substring contains check +has_db_in_name: "%{match($hostname, 'db')}" + +# Complex regex with capture groups +is_valid_version: "%{match($version, '/^(\d+)\.(\d+)\.(\d+)$/')}" +``` + +**In hiera.yaml hierarchy:** + +You can use `match()` results in hierarchy paths, though typically you'd use it in data values for conditional logic: + +```yaml +# data/common.yaml +--- +# Set a value based on pattern matching +database_type: "%{match($hostname, '/^db/') ? 'primary' : 'replica'}" +``` + +Note: The ternary operator shown above is not currently supported. Use `match()` to return `'true'`/`'false'` and handle the logic in your Puppet code. + +### grep_captures + +Extract capture groups from a regex match and join them with a separator. + +**Syntax:** +``` +%{grep_captures(string, '/regex/')} +%{grep_captures(string, '/regex/', separator)} +%{grep_captures(string, '/regex/', [group_indices])} +%{grep_captures(string, '/regex/', separator, [group_indices])} +``` + +**Parameters:** +- `string` - The string to match against (quoted literal or scope variable) +- `'/regex/'` - A single-quoted regex literal with capture groups +- `separator` - Optional separator string (default: `''` empty). Auto-detected if 3rd arg is a quoted string. +- `[group_indices]` - Optional array of capture group numbers to extract (default: all groups). Auto-detected if 3rd arg is an array. + +**Returns:** The specified capture groups joined by the separator, or empty string if no match. + +**Examples:** + +```yaml +# In data files +--- +# Extract all capture groups concatenated (default behavior) +all_concat: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/')}" # Returns 'abc123def' + +# Extract all groups with separator +all_sep: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '-')}" # Returns 'abc-123-def' + +# Extract specific groups concatenated (3rd arg is array) +selected: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', [1,3])}" # Returns 'abcdef' + +# Extract specific groups with custom separator +parts: "%{grep_captures('abc-123-def', '/([a-z]+)-(\d+)-([a-z]+)/', '_', [1,3])}" # Returns 'abc_def' + +# Parse structured hostname: web01-prod-dc1 +# Extract environment and datacenter +env_dc: "%{grep_captures($hostname, '/\w+-(\w+)-(\w+)/', '_', [1,2])}" # Returns 'prod_dc1' +``` + +**Use case - Extracting components from structured names:** + +```yaml +# data/common.yaml +--- +# Hostname format: --.example.com +# Example: web01-prod-dc1.example.com + +# Extract all parts concatenated +all_parts: "%{grep_captures($hostname, '/(\w+)-(\w+)-(\w+)/')}" # Returns 'web01proddc1' + +# Extract environment from hostname (single group) +extracted_env: "%{grep_captures($hostname, '/\w+-(\w+)-\w+/')}" # Returns 'prod' + +# Extract datacenter +extracted_dc: "%{grep_captures($hostname, '/\w+-\w+-(\w+)/')}" # Returns 'dc1' + +# Create a combined identifier with separator +node_id: "%{grep_captures($hostname, '/(\w+)-(\w+)-(\w+)/', '_')}" # Returns 'web01_prod_dc1' +``` + +## Practical Examples + +### Example 1: Environment-based configuration + +```yaml +# hiera.yaml +:hierarchy: + - name: "node" + path: "nodes/%{certname}.yaml" + - name: "role" + path: "roles/%{role}.yaml" + - name: "environment" + path: "env/%{environment}.yaml" + - name: "common" + path: "common.yaml" + +# data/common.yaml +--- +# Use substring to create short identifiers +short_hostname: "%{substring($hostname, 0, 8)}" + +# Check if this is a production-like environment +is_production: "%{match($environment, '/^prod/')}" +``` + +### Example 2: Version-based paths + +```yaml +# hiera.yaml +:hierarchy: + - "os/%{operatingsystem}/%{substring($operatingsystemrelease, 0, 1)}" + - "os/%{operatingsystem}" + - common +``` + +This creates paths like `os/CentOS/7` for CentOS 7.9.2009. + +### Example 3: Datacenter detection + +```yaml +# data/common.yaml +--- +# Detect datacenter from hostname pattern +# Hostnames like: web01-dc1.example.com, db02-dc2.example.com +is_dc1: "%{match($hostname, '/-dc1\./')}" +is_dc2: "%{match($hostname, '/-dc2\./')}" +``` + +### Version Comparison Functions + +Compare semantic versions using `Gem::Version` comparison. + +**Syntax:** +``` +%{version_gt(version_a, version_b)} # true if a > b +%{version_gte(version_a, version_b)} # true if a >= b +%{version_lt(version_a, version_b)} # true if a < b +%{version_lte(version_a, version_b)} # true if a <= b +``` + +**Parameters:** +- `version_a` - First version string (quoted literal or scope variable) +- `version_b` - Second version string (quoted literal or scope variable) + +**Returns:** `'true'` or `'false'` (as strings) + +**Examples:** + +```yaml +# In data files +--- +# Check if app version meets minimum requirement +is_compatible: "%{version_gte($app_version, '2.0.0')}" + +# Check if OS needs upgrade +needs_upgrade: "%{version_lt($os_version, '8.0')}" + +# Compare two scope variables +is_newer: "%{version_gt($installed_version, $required_version)}" +``` + +**Semantic versioning:** + +The functions use Ruby's `Gem::Version` for proper comparison: + +```yaml +# Numeric comparison (not string comparison) +correct: "%{version_gte('1.10.0', '1.9.0')}" # Returns 'true' (1.10 > 1.9) + +# Prerelease versions sort before release +prerelease: "%{version_lt('1.0.0.alpha', '1.0.0')}" # Returns 'true' +``` + +## Error Handling + +- If `substring()` indices are out of range, an empty string is returned. +- If `match()` receives an invalid regex, an error is raised. +- If `grep_captures()` doesn't match, an empty string is returned. +- If `grep_captures()` receives a non-regex pattern, an error is raised. +- If version comparison receives malformed versions, falls back to string comparison. +- Missing scope variables resolve to empty strings. + +## Single-Quoted Regex Patterns + +You can wrap regex patterns in single quotes (`'/pattern/'`) to gain two benefits: + +1. **Curly brace quantifiers**: Allows `{3,5}` without breaking the `%{...}` interpolation +2. **No double-escaping**: Backslashes work naturally without YAML escape doubling + +```yaml +# Single-quoted pattern - clean and simple +valid: "%{grep_captures('12345-widget', '/(\d{3,5})-(\w+)/', '_', [1,2])}" + +# Without quotes, curly braces break the interpolation +# invalid: "%{grep_captures('12345', /\d{3,5}/)}" # DON'T do this +``` + +**Recommended approach** - use single-quoted patterns for cleaner regex: + +```yaml +# Single-quoted: no double-escaping needed +clean: "%{match($path, '/a\/b/')}" +digits: "%{match($str, '/\d+/')}" + +# Unquoted regex in YAML double-quoted strings requires double-escaping +escaped: "%{match($path, /a\\/b/)}" +``` + +## Escaping Special Characters (Unquoted Regex) + +When using unquoted regex patterns (`/pattern/`) in YAML double-quoted strings: + +1. **YAML escaping**: Backslashes must be doubled. + ```yaml + # To match a literal slash in regex: + has_slash: "%{match($path, /a\\/b/)}" # \\\\ in YAML becomes \\ + ``` + +2. **Regex escaping**: Standard regex escaping rules apply within the pattern. + ```yaml + # Match a literal dot + has_dot: "%{match($hostname, /\\./)}"` + ``` + +**Tip**: Use single-quoted patterns (`'/pattern/'`) to avoid double-escaping complexity. + +## Tips + +1. **Use in hierarchy paths**: These functions are especially powerful in `hiera.yaml` hierarchy definitions to create dynamic data source paths. + +2. **Combine with facts**: Use Facter facts as function arguments to create flexible, fact-driven configurations. + +3. **Keep it simple**: While powerful, complex interpolation expressions can be hard to debug. Consider moving complex logic to Puppet code when appropriate. diff --git a/ext/hiera_with_functions.yaml b/ext/hiera_with_functions.yaml new file mode 100644 index 000000000..7ff12bef1 --- /dev/null +++ b/ext/hiera_with_functions.yaml @@ -0,0 +1,40 @@ +# Example hiera.yaml demonstrating interpolation functions +# +# This configuration shows how to use substring() and match() functions +# in hierarchy paths for dynamic data source resolution. +# +--- +:backends: + - yaml + +:logger: console + +:hierarchy: + # Node-specific data using full certname + - name: "node" + path: "nodes/%{certname}.yaml" + + # Group nodes by first 4 characters of certname + # e.g., web01.example.com -> nodes/prefix/web0 + - name: "node prefix" + path: "nodes/prefix/%{substring($certname, 0, 4)}.yaml" + + # Role-based configuration + - name: "role" + path: "roles/%{role}.yaml" + + # Environment-specific + - name: "environment" + path: "env/%{environment}.yaml" + + # OS family with major version only + # e.g., CentOS 7.9.2009 -> os/CentOS/7 + - name: "os" + path: "os/%{operatingsystem}/%{substring($operatingsystemrelease, 0, 1)}.yaml" + + # Common defaults + - name: "common" + path: "common.yaml" + +:yaml: + :datadir: /etc/puppetlabs/code/hieradata diff --git a/lib/hiera/interpolate.rb b/lib/hiera/interpolate.rb index 88bfa875e..81bf02467 100644 --- a/lib/hiera/interpolate.rb +++ b/lib/hiera/interpolate.rb @@ -8,7 +8,7 @@ class Hiera::InterpolationInvalidValue < StandardError; end class Hiera::Interpolate RX_INTERPOLATION = /%\{([^\}]*)\}/ RX_ONLY_INTERPOLATION = /^%\{([^\}]*)\}$/ - RX_METHOD_AND_ARG = /^(\w+)\(([^)]*)\)$/ + RX_METHOD_AND_ARG = /^(\w+)\((.*)\)$/ EMPTY_INTERPOLATIONS = { '' => true, @@ -23,7 +23,14 @@ class Hiera::Interpolate 'hiera' => :hiera_interpolate, 'scope' => :scope_interpolate, 'literal' => :literal_interpolate, - 'alias' => :alias_interpolate + 'alias' => :alias_interpolate, + 'substring' => :substring_interpolate, + 'match' => :match_interpolate, + 'grep_captures' => :grep_captures_interpolate, + 'version_gt' => :version_gt_interpolate, + 'version_gte' => :version_gte_interpolate, + 'version_lt' => :version_lt_interpolate, + 'version_lte' => :version_lte_interpolate }.freeze class << self @@ -43,25 +50,102 @@ def interpolate(data, scope, extra_data, context) # each interpolation site in isolation using separate recursion guards. new_context = context.nil? ? {} : context.clone new_context[:recurse_guard] ||= Hiera::RecursiveGuard.new - data.gsub(RX_INTERPOLATION) do |match| + + result = data.dup + offset = 0 + + find_interpolations(data).each do |start_pos, end_pos, inner| + match = "%{#{inner}}" (interp_val, interpolate_method) = do_interpolation(match, scope, extra_data, new_context) if (interpolate_method == :alias_interpolate) && !interp_val.is_a?(String) - return interp_val if data.match(RX_ONLY_INTERPOLATION) + return interp_val if data == match raise Hiera::InterpolationInvalidValue, "Cannot call alias in the string context" - else - interp_val end + + replacement = interp_val.to_s + result[start_pos + offset, end_pos - start_pos + 1] = replacement + offset += replacement.length - (end_pos - start_pos + 1) end + + result else data end end + def find_interpolations(data) + results = [] + i = 0 + while i < data.length - 1 + if data[i] == '%' && data[i + 1] == '{' + start_pos = i + inner_start = i + 2 + j = inner_start + in_single_quote = false + in_func_args = false + first_close_brace = nil + + while j < data.length + ch = data[j] + + # Remember first } position for fallback + first_close_brace ||= j if ch == '}' + + # Track if we're inside function arguments (after opening paren) + if ch == '(' && !in_single_quote + in_func_args = true + elsif ch == ')' && !in_single_quote + in_func_args = false + end + + # Only track single quotes inside function arguments + # This allows patterns like '/\d{3,5}/' while preserving + # error detection for malformed keys like %{'the.key"} + if in_func_args + if in_single_quote + in_single_quote = false if ch == "'" + elsif ch == "'" + in_single_quote = true + end + end + + if ch == '}' && !in_single_quote + inner = data[inner_start...j] + results << [start_pos, j, inner] + i = j + break + end + j += 1 + end + + # If we reached end of string with unclosed quote, fall back to first } + # This allows downstream validation to catch the quote mismatch error + if j >= data.length && in_single_quote && first_close_brace + inner = data[inner_start...first_close_brace] + results << [start_pos, first_close_brace, inner] + i = first_close_brace + end + end + i += 1 + end + results + end + private :find_interpolations + def do_interpolation(data, scope, extra_data, context) - if data.is_a?(String) && (match = data.match(RX_INTERPOLATION)) - interpolation_variable = match[1] + # Extract interpolation variable - try smart parser first, fall back to regex + interpolation_variable = nil + if data.is_a?(String) + if data.start_with?('%{') && data.end_with?('}') + # Already extracted by find_interpolations + interpolation_variable = data[2...-1] + elsif (match = data.match(RX_INTERPOLATION)) + interpolation_variable = match[1] + end + end + if interpolation_variable # HI-494 return ['', nil] if EMPTY_INTERPOLATIONS[interpolation_variable.strip] @@ -87,6 +171,7 @@ def get_interpolation_method_and_key(interpolation_variable, context) method_sym = INTERPOLATION_METHODS[method] raise Hiera::InterpolationInvalidValue, "Invalid interpolation method '#{method}'" unless method_sym arg = match[2] + return [method_sym, arg] if %w[substring match grep_captures version_gt version_gte version_lt version_lte].include?(method) match_data = arg.match(Hiera::QUOTED_KEY) raise Hiera::InterpolationInvalidValue, "Argument to interpolation method '#{method}' must be quoted, got '#{arg}'" unless match_data [method_sym, match_data[1] || match_data[2]] @@ -113,6 +198,365 @@ def literal_interpolate(data, key, scope, extra_data, context) end private :literal_interpolate + def substring_interpolate(data, key, scope, extra_data, context) + args = parse_substring_args(key, data) + value = resolve_substring_value(args[0], scope, extra_data, data) + start = resolve_substring_integer(args[1], scope, extra_data, data) + + if args.length == 3 + count = resolve_substring_integer(args[2], scope, extra_data, data) + result = value[start, count] + else + result = value[start..-1] + end + result.nil? ? '' : result + end + private :substring_interpolate + + def match_interpolate(data, key, scope, extra_data, context) + args = parse_match_args(key, data) + value = resolve_substring_value(args[0], scope, extra_data, data) + pattern = args[1] + + regex = parse_regex_literal(pattern, data) + matched = if regex + !(value.match(regex)).nil? + else + needle = resolve_substring_value(pattern, scope, extra_data, data) + value.include?(needle) + end + + matched ? 'true' : 'false' + end + private :match_interpolate + + def grep_captures_interpolate(data, key, scope, extra_data, context) + args = parse_grep_captures_args(key, data) + value = resolve_substring_value(args[0], scope, extra_data, data) + pattern = args[1] + + regex = parse_regex_literal(pattern, data) + raise Hiera::InterpolationInvalidValue, "grep_captures requires a regex pattern in interpolation expression: #{data}" unless regex + + match_result = value.match(regex) + return '' if match_result.nil? + + # Determine separator and capture group indices + # Default: empty separator, all capture groups + separator = '' + group_indices = (1..match_result.length - 1).to_a + + if args.length >= 3 + third_arg = args[2].strip + if third_arg.start_with?('[') + # Third arg is array literal - use default separator + group_indices = parse_array_literal(args[2], scope, extra_data, data) + else + # Third arg is separator (quoted string) + separator = resolve_substring_value(args[2], scope, extra_data, data) + if args.length >= 4 + # Fourth arg is array literal of capture group indices: [1,2,4] + group_indices = parse_array_literal(args[3], scope, extra_data, data) + end + end + end + + # Extract specified capture groups + captures = group_indices.map do |idx| + match_result[idx].to_s + end + + captures.join(separator) + end + private :grep_captures_interpolate + + def parse_grep_captures_args(arg_string, data) + args = split_function_args(arg_string) + unless args.length >= 2 + raise Hiera::InterpolationInvalidValue, "grep_captures requires at least 2 arguments (string, regex) in interpolation expression: #{data}" + end + if args[0].empty? || args[1].empty? + raise Hiera::InterpolationInvalidValue, "grep_captures contains an empty required argument in interpolation expression: #{data}" + end + args + end + private :parse_grep_captures_args + + def version_gt_interpolate(data, key, scope, extra_data, context) + versioncmp_interpolate(data, key, scope, extra_data, :>) + end + private :version_gt_interpolate + + def version_gte_interpolate(data, key, scope, extra_data, context) + versioncmp_interpolate(data, key, scope, extra_data, :>=) + end + private :version_gte_interpolate + + def version_lt_interpolate(data, key, scope, extra_data, context) + versioncmp_interpolate(data, key, scope, extra_data, :<) + end + private :version_lt_interpolate + + def version_lte_interpolate(data, key, scope, extra_data, context) + versioncmp_interpolate(data, key, scope, extra_data, :<=) + end + private :version_lte_interpolate + + def versioncmp_interpolate(data, key, scope, extra_data, operator) + args = parse_versioncmp_args(key, data) + version_a = resolve_substring_value(args[0], scope, extra_data, data) + version_b = resolve_substring_value(args[1], scope, extra_data, data) + + result = compare_versions(version_a, version_b, operator) + result ? 'true' : 'false' + end + private :versioncmp_interpolate + + def parse_versioncmp_args(arg_string, data) + args = split_function_args(arg_string) + unless args.length == 2 + raise Hiera::InterpolationInvalidValue, "version comparison requires 2 arguments in interpolation expression: #{data}" + end + if args.any? { |a| a.empty? } + raise Hiera::InterpolationInvalidValue, "version comparison contains an empty argument in interpolation expression: #{data}" + end + args + end + private :parse_versioncmp_args + + def compare_versions(version_a, version_b, operator) + # Use Gem::Version for proper semantic version comparison + # It handles versions like "1.2.3", "1.2.3-beta", "1.2.3.4", etc. + begin + gem_a = Gem::Version.new(version_a) + gem_b = Gem::Version.new(version_b) + gem_a.send(operator, gem_b) + rescue ArgumentError + # Fall back to string comparison if versions are malformed + version_a.send(operator, version_b) + end + end + private :compare_versions + + def parse_array_literal(token, scope, extra_data, data) + value = token.to_s.strip + unless value.start_with?('[') && value.end_with?(']') + raise Hiera::InterpolationInvalidValue, "grep_captures capture groups must be an array literal like [1,2,3] in interpolation expression: #{data}" + end + + inner = value[1...-1] + return [1] if inner.strip.empty? + + inner.split(',').map do |item| + resolve_substring_integer(item.strip, scope, extra_data, data) + end + end + private :parse_array_literal + + def parse_match_args(arg_string, data) + args = split_function_args(arg_string) + unless args.length == 2 + raise Hiera::InterpolationInvalidValue, "match requires 2 arguments in interpolation expression: #{data}" + end + if args.any? { |a| a.empty? } + raise Hiera::InterpolationInvalidValue, "match contains an empty argument in interpolation expression: #{data}" + end + args + end + private :parse_match_args + + def parse_substring_args(arg_string, data) + args = split_function_args(arg_string) + unless args.length == 2 || args.length == 3 + raise Hiera::InterpolationInvalidValue, "substring requires 2 or 3 arguments in interpolation expression: #{data}" + end + if args.any? { |a| a.empty? } + raise Hiera::InterpolationInvalidValue, "substring contains an empty argument in interpolation expression: #{data}" + end + args + end + private :parse_substring_args + + def split_function_args(arg_string) + input = arg_string.to_s + args = [] + current = '' + in_single_quote = false + in_double_quote = false + in_regex = false + in_array = false + in_curly = 0 + escaped = false + regex_escaped = false + + input.each_char do |ch| + if escaped + current << ch + escaped = false + next + end + + if ch == '\\' + current << ch + escaped = true + next + end + + if in_single_quote + current << ch + in_single_quote = false if ch == "'" + next + end + + if in_double_quote + current << ch + in_double_quote = false if ch == '"' + next + end + + if in_regex + current << ch + if ch == '/' && !regex_escaped + in_regex = false + end + regex_escaped = (ch == '\\' && !regex_escaped) + next + end + + if in_array + current << ch + in_array = false if ch == ']' + next + end + + if in_curly > 0 + current << ch + in_curly += 1 if ch == '{' + in_curly -= 1 if ch == '}' + next + end + + case ch + when "'" + current << ch + in_single_quote = true + when '"' + current << ch + in_double_quote = true + when '/' + current << ch + in_regex = current.strip == '/' + when '[' + current << ch + in_array = true + when '{' + current << ch + in_curly = 1 + when ',' + args << current.strip + current = '' + else + current << ch + end + end + + args << current.strip + args + end + private :split_function_args + + def find_closing_regex_delimiter(value) + escaped = false + (1...value.length).each do |i| + ch = value[i] + if escaped + escaped = false + next + end + if ch == '\\' + escaped = true + next + end + return i if ch == '/' + end + nil + end + private :find_closing_regex_delimiter + + def parse_regex_literal(token, data) + value = token.to_s.strip + + # Support quoted regex: '/pattern/' or "/pattern/" + if (value.start_with?("'") && value.end_with?("'")) || + (value.start_with?('"') && value.end_with?('"')) + inner = value[1...-1].strip + return nil unless inner.start_with?('/') + value = inner + end + + return nil unless value.start_with?('/') + + closing = find_closing_regex_delimiter(value) + return nil if closing.nil? || closing == 0 + + pattern = value[1...closing] + flags = value[(closing + 1)..] || '' + + options = 0 + flags.each_char do |flag| + case flag + when 'i' + options |= Regexp::IGNORECASE + when 'm' + options |= Regexp::MULTILINE + when 'x' + options |= Regexp::EXTENDED + else + raise Hiera::InterpolationInvalidValue, "Invalid regex option '#{flag}' in interpolation expression: #{data}" + end + end + + Regexp.new(pattern, options) + rescue RegexpError + raise Hiera::InterpolationInvalidValue, "Invalid regex '#{token}' in interpolation expression: #{data}" + end + private :parse_regex_literal + + def resolve_substring_value(token, scope, extra_data, data) + match_data = token.match(Hiera::QUOTED_KEY) + return (match_data[1] || match_data[2]).to_s if match_data + + # Handle empty quoted strings which QUOTED_KEY doesn't match + return '' if token == "''" || token == '""' + + name = token.sub(/^\$/, '') + segments = Hiera::Util.split_key(name) { |problem| Hiera::InterpolationInvalidValue.new("#{problem} in interpolation expression: #{data}") } + value = catch(:no_such_key) { Hiera::Backend.qualified_lookup(segments, scope, name) } + return value.to_s unless value.nil? + + value = catch(:no_such_key) { Hiera::Backend.qualified_lookup(segments, extra_data, name) } + value.to_s + end + private :resolve_substring_value + + def resolve_substring_integer(token, scope, extra_data, data) + match_data = token.match(Hiera::QUOTED_KEY) + token = (match_data[1] || match_data[2]) if match_data + token = token.to_s + + if token =~ /^\$/ + token = token.sub(/^\$/, '') + segments = Hiera::Util.split_key(token) { |problem| Hiera::InterpolationInvalidValue.new("#{problem} in interpolation expression: #{data}") } + token = catch(:no_such_key) { return Integer(Hiera::Backend.qualified_lookup(segments, scope, token).to_s) } + token = catch(:no_such_key) { Hiera::Backend.qualified_lookup(segments, extra_data, token) } + end + + Integer(token.to_s) + rescue ArgumentError + raise Hiera::InterpolationInvalidValue, "substring requires integer arguments in interpolation expression: #{data}" + end + private :resolve_substring_integer + def alias_interpolate(data, key, scope, extra_data, context) Hiera::Backend.lookup(key, nil, scope, context[:order_override], :priority, context) end diff --git a/spec/unit/fixtures/interpolate/data/weird_keys.yaml b/spec/unit/fixtures/interpolate/data/weird_keys.yaml index 93af5facb..bca9abfde 100644 --- a/spec/unit/fixtures/interpolate/data/weird_keys.yaml +++ b/spec/unit/fixtures/interpolate/data/weird_keys.yaml @@ -10,3 +10,47 @@ angry: '%{alias("#@!&%|§")}' '#@!&%|§': 'not happy at all' very_angry: '%{alias("!$\%!.#@!&%|§")}' + +substring_literal: "%{substring('abcdef', 2, 3)}" +substring_literal_to_end: "%{substring('abcdef', 2)}" +substring_scope: "%{substring($str, $start, $count)}" + +match_regex_true: "%{match('abcdef', /cde/)}" +match_regex_false: "%{match('abcdef', /xyz/)}" +match_regex_parens: "%{match('abcdef', /(c(d)e)/)}" +match_regex_flags: "%{match('ABCDEF', /cde/i)}" +match_regex_escaped_slash: "%{match('a/b/c', /a\\/b/)}" +match_string_true: "%{match('abcdef', 'cde')}" +match_string_false: "%{match('abcdef', 'xyz')}" + +grep_captures_simple: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '-')}" +grep_captures_with_sep: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '_')}" +grep_captures_with_groups: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '_', [1,3])}" +grep_captures_all_groups: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '-', [1,2,3])}" +grep_captures_no_match: "%{grep_captures('xyz', /([a-z]+)-(\\d+)/)}" +grep_captures_version: "%{grep_captures('12345-widget-org-prod-99', /(\\d+)-(\\w+)-(\\w+)-(\\w+)-(\\d+)/, '_', [1,2,4])}" +grep_captures_empty_sep: "%{grep_captures('abc-123-def', /([a-z]+)-(\\d+)-([a-z]+)/, '', [1,2])}" +grep_captures_quoted_regex: '%{grep_captures(''abc-123-def'', ''/([a-z]+)-(\d+)-([a-z]+)/'', ''_'', [1,2])}' +match_quoted_regex: '%{match(''abc123'', ''/\d+/'')}' +grep_captures_curly_quantifier: '%{grep_captures(''12345-widget'', ''/(\d{3,5})-(\w+)/'', ''_'', [1,2])}' + +version_gt_true: "%{version_gt('2.0.0', '1.0.0')}" +version_gt_false: "%{version_gt('1.0.0', '2.0.0')}" +version_gt_equal: "%{version_gt('1.0.0', '1.0.0')}" +version_gte_true: "%{version_gte('2.0.0', '1.0.0')}" +version_gte_equal: "%{version_gte('1.0.0', '1.0.0')}" +version_gte_false: "%{version_gte('1.0.0', '2.0.0')}" +version_lt_true: "%{version_lt('1.0.0', '2.0.0')}" +version_lt_false: "%{version_lt('2.0.0', '1.0.0')}" +version_lt_equal: "%{version_lt('1.0.0', '1.0.0')}" +version_lte_true: "%{version_lte('1.0.0', '2.0.0')}" +version_lte_equal: "%{version_lte('1.0.0', '1.0.0')}" +version_lte_false: "%{version_lte('2.0.0', '1.0.0')}" +version_semver: "%{version_gte('1.10.0', '1.9.0')}" +version_prerelease: "%{version_lt('1.0.0.alpha', '1.0.0')}" + +grep_captures_5groups_selected: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', [1,2,4])}' +grep_captures_5groups_underscore: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', ''_'', [1,2,4])}' +grep_captures_5groups_empty: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', '''', [1,2,4])}' +grep_captures_5groups_all: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'')}' +grep_captures_5groups_sep_only: '%{grep_captures(''abcd 1234 ABCD !@#$ 4567'', ''/^([a-z]+).*?(\d{3}).*(ABC).* !(.{3}) (\d{3})/'', ''-'')}' diff --git a/spec/unit/interpolate_spec.rb b/spec/unit/interpolate_spec.rb index 7548d219e..21463dc89 100644 --- a/spec/unit/interpolate_spec.rb +++ b/spec/unit/interpolate_spec.rb @@ -31,6 +31,153 @@ it 'allows keys with non alphanumeric characters' do expect(hiera.lookup('angry', nil, {})).to eq('not happy') end + + it 'supports substring interpolation with literal arguments' do + expect(hiera.lookup('substring_literal', nil, {})).to eq('cde') + end + + it 'supports substring interpolation to end of string' do + expect(hiera.lookup('substring_literal_to_end', nil, {})).to eq('cdef') + end + + it 'supports substring interpolation with scope arguments' do + expect(hiera.lookup('substring_scope', nil, {'str' => 'abcdef', 'start' => 1, 'count' => 2})).to eq('bc') + end + + it 'supports match interpolation with regex literals' do + expect(hiera.lookup('match_regex_true', nil, {})).to eq('true') + expect(hiera.lookup('match_regex_false', nil, {})).to eq('false') + end + + it 'supports match interpolation with regex parentheses and flags' do + expect(hiera.lookup('match_regex_parens', nil, {})).to eq('true') + expect(hiera.lookup('match_regex_flags', nil, {})).to eq('true') + end + + it 'supports match interpolation with escaped slash in regex' do + expect(hiera.lookup('match_regex_escaped_slash', nil, {})).to eq('true') + end + + it 'supports match interpolation with plain string matching' do + expect(hiera.lookup('match_string_true', nil, {})).to eq('true') + expect(hiera.lookup('match_string_false', nil, {})).to eq('false') + end + + it 'supports grep_captures interpolation with explicit separator' do + expect(hiera.lookup('grep_captures_simple', nil, {})).to eq('abc-123-def') + end + + it 'supports grep_captures interpolation with custom separator (all groups)' do + expect(hiera.lookup('grep_captures_with_sep', nil, {})).to eq('abc_123_def') + end + + it 'supports grep_captures interpolation with specific capture groups' do + expect(hiera.lookup('grep_captures_with_groups', nil, {})).to eq('abc_def') + end + + it 'supports grep_captures interpolation with all capture groups' do + expect(hiera.lookup('grep_captures_all_groups', nil, {})).to eq('abc-123-def') + end + + it 'returns empty string when grep_captures does not match' do + expect(hiera.lookup('grep_captures_no_match', nil, {})).to eq('') + end + + it 'supports grep_captures with complex version-like pattern' do + expect(hiera.lookup('grep_captures_version', nil, {})).to eq('12345_widget_prod') + end + + it 'supports grep_captures with empty separator to merge captures directly' do + expect(hiera.lookup('grep_captures_empty_sep', nil, {})).to eq('abc123') + end + + it 'supports grep_captures with quoted regex pattern' do + expect(hiera.lookup('grep_captures_quoted_regex', nil, {})).to eq('abc_123') + end + + it 'supports match with quoted regex pattern' do + expect(hiera.lookup('match_quoted_regex', nil, {})).to eq('true') + end + + it 'supports grep_captures with curly brace quantifier in quoted regex' do + expect(hiera.lookup('grep_captures_curly_quantifier', nil, {})).to eq('12345_widget') + end + + it 'supports version_gt returning true when first version is greater' do + expect(hiera.lookup('version_gt_true', nil, {})).to eq('true') + end + + it 'supports version_gt returning false when first version is less' do + expect(hiera.lookup('version_gt_false', nil, {})).to eq('false') + end + + it 'supports version_gt returning false when versions are equal' do + expect(hiera.lookup('version_gt_equal', nil, {})).to eq('false') + end + + it 'supports version_gte returning true when first version is greater' do + expect(hiera.lookup('version_gte_true', nil, {})).to eq('true') + end + + it 'supports version_gte returning true when versions are equal' do + expect(hiera.lookup('version_gte_equal', nil, {})).to eq('true') + end + + it 'supports version_gte returning false when first version is less' do + expect(hiera.lookup('version_gte_false', nil, {})).to eq('false') + end + + it 'supports version_lt returning true when first version is less' do + expect(hiera.lookup('version_lt_true', nil, {})).to eq('true') + end + + it 'supports version_lt returning false when first version is greater' do + expect(hiera.lookup('version_lt_false', nil, {})).to eq('false') + end + + it 'supports version_lt returning false when versions are equal' do + expect(hiera.lookup('version_lt_equal', nil, {})).to eq('false') + end + + it 'supports version_lte returning true when first version is less' do + expect(hiera.lookup('version_lte_true', nil, {})).to eq('true') + end + + it 'supports version_lte returning true when versions are equal' do + expect(hiera.lookup('version_lte_equal', nil, {})).to eq('true') + end + + it 'supports version_lte returning false when first version is greater' do + expect(hiera.lookup('version_lte_false', nil, {})).to eq('false') + end + + it 'handles semantic versioning correctly (1.10.0 > 1.9.0)' do + expect(hiera.lookup('version_semver', nil, {})).to eq('true') + end + + it 'handles prerelease versions correctly (1.0.0.alpha < 1.0.0)' do + expect(hiera.lookup('version_prerelease', nil, {})).to eq('true') + end + + it 'supports grep_captures with 5 groups and selected groups (no separator)' do + expect(hiera.lookup('grep_captures_5groups_selected', nil, {})).to eq('abcd123@#$') + end + + it 'supports grep_captures with 5 groups and underscore separator' do + expect(hiera.lookup('grep_captures_5groups_underscore', nil, {})).to eq('abcd_123_@#$') + end + + it 'supports grep_captures with 5 groups and explicit empty separator' do + expect(hiera.lookup('grep_captures_5groups_empty', nil, {})).to eq('abcd123@#$') + end + + it 'supports grep_captures with 5 groups returning all groups concatenated' do + expect(hiera.lookup('grep_captures_5groups_all', nil, {})).to eq('abcd123ABC@#$456') + end + + it 'supports grep_captures with 5 groups and separator only (all groups)' do + expect(hiera.lookup('grep_captures_5groups_sep_only', nil, {})).to eq('abcd-123-ABC-@#$-456') + end end context "when not finding value for interpolated key" do