diff --git a/.Rbuildignore b/.Rbuildignore index 46d890a..b10256c 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -20,3 +20,4 @@ ^CRAN-SUBMISSION$ ^\.roborev$ ^tests/testthat/_problems$ +^dev$ diff --git a/NAMESPACE b/NAMESPACE index 90186e3..fd86a62 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,6 +2,7 @@ export(aws_connect) export(build_lambda) +export(delete_lambda) export(deploy_lambda) export(invoke_lambda) export(schedule_lambda) @@ -13,6 +14,7 @@ importFrom(glue, single_quote ) importFrom(paws, + cloudwatchlogs, ecr, eventbridge, iam, diff --git a/R/lambda-deploy.R b/R/lambda-deploy.R index aab56fa..ac460c8 100644 --- a/R/lambda-deploy.R +++ b/R/lambda-deploy.R @@ -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, ...) { @@ -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 `--`, 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) +} diff --git a/R/lambda-util.R b/R/lambda-util.R index 6470e97..ead7ca5 100644 --- a/R/lambda-util.R +++ b/R/lambda-util.R @@ -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 @@ -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 `--`, 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 +} diff --git a/README.md b/README.md index a0905fd..12f2c39 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/dev/smoke-test.R b/dev/smoke-test.R new file mode 100644 index 0000000..eb0f3ac --- /dev/null +++ b/dev/smoke-test.R @@ -0,0 +1,113 @@ +# Smoke test against a real AWS account. +# +# Builds, tests, deploys, invokes and schedules the parity example, then +# deletes everything with delete_lambda(). It uses the AWS credentials of the +# current session, takes about 10 minutes, and costs a few cents at most. +# Docker must be running. +# +# Run from the package root: +# Rscript dev/smoke-test.R +# ARCH=arm64 Rscript dev/smoke-test.R +# +# delete_lambda() runs even when a step fails. If the script is killed before +# it gets there, run delete_lambda() with the tag printed at the start. + +devtools::load_all() + +architecture <- Sys.getenv("ARCH", "x86_64") +tag <- paste0("r2lambda-smoke-", format(Sys.time(), "%Y%m%d%H%M%S")) +message("Smoke test tag: ", tag) + +check <- function(ok, what) { + if (!isTRUE(ok)) { + stop("FAILED: ", what, call. = FALSE) + } + message("OK: ", what) +} + +wait_until <- function(condition, what, timeout = 300, every = 10) { + deadline <- Sys.time() + timeout + while (!isTRUE(condition())) { + if (Sys.time() > deadline) { + stop("Timed out waiting for ", what, call. = FALSE) + } + Sys.sleep(every) + } + message("OK: ", what) +} + +local({ + on.exit({ + deleted <- delete_lambda(tag) + str(deleted) + lambda_gone <- tryCatch( + { + aws_connect("lambda")$get_function(FunctionName = tag) + FALSE + }, + error = function(e) { + grepl("ResourceNotFoundException", conditionMessage(e)) + } + ) + check(lambda_gone, "function deleted") + check(length(delete_lambda_exec_roles(tag)) == 0, "no roles left") + }) + + build_lambda( + tag = tag, + runtime_function = "parity", + runtime_path = system.file("parity.R", package = "r2lambda"), + architecture = architecture + ) + + local_response <- test_lambda(tag, list(number = 2)) + check( + grepl('"even"', rawToChar(local_response$stdout)), + "local invocation returns even" + ) + + deploy_lambda(tag, Timeout = 30, MemorySize = 512) + lambda_service <- aws_connect("lambda") + wait_until( + function() { + lambda_service$get_function(FunctionName = tag)$Configuration$State == + "Active" + }, + "function is Active" + ) + + response <- invoke_lambda( + function_name = tag, + invocation_type = "RequestResponse", + payload = list(number = 2), + include_logs = TRUE + ) + check( + identical(rawToChar(response$Payload), '{"parity":"even"}'), + "deployed invocation returns even" + ) + + # Scheduled invocations send an EventBridge event, which parity() can't + # take, so they fail. Each attempt still writes a START line to the log. + schedule_lambda(tag, "rate(1 minute)") + logs <- aws_connect("cloudwatchlogs") + started_at <- as.numeric(Sys.time()) * 1000 + wait_until( + function() { + events <- tryCatch( + logs$filter_log_events( + logGroupName = paste0("/aws/lambda/", tag), + startTime = started_at, + filterPattern = "START" + )$events, + error = function(e) list() + ) + length(events) > 0 + }, + "scheduled invocation ran", + timeout = 240, + every = 30 + ) +}) + +message("Smoke test passed.") diff --git a/man/delete_lambda.Rd b/man/delete_lambda.Rd new file mode 100644 index 0000000..d805392 --- /dev/null +++ b/man/delete_lambda.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/lambda-deploy.R +\name{delete_lambda} +\alias{delete_lambda} +\title{Delete a Lambda function and the AWS resources created for it} +\usage{ +delete_lambda(tag, keep_ecr_image = FALSE, keep_local_image = FALSE) +} +\arguments{ +\item{tag}{The tag used with `build_lambda()` and `deploy_lambda()`, which +is also the function name.} + +\item{keep_ecr_image}{logical, whether to keep the ECR repository and its +images (default: FALSE)} + +\item{keep_local_image}{logical, whether to keep the local Docker image +(default: FALSE)} +} +\value{ +Invisibly, a list with the names of the deleted resources. +} +\description{ +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. +} +\details{ +Only roles named `--`, 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. +} +\examples{ +\dontrun{ + delete_lambda("parity1") +} +} diff --git a/tests/testthat/test-delete-lambda.R b/tests/testthat/test-delete-lambda.R new file mode 100644 index 0000000..b2513fd --- /dev/null +++ b/tests/testthat/test-delete-lambda.R @@ -0,0 +1,228 @@ +not_found <- function(code = "ResourceNotFoundException") { + function(...) stop(code, " (HTTP 404). Not found.") +} + +# Fake AWS services that record each call in `calls`, in order. +fake_services <- function(calls, ...) { + record <- function(name, value = list()) { + function(...) { + calls$log <- c(calls$log, name) + value + } + } + defaults <- list( + lambda = list( + get_function = function(FunctionName) { + list(Configuration = list(FunctionArn = "arn:fn")) + }, + delete_function = record("delete_function") + ), + eventbridge = list( + list_rule_names_by_target = function(...) { + list(RuleNames = list("schedule_rule_rate_1_minute_parity")) + }, + list_targets_by_rule = function(Rule) { + list(Targets = list(list(Id = "1", Arn = "arn:fn"))) + }, + remove_targets = record("remove_targets"), + delete_rule = record("delete_rule") + ), + iam = list( + list_roles = function(...) { + list( + Roles = list(list( + RoleName = "parity--0e4d6c1a-5b2f-4c3d-9e8f-1a2b3c4d5e6f" + )), + IsTruncated = FALSE + ) + }, + list_attached_role_policies = function(RoleName) { + list(AttachedPolicies = list(list(PolicyArn = "arn:policy"))) + }, + detach_role_policy = record("detach_role_policy"), + list_role_policies = function(RoleName) list(PolicyNames = list()), + delete_role_policy = record("delete_role_policy"), + delete_role = record("delete_role") + ), + ecr = list(delete_repository = record("delete_repository")), + cloudwatchlogs = list(delete_log_group = record("delete_log_group")) + ) + services <- utils::modifyList(defaults, list(...)) + function(service) services[[service]] +} + +local_fake_docker <- function( + calls, + images = "reg/parity:latest", + env = parent.frame() +) { + local_mocked_bindings( + has_docker = function() TRUE, + docker = function(args, ...) { + if (args[[1]] == "rmi") { + calls$log <- c(calls$log, "rmi") + return(character()) + } + images + }, + .env = env + ) +} + +test_that("delete_lambda deletes every resource, schedules first", { + calls <- new.env() + local_mocked_bindings(aws_connect = fake_services(calls)) + local_fake_docker(calls) + + deleted <- delete_lambda("parity") + + expect_equal( + calls$log, + c( + "remove_targets", + "delete_rule", + "delete_function", + "detach_role_policy", + "delete_role", + "delete_repository", + "delete_log_group", + "rmi" + ) + ) + expect_equal(deleted$lambda_function, "parity") + expect_equal(deleted$local_image, "reg/parity:latest") + expect_equal(deleted$log_group, "/aws/lambda/parity") +}) + +test_that("delete_lambda keeps the images when asked", { + calls <- new.env() + local_mocked_bindings(aws_connect = fake_services(calls)) + local_fake_docker(calls) + + delete_lambda("parity", keep_ecr_image = TRUE, keep_local_image = TRUE) + + expect_false("delete_repository" %in% calls$log) + expect_false("rmi" %in% calls$log) +}) + +test_that("delete_lambda skips resources that don't exist", { + calls <- new.env() + local_mocked_bindings( + aws_connect = fake_services( + calls, + lambda = list(get_function = not_found()), + ecr = list(delete_repository = not_found("RepositoryNotFoundException")), + cloudwatchlogs = list(delete_log_group = not_found()) + ) + ) + + deleted <- delete_lambda("parity", keep_local_image = TRUE) + + expect_equal(calls$log, c("detach_role_policy", "delete_role")) + expect_null(deleted$lambda_function) + expect_null(deleted$ecr_repository) + expect_null(deleted$log_group) +}) + +test_that("delete_lambda passes other AWS errors through", { + calls <- new.env() + local_mocked_bindings( + aws_connect = fake_services( + calls, + lambda = list(get_function = function(...) stop("AccessDeniedException")) + ) + ) + expect_error(delete_lambda("parity"), "AccessDeniedException") +}) + +test_that("delete_lambda_exec_roles deletes only roles deploy_lambda created", { + uuid <- "0e4d6c1a-5b2f-4c3d-9e8f-1a2b3c4d5e6f" + pages <- list( + list( + Roles = list( + list(RoleName = paste0("parity--", uuid)), + list(RoleName = "parity--my-own-role") + ), + IsTruncated = TRUE, + Marker = "next" + ), + list( + Roles = list( + list(RoleName = paste0("parity.x--", uuid)), + list(RoleName = paste0("other--", uuid)), + list(RoleName = paste0("parity--", sub("^0", "1", uuid))) + ), + IsTruncated = FALSE + ) + ) + deleted_roles <- character() + local_mocked_bindings( + aws_connect = function(service) { + list( + list_roles = function(Marker = NULL) { + if (is.null(Marker)) pages[[1]] else pages[[2]] + }, + list_attached_role_policies = function(RoleName) list(), + list_role_policies = function(RoleName) { + list(PolicyNames = list("inline")) + }, + delete_role_policy = function(RoleName, PolicyName) { + expect_false(RoleName %in% deleted_roles) + }, + delete_role = function(RoleName) { + deleted_roles <<- c(deleted_roles, RoleName) + } + ) + } + ) + + expected <- c( + paste0("parity--", uuid), + paste0("parity--", sub("^0", "1", uuid)) + ) + expect_equal(delete_lambda_exec_roles("parity"), expected) + expect_equal(deleted_roles, expected) +}) + +test_that("remove_lambda_schedules keeps rules with other targets", { + removed <- list() + deleted_rules <- character() + local_mocked_bindings( + aws_connect = function(service) { + list( + list_rule_names_by_target = function(TargetArn, NextToken = NULL) { + if (is.null(NextToken)) { + list(RuleNames = list("schedule_rule_shared"), NextToken = "next") + } else { + list(RuleNames = list("my_rule"), NextToken = character()) + } + }, + list_targets_by_rule = function(Rule) { + if (Rule == "schedule_rule_shared") { + list( + Targets = list( + list(Id = "1", Arn = "arn:fn"), + list(Id = "2", Arn = "arn:other") + ) + ) + } else { + list(Targets = list(list(Id = "a", Arn = "arn:fn"))) + } + }, + remove_targets = function(Rule, Ids) { + removed[[Rule]] <<- Ids + }, + delete_rule = function(Name) { + deleted_rules <<- c(deleted_rules, Name) + } + ) + } + ) + + expect_equal(remove_lambda_schedules("arn:fn"), character()) + expect_equal( + removed, + list(schedule_rule_shared = list("1"), my_rule = list("a")) + ) + expect_equal(deleted_rules, character()) +}) diff --git a/vignettes/lambda-s3.qmd b/vignettes/lambda-s3.qmd index 10a00bd..9e35246 100644 --- a/vignettes/lambda-s3.qmd +++ b/vignettes/lambda-s3.qmd @@ -331,5 +331,21 @@ r2lambda::schedule_lambda( Next Wednesday morning, we should have two objects, with keys matching the two most-recent Tuesdays. +## Clean up + +`delete_lambda` removes the schedules, the function, its execution role, the ECR +repository with the image, the function's log group, and the local Docker image. +It doesn't touch the S3 bucket, which we delete ourselves once it's empty: + +```{r} +r2lambda::delete_lambda("tidytuesday_lambda_s3") + +objs <- s3_service$list_objects(Bucket = "tidytuesday-datasets") +for (obj in objs$Contents) { + s3_service$delete_object(Bucket = "tidytuesday-datasets", Key = obj$Key) +} +s3_service$delete_bucket(Bucket = "tidytuesday-datasets") +``` + # Lambda + S3 integration: Triggering Lambda when on new file in S3 bucket ## TODO diff --git a/vignettes/schedule-lambda.qmd b/vignettes/schedule-lambda.qmd index 8e21ffa..c7ff176 100644 --- a/vignettes/schedule-lambda.qmd +++ b/vignettes/schedule-lambda.qmd @@ -165,35 +165,11 @@ data.frame(Current_time_lambda = current_time_messages) ## Clean up -We don't want to let a this trivial lambda fire every minute, it will still incur -some cost. So its wise to delete the event schedule rule and maybe even the lambda -function it self. - -To remove the event rule, we first need to remove associated targets, and then remove -the rule. - -```{r} -# connect to the EventBridge service -events_service <- r2lambda::aws_connect("eventbridge") -# find the names of all rules we need -schedule_rules <- events_service$list_rules()[[1]] |> sapply("[[", 1) - -# find the targets associated with the rule we want to remove -rule_to_remove <- schedule_rules[[1]] - -target_arn_to_remove <- events_service$list_targets_by_rule(Rule = rule_to_remove)$Targets[[1]]$Id -events_service$remove_targets(Rule = rule_to_remove, Ids = target_arn_to_remove) -events_service$delete_rule(Name = rule_to_remove) - -events_service$list_rules()[[1]] |> sapply("[[", 1) - -``` - -Finally, to remove the Lambda: +We don't want to let this trivial lambda fire every minute, as it still incurs +some cost. `delete_lambda` removes the schedule, the function, its execution +role, the ECR repository with the image, the function's log group, and the local +Docker image: ```{r} -lambda_service <- r2lambda::aws_connect("lambda") -lambda_service$list_functions()$Functions |> sapply("[[","FunctionName") -lambda_service$delete_function(FunctionName = "parity1") +r2lambda::delete_lambda("current_time") ``` - diff --git a/vignettes/tt-lambda.qmd b/vignettes/tt-lambda.qmd index 79953d5..8151961 100644 --- a/vignettes/tt-lambda.qmd +++ b/vignettes/tt-lambda.qmd @@ -189,3 +189,12 @@ tidytuesday_dataset <- response$Payload |> tidytuesday_dataset[[1]][1:5, 1:5] ``` +### 5. Clean up + +When we no longer need the function, `delete_lambda` removes it together with +its execution role, the ECR repository with the image, the function's log group, +and the local Docker image: + +```{r} +r2lambda::delete_lambda("tidytuesday3") +```