diff --git a/README.md b/README.md index 6bfe700..f907492 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.zig b/build.zig index 57f12ee..dd20a69 100644 --- a/build.zig +++ b/build.zig @@ -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); } diff --git a/lambdabuild.zig b/lambdabuild.zig index a1ab35c..cf16d86 100644 --- a/lambdabuild.zig +++ b/lambdabuild.zig @@ -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"); diff --git a/tools/build/build.zig.zon b/tools/build/build.zig.zon index f1f10d4..76eb779 100644 --- a/tools/build/build.zig.zon +++ b/tools/build/build.zig.zon @@ -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 = .{ diff --git a/tools/build/src/LambdaBuildConfig.zig b/tools/build/src/LambdaBuildConfig.zig new file mode 100644 index 0000000..e6fe9c0 --- /dev/null +++ b/tools/build/src/LambdaBuildConfig.zig @@ -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, + }; +} diff --git a/tools/build/src/deploy.zig b/tools/build/src/deploy.zig index b8f3ad0..a4daecb 100644 --- a/tools/build/src/deploy.zig +++ b/tools/build/src/deploy.zig @@ -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 of the Lambda function (required) - \\ --zip-file Path to the deployment zip (required) - \\ --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) - \\ --allow-principal

Grant invoke permission to AWS service principal - \\ (e.g., alexa-appkit.amazon.com) - \\ --deploy-output Write deployment info (ARN, region, etc.) to JSON file - \\ --help, -h Show this help message + \\ --function-name Name of the Lambda function (required) + \\ --zip-file Path to the deployment zip (required) + \\ --role-arn IAM role ARN (optional - creates role if omitted) + \\ --arch Architecture: x86_64 or aarch64 (default: x86_64) + \\ --env Set environment variable (can be repeated) + \\ --env-file Load environment variables from file + \\ --config-file Path to JSON config file (required, error if missing) + \\ --config-file-optional Path to JSON config file (optional, use defaults if missing) + \\ --deploy-output 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| { diff --git a/tools/build/src/iam.zig b/tools/build/src/iam.zig index 0184a57..931e1d5 100644 --- a/tools/build/src/iam.zig +++ b/tools/build/src/iam.zig @@ -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 of the IAM role (required) - \\ --help, -h Show this help message + \\ --config-file Path to JSON config file (required, error if missing) + \\ --config-file-optional 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();