diff --git a/README.md b/README.md index a8e502a..018bac9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Build options: * **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 The Lambda function can be compiled for x86_64 or aarch64. The build system automatically configures the Lambda architecture based on the target. @@ -25,6 +26,53 @@ 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 +Environment Variables +--------------------- + +Lambda functions can be configured with environment variables during deployment. +This is useful for passing configuration, secrets, or credentials to your function. + +### Using the build system + +Pass the `-Denv-file` option to specify a file containing environment variables: + +```sh +zig build awslambda_deploy -Dfunction-name=my-function -Denv-file=.env +``` + +### Using the CLI directly + +The `lambda-build` CLI supports both `--env` flags and `--env-file`: + +```sh +# Set individual variables +./lambda-build deploy --function-name my-fn --zip-file function.zip \ + --env DB_HOST=localhost --env DB_PORT=5432 + +# Load from file +./lambda-build deploy --function-name my-fn --zip-file function.zip \ + --env-file .env + +# Combine both (--env values override --env-file) +./lambda-build deploy --function-name my-fn --zip-file function.zip \ + --env-file .env --env DEBUG=true +``` + +### Environment file format + +The environment file uses a simple `KEY=VALUE` format, one variable per line: + +```sh +# Database configuration +DB_HOST=localhost +DB_PORT=5432 + +# API keys +API_KEY=secret123 +``` + +Lines starting with `#` are treated as comments. Empty lines are ignored. + Using the Zig Package Manager ----------------------------- diff --git a/lambdabuild.zig b/lambdabuild.zig index 03b1e4f..c090153 100644 --- a/lambdabuild.zig +++ b/lambdabuild.zig @@ -34,6 +34,11 @@ pub fn configureBuild( "payload", "Lambda invocation payload", ) orelse "{}"; + const env_file = b.option( + []const u8, + "env-file", + "Path to environment variables file (KEY=VALUE format)", + ) orelse null; // Determine architecture for Lambda const target_arch = exe.root_module.resolved_target.?.result.cpu.arch; @@ -88,6 +93,7 @@ pub fn configureBuild( "--arch", arch_str, }); + if (env_file) |ef| deploy_cmd.addArgs(&.{ "--env-file", ef }); deploy_cmd.step.dependOn(&package_cmd.step); const deploy_step = b.step("awslambda_deploy", "Deploy the Lambda function"); diff --git a/tools/build/src/deploy.zig b/tools/build/src/deploy.zig index 5740d9d..cb771d4 100644 --- a/tools/build/src/deploy.zig +++ b/tools/build/src/deploy.zig @@ -1,12 +1,16 @@ //! Deploy command - deploys a Lambda function to AWS. //! //! Creates a new function or updates an existing one. +//! Supports setting environment variables via --env or --env-file. const std = @import("std"); const aws = @import("aws"); const iam_cmd = @import("iam.zig"); const RunOptions = @import("main.zig").RunOptions; +// Get Lambda EnvironmentVariableKeyValue type from AWS SDK +const EnvVar = aws.services.lambda.EnvironmentVariableKeyValue; + pub fn run(args: []const []const u8, options: RunOptions) !void { var function_name: ?[]const u8 = null; var zip_file: ?[]const u8 = null; @@ -14,6 +18,17 @@ pub fn run(args: []const []const u8, options: RunOptions) !void { var role_name: []const u8 = "lambda_basic_execution"; var arch: ?[]const u8 = null; + // Environment variables storage + var env_vars = std.StringHashMap([]const u8).init(options.allocator); + defer { + var it = env_vars.iterator(); + while (it.next()) |entry| { + options.allocator.free(entry.key_ptr.*); + options.allocator.free(entry.value_ptr.*); + } + env_vars.deinit(); + } + var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; @@ -37,6 +52,14 @@ pub fn run(args: []const []const u8, options: RunOptions) !void { i += 1; if (i >= args.len) return error.MissingArch; arch = args[i]; + } else if (std.mem.eql(u8, arg, "--env")) { + i += 1; + if (i >= args.len) return error.MissingEnvValue; + try parseEnvVar(args[i], &env_vars, options.allocator); + } else if (std.mem.eql(u8, arg, "--env-file")) { + i += 1; + if (i >= args.len) return error.MissingEnvFile; + try loadEnvFile(args[i], &env_vars, options.allocator); } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { printHelp(options.stdout); try options.stdout.flush(); @@ -68,10 +91,81 @@ pub fn run(args: []const []const u8, options: RunOptions) !void { .role_arn = role_arn, .role_name = role_name, .arch = arch, + .env_vars = if (env_vars.count() > 0) &env_vars else null, }, options); } -fn printHelp(writer: *std.Io.Writer) void { +/// Parse a KEY=VALUE string and add to the env vars map +fn parseEnvVar( + env_str: []const u8, + env_vars: *std.StringHashMap([]const u8), + allocator: std.mem.Allocator, +) !void { + const eq_pos = std.mem.indexOf(u8, env_str, "=") orelse { + return error.InvalidEnvFormat; + }; + + const key = try allocator.dupe(u8, env_str[0..eq_pos]); + errdefer allocator.free(key); + const value = try allocator.dupe(u8, env_str[eq_pos + 1 ..]); + errdefer allocator.free(value); + + // If key already exists, free the old value + if (env_vars.fetchRemove(key)) |old| { + allocator.free(old.key); + allocator.free(old.value); + } + + try env_vars.put(key, value); +} + +/// Load environment variables from a file (KEY=VALUE format, one per line) +fn loadEnvFile( + path: []const u8, + env_vars: *std.StringHashMap([]const u8), + allocator: std.mem.Allocator, +) !void { + const file = std.fs.cwd().openFile(path, .{}) catch |err| { + std.log.err("Failed to open env file '{s}': {}", .{ path, err }); + return error.EnvFileNotFound; + }; + defer file.close(); + + // Read entire file (env files are typically small) + 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 env file: {}", .{err}); + return error.EnvFileReadError; + }; + defer allocator.free(content); + + // Parse line by line + var line_start: usize = 0; + for (content, 0..) |c, idx| { + if (c == '\n') { + const line = content[line_start..idx]; + line_start = idx + 1; + + // Skip empty lines and comments + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + + try parseEnvVar(trimmed, env_vars, allocator); + } + } + + // Handle last line if no trailing newline + if (line_start < content.len) { + const line = content[line_start..]; + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len > 0 and trimmed[0] != '#') { + try parseEnvVar(trimmed, env_vars, allocator); + } + } +} + +fn printHelp(writer: anytype) void { writer.print( \\Usage: lambda-build deploy [options] \\ @@ -83,8 +177,19 @@ fn printHelp(writer: *std.Io.Writer) void { \\ --role-arn IAM role ARN (optional - creates role if omitted) \\ --role-name IAM role name if creating (default: lambda_basic_execution) \\ --arch Architecture: x86_64 or aarch64 (default: x86_64) + \\ --env Set environment variable (can be repeated) + \\ --env-file Load environment variables from file (KEY=VALUE format) \\ --help, -h Show this help message \\ + \\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. + \\ + \\ Example .env file: + \\ # Database configuration + \\ DB_HOST=localhost + \\ DB_PORT=5432 + \\ \\If the function exists, its code is updated. Otherwise, a new function \\is created with the provided configuration. \\ @@ -97,6 +202,7 @@ const DeployOptions = struct { role_arn: ?[]const u8, role_name: []const u8, arch: ?[]const u8, + env_vars: ?*const std.StringHashMap([]const u8), }; fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void { @@ -141,12 +247,24 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void { }; // Convert arch string to Lambda format - const lambda_arch = if (std.mem.eql(u8, arch_str, "aarch64") or std.mem.eql(u8, arch_str, "arm64")) + const lambda_arch: []const u8 = if (std.mem.eql(u8, arch_str, "aarch64") or std.mem.eql(u8, arch_str, "arm64")) "arm64" else "x86_64"; - const architectures: []const []const u8 = &.{lambda_arch}; + // Use a mutable array so the slice type is [][]const u8, not []const []const u8 + var architectures_arr = [_][]const u8{lambda_arch}; + const architectures: [][]const u8 = &architectures_arr; + + // Build environment variables for AWS API + const env_variables = try buildEnvVariables(deploy_opts.env_vars, options.allocator); + defer if (env_variables) |vars| { + for (vars) |v| { + options.allocator.free(v.key); + if (v.value) |val| options.allocator.free(val); + } + options.allocator.free(vars); + }; // 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}); @@ -165,12 +283,13 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void { const create_result = aws.Request(services.lambda.create_function).call(.{ .function_name = deploy_opts.function_name, - .architectures = @constCast(architectures), + .architectures = architectures, .code = .{ .zip_file = base64_data }, .handler = "bootstrap", .package_type = "Zip", .runtime = "provided.al2023", .role = role_arn, + .environment = if (env_variables) |vars| .{ .variables = vars } else null, }, create_options) catch |err| { defer create_diagnostics.deinit(); std.log.info("CreateFunction returned: error={}, HTTP code={}", .{ err, create_diagnostics.http_code }); @@ -181,7 +300,7 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void { const update_result = try aws.Request(services.lambda.update_function_code).call(.{ .function_name = deploy_opts.function_name, - .architectures = @constCast(architectures), + .architectures = architectures, .zip_file = base64_data, }, aws_options); defer update_result.deinit(); @@ -192,8 +311,14 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void { } try options.stdout.flush(); - // Wait for function to be ready before returning + // Wait for function to be ready before updating configuration try waitForFunctionReady(deploy_opts.function_name, aws_options); + + // Update environment variables if provided + if (env_variables) |vars| { + try updateFunctionConfiguration(deploy_opts.function_name, vars, aws_options, options); + } + return; } @@ -212,6 +337,54 @@ fn deployFunction(deploy_opts: DeployOptions, options: RunOptions) !void { try waitForFunctionReady(deploy_opts.function_name, aws_options); } +/// Build environment variables in the format expected by AWS Lambda API +fn buildEnvVariables( + env_vars: ?*const std.StringHashMap([]const u8), + allocator: std.mem.Allocator, +) !?[]EnvVar { + const vars = env_vars orelse return null; + if (vars.count() == 0) return null; + + var result = try allocator.alloc(EnvVar, vars.count()); + errdefer allocator.free(result); + + var idx: usize = 0; + var it = vars.iterator(); + while (it.next()) |entry| { + result[idx] = .{ + .key = try allocator.dupe(u8, entry.key_ptr.*), + .value = try allocator.dupe(u8, entry.value_ptr.*), + }; + idx += 1; + } + + return result; +} + +/// Update function configuration (environment variables) +fn updateFunctionConfiguration( + function_name: []const u8, + env_variables: []EnvVar, + aws_options: aws.Options, + options: RunOptions, +) !void { + const services = aws.Services(.{.lambda}){}; + + std.log.info("Updating function configuration for: {s}", .{function_name}); + + const update_config_result = try aws.Request(services.lambda.update_function_configuration).call(.{ + .function_name = function_name, + .environment = .{ .variables = env_variables }, + }, aws_options); + defer update_config_result.deinit(); + + try options.stdout.print("Updated environment variables\n", .{}); + try options.stdout.flush(); + + // Wait for configuration update to complete + try waitForFunctionReady(function_name, aws_options); +} + fn waitForFunctionReady(function_name: []const u8, aws_options: aws.Options) !void { const services = aws.Services(.{.lambda}){};