diff --git a/DESCRIPTION b/DESCRIPTION index 4cdfc0e9..821f29a5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -34,6 +34,7 @@ Imports: tools, yaml (>= 2.1.5) Suggests: + callr, knitr, Biobase, BiocManager, diff --git a/NAMESPACE b/NAMESPACE index 2491bbb2..bb6d8619 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -26,6 +26,7 @@ export(deploySite) export(deployTFModel) export(deployments) export(discoverServers) +export(dryRun) export(forgetDeployment) export(generateAppName) export(lint) diff --git a/NEWS.md b/NEWS.md index 38232d19..dbc4b3a2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,11 @@ # rsconnect (development version) +* New experiment `dryRun()` performs a deployment "dry run", simulating running + your app or document as if it's on the server. We can only do a partial + simulation but this allows us to catch a few common problems in a way that + allows you to rapidly iterate. This feature is under active development + so your feedback is appreciated! (#725, #208, #253, #256). + * `deploySite()` now supports quarto websites (#813). * `deployApp()` and `writeManifest()` now respect renv lock files, if present. diff --git a/R/dryRun.R b/R/dryRun.R new file mode 100644 index 00000000..52b99d4d --- /dev/null +++ b/R/dryRun.R @@ -0,0 +1,175 @@ +#' Perform a deployment "dry run" +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' `dryRun()` runs your app locally, attempting to simulate what will happen +#' when you deploy it on another machine. This isn't a 100% reliable way of +#' discovering problems, but it offers a much faster iteration cycle, so where +#' it does reveal a problem, it will typically make identifying and fixing it +#' much faster. +#' +#' This function is still experimental, so please let us know your experiences +#' and where we could do better: +#' +#' +#' ## Where it helps +#' +#' `dryRun()` was motivated by the two most common problems when deploying +#' your app: +#' +#' * The server doesn't install all the packages your app needs to work. +#' `dryRun()` reveals this by using `renv::restore()` to create a project +#' specific library that uses only the packages that are explicitly used +#' by your project. +#' +#' * The server doesn't have environment variables you need. `dryRun()` +#' reveals this by removing any environment variables that you've +#' set in `~/.Renviron`, except for those that you declare in `envVars`. +#' Additionally, to help debugging it also reports whenever any env var is +#' used. +#' +#' `dryRun` will also log when you use functions that are usually best avoided +#' in deployed code. This includes: +#' +#' * `rsconnect::deployApp()` because you shouldn't deploy an app from another +#' app. This typically indicates that you've included a file with scratch +#' code. +#' +#' * `install.packages()` and `.libPaths()` because you should rely on the +#' server to install and manage your packages. +#' +#' * `browser()`, `browseURL()`, and `rstudioapi::askForPassword()` because +#' they need an interactive session that your deployment server will lack. +#' +#' ## Current limitations +#' +#' * `dryRun()` currently offers no way to diagnose problems with +#' mismatched R/Python/Quarto/pandoc versions. +#' +#' * `dryRun()` doesn't help much with paths. There are two common problems +#' it can't help with: using an absolute path and using the wrong case. +#' Both of these will work locally but fail on the server. [lint()] +#' uses an alternative technique (static analysis) to detect many of these +#' cases. +#' +#' @inheritParams deployApp +#' @param contentCategory Set this to `"site"` if you'd deploy with +#' [deploySite()]; otherwise leave as is. +#' @export +dryRun <- function(appDir = getwd(), + envVars = NULL, + appFiles = NULL, + appFileManifest = NULL, + appPrimaryDoc = NULL, + contentCategory = NULL, + quarto = NA) { + check_installed("callr") + + appFiles <- listDeploymentFiles( + appDir, + appFiles = appFiles, + appFileManifest = appFileManifest + ) + + appMetadata <- appMetadata( + appDir = appDir, + appFiles = appFiles, + appPrimaryDoc = appPrimaryDoc, + quarto = quarto, + contentCategory = contentCategory, + ) + + # copy files to bundle dir to stage + cli::cli_alert_info("Bundling app") + bundleDir <- bundleAppDir( + appDir = appDir, + appFiles = appFiles, + appPrimaryDoc = appMetadata$appPrimaryDoc + ) + defer(unlink(bundleDir, recursive = TRUE)) + + cli::cli_alert_info("Creating project specific library") + callr::r( + function() { + options(renv.verbose = FALSE) + renv::init() + renv::restore() + }, + wd = bundleDir + ) + + # Add tracing code ------------------------------------------------- + cli::cli_alert_info("Adding shims") + + file.copy( + system.file("dryRunTrace.R", package = "rsconnect"), + file.path(bundleDir, "__rsconnect-dryRunTrace.R") + ) + appendLines( + file.path(bundleDir, ".Rprofile"), + c("", 'source("__rsconnect-dryRunTrace.R")') + ) + + # Run --------------------------------------------------------------- + cli::cli_alert_info("Starting {appMetadata$appMode}") + if (appMetadata$appMode %in% c("rmd-shiny", "quarto-shiny", "shiny", "api")) { + cli::cli_alert_warning("Terminate the app to complete the dry run") + } + + envVarNames <- setdiff(userEnvVars(), c(envVars, "PATH")) + envVarReset <- c(rep_named(envVarNames, ""), callr::rcmd_safe_env()) + + callr::r( + appRunner(appMetadata$appMode), + args = list(primaryDoc = appMetadata$appPrimaryDoc), + env = envVarReset, + wd = bundleDir, + show = TRUE + ) + invisible() +} + +appRunner <- function(appMode) { + switch(appMode, + "rmd-static" = , + "rmd-shiny" = function(primaryDoc) { + rmarkdown::render(primaryDoc, quiet = TRUE) + }, + "quarto-static" = , + "quarto-shiny" = function(primaryDoc) { + quarto::quarto_render(primaryDoc, quiet = TRUE) + }, + "shiny" = function(primaryDoc) { + shiny::runApp() + }, + "api" = function(primaryDoc) { + plumber::pr_run(plumber::pr("plumber.R")) + }, + cli::cli_abort("Content type {appMode} not currently supported") + ) +} + +appendLines <- function(path, lines) { + lines <- c(readLines(path), lines) + writeLines(lines, path) +} + +userEnvVars <- function(path = "~/.Renviron") { + if (!file.exists(path)) { + return(character()) + } + + lines <- readLines(path) + lines <- lines[lines != ""] + lines <- lines[!grepl("^#", lines)] + + pieces <- strsplit(lines, "=", fixed = TRUE) + names <- vapply( + pieces, + function(x) if (length(x) >= 2) x[[1]] else "", + character(1) + ) + + sort(unique(names[names != ""])) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 58b034fd..514decbb 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -29,6 +29,7 @@ reference: contents : - writeManifest - matches("[Dd]eploy") + - dryRun - listDeploymentFiles - title: Servers diff --git a/inst/dryRunTrace.R b/inst/dryRunTrace.R new file mode 100644 index 00000000..1d2ec788 --- /dev/null +++ b/inst/dryRunTrace.R @@ -0,0 +1,81 @@ +rsconnect_log <- function(...) { + cat(paste0("[dryRun] ", ..., "\n", collapse = ""), file = stderr()) +} +traceFun <- function(ns, fun, code) { + traceFun_(ns, fun, substitute(code)) +} +traceFun_ <- function(ns, fun, code) { + if (ns == "base") { + where <- baseenv() + } else { + where <- asNamespace(ns) + } + + suppressMessages( + trace(fun, code, where = where, print = FALSE) + ) + invisible() +} + +# Messages ---------------------------------------------------------------- + +env_seen <- new.env(parent = emptyenv()) +env_seen$PATH <- TRUE +env_seen$TESTTHAT <- TRUE +env_seen$RSTUDIO <- TRUE + +traceFun("base", "Sys.getenv", { + if (!grepl("^(_R|R|RSTUDIO|CALLR|CLI|RENV|RMARKDOWN)_", x)) { + if (!exists(x, envir = env_seen)) { + rsconnect_log("Getting env var '", x, "'") + env_seen[[x]] <- TRUE + } + } +}) +traceFun("base", ".libPaths", { + if (!missing(new)) { + rsconnect_log("Updating .libPaths") + } +}) + +traceFun("base", "browser", { + rsconnect_log("Can't use browser(); no interactive session") +}) + +traceFun("utils", "browseURL", { + rsconnect_log("Attempting to browse to <", url, ">") +}) + + +# Errors ------------------------------------------------------------------ + +errorOnServer <- function(ns, fun, reason) { + code <- substitute( + stop(paste0("[dryRun] `", ns, "::", fun, "()`: ", reason), call. = FALSE) + ) + traceFun_(ns, fun, code) +} + +errorOnServer( + "utils", + "install.packages", + "install packages locally, not on the server" +) + +setHook( + packageEvent("rstudioapi", "onLoad"), + errorOnServer( + "rsconnect", + "deployApp", + "apps shouldn't deploy apps on the server" + ) +) + +setHook( + packageEvent("rstudioapi", "onLoad"), + errorOnServer( + "rstudioapi", + "askForPassword", + "can't interactively ask for password on server" + ) +) diff --git a/man/dryRun.Rd b/man/dryRun.Rd new file mode 100644 index 00000000..21a169c9 --- /dev/null +++ b/man/dryRun.Rd @@ -0,0 +1,113 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dryRun.R +\name{dryRun} +\alias{dryRun} +\title{Perform a deployment "dry run"} +\usage{ +dryRun( + appDir = getwd(), + envVars = NULL, + appFiles = NULL, + appFileManifest = NULL, + appPrimaryDoc = NULL, + contentCategory = NULL, + quarto = NA +) +} +\arguments{ +\item{appDir}{A directory containing an application (e.g. a Shiny app +or plumber API). Defaults to the current directory.} + +\item{envVars}{A character vector giving the names of environment variables +whose values should be synchronised with the server (currently supported by +Connect only). The values of the environment variables are sent over an +encrypted connection and are not stored in the bundle, making this a safe +way to send private data to Connect. + +The names (not values) are stored in the deployment record so that future +deployments will automatically update their values. Other environment +variables on the server will not be affected. This means that removing an +environment variable from \code{envVars} will leave it unchanged on the server. +To remove it, either delete it using the Connect UI, or temporarily unset +it (with \code{Sys.unsetenv()} or similar) then re-deploy. + +Environment variables are set prior to deployment so that your code +can use them and the first deployment can still succeed. Note that means +that if the deployment fails, the values will still be updated.} + +\item{appFiles, appFileManifest}{Use \code{appFiles} to specify a +character vector of files to bundle in the app or \code{appManifestFiles} +to provide a path to a file containing a list of such files. If neither +are supplied, will bundle all files in \code{appDir}, apart from standard +exclusions and files listed in a \code{.rscignore} file. See +\code{\link[=listDeploymentFiles]{listDeploymentFiles()}} for more details.} + +\item{appPrimaryDoc}{If the application contains more than one document, this +parameter indicates the primary one, as a path relative to \code{appDir}. Can be +\code{NULL}, in which case the primary document is inferred from the contents +being deployed.} + +\item{contentCategory}{Set this to \code{"site"} if you'd deploy with +\code{\link[=deploySite]{deploySite()}}; otherwise leave as is.} + +\item{quarto}{Should the deployed content be built by quarto? +(\code{TRUE}, \code{FALSE}, or \code{NA}). The default, \code{NA}, will use quarto if +there are \code{.qmd} files in the bundle, or if there is a +\verb{_quarto.yml} and \code{.Rmd} files. + +(This option is ignored and quarto will always be used if the +\code{metadata} contains \code{quarto_version} and \code{quarto_engines} fields.)} +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +\code{dryRun()} runs your app locally, attempting to simulate what will happen +when you deploy it on another machine. This isn't a 100\% reliable way of +discovering problems, but it offers a much faster iteration cycle, so where +it does reveal a problem, it will typically make identifying and fixing it +much faster. + +This function is still experimental, so please let us know your experiences +and where we could do better: +\url{https://github.com/rstudio/rsconnect/issues/new} +\subsection{Where it helps}{ + +\code{dryRun()} was motivated by the two most common problems when deploying +your app: +\itemize{ +\item The server doesn't install all the packages your app needs to work. +\code{dryRun()} reveals this by using \code{renv::restore()} to create a project +specific library that uses only the packages that are explicitly used +by your project. +\item The server doesn't have environment variables you need. \code{dryRun()} +reveals this by removing any environment variables that you've +set in \verb{~/.Renviron}, except for those that you declare in \code{envVars}. +Additionally, to help debugging it also reports whenever any env var is +used. +} + +\code{dryRun} will also log when you use functions that are usually best avoided +in deployed code. This includes: +\itemize{ +\item \code{rsconnect::deployApp()} because you shouldn't deploy an app from another +app. This typically indicates that you've included a file with scratch +code. +\item \code{install.packages()} and \code{.libPaths()} because you should rely on the +server to install and manage your packages. +\item \code{browser()}, \code{browseURL()}, and \code{rstudioapi::askForPassword()} because +they need an interactive session that your deployment server will lack. +} +} + +\subsection{Current limitations}{ +\itemize{ +\item \code{dryRun()} currently offers no way to diagnose problems with +mismatched R/Python/Quarto/pandoc versions. +\item \code{dryRun()} doesn't help much with paths. There are two common problems +it can't help with: using an absolute path and using the wrong case. +Both of these will work locally but fail on the server. \code{\link[=lint]{lint()}} +uses an alternative technique (static analysis) to detect many of these +cases. +} +} +} diff --git a/tests/testthat/test-dryRun.R b/tests/testthat/test-dryRun.R new file mode 100644 index 00000000..9c8d6655 --- /dev/null +++ b/tests/testthat/test-dryRun.R @@ -0,0 +1,61 @@ +test_that("multiplication works", { + app <- local_temp_app(list( + "index.Rmd" = c( + "---", + "title: rmd", + "---", + "", + "```{r}", + "Sys.getenv('MYSQL_USER')", + "```" + ) + )) + + # dryRun(app) +}) + +test_that("multiplication works", { + app <- local_temp_app(list( + "app.R" = "rsconnect::deployApp()" + )) + + # dryRun(app) +}) + + +# userEnvVars ------------------------------------------------------------- + +test_that("ignores non-existent file", { + expect_equal(userEnvVars("DOESNTEXIST"), character()) +}) + +test_that("can parse simple .Renviron", { + path <- withr::local_tempfile(lines = c( + "# a comment", + "", + "A=1", + "B=2" + )) + expect_equal(userEnvVars(path), c("A", "B")) +}) + +test_that("removes duplicates", { + path <- withr::local_tempfile(lines = c( + "# a comment", + "", + "A=1", + "A=2" + )) + expect_equal(userEnvVars(path), "A") +}) + +test_that("not troubled by wrong number of equals", { + path <- withr::local_tempfile(lines = c( + "# a comment", + "", + "A", + "B=1", + "C=2=3" + )) + expect_equal(userEnvVars(path), c("B", "C")) +})