diff --git a/.Rbuildignore b/.Rbuildignore index 5d24fa9..41ebf77 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -7,3 +7,4 @@ ^README\.Rmd$ ^\.github$ ^\.gitlab-ci\.yml$ +^tests/testthat/fixtures$ diff --git a/DESCRIPTION b/DESCRIPTION index 972e454..9bcf3f0 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -22,15 +22,16 @@ Imports: yesno Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 +RoxygenNote: 7.3.3 URL: https://github.com/VincentGuyader/shiny2docker BugReports: https://github.com/VincentGuyader/shiny2docker/issues -Suggests: +Suggests: shiny, renv, testthat (>= 3.0.0), knitr, rmarkdown, - rstudioapi + rstudioapi, + withr Config/testthat/edition: 3 VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index 1f3e0a1..19099e2 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,12 +1,15 @@ # Generated by roxygen2: do not edit by hand +export(install_uvr) export(set_github_action) export(set_gitlab_ci) export(shiny2docker) +export(shiny2docker_uvr) importFrom(attachment,create_renv_for_prod) importFrom(cli,cli_alert_danger) importFrom(cli,cli_alert_info) importFrom(cli,cli_alert_success) +importFrom(dockerfiler,Dockerfile) importFrom(dockerfiler,dock_from_renv) importFrom(here,here) importFrom(yesno,yesno2) diff --git a/R/shiny2docker_uvr.R b/R/shiny2docker_uvr.R new file mode 100644 index 0000000..95dcc45 --- /dev/null +++ b/R/shiny2docker_uvr.R @@ -0,0 +1,140 @@ +#' shiny2docker_uvr +#' +#' Generate a Dockerfile for a Shiny application using +#' [uvr](https://github.com/nbafrank/uvr) instead of `renv`. The R version is +#' installed inside the container by `uvr` (no `rocker/r-ver` base image +#' required) and packages are restored from `uvr.lock` via `uvr sync`. +#' +#' Lifecycle: experimental. The function expects (or bootstraps) the three +#' uvr files — `uvr.toml`, `uvr.lock`, and `.r-version` — at the root of +#' `path`, under those exact filenames (the generated Dockerfile copies them +#' by name from the build context, matching uvr's own convention). +#' +#' Bootstrapping (default `bootstrap = TRUE`): if any of the three files is +#' missing, `shiny2docker_uvr()` creates them automatically — the same way +#' `shiny2docker()` auto-creates `renv.lock` via +#' `attachment::create_renv_for_prod()`. Concretely, when `uvr.toml` is +#' missing, we generate a `renv.lock` (via `attachment`) if needed, then run +#' `uvr init` + `uvr import`. When `.r-version` is missing, we pin to the +#' current local R version. When `uvr.lock` is missing or stale, we run +#' `uvr lock`. This requires the `uvr` CLI on `PATH`; if absent, you get an +#' actionable error with install instructions. Set `bootstrap = FALSE` to +#' fail fast instead. +#' +#' What `uvr_assert_state()` checks after bootstrapping: the three files exist, +#' `uvr.lock` is at least as recent as `uvr.toml` (mtime check), and +#' `.r-version` contains a single `MAJOR.MINOR.PATCH` line. It does **not** +#' parse `uvr.toml` to cross-validate the pinned R version against any +#' `[r] version` constraint — uvr itself owns that consistency contract. +#' +#' Platform constraints: the generated Dockerfile uses `apt-get` and Debian +#' package names, and downloads the `x86_64-unknown-linux-gnu` uvr binary, so +#' `base_image` must be a Debian/Ubuntu-derived image on amd64/glibc. +#' +#' @param path Character. Path to the folder containing the Shiny application +#' *and* the uvr files (`uvr.toml`, `uvr.lock`, `.r-version`). +#' @param output Character. Path to the generated Dockerfile. +#' @param base_image Character. Docker base image. Defaults to `"debian:stable-slim"`. +#' Must be a Debian/Ubuntu-based amd64 image (see "Platform constraints" above). +#' @param uvr_version Character. `"latest"` or a semver tag like `"v0.3.1"`. +#' Pinning a tag is recommended for reproducible builds. +#' @param port Integer in `[1, 65535]`. Shiny port to expose. Default `3838`. +#' @param host Character. Shiny host. Default `"0.0.0.0"`. Single value, no +#' shell metacharacters; validated before being interpolated into the image. +#' @param extra_sysreqs Character vector of extra debian package names to +#' install before `uvr sync` (escape hatch for system dependencies that uvr +#' does not detect on its own). Each entry must match +#' `[A-Za-z0-9.+:-]+` to avoid shell injection. +#' @param bootstrap Logical. If `TRUE` (default), missing `uvr.toml` / +#' `uvr.lock` / `.r-version` are auto-generated by calling the local `uvr` +#' CLI (and `attachment::create_renv_for_prod()` for the dep list). When +#' `FALSE`, missing files trigger an immediate error, matching the strict +#' behaviour expected in CI. Requires the `uvr` binary on `PATH` only when +#' actual bootstrapping is needed. +#' @param frozen Logical. If `TRUE`, the generated Dockerfile runs +#' `uvr sync --frozen` so the build fails when `uvr.lock` is out of date +#' relative to `uvr.toml`. Default `FALSE` because uvr 0.2.15 rejects +#' locks generated on a different host than the container's target OS; +#' opt in once your setup supports it. When `FALSE`, a `cli` warning is +#' emitted to make the relaxed strictness explicit. +#' @param write Logical. Whether to write the Dockerfile and `.dockerignore` +#' to disk. When `FALSE`, the function only returns the in-memory Dockerfile +#' object and does not touch the filesystem. Default `TRUE`. The parent +#' directory of `output` is created if it does not already exist. +#' +#' @return Invisibly, an R6 `dockerfiler::Dockerfile` object that can be further +#' customised before writing. +#' +#' @export +#' +#' @importFrom dockerfiler Dockerfile +shiny2docker_uvr <- function(path = ".", + output = file.path(path, "Dockerfile"), + base_image = "debian:stable-slim", + uvr_version = "latest", + port = 3838, + host = "0.0.0.0", + extra_sysreqs = NULL, + bootstrap = TRUE, + frozen = FALSE, + write = TRUE) { + + uvr_toml <- file.path(path, "uvr.toml") + uvr_lock <- file.path(path, "uvr.lock") + rver_file <- file.path(path, ".r-version") + + needs_bootstrap <- !file.exists(uvr_toml) || + !file.exists(uvr_lock) || + !file.exists(rver_file) + + if (needs_bootstrap) { + if (!isTRUE(bootstrap)) { + stop( + "uvr files missing at '", path, + "/'. Re-run with `bootstrap = TRUE`, or run `uvr init`/`uvr import`/`uvr lock` manually.", + call. = FALSE + ) + } + uvr_bootstrap(path = path) + } + + uvr_assert_state( + uvr_toml = uvr_toml, + uvr_lock = uvr_lock, + r_version_file = rver_file + ) + + uvr_validate_host_port(host = host, port = port) + uvr_validate_sysreqs(extra_sysreqs) + + if (!isTRUE(frozen)) { + cli::cli_alert_warning(paste( + "frozen = FALSE: the generated build runs `uvr sync` (no --frozen),", + "so a stale uvr.lock will not block the image. Set frozen = TRUE", + "once your environment supports it." + )) + } + + dock <- uvr_build_dockerfile( + base_image = base_image, + uvr_version = uvr_version, + port = port, + host = host, + extra_sysreqs = extra_sysreqs, + frozen = frozen + ) + + if (isTRUE(write)) { + out_dir <- dirname(output) + if (!dir.exists(out_dir)) { + dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) + } + dockerignore <- file.path(out_dir, ".dockerignore") + if (!file.exists(dockerignore)) { + create_dockerignore_uvr(path = dockerignore) + } + dock$write(output) + } + + invisible(dock) +} diff --git a/R/utils_uvr.R b/R/utils_uvr.R new file mode 100644 index 0000000..bca5359 --- /dev/null +++ b/R/utils_uvr.R @@ -0,0 +1,543 @@ +#' Write a uvr-aware .dockerignore +#' +#' Adds the entries that are specific to a uvr-managed project (`.uvr/`, +#' `.Rprofile` written by `uvr init`, leftover `renv/` artefacts) on top of +#' the defaults used by the `renv` backend. +#' +#' @param path Path to the `.dockerignore` to write. +#' @noRd +create_dockerignore_uvr <- function(path = ".dockerignore") { + entries <- c( + ".Rhistory", + ".git", + ".gitignore", + ".Rproj.user", + "manifest.json", + "rsconnect/", + ".uvr/", + ".Rprofile", + "renv/", + "renv.lock" + ) + writeLines(entries, con = path) +} + +#' Validate user-supplied host and port +#' +#' Both values are interpolated into shell commands inside the Dockerfile, so +#' we reject anything that could break the build or inject extra tokens. +#' +#' @param host Character of length 1. +#' @param port Numeric of length 1, integer in `[1, 65535]`. +#' @return Invisibly `TRUE` on success; raises an error otherwise. +#' @noRd +uvr_validate_host_port <- function(host, port) { + if (!is.character(host) || length(host) != 1L || is.na(host) || + !grepl("^[A-Za-z0-9._-]+$", host)) { + stop("`host` must be a single non-NA character matching ", + "[A-Za-z0-9._-]+ (e.g. \"0.0.0.0\").", call. = FALSE) + } + if (!is.numeric(port) || length(port) != 1L || is.na(port) || + port != as.integer(port) || port < 1L || port > 65535L) { + stop("`port` must be a single integer in [1, 65535].", call. = FALSE) + } + invisible(TRUE) +} + +#' Validate user-supplied extra system requirements +#' +#' Each entry is concatenated into an `apt-get install` command in the +#' Dockerfile. We restrict to a conservative Debian package-name pattern to +#' avoid shell injection. +#' +#' @param sysreqs `NULL` or a character vector. +#' @return Invisibly `TRUE` on success; raises an error otherwise. +#' @noRd +uvr_validate_sysreqs <- function(sysreqs) { + if (is.null(sysreqs) || length(sysreqs) == 0L) return(invisible(TRUE)) + if (!is.character(sysreqs) || any(is.na(sysreqs))) { + stop("`extra_sysreqs` must be a character vector with no NA.", call. = FALSE) + } + bad <- !grepl("^[A-Za-z0-9.+:-]+$", sysreqs) + if (any(bad)) { + stop("Invalid package name(s) in `extra_sysreqs`: ", + paste(shQuote(sysreqs[bad]), collapse = ", "), + ". Allowed characters: A-Z a-z 0-9 . + : -", call. = FALSE) + } + invisible(TRUE) +} + +#' Default install location for the uvr binary on the current platform +#' +#' - Unix (Linux/macOS): `~/.local/bin/` -- matches the official `install.sh`. +#' Often already on `PATH`. +#' - Windows: `%LOCALAPPDATA%/shiny2docker/bin/` (e.g. +#' `C:/Users//AppData/Local/shiny2docker/bin/`). Not on `PATH` by +#' default; the install function prints a `setx`-flavoured hint. +#' @noRd +uvr_default_install_dir <- function() { + if (.Platform$OS.type == "windows") { + base <- Sys.getenv("LOCALAPPDATA", + unset = file.path(Sys.getenv("USERPROFILE"), + "AppData", "Local")) + file.path(base, "shiny2docker", "bin") + } else { + file.path(Sys.getenv("HOME"), ".local", "bin") + } +} + +#' How to put `dest` on PATH for the user's shell, formatted for help text +#' @noRd +uvr_path_hint <- function(dest) { + if (.Platform$OS.type == "windows") { + sprintf("setx PATH \"%%PATH%%;%s\" (then restart your shell)", + normalizePath(dest, winslash = "\\", mustWork = FALSE)) + } else { + sprintf("export PATH=\"%s:$PATH\" (add to ~/.bashrc or ~/.zshrc)", dest) + } +} + +#' Detect the uvr release asset that matches the current host +#' @return A list with `name` (asset filename) and `ext` (".tar.gz" or ".zip"). +#' @noRd +uvr_detect_asset <- function() { + os <- Sys.info()[["sysname"]] + arch <- Sys.info()[["machine"]] + if (arch == "arm64") arch <- "aarch64" + if (os == "Linux") { + return(list(name = sprintf("uvr-%s-unknown-linux-gnu.tar.gz", arch), ext = ".tar.gz")) + } + if (os == "Darwin") { + return(list(name = sprintf("uvr-%s-apple-darwin.tar.gz", arch), ext = ".tar.gz")) + } + if (os == "Windows") { + return(list(name = "uvr-x86_64-pc-windows-msvc.zip", ext = ".zip")) + } + stop("Unsupported platform: ", os, "/", arch, + ". See https://github.com/nbafrank/uvr/releases for a manual download.", + call. = FALSE) +} + +#' Locate the `uvr` binary, optionally offering to install it +#' +#' Looks on `PATH` first, then in the default install location +#' (`~/.local/bin/uvr`). When neither is present and the session is +#' interactive, prompts the user to run `install_uvr()` automatically. +#' +#' @param interactive_install Logical. If `TRUE` (default in interactive +#' sessions), prompt to install when the binary is missing. When `FALSE`, +#' raise an actionable error instead. +#' @return Absolute path to the `uvr` binary. +#' @noRd +uvr_locate_or_install <- function(interactive_install = interactive()) { + bin_name <- if (.Platform$OS.type == "windows") "uvr.exe" else "uvr" + bin <- unname(Sys.which("uvr")) + if (nzchar(bin) && file.exists(bin)) return(bin) + candidate <- file.path(uvr_default_install_dir(), bin_name) + if (file.exists(candidate)) return(candidate) + + if (isTRUE(interactive_install)) { + dest <- uvr_default_install_dir() + cli::cli_alert_info(paste( + "{.code uvr} CLI is required to bootstrap a uvr project but isn't", + "installed yet." + )) + ans <- tolower(trimws(readline(sprintf( + "Download and install it now to %s? [Y/n] ", dest + )))) + if (ans %in% c("", "y", "yes", "o", "oui")) { + return(install_uvr()) + } + } + + stop( + "`uvr` CLI not found on PATH; needed to bootstrap a uvr project.\n", + "Easiest: run `shiny2docker::install_uvr()` from R (downloads and ", + "installs the binary for your platform).\n", + "Or via shell: ", + "curl -fsSL https://raw.githubusercontent.com/nbafrank/uvr/main/install.sh | sh\n", + "If you've already bootstrapped the project elsewhere, make sure ", + "`uvr.toml`, `uvr.lock` and `.r-version` are at the project root.", + call. = FALSE + ) +} + +#' Install the `uvr` CLI binary for the current platform +#' +#' Downloads the matching release asset from +#' \url{https://github.com/nbafrank/uvr/releases} and places the `uvr` +#' executable under `dest` (default `~/.local/bin/`). After install, +#' [shiny2docker_uvr()] picks it up automatically. +#' +#' @param dest Directory where the binary should land. Created if missing. +#' Default is platform-aware: `~/.local/bin/` on Unix (Linux/macOS), +#' `%LOCALAPPDATA%/shiny2docker/bin/` on Windows. +#' @param version Release tag, e.g. `"v0.2.15"`, or `"latest"`. +#' @param force If `TRUE`, install even when `uvr` is already on `PATH`. +#' +#' @return Invisibly, the absolute path to the installed binary. +#' +#' @export +install_uvr <- function(dest = uvr_default_install_dir(), + version = "latest", + force = FALSE) { + + if (!isTRUE(force)) { + existing <- unname(Sys.which("uvr")) + if (nzchar(existing) && file.exists(existing)) { + cli::cli_alert_info(paste0( + "uvr already on PATH at ", existing, + "; pass force = TRUE to reinstall." + )) + return(invisible(existing)) + } + } + + asset <- uvr_detect_asset() + url <- if (identical(version, "latest")) { + sprintf("https://github.com/nbafrank/uvr/releases/latest/download/%s", + asset$name) + } else { + tag <- if (startsWith(version, "v")) version else paste0("v", version) + sprintf("https://github.com/nbafrank/uvr/releases/download/%s/%s", + tag, asset$name) + } + + tmp <- tempfile(fileext = asset$ext) + ext_dir <- tempfile("uvr_extract_"); dir.create(ext_dir) + on.exit({ + unlink(tmp, force = TRUE) + unlink(ext_dir, recursive = TRUE, force = TRUE) + }, add = TRUE) + + cli::cli_alert_info("Downloading {.url {url}}") + utils::download.file(url, tmp, mode = "wb", quiet = TRUE) + + if (asset$ext == ".zip") { + utils::unzip(tmp, exdir = ext_dir) + } else { + utils::untar(tmp, exdir = ext_dir) + } + + bin_name <- if (.Platform$OS.type == "windows") "uvr.exe" else "uvr" + src <- file.path(ext_dir, bin_name) + if (!file.exists(src)) { + found <- list.files(ext_dir, recursive = TRUE, + pattern = paste0("^", bin_name, "$"), + full.names = TRUE) + if (length(found) == 0L) { + stop("`", bin_name, "` not found in archive ", asset$name, call. = FALSE) + } + src <- found[1] + } + + dir.create(dest, recursive = TRUE, showWarnings = FALSE) + out <- file.path(dest, bin_name) + file.copy(src, out, overwrite = TRUE) + if (.Platform$OS.type != "windows") Sys.chmod(out, mode = "0755") + + cli::cli_alert_success("Installed uvr to {.path {out}}") + if (!nzchar(Sys.which("uvr"))) { + cli::cli_alert_warning(paste0( + dest, " is not on your PATH for future R sessions. ", + "Make it permanent with: ", + uvr_path_hint(dest) + )) + } + invisible(out) +} + +#' Bootstrap a uvr project layout if files are missing +#' +#' Mirrors what `shiny2docker()` does for `renv` (auto-creating `renv.lock` via +#' `attachment::create_renv_for_prod()`): if `uvr.toml`, `uvr.lock` or +#' `.r-version` are absent, generate them so the user does not have to learn +#' the uvr CLI before being able to call `shiny2docker_uvr()`. +#' +#' Strategy: +#' 1. If `uvr.toml` is missing, generate a `renv.lock` via `attachment` (or +#' reuse an existing one), then `uvr import` it to produce `uvr.toml`. +#' 2. If `.r-version` is missing, pin it to the current R version. +#' 3. If `uvr.lock` is missing or older than `uvr.toml`, run `uvr lock`. +#' +#' Requires the `uvr` CLI on PATH; errors out with install instructions +#' otherwise. All `uvr` invocations run with `path` as the working directory. +#' +#' @param path Project root. +#' @param renv_lockfile Path to `renv.lock` to import from (created if missing). +#' @param document Passed to `attachment::create_renv_for_prod()`. +#' @param folder_to_exclude Passed to `attachment::create_renv_for_prod()`. +#' +#' @return Invisibly `TRUE`. +#' @noRd +uvr_bootstrap <- function(path, + renv_lockfile = file.path(path, "renv.lock"), + document = TRUE, + folder_to_exclude = c("renv", ".uvr")) { + + uvr_bin <- uvr_locate_or_install() + + uvr_toml <- file.path(path, "uvr.toml") + uvr_lock <- file.path(path, "uvr.lock") + rver_file <- file.path(path, ".r-version") + + run_uvr <- function(args, label) { + owd <- setwd(path); on.exit(setwd(owd), add = TRUE) + rc <- suppressWarnings(system2(uvr_bin, args, stdout = TRUE, stderr = TRUE)) + setwd(owd); on.exit() + status <- attr(rc, "status") + if (!is.null(status) && status != 0L) { + stop(label, " failed (exit ", status, "):\n", + paste(rc, collapse = "\n"), call. = FALSE) + } + invisible(rc) + } + + if (!file.exists(uvr_toml)) { + cli::cli_alert_info("uvr.toml not found at {.path {uvr_toml}} -- bootstrapping.") + + if (!file.exists(renv_lockfile)) { + cli::cli_alert_info( + "Generating {.path renv.lock} via {.fn attachment::create_renv_for_prod} ..." + ) + attachment::create_renv_for_prod( + path = path, + output = renv_lockfile, + folder_to_exclude = folder_to_exclude, + document = document + ) + } + + cli::cli_alert_info("Running {.code uvr init} ...") + run_uvr(c("init", "--quiet", "."), "uvr init") + + cli::cli_alert_info( + "Running {.code uvr import {basename(renv_lockfile)}} ..." + ) + run_uvr(c("import", basename(renv_lockfile)), "uvr import") + } + + if (!file.exists(rver_file)) { + # R.version$minor packs MINOR.PATCH (e.g. "4.3" for R 4.4.3), so a simple + # paste gives MAJOR.MINOR.PATCH directly. + current_r <- paste(R.version$major, R.version$minor, sep = ".") + cli::cli_alert_info( + "Pinning R to current local version: {.val {current_r}} ..." + ) + tryCatch( + run_uvr(c("r", "pin", current_r), "uvr r pin"), + error = function(e) { + fallback <- "4.4.2" + cli::cli_alert_warning(paste0( + "Could not pin R ", current_r, " (", e$message, + "). Falling back to ", fallback, "." + )) + run_uvr(c("r", "pin", fallback), "uvr r pin (fallback)") + } + ) + } + + if (!file.exists(uvr_lock) || + file.info(uvr_lock)$mtime < file.info(uvr_toml)$mtime) { + cli::cli_alert_info("Running {.code uvr lock} ...") + run_uvr(c("lock", "--quiet"), "uvr lock") + } + + invisible(TRUE) +} + +#' Assert that the project is uvr-ready +#' +#' Checks that `uvr.toml`, `uvr.lock` and a R version pin file all exist, and +#' that `uvr.lock` is at least as recent as `uvr.toml`. Stops with an actionable +#' message otherwise. +#' +#' @param uvr_toml Path to `uvr.toml`. +#' @param uvr_lock Path to `uvr.lock`. +#' @param r_version_file Path to `.r-version`. +#' +#' @return Invisibly `TRUE` on success. +#' @noRd +uvr_assert_state <- function(uvr_toml, uvr_lock, r_version_file) { + + if (!file.exists(uvr_toml)) { + stop( + "uvr.toml not found at '", uvr_toml, "'.\n", + "Run `uvr init` (or `uvr import` from an existing renv.lock), then `uvr lock`.", + call. = FALSE + ) + } + if (!file.exists(uvr_lock)) { + stop( + "uvr.lock not found at '", uvr_lock, "'.\n", + "Run `uvr lock` to generate it.", + call. = FALSE + ) + } + if (file.info(uvr_lock)$mtime < file.info(uvr_toml)$mtime) { + stop( + "uvr.lock is older than uvr.toml -- manifest changed since last lock.\n", + "Run `uvr lock` to refresh it.", + call. = FALSE + ) + } + if (!file.exists(r_version_file)) { + stop( + ".r-version not found at '", r_version_file, "'.\n", + "Run `uvr r pin ` to pin the R version for the project.", + call. = FALSE + ) + } + r_lines <- readLines(r_version_file, warn = FALSE) + r_lines <- r_lines[nzchar(trimws(r_lines))] + if (length(r_lines) != 1L || + !grepl("^[0-9]+\\.[0-9]+\\.[0-9]+$", trimws(r_lines))) { + stop( + ".r-version at '", r_version_file, "' must contain a single line ", + "with a MAJOR.MINOR.PATCH version (e.g. \"4.4.2\"). Got: ", + paste(shQuote(r_lines), collapse = ", "), + call. = FALSE + ) + } + + invisible(TRUE) +} + +#' Build the uvr release download URL +#' +#' @param uvr_version Either `"latest"` or a tag like `"v0.3.1"` / `"0.3.1"`. +#' @return A list with `url` and `arg_value`. `arg_value` is `NA` when +#' `uvr_version = "latest"` (no `ARG UVR_VERSION` injected). +#' @noRd +uvr_release_url <- function(uvr_version = "latest") { + asset <- "uvr-x86_64-unknown-linux-gnu.tar.gz" + base <- "https://github.com/nbafrank/uvr/releases" + + if (identical(uvr_version, "latest")) { + return(list( + url = sprintf("%s/latest/download/%s", base, asset), + arg_value = NA_character_ + )) + } + + tag <- if (startsWith(uvr_version, "v")) uvr_version else paste0("v", uvr_version) + if (!grepl("^v[0-9]+\\.[0-9]+\\.[0-9]+$", tag)) { + stop( + "uvr_version must be 'latest' or a semver tag like 'v0.3.1' / '0.3.1'. ", + "Got: '", uvr_version, "'", + call. = FALSE + ) + } + list( + url = sprintf("%s/download/%s/%s", base, tag, asset), + arg_value = tag + ) +} + +#' Build the Dockerfile object for the uvr backend +#' +#' @param base_image Base OS image (e.g. `"debian:stable-slim"`). +#' @param uvr_version `"latest"` or a tag (`"v0.3.1"`). +#' @param port Shiny port. +#' @param host Shiny host. +#' @param extra_sysreqs Optional character vector of extra debian packages to +#' `apt-get install` before `uvr sync`. +#' +#' @return An R6 `dockerfiler::Dockerfile` object. +#' @noRd +uvr_build_dockerfile <- function(base_image = "debian:stable-slim", + uvr_version = "latest", + port = 3838, + host = "0.0.0.0", + extra_sysreqs = NULL, + frozen = FALSE) { + + release <- uvr_release_url(uvr_version) + + dock <- dockerfiler::Dockerfile$new(FROM = base_image) + + dock$ENV("DEBIAN_FRONTEND", "noninteractive") + dock$ENV("LANG", "en_US.UTF-8") + dock$ENV("LC_ALL", "en_US.UTF-8") + + dock$RUN(paste( + "apt-get update && apt-get install -y --no-install-recommends", + # base tooling + "ca-certificates curl locales tzdata", + # binutils provides `ar`, used by `uvr r install` to extract the Posit .deb + "binutils", + # runtime + build deps required by Posit's R .deb + "g++ gcc gfortran make zip unzip ucf", + "libbz2-dev libc6 libcairo2 libcurl4-openssl-dev libdeflate-dev", + "libglib2.0-0 libgomp1 libicu-dev liblzma-dev libopenblas-dev", + "libpango-1.0-0 libpangocairo-1.0-0 libpaper-utils libpcre2-dev", + "libpng16-16 libreadline8 libtcl8.6 libtiff6 libtirpc-dev libtk8.6", + "libx11-6 libxt6 zlib1g-dev", + # extra deps commonly needed when uvr falls back to source compilation + # (P3M binaries are not always available on debian targets as of uvr 0.2.15) + "libuv1-dev libxml2-dev libssl-dev libsodium-dev", + "&& sed -i '/en_US.UTF-8/s/^# //' /etc/locale.gen && locale-gen", + "&& rm -rf /var/lib/apt/lists/*" + )) + + if (!is.na(release$arg_value)) { + dock$ARG(paste0("UVR_VERSION=", release$arg_value)) + } + + dock$RUN(paste0( + "curl -fsSL ", release$url, + " | tar xz -C /usr/local/bin uvr && uvr --version" + )) + + dock$WORKDIR("/srv/shiny-server") + dock$COPY(from = ".r-version", to = "./") + dock$COPY(from = "uvr.toml", to = "./") + dock$COPY(from = "uvr.lock", to = "./") + + # Workaround for nbafrank/uvr <= 0.2.15: `uvr r install` writes + # `etc/Renviron.site` directly under // but fails because + # that `etc/` dir doesn't exist (Posit's .deb places etc under lib/R/). + # Pre-creating the dir lets the install complete. Track upstream and drop + # this once fixed. + dock$RUN(paste( + "mkdir -p \"/root/.uvr/r-versions/$(cat .r-version)/etc\"", + "&& uvr r install \"$(cat .r-version)\"" + )) + + if (length(extra_sysreqs) > 0) { + dock$RUN(paste( + "apt-get update && apt-get install -y --no-install-recommends", + paste(extra_sysreqs, collapse = " "), + "&& rm -rf /var/lib/apt/lists/*" + )) + } + + # `--frozen` is what we want in CI (lock = source of truth), but uvr 0.2.15 + # rejects locks generated on a different host than the container's target OS + # (e.g. host-side P3M URL vs CRAN source URL inside the container). Default + # is FALSE to keep the build green; opt in once your environment supports it. + sync_cmd <- if (isTRUE(frozen)) "uvr sync --frozen" else "uvr sync" + dock$RUN(sync_cmd) + + dock$COPY(from = ".", to = "/srv/shiny-server/") + + # `uvr run` takes a script, not arbitrary R args, so embed the launch as a + # tiny R file written into the image at build time. host/port are validated + # by the public entry point; we still escape them defensively for the shell + # and the R source. + port_int <- as.integer(port) + r_call <- sprintf( + "shiny::runApp(appDir = '/srv/shiny-server', host = %s, port = %d)", + encodeString(host, quote = "\""), port_int + ) + dock$RUN(sprintf( + "printf '%%s\\n' %s > /srv/shiny-server/_uvr_start.R", + shQuote(r_call) + )) + + dock$EXPOSE(port) + + dock$CMD("uvr run /srv/shiny-server/_uvr_start.R") + + dock +} diff --git a/README.Rmd b/README.Rmd index e6ecbba..6782492 100644 --- a/README.Rmd +++ b/README.Rmd @@ -105,6 +105,125 @@ set_github_action(path = ".") Once the `docker-build.yml` file is in place, you can integrate it with GitHub Actions to automate the Docker image build and deployment process. +## Experimental: uvr backend (`shiny2docker_uvr()`) + +`shiny2docker_uvr()` is a sister function that generates a Dockerfile using +[**uvr**](https://github.com/nbafrank/uvr) — a Rust-based, uv-style R project +manager — instead of `renv`. With this backend: + +- The R version is installed **inside the container** by `uvr` + (no `rocker/r-ver` base image required); pick any Debian/Ubuntu-derived + amd64 image you want. +- Packages are restored from `uvr.lock` via `uvr sync` at build time. +- Manifest, lockfile and R version live in three small files at the project + root: `uvr.toml`, `uvr.lock`, `.r-version`. + +> **Lifecycle: experimental.** It works end-to-end on Linux, but on Debian +> targets `uvr` 0.2.15 currently falls back to source compilation for some +> packages (P3M binary detection still maturing), so cold builds can be slow. +> See the [uvr README](https://github.com/nbafrank/uvr) for the upstream +> roadmap. + +### Install the `uvr` CLI from R + +You don't have to leave R to install the binary — `install_uvr()` downloads +the right release for your platform and drops the executable in a sensible +place: + +```r +shiny2docker::install_uvr() +# Linux/macOS default: ~/.local/bin/uvr +# Windows default: %LOCALAPPDATA%\shiny2docker\bin\uvr.exe +``` + +If `~/.local/bin` (or the Windows equivalent) isn't on your `PATH` yet, +`install_uvr()` prints a one-line `export`/`setx` hint to make it +permanent. + +You can also pin a specific release or a custom destination: + +```r +shiny2docker::install_uvr(version = "v0.2.15", dest = "/usr/local/bin") +``` + +### Generate the Dockerfile + +```r +library(shiny2docker) + +# In a directory containing your shiny app: +shiny2docker_uvr() +``` + +If `uvr.toml` / `uvr.lock` / `.r-version` are missing, the function bootstraps +them automatically (same UX as `shiny2docker()` does for `renv.lock`): + +1. generate `renv.lock` via `attachment::create_renv_for_prod()` if missing, +2. run `uvr init` + `uvr import` to produce `uvr.toml`, +3. pin `.r-version` to the local R version, +4. run `uvr lock` to produce `uvr.lock`. + +Set `bootstrap = FALSE` if you want it to fail fast instead — useful in CI +where you expect those three files to already exist. + +If the `uvr` CLI isn't installed when bootstrapping is needed and the +session is interactive, you'll be prompted to install it on the spot. + +### The `frozen` argument + +Reproducibility detail worth understanding before deploying: + +- `uvr.toml` is the **manifest** ("I want shiny, dplyr, …"). +- `uvr.lock` is the **lockfile** ("here are the exact versions: shiny 1.10.0, + dplyr 1.1.4, …"). It's the one to commit and trust as source of truth. + +Inside the generated Dockerfile, the package install step is either: + +- `uvr sync --frozen` — strict CI mode. **If `uvr.lock` is out of date + relative to `uvr.toml`, the build fails.** This is what you want long-term: + the image installs *exactly* what's in your committed lockfile, no surprise. +- `uvr sync` — lenient mode. If `uvr.lock` looks stale, uvr re-resolves on + the fly. Build still succeeds, but the image may contain package versions + that aren't pinned in your committed lock — you've quietly lost + reproducibility. + +`shiny2docker_uvr()` defaults to `frozen = FALSE` (lenient) and emits a +`cli` warning to make this explicit. Why not strict by default? Because +`uvr` 0.2.15 has a known false-positive: a lockfile generated on host OS A +gets rejected as "stale" by `uvr sync --frozen` running inside a container +based on host OS B (Ubuntu host → Debian image is the typical case). So +flipping `--frozen` on by default would break builds for the wrong reason. + +Switch to `frozen = TRUE` once any of these is true: + +- upstream uvr fixes the cross-host false positive (track + [nbafrank/uvr](https://github.com/nbafrank/uvr)); +- your dev host runs the same distribution as your container (e.g. Debian + dev → Debian image); +- you're confident the lock will always be regenerated on the same OS as + the Docker target. + +```r +# Once your environment supports it: +shiny2docker_uvr(frozen = TRUE) +``` + +### Other useful arguments + +```r +shiny2docker_uvr( + path = ".", + base_image = "debian:stable-slim", # any Debian/Ubuntu amd64 image + uvr_version = "v0.2.15", # pin the uvr binary version + port = 3838, + host = "0.0.0.0", + extra_sysreqs = c("libsqlite3-dev"), # injected before `uvr sync` + bootstrap = TRUE, # auto-create missing uvr files + frozen = FALSE, # see above + write = TRUE # FALSE -> return Dockerfile only +) +``` + ## Example Workflow 1. **Prepare Your Shiny Application**: diff --git a/README.md b/README.md index dcda84e..081e82e 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,130 @@ Once the `docker-build.yml` file is in place, you can integrate it with GitHub Actions to automate the Docker image build and deployment process. +## Experimental: uvr backend (`shiny2docker_uvr()`) + +`shiny2docker_uvr()` is a sister function that generates a Dockerfile +using [**uvr**](https://github.com/nbafrank/uvr) — a Rust-based, +uv-style R project manager — instead of `renv`. With this backend: + +- The R version is installed **inside the container** by `uvr` (no + `rocker/r-ver` base image required); pick any Debian/Ubuntu-derived + amd64 image you want. +- Packages are restored from `uvr.lock` via `uvr sync` at build time. +- Manifest, lockfile and R version live in three small files at the + project root: `uvr.toml`, `uvr.lock`, `.r-version`. + +> **Lifecycle: experimental.** It works end-to-end on Linux, but on +> Debian targets `uvr` 0.2.15 currently falls back to source compilation +> for some packages (P3M binary detection still maturing), so cold +> builds can be slow. See the [uvr +> README](https://github.com/nbafrank/uvr) for the upstream roadmap. + +### Install the `uvr` CLI from R + +You don’t have to leave R to install the binary — `install_uvr()` +downloads the right release for your platform and drops the executable +in a sensible place: + +``` r +shiny2docker::install_uvr() +# Linux/macOS default: ~/.local/bin/uvr +# Windows default: %LOCALAPPDATA%\shiny2docker\bin\uvr.exe +``` + +If `~/.local/bin` (or the Windows equivalent) isn’t on your `PATH` yet, +`install_uvr()` prints a one-line `export`/`setx` hint to make it +permanent. + +You can also pin a specific release or a custom destination: + +``` r +shiny2docker::install_uvr(version = "v0.2.15", dest = "/usr/local/bin") +``` + +### Generate the Dockerfile + +``` r +library(shiny2docker) + +# In a directory containing your shiny app: +shiny2docker_uvr() +``` + +If `uvr.toml` / `uvr.lock` / `.r-version` are missing, the function +bootstraps them automatically (same UX as `shiny2docker()` does for +`renv.lock`): + +1. generate `renv.lock` via `attachment::create_renv_for_prod()` if + missing, +2. run `uvr init` + `uvr import` to produce `uvr.toml`, +3. pin `.r-version` to the local R version, +4. run `uvr lock` to produce `uvr.lock`. + +Set `bootstrap = FALSE` if you want it to fail fast instead — useful in +CI where you expect those three files to already exist. + +If the `uvr` CLI isn’t installed when bootstrapping is needed and the +session is interactive, you’ll be prompted to install it on the spot. + +### The `frozen` argument + +Reproducibility detail worth understanding before deploying: + +- `uvr.toml` is the **manifest** (“I want shiny, dplyr, …”). +- `uvr.lock` is the **lockfile** (“here are the exact versions: shiny + 1.10.0, dplyr 1.1.4, …”). It’s the one to commit and trust as source + of truth. + +Inside the generated Dockerfile, the package install step is either: + +- `uvr sync --frozen` — strict CI mode. **If `uvr.lock` is out of date + relative to `uvr.toml`, the build fails.** This is what you want + long-term: the image installs *exactly* what’s in your committed + lockfile, no surprise. +- `uvr sync` — lenient mode. If `uvr.lock` looks stale, uvr re-resolves + on the fly. Build still succeeds, but the image may contain package + versions that aren’t pinned in your committed lock — you’ve quietly + lost reproducibility. + +`shiny2docker_uvr()` defaults to `frozen = FALSE` (lenient) and emits a +`cli` warning to make this explicit. Why not strict by default? Because +`uvr` 0.2.15 has a known false-positive: a lockfile generated on host OS +A gets rejected as “stale” by `uvr sync --frozen` running inside a +container based on host OS B (Ubuntu host → Debian image is the typical +case). So flipping `--frozen` on by default would break builds for the +wrong reason. + +Switch to `frozen = TRUE` once any of these is true: + +- upstream uvr fixes the cross-host false positive (track + [nbafrank/uvr](https://github.com/nbafrank/uvr)); +- your dev host runs the same distribution as your container + (e.g. Debian dev → Debian image); +- you’re confident the lock will always be regenerated on the same OS as + the Docker target. + +``` r +# Once your environment supports it: +shiny2docker_uvr(frozen = TRUE) +``` + +### Other useful arguments + +``` r +shiny2docker_uvr( + path = ".", + base_image = "debian:stable-slim", # any Debian/Ubuntu amd64 image + uvr_version = "v0.2.15", # pin the uvr binary version + port = 3838, + host = "0.0.0.0", + extra_sysreqs = c("libsqlite3-dev"), # injected before `uvr sync` + bootstrap = TRUE, # auto-create missing uvr files + frozen = FALSE, # see above + write = TRUE # FALSE -> return Dockerfile only +) +``` + ## Example Workflow 1. **Prepare Your Shiny Application**: diff --git a/man/install_uvr.Rd b/man/install_uvr.Rd new file mode 100644 index 0000000..4a6eed6 --- /dev/null +++ b/man/install_uvr.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_uvr.R +\name{install_uvr} +\alias{install_uvr} +\title{Install the \code{uvr} CLI binary for the current platform} +\usage{ +install_uvr( + dest = uvr_default_install_dir(), + version = "latest", + force = FALSE +) +} +\arguments{ +\item{dest}{Directory where the binary should land. Created if missing. +Default is platform-aware: \verb{~/.local/bin/} on Unix (Linux/macOS), +\verb{\%LOCALAPPDATA\%/shiny2docker/bin/} on Windows.} + +\item{version}{Release tag, e.g. \code{"v0.2.15"}, or \code{"latest"}.} + +\item{force}{If \code{TRUE}, install even when \code{uvr} is already on \code{PATH}.} +} +\value{ +Invisibly, the absolute path to the installed binary. +} +\description{ +Downloads the matching release asset from +\url{https://github.com/nbafrank/uvr/releases} and places the \code{uvr} +executable under \code{dest} (default \verb{~/.local/bin/}). After install, +\code{\link[=shiny2docker_uvr]{shiny2docker_uvr()}} picks it up automatically. +} diff --git a/man/shiny2docker_uvr.Rd b/man/shiny2docker_uvr.Rd new file mode 100644 index 0000000..ca89099 --- /dev/null +++ b/man/shiny2docker_uvr.Rd @@ -0,0 +1,97 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shiny2docker_uvr.R +\name{shiny2docker_uvr} +\alias{shiny2docker_uvr} +\title{shiny2docker_uvr} +\usage{ +shiny2docker_uvr( + path = ".", + output = file.path(path, "Dockerfile"), + base_image = "debian:stable-slim", + uvr_version = "latest", + port = 3838, + host = "0.0.0.0", + extra_sysreqs = NULL, + bootstrap = TRUE, + frozen = FALSE, + write = TRUE +) +} +\arguments{ +\item{path}{Character. Path to the folder containing the Shiny application +\emph{and} the uvr files (\code{uvr.toml}, \code{uvr.lock}, \code{.r-version}).} + +\item{output}{Character. Path to the generated Dockerfile.} + +\item{base_image}{Character. Docker base image. Defaults to \code{"debian:stable-slim"}. +Must be a Debian/Ubuntu-based amd64 image (see "Platform constraints" above).} + +\item{uvr_version}{Character. \code{"latest"} or a semver tag like \code{"v0.3.1"}. +Pinning a tag is recommended for reproducible builds.} + +\item{port}{Integer in \verb{[1, 65535]}. Shiny port to expose. Default \code{3838}.} + +\item{host}{Character. Shiny host. Default \code{"0.0.0.0"}. Single value, no +shell metacharacters; validated before being interpolated into the image.} + +\item{extra_sysreqs}{Character vector of extra debian package names to +install before \verb{uvr sync} (escape hatch for system dependencies that uvr +does not detect on its own). Each entry must match +\verb{[A-Za-z0-9.+:-]+} to avoid shell injection.} + +\item{bootstrap}{Logical. If \code{TRUE} (default), missing \code{uvr.toml} / +\code{uvr.lock} / \code{.r-version} are auto-generated by calling the local \code{uvr} +CLI (and \code{attachment::create_renv_for_prod()} for the dep list). When +\code{FALSE}, missing files trigger an immediate error, matching the strict +behaviour expected in CI. Requires the \code{uvr} binary on \code{PATH} only when +actual bootstrapping is needed.} + +\item{frozen}{Logical. If \code{TRUE}, the generated Dockerfile runs +\verb{uvr sync --frozen} so the build fails when \code{uvr.lock} is out of date +relative to \code{uvr.toml}. Default \code{FALSE} because uvr 0.2.15 rejects +locks generated on a different host than the container's target OS; +opt in once your setup supports it. When \code{FALSE}, an \code{cli} warning is +emitted to make the relaxed strictness explicit.} + +\item{write}{Logical. Whether to write the Dockerfile and \code{.dockerignore} +to disk. When \code{FALSE}, the function only returns the in-memory Dockerfile +object and does not touch the filesystem. Default \code{TRUE}. The parent +directory of \code{output} is created if it does not already exist.} +} +\value{ +Invisibly, an R6 \code{dockerfiler::Dockerfile} object that can be further +customised before writing. +} +\description{ +Generate a Dockerfile for a Shiny application using +\href{https://github.com/nbafrank/uvr}{uvr} instead of \code{renv}. The R version is +installed inside the container by \code{uvr} (no \code{rocker/r-ver} base image +required) and packages are restored from \code{uvr.lock} via \verb{uvr sync}. +} +\details{ +Lifecycle: experimental. The function expects (or bootstraps) the three +uvr files — \code{uvr.toml}, \code{uvr.lock}, and \code{.r-version} — at the root of +\code{path}, under those exact filenames (the generated Dockerfile copies them +by name from the build context, matching uvr's own convention). + +Bootstrapping (default \code{bootstrap = TRUE}): if any of the three files is +missing, \code{shiny2docker_uvr()} creates them automatically — the same way +\code{shiny2docker()} auto-creates \code{renv.lock} via +\code{attachment::create_renv_for_prod()}. Concretely, when \code{uvr.toml} is +missing, we generate a \code{renv.lock} (via \code{attachment}) if needed, then run +\verb{uvr init} + \verb{uvr import}. When \code{.r-version} is missing, we pin to the +current local R version. When \code{uvr.lock} is missing or stale, we run +\verb{uvr lock}. This requires the \code{uvr} CLI on \code{PATH}; if absent, you get an +actionable error with install instructions. Set \code{bootstrap = FALSE} to +fail fast instead. + +What \code{uvr_assert_state()} checks after bootstrapping: the three files exist, +\code{uvr.lock} is at least as recent as \code{uvr.toml} (mtime check), and +\code{.r-version} contains a single \code{MAJOR.MINOR.PATCH} line. It does \strong{not} +parse \code{uvr.toml} to cross-validate the pinned R version against any +\verb{[r] version} constraint — uvr itself owns that consistency contract. + +Platform constraints: the generated Dockerfile uses \code{apt-get} and Debian +package names, and downloads the \code{x86_64-unknown-linux-gnu} uvr binary, so +\code{base_image} must be a Debian/Ubuntu-derived image on amd64/glibc. +} diff --git a/tests/testthat/fixtures/uvr-app/.gitignore b/tests/testthat/fixtures/uvr-app/.gitignore new file mode 100644 index 0000000..d7f2a6c --- /dev/null +++ b/tests/testthat/fixtures/uvr-app/.gitignore @@ -0,0 +1 @@ +/.uvr/library/ diff --git a/tests/testthat/fixtures/uvr-app/.r-version b/tests/testthat/fixtures/uvr-app/.r-version new file mode 100644 index 0000000..1d068c6 --- /dev/null +++ b/tests/testthat/fixtures/uvr-app/.r-version @@ -0,0 +1 @@ +4.4.2 diff --git a/tests/testthat/fixtures/uvr-app/app.R b/tests/testthat/fixtures/uvr-app/app.R new file mode 100644 index 0000000..17efbe3 --- /dev/null +++ b/tests/testthat/fixtures/uvr-app/app.R @@ -0,0 +1,9 @@ +library(shiny) +ui <- fluidPage( + titlePanel("uvr POC"), + textOutput("msg") +) +server <- function(input, output, session) { + output$msg <- renderText("Hello from a uvr-built shiny container.") +} +shinyApp(ui, server) diff --git a/tests/testthat/fixtures/uvr-app/uvr.lock b/tests/testthat/fixtures/uvr-app/uvr.lock new file mode 100644 index 0000000..f9bed03 --- /dev/null +++ b/tests/testthat/fixtures/uvr-app/uvr.lock @@ -0,0 +1,329 @@ +[r] +version = "*" + +[[package]] +name = "R6" +version = "2.6.1" +source = "cran" +raw_version = "2.6.1" +url = "https://cran.r-project.org/src/contrib/R6_2.6.1.tar.gz" +checksum = "md5:f01b1787f12797c29194d63c9afd5d70" + +[[package]] +name = "Rcpp" +version = "1.1.1-4.1" +source = "cran" +raw_version = "1.1.1-1.1" +url = "https://cran.r-project.org/src/contrib/Rcpp_1.1.1-1.1.tar.gz" +checksum = "md5:ad526121c2f2b9b3be8ce1f36aff7888" + +[[package]] +name = "base64enc" +version = "0.1.6" +source = "cran" +raw_version = "0.1-6" +url = "https://cran.r-project.org/src/contrib/base64enc_0.1-6.tar.gz" +checksum = "md5:2c4ecc969451a673ee05eb2e725fcbaf" + +[[package]] +name = "bslib" +version = "0.10.0" +source = "cran" +raw_version = "0.10.0" +url = "https://cran.r-project.org/src/contrib/bslib_0.10.0.tar.gz" +checksum = "md5:63fd55ca6856c3c55d65e2fe5ccd2cd2" +requires = [ + "base64enc", + "cachem", + "fastmap", + "htmltools", + "jquerylib", + "jsonlite", + "lifecycle", + "memoise", + "mime", + "rlang", + "sass", +] + +[[package]] +name = "cachem" +version = "1.1.0" +source = "cran" +raw_version = "1.1.0" +url = "https://cran.r-project.org/src/contrib/cachem_1.1.0.tar.gz" +checksum = "md5:53980eb40dd09b0cd44673d3a6fabe36" +requires = [ + "rlang", + "fastmap", +] + +[[package]] +name = "cli" +version = "3.6.6" +source = "cran" +raw_version = "3.6.6" +url = "https://cran.r-project.org/src/contrib/cli_3.6.6.tar.gz" +checksum = "md5:eedc08ba864ae6cd640b05eff1a607ee" + +[[package]] +name = "commonmark" +version = "2.0.0" +source = "cran" +raw_version = "2.0.0" +url = "https://cran.r-project.org/src/contrib/commonmark_2.0.0.tar.gz" +checksum = "md5:ac18da7841af62e8c457dd4872cce1ba" + +[[package]] +name = "digest" +version = "0.6.39" +source = "cran" +raw_version = "0.6.39" +url = "https://cran.r-project.org/src/contrib/digest_0.6.39.tar.gz" +checksum = "md5:2b7268922b0f86dbc8064ff8e3a1ca1b" + +[[package]] +name = "fastmap" +version = "1.2.0" +source = "cran" +raw_version = "1.2.0" +url = "https://cran.r-project.org/src/contrib/fastmap_1.2.0.tar.gz" +checksum = "md5:f53773acd6ba9a86413e8c2dc6441544" + +[[package]] +name = "fontawesome" +version = "0.5.3" +source = "cran" +raw_version = "0.5.3" +url = "https://cran.r-project.org/src/contrib/fontawesome_0.5.3.tar.gz" +checksum = "md5:95b072a27418b962792410dfb21c3d2b" +requires = [ + "rlang", + "htmltools", +] + +[[package]] +name = "fs" +version = "2.1.0" +source = "cran" +raw_version = "2.1.0" +url = "https://cran.r-project.org/src/contrib/fs_2.1.0.tar.gz" +checksum = "md5:6011a5e758c21acc297ed8bbcf97e7d9" + +[[package]] +name = "glue" +version = "1.8.1" +source = "cran" +raw_version = "1.8.1" +url = "https://cran.r-project.org/src/contrib/glue_1.8.1.tar.gz" +checksum = "md5:24e3cd382f52015c929538a5208d11aa" + +[[package]] +name = "htmltools" +version = "0.5.9" +source = "cran" +raw_version = "0.5.9" +url = "https://cran.r-project.org/src/contrib/htmltools_0.5.9.tar.gz" +checksum = "md5:f4a0cac920fb54b5c5ea94e175e20067" +requires = [ + "base64enc", + "digest", + "fastmap", + "rlang", +] + +[[package]] +name = "httpuv" +version = "1.6.17" +source = "cran" +raw_version = "1.6.17" +url = "https://cran.r-project.org/src/contrib/httpuv_1.6.17.tar.gz" +checksum = "md5:50763765704e547c15aac4be97cec437" +requires = [ + "later", + "promises", + "R6", + "Rcpp", + "later", + "Rcpp", +] + +[[package]] +name = "jquerylib" +version = "0.1.4" +source = "cran" +raw_version = "0.1.4" +url = "https://cran.r-project.org/src/contrib/jquerylib_0.1.4.tar.gz" +checksum = "md5:3607a34ac45ff420bc325d84eab4b0cb" +requires = ["htmltools"] + +[[package]] +name = "jsonlite" +version = "2.0.0" +source = "cran" +raw_version = "2.0.0" +url = "https://cran.r-project.org/src/contrib/jsonlite_2.0.0.tar.gz" +checksum = "md5:3e54e6fbc0c9063936e3d01e91419c14" + +[[package]] +name = "later" +version = "1.4.8" +source = "cran" +raw_version = "1.4.8" +url = "https://cran.r-project.org/src/contrib/later_1.4.8.tar.gz" +checksum = "md5:7ebbfd8a26cd6341a6d9d03ba18d259c" +requires = [ + "Rcpp", + "rlang", + "Rcpp", +] + +[[package]] +name = "lifecycle" +version = "1.0.5" +source = "cran" +raw_version = "1.0.5" +url = "https://cran.r-project.org/src/contrib/lifecycle_1.0.5.tar.gz" +checksum = "md5:bce35a7a89fe4108f262db3e441cccae" +requires = [ + "cli", + "rlang", +] + +[[package]] +name = "magrittr" +version = "2.0.5" +source = "cran" +raw_version = "2.0.5" +url = "https://cran.r-project.org/src/contrib/magrittr_2.0.5.tar.gz" +checksum = "md5:4ee23a88bc1589527696d777d7e68dfc" + +[[package]] +name = "memoise" +version = "2.0.1" +source = "cran" +raw_version = "2.0.1" +url = "https://cran.r-project.org/src/contrib/memoise_2.0.1.tar.gz" +checksum = "md5:89da4ce771967d851db47132b762ce6f" +requires = [ + "rlang", + "cachem", +] + +[[package]] +name = "mime" +version = "0.13.0" +source = "cran" +raw_version = "0.13" +url = "https://cran.r-project.org/src/contrib/mime_0.13.tar.gz" +checksum = "md5:45c3868fb2f5a7f3817d54d1c75799f9" + +[[package]] +name = "otel" +version = "0.2.0" +source = "cran" +raw_version = "0.2.0" +url = "https://cran.r-project.org/src/contrib/otel_0.2.0.tar.gz" +checksum = "md5:cecd0f434a6327dcf5ca0bc2da494d06" + +[[package]] +name = "promises" +version = "1.5.0" +source = "cran" +raw_version = "1.5.0" +url = "https://cran.r-project.org/src/contrib/promises_1.5.0.tar.gz" +checksum = "md5:365a4097be36772605653818da60eae6" +requires = [ + "fastmap", + "later", + "lifecycle", + "magrittr", + "otel", + "R6", + "rlang", +] + +[[package]] +name = "rappdirs" +version = "0.3.4" +source = "cran" +raw_version = "0.3.4" +url = "https://cran.r-project.org/src/contrib/rappdirs_0.3.4.tar.gz" +checksum = "md5:a990a3ddcfa9eb422cd406f78084fbd8" + +[[package]] +name = "rlang" +version = "1.2.0" +source = "cran" +raw_version = "1.2.0" +url = "https://cran.r-project.org/src/contrib/rlang_1.2.0.tar.gz" +checksum = "md5:df0168c2a86c038133125ee054a646c9" + +[[package]] +name = "sass" +version = "0.4.10" +source = "cran" +raw_version = "0.4.10" +url = "https://cran.r-project.org/src/contrib/sass_0.4.10.tar.gz" +checksum = "md5:16054a7117a81ba75893fec17da62209" +requires = [ + "fs", + "rlang", + "htmltools", + "R6", + "rappdirs", +] + +[[package]] +name = "shiny" +version = "1.13.0" +source = "cran" +raw_version = "1.13.0" +url = "https://cran.r-project.org/src/contrib/shiny_1.13.0.tar.gz" +checksum = "md5:855af399bccf9df94450f850580012e5" +requires = [ + "bslib", + "cachem", + "cli", + "commonmark", + "fastmap", + "fontawesome", + "glue", + "htmltools", + "httpuv", + "jsonlite", + "later", + "lifecycle", + "mime", + "otel", + "promises", + "R6", + "rlang", + "sourcetools", + "withr", + "xtable", +] + +[[package]] +name = "sourcetools" +version = "0.1.7-4.2" +source = "cran" +raw_version = "0.1.7-2" +url = "https://cran.r-project.org/src/contrib/sourcetools_0.1.7-2.tar.gz" +checksum = "md5:b30ed63295fc9659f7526914d6687e0e" + +[[package]] +name = "withr" +version = "3.0.2" +source = "cran" +raw_version = "3.0.2" +url = "https://cran.r-project.org/src/contrib/withr_3.0.2.tar.gz" +checksum = "md5:f2e3780a71f6258d6f9055f138d474f2" + +[[package]] +name = "xtable" +version = "1.8.8" +source = "cran" +raw_version = "1.8-8" +url = "https://cran.r-project.org/src/contrib/xtable_1.8-8.tar.gz" +checksum = "md5:4d07e6f65181f7227fac2bd3e6122a0b" diff --git a/tests/testthat/fixtures/uvr-app/uvr.toml b/tests/testthat/fixtures/uvr-app/uvr.toml new file mode 100644 index 0000000..4663b2d --- /dev/null +++ b/tests/testthat/fixtures/uvr-app/uvr.toml @@ -0,0 +1,5 @@ +[project] +name = "uvr-poc" + +[dependencies] +shiny = "*" diff --git a/tests/testthat/test-shiny2docker_uvr-integration.R b/tests/testthat/test-shiny2docker_uvr-integration.R new file mode 100644 index 0000000..496c632 --- /dev/null +++ b/tests/testthat/test-shiny2docker_uvr-integration.R @@ -0,0 +1,125 @@ +# Heavy end-to-end test: builds a Docker image from the uvr fixture, starts it, +# and probes the running shiny app over HTTP. Skipped by default — only runs +# when SHINY2DOCKER_TEST_INTEGRATION=true and `docker` is on PATH. +# +# Local run: +# SHINY2DOCKER_TEST_INTEGRATION=true Rscript -e \ +# 'devtools::test_active_file("tests/testthat/test-shiny2docker_uvr-integration.R")' +# +# Expect 5–10 min on a cold cache (uvr 0.2.15 currently compiles all R deps +# from source on debian targets). + +skip_unless_docker_uvr_integration <- function() { + testthat::skip_on_cran() + if (!identical(Sys.getenv("SHINY2DOCKER_TEST_INTEGRATION"), "true")) { + testthat::skip("Set SHINY2DOCKER_TEST_INTEGRATION=true to run.") + } + if (!nzchar(Sys.which("docker"))) { + testthat::skip("docker not on PATH") + } + if (system("docker info > /dev/null 2>&1") != 0) { + testthat::skip("docker daemon not reachable") + } +} + +http_probe <- function(host, port, path = "/", tries = 30L, delay = 2) { + request <- sprintf( + "GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", + path, host, port + ) + one_attempt <- function() { + con <- NULL + on.exit(if (!is.null(con)) try(close(con), silent = TRUE), add = TRUE) + con <- tryCatch( + suppressWarnings(socketConnection(host = host, port = port, + blocking = TRUE, open = "r+", + timeout = 5)), + error = function(e) NULL + ) + if (is.null(con)) return(NA_integer_) + tryCatch({ + writeLines(request, con, sep = "") + first <- readLines(con, n = 1, warn = FALSE) + m <- regmatches(first, regexec("^HTTP/[0-9.]+ ([0-9]{3})", first))[[1]] + if (length(m) >= 2) as.integer(m[2]) else NA_integer_ + }, error = function(e) NA_integer_) + } + for (i in seq_len(tries)) { + status <- one_attempt() + if (isTRUE(status == 200L)) return(200L) + Sys.sleep(delay) + } + NA_integer_ +} + +test_that("shiny2docker_uvr produces an image that serves a shiny app over HTTP", { + skip_unless_docker_uvr_integration() + + # Stage the committed fixture into a tmp build context (we don't want the + # Dockerfile/.dockerignore the test generates polluting the repo). + # The fixture dir is excluded from the package tarball via .Rbuildignore, + # so when running R CMD check on an installed package it is absent. + # Skip rather than fail in that case. + src_fixture <- testthat::test_path("fixtures", "uvr-app") + if (!dir.exists(src_fixture)) { + testthat::skip(paste0( + "Fixture not found at '", src_fixture, + "' (likely installed package, not source). Run from the source repo." + )) + } + ctx <- tempfile("uvr_app_ctx_"); dir.create(ctx) + file.copy(list.files(src_fixture, all.files = TRUE, no.. = TRUE, + full.names = TRUE), + ctx, recursive = TRUE, copy.date = TRUE) + # Make sure uvr.lock is at least as recent as uvr.toml after copy. + Sys.setFileTime(file.path(ctx, "uvr.lock"), Sys.time()) + + shiny2docker_uvr( + path = ctx, + uvr_version = "v0.2.15" + ) + expect_true(file.exists(file.path(ctx, "Dockerfile"))) + expect_true(file.exists(file.path(ctx, ".dockerignore"))) + + tag <- paste0("shiny2docker-uvr-it:", as.integer(Sys.time())) + port <- sample(20000:39999, 1) + container <- sub("^.*:", "s2duvr-it-", tag) + + on.exit({ + system(paste("docker rm -f", shQuote(container), "> /dev/null 2>&1")) + system(paste("docker rmi -f", shQuote(tag), "> /dev/null 2>&1")) + unlink(ctx, recursive = TRUE) + }, add = TRUE) + + t0 <- Sys.time() + build_rc <- system2( + "docker", + args = c("build", "-q", "-t", tag, ctx), + stdout = TRUE, stderr = TRUE + ) + build_dt <- as.numeric(difftime(Sys.time(), t0, units = "secs")) + expect_true(is.null(attr(build_rc, "status")), + info = paste("docker build failed:", + paste(build_rc, collapse = "\n"))) + message(sprintf("[uvr-IT] docker build: %.1fs", build_dt)) + + size_str <- system2( + "docker", + args = c("images", tag, "--format", "{{.Size}}"), + stdout = TRUE + ) + message(sprintf("[uvr-IT] image size: %s", size_str)) + + run_rc <- system2( + "docker", + args = c("run", "-d", "--name", container, "-p", + paste0(port, ":3838"), tag), + stdout = TRUE, stderr = TRUE + ) + expect_true(is.null(attr(run_rc, "status")) || attr(run_rc, "status") == 0, + info = paste("docker run failed:", paste(run_rc, collapse = "\n"))) + + status <- http_probe(host = "127.0.0.1", port = port, path = "/", + tries = 30L, delay = 2) + expect_identical(status, 200L) +}) diff --git a/tests/testthat/test-shiny2docker_uvr.R b/tests/testthat/test-shiny2docker_uvr.R new file mode 100644 index 0000000..bda5f14 --- /dev/null +++ b/tests/testthat/test-shiny2docker_uvr.R @@ -0,0 +1,266 @@ +# Helper: lay down a minimal uvr-ready project in a tmp dir +make_uvr_fixture <- function() { + tmp <- tempfile("uvrproj_") + dir.create(tmp) + writeLines("4.4.2", file.path(tmp, ".r-version")) + writeLines( + c( + "[project]", + "name = \"demo\"", + "r-version = \">=4.3\"", + "", + "[dependencies]", + "shiny = \"*\"" + ), + file.path(tmp, "uvr.toml") + ) + # uvr.lock placeholder; mtime must be >= uvr.toml's mtime + writeLines("# uvr.lock fixture", file.path(tmp, "uvr.lock")) + Sys.setFileTime(file.path(tmp, "uvr.lock"), Sys.time() + 1) + writeLines("library(shiny); shinyApp(fluidPage(), function(input,output){})", + file.path(tmp, "app.R")) + tmp +} + +test_that("uvr_release_url returns latest URL when version = 'latest'", { + out <- uvr_release_url("latest") + expect_match(out$url, "releases/latest/download/uvr-x86_64-unknown-linux-gnu\\.tar\\.gz$") + expect_true(is.na(out$arg_value)) +}) + +test_that("uvr_release_url accepts pinned tags with or without leading v", { + a <- uvr_release_url("0.3.1") + b <- uvr_release_url("v0.3.1") + expect_identical(a$url, b$url) + expect_match(a$url, "releases/download/v0\\.3\\.1/") + expect_identical(a$arg_value, "v0.3.1") +}) + +test_that("uvr_release_url rejects garbage versions", { + expect_error(uvr_release_url("nope"), "semver") + expect_error(uvr_release_url("1.2"), "semver") + expect_error(uvr_release_url("v0.3.1junk"), "semver") + expect_error(uvr_release_url("0.3.1-beta"), "semver") +}) + +test_that("uvr_validate_host_port rejects shell-unsafe values", { + expect_silent(uvr_validate_host_port("0.0.0.0", 3838)) + expect_silent(uvr_validate_host_port("my-host_1.local", 80)) + expect_error(uvr_validate_host_port("0.0.0.0; rm -rf /", 3838), "host") + expect_error(uvr_validate_host_port("0.0.0.0", 0), "port") + expect_error(uvr_validate_host_port("0.0.0.0", 99999), "port") + expect_error(uvr_validate_host_port("0.0.0.0", "3838"), "port") + expect_error(uvr_validate_host_port(NA, 3838), "host") + expect_error(uvr_validate_host_port(c("a","b"), 3838), "host") +}) + +test_that("uvr_validate_sysreqs rejects shell-unsafe entries", { + expect_silent(uvr_validate_sysreqs(NULL)) + expect_silent(uvr_validate_sysreqs(character())) + expect_silent(uvr_validate_sysreqs(c("libpq-dev", "libsqlite3-dev"))) + expect_error(uvr_validate_sysreqs(c("libpq-dev", "evil; rm -rf /")), "Invalid") + expect_error(uvr_validate_sysreqs(c("libpq-dev", NA)), "NA") +}) + +test_that("shiny2docker_uvr propagates validation errors", { + tmp <- make_uvr_fixture() + expect_error( + shiny2docker_uvr(path = tmp, host = "0.0.0.0; whoami", write = FALSE), + "host" + ) + expect_error( + shiny2docker_uvr(path = tmp, port = 70000, write = FALSE), + "port" + ) + expect_error( + shiny2docker_uvr(path = tmp, + extra_sysreqs = c("libpq-dev", "evil;cmd"), + write = FALSE), + "Invalid" + ) +}) + +test_that("shiny2docker_uvr does not write a .dockerignore when write = FALSE", { + tmp <- make_uvr_fixture() + out_file <- file.path(tmp, "Dockerfile") + suppressMessages(shiny2docker_uvr(path = tmp, output = out_file, write = FALSE)) + expect_false(file.exists(out_file)) + expect_false(file.exists(file.path(tmp, ".dockerignore"))) +}) + +test_that("uvr_assert_state errors when files are missing", { + tmp <- tempfile("uvr_missing_"); dir.create(tmp) + expect_error( + uvr_assert_state(file.path(tmp, "uvr.toml"), + file.path(tmp, "uvr.lock"), + file.path(tmp, ".r-version")), + "uvr.toml not found" + ) +}) + +test_that("uvr_assert_state errors when .r-version is missing or malformed", { + tmp <- make_uvr_fixture() + rv <- file.path(tmp, ".r-version") + + # missing + unlink(rv) + expect_error( + uvr_assert_state(file.path(tmp, "uvr.toml"), + file.path(tmp, "uvr.lock"), + rv), + ".r-version not found" + ) + # multi-line + writeLines(c("4.4.2", "4.5.0"), rv) + Sys.setFileTime(file.path(tmp, "uvr.lock"), Sys.time() + 1) + expect_error( + uvr_assert_state(file.path(tmp, "uvr.toml"), + file.path(tmp, "uvr.lock"), + rv), + "MAJOR\\.MINOR\\.PATCH" + ) + # garbage content + writeLines("not-a-version", rv) + Sys.setFileTime(file.path(tmp, "uvr.lock"), Sys.time() + 1) + expect_error( + uvr_assert_state(file.path(tmp, "uvr.toml"), + file.path(tmp, "uvr.lock"), + rv), + "MAJOR\\.MINOR\\.PATCH" + ) + # empty file + writeLines(character(), rv) + Sys.setFileTime(file.path(tmp, "uvr.lock"), Sys.time() + 1) + expect_error( + uvr_assert_state(file.path(tmp, "uvr.toml"), + file.path(tmp, "uvr.lock"), + rv), + "MAJOR\\.MINOR\\.PATCH" + ) + # trailing whitespace / blank lines around a valid version: accepted + writeLines(c("", "4.4.2", " "), rv) + Sys.setFileTime(file.path(tmp, "uvr.lock"), Sys.time() + 1) + expect_silent( + uvr_assert_state(file.path(tmp, "uvr.toml"), + file.path(tmp, "uvr.lock"), + rv) + ) +}) + +test_that("uvr_assert_state errors when uvr.lock is older than uvr.toml", { + tmp <- make_uvr_fixture() + Sys.setFileTime(file.path(tmp, "uvr.lock"), Sys.time() - 60) + Sys.setFileTime(file.path(tmp, "uvr.toml"), Sys.time()) + expect_error( + uvr_assert_state(file.path(tmp, "uvr.toml"), + file.path(tmp, "uvr.lock"), + file.path(tmp, ".r-version")), + "older than uvr.toml" + ) +}) + +test_that("shiny2docker_uvr produces a Dockerfile with the expected steps", { + tmp <- make_uvr_fixture() + out_file <- file.path(tmp, "Dockerfile") + + dock <- suppressMessages( + shiny2docker_uvr(path = tmp, output = out_file, uvr_version = "v0.3.1") + ) + + expect_s3_class(dock, "Dockerfile") + expect_s3_class(dock, "R6") + expect_true(file.exists(out_file)) + expect_true(file.exists(file.path(tmp, ".dockerignore"))) + + contents <- paste(readLines(out_file), collapse = "\n") + expect_match(contents, "FROM debian:stable-slim") + expect_match(contents, "ARG UVR_VERSION=v0\\.3\\.1") + expect_match(contents, "uvr r install") + expect_match(contents, "uvr sync") + expect_match(contents, "EXPOSE 3838") + expect_match(contents, "shiny::runApp") +}) + +test_that("shiny2docker_uvr does not write when write = FALSE", { + tmp <- make_uvr_fixture() + out_file <- file.path(tmp, "Dockerfile") + dock <- suppressMessages( + shiny2docker_uvr(path = tmp, output = out_file, write = FALSE) + ) + expect_false(file.exists(out_file)) + expect_s3_class(dock, "Dockerfile") +}) + +test_that("shiny2docker_uvr injects extra_sysreqs in a dedicated RUN before uvr sync", { + tmp <- make_uvr_fixture() + out_file <- file.path(tmp, "Dockerfile") + dock <- suppressMessages( + shiny2docker_uvr( + path = tmp, output = out_file, + extra_sysreqs = c("libsqlite3-dev", "libpq-dev") + ) + ) + contents <- paste(readLines(out_file), collapse = "\n") + expect_match(contents, "libsqlite3-dev libpq-dev") + pos_extra <- regexpr("libsqlite3-dev libpq-dev", contents) + pos_sync <- regexpr("uvr sync", contents) + expect_true(pos_extra > 0 && pos_sync > 0 && pos_extra < pos_sync) +}) + +test_that("frozen = TRUE generates `uvr sync --frozen`; FALSE warns and runs plain sync", { + tmp <- make_uvr_fixture() + out_file <- file.path(tmp, "Dockerfile") + + # Default (FALSE) emits a warning and produces plain `uvr sync` + expect_message( + shiny2docker_uvr(path = tmp, output = out_file), + "frozen = FALSE" + ) + contents_loose <- paste(readLines(out_file), collapse = "\n") + expect_match(contents_loose, "(?m)^RUN uvr sync$", perl = TRUE) + expect_false(grepl("uvr sync --frozen", contents_loose)) + + # frozen = TRUE produces `uvr sync --frozen` and emits no warning + expect_silent( + shiny2docker_uvr(path = tmp, output = out_file, frozen = TRUE) + ) + contents_strict <- paste(readLines(out_file), collapse = "\n") + expect_match(contents_strict, "uvr sync --frozen") +}) + +test_that("bootstrap = FALSE errors immediately when uvr files are missing", { + tmp <- tempfile("uvr_no_bootstrap_"); dir.create(tmp) + writeLines("library(shiny)", file.path(tmp, "app.R")) + expect_error( + shiny2docker_uvr(path = tmp, bootstrap = FALSE, write = FALSE), + "uvr files missing" + ) +}) + +test_that("bootstrap = TRUE without uvr CLI errors with install instructions", { + tmp <- tempfile("uvr_no_cli_"); dir.create(tmp) + writeLines("library(shiny)", file.path(tmp, "app.R")) + withr::local_envvar(PATH = "/nonexistent", + HOME = tempfile("fake_home_")) + # Non-interactive locate path: we expect a clean error pointing at install_uvr() + expect_error( + uvr_locate_or_install(interactive_install = FALSE), + "shiny2docker::install_uvr" + ) +}) + +test_that("uvr_detect_asset returns the right asset for known platforms", { + # We only assert the broad shape: no platform crash + plausible filename. + asset <- uvr_detect_asset() + expect_true(grepl("^uvr-.+\\.(tar\\.gz|zip)$", asset$name)) + expect_true(asset$ext %in% c(".tar.gz", ".zip")) +}) + +test_that("shiny2docker_uvr creates the parent directory of `output` if missing", { + tmp <- make_uvr_fixture() + nested <- file.path(tmp, "deploy", "subdir", "Dockerfile") + expect_false(dir.exists(dirname(nested))) + suppressMessages(shiny2docker_uvr(path = tmp, output = nested)) + expect_true(file.exists(nested)) + expect_true(file.exists(file.path(dirname(nested), ".dockerignore"))) +})