Skip to content

Commit e4bb09a

Browse files
authored
Fix: lambda-deploy update path now verifies the execution role exists (#676)
### Problem The `lambda-deploy` plugin's **update** path never verifies that the function's execution role still exists. Lambda validates the execution role lazily — only at *invoke* time, not when a function is created or updated — so a redeploy against a function whose role was deleted (for example by a previous `lambda-deploy --delete`, which removes both the function and its role) succeeds silently. The function is left pointing at a non-existent role and fails at invoke time with: ``` An error occurred (AccessDeniedException) when calling the Invoke operation: The role defined for the function cannot be assumed by Lambda. ``` The role was only ever resolved/created on the **create** path, so once the auto-managed role went missing there was no way to recover via redeploy, and no diagnostic pointing at the cause. ### Fix - On the **update** path, verify the function's configured execution role still exists in IAM **before** updating the code. If it is gone, stop the deployment with a clear error that names the function and role and suggests the recovery action (delete + redeploy so the role is recreated). - The existence check now reuses the `GetFunction` response already fetched to determine create-vs-update, so the verification adds **no extra `GetFunction` call** — `functionExists(...) -> Bool` became `existingFunctionConfiguration(...) -> FunctionConfiguration?` and the configuration is carried downstream. - New helpers: `roleName(fromARN:)` (extracts the role name from an execution-role ARN, handling nested paths and partitions) and `verifyExecutionRoleExists(...)` (maps IAM `NoSuchEntity` to the actionable `executionRoleMissing` error). ### Tests Added a property-test suite covering `roleName(fromARN:)` — valid ARNs including service-role/nested paths and the `aws-us-gov` partition, plus malformed/empty ARNs that must return `nil`. All `AWSLambdaPluginHelperTests` pass (75 tests); the `AWSLambdaPluginHelper` target builds clean on Swift 6.4.
1 parent ecc20f0 commit e4bb09a

2 files changed

Lines changed: 126 additions & 11 deletions

File tree

Sources/AWSLambdaPluginHelper/lambda-deploy/Deployer.swift

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,26 @@ struct Deployer {
5757
}
5858
}
5959

60-
/// Checks whether a Lambda function with the given name already exists.
61-
func functionExists(
60+
/// Looks up an existing Lambda function's configuration.
61+
///
62+
/// Returns the function's current configuration, or `nil` if no function with the given name
63+
/// exists. The configuration is carried downstream so callers (e.g. the update path) can reuse
64+
/// it — for example to validate the execution role — without issuing a second `GetFunction` call.
65+
func existingFunctionConfiguration(
6266
_ functionName: String,
6367
using lambdaClient: LambdaClient
64-
) async throws -> Bool {
68+
) async throws -> FunctionConfiguration? {
6569
do {
66-
_ = try await lambdaClient.getFunction(
70+
return try await lambdaClient.getFunction(
6771
GetFunctionRequest(functionName: functionName)
68-
)
69-
return true
72+
).configuration
7073
} catch {
7174
// If the error indicates the resource was not found, the function doesn't exist
7275
let errorDescription = "\(error)"
7376
if errorDescription.contains("ResourceNotFoundException")
7477
|| errorDescription.contains("Function not found")
7578
{
76-
return false
79+
return nil
7780
}
7881
throw DeployerErrors.awsAPIError(
7982
service: "Lambda",
@@ -656,13 +659,17 @@ struct Deployer {
656659
print("Deploying function '\(functionName)' to \(region.rawValue)...")
657660
}
658661

659-
// Check if function already exists
662+
// Check if function already exists. The configuration is reused downstream (e.g. the
663+
// update path validates the execution role from it) to avoid a second GetFunction call.
660664
print("Checking if function '\(functionName)' exists...")
661-
let exists = try await functionExists(functionName, using: lambdaClient)
662-
let action = try determineDeploymentAction(functionExists: exists, delete: configuration.delete)
665+
let existingConfiguration = try await existingFunctionConfiguration(functionName, using: lambdaClient)
666+
let action = try determineDeploymentAction(
667+
functionExists: existingConfiguration != nil,
668+
delete: configuration.delete
669+
)
663670

664671
if configuration.verboseLogging {
665-
print("[verbose] Function '\(functionName)' exists: \(exists), action: \(action)")
672+
print("[verbose] Function '\(functionName)' exists: \(existingConfiguration != nil), action: \(action)")
666673
}
667674

668675
switch action {
@@ -780,6 +787,17 @@ struct Deployer {
780787
}
781788
functionArn = response.functionArn
782789
} else {
790+
// Verify the function's execution role still exists before updating.
791+
// Lambda validates the role lazily (at invoke time), so an update against a
792+
// function whose role was deleted would succeed here but fail at invoke.
793+
// Reuse the configuration fetched during the existence check above.
794+
try await verifyExecutionRoleExists(
795+
roleARN: existingConfiguration?.role,
796+
functionName: functionName,
797+
using: iamClient,
798+
verbose: configuration.verboseLogging
799+
)
800+
783801
// Update the function code
784802
print("Updating Lambda function '\(functionName)'...")
785803
let response: UpdateFunctionCodeResponse
@@ -908,6 +926,62 @@ struct Deployer {
908926
"swift-lambda-\(functionName)-role"
909927
}
910928

929+
/// Extracts the role name from an IAM role ARN.
930+
/// e.g. `arn:aws:iam::123456789012:role/my-role` -> `my-role`,
931+
/// `arn:aws:iam::123456789012:role/path/my-role` -> `my-role`.
932+
/// Returns `nil` if the ARN does not contain a role name.
933+
static func roleName(fromARN arn: String) -> String? {
934+
guard let slashIndex = arn.lastIndex(of: "/") else { return nil }
935+
let name = arn[arn.index(after: slashIndex)...]
936+
return name.isEmpty ? nil : String(name)
937+
}
938+
939+
/// Verifies that the IAM role referenced by a function's execution role ARN still exists.
940+
///
941+
/// Lambda only validates that an execution role is assumable lazily, at invoke time, not when
942+
/// the function is created or updated. If the role was deleted (for example by a previous
943+
/// `--delete` run), an update would silently succeed but the function would fail to invoke with
944+
/// `The role defined for the function cannot be assumed by Lambda`. This check surfaces the
945+
/// problem at deploy time instead.
946+
///
947+
/// - Parameters:
948+
/// - roleARN: The execution role ARN configured on the function, if any.
949+
/// - functionName: The function name, used for error reporting.
950+
/// - iamClient: The IAM client to use for the lookup.
951+
/// - verbose: Whether to emit verbose progress output.
952+
/// - Throws: `DeployerErrors.executionRoleMissing` if the role does not exist.
953+
func verifyExecutionRoleExists(
954+
roleARN: String?,
955+
functionName: String,
956+
using iamClient: IAMClient,
957+
verbose: Bool
958+
) async throws {
959+
guard let roleARN, let roleName = Self.roleName(fromARN: roleARN) else {
960+
// No role ARN to verify (or an unparsable ARN) — nothing to check.
961+
return
962+
}
963+
964+
if verbose {
965+
print("[verbose] Verifying execution role '\(roleName)' still exists...")
966+
}
967+
968+
do {
969+
_ = try await iamClient.getRole(IAMGetRoleRequest(roleName: roleName))
970+
if verbose {
971+
print("[verbose] Execution role '\(roleName)' exists")
972+
}
973+
} catch {
974+
if "\(error)".contains("NoSuchEntity") {
975+
throw DeployerErrors.executionRoleMissing(functionName: functionName, role: roleARN)
976+
}
977+
throw DeployerErrors.awsAPIError(
978+
service: "IAM",
979+
operation: "GetRole",
980+
message: "failed to verify execution role '\(roleName)': \(error)"
981+
)
982+
}
983+
}
984+
911985
/// The trust policy document that allows Lambda to assume the role.
912986
private static let lambdaTrustPolicy = """
913987
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
@@ -1296,6 +1370,7 @@ enum DeployerErrors: Error, CustomStringConvertible {
12961370
case archiveNotFound(URL)
12971371
case functionURLCreationFailed(String)
12981372
case iamRoleCreationFailed(String)
1373+
case executionRoleMissing(functionName: String, role: String)
12991374
case missingProduct
13001375

13011376
var description: String {
@@ -1312,6 +1387,16 @@ enum DeployerErrors: Error, CustomStringConvertible {
13121387
return "failed to create Function URL: \(message)"
13131388
case .iamRoleCreationFailed(let message):
13141389
return "failed to create IAM role: \(message)"
1390+
case .executionRoleMissing(let functionName, let role):
1391+
return """
1392+
the execution role configured for function '\(functionName)' no longer exists in IAM:
1393+
\(role)
1394+
Lambda cannot assume a role that does not exist, so the function would fail to invoke.
1395+
1396+
Suggested action: delete the function and redeploy it so the role is recreated:
1397+
swift package --allow-network-connections all:443 lambda-deploy --delete
1398+
swift package --allow-network-connections all:443 lambda-deploy
1399+
"""
13151400
case .missingProduct:
13161401
return "no product specified. Use --products or define an executable target in Package.swift."
13171402
}

Tests/AWSLambdaPluginHelperTests/PropertyTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,3 +627,33 @@ struct UnsupportedCrossCompileMethodsPropertyTests {
627627
}
628628
}
629629
}
630+
631+
@Suite("Property 10: Execution role name extraction from ARN")
632+
struct ExecutionRoleARNParsingTests {
633+
634+
static let validARNs: [(arn: String, expected: String)] = [
635+
("arn:aws:iam::123456789012:role/swift-lambda-MyLambda-role", "swift-lambda-MyLambda-role"),
636+
("arn:aws:iam::000000000000:role/basic", "basic"),
637+
("arn:aws:iam::123456789012:role/service-role/my-service-role", "my-service-role"),
638+
("arn:aws-us-gov:iam::123456789012:role/gov-role", "gov-role"),
639+
("arn:aws:iam::123456789012:role/path/to/deeply/nested-role", "nested-role"),
640+
]
641+
642+
@available(LambdaSwift 2.0, *)
643+
@Test("Role name is the last path component of the ARN", arguments: validARNs)
644+
func extractsRoleName(arn: String, expected: String) {
645+
#expect(Deployer.roleName(fromARN: arn) == expected)
646+
}
647+
648+
static let invalidARNs: [String] = [
649+
"",
650+
"not-an-arn",
651+
"arn:aws:iam::123456789012:role/",
652+
]
653+
654+
@available(LambdaSwift 2.0, *)
655+
@Test("ARNs without a role name return nil", arguments: invalidARNs)
656+
func returnsNilForInvalidARN(arn: String) {
657+
#expect(Deployer.roleName(fromARN: arn) == nil)
658+
}
659+
}

0 commit comments

Comments
 (0)