rework configuration

A config file option is now available for all non-managed
parameters to CreateFunction. This data can also be passed
via build configuration if desired. Net net, we gain flexibility
and reduce the number of build options we add
This commit is contained in:
Emil Lerch 2026-02-05 14:28:57 -08:00
parent fb84eb8d86
commit 5292283c53
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 809 additions and 140 deletions

177
README.md
View file

@ -17,9 +17,8 @@ Build options:
* **payload**: JSON payload for function invocation (used with awslambda_run)
* **region**: AWS region for deployment and invocation
* **profile**: AWS profile to use for credentials
* **role-name**: IAM role name for the function (default: lambda_basic_execution)
* **env-file**: Path to environment variables file for the Lambda function
* **allow-principal**: AWS service principal to grant invoke permission (e.g., alexa-appkit.amazon.com)
* **config-file**: Path to lambda.json configuration file (overrides build.zig settings)
The Lambda function can be compiled for x86_64 or aarch64. The build system
automatically configures the Lambda architecture based on the target.
@ -27,6 +26,155 @@ automatically configures the Lambda architecture based on the target.
A sample project using this runtime can be found at
https://git.lerch.org/lobo/lambda-zig-sample
Lambda Configuration
--------------------
Lambda functions can be configured via a `lambda.json` file or inline in `build.zig`.
The configuration controls IAM roles, function settings, and deployment options.
### Configuration File (lambda.json)
By default, the build system looks for an optional `lambda.json` file in your project root.
If found, it will use these settings for deployment.
```json
{
"role_name": "my_lambda_role",
"timeout": 30,
"memory_size": 512,
"description": "My Lambda function",
"allow_principal": "alexa-appkit.amazon.com",
"tags": [
{ "key": "Environment", "value": "production" },
{ "key": "Project", "value": "my-project" }
]
}
```
### Available Configuration Options
Many of these configuration options are from the Lambda [CreateFunction](https://docs.aws.amazon.com/lambda/latest/api/API_CreateFunction.html#API_CreateFunction_RequestBody)
API call and more details are available there.
| Option | Type | Default | Description |
|----------------------|----------|----------------------------|---------------------------------------------|
| `role_name` | string | `"lambda_basic_execution"` | IAM role name for the function |
| `timeout` | integer | AWS default (3) | Execution timeout in seconds (1-900) |
| `memory_size` | integer | AWS default (128) | Memory allocation in MB (128-10240) |
| `description` | string | null | Human-readable function description |
| `allow_principal` | string | null | AWS service principal for invoke permission |
| `kmskey_arn` | string | null | KMS key ARN for environment encryption |
| `layers` | string[] | null | Lambda layer ARNs to attach |
| `tags` | Tag[] | null | Resource tags (array of `{key, value}`) |
| `vpc_config` | object | null | VPC configuration (see below) |
| `dead_letter_config` | object | null | Dead letter queue configuration |
| `tracing_config` | object | null | X-Ray tracing configuration |
| `ephemeral_storage` | object | AWS default (512) | Ephemeral storage configuration |
| `logging_config` | object | null | CloudWatch logging configuration |
### VPC Configuration
```json
{
"vpc_config": {
"subnet_ids": ["subnet-12345", "subnet-67890"],
"security_group_ids": ["sg-12345"],
"ipv6_allowed_for_dual_stack": false
}
}
```
### Tracing Configuration
```json
{
"tracing_config": {
"mode": "Active"
}
}
```
Mode must be `"Active"` or `"PassThrough"`.
### Logging Configuration
```json
{
"logging_config": {
"log_format": "JSON",
"application_log_level": "INFO",
"system_log_level": "WARN",
"log_group": "/aws/lambda/my-function"
}
}
```
Log format must be `"JSON"` or `"Text"`.
### Ephemeral Storage
```json
{
"ephemeral_storage": {
"size": 512
}
}
```
Size must be between 512-10240 MB.
### Dead Letter Configuration
```json
{
"dead_letter_config": {
"target_arn": "arn:aws:sqs:us-east-1:123456789:my-dlq"
}
}
```
### Build Integration Options
You can also configure Lambda settings directly in `build.zig`:
```zig
// Use a specific config file (required - fails if missing)
_ = try lambda.configureBuild(b, dep, exe, .{
.lambda_config = .{ .file = .{
.path = b.path("deploy/lambda.json"),
.required = true,
}},
});
// Use inline configuration
_ = try lambda.configureBuild(b, dep, exe, .{
.lambda_config = .{ .config = .{
.role_name = "my_role",
.timeout = 30,
.memory_size = 512,
.description = "My function",
}},
});
// Disable config file lookup entirely
_ = try lambda.configureBuild(b, dep, exe, .{
.lambda_config = .none,
});
```
### Overriding Config at Build Time
The `-Dconfig-file` build option overrides the `build.zig` configuration:
```sh
# Use a different config file for staging
zig build awslambda_deploy -Dconfig-file=lambda-staging.json
# Use production config
zig build awslambda_deploy -Dconfig-file=deploy/lambda-prod.json
```
Environment Variables
---------------------
@ -81,24 +229,21 @@ Lambda functions can be configured to allow invocation by AWS service principals
This is required for services like Alexa Skills Kit, API Gateway, or S3 to trigger
your Lambda function.
### Using the build system
### Using lambda.json (Recommended)
Pass the `-Dallow-principal` option to grant invoke permission to a service:
Add `allow_principal` to your configuration file:
```sh
# Allow Alexa Skills Kit to invoke the function
zig build awslambda_deploy -Dfunction-name=my-skill -Dallow-principal=alexa-appkit.amazon.com
# Allow API Gateway to invoke the function
zig build awslambda_deploy -Dfunction-name=my-api -Dallow-principal=apigateway.amazonaws.com
```json
{
"allow_principal": "alexa-appkit.amazon.com"
}
```
### Using the CLI directly
```sh
./lambda-build deploy --function-name my-fn --zip-file function.zip \
--allow-principal alexa-appkit.amazon.com
```
Common service principals:
- `alexa-appkit.amazon.com` - Alexa Skills Kit
- `apigateway.amazonaws.com` - API Gateway
- `s3.amazonaws.com` - S3 event notifications
- `events.amazonaws.com` - EventBridge/CloudWatch Events
The permission is idempotent - if it already exists, the deployment will continue
successfully.

119
build.zig
View file

@ -110,9 +110,23 @@ fn configureBuildInternal(b: *std.Build, exe: *std.Build.Step.Compile) !void {
_ = try @import("lambdabuild.zig").configureBuild(b, lambda_build_dep, exe, .{});
}
/// Re-export types for consumers
pub const LambdaConfig = @import("lambdabuild.zig").Config;
pub const LambdaBuildInfo = @import("lambdabuild.zig").BuildInfo;
// Re-export types for consumers
const lambdabuild = @import("lambdabuild.zig");
/// Options for Lambda build integration.
pub const Options = lambdabuild.Options;
/// Source for Lambda build configuration (none, file, or inline config).
pub const LambdaConfigSource = lambdabuild.LambdaConfigSource;
/// A config file path with explicit required/optional semantics.
pub const ConfigFile = lambdabuild.ConfigFile;
/// Lambda build configuration struct (role_name, timeout, memory_size, VPC, etc.).
pub const LambdaBuildConfig = lambdabuild.LambdaBuildConfig;
/// Information about the configured Lambda build steps.
pub const BuildInfo = lambdabuild.BuildInfo;
/// Configure Lambda build steps for a Zig project.
///
@ -136,19 +150,42 @@ pub const LambdaBuildInfo = @import("lambdabuild.zig").BuildInfo;
///
/// ## Build Options
///
/// The following options are added to the build (command-line options override
/// config defaults):
/// The following command-line options are available:
///
/// - `-Dfunction-name=[string]`: Name of the Lambda function
/// (default: "zig-fn", or as provided by config parameter)
/// (default: exe.name, or as provided by config parameter)
/// - `-Dregion=[string]`: AWS region for deployment and invocation
/// - `-Dprofile=[string]`: AWS profile to use for credentials
/// - `-Drole-name=[string]`: IAM role name
/// (default: "lambda_basic_execution", or as provided by config parameter)
/// - `-Dpayload=[string]`: JSON payload for invocation (default: "{}")
/// - `-Denv-file=[string]`: Path to environment variables file (KEY=VALUE format)
/// - `-Dallow-principal=[string]`: AWS service principal to grant invoke permission
/// (e.g., "alexa-appkit.amazon.com" for Alexa Skills Kit)
/// - `-Dconfig-file=[string]`: Path to Lambda build config JSON file (overrides function_config)
///
/// ## Configuration File
///
/// Function settings (timeout, memory, VPC, etc.) and deployment settings
/// (role_name, allow_principal) are configured via a JSON file or inline config.
///
/// By default, looks for `lambda.json` in the project root. If not found,
/// uses sensible defaults (role_name = "lambda_basic_execution").
///
/// ### Example lambda.json
///
/// ```json
/// {
/// "role_name": "my_lambda_role",
/// "timeout": 30,
/// "memory_size": 512,
/// "description": "My function description",
/// "allow_principal": "alexa-appkit.amazon.com",
/// "tags": [
/// { "key": "Environment", "value": "production" }
/// ],
/// "logging_config": {
/// "log_format": "JSON",
/// "application_log_level": "INFO"
/// }
/// }
/// ```
///
/// ## Deploy Output
///
@ -170,6 +207,8 @@ pub const LambdaBuildInfo = @import("lambdabuild.zig").BuildInfo;
///
/// ## Example
///
/// ### Basic Usage (uses lambda.json if present)
///
/// ```zig
/// const lambda_zig = @import("lambda_zig");
///
@ -185,27 +224,73 @@ pub const LambdaBuildInfo = @import("lambdabuild.zig").BuildInfo;
/// const exe = b.addExecutable(.{ ... });
/// b.installArtifact(exe);
///
/// // Configure Lambda build and get deployment info
/// const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
/// .default_function_name = "my-function",
/// _ = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{});
/// }
/// ```
///
/// ### Inline Configuration
///
/// ```zig
/// _ = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
/// .lambda_config = .{ .config = .{
/// .role_name = "my_custom_role",
/// .timeout = 30,
/// .memory_size = 512,
/// .allow_principal = "alexa-appkit.amazon.com",
/// }},
/// });
/// ```
///
/// ### Custom Config File Path (required by default)
///
/// ```zig
/// _ = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
/// .lambda_config = .{ .file = .{ .path = b.path("deploy/production.json") } },
/// });
/// ```
///
/// ### Optional Config File (silent defaults if missing)
///
/// ```zig
/// _ = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
/// .lambda_config = .{ .file = .{
/// .path = b.path("lambda.json"),
/// .required = false,
/// } },
/// });
/// ```
///
/// ### Dynamically Generated Config
///
/// ```zig
/// const wf = b.addWriteFiles();
/// const config_json = wf.add("lambda-config.json", generated_content);
///
/// _ = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
/// .lambda_config = .{ .file = .{ .path = config_json } },
/// });
/// ```
///
/// ### Using Deploy Output
///
/// ```zig
/// const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{});
///
/// // Use lambda.deploy_output in other steps that need the ARN
/// const my_step = b.addRunArtifact(my_tool);
/// my_step.addFileArg(lambda.deploy_output);
/// my_step.step.dependOn(lambda.deploy_step); // Ensure deploy runs first
/// }
/// ```
pub fn configureBuild(
b: *std.Build,
lambda_zig_dep: *std.Build.Dependency,
exe: *std.Build.Step.Compile,
config: LambdaConfig,
) !LambdaBuildInfo {
options: Options,
) !BuildInfo {
// Get lambda_build from the lambda_zig dependency's Build context
const lambda_build_dep = lambda_zig_dep.builder.dependency("lambda_build", .{
.target = b.graph.host,
.optimize = .ReleaseSafe,
});
return @import("lambdabuild.zig").configureBuild(b, lambda_build_dep, exe, config);
return lambdabuild.configureBuild(b, lambda_build_dep, exe, options);
}

View file

@ -5,25 +5,53 @@
const std = @import("std");
/// Configuration options for Lambda build integration.
pub const LambdaBuildConfig = @import("tools/build/src/LambdaBuildConfig.zig");
/// A config file path with explicit required/optional semantics.
pub const ConfigFile = struct {
path: std.Build.LazyPath,
/// If true (default), error when file is missing. If false, silently use defaults.
required: bool = true,
};
/// Source for Lambda build configuration.
///
/// Determines how Lambda function settings (timeout, memory, VPC, etc.)
/// and deployment settings (role_name, allow_principal) are provided.
pub const LambdaConfigSource = union(enum) {
/// No configuration file. Uses hardcoded defaults.
none,
/// Path to a JSON config file with explicit required/optional semantics.
file: ConfigFile,
/// Inline configuration. Will be serialized to JSON and
/// written to a generated file.
config: LambdaBuildConfig,
};
/// Options for Lambda build integration.
///
/// These provide project-level defaults that can still be overridden
/// via command-line options (e.g., `-Dfunction-name=...`).
pub const Config = struct {
pub const Options = struct {
/// Default function name if not specified via -Dfunction-name.
/// If null, falls back to the executable name (exe.name).
default_function_name: ?[]const u8 = null,
/// Default IAM role name if not specified via -Drole-name.
default_role_name: []const u8 = "lambda_basic_execution",
/// Default environment file if not specified via -Denv-file.
/// If the file doesn't exist, it's silently skipped.
default_env_file: ?[]const u8 = ".env",
/// Default AWS service principal to grant invoke permission.
/// For Alexa skills, use "alexa-appkit.amazon.com".
default_allow_principal: ?[]const u8 = null,
/// Lambda build configuration source.
/// Defaults to looking for "lambda.json" (optional - uses defaults if missing).
///
/// Examples:
/// - `.none`: No config file, use defaults
/// - `.{ .file = .{ .path = b.path("lambda.json") } }`: Required config file
/// - `.{ .file = .{ .path = b.path("lambda.json"), .required = false } }`: Optional config file
/// - `.{ .config = .{ ... } }`: Inline configuration
lambda_config: LambdaConfigSource = .{ .file = .{ .path = .{ .cwd_relative = "lambda.json" }, .required = false } },
};
/// Information about the configured Lambda build steps.
@ -60,8 +88,39 @@ pub const BuildInfo = struct {
/// - awslambda_deploy: Deploy the function to AWS
/// - awslambda_run: Invoke the deployed function
///
/// The `config` parameter allows setting project-level defaults that can
/// still be overridden via command-line options.
/// ## Configuration
///
/// Function settings (timeout, memory, VPC, etc.) and deployment settings
/// (role_name, allow_principal) are configured via a JSON file or inline config.
///
/// By default, looks for `lambda.json` in the project root. If not found,
/// uses sensible defaults (role_name = "lambda_basic_execution").
///
/// ### Example lambda.json
///
/// ```json
/// {
/// "role_name": "my_lambda_role",
/// "timeout": 30,
/// "memory_size": 512,
/// "allow_principal": "alexa-appkit.amazon.com",
/// "tags": [
/// { "key": "Environment", "value": "production" }
/// ]
/// }
/// ```
///
/// ### Inline Configuration
///
/// ```zig
/// lambda.configureBuild(b, dep, exe, .{
/// .lambda_config = .{ .config = .{
/// .role_name = "my_role",
/// .timeout = 30,
/// .memory_size = 512,
/// }},
/// });
/// ```
///
/// Returns a `BuildInfo` struct containing references to all steps and
/// a `deploy_output` LazyPath to the deployment info JSON file.
@ -69,20 +128,15 @@ pub fn configureBuild(
b: *std.Build,
lambda_build_dep: *std.Build.Dependency,
exe: *std.Build.Step.Compile,
config: Config,
options: Options,
) !BuildInfo {
// Get the lambda-build CLI artifact from the dependency
const cli = lambda_build_dep.artifact("lambda-build");
// Get configuration options (command-line overrides config defaults)
const function_name = b.option([]const u8, "function-name", "Function name for Lambda") orelse config.default_function_name orelse exe.name;
const function_name = b.option([]const u8, "function-name", "Function name for Lambda") orelse options.default_function_name orelse exe.name;
const region = b.option([]const u8, "region", "AWS region") orelse null;
const profile = b.option([]const u8, "profile", "AWS profile") orelse null;
const role_name = b.option(
[]const u8,
"role-name",
"IAM role name (default: lambda_basic_execution)",
) orelse config.default_role_name;
const payload = b.option(
[]const u8,
"payload",
@ -92,12 +146,12 @@ pub fn configureBuild(
[]const u8,
"env-file",
"Path to environment variables file (KEY=VALUE format)",
) orelse config.default_env_file;
const allow_principal = b.option(
) orelse options.default_env_file;
const config_file_override = b.option(
[]const u8,
"allow-principal",
"AWS service principal to grant invoke permission (e.g., alexa-appkit.amazon.com)",
) orelse config.default_allow_principal;
"config-file",
"Path to Lambda build config JSON file (overrides function_config)",
);
// Determine architecture for Lambda
const target_arch = exe.root_module.resolved_target.?.result.cpu.arch;
@ -112,6 +166,39 @@ pub fn configureBuild(
}
};
// Determine config file source - resolves to a path and required flag
// Internal struct since we need nullable path for the .none case
const ResolvedConfig = struct {
path: ?std.Build.LazyPath,
required: bool,
};
const config_file: ResolvedConfig = if (config_file_override) |override|
.{ .path = .{ .cwd_relative = override }, .required = true }
else switch (options.lambda_config) {
.none => .{ .path = null, .required = false },
.file => |cf| .{ .path = cf.path, .required = cf.required },
.config => |func_config| blk: {
// Serialize inline config to JSON and write to generated file
const json_content = std.fmt.allocPrint(b.allocator, "{f}", .{
std.json.fmt(func_config, .{}),
}) catch @panic("OOM");
const wf = b.addWriteFiles();
break :blk .{ .path = wf.add("lambda-config.json", json_content), .required = true };
},
};
// Helper to add config file arg to a command
const addConfigArg = struct {
fn add(cmd: *std.Build.Step.Run, file: ResolvedConfig) void {
if (file.path) |f| {
const flag = if (file.required) "--config-file" else "--config-file-optional";
cmd.addArg(flag);
cmd.addFileArg(f);
}
}
}.add;
// Package step - output goes to cache based on input hash
const package_cmd = b.addRunArtifact(cli);
package_cmd.step.name = try std.fmt.allocPrint(b.allocator, "{s} package", .{cli.name});
@ -129,7 +216,8 @@ pub fn configureBuild(
iam_cmd.step.name = try std.fmt.allocPrint(b.allocator, "{s} iam", .{cli.name});
if (profile) |p| iam_cmd.addArgs(&.{ "--profile", p });
if (region) |r| iam_cmd.addArgs(&.{ "--region", r });
iam_cmd.addArgs(&.{ "iam", "--role-name", role_name });
iam_cmd.addArg("iam");
addConfigArg(iam_cmd, config_file);
const iam_step = b.step("awslambda_iam", "Create/verify IAM role for Lambda");
iam_step.dependOn(&iam_cmd.step);
@ -150,13 +238,11 @@ pub fn configureBuild(
});
deploy_cmd.addFileArg(zip_output);
deploy_cmd.addArgs(&.{
"--role-name",
role_name,
"--arch",
arch_str,
});
if (env_file) |ef| deploy_cmd.addArgs(&.{ "--env-file", ef });
if (allow_principal) |ap| deploy_cmd.addArgs(&.{ "--allow-principal", ap });
addConfigArg(deploy_cmd, config_file);
// Add deploy output file for deployment info JSON
deploy_cmd.addArg("--deploy-output");
const deploy_output = deploy_cmd.addOutputFileArg("deploy-output.json");

View file

@ -4,8 +4,8 @@
.fingerprint = 0x6e61de08e7e51114,
.dependencies = .{
.aws = .{
.url = "git+https://git.lerch.org/lobo/aws-sdk-for-zig#5c7aed071f6251d53a1627080a21d604ff58f0a5",
.hash = "aws-0.0.1-SbsFcFE7CgDBilPa15i4gIB6Qr5ozBz328O63abDQDDk",
.url = "git+https://git.lerch.org/lobo/aws-sdk-for-zig#1a03250fbeb2840ab8b6010f1ad4e899cdfc185a",
.hash = "aws-0.0.1-SbsFcCg7CgC0yYv2Y7aOjonSAU3mltOSfY0x2w9jZlMV",
},
},
.paths = .{

View file

@ -0,0 +1,195 @@
//! Lambda build configuration types.
//!
//! These types define the JSON schema for lambda.json configuration files,
//! encompassing IAM, Lambda function, and deployment settings.
//!
//! Used by both the build system (lambdabuild.zig) and the CLI commands
//! (deploy.zig, iam.zig).
const std = @import("std");
const LambdaBuildConfig = @This();
/// Wrapper for parsed config that owns both the JSON parse result
/// and the source file data (since parsed strings point into it).
pub const Parsed = struct {
parsed: std.json.Parsed(LambdaBuildConfig),
source_data: []const u8,
allocator: std.mem.Allocator,
pub fn deinit(self: *Parsed) void {
self.parsed.deinit();
self.allocator.free(self.source_data);
}
};
// === IAM Configuration ===
/// IAM role name for the Lambda function.
role_name: []const u8 = "lambda_basic_execution",
// Future: policy_statements, trust_policy, etc.
// === Deployment Settings ===
/// AWS service principal to grant invoke permission.
/// Example: "alexa-appkit.amazon.com" for Alexa Skills.
allow_principal: ?[]const u8 = null,
// === Lambda Function Configuration ===
/// Human-readable description of the function.
description: ?[]const u8 = null,
/// Maximum execution time in seconds (1-900).
timeout: ?i64 = null,
/// Memory allocation in MB (128-10240).
memory_size: ?i64 = null,
/// KMS key ARN for environment variable encryption.
kmskey_arn: ?[]const u8 = null,
// Nested configs
vpc_config: ?VpcConfig = null,
dead_letter_config: ?DeadLetterConfig = null,
tracing_config: ?TracingConfig = null,
ephemeral_storage: ?EphemeralStorage = null,
logging_config: ?LoggingConfig = null,
// Collections
tags: ?[]const Tag = null,
layers: ?[]const []const u8 = null,
pub const VpcConfig = struct {
subnet_ids: ?[]const []const u8 = null,
security_group_ids: ?[]const []const u8 = null,
ipv6_allowed_for_dual_stack: ?bool = null,
};
pub const DeadLetterConfig = struct {
target_arn: ?[]const u8 = null,
};
pub const TracingConfig = struct {
/// "Active" or "PassThrough"
mode: ?[]const u8 = null,
};
pub const EphemeralStorage = struct {
/// Size in MB (512-10240)
size: i64,
};
pub const LoggingConfig = struct {
/// "JSON" or "Text"
log_format: ?[]const u8 = null,
/// "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"
application_log_level: ?[]const u8 = null,
system_log_level: ?[]const u8 = null,
log_group: ?[]const u8 = null,
};
pub const Tag = struct {
key: []const u8,
value: []const u8,
};
/// Validate configuration values are within AWS limits.
pub fn validate(self: LambdaBuildConfig) !void {
// Timeout: 1-900 seconds
if (self.timeout) |t| {
if (t < 1 or t > 900) {
std.log.err("Invalid timeout: {} (must be 1-900 seconds)", .{t});
return error.InvalidTimeout;
}
}
// Memory: 128-10240 MB
if (self.memory_size) |m| {
if (m < 128 or m > 10240) {
std.log.err("Invalid memory_size: {} (must be 128-10240 MB)", .{m});
return error.InvalidMemorySize;
}
}
// Ephemeral storage: 512-10240 MB
if (self.ephemeral_storage) |es| {
if (es.size < 512 or es.size > 10240) {
std.log.err("Invalid ephemeral_storage.size: {} (must be 512-10240 MB)", .{es.size});
return error.InvalidEphemeralStorage;
}
}
// Tracing mode validation
if (self.tracing_config) |tc| {
if (tc.mode) |mode| {
if (!std.mem.eql(u8, mode, "Active") and !std.mem.eql(u8, mode, "PassThrough")) {
std.log.err("Invalid tracing_config.mode: '{s}' (must be 'Active' or 'PassThrough')", .{mode});
return error.InvalidTracingMode;
}
}
}
// Log format validation
if (self.logging_config) |lc| {
if (lc.log_format) |format| {
if (!std.mem.eql(u8, format, "JSON") and !std.mem.eql(u8, format, "Text")) {
std.log.err("Invalid logging_config.log_format: '{s}' (must be 'JSON' or 'Text')", .{format});
return error.InvalidLogFormat;
}
}
}
}
/// Load configuration from a JSON file.
///
/// If is_default is true and the file doesn't exist, returns null.
/// If is_default is false (explicitly specified) and file doesn't exist, returns error.
pub fn loadFromFile(
allocator: std.mem.Allocator,
path: []const u8,
is_default: bool,
) !?Parsed {
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
if (err == error.FileNotFound) {
if (is_default) {
std.log.debug("Config file '{s}' not found, using defaults", .{path});
return null;
}
std.log.err("Config file not found: {s}", .{path});
return error.ConfigFileNotFound;
}
std.log.err("Failed to open config file '{s}': {}", .{ path, err });
return error.ConfigFileOpenError;
};
defer file.close();
// Read entire file
var read_buffer: [4096]u8 = undefined;
var file_reader = file.reader(&read_buffer);
const content = file_reader.interface.allocRemaining(allocator, std.Io.Limit.limited(64 * 1024)) catch |err| {
std.log.err("Error reading config file: {}", .{err});
return error.ConfigFileReadError;
};
errdefer allocator.free(content);
// Parse JSON - strings will point into content, which we keep alive
const parsed = std.json.parseFromSlice(
LambdaBuildConfig,
allocator,
content,
.{},
) catch |err| {
std.log.err("Error parsing config JSON: {}", .{err});
return error.ConfigFileParseError;
};
errdefer parsed.deinit();
try parsed.value.validate();
return .{
.parsed = parsed,
.source_data = content,
.allocator = allocator,
};
}

View file

@ -2,11 +2,13 @@
//!
//! Creates a new function or updates an existing one.
//! Supports setting environment variables via --env or --env-file.
//! Function configuration (timeout, memory, VPC, etc.) comes from --config-file.
const std = @import("std");
const aws = @import("aws");
const iam_cmd = @import("iam.zig");
const RunOptions = @import("main.zig").RunOptions;
const LambdaBuildConfig = @import("LambdaBuildConfig.zig");
// Get Lambda EnvironmentVariableKeyValue type from AWS SDK
const EnvVar = aws.services.lambda.EnvironmentVariableKeyValue;
@ -15,10 +17,10 @@ pub fn run(args: []const []const u8, options: RunOptions) !void {
var function_name: ?[]const u8 = null;
var zip_file: ?[]const u8 = null;
var role_arn: ?[]const u8 = null;
var role_name: []const u8 = "lambda_basic_execution";
var arch: ?[]const u8 = null;
var allow_principal: ?[]const u8 = null;
var deploy_output: ?[]const u8 = null;
var config_file: ?[]const u8 = null;
var is_config_required = false;
// Environment variables storage
var env_vars = std.StringHashMap([]const u8).init(options.allocator);
@ -46,10 +48,6 @@ pub fn run(args: []const []const u8, options: RunOptions) !void {
i += 1;
if (i >= args.len) return error.MissingRoleArn;
role_arn = args[i];
} else if (std.mem.eql(u8, arg, "--role-name")) {
i += 1;
if (i >= args.len) return error.MissingRoleName;
role_name = args[i];
} else if (std.mem.eql(u8, arg, "--arch")) {
i += 1;
if (i >= args.len) return error.MissingArch;
@ -62,10 +60,16 @@ pub fn run(args: []const []const u8, options: RunOptions) !void {
i += 1;
if (i >= args.len) return error.MissingEnvFile;
try loadEnvFile(args[i], &env_vars, options.allocator);
} else if (std.mem.eql(u8, arg, "--allow-principal")) {
} else if (std.mem.eql(u8, arg, "--config-file")) {
i += 1;
if (i >= args.len) return error.MissingAllowPrincipal;
allow_principal = args[i];
if (i >= args.len) return error.MissingConfigFile;
config_file = args[i];
is_config_required = true;
} else if (std.mem.eql(u8, arg, "--config-file-optional")) {
i += 1;
if (i >= args.len) return error.MissingConfigFile;
config_file = args[i];
is_config_required = false;
} else if (std.mem.eql(u8, arg, "--deploy-output")) {
i += 1;
if (i >= args.len) return error.MissingDeployOutput;
@ -95,15 +99,21 @@ pub fn run(args: []const []const u8, options: RunOptions) !void {
return error.MissingZipFile;
}
// Load config file if provided
var parsed_config = if (config_file) |path|
try LambdaBuildConfig.loadFromFile(options.allocator, path, !is_config_required)
else
null;
defer if (parsed_config) |*pc| pc.deinit();
try deployFunction(.{
.function_name = function_name.?,
.zip_file = zip_file.?,
.role_arn = role_arn,
.role_name = role_name,
.arch = arch,
.env_vars = if (env_vars.count() > 0) &env_vars else null,
.allow_principal = allow_principal,
.deploy_output = deploy_output,
.config = if (parsed_config) |pc| &pc.parsed.value else null,
}, options);
}
@ -192,15 +202,25 @@ fn printHelp(writer: anytype) void {
\\ --function-name <name> Name of the Lambda function (required)
\\ --zip-file <path> Path to the deployment zip (required)
\\ --role-arn <arn> IAM role ARN (optional - creates role if omitted)
\\ --role-name <name> IAM role name if creating (default: lambda_basic_execution)
\\ --arch <arch> Architecture: x86_64 or aarch64 (default: x86_64)
\\ --env <KEY=VALUE> Set environment variable (can be repeated)
\\ --env-file <path> Load environment variables from file (KEY=VALUE format)
\\ --allow-principal <p> Grant invoke permission to AWS service principal
\\ (e.g., alexa-appkit.amazon.com)
\\ --deploy-output <path> Write deployment info (ARN, region, etc.) to JSON file
\\ --env-file <path> Load environment variables from file
\\ --config-file <path> Path to JSON config file (required, error if missing)
\\ --config-file-optional <path> Path to JSON config file (optional, use defaults if missing)
\\ --deploy-output <path> Write deployment info to JSON file
\\ --help, -h Show this help message
\\
\\Config File:
\\ The config file specifies function settings:
\\ {{
\\ "role_name": "my_lambda_role",
\\ "timeout": 30,
\\ "memory_size": 512,
\\ "allow_principal": "alexa-appkit.amazon.com",
\\ "description": "My function",
\\ "tags": [{{ "key": "Env", "value": "prod" }}]
\\ }}
\\
\\Environment File Format:
\\ The --env-file option reads a file with KEY=VALUE pairs, one per line.
\\ Lines starting with # are treated as comments. Empty lines are ignored.
@ -220,11 +240,10 @@ const DeployOptions = struct {
function_name: []const u8,
zip_file: []const u8,
role_arn: ?[]const u8,
role_name: []const u8,
arch: ?[]const u8,
env_vars: ?*const std.StringHashMap([]const u8),
allow_principal: ?[]const u8,
deploy_output: ?[]const u8,
config: ?*const LambdaBuildConfig,
};
fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void {
@ -234,11 +253,14 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void {
return error.InvalidArchitecture;
}
// Get role_name from config or use default
const role_name = if (deploy_opts.config) |c| c.role_name else "lambda_basic_execution";
// Get or create IAM role if not provided
const role_arn = if (deploy_opts.role_arn) |r|
try options.allocator.dupe(u8, r)
else
try iam_cmd.getOrCreateRole(deploy_opts.role_name, options);
try iam_cmd.getOrCreateRole(role_name, options);
defer options.allocator.free(role_arn);
@ -276,6 +298,58 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void {
options.allocator.free(vars);
};
// Build config-based parameters
const config = deploy_opts.config;
// Build tags array if present in config
const tags = if (config) |c| if (c.tags) |t| blk: {
var tag_arr = try options.allocator.alloc(aws.services.lambda.TagKeyValue, t.len);
for (t, 0..) |tag, idx| {
tag_arr[idx] = .{ .key = tag.key, .value = tag.value };
}
break :blk tag_arr;
} else null else null;
defer if (tags) |t| options.allocator.free(t);
// Build VPC config if present
const vpc_config: ?aws.services.lambda.VpcConfig = if (config) |c| if (c.vpc_config) |vc|
.{
.subnet_ids = if (vc.subnet_ids) |ids| @constCast(ids) else null,
.security_group_ids = if (vc.security_group_ids) |ids| @constCast(ids) else null,
.ipv6_allowed_for_dual_stack = vc.ipv6_allowed_for_dual_stack,
}
else
null else null;
// Build dead letter config if present
const dead_letter_config: ?aws.services.lambda.DeadLetterConfig = if (config) |c| if (c.dead_letter_config) |dlc|
.{ .target_arn = dlc.target_arn }
else
null else null;
// Build tracing config if present
const tracing_config: ?aws.services.lambda.TracingConfig = if (config) |c| if (c.tracing_config) |tc|
.{ .mode = tc.mode }
else
null else null;
// Build ephemeral storage if present
const ephemeral_storage: ?aws.services.lambda.EphemeralStorage = if (config) |c| if (c.ephemeral_storage) |es|
.{ .size = es.size }
else
null else null;
// Build logging config if present
const logging_config: ?aws.services.lambda.LoggingConfig = if (config) |c| if (c.logging_config) |lc|
.{
.log_format = lc.log_format,
.application_log_level = lc.application_log_level,
.system_log_level = lc.system_log_level,
.log_group = lc.log_group,
}
else
null else null;
// Try to create the function first - if it already exists, we'll update it
std.log.info("Attempting to create function: {s}", .{deploy_opts.function_name});
@ -304,6 +378,18 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void {
.runtime = "provided.al2023",
.role = role_arn,
.environment = if (env_variables) |vars| .{ .variables = vars } else null,
// Config-based parameters
.description = if (config) |c| c.description else null,
.timeout = if (config) |c| c.timeout else null,
.memory_size = if (config) |c| c.memory_size else null,
.kmskey_arn = if (config) |c| c.kmskey_arn else null,
.vpc_config = vpc_config,
.dead_letter_config = dead_letter_config,
.tracing_config = tracing_config,
.ephemeral_storage = ephemeral_storage,
.logging_config = logging_config,
.tags = tags,
.layers = if (config) |c| if (c.layers) |l| @constCast(l) else null else null,
}, create_options) catch |err| {
defer create_diagnostics.deinit();
@ -328,20 +414,23 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void {
// Wait for function to be ready before updating configuration
try waitForFunctionReady(deploy_opts.function_name, options);
// Update environment variables if provided
if (env_variables) |vars| {
try updateFunctionConfiguration(deploy_opts.function_name, vars, options);
}
// Update function configuration if we have config or env variables
if (config != null or env_variables != null)
try updateFunctionConfiguration(
deploy_opts.function_name,
env_variables,
config,
options,
);
// Add invoke permission if requested
if (deploy_opts.allow_principal) |principal| {
if (config) |c|
if (c.allow_principal) |principal|
try addPermission(deploy_opts.function_name, principal, options);
}
// Write deploy output if requested
if (deploy_opts.deploy_output) |output_path| {
if (deploy_opts.deploy_output) |output_path|
try writeDeployOutput(output_path, function_arn.?, role_arn, lambda_arch, deploy_opts.env_vars);
}
return;
}
@ -364,14 +453,13 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void {
try waitForFunctionReady(deploy_opts.function_name, options);
// Add invoke permission if requested
if (deploy_opts.allow_principal) |principal| {
if (config) |c|
if (c.allow_principal) |principal|
try addPermission(deploy_opts.function_name, principal, options);
}
// Write deploy output if requested
if (deploy_opts.deploy_output) |output_path| {
if (deploy_opts.deploy_output) |output_path|
try writeDeployOutput(output_path, function_arn.?, role_arn, lambda_arch, deploy_opts.env_vars);
}
}
/// Build environment variables in the format expected by AWS Lambda API
@ -398,23 +486,74 @@ fn buildEnvVariables(
return result;
}
/// Update function configuration (environment variables)
/// Update function configuration (environment variables and config settings)
fn updateFunctionConfiguration(
function_name: []const u8,
env_variables: []EnvVar,
env_variables: ?[]EnvVar,
config: ?*const LambdaBuildConfig,
options: RunOptions,
) !void {
const services = aws.Services(.{.lambda}){};
std.log.info("Updating function configuration for: {s}", .{function_name});
// Build VPC config if present
const vpc_config: ?aws.services.lambda.VpcConfig = if (config) |c| if (c.vpc_config) |vc|
.{
.subnet_ids = if (vc.subnet_ids) |ids| @constCast(ids) else null,
.security_group_ids = if (vc.security_group_ids) |ids| @constCast(ids) else null,
.ipv6_allowed_for_dual_stack = vc.ipv6_allowed_for_dual_stack,
}
else
null else null;
// Build dead letter config if present
const dead_letter_config: ?aws.services.lambda.DeadLetterConfig = if (config) |c| if (c.dead_letter_config) |dlc|
.{ .target_arn = dlc.target_arn }
else
null else null;
// Build tracing config if present
const tracing_config: ?aws.services.lambda.TracingConfig = if (config) |c| if (c.tracing_config) |tc|
.{ .mode = tc.mode }
else
null else null;
// Build ephemeral storage if present
const ephemeral_storage: ?aws.services.lambda.EphemeralStorage = if (config) |c| if (c.ephemeral_storage) |es|
.{ .size = es.size }
else
null else null;
// Build logging config if present
const logging_config: ?aws.services.lambda.LoggingConfig = if (config) |c| if (c.logging_config) |lc|
.{
.log_format = lc.log_format,
.application_log_level = lc.application_log_level,
.system_log_level = lc.system_log_level,
.log_group = lc.log_group,
}
else
null else null;
const update_config_result = try aws.Request(services.lambda.update_function_configuration).call(.{
.function_name = function_name,
.environment = .{ .variables = env_variables },
.environment = if (env_variables) |vars| .{ .variables = vars } else null,
// Config-based parameters
.description = if (config) |c| c.description else null,
.timeout = if (config) |c| c.timeout else null,
.memory_size = if (config) |c| c.memory_size else null,
.kmskey_arn = if (config) |c| c.kmskey_arn else null,
.vpc_config = vpc_config,
.dead_letter_config = dead_letter_config,
.tracing_config = tracing_config,
.ephemeral_storage = ephemeral_storage,
.logging_config = logging_config,
.layers = if (config) |c| if (c.layers) |l| @constCast(l) else null else null,
}, options.aws_options);
defer update_config_result.deinit();
try options.stdout.print("Updated environment variables\n", .{});
try options.stdout.print("Updated function configuration\n", .{});
try options.stdout.flush();
// Wait for configuration update to complete
@ -437,21 +576,17 @@ fn waitForFunctionReady(function_name: []const u8, options: RunOptions) !void {
defer result.deinit();
// Check if function is ready
if (result.response.configuration) |config| {
if (config.last_update_status) |status| {
if (result.response.configuration) |cfg| {
if (cfg.last_update_status) |status| {
if (std.mem.eql(u8, status, "Successful")) {
std.log.info("Function is ready", .{});
std.log.debug("Function is ready", .{});
return;
} else if (std.mem.eql(u8, status, "Failed")) {
return error.FunctionUpdateFailed;
}
// "InProgress" - keep waiting
} else {
return; // No status means it's ready
}
} else {
return; // No configuration means we can't check, assume ready
}
} else return; // No status means it's ready
} else return; // No configuration means we can't check, assume ready
std.Thread.sleep(200 * std.time.ns_per_ms);
}
@ -544,7 +679,7 @@ fn writeDeployOutput(
const region = arn_parts.next() orelse return error.InvalidArn;
const account_id = arn_parts.next() orelse return error.InvalidArn;
_ = arn_parts.next(); // function
const function_name = arn_parts.next() orelse return error.InvalidArn;
const fn_name = arn_parts.next() orelse return error.InvalidArn;
const file = try std.fs.cwd().createFile(output_path, .{});
defer file.close();
@ -564,7 +699,7 @@ fn writeDeployOutput(
\\ "role_arn": "{s}",
\\ "architecture": "{s}",
\\ "environment_keys": [
, .{ function_arn, function_name, partition, region, account_id, role_arn, architecture });
, .{ function_arn, fn_name, partition, region, account_id, role_arn, architecture });
// Write environment variable keys
if (env_vars) |vars| {

View file

@ -3,17 +3,25 @@
const std = @import("std");
const aws = @import("aws");
const RunOptions = @import("main.zig").RunOptions;
const LambdaBuildConfig = @import("LambdaBuildConfig.zig");
pub fn run(args: []const []const u8, options: RunOptions) !void {
var role_name: ?[]const u8 = null;
var config_file: ?[]const u8 = null;
var is_config_required = false;
var i: usize = 0;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--role-name")) {
if (std.mem.eql(u8, arg, "--config-file")) {
i += 1;
if (i >= args.len) return error.MissingRoleName;
role_name = args[i];
if (i >= args.len) return error.MissingConfigFile;
config_file = args[i];
is_config_required = true;
} else if (std.mem.eql(u8, arg, "--config-file-optional")) {
i += 1;
if (i >= args.len) return error.MissingConfigFile;
config_file = args[i];
is_config_required = false;
} else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
printHelp(options.stdout);
try options.stdout.flush();
@ -25,30 +33,44 @@ pub fn run(args: []const []const u8, options: RunOptions) !void {
}
}
if (role_name == null) {
try options.stderr.print("Error: --role-name is required\n", .{});
printHelp(options.stderr);
try options.stderr.flush();
return error.MissingRoleName;
}
// Load config file if provided
var parsed_config = if (config_file) |path|
try LambdaBuildConfig.loadFromFile(options.allocator, path, !is_config_required)
else
null;
defer if (parsed_config) |*pc| pc.deinit();
const arn = try getOrCreateRole(role_name.?, options);
// Get role_name from config or use default
const role_name = if (parsed_config) |pc|
pc.parsed.value.role_name
else
"lambda_basic_execution";
const arn = try getOrCreateRole(role_name, options);
defer options.allocator.free(arn);
try options.stdout.print("{s}\n", .{arn});
try options.stdout.flush();
}
fn printHelp(writer: *std.Io.Writer) void {
fn printHelp(writer: anytype) void {
writer.print(
\\Usage: lambda-build iam [options]
\\
\\Create or retrieve an IAM role for Lambda execution.
\\
\\Options:
\\ --role-name <name> Name of the IAM role (required)
\\ --config-file <path> Path to JSON config file (required, error if missing)
\\ --config-file-optional <path> Path to JSON config file (optional, use defaults if missing)
\\ --help, -h Show this help message
\\
\\Config File:
\\ The config file can specify the IAM role name:
\\ {{
\\ "role_name": "my_lambda_role"
\\ }}
\\
\\If no config file is provided, uses "lambda_basic_execution" as the role name.
\\If the role exists, its ARN is returned. If not, a new role is created
\\with the AWSLambdaExecute policy attached.
\\
@ -71,19 +93,20 @@ pub fn getOrCreateRole(role_name: []const u8, options: RunOptions) ![]const u8 {
// Use the shared aws_options but add diagnostics for this call
var aws_options = options.aws_options;
aws_options.diagnostics = &diagnostics;
defer aws_options.diagnostics = null;
const get_result = aws.Request(services.iam.get_role).call(.{
.role_name = role_name,
}, aws_options) catch |err| {
defer diagnostics.deinit();
if (diagnostics.response_status == .not_found) {
// Check for "not found" via HTTP status or error response body
if (diagnostics.response_status == .not_found or
std.mem.indexOf(u8, diagnostics.response_body, "NoSuchEntity") != null)
// Role doesn't exist, create it
return try createRole(role_name, options);
}
std.log.err(
"IAM GetRole failed: {} (HTTP Response code {})",
.{ err, diagnostics.response_status },
);
std.log.err("IAM GetRole failed: {} (HTTP {})", .{ err, diagnostics.response_status });
return error.IamGetRoleFailed;
};
defer get_result.deinit();