diff --git a/.gitignore b/.gitignore index e73c965..928caff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ zig-cache/ zig-out/ +.zig-cache diff --git a/README.md b/README.md index a2b5a66..87810e6 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,37 @@ lambda-zig: A Custom Runtime for AWS Lambda =========================================== -This is a sample custom runtime built in zig (0.12). Simple projects will execute +This is a sample custom runtime built in zig (0.13). Simple projects will execute in <1ms, with a cold start init time of approximately 11ms. Some custom build steps have been added to build.zig, which will only currently appear if compiling from a linux operating system: -* `zig build iam`: Deploy and record a default IAM role for the lambda function -* `zig build package`: Package the lambda function for upload -* `zig build deploy`: Deploy the lambda function -* `zig build remoterun`: Run the lambda function +* `zig build awslambda_iam`: Deploy and record a default IAM role for the lambda function +* `zig build awslambda_package`: Package the lambda function for upload +* `zig build awslambda_deploy`: Deploy the lambda function +* `zig build awslambda_run`: Run the lambda function Custom options: * **function-name**: set the name of the AWS Lambda function -* **payload**: Use this to set the payload of the function when run using `zig build remoterun` +* **payload**: Use this to set the payload of the function when run using `zig build awslambda_run` +* **region**: Use this to set the region for the function deployment/run +* **function-role**: Name of the role to use for the function. The system will + look up the arn from this name, and create if it does not exist +* **function-arn**: Role arn to use with the function. This must exist -Additionally, a custom IAM role can be used for the function by appending ``-- --role myawesomerole`` -to the `zig build deploy` command. This has not really been tested. The role name -is cached in zig-out/bin/iam_role_name, so you can also just set that to the full -arn of your iam role if you'd like. - -The AWS Lambda function is compiled as a linux ARM64 executable. Since the build.zig -calls out to the shell for AWS operations, you will need the AWS CLI. v2.2.43 has been tested. +The AWS Lambda function can be compiled as a linux x86_64 or linux aarch64 +executable. The build script will set the architecture appropriately Caveats: -* Unhandled invocation errors seem to be causing timeouts -* zig build options only appear if compiling using linux, although it should be trivial - to make it work on other Unix-like operating systems (e.g. macos, freebsd). In fact, - it will likely work with just a change to the operating system check -* There are a **ton** of TODO's in this code. Current state is more of a proof of - concept. PRs are welcome! +* Building on Windows will not yet work, as the package step still uses + system commands due to the need to create a zip file, and the current lack + of zip file creation capabilities in the standard library (you can read, but + not write, zip files with the standard library). A TODO exists with more + information should you wish to file a PR. +* Caching is not yet implemented in the package or deployment steps, so the + function will be deployed on every build A sample project using this runtime can be found at https://git.lerch.org/lobo/lambda-zig-sample diff --git a/build.zig.zon b/build.zig.zon index 737012f..81d44b2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,6 +7,12 @@ // This field is optional. // This is currently advisory only; Zig does not yet do anything // with this value. + .dependencies = .{ + .aws = .{ + .url = "https://git.lerch.org/api/packages/lobo/generic/aws-sdk-with-models/908c9d2d429b1f38c835363db566aa17bf1742fd/908c9d2d429b1f38c835363db566aa17bf1742fd-with-models.tar.gz", + .hash = "122022770a177afb2ee46632f88ad5468a5dea8df22170d1dea5163890b0a881399d", + }, + }, .minimum_zig_version = "0.12.0", // Specifies the set of files and directories that are included in this package. @@ -20,6 +26,7 @@ "build.zig.zon", "lambdabuild.zig", "src", + "lambdabuild", "LICENSE", "README.md", }, diff --git a/lambdabuild.zig b/lambdabuild.zig index 99e2d23..fd651cf 100644 --- a/lambdabuild.zig +++ b/lambdabuild.zig @@ -1,5 +1,9 @@ const std = @import("std"); const builtin = @import("builtin"); +const Package = @import("lambdabuild/Package.zig"); +const Iam = @import("lambdabuild/Iam.zig"); +const Deploy = @import("lambdabuild/Deploy.zig"); +const Invoke = @import("lambdabuild/Invoke.zig"); fn fileExists(file_name: []const u8) bool { const file = std.fs.openFileAbsolute(file_name, .{}) catch return false; @@ -40,159 +44,122 @@ pub fn configureBuild(b: *std.Build, exe: *std.Build.Step.Compile, function_name // TODO: support other host OSs if (builtin.os.tag != .linux) return; - // Package step - const package_step = b.step("awslambda_package", "Package the function"); - const function_zip = b.getInstallPath(.bin, "function.zip"); + @import("aws").aws.globalLogControl(.info, .warn, .info, false); + const package_step = Package.create(b, .{ .exe = exe }); - // TODO: Avoid use of system-installed zip, maybe using something like - // https://github.com/hdorio/hwzip.zig/blob/master/src/hwzip.zig - const zip = if (std.mem.eql(u8, "bootstrap", exe.out_filename)) - try std.fmt.allocPrint(b.allocator, - \\zip -qj9 {s} {s} - , .{ - function_zip, - b.getInstallPath(.bin, "bootstrap"), - }) - else - // We need to copy stuff around - try std.fmt.allocPrint(b.allocator, - \\cp {s} {s} && \ - \\zip -qj9 {s} {s} && \ - \\rm {s} - , .{ - b.getInstallPath(.bin, exe.out_filename), - b.getInstallPath(.bin, "bootstrap"), - function_zip, - b.getInstallPath(.bin, "bootstrap"), - b.getInstallPath(.bin, "bootstrap"), - }); - // std.debug.print("\nzip cmdline: {s}", .{zip}); - defer b.allocator.free(zip); - var zip_cmd = b.addSystemCommand(&.{ "/bin/sh", "-c", zip }); - zip_cmd.step.dependOn(b.getInstallStep()); - package_step.dependOn(&zip_cmd.step); + const step = b.step("awslambda_package", "Package the function"); + step.dependOn(&package_step.step); + package_step.step.dependOn(b.getInstallStep()); - // Deployment - const deploy_step = b.step("awslambda_deploy", "Deploy the function"); + // Doing this will require that the aws dependency be added to the downstream + // build.zig.zon + // const lambdabuild = b.addExecutable(.{ + // .name = "lambdabuild", + // .root_source_file = .{ + // // we use cwd_relative here because we need to compile this relative + // // to whatever directory this file happens to be. That is likely + // // in a cache directory, not the base of the build. + // .cwd_relative = try std.fs.path.join(b.allocator, &[_][]const u8{ + // std.fs.path.dirname(@src().file).?, + // "lambdabuild/src/main.zig", + // }), + // }, + // .target = b.host, + // }); + // const aws_dep = b.dependency("aws", .{ + // .target = b.host, + // .optimize = lambdabuild.root_module.optimize orelse .Debug, + // }); + // const aws_module = aws_dep.module("aws"); + // lambdabuild.root_module.addImport("aws", aws_module); + // const iam_role_name = b.option( []const u8, "function-role", "IAM role name for function (will create if it does not exist) [lambda_basic_execution]", - ) orelse "lambda_basic_execution"; + ) orelse "lambda_basic_execution_blah2"; + const iam_role_arn = b.option( []const u8, "function-arn", "Preexisting IAM role arn for function", ); - const iam_step = b.step("awslambda_iam", "Create/Get IAM role for function"); - deploy_step.dependOn(iam_step); // iam_step will either be a noop or all the stuff below - const iam_role_param: []u8 = blk: { - if (iam_role_arn != null) - break :blk try std.fmt.allocPrint(b.allocator, "--role {s}", .{iam_role_arn.?}); - - if (iam_role_name.len == 0) - @panic("Either function-role or function-arn must be specified. function-arn will allow deployment without creating a role"); - - // Now we have an iam role name to use, but no iam role arn. Let's go hunting - // Once this is done once, we'll have a file with the arn in "cache" - // The iam arn will reside in an 'iam_role' file in the bin directory - - // Build system command to create the role if necessary and get the role arn - const iam_role_file = b.getInstallPath(.bin, "iam_role"); - - if (!fileExists(iam_role_file)) { - // std.debug.print("file does not exist", .{}); - // Our cache file does not exist on disk, so we'll create/get the role - // arn using the AWS CLI and dump to disk here - const ifstatement_fmt = - \\ if aws iam get-role --role-name {s} 2>&1 |grep -q NoSuchEntity; then aws iam create-role --output text --query Role.Arn --role-name {s} --assume-role-policy-document '{{ - \\ "Version": "2012-10-17", - \\ "Statement": [ - \\ {{ - \\ "Sid": "", - \\ "Effect": "Allow", - \\ "Principal": {{ - \\ "Service": "lambda.amazonaws.com" - \\ }}, - \\ "Action": "sts:AssumeRole" - \\ }} - \\ ]}}' > /dev/null; fi && \ - \\ aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AWSLambdaExecute --role-name lambda_basic_execution && \ - \\ aws iam get-role --role-name lambda_basic_execution --query Role.Arn --output text > {s} - ; - const ifstatement = try std.fmt.allocPrint( - b.allocator, - ifstatement_fmt, - .{ iam_role_name, iam_role_name, iam_role_file }, - ); - iam_step.dependOn(&b.addSystemCommand(&.{ "/bin/sh", "-c", ifstatement }).step); - } - - break :blk try std.fmt.allocPrint(b.allocator, "--role \"$(cat {s})\"", .{iam_role_file}); - }; - const function_name_file = b.getInstallPath(.bin, function_name); - const ifstatement = "if [ ! -f {s} ] || [ {s} -nt {s} ]; then if aws lambda get-function --function-name {s} 2>&1 |grep -q ResourceNotFoundException; then echo not found > /dev/null; {s}; else echo found > /dev/null; {s}; fi; fi"; - // The architectures option was introduced in 2.2.43 released 2021-10-01 - // We want to use arm64 here because it is both faster and cheaper for most - // Amazon Linux 2 is the only arm64 supported option - // TODO: This should determine compilation target and use x86_64 if needed - const not_found = "aws lambda create-function --architectures arm64 --runtime provided.al2 --function-name {s} --zip-file fileb://{s} --handler not_applicable {s} && touch {s}"; - const not_found_fmt = try std.fmt.allocPrint(b.allocator, not_found, .{ function_name, function_zip, iam_role_param, function_name_file }); - defer b.allocator.free(not_found_fmt); - const found = "aws lambda update-function-code --function-name {s} --zip-file fileb://{s} && touch {s}"; - const found_fmt = try std.fmt.allocPrint(b.allocator, found, .{ function_name, function_zip, function_name_file }); - defer b.allocator.free(found_fmt); - var found_final: []const u8 = undefined; - var not_found_final: []const u8 = undefined; - if (b.args) |args| { - found_final = try addArgs(b.allocator, found_fmt, args); - not_found_final = try addArgs(b.allocator, not_found_fmt, args); - } else { - found_final = found_fmt; - not_found_final = not_found_fmt; - } - const cmd = try std.fmt.allocPrint(b.allocator, ifstatement, .{ - function_name_file, - b.getInstallPath(.bin, exe.out_filename), - function_name_file, - function_name, - not_found_fmt, - found_fmt, + const iam = Iam.create(b, .{ + .role_name = iam_role_name, + .role_arn = iam_role_arn, }); + const iam_step = b.step("awslambda_iam", "Create/Get IAM role for function"); + iam_step.dependOn(&iam.step); - defer b.allocator.free(cmd); + const region = b.option([]const u8, "region", "Region to use [default is autodetect from environment/config]") orelse try findRegionFromSystem(b.allocator); - // std.debug.print("{s}\n", .{cmd}); - deploy_step.dependOn(package_step); - deploy_step.dependOn(&b.addSystemCommand(&.{ "/bin/sh", "-c", cmd }).step); + // Deployment + const deploy = Deploy.create(b, .{ + .name = function_name, + .package = package_step.packagedFileLazyPath(), + .arch = exe.root_module.resolved_target.?.result.cpu.arch, + .iam_step = iam, + .region = region, + }); + deploy.step.dependOn(&package_step.step); + + const deploy_step = b.step("awslambda_deploy", "Deploy the function"); + deploy_step.dependOn(&deploy.step); const payload = b.option([]const u8, "payload", "Lambda payload [{\"foo\":\"bar\", \"baz\": \"qux\"}]") orelse \\ {"foo": "bar", "baz": "qux"}" ; - const run_script = - \\ f=$(mktemp) && \ - \\ logs=$(aws lambda invoke \ - \\ --cli-binary-format raw-in-base64-out \ - \\ --invocation-type RequestResponse \ - \\ --function-name {s} \ - \\ --payload '{s}' \ - \\ --log-type Tail \ - \\ --query LogResult \ - \\ --output text "$f" |base64 -d) && \ - \\ cat "$f" && rm "$f" && \ - \\ echo && echo && echo "$logs" - ; - const run_script_fmt = try std.fmt.allocPrint(b.allocator, run_script, .{ function_name, payload }); - defer b.allocator.free(run_script_fmt); - const run_cmd = b.addSystemCommand(&.{ "/bin/sh", "-c", run_script_fmt }); - run_cmd.step.dependOn(deploy_step); - if (b.args) |args| { - run_cmd.addArgs(args); - } - + const invoke = Invoke.create(b, .{ + .name = function_name, + .payload = payload, + .region = region, + }); + invoke.step.dependOn(&deploy.step); const run_step = b.step("awslambda_run", "Run the app in AWS lambda"); - run_step.dependOn(&run_cmd.step); + run_step.dependOn(&invoke.step); +} + +// AWS_CONFIG_FILE (default is ~/.aws/config +// AWS_DEFAULT_REGION +fn findRegionFromSystem(allocator: std.mem.Allocator) ![]const u8 { + const env_map = try std.process.getEnvMap(allocator); + if (env_map.get("AWS_DEFAULT_REGION")) |r| return r; + const config_file_path = env_map.get("AWS_CONFIG_FILE") orelse + try std.fs.path.join(allocator, &[_][]const u8{ + env_map.get("HOME") orelse env_map.get("USERPROFILE").?, + ".aws", + "config", + }); + const config_file = try std.fs.openFileAbsolute(config_file_path, .{}); + defer config_file.close(); + const config_bytes = try config_file.readToEndAlloc(allocator, 1024 * 1024); + const profile = env_map.get("AWS_PROFILE") orelse "default"; + var line_iterator = std.mem.split(u8, config_bytes, "\n"); + var in_profile = false; + while (line_iterator.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + if (!in_profile) { + if (trimmed[0] == '[' and trimmed[trimmed.len - 1] == ']') { + // this is a profile directive! + // std.debug.print("profile: {s}, in file: {s}\n", .{ profile, trimmed[1 .. trimmed.len - 1] }); + if (std.mem.eql(u8, profile, trimmed[1 .. trimmed.len - 1])) { + in_profile = true; + } + } + continue; // we're only looking for a profile at this point + } + // look for our region directive + if (trimmed[0] == '[' and trimmed[trimmed.len - 1] == ']') + return error.RegionNotFound; // we've hit another profile without getting our region + if (!std.mem.startsWith(u8, trimmed, "region")) continue; + var equalityiterator = std.mem.split(u8, trimmed, "="); + _ = equalityiterator.next() orelse return error.RegionNotFound; + const raw_val = equalityiterator.next() orelse return error.RegionNotFound; + return try allocator.dupe(u8, std.mem.trimLeft(u8, raw_val, " \t")); + } + return error.RegionNotFound; } diff --git a/lambdabuild/Deploy.zig b/lambdabuild/Deploy.zig new file mode 100644 index 0000000..231c4a0 --- /dev/null +++ b/lambdabuild/Deploy.zig @@ -0,0 +1,161 @@ +const std = @import("std"); +const aws = @import("aws").aws; + +const Deploy = @This(); + +step: std.Build.Step, +options: Options, + +const base_id: std.Build.Step.Id = .custom; + +pub const Options = struct { + /// Function name to be used for the function + name: []const u8, + + /// LazyPath for the function package (zip file) + package: std.Build.LazyPath, + + /// Architecture for Lambda function + arch: std.Target.Cpu.Arch, + + /// Iam step. This will be a dependency of the deployment + iam_step: *@import("Iam.zig"), + + /// Region for deployment + region: []const u8, +}; + +pub fn create(owner: *std.Build, options: Options) *Deploy { + const name = owner.dupe(options.name); + const step_name = owner.fmt("{s} {s}{s}", .{ + "aws lambda", + "deploy", + name, + }); + const self = owner.allocator.create(Deploy) catch @panic("OOM"); + self.* = .{ + .step = std.Build.Step.init(.{ + .id = base_id, + .name = step_name, + .owner = owner, + .makeFn = make, + }), + .options = options, + }; + + self.step.dependOn(&options.iam_step.step); + return self; +} + +/// gets the last time we deployed this function from the name in cache. +/// If not in cache, null is returned. Note that cache is not account specific, +/// so if you're banging around multiple accounts, you'll want to use different +/// local zig caches for each +fn getlastDeployedTime(step: *std.Build.Step, name: []const u8) !?[]const u8 { + try step.owner.cache_root.handle.makePath("iam"); + // we should be able to use the role name, as only the following characters + // are allowed: _+=,.@-. + const cache_file = try std.fmt.allocPrint( + step.owner.allocator, + "deploy{s}{s}", + .{ std.fs.path.sep_str, name }, + ); + const buff = try step.owner.allocator.alloc(u8, 64); + const time = step.owner.cache_root.handle.readFile(cache_file, buff) catch return null; + return time; +} + +fn make(step: *std.Build.Step, node: std.Progress.Node) anyerror!void { + _ = node; + const self: *Deploy = @fieldParentPtr("step", step); + + if (self.options.arch != .aarch64 and self.options.arch != .x86_64) + return step.fail("AWS Lambda can only deploy aarch64 and x86_64 functions ({} not allowed)", .{self.options.arch}); + + // TODO: Work out cache. HOWEVER...this cannot be done until the caching + // for the Deploy command works properly. Right now, it regenerates + // the zip file every time + // if (try getIamArnFromName(step, self.options.role_name)) |_| { + // step.result_cached = true; + // return; // exists in cache - nothing to do + // } + + var client = aws.Client.init(self.step.owner.allocator, .{}); + defer client.deinit(); + const services = aws.Services(.{.lambda}){}; + const function = blk: { + var diagnostics = aws.Diagnostics{ + .http_code = undefined, + .response_body = undefined, + .allocator = self.step.owner.allocator, + }; + const options = aws.Options{ + .client = client, + .diagnostics = &diagnostics, + .region = self.options.region, + }; + + aws.globalLogControl(.info, .warn, .info, true); + defer aws.globalLogControl(.info, .warn, .info, false); + const call = aws.Request(services.lambda.get_function).call(.{ + .function_name = self.options.name, + }, options) catch |e| { + // There seems an issue here, but realistically, we have an arena + // so there's no leak leaving this out + defer diagnostics.deinit(); + if (diagnostics.http_code == 404) break :blk null; + return step.fail( + "Unknown error {} from Lambda GetFunction. HTTP code {}, message: {s}", + .{ e, diagnostics.http_code, diagnostics.response_body }, + ); + }; + defer call.deinit(); + + // TODO: Write call.response.configuration.last_modified to cache + + // std.debug.print("Function found. Last modified: {s}, revision id: {s}\n", .{ call.response.configuration.?.last_modified.?, call.response.configuration.?.revision_id.? }); + break :blk .{ + .last_modified = try step.owner.allocator.dupe(u8, call.response.configuration.?.last_modified.?), + .revision_id = try step.owner.allocator.dupe(u8, call.response.configuration.?.revision_id.?), + }; + }; + + const encoder = std.base64.standard.Encoder; + const file = try std.fs.openFileAbsolute(self.options.package.getPath2(step.owner, step), .{}); + defer file.close(); + const bytes = try file.readToEndAlloc(step.owner.allocator, 100 * 1024 * 1024); + const base64_buf = try step.owner.allocator.alloc(u8, encoder.calcSize(bytes.len)); + const base64_bytes = encoder.encode(base64_buf, bytes); + const options = aws.Options{ + .client = client, + .region = self.options.region, + }; + const arm64_arch = [_][]const u8{"arm64"}; + const x86_64_arch = [_][]const u8{"x86_64"}; + const architectures = (if (self.options.arch == .aarch64) arm64_arch else x86_64_arch); + const arches: [][]const u8 = @constCast(architectures[0..]); + if (function) |f| { + // TODO: make sure our zipfile newer than the lambda function + const update_call = try aws.Request(services.lambda.update_function_code).call(.{ + .function_name = self.options.name, + .architectures = arches, + .revision_id = f.revision_id, + .zip_file = base64_bytes, + }, options); + defer update_call.deinit(); + // TODO: Write call.response.last_modified to cache + // TODO: Write call.response.revision_id to cache? + } else { + // New function - we need to create from scratch + const create_call = try aws.Request(services.lambda.create_function).call(.{ + .function_name = self.options.name, + .architectures = arches, + .code = .{ .zip_file = base64_bytes }, + .handler = "not_applicable", + .package_type = "Zip", + .runtime = "provided.al2", + .role = self.options.iam_step.resolved_arn, + }, options); + defer create_call.deinit(); + } +} diff --git a/lambdabuild/Iam.zig b/lambdabuild/Iam.zig new file mode 100644 index 0000000..facc21d --- /dev/null +++ b/lambdabuild/Iam.zig @@ -0,0 +1,146 @@ +const std = @import("std"); +const aws = @import("aws").aws; + +const Iam = @This(); + +step: std.Build.Step, +options: Options, +/// resolved_arn will be set only after make is run +resolved_arn: []const u8 = undefined, + +arn_buf: [2048]u8 = undefined, // https://docs.aws.amazon.com/IAM/latest/APIReference/API_Role.html has 2k limit +const base_id: std.Build.Step.Id = .custom; + +pub const Options = struct { + name: []const u8 = "", + role_name: []const u8, + role_arn: ?[]const u8, +}; + +pub fn create(owner: *std.Build, options: Options) *Iam { + const name = owner.dupe(options.name); + const step_name = owner.fmt("{s} {s}{s}", .{ + "aws lambda", + "iam", + name, + }); + const self = owner.allocator.create(Iam) catch @panic("OOM"); + self.* = .{ + .step = std.Build.Step.init(.{ + .id = base_id, + .name = step_name, + .owner = owner, + .makeFn = make, + }), + .options = options, + }; + + return self; +} + +/// gets an IamArn from the name in cache. If not in cache, null is returned +/// Note that cache is not account specific, so if you're banging around multiple +/// accounts, you'll want to use different local zig caches for each +pub fn getIamArnFromName(step: *std.Build.Step, name: []const u8) !?[]const u8 { + try step.owner.cache_root.handle.makePath("iam"); + // we should be able to use the role name, as only the following characters + // are allowed: _+=,.@-. + const iam_file = try std.fmt.allocPrint( + step.owner.allocator, + "iam{s}{s}", + .{ std.fs.path.sep_str, name }, + ); + const buff = try step.owner.allocator.alloc(u8, 64); + const arn = step.owner.cache_root.handle.readFile(iam_file, buff) catch return null; + return arn; +} + +fn make(step: *std.Build.Step, node: std.Progress.Node) anyerror!void { + _ = node; + const self: *Iam = @fieldParentPtr("step", step); + + if (try getIamArnFromName(step, self.options.role_name)) |a| { + step.result_cached = true; + @memcpy(self.arn_buf[0..a.len], a); + self.resolved_arn = self.arn_buf[0..a.len]; + return; // exists in cache - nothing to do + } + + var client = aws.Client.init(self.step.owner.allocator, .{}); + defer client.deinit(); + const services = aws.Services(.{.iam}){}; + + var arn = blk: { + var diagnostics = aws.Diagnostics{ + .http_code = undefined, + .response_body = undefined, + .allocator = self.step.owner.allocator, + }; + const options = aws.Options{ + .client = client, + .diagnostics = &diagnostics, + }; + + const call = aws.Request(services.iam.get_role).call(.{ + .role_name = self.options.role_name, // TODO: if we have a role_arn, we should use it and skip + }, options) catch |e| { + defer diagnostics.deinit(); + if (diagnostics.http_code == 404) break :blk null; + return step.fail( + "Unknown error {} from IAM GetRole. HTTP code {}, message: {s}", + .{ e, diagnostics.http_code, diagnostics.response_body }, + ); + }; + defer call.deinit(); + + break :blk try step.owner.allocator.dupe(u8, call.response.role.arn); + }; + // Now ARN will either be null (does not exist), or a value + + if (arn == null) { + // we need to create the role before proceeding + const options = aws.Options{ + .client = client, + }; + + const create_call = try aws.Request(services.iam.create_role).call(.{ + .role_name = self.options.role_name, + .assume_role_policy_document = + \\{ + \\ "Version": "2012-10-17", + \\ "Statement": [ + \\ { + \\ "Sid": "", + \\ "Effect": "Allow", + \\ "Principal": { + \\ "Service": "lambda.amazonaws.com" + \\ }, + \\ "Action": "sts:AssumeRole" + \\ } + \\ ] + \\} + , + }, options); + defer create_call.deinit(); + arn = try step.owner.allocator.dupe(u8, create_call.response.role.arn); + const attach_call = try aws.Request(services.iam.attach_role_policy).call(.{ + .policy_arn = "arn:aws:iam::aws:policy/AWSLambdaExecute", + .role_name = self.options.role_name, + }, options); + defer attach_call.deinit(); + } + + @memcpy(self.arn_buf[0..arn.?.len], arn.?); + self.resolved_arn = self.arn_buf[0..arn.?.len]; + + // NOTE: This must match getIamArnFromName + const iam_file = try std.fmt.allocPrint( + step.owner.allocator, + "iam{s}{s}", + .{ std.fs.path.sep_str, self.options.role_name }, + ); + try step.owner.cache_root.handle.writeFile(.{ + .sub_path = iam_file, + .data = arn.?, + }); +} diff --git a/lambdabuild/Invoke.zig b/lambdabuild/Invoke.zig new file mode 100644 index 0000000..9a1155c --- /dev/null +++ b/lambdabuild/Invoke.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const aws = @import("aws").aws; + +const Invoke = @This(); + +step: std.Build.Step, +options: Options, + +const base_id: std.Build.Step.Id = .custom; + +pub const Options = struct { + /// Function name to invoke + name: []const u8, + + /// Payload to send to the function + payload: []const u8, + + /// Region for deployment + region: []const u8, +}; + +pub fn create(owner: *std.Build, options: Options) *Invoke { + const name = owner.dupe(options.name); + const step_name = owner.fmt("{s} {s}{s}", .{ + "aws lambda", + "invoke", + name, + }); + const self = owner.allocator.create(Invoke) catch @panic("OOM"); + self.* = .{ + .step = std.Build.Step.init(.{ + .id = base_id, + .name = step_name, + .owner = owner, + .makeFn = make, + }), + .options = options, + }; + + return self; +} + +fn make(step: *std.Build.Step, node: std.Progress.Node) anyerror!void { + _ = node; + const self: *Invoke = @fieldParentPtr("step", step); + + var client = aws.Client.init(self.step.owner.allocator, .{}); + defer client.deinit(); + const services = aws.Services(.{.lambda}){}; + + const options = aws.Options{ + .client = client, + .region = self.options.region, + }; + const call = try aws.Request(services.lambda.invoke).call(.{ + .function_name = self.options.name, + .payload = self.options.payload, + .log_type = "Tail", + .invocation_type = "RequestResponse", + }, options); + defer call.deinit(); + std.debug.print("{?s}\n", .{call.response.payload}); +} diff --git a/lambdabuild/Package.zig b/lambdabuild/Package.zig new file mode 100644 index 0000000..350ae5e --- /dev/null +++ b/lambdabuild/Package.zig @@ -0,0 +1,94 @@ +const std = @import("std"); + +const Package = @This(); + +step: std.Build.Step, +lambda_zipfile: []const u8, + +const base_id: std.Build.Step.Id = .install_file; + +pub const Options = struct { + name: []const u8 = "", + exe: *std.Build.Step.Compile, + zipfile_name: []const u8 = "function.zip", +}; + +pub fn create(owner: *std.Build, options: Options) *Package { + const name = owner.dupe(options.name); + const step_name = owner.fmt("{s} {s}{s}", .{ + "aws lambda", + "package", + name, + }); + const package = owner.allocator.create(Package) catch @panic("OOM"); + package.* = .{ + .step = std.Build.Step.init(.{ + .id = base_id, + .name = step_name, + .owner = owner, + .makeFn = make, + }), + .lambda_zipfile = options.zipfile_name, + }; + + // TODO: For Windows, tar.exe can actually do zip files. tar -a -cf function.zip file1 [file2...] + // https://superuser.com/questions/201371/create-zip-folder-from-the-command-line-windows#comment2725283_898508 + // + // We'll want two system commands here. One for the exe itself, and one for + // other files (TODO: what does this latter one look like? maybe it's an option?) + var zip_cmd = owner.addSystemCommand(&.{ "zip", "-qj9X" }); + zip_cmd.has_side_effects = true; // TODO: move these to makeFn as we have little cache control here... + zip_cmd.setCwd(.{ .src_path = .{ + .owner = owner, + .sub_path = owner.getInstallPath(.prefix, "."), + } }); + const zipfile = zip_cmd.addOutputFileArg(options.zipfile_name); + zip_cmd.addArg(owner.getInstallPath(.bin, "bootstrap")); + // std.debug.print("\nzip cmdline: {s}", .{zip}); + if (!std.mem.eql(u8, "bootstrap", options.exe.out_filename)) { + // We need to copy stuff around + // TODO: should this be installing bootstrap binary in .bin directory? + const cp_cmd = owner.addSystemCommand(&.{ "cp", owner.getInstallPath(.bin, options.exe.out_filename) }); + cp_cmd.has_side_effects = true; + const copy_output = cp_cmd.addOutputFileArg("bootstrap"); + const install_copy = owner.addInstallFileWithDir(copy_output, .bin, "bootstrap"); + cp_cmd.step.dependOn(owner.getInstallStep()); + zip_cmd.step.dependOn(&install_copy.step); + // might as well leave this bootstrap around for caching purposes + // const rm_cmd = owner.addSystemCommand(&.{ "rm", owner.getInstallPath(.bin, "bootstrap"), }); + } + const install_zipfile = owner.addInstallFileWithDir(zipfile, .prefix, options.zipfile_name); + install_zipfile.step.dependOn(&zip_cmd.step); + package.step.dependOn(&install_zipfile.step); + return package; +} + +pub fn packagedFilePath(self: Package) []const u8 { + return self.step.owner.getInstallPath(.prefix, self.options.zipfile_name); +} +pub fn packagedFileLazyPath(self: Package) std.Build.LazyPath { + return .{ .src_path = .{ + .owner = self.step.owner, + .sub_path = self.step.owner.getInstallPath(.prefix, self.lambda_zipfile), + } }; +} + +fn make(step: *std.Build.Step, node: std.Progress.Node) anyerror!void { + // Make here doesn't actually do anything. But we want to set up this + // step this way, so that when (if) zig stdlib gains the abiltity to write + // zip files in addition to reading them, we can skip all the system commands + // and just do all the things here instead + // + // + // TODO: The caching plan will be: + // + // get a hash of the bootstrap and whatever other files we put into the zip + // file (because a zip is not really reproducible). If the cache directory + // has the hash as its latest hash, we have nothing to do, so we can exit + // at that point + // + // Otherwise, store that hash in our cache, and copy our bootstrap, zip + // things up and install the file into zig-out + _ = node; + _ = step; +} diff --git a/lambdabuild/function.zip b/lambdabuild/function.zip new file mode 100644 index 0000000..9975889 Binary files /dev/null and b/lambdabuild/function.zip differ