Defines functions do_ip do_remove_forward do_remove_api do_forward do_deploy_api do_configure_https install_new_r install_nginx install_firewall install_api droplet_capture install_plumber do_install_plumber do_provision

Documented in do_configure_https do_deploy_api do_forward do_install_plumber do_ip do_provision do_remove_api do_remove_forward

# can't really test these.
# nocov start

#' Provision a DigitalOcean plumber server
#' Create (if required), install the necessary prerequisites, and
#' deploy a sample plumber application on a DigitalOcean virtual machine.
#' You may sign up for a Digital Ocean account
#' [here](https://www.digitalocean.com/?refcode=add0b50f54c4&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=CopyPaste).
#' You should configure an account ssh key with [analogsea::key_create()] prior to using this method.
#' This command is idempotent, so feel free to run it on a single server multiple times.
#' @param droplet The DigitalOcean droplet that you want to provision
#' (see [analogsea::droplet()]). If empty, a new DigitalOcean server will be created.
#' @param unstable If `FALSE`, will install plumber from CRAN. If `TRUE`, will
#' install the unstable version of plumber from GitHub.
#' @param example If `TRUE`, will deploy an example API named `hello`
#' to the server on port 8000.
#' @param ... Arguments passed into the [analogsea::droplet_create()] function.
#' @details Provisions a Ubuntu 20.04-x64 droplet with the following customizations:
#'  - A recent version of R installed
#'  - plumber installed globally in the system library
#'  - An example plumber API deployed at `/var/plumber`
#'  - A systemd definition for the above plumber API which will ensure that the plumber
#'    API is started on machine boot and respawned if the R process ever crashes. On the
#'    server you can use commands like `systemctl restart plumber` to manage your API, or
#'    `journalctl -u plumber` to see the logs associated with your plumber process.
#'  - The `nginx`` web server installed to route web traffic from port 80 (HTTP) to your plumber
#'    process.
#'  - `ufw` installed as a firewall to restrict access on the server. By default it only
#'    allows incoming traffic on port 22 (SSH) and port 80 (HTTP).
#'  - A 4GB swap file is created to ensure that machines with little RAM (the default) are
#'    able to get through the necessary R package compilations.
#' @note Please see \url{https://github.com/sckott/analogsea/issues/205} in case
#' of an error by default `do_provision` and an error of
#' `"Error: Size is not available in this region."`.
#' @return The DigitalOcean droplet
#' @export
#' @examples \dontrun{
#'   auth = try(analogsea::do_oauth())
#'   if (!inherits(auth, "try-error") &&
#'       inherits(auth, "request")) {
#'     analogsea::droplets()
#'     droplet = do_provision(region = "sfo3", example = FALSE)
#'     analogsea::droplets()
#'     analogsea::install_r_package(droplet, c("readr", "remotes"))
#'     do_deploy_api(droplet, "hello",
#'                   system.file("plumber", "10-welcome", package = "plumber"),
#'                   port=8000, forward=TRUE)
#'     if (interactive()) {
#'         utils::browseURL(do_ip(droplet, "/hello"))
#'     }
#'     analogsea::droplet_delete(droplet)
#'   }
#' }
do_provision <- function(droplet, unstable=FALSE, example=TRUE, ...){

  if (missing(droplet) || is.null(droplet)){

    # No droplet provided; create a new server
    message("Provisioning a new server for which you will get a bill from DigitalOcean.")

    # Check if DO has ssh keys configured
    if (!length(analogsea::keys())) {
      stop("Please add an ssh key to your Digital Ocean account before using this method. See `analogsea::key_create` method.")

    createArgs <- list(...)
    createArgs$tags <- c(createArgs$tags, "plumber")
    createArgs$image <- "ubuntu-20-04-x64"

    droplet <- do.call(analogsea::droplet_create, createArgs)

    # Wait for the droplet to come online

    # I often still get a closed port after droplet_wait returns. Buffer for just a bit

    # Refresh the droplet; sometimes the original one doesn't yet have a network interface.
    droplet <- analogsea::droplet(id=droplet$id)

  # Provision
  lines <- droplet_capture(droplet, 'swapon | grep "/swapfile" | wc -l')
  if (lines != "1"){

  do_install_plumber(droplet, unstable)

  if (example){
    do_deploy_api(droplet, "hello", system.file("plumber", "10-welcome", package = "plumber"), port=8000, forward=TRUE)


#' @export
#' @rdname do_provision
do_install_plumber = function(droplet, unstable, ...) {
  install_new_r(droplet, ...)
  install_plumber(droplet, unstable, ...)
  install_api(droplet, ...)
  install_nginx(droplet, ...)
  install_firewall(droplet, ...)

install_plumber <- function(droplet, unstable, ...){

  analogsea::debian_apt_get_install(droplet, "libssl-dev", "make", "libsodium-dev", "libcurl4-openssl-dev", ...)

  if (unstable){
    analogsea::install_r_package(droplet, "remotes", repo = "https://packagemanager.rstudio.com/cran/__linux__/focal/latest", ...)
    analogsea::droplet_ssh(droplet, "Rscript -e \"remotes::install_github('rstudio/plumber')\"", ...)
  } else {
    analogsea::install_r_package(droplet, "plumber", repo = "https://packagemanager.rstudio.com/cran/__linux__/focal/latest", ...)


#' Captures the output from running some command via SSH
#' @noRd
droplet_capture <- function(droplet, command, ...){
  tf <- tempdir()
  randName <- paste(sample(c(letters, LETTERS), size=10, replace=TRUE), collapse="")
  tff <- file.path(tf, randName)
    if (file.exists(tff)) {
  analogsea::droplet_ssh(droplet, paste0(command, " > /tmp/", randName), ...)
  analogsea::droplet_download(droplet, paste0("/tmp/", randName), tf, ...)
  analogsea::droplet_ssh(droplet, paste0("rm /tmp/", randName), ...)
  lin <- readLines(tff)

install_api <- function(droplet, ...){
  analogsea::droplet_ssh(droplet, "mkdir -p /var/plumber", ...)
  example_plumber_file <- system.file("plumber", "10-welcome", "plumber.R", package="plumber")
  if (nchar(example_plumber_file) < 1) {
    stop("Could not find example 10-welcome plumber file", call. = FALSE)
    local = example_plumber_file,
    remote = "/var/plumber/",

install_firewall <- function(droplet, ...){
  analogsea::droplet_ssh(droplet, "ufw allow http", ...)
  analogsea::droplet_ssh(droplet, "ufw allow ssh", ...)
  analogsea::droplet_ssh(droplet, "ufw -f enable", ...)

install_nginx <- function(droplet, ...){
  analogsea::debian_apt_get_install(droplet, "nginx", ...)
  analogsea::droplet_ssh(droplet, "rm -f /etc/nginx/sites-enabled/default", ...) # Disable the default site
  analogsea::droplet_ssh(droplet, "mkdir -p /var/certbot", ...)
  analogsea::droplet_ssh(droplet, "mkdir -p /etc/nginx/sites-available/plumber-apis/", ...)
  analogsea::droplet_upload(droplet, local=system.file("server", "nginx.conf", package="plumberDeploy"),
                            remote="/etc/nginx/sites-available/plumber", ...)
  analogsea::droplet_ssh(droplet, "ln -sf /etc/nginx/sites-available/plumber /etc/nginx/sites-enabled/", ...)
  analogsea::droplet_ssh(droplet, "systemctl reload nginx", ...)

install_new_r <- function(droplet, ...){
  analogsea::droplet_ssh(droplet, "sudo echo 'DEBIAN_FRONTEND=noninteractive' >> /etc/environment", ...)
  analogsea::debian_apt_get_install(droplet, c("dirmngr", "gnupg","apt-transport-https", "ca-certificates", "software-properties-common"),
  analogsea::droplet_ssh(droplet, "apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E298A3A825C0D65DFD57CBB651716619E084DAB9", ...)
  analogsea::droplet_ssh(droplet, "add-apt-repository 'deb https://cloud.r-project.org/bin/linux/ubuntu focal-cran40/'", ...)
  analogsea::droplet_upload(droplet, local=system.file("server", "apt.conf.d", package="plumberDeploy"),
                            remote = "/etc/apt", ...)
  analogsea::debian_apt_get_update(droplet, ...)
  analogsea::debian_install_r(droplet, ...)

#' Add HTTPS to a plumber Droplet
#' Adds TLS/SSL (HTTPS) to a droplet created using [do_provision()].
#' In order to get a TLS/SSL certificate, you need to point a domain name to the
#' IP address associated with your droplet. If you don't already have a domain
#' name, you can register one [here](https://www.name.com/). Point a (sub)domain
#' to the IP address associated with your plumber droplet before calling this
#' function. These changes may take a few minutes or hours to propagate around
#' the Internet, but once complete you can then execute this function with the
#' given domain to be granted a TLS/SSL certificate for that domain.
#' @details Obtains a free TLS/SSL certificate from
#'   [letsencrypt](https://letsencrypt.org/) and installs it in nginx. It also
#'   configures nginx to route all unencrypted HTTP traffic (port 80) to HTTPS.
#'   Your TLS certificate will be automatically renewed and deployed. It also
#'   opens port 443 in the firewall to allow incoming HTTPS traffic.
#'   Historically, HTTPS certificates required payment in advance. If you
#'   appreciate this service, consider [donating to the letsencrypt
#'   project](https://letsencrypt.org/donate/).
#' @param droplet The droplet on which to act. See [analogsea::droplet()].
#' @param domain The domain name associated with this instance. Used to obtain a
#'   TLS/SSL certificate.
#' @param email Your email address; given only to letsencrypt when requesting a
#'   certificate to enable them to contact you about issues with renewal or
#'   security.
#' @param termsOfService Set to `TRUE` to agree to the letsencrypt subscriber
#'   agreement. At the time of writing, the current version is available
#'   [here](https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf).
#'   Must be set to true to obtain a certificate through letsencrypt.
#' @param force If `FALSE`, will abort if it believes that the given domain name
#'   is not yet pointing at the appropriate IP address for this droplet. If
#'   `TRUE`, will ignore this check and attempt to proceed regardless.
#' @param ... additional arguments to pass to [analogsea::droplet_ssh()]
#' @return The DigitalOcean droplet
#' @export
do_configure_https <- function(droplet, domain, email,
                               termsOfService=FALSE, force=FALSE,

  # This could be done locally, but I don't have a good way of testing cross-platform currently.
  # I can't figure out how to capture the output of the system() call inside
  # of droplet_ssh, so just write to and download a file :\
  if (!force){
    nslookup <- tempfile()

    nsout <- droplet_capture(droplet, paste0("nslookup ", domain), ...)

    ips <- nsout[grepl("^Address: ", nsout)]
    ip <- gsub("^Address: (.*)$", "\\1", ips)

    # It turns out that the floating IP is not data that we have about the droplet
    # Also, if the floating IP was assigned after we created the droplet object that was
    # passed in, then we might not have that information available anyways.
    # It turns out that we can use the 'Droplet Metadata' system to query for this info
    # from the droplet to get a real-time response.
    metadata <- droplet_capture(droplet, "curl", ...)

    parsed <- jsonlite::parse_json(metadata, simplifyVector = TRUE)
    floating <- unlist(lapply(parsed$floating_ip, function(ipv){ ipv$ip_address }))
    ephemeral <- unlist(parsed$interfaces$public)["ipv4.ip_address"]

    if (ip %in% ephemeral) {
      warning("You should consider using a Floating IP address on your droplet for DNS. Currently ",
              "you're using the ephemeral IP address of your droplet for DNS which is dangerous; ",
              "as soon as you terminate your droplet your DNS records will be pointing to an IP ",
              "address you no longer control. A floating IP will give you the opportunity to ",
              "create a new droplet and reassign the floating IP used with DNS later.")
    } else if (! ip %in% floating) {
      print(list(ip=ip, floatingIPs = unname(floating), ephemeralIPs = unname(ephemeral)))
        "It doesn't appear that the domain name '", domain, "' is pointed to an IP address associated with this droplet. ",
        "This could be due to a DNS misconfiguration or because the changes just haven't propagated through the Internet yet. ",
        "If you believe this is an error, you can override this check by setting force=TRUE.")
    message("Confirmed that '", domain, "' references one of the available IP addresses.")

    stop("You must provide a valid domain name which points to this server in order to get an SSL certificate.")
  if (missing(email)){
      "You must provide an email to letsencrypt -- the provider of your SSL certificate -- for 'urgent renewal and security notices'.")
  if (!termsOfService){
    stop("You must agree to the letsencrypt terms of service before running this function")

  # Trim off any protocol prefix if one exists
  domain <- sub("^https?://", "", domain)
  # Trim off any trailing slash if one exists.
  domain <- sub("/$", "", domain)

  # Prepare the nginx conf file.
  conf <- readLines(system.file("server", "nginx-ssl.conf", package="plumberDeploy"))
  conf <- gsub("\\$DOMAIN\\$", domain, conf)

  conffile <- tempfile()
  writeLines(conf, conffile)

  analogsea::droplet_ssh(droplet, "add-apt-repository ppa:certbot/certbot", ...)
  analogsea::debian_apt_get_update(droplet, ...)
  analogsea::debian_apt_get_install(droplet, "certbot", ...)
  analogsea::droplet_ssh(droplet, "ufw allow https", ...)
      paste0("certbot certonly --webroot -w ",
             "/var/certbot/ -n -d %s --email %s ",
             "--agree-tos --renew-hook ",
             "'/bin/systemctl reload nginx'"),
      domain, email), ...
  analogsea::droplet_upload(droplet, conffile, "/etc/nginx/sites-available/plumber", ...)
  analogsea::droplet_ssh(droplet, "systemctl reload nginx", ...)

  # TODO: add this as a catch()


#' Deploy or Update an API
#' Deploys an API from your local machine to make it available on the remote
#' plumber server.
#' @param droplet The droplet on which to act. It's expected that this droplet
#'   was provisioned using [do_provision()].  See [analogsea::droplet()] to
#'   obtain a reference to a running droplet.
#' @param path The remote path/name of the application
#' @param localPath The local path to the API that you want to deploy. The
#'   entire directory referenced will be deployed, and the `plumber.R` file
#'   inside of that directory will be used as the root plumber file. The
#'   directory MUST contain a `plumber.R` file.
#' @param port The internal port on which this service should run. This will not
#'   be user visible, but must be unique and point to a port that is available
#'   on your server. If unsure, try a number around `8000`.
#' @param forward If `TRUE`, will setup requests targeting the root URL on the
#'   server to point to this application. See the [do_forward()] function for
#'   more details.
#' @param docs If `TRUE`, will enable the documentation interface for the remotely
#'   deployed API. By default, the interface is disabled.
#' @param preflight R commands to run after \code{plumb()}ing the `plumber.R` file,
#'   but before `run()`ing the plumber service. This is an opportunity to e.g.
#'   add new filters. If you need to specify multiple commands, they should be
#'   semi-colon-delimited.
#' @param ... additional arguments to pass to [analogsea::droplet_ssh()] or
#'   [analogsea::droplet_upload()], such as `keyfile`.
#'   Cannot contain `remote`, `local` as named arguments.
#' @param overwrite if an application is already running for this `path` name,
#'   and `overwrite = TRUE`, then `do_remove_api` will be run.
#' @return The DigitalOcean droplet, but called for side effects
#' @export
do_deploy_api <- function(droplet, path, localPath, port, forward=FALSE,
                          docs=FALSE, preflight, overwrite = FALSE, ...){
  args = list(...)
  if ("swagger" %in% names(args)) {
                              "do_deploy_api(swagger = )",
                              "do_deploy_api(docs = )")

  # Trim off any leading slashes
  path <- sub("^/+", "", path)
  # Trim off any trailing slashes if any exist.
  path <- sub("/+$", "", path)

  if (grepl("/", path)){
    stop("Can't deploy to nested paths. '", path, "' should not have a / in it.")
  if (grepl(" ", path)){
    stop("Can't deploy to paths with whitespace. '", path, "' should not have a whitespace in it.")

  # TODO: check local path for plumber.R file.
  apiPath <- file.path(localPath, "plumber.R")
  if (!file.exists(apiPath)){
    stop("Your local API must contain a `plumber.R` file. ", apiPath, " does not exist")

  ### UPLOAD the API ###
  remoteTmp <- paste0("/tmp/",
                      paste0(sample(LETTERS, 10, replace=TRUE), collapse=""))
  dirName <- gsub("^\\.?$", "*", basename(localPath))
  dirName <- gsub(" ", "\\\\ ", dirName)

  plumber_path = paste0("/var/plumber/", path)

  # removing the path in case it already exists (may want to error here)
  # analogsea::droplet_ssh(droplet, paste0("rm -rf ", plumber_path), ...)
  if (overwrite) {
    output = try({
      do_remove_api(droplet, path = path, delete = TRUE, ...)
    }, silent = TRUE)
    if (inherits(output, "try-error")) {
      msg = paste0("Tried to remove ", path, " application had issues")

  cmd = paste0("if [ -d ", plumber_path, " ]; then echo 'TRUE'; else echo 'FALSE'; fi")
  check_path =
      analogsea::droplet_ssh(droplet, cmd, ...)
    }, type = "output")
  check_path = check_path[ check_path %in% c("TRUE", "FALSE")]
  check_path = as.logical(check_path)
  if (check_path) {
      stop(paste0(plumber_path, " already exists, either rename, ",
                  "rerun with `overwrite=TRUE`"))

  analogsea::droplet_ssh(droplet, paste0("mkdir -p ", remoteTmp), ...)
  analogsea::droplet_upload(droplet, local=localPath, remote=remoteTmp, ...)
                         paste("mv", paste(remoteTmp, dirName, sep = "/"),
                               paste0("/var/plumber/", path)), ...)

  ### SYSTEMD ###
  serviceName <- paste0("plumber-", path)

  service <- readLines(system.file("server", "plumber.service", package="plumberDeploy"))
  service <- gsub("\\$PORT\\$", port, service)
  service <- gsub("\\$PATH\\$", paste0("/", path), service)

  if (missing(preflight)){
    preflight <- ""
  } else {
    # Append semicolon if necessary
    if (!grepl(";\\s*$", preflight)){
      preflight <- paste0(preflight, ";")
  service <- gsub("\\$PREFLIGHT\\$", preflight, service)

  service <- gsub("\\$DOCS\\$", as.character(docs), service)

  servicefile <- tempfile()
  writeLines(service, servicefile)

  remotePath <- file.path("/etc/systemd/system", paste0(serviceName, ".service"))

  analogsea::droplet_upload(droplet, servicefile, remotePath, ...)
  analogsea::droplet_ssh(droplet, "systemctl daemon-reload", ...)

  # TODO: add this as a catch()

  # TODO: differentiate between new service (start) and existing service (restart)
                         paste0("systemctl start ", serviceName, " && sleep 1"),
  #TODO: can systemctl listen for the port to come online so we don't have to guess at a sleep value?
  analogsea::droplet_ssh(droplet, paste0("systemctl restart ", serviceName, " && sleep 1"), ...)
  analogsea::droplet_ssh(droplet, paste0("systemctl enable ", serviceName), ...)
  analogsea::droplet_ssh(droplet, paste0("systemctl status ", serviceName), ...)

  ### NGINX ###
  # Prepare the nginx conf file
  conf <- readLines(system.file("server", "plumber-api.conf", package="plumberDeploy"))
  conf <- gsub("\\$PORT\\$", port, conf)
  conf <- gsub("\\$PATH\\$", path, conf)

  conffile <- tempfile()
  writeLines(conf, conffile)

  remotePath <- file.path("/etc/nginx/sites-available/plumber-apis", paste0(path, ".conf"))

  analogsea::droplet_upload(droplet, conffile, remotePath, ...)

  # TODO: add this as a catch()

  if (forward){
    do_forward(droplet, path)

  analogsea::droplet_ssh(droplet, "systemctl reload nginx", ...)

  public_ip <- droplet_ip_safe(droplet)
  if (isTRUE(docs)) {
    message("Navigate to ", public_ip, "/", path, "/__docs__/ to access api documentation.")
  } else {
    message("Api root url is ", public_ip, "/", path,". Any endpoints from api will be served relative to this root.")

#' Forward Root Requests to an API
#' @param droplet The droplet on which to act. It's expected that this droplet
#'   was provisioned using [do_provision()].
#' @param path The path to which root requests should be forwarded
#' @param ... additional arguments to pass to [analogsea::droplet_upload()]
#' @return The DigitalOcean droplet
#' @export
do_forward <- function(droplet, path, ...){
  # Trim off any leading slashes
  path <- sub("^/+", "", path)
  # Trim off any trailing slashes if any exist.
  path <- sub("/+$", "", path)

  if (grepl("/", path)){
    stop("Can't deploy to nested paths. '", path, "' should not have a / in it.")

  forward <- readLines(system.file("server", "forward.conf", package="plumberDeploy"))
  forward <- gsub("\\$PATH\\$", paste0(path), forward)

  forwardfile <- tempfile()
  writeLines(forward, forwardfile)

  analogsea::droplet_upload(droplet, forwardfile, "/etc/nginx/sites-available/plumber-apis/_forward.conf", ...)

  # TODO: add this as a catch()


#' Remove an API from the server
#' Removes all services and routing rules associated with a particular service.
#' Optionally purges the associated API directory from disk.
#' @param droplet The droplet on which to act. It's expected that this droplet
#'   was provisioned using [do_provision()]. See [analogsea::droplet()] to
#'   obtain a reference to a running droplet.
#' @param path The path/name of the plumber service
#' @param delete If `TRUE`, will also delete the associated directory
#'   (`/var/plumber/whatever`) from the server.
#' @param ... additional arguments to pass to [analogsea::droplet_ssh()] or
#'   [analogsea::droplet_upload()], such as `keyfile`.
#'   Cannot contain `remote`, `local` as named arguments.
#' @return The DigitalOcean droplet, but called for side effects
#' @export
do_remove_api <- function(droplet, path, delete=FALSE, ...){
  # Trim off any leading slashes
  path <- sub("^/+", "", path)
  # Trim off any trailing slashes if any exist.
  path <- sub("/+$", "", path)

  if (grepl("/", path)){
    stop("Can't deploy to nested paths. '", path, "' should not have a / in it.")

  # Given that we're about to `rm -rf`, let's just be safe...
  if (grepl("\\.\\.", path)){
    stop("Paths don't allow '..'s.")
  if (nchar(path)==0){
    stop("Path cannot be empty.")
  try_ssh = function(...) {
    try(analogsea::droplet_ssh(...), silent = TRUE)
  serviceName <- paste0("plumber-", path)
  message("stopping service: ", serviceName)
  try_ssh(droplet, paste0("systemctl stop ", serviceName), ...)
  message("disabling service: ", serviceName)
  try_ssh(droplet, paste0("systemctl disable ", serviceName), ...)
  message("removing service: ", serviceName)
  try_ssh(droplet, paste0("rm /etc/systemd/system/", serviceName, ".service"), ...)
  message("removing config: ", serviceName)
  try_ssh(droplet, paste0("rm /etc/nginx/sites-available/plumber-apis/", path, ".conf"), ...)

  message("reloading nginx: ", serviceName)
  try_ssh(droplet, "systemctl reload nginx", ...)

    message("reloading plumber folder: ", serviceName)
    try_ssh(droplet, paste0("rm -rf /var/plumber/", path), ...)

#' Remove the forwarding rule
#' Removes the forwarding rule from the root path on the server. The server will
#' no longer forward requests for `/` to an application.
#' @param droplet The droplet on which to act. It's expected that this droplet
#'   was provisioned using [do_provision()]. See [analogsea::droplet()] to obtain a reference to a running droplet.
#' @param ... additional arguments to pass to [analogsea::droplet_ssh()]
#' @return The DigitalOcean droplet, but called for side effects
#' @export
do_remove_forward <- function(droplet, ...){
  analogsea::droplet_ssh(droplet, "rm /etc/nginx/sites-available/plumber-apis/_forward.conf", ...)
  analogsea::droplet_ssh(droplet, "systemctl reload nginx", ...)

#' Open a DigitalOcean Droplet IP address
#' @param droplet The DigitalOcean droplet that you want to provision
#' (see [analogsea::droplet()]).
#' @param path path to attach to the IP address before browsing.  Should likely
#' start with a `/` or `:` (as in `:8080`), otherwise `/` will be added.
#' @rdname do_provision
#' @export
#' @return The URL to be browsed
do_ip = function(droplet, path) {
  ip = droplet_ip(droplet)
  if (!grepl("^(/|:)", path)) {
    path = paste0("/", path)
  ip = paste0(ip, path)

# nocov end

Try the plumberDeploy package in your browser

Any scripts or data that you put into this service are public.

plumberDeploy documentation built on March 22, 2021, 5:07 p.m.