Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@
^CRAN-SUBMISSION$
^\.roborev$
^tests/testthat/_problems$
^dev$
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export(aws_connect)
export(build_lambda)
export(delete_lambda)
export(deploy_lambda)
export(invoke_lambda)
export(schedule_lambda)
Expand All @@ -13,6 +14,7 @@ importFrom(glue,
single_quote
)
importFrom(paws,
cloudwatchlogs,
ecr,
eventbridge,
iam,
Expand Down
79 changes: 78 additions & 1 deletion R/lambda-deploy.R
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ test_lambda <- function(tag, payload) {
#' )
#'
#' }
#' @importFrom paws ecr iam lambda s3 eventbridge
#' @importFrom paws ecr iam lambda s3 eventbridge cloudwatchlogs
#' @export
deploy_lambda <-
function(tag, policy_arns = NULL, role_arn = NULL, ...) {
Expand Down Expand Up @@ -466,3 +466,80 @@ schedule_lambda <- function(lambda_function, execution_rate) {

logger::log_info("[schedule_lambda] Done.")
}

#' Delete a Lambda function and the AWS resources created for it
#'
#' Removes what `build_lambda()`, `deploy_lambda()` and `schedule_lambda()`
#' created for `tag`: the function's EventBridge schedules, the function, its
#' execution roles, the ECR repository with its images, the function's
#' CloudWatch log group, and the local Docker image. Resources that don't
#' exist are skipped, so it also cleans up after a failed deploy.
#'
#' Only roles named `<tag>--<uuid>`, as `deploy_lambda()` names them, are
#' deleted. A role passed to `deploy_lambda(role_arn = )` is kept. A
#' schedule rule that also targets other resources is kept, without this
#' function as a target.
#'
#' @param tag The tag used with `build_lambda()` and `deploy_lambda()`, which
#' is also the function name.
#' @param keep_ecr_image logical, whether to keep the ECR repository and its
#' images (default: FALSE)
#' @param keep_local_image logical, whether to keep the local Docker image
#' (default: FALSE)
#' @return Invisibly, a list with the names of the deleted resources.
#'
#' @examples
#' \dontrun{
#' delete_lambda("parity1")
#' }
#' @export
delete_lambda <- function(
tag,
keep_ecr_image = FALSE,
keep_local_image = FALSE
) {
checkmate::assert_string(tag, min.chars = 1)
checkmate::assert_flag(keep_ecr_image)
checkmate::assert_flag(keep_local_image)
deleted <- list()

lambda_service <- aws_connect("lambda")
function_arn <- ignore_not_found(
lambda_service$get_function(FunctionName = tag)$Configuration$FunctionArn
)
if (is.null(function_arn)) {
logger::log_info("[delete_lambda] No Lambda function named {tag}.")
} else {
logger::log_info("[delete_lambda] Removing schedules.")
deleted$schedule_rules <- remove_lambda_schedules(function_arn)
logger::log_info("[delete_lambda] Deleting Lambda function {tag}.")
lambda_service$delete_function(FunctionName = tag)
deleted$lambda_function <- tag
}

logger::log_info("[delete_lambda] Deleting execution roles.")
deleted$iam_roles <- delete_lambda_exec_roles(tag)

if (!keep_ecr_image) {
logger::log_info("[delete_lambda] Deleting ECR repository {tag}.")
repo <- ignore_not_found(
aws_connect("ecr")$delete_repository(repositoryName = tag, force = TRUE)
)
deleted$ecr_repository <- if (!is.null(repo)) tag
}

log_group <- glue("/aws/lambda/{tag}")
logger::log_info("[delete_lambda] Deleting log group {log_group}.")
logs <- ignore_not_found(
aws_connect("cloudwatchlogs")$delete_log_group(logGroupName = log_group)
)
deleted$log_group <- if (!is.null(logs)) as.character(log_group)

if (!keep_local_image) {
logger::log_info("[delete_lambda] Deleting local Docker image.")
deleted$local_image <- delete_local_image(tag)
}

logger::log_success("[delete_lambda] Done.")
invisible(deleted)
}
125 changes: 124 additions & 1 deletion R/lambda-util.R
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
has_docker <- function() {
nzchar(Sys.which("docker"))
}

is_docker_installed <- function() {
checkmate::assert_true(nzchar(Sys.which("docker")))
checkmate::assert_true(has_docker())
}

#' check_system_deps
Expand Down Expand Up @@ -703,3 +707,122 @@ add_lambda_to_eventridge <-
)
logger::log_debug("[add_lambda_to_eventridge] Done.")
}

#' Run an AWS call, treating a missing resource as already deleted
#'
#' @param expr the AWS call
#' @return the value of `expr`, or `NULL` if the resource does not exist
#' @noRd
ignore_not_found <- function(expr) {
tryCatch(expr, error = function(e) {
if (!grepl("NotFound|NoSuchEntity", conditionMessage(e))) {
stop(e)
}
NULL
})
}

#' Remove a Lambda function from its EventBridge schedules
#'
#' Removes the function from every rule that targets it, and deletes the rules
#' `schedule_lambda()` created once no other targets remain.
#' @param function_arn the ARN of the Lambda function
#' @return names of the deleted rules
#' @noRd
remove_lambda_schedules <- function(function_arn) {
aws_event <- aws_connect("eventbridge")

rules <- character()
token <- NULL
repeat {
page <- aws_event$list_rule_names_by_target(
TargetArn = function_arn,
NextToken = token
)
rules <- c(rules, unlist(page$RuleNames))
token <- page$NextToken
if (length(token) == 0) break
}

deleted <- character()
for (rule in rules) {
targets <- aws_event$list_targets_by_rule(Rule = rule)$Targets
is_function <- vapply(targets, function(x) x$Arn == function_arn, TRUE)
logger::log_debug("[remove_lambda_schedules] Removing target from {rule}.")
aws_event$remove_targets(
Rule = rule,
Ids = lapply(targets[is_function], function(x) x$Id)
)
if (all(is_function) && startsWith(rule, "schedule_rule_")) {
logger::log_debug("[remove_lambda_schedules] Deleting rule {rule}.")
aws_event$delete_rule(Name = rule)
deleted <- c(deleted, rule)
}
}
deleted
}

#' Delete the execution roles created for a Lambda function
#'
#' Deletes only roles named `<tag>--<uuid>`, as `create_lambda_exec_role()`
#' names them, so a role passed to `deploy_lambda(role_arn = )` is kept.
#' @param tag the tag of the Lambda function
#' @return names of the deleted roles
#' @noRd
delete_lambda_exec_roles <- function(tag) {
iam_service <- aws_connect("iam")

roles <- character()
marker <- NULL
repeat {
page <- iam_service$list_roles(Marker = marker)
roles <- c(roles, vapply(page$Roles, function(x) x$RoleName, ""))
if (!isTRUE(page$IsTruncated)) {
break
}
marker <- page$Marker
}

prefix <- paste0(tag, "--")
suffix <- substring(roles, nchar(prefix) + 1)
roles <- roles[startsWith(roles, prefix) & grepl("^[0-9a-f-]{36}$", suffix)]

for (role in roles) {
logger::log_debug("[delete_lambda_exec_roles] Deleting role {role}.")
attached <- iam_service$list_attached_role_policies(RoleName = role)
for (policy in attached$AttachedPolicies) {
iam_service$detach_role_policy(
RoleName = role,
PolicyArn = policy$PolicyArn
)
}
inline <- iam_service$list_role_policies(RoleName = role)
for (policy_name in unlist(inline$PolicyNames)) {
iam_service$delete_role_policy(RoleName = role, PolicyName = policy_name)
}
iam_service$delete_role(RoleName = role)
}
roles
}

#' Delete the local image built for a Lambda function
#'
#' @param tag the tag of the image
#' @return the name of the deleted image, or `NULL` if there is none
#' @noRd
delete_local_image <- function(tag) {
if (!has_docker()) {
logger::log_info("[delete_local_image] docker is not installed. Skipping.")
return(NULL)
}
tags <- docker(
c("image", "ls", "--format", "{{.Repository}}:{{.Tag}}"),
capture = TRUE
)
if (!any(endsWith(tags, paste0("/", tag, ":latest")))) {
return(NULL)
}
image <- find_local_image(tag)
docker(c("rmi", image), capture = TRUE)
image
}
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ test_lambda(tag = "parity1", payload)
deploy_lambda(tag = "parity1")
```

Lambda stops a function after 3 seconds by default, which an R
container may not meet, especially on its first call. Pass a longer
`Timeout`, in seconds, and other `create_function()` settings through
`...`:

``` r
deploy_lambda(tag = "parity1", Timeout = 30, MemorySize = 512)
```

The function runs under a new IAM role with basic execution permissions.
To let it call other AWS services, attach policies to that role, or use
a role you already have:
Expand Down Expand Up @@ -121,6 +130,31 @@ invoke_lambda(
#> {"parity":"even"}
```

### Run on a schedule

`schedule_lambda()` runs a deployed function at a `rate()` or `cron()`
[schedule
expression](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-rule-schedule.html):

``` r
schedule_lambda("parity1", "rate(5 minutes)")
```

### Clean up

`delete_lambda()` removes what the steps above created: the function
and its schedules, the execution role, the ECR repository with its
images, the function’s log group, and the local Docker image.

``` r
delete_lambda("parity1")

# Keep the images, for example to deploy them again later
delete_lambda("parity1", keep_ecr_image = TRUE, keep_local_image = TRUE)
```

A role you passed with `deploy_lambda(role_arn = )` is never deleted.

## Code of Conduct

Please note that the r2lambda project is released with a [Contributor
Expand Down
Loading
Loading