Skip to content

Commit e74595a

Browse files
bordeuxclaude
andcommitted
feat: add Kubernetes helper functions with k8s_ prefix
Implement 3 Kubernetes-specific functions for manifest generation: - k8s_resource_request(cpu, memory) - Format resource requests in YAML - k8s_label_safe(value) - Sanitize strings for K8s labels - k8s_dns_label_safe(value) - Sanitize strings for DNS-safe names k8s_resource_request features: - Auto-converts numeric CPU to millicores (0.5 → "500m", 2 → "2000m") - Auto-converts numeric memory to Mi/Gi (512 → "512Mi", 1024 → "1Gi") - Accepts string values as-is for manual control - Returns YAML-formatted resource request block k8s_label_safe features: - Converts to lowercase - Allows alphanumeric, dashes, underscores, dots - Removes leading/trailing non-alphanumeric chars - Truncates to 63 characters (K8s label limit) - Ensures start/end with alphanumeric k8s_dns_label_safe features: - Stricter than label_safe (DNS RFC 1123) - Only lowercase alphanumeric and dashes - No underscores or dots allowed - Collapses multiple consecutive dashes - Max 63 characters Use cases: - Generating Kubernetes deployments with dynamic resources - Environment-based resource allocation (dev vs prod) - Sanitizing user input for K8s resource names - Multi-service deployments with consistent labeling Testing: - 30 unit tests in tests/test_kubernetes_functions.rs - 29 integration test cases in tests/integration/tests/21_kubernetes_functions.sh - Full deployment manifest generation examples - Label truncation and sanitization edge cases Files created: - src/functions/kubernetes.rs - Kubernetes helper implementations - tests/test_kubernetes_functions.rs - Unit tests - tests/integration/tests/21_kubernetes_functions.sh - Integration tests Updated: - README.md - Added Kubernetes Functions section with examples - TODO.md - Marked 3 functions as complete - src/functions/mod.rs - Registered k8s_ functions All tests passing, clippy clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2035a51 commit e74595a

6 files changed

Lines changed: 1007 additions & 3 deletions

File tree

README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2570,6 +2570,105 @@ Python files:
25702570
{% endif %}
25712571
```
25722572
2573+
### Kubernetes Functions
2574+
2575+
Kubernetes-specific helpers for manifest generation and label sanitization.
2576+
2577+
#### `k8s_resource_request(cpu, memory)`
2578+
2579+
Format Kubernetes resource requests in YAML format.
2580+
2581+
**Arguments:**
2582+
- `cpu` (required): CPU request - string like `"500m"` or number (converted to millicores)
2583+
- `memory` (required): Memory request - string like `"512Mi"` or number in MiB (auto-converted to Mi/Gi)
2584+
2585+
**Returns:** YAML-formatted resource request block
2586+
2587+
**Numeric conversions:**
2588+
- CPU: `0.5``"500m"`, `2``"2000m"`
2589+
- Memory: `512``"512Mi"`, `1024``"1Gi"`, `2048``"2Gi"`
2590+
2591+
**Example:**
2592+
```jinja
2593+
{# Basic usage with strings #}
2594+
{{ k8s_resource_request(cpu="500m", memory="512Mi") }}
2595+
{# Output:
2596+
requests:
2597+
cpu: "500m"
2598+
memory: "512Mi"
2599+
#}
2600+
2601+
{# With numeric values (auto-formatted) #}
2602+
{{ k8s_resource_request(cpu=0.5, memory=512) }}
2603+
{# Output:
2604+
requests:
2605+
cpu: "500m"
2606+
memory: "512Mi"
2607+
#}
2608+
2609+
{# In a Kubernetes deployment #}
2610+
apiVersion: apps/v1
2611+
kind: Deployment
2612+
metadata:
2613+
name: my-app
2614+
spec:
2615+
template:
2616+
spec:
2617+
containers:
2618+
- name: app
2619+
image: myapp:latest
2620+
resources:
2621+
{{ k8s_resource_request(cpu="1000m", memory="1Gi") | indent(10) }}
2622+
```
2623+
2624+
#### `k8s_label_safe(value)`
2625+
2626+
Sanitize string to be Kubernetes label-safe.
2627+
2628+
**Arguments:**
2629+
- `value` (required): String to sanitize
2630+
2631+
**Returns:** Sanitized string following Kubernetes label requirements:
2632+
- Max 63 characters
2633+
- Only alphanumeric, dashes, underscores, dots
2634+
- Must start and end with alphanumeric
2635+
- Lowercase
2636+
2637+
**Example:**
2638+
```jinja
2639+
{# Sanitize label value #}
2640+
{{ k8s_label_safe(value="My App Name (v2.0)") }}
2641+
{# Output: my-app-name-v2.0 #}
2642+
2643+
{# Use in labels #}
2644+
metadata:
2645+
labels:
2646+
app: {{ k8s_label_safe(value=app_name) }}
2647+
version: {{ k8s_label_safe(value=version) }}
2648+
```
2649+
2650+
#### `k8s_dns_label_safe(value)`
2651+
2652+
Format DNS-safe label (max 63 chars, lowercase, alphanumeric and dashes only).
2653+
2654+
**Arguments:**
2655+
- `value` (required): String to format
2656+
2657+
**Returns:** DNS-safe string suitable for Kubernetes resource names
2658+
2659+
**Example:**
2660+
```jinja
2661+
{# Format DNS label #}
2662+
{{ k8s_dns_label_safe(value="My Service Name") }}
2663+
{# Output: my-service-name #}
2664+
2665+
{# Use in service names #}
2666+
apiVersion: v1
2667+
kind: Service
2668+
metadata:
2669+
name: {{ k8s_dns_label_safe(value=service_name) }}
2670+
```
2671+
25732672
### Logic Functions
25742673
25752674
Conditional logic and default value handling.

TODO.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,9 @@ This document contains ideas for new functions and features to make tmpltool mor
228228
*Specific for Docker, Kubernetes, docker-compose*
229229

230230
- [ ] `docker_image_tag(image, tag)` - Format Docker image with tag
231-
- [ ] `k8s_label_safe(string)` - Convert to Kubernetes-safe label
232-
- [ ] `dns_label_safe(string)` - Convert to DNS-safe label (max 63 chars)
233-
- [ ] `resource_request(cpu, memory)` - Format k8s resource request
231+
- [x] `k8s_label_safe(string)` - Convert to Kubernetes-safe label
232+
- [x] `k8s_dns_label_safe(string)` - Convert to DNS-safe label (max 63 chars)
233+
- [x] `k8s_resource_request(cpu, memory)` - Format k8s resource request
234234
- [ ] `env_var_ref(var_name)` - Format environment variable reference
235235
- [ ] `secret_ref(secret_name, key)` - Format secret reference
236236
- [ ] `configmap_ref(cm_name, key)` - Format ConfigMap reference

src/functions/kubernetes.rs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
//! Kubernetes helper functions for MiniJinja templates
2+
//!
3+
//! This module provides Kubernetes-specific formatting and validation functions:
4+
//! - Resource request/limit formatting
5+
//! - Label sanitization
6+
//! - Reference formatting
7+
8+
use minijinja::value::Kwargs;
9+
use minijinja::{Error, ErrorKind, Value};
10+
11+
/// Format Kubernetes resource requests
12+
///
13+
/// # Arguments
14+
///
15+
/// * `cpu` (required) - CPU request (string like "500m" or number like 0.5)
16+
/// * `memory` (required) - Memory request (string like "512Mi" or number for MiB)
17+
///
18+
/// # Returns
19+
///
20+
/// Returns a YAML-formatted string with resource requests
21+
///
22+
/// # Example
23+
///
24+
/// ```jinja
25+
/// {# Basic usage with strings #}
26+
/// {{ k8s_resource_request(cpu="500m", memory="512Mi") }}
27+
/// {# Output:
28+
/// requests:
29+
/// cpu: "500m"
30+
/// memory: "512Mi"
31+
/// #}
32+
///
33+
/// {# With numeric values (auto-formatted) #}
34+
/// {{ k8s_resource_request(cpu=0.5, memory=512) }}
35+
/// {# Output:
36+
/// requests:
37+
/// cpu: "500m"
38+
/// memory: "512Mi"
39+
/// #}
40+
///
41+
/// {# In a Kubernetes deployment #}
42+
/// apiVersion: apps/v1
43+
/// kind: Deployment
44+
/// metadata:
45+
/// name: my-app
46+
/// spec:
47+
/// template:
48+
/// spec:
49+
/// containers:
50+
/// - name: app
51+
/// image: myapp:latest
52+
/// resources:
53+
/// {{ k8s_resource_request(cpu="1000m", memory="1Gi") | indent(10) }}
54+
///
55+
/// {# With variables from config #}
56+
/// {% set app_config = {"cpu": "250m", "memory": "256Mi"} %}
57+
/// resources:
58+
/// {{ k8s_resource_request(cpu=app_config.cpu, memory=app_config.memory) | indent(2) }}
59+
/// ```
60+
pub fn k8s_resource_request_fn(kwargs: Kwargs) -> Result<Value, Error> {
61+
let cpu: Value = kwargs.get("cpu")?;
62+
let memory: Value = kwargs.get("memory")?;
63+
64+
// Format CPU value
65+
let cpu_str = if let Some(cpu_str) = cpu.as_str() {
66+
// Already a string, use as-is
67+
cpu_str.to_string()
68+
} else {
69+
// Try to convert to number
70+
let json_cpu: serde_json::Value = serde_json::to_value(&cpu).map_err(|e| {
71+
Error::new(
72+
ErrorKind::InvalidOperation,
73+
format!("Failed to convert cpu: {}", e),
74+
)
75+
})?;
76+
77+
let cpu_num = json_cpu.as_f64().ok_or_else(|| {
78+
Error::new(
79+
ErrorKind::InvalidOperation,
80+
format!("cpu must be a string or number, found: {}", cpu),
81+
)
82+
})?;
83+
84+
// Convert to millicores (1 CPU = 1000m)
85+
let millicores = (cpu_num * 1000.0).round() as i64;
86+
format!("{}m", millicores)
87+
};
88+
89+
// Format memory value
90+
let memory_str = if let Some(memory_str) = memory.as_str() {
91+
// Already a string, use as-is
92+
memory_str.to_string()
93+
} else {
94+
// Try to convert to number
95+
let json_memory: serde_json::Value = serde_json::to_value(&memory).map_err(|e| {
96+
Error::new(
97+
ErrorKind::InvalidOperation,
98+
format!("Failed to convert memory: {}", e),
99+
)
100+
})?;
101+
102+
let memory_num = json_memory.as_f64().ok_or_else(|| {
103+
Error::new(
104+
ErrorKind::InvalidOperation,
105+
format!("memory must be a string or number, found: {}", memory),
106+
)
107+
})?;
108+
109+
// Convert to appropriate unit
110+
if memory_num >= 1024.0 {
111+
// Use Gi for values >= 1024 MiB
112+
let gib = memory_num / 1024.0;
113+
if gib.fract() == 0.0 {
114+
format!("{}Gi", gib as i64)
115+
} else {
116+
format!("{:.2}Gi", gib)
117+
}
118+
} else {
119+
// Use Mi for smaller values
120+
if memory_num.fract() == 0.0 {
121+
format!("{}Mi", memory_num as i64)
122+
} else {
123+
format!("{:.2}Mi", memory_num)
124+
}
125+
}
126+
};
127+
128+
// Build YAML output
129+
let output = format!(
130+
"requests:\n cpu: \"{}\"\n memory: \"{}\"",
131+
cpu_str, memory_str
132+
);
133+
134+
Ok(Value::from(output))
135+
}
136+
137+
/// Sanitize string to be Kubernetes label-safe
138+
///
139+
/// # Arguments
140+
///
141+
/// * `value` (required) - String to sanitize
142+
///
143+
/// # Returns
144+
///
145+
/// Returns a sanitized string that follows Kubernetes label requirements:
146+
/// - Max 63 characters
147+
/// - Only alphanumeric, dashes, underscores, dots
148+
/// - Must start and end with alphanumeric
149+
///
150+
/// # Example
151+
///
152+
/// ```jinja
153+
/// {# Sanitize label value #}
154+
/// {{ k8s_label_safe(value="My App Name (v2.0)") }}
155+
/// {# Output: my-app-name-v2.0 #}
156+
///
157+
/// {# Long string gets truncated #}
158+
/// {{ k8s_label_safe(value="this-is-a-very-long-label-name-that-exceeds-the-kubernetes-maximum-label-length-limit") }}
159+
/// {# Output: this-is-a-very-long-label-name-that-exceeds-the-kubernetes-ma #}
160+
///
161+
/// {# Use in labels #}
162+
/// metadata:
163+
/// labels:
164+
/// app: {{ k8s_label_safe(value=app_name) }}
165+
/// version: {{ k8s_label_safe(value=version) }}
166+
/// ```
167+
pub fn k8s_label_safe_fn(kwargs: Kwargs) -> Result<Value, Error> {
168+
let value: String = kwargs.get("value")?;
169+
170+
// Convert to lowercase
171+
let mut result = value.to_lowercase();
172+
173+
// Replace invalid characters with dashes
174+
result = result
175+
.chars()
176+
.map(|c| {
177+
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
178+
c
179+
} else {
180+
'-'
181+
}
182+
})
183+
.collect();
184+
185+
// Remove leading/trailing non-alphanumeric characters
186+
result = result
187+
.trim_matches(|c: char| !c.is_ascii_alphanumeric())
188+
.to_string();
189+
190+
// Truncate to 63 characters
191+
if result.len() > 63 {
192+
result.truncate(63);
193+
// Ensure it still ends with alphanumeric after truncation
194+
result = result
195+
.trim_end_matches(|c: char| !c.is_ascii_alphanumeric())
196+
.to_string();
197+
}
198+
199+
// If empty after sanitization, use a default
200+
if result.is_empty() {
201+
result = "default".to_string();
202+
}
203+
204+
Ok(Value::from(result))
205+
}
206+
207+
/// Format DNS-safe label (max 63 chars)
208+
///
209+
/// # Arguments
210+
///
211+
/// * `value` (required) - String to format
212+
///
213+
/// # Returns
214+
///
215+
/// Returns a DNS-safe string (lowercase, alphanumeric and dashes only, max 63 chars)
216+
///
217+
/// # Example
218+
///
219+
/// ```jinja
220+
/// {# Format DNS label #}
221+
/// {{ k8s_dns_label_safe(value="My Service Name") }}
222+
/// {# Output: my-service-name #}
223+
///
224+
/// {# Use in service names #}
225+
/// apiVersion: v1
226+
/// kind: Service
227+
/// metadata:
228+
/// name: {{ k8s_dns_label_safe(value=service_name) }}
229+
/// ```
230+
pub fn k8s_dns_label_safe_fn(kwargs: Kwargs) -> Result<Value, Error> {
231+
let value: String = kwargs.get("value")?;
232+
233+
// Convert to lowercase
234+
let mut result = value.to_lowercase();
235+
236+
// Replace invalid characters with dashes
237+
result = result
238+
.chars()
239+
.map(|c| {
240+
if c.is_ascii_alphanumeric() || c == '-' {
241+
c
242+
} else {
243+
'-'
244+
}
245+
})
246+
.collect();
247+
248+
// Remove leading/trailing dashes
249+
result = result.trim_matches('-').to_string();
250+
251+
// Replace multiple consecutive dashes with single dash
252+
while result.contains("--") {
253+
result = result.replace("--", "-");
254+
}
255+
256+
// Truncate to 63 characters
257+
if result.len() > 63 {
258+
result.truncate(63);
259+
// Ensure it still ends with alphanumeric after truncation
260+
result = result.trim_end_matches('-').to_string();
261+
}
262+
263+
// If empty after sanitization, use a default
264+
if result.is_empty() {
265+
result = "default".to_string();
266+
}
267+
268+
Ok(Value::from(result))
269+
}

0 commit comments

Comments
 (0)