Skip to content

Commit 831fa0e

Browse files
authored
[plugin] Enforce build/deploy architecture consistency (#683) (#688)
Fixes #683 ## Description of changes `lambda-build` and `lambda-deploy` could independently choose the target architecture, so a user could build an `arm64` binary and deploy a function declaring `x64` (or the reverse). The deploy "succeeded" but the function was broken at invoke time, with no error at build or deploy. The OCI PR (#687) introduced the `build-manifest.json` hand-off but only wired the architecture into the image deploy path. This PR closes the underlying latent bug for the ZIP path and makes a mismatch impossible to deploy silently. ### `lambda-build` - Adds `--architecture <x64|arm64>` (default: host). - The flag actually drives the build: the container is run/pulled/built for that platform (`docker --platform linux/<arch>`, Apple `container --arch <arch>`), so the produced binary matches what the manifest records. The CLI-specific spelling lives in each `ContainerCLI` implementation. - Both ZIP and OCI backends record the real architecture in `build-manifest.json` (ZIP previously hardcoded `.host`). - A native (Amazon Linux host) build rejects an explicit cross-architecture request, since it can only target the host and would otherwise record a mismatched architecture. ### `lambda-deploy` - When `--architecture` is omitted, deploy adopts the architecture recorded in the manifest instead of independently re-defaulting to the host. - When `--architecture` is passed and disagrees with the built artifact, deploy fails fast with a descriptive error rather than creating a broken function. - No manifest (legacy `archive` output, or `--input-directory`) falls back to the previous behavior; `--architecture` remains the way to declare the arch there. ### Docs & tests - Documents the flag and the build→deploy hand-off in the DocC articles. - Adds unit tests for arg parsing, backend threading, the updated CLI argv, and the deploy-side reconciliation (match / mismatch / omitted / no-manifest). ## New/existing dependencies impact assessment, if applicable No new dependencies. ## Conventional Commits fix: enforce architecture consistency between lambda-build and lambda-deploy By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 8345d55 commit 831fa0e

22 files changed

Lines changed: 613 additions & 92 deletions

Plugins/AWSLambdaBuilder/Plugin.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,20 @@ struct AWSLambdaBuilder: CommandPlugin {
3434
// The helper requires --configuration; the plugin supplies the default. Validation of the
3535
// value itself is left to the helper.
3636
let configurationArgument = argumentExtractor.extractOption(named: "configuration")
37-
// `--container-cli` is a deprecated alias for `--cross-compile`.
3837
let crossCompileArgument = argumentExtractor.extractOption(named: "cross-compile")
39-
let containerCliArgument = argumentExtractor.extractOption(named: "container-cli")
38+
// `--container-cli` only exists on the deprecated `archive` command. Reject it here rather
39+
// than let it fall through to the helper and be silently ignored (which would build with the
40+
// wrong CLI). The user is told to use the canonical `--cross-compile` instead.
41+
guard argumentExtractor.extractOption(named: "container-cli").isEmpty else {
42+
throw BuilderErrors.invalidArgument(
43+
"'--container-cli' is not supported by lambda-build. Use '--cross-compile <docker|container>' instead."
44+
)
45+
}
4046

4147
// Resolve the container CLI that matches the requested cross-compilation method. The plugin
4248
// sandbox can only run tools it resolves up front, so we must pick the right binary here:
4349
// `container` for `--cross-compile container`, `docker` otherwise.
44-
let crossCompileMethod = (crossCompileArgument.first ?? containerCliArgument.first)?.lowercased()
50+
let crossCompileMethod = crossCompileArgument.first?.lowercased()
4551
let containerCLIToolName = crossCompileMethod == "container" ? "container" : "docker"
4652
let containerToolPath = try context.tool(named: containerCLIToolName).url
4753
let zipToolPath = try context.tool(named: "zip").url

Plugins/AWSLambdaDeployer/Plugin.swift

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,16 @@ struct AWSLambdaDeployer: CommandPlugin {
2929
var argumentExtractor = ArgumentExtractor(arguments)
3030
let productsArgument = argumentExtractor.extractOption(named: "products")
3131
// `--cross-compile` selects the container CLI used to push an OCI image to ECR (docker or
32-
// container). The plugin sandbox can only run tools it resolves up front, so the matching
33-
// binary is resolved here and forwarded as --cross-compile-tool-path. For a ZIP deploy this
34-
// is unused; resolving docker by default is harmless.
32+
// container).
3533
let crossCompileArgument = argumentExtractor.extractOption(named: "cross-compile")
34+
// `--container-cli` only exists on the deprecated `archive` command. Reject it here rather
35+
// than let it fall through to the helper and be silently ignored (which would push with the
36+
// wrong CLI). The user is told to use the canonical `--cross-compile` instead.
37+
guard argumentExtractor.extractOption(named: "container-cli").isEmpty else {
38+
throw DeployerPluginErrors.invalidArgument(
39+
"'--container-cli' is not supported by lambda-deploy. Use '--cross-compile <docker|container>' instead."
40+
)
41+
}
3642

3743
let products: [Product]
3844
if !productsArgument.isEmpty {
@@ -44,17 +50,20 @@ struct AWSLambdaDeployer: CommandPlugin {
4450
let productNames = products.map { $0.name }.joined(separator: ",")
4551

4652
let crossCompile = crossCompileArgument.first?.lowercased()
47-
let containerCLIToolName = crossCompile == "container" ? "container" : "docker"
48-
// Best-effort: a container CLI may not be installed for a plain ZIP deploy, so don't fail
49-
// here if it can't be resolved — the helper only needs it for an image artifact.
50-
let containerToolPath = try? context.tool(named: containerCLIToolName).url
5153

5254
var args = ["deploy", "--products", productNames]
5355
if let crossCompile {
5456
args += ["--cross-compile", crossCompile]
5557
}
56-
if let containerToolPath {
57-
args += ["--cross-compile-tool-path", containerToolPath.path]
58+
// The CLI flavor to use is only known after the helper reads the build manifest, and the
59+
// plugin sandbox can only run tools it resolves up front. So resolve every container CLI that
60+
// is installed and forward each as `--cross-compile-tool-path <name>=<path>`; the helper then
61+
// picks the path matching the CLI it selects. Best-effort: a plain ZIP deploy needs none, so
62+
// a CLI that can't be resolved is simply omitted.
63+
for cliName in ["docker", "container"] {
64+
if let toolPath = try? context.tool(named: cliName).url {
65+
args += ["--cross-compile-tool-path", "\(cliName)=\(toolPath.path)"]
66+
}
5867
}
5968
args += argumentExtractor.remainingArguments
6069

@@ -75,3 +84,14 @@ struct AWSLambdaDeployer: CommandPlugin {
7584
}
7685

7786
}
87+
88+
private enum DeployerPluginErrors: Error, CustomStringConvertible {
89+
case invalidArgument(String)
90+
91+
var description: String {
92+
switch self {
93+
case .invalidArgument(let description):
94+
return description
95+
}
96+
}
97+
}

Sources/AWSLambdaPluginHelper/lambda-build/ArchiveBackends/ZipArchiveBackend.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ struct ZipArchiveBackend: ArchiveBackend {
3030
/// The resolved path to the `zip` tool.
3131
let zipToolPath: URL
3232

33+
/// The architecture the binaries were built for, recorded in the build manifest so the deploy
34+
/// step deploys the function for the architecture it was actually built for.
35+
let architecture: BuildArchitecture
36+
3337
// TODO: explore using ziplib or similar instead of shelling out
3438
func archive(
3539
products: [String: URL],
@@ -121,7 +125,7 @@ struct ZipArchiveBackend: ArchiveBackend {
121125
// (package type + architecture) instead of re-deriving everything from the path.
122126
try BuildManifest.zip(
123127
product: product,
124-
architecture: .host,
128+
architecture: self.architecture,
125129
zipPath: zipfilePath.path()
126130
).write(into: workingDirectory)
127131

Sources/AWSLambdaPluginHelper/lambda-build/BuildArchitecture.swift

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616
/// The target CPU architecture an artifact is built for.
1717
///
18-
/// An OCI image bakes in a single architecture, so the build step needs to know which one to
19-
/// target. Today this defaults to the host architecture; a user-facing `--architecture` flag and
20-
/// build-manifest plumbing are tracked separately (issue #683) and will set this explicitly.
18+
/// Set explicitly by `--architecture` (defaulting to the host architecture) and recorded in the
19+
/// build manifest so `lambda-deploy` deploys the function for the architecture the binary was
20+
/// actually built for. Both the ZIP and OCI archive backends target this single architecture.
2121
@available(LambdaSwift 2.0, *)
2222
enum BuildArchitecture: String, Codable, CustomStringConvertible {
2323
case x64
@@ -32,20 +32,17 @@ enum BuildArchitecture: String, Codable, CustomStringConvertible {
3232
#endif
3333
}
3434

35-
/// The value docker's `--platform` flag expects (`linux/amd64`, `linux/arm64`).
36-
var dockerPlatform: String {
37-
switch self {
38-
case .x64: return "linux/amd64"
39-
case .arm64: return "linux/arm64"
35+
/// Parses the `--architecture` value, defaulting to the host architecture when omitted.
36+
static func parse(_ value: String?) throws -> Self {
37+
guard let value else {
38+
return .host
4039
}
41-
}
42-
43-
/// The value Apple `container`'s `--arch` flag expects (`amd64`, `arm64`).
44-
var containerArch: String {
45-
switch self {
46-
case .x64: return "amd64"
47-
case .arm64: return "arm64"
40+
guard let architecture = BuildArchitecture(rawValue: value.lowercased()) else {
41+
throw BuilderErrors.invalidArgument(
42+
"invalid architecture '\(value)'. Use 'x64' or 'arm64'."
43+
)
4844
}
45+
return architecture
4946
}
5047

5148
var description: String {

Sources/AWSLambdaPluginHelper/lambda-build/BuildBackends/AppleContainerCLI.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,29 @@
2323
struct AppleContainerCLI: ContainerCLI {
2424
let executableName = "container"
2525

26-
func pullArguments(image: String) -> [String] {
27-
["image", "pull", image]
26+
/// The value Apple `container`'s `--arch` flag expects (`amd64`, `arm64`).
27+
func arch(for architecture: BuildArchitecture) -> String {
28+
switch architecture {
29+
case .x64: return "amd64"
30+
case .arm64: return "arm64"
31+
}
32+
}
33+
34+
func pullArguments(image: String, architecture: BuildArchitecture) -> [String] {
35+
["image", "pull", "--platform", "linux/\(self.arch(for: architecture))", image]
2836
}
2937

3038
func runArguments(
3139
baseImage: String,
40+
architecture: BuildArchitecture,
3241
workingDirectory: String,
3342
mounts: [String],
3443
env: [String: String]?,
3544
command: String
3645
) -> [String] {
37-
// container's runtime needs a bit more memory than the default
38-
var args: [String] = ["run", "--memory", "4G", "--rm"]
46+
// container's runtime needs a bit more memory than the default. `--arch` selects the
47+
// container's CPU architecture, which is what the in-container `swift build` compiles for.
48+
var args: [String] = ["run", "--arch", self.arch(for: architecture), "--memory", "4G", "--rm"]
3949
for mount in mounts {
4050
args += ["-v", mount]
4151
}
@@ -59,7 +69,7 @@ struct AppleContainerCLI: ContainerCLI {
5969
// explicit `-f`. `--arch` selects the single target architecture.
6070
[
6171
"build",
62-
"--arch", architecture.containerArch,
72+
"--arch", self.arch(for: architecture),
6373
"-f", dockerfile,
6474
"-t", tag,
6575
contextDir,

Sources/AWSLambdaPluginHelper/lambda-build/BuildBackends/ContainerBuildBackend.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ struct ContainerBuildBackend: BuildBackend {
3232
let baseImage: String
3333
let disableImageUpdate: Bool
3434

35+
/// The CPU architecture to build for. The container runs as this architecture, so the compiled
36+
/// binary targets it; recorded in the build manifest and matched against the deploy architecture.
37+
let architecture: BuildArchitecture
38+
3539
/// The cross-compile method that selected this backend, retained for error reporting.
3640
let method: CrossCompileMethod
3741

@@ -60,7 +64,7 @@ struct ContainerBuildBackend: BuildBackend {
6064
print("updating \"\(self.baseImage)\" image")
6165
try Utils.execute(
6266
executable: self.toolPath,
63-
arguments: self.cli.pullArguments(image: self.baseImage),
67+
arguments: self.cli.pullArguments(image: self.baseImage, architecture: self.architecture),
6468
logLevel: verboseLogging ? .debug : .output
6569
)
6670
}
@@ -71,6 +75,7 @@ struct ContainerBuildBackend: BuildBackend {
7175
executable: self.toolPath,
7276
arguments: self.cli.runArguments(
7377
baseImage: self.baseImage,
78+
architecture: self.architecture,
7479
workingDirectory: "/workspace",
7580
mounts: ["\(packageDirectory.path()):/workspace"],
7681
env: nil,
@@ -103,6 +108,7 @@ struct ContainerBuildBackend: BuildBackend {
103108
executable: self.toolPath,
104109
arguments: self.cli.runArguments(
105110
baseImage: self.baseImage,
111+
architecture: self.architecture,
106112
workingDirectory: "/workspace/\(slice.joined(separator: "/"))",
107113
mounts: ["\(packageDirectory.path())../..:/workspace"],
108114
env: ["LAMBDA_USE_LOCAL_DEPS": localPath],
@@ -115,6 +121,7 @@ struct ContainerBuildBackend: BuildBackend {
115121
executable: self.toolPath,
116122
arguments: self.cli.runArguments(
117123
baseImage: self.baseImage,
124+
architecture: self.architecture,
118125
workingDirectory: "/workspace",
119126
mounts: ["\(packageDirectory.path()):/workspace"],
120127
env: nil,

Sources/AWSLambdaPluginHelper/lambda-build/BuildBackends/ContainerCLI.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,23 @@ protocol ContainerCLI {
2727
/// The name of the executable to resolve and run (e.g. "docker", "container").
2828
var executableName: String { get }
2929

30-
/// The arguments to pull (update) the given base image.
31-
func pullArguments(image: String) -> [String]
30+
/// The arguments to pull (update) the given base image for a target architecture.
31+
func pullArguments(image: String, architecture: BuildArchitecture) -> [String]
3232

3333
/// The arguments to run a command inside a container created from `baseImage`.
3434
///
3535
/// - Parameters:
3636
/// - baseImage: The container image to run.
37+
/// - architecture: The CPU architecture the container should run as. The build compiles for
38+
/// the container's architecture, so this is what determines the produced binary's
39+
/// architecture and must match what the function is deployed for.
3740
/// - workingDirectory: The working directory inside the container.
3841
/// - mounts: Volume mounts, each in the CLI's `host:container` form.
3942
/// - env: Environment variables to set inside the container, or `nil`.
4043
/// - command: The shell command to execute inside the container.
4144
func runArguments(
4245
baseImage: String,
46+
architecture: BuildArchitecture,
4347
workingDirectory: String,
4448
mounts: [String],
4549
env: [String: String]?,

Sources/AWSLambdaPluginHelper/lambda-build/BuildBackends/DockerCLI.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,27 @@
2121
struct DockerCLI: ContainerCLI {
2222
let executableName = "docker"
2323

24-
func pullArguments(image: String) -> [String] {
25-
["pull", image]
24+
/// The value docker's `--platform` flag expects (`linux/amd64`, `linux/arm64`).
25+
func platform(for architecture: BuildArchitecture) -> String {
26+
switch architecture {
27+
case .x64: return "linux/amd64"
28+
case .arm64: return "linux/arm64"
29+
}
30+
}
31+
32+
func pullArguments(image: String, architecture: BuildArchitecture) -> [String] {
33+
["pull", "--platform", self.platform(for: architecture), image]
2634
}
2735

2836
func runArguments(
2937
baseImage: String,
38+
architecture: BuildArchitecture,
3039
workingDirectory: String,
3140
mounts: [String],
3241
env: [String: String]?,
3342
command: String
3443
) -> [String] {
35-
var args: [String] = ["run", "--rm"]
44+
var args: [String] = ["run", "--platform", self.platform(for: architecture), "--rm"]
3645
for mount in mounts {
3746
args += ["-v", mount]
3847
}
@@ -53,7 +62,7 @@ struct DockerCLI: ContainerCLI {
5362
) -> [String] {
5463
[
5564
"build",
56-
"--platform", architecture.dockerPlatform,
65+
"--platform", self.platform(for: architecture),
5766
"-f", dockerfile,
5867
"-t", tag,
5968
contextDir,

0 commit comments

Comments
 (0)