Dynamic Helm Configuration with Lua Scripts
Enhance Helm deployments with dynamic configuration by using Lua scripts
When luaScript is present and non-empty, it is executed by a Go library that processes Lua scripts and generates structured output in the format:
{
"values": {
"key": "value"
},
"valuesFiles": ["file1.txt", "file2.json"]
}The full Helm release is rendered using this merged configuration.
Security Architecture
The Lua execution environment follows the principle of least privilege by:
- Selective Library Loading: Only essential, safe standard libraries are loaded
- Custom Module Control: All additional functionality is provided through controlled custom modules
- Restricted File Access: File operations are confined to a specified base directory
Lua Modules
Encoding Module
The encoding module provides functions for JSON and YAML serialization.
Functions
encoding.jsonEncode(value) -> string
Converts a Lua value to a JSON string.
local data = {name = "John", age = 30}
local jsonStr = encoding.jsonEncode(data)
-- Result: '{"age":30,"name":"John"}'encoding.jsonDecode(jsonString) -> value
Parses a JSON string into a Lua value.
local jsonStr = '{"name":"Alice","age":25}'
local data = encoding.jsonDecode(jsonStr)
-- Result: {name = "Alice", age = 25}encoding.yamlEncode(value) -> string
Converts a Lua value to a YAML string.
local data = {
user = {
name = "Bob",
roles = {"admin", "user"}
}
}
local yamlStr = encoding.yamlEncode(data)encoding.yamlDecode(yamlString) -> value
Parses a YAML string into a Lua value.
local yamlStr = [[
user:
name: Charlie
active: true
]]
local data = encoding.yamlDecode(yamlStr)
-- Result: {user = {name = "Charlie", active = true}}encoding.jsonSchema(value, pathStr) -> bool, error
Accepts a Lua value and a string representing a path to a JSON Schema definition file, and determines whether the value matches that schema. pathStr must be a relative path within the underlying tarball of your Plural service, and error will be nil if there's no error fetching or parsing the JSON Schema file.
local value = {
name = "Plural Labs",
employees = 10,
repositories = [
{name = "pluralsh/plural"},
{name = "pluralsh/console"}
]
}
local valid, err = encoding.jsonSchema(value, "./schema.json")
if not valid do
error(err) -- return the validation error to the caller
end
-- Result: true (if valid)File System Module
The fs module provides secure file operations within a restricted base directory.
Security Features
- Path traversal prevention: Blocks attempts to access files outside the base directory
- Base directory restriction: All file operations are confined to a specified directory
- Input validation: Cleans and validates all file paths
Functions
fs.read(filePath) -> string
Reads the entire contents of a file as a string.
local content = fs.read("config.json")
if content then
local config = encoding.jsonDecode(content)
values["config"] = config
endParameters:
filePath(string): Relative path to the file within the base directory
Returns:
string: File contents, orniland an error message on failure
fs.walk(directory) -> [string]
Returns a list of all files within the specified directory and its subdirectories.
-- Example 1: Walk directory without ignoring dotfiles
local files, err = fs.walk("mydir")
if not files then
print("Error:", err)
else
for _, file in ipairs(files) do
print(file)
end
end
-- Example 2: Walk directory while ignoring dotfiles
local files, err = fs.walk("mydir", true)
if not files then
print("Error:", err)
else
for _, file in ipairs(files) do
print(file)
end
endParameters:
directory(string): Relative path to the directory within the base directoryignoreDotfiles(boolean, optional): When set totrue, the function will exclude dotfiles and directories starting with.from the returned list. Default value:false
Returns:
[string]: Array of relative file paths, orniland an error message on failure
Utils Module
Functions available in Lua as utils.*
merge
utils.merge(destination, source, strategy) -> table, error
Performs a deep merge of two tables, combining their contents recursively.
Parameters:
destination(table): The target table to merge intosource(table): The source table to merge fromstrategy(string, optional): Merge strategy - "override" (default) or "append"
Returns:
table: Merged table, ornilon errorstring: Error message if merging fails
Merge Strategies:
"override": Source values replace destination values (default behavior)"append": For arrays/slices, append source items to destination instead of replacing
Example:
local base = {
server = {
host = "localhost",
port = 8080,
ssl = {enabled = false}
},
features = {"auth", "logging"}
}
local override = {
server = {
host = "0.0.0.0",
ssl = {enabled = true, cert = "prod.crt"}
},
features = {"metrics"}
}
-- Override merge (default)
local merged = utils.merge(base, override)
-- Result: server.host = "0.0.0.0", features = {"metrics"}
-- Append merge for arrays
local appended = utils.merge(base, override, "append")
-- Result: features = {"auth", "logging", "metrics"}splitString
utils.splitString(string, delimiter) -> [string]
Splits a string into an array of substrings using the specified delimiter.
Parameters:
string(string): The string to splitdelimiter(string): The delimiter to split on
Returns:
[string]: Array of string parts
Example:
local path = "user/config/settings.json"
local parts = utils.splitString(path, "/")
-- Result: {"user", "config", "settings.json"}
local csv = "apple,banana,cherry"
local fruits = utils.splitString(csv, ",")
-- Result: {"apple", "banana", "cherry"}
values["pathParts"] = parts
values["fruits"] = fruitspathJoin
utils.pathJoin(parts) -> string
Joins an array of path components into a single path using the OS-appropriate path separator.
Parameters:
parts(table): Array of path components as strings
Returns:
string: Joined path
Example:
local pathParts = {"user", "config", "settings.json"}
local fullPath = utils.pathJoin(pathParts)
-- Result: "user/config/settings.json" (Unix) or "user\config\settings.json" (Windows)
-- Useful for building file paths dynamically
local baseDir = "templates"
local category = "emails"
local filename = "welcome.html"
local templatePath = utils.pathJoin({baseDir, category, filename})
-- Result: "templates/emails/welcome.html"
values["templatePath"] = templatePath
valuesFiles = {templatePath}Kubernetes Object Metadata
Helm Lua scripts can look up cached Kubernetes object metadata from the deployment agent. This is a read against the agent's applied-object cache, not a live API request. Annotations are not included.
k8s_object_meta(group, version, kind, namespace, name) -> table | nil
Parameters:
group(string): API group. Use""for core objects such as Namespaceversion(string): API version, for example"v1"kind(string): Kind, for example"Namespace"namespace(string): Namespace, or""for cluster-scoped objectsname(string): Object name
Returns:
tablewithuid,name,namespace, andlabelswhen the object is cachednilwhen the object is not in the cache
Store errors raise a Lua error.
local ns = k8s_object_meta("", "v1", "Namespace", "", "kube-system")
if ns then
values["observeClusterId"] = ns.uid
values["name"] = ns.name
values["namespace"] = ns.namespace
values["label"] = ns.labels["kubernetes.io/metadata.name"]
endService Warnings
Helm Lua scripts can report non-fatal problems back to the service with warn. Warnings are shown in the service errors in the Plural Console, but unlike errors they do not fail the deployment.
warn(message)
Parameters:
message(string): Warning message to report
Calling warn with anything other than a string raises a Lua error.
Keep in mind that:
- A service with warnings is marked as
staleinstead ofhealthyuntil the script stops reporting them. - Warnings are collected on every render, so keep messages deterministic, e.g. do not include timestamps.
- Empty and duplicate messages are dropped, and up to 20 warnings of at most 1 KB each are reported per render.
local ns = k8s_object_meta("", "v1", "Namespace", "", "kube-system")
if ns then
values["observeClusterId"] = ns.uid
else
warn("kube-system namespace not found in the agent cache, observeClusterId will not be set")
endError Handling
All functions return errors in a consistent format:
- File operations return
niland an error message on failure - Encoding operations return
niland an error message on failure - Path security violations return descriptive error messages
- Merge operations return
niland an error message on failure
When running Lua code from Go using GopherLua, safe error handling is important to prevent your application from panicking due to unhandled Lua runtime errors. What is pcall in Lua? pcall stands for protected call.
ok, result = pcall(function()
error("fail")
end)ok: true if no error occurred, false otherwise
result: the function’s return value or the error message
This prevents Lua from throwing a hard error that would propagate up and crash the engine.
Example
Simple Error Handling and Throwing Example
values = values or {}
valuesFiles = valuesFiles or {}
-- Example 1: Basic Error Throwing
function validatePort(port)
if not port then
error("Port is required")
end
if type(port) ~= "number" then
error("Port must be a number, got " .. type(port))
end
if port < 1 or port > 65535 then
error("Port must be between 1 and 65535, got " .. port)
end
return true
end
-- Example 2: Safe Error Handling with pcall
function safeValidatePort(port)
local success, result = pcall(validatePort, port)
if success then
return true, nil -- Valid port
else
return false, result -- Invalid port with error message
end
end
-- Test different port values
local testPorts = {8080, "invalid", -1, 99999, nil}
local portResults = {}
for i, port in ipairs(testPorts) do
local isValid, errorMsg = safeValidatePort(port)
table.insert(portResults, {
port = port,
valid = isValid,
error = errorMsg
})
endBasic Usage
Simple Values Assignment
-- Basic value assignment
values = {}
values["appName"] = "MyApplication"
values["version"] = "1.2.3"
values["debug"] = true
values["maxConnections"] = 100
-- File references
valuesFiles = {"config.json", "secrets.yaml"}Nested Data Structures
-- Complex nested structure
values = {}
values["database"] = {
host = "localhost",
port = 5432,
credentials = {
username = "admin",
passwordFile = "db-password.txt"
},
pools = {
{name = "read", size = 10},
{name = "write", size = 5}
}
}
values["features"] = {
authentication = true,
logging = true,
metrics = false
}
valuesFiles = {
"database/schema.sql",
"database/migrations/001_initial.sql",
"config/app.yaml"
}Arrays and Lists
-- Working with arrays
values = {}
-- Simple array
values["environments"] = {"development", "staging", "production"}
-- Array of objects
values["services"] = {
{name = "web", port = 8080, replicas = 3},
{name = "api", port = 8081, replicas = 2},
{name = "worker", port = 8082, replicas = 1}
}
-- Mixed array
values["mixed"] = {"string", 42, true, {nested = "object"}}File Operations
Reading Configuration Files
-- Read and parse JSON configuration
local configContent = fs.read("config.json")
if configContent then
local config = encoding.jsonDecode(configContent)
if config then
values["serverConfig"] = config
values["port"] = config.server.port
values["host"] = config.server.host
else
values["configError"] = "Failed to parse config.json"
end
else
values["configError"] = "Failed to read config.json"
end
-- Read YAML configuration
local yamlContent = fs.read("app.yaml")
if yamlContent then
local yamlConfig = encoding.yamlDecode(yamlContent)
if yamlConfig then
values["appConfig"] = yamlConfig
end
endDirectory Traversal
-- List all template files
local templateFiles = fs.walk("templates")
if templateFiles then
values["templates"] = templateFiles
-- Add template files to valuesFiles for processing
valuesFiles = {}
for i, file in ipairs(templateFiles) do
table.insert(valuesFiles, file)
end
-- Count files by extension
local counts = {}
for i, file in ipairs(templateFiles) do
local ext = file:match("%.([^%.]+)$")
if ext then
counts[ext] = (counts[ext] or 0) + 1
end
end
values["templateCounts"] = counts
endConditional File Loading
-- Load different configs based on environment
local env = "production" -- This could come from environment variable
local configFile = "config-" .. env .. ".json"
local configContent = fs.read(configFile)
if configContent then
local config = encoding.jsonDecode(configContent)
values["config"] = config
values["environment"] = env
else
-- Fallback to default config
local defaultConfig = fs.read("config-default.json")
if defaultConfig then
values["config"] = encoding.jsonDecode(defaultConfig)
values["environment"] = "default"
end
end
valuesFiles = {configFile}Encoding Operations
JSON Processing
-- Create complex data structure
local appData = {
metadata = {
name = "MyApp",
version = "2.0.0",
authors = {"Alice", "Bob"},
license = "MIT"
},
dependencies = {
{name = "express", version = "^4.18.0"},
{name = "lodash", version = "^4.17.21"}
},
scripts = {
start = "node server.js",
test = "jest",
build = "webpack --mode=production"
}
}
-- Convert to JSON
local jsonString = encoding.jsonEncode(appData)
values["packageJson"] = jsonString
-- Parse it back to verify
local parsed = encoding.jsonDecode(jsonString)
values["verification"] = parsed.metadata.name == "MyApp"YAML Processing
-- Create Kubernetes-style configuration
local k8sConfig = {
apiVersion = "apps/v1",
kind = "Deployment",
metadata = {
name = "my-app",
labels = {
app = "my-app",
version = "v1"
}
},
spec = {
replicas = 3,
selector = {
matchLabels = {
app = "my-app"
}
},
template = {
metadata = {
labels = {
app = "my-app"
}
},
spec = {
containers = {
{
name = "app",
image = "my-app:latest",
ports = {
{containerPort = 8080}
}
}
}
}
}
}
}
-- Convert to YAML
local yamlString = encoding.yamlEncode(k8sConfig)
values["deploymentYaml"] = yamlString
-- Store as file reference
valuesFiles = {"deployment.yaml"}