Compare commits

..

2 commits

Author SHA1 Message Date
09e9a32241
use awslambda prefix on option name
All checks were successful
Lambda-Zig Build / build (push) Successful in 1m8s
2026-02-05 14:30:10 -08:00
5292283c53
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
2026-02-05 14:28:57 -08:00
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.

129
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",
/// });
///
/// // 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
/// _ = 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;
"awslambda-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);
}
@ -189,17 +199,27 @@ fn printHelp(writer: anytype) void {
\\Deploy a Lambda function to AWS.
\\
\\Options:
\\ --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
\\ --help, -h Show this help message
\\ --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)
\\ --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
\\ --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.
@ -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| {
try addPermission(deploy_opts.function_name, principal, options);
}
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| {
try addPermission(deploy_opts.function_name, principal, options);
}
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)
\\ --help, -h Show this help message
\\ --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();