From cb6445138a6378962627457a24e9c64c11634ae3 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:11:17 +0200 Subject: [PATCH 01/11] feat: experimental uvr backend (shiny2docker_uvr) Adds shiny2docker_uvr() as an experimental sister to shiny2docker(): generates a Dockerfile that uses nbafrank/uvr to install R inside the container and restore packages from uvr.lock, instead of an rocker/r-ver image + renv::restore(). Decouples the base image from the R version so any minimal Linux base can be used (defaults to debian:stable-slim). Pre-conditions are asserted in R (uvr.toml, uvr.lock, .r-version present and consistent), the launch step writes a small _uvr_start.R inside the image since `uvr run` only accepts a script. Workarounds embedded in the template for current uvr 0.2.15 issues: - pre-create //etc before `uvr r install` (otherwise fails writing Renviron.site) - include `binutils` so uvr can call `ar x` on Posit's R .deb - ship the R-build sysreqs and a few common shiny-stack ones, since P3M binary fallback is not effective on debian targets and uvr ends up compiling all packages from source - run `uvr sync` without --frozen for now (--frozen rejects locks generated on a different host than the container's target OS) Tested via tests/testthat/test-shiny2docker_uvr.R (23 passing) and an env-gated end-to-end test that builds the image and probes the running shiny app over HTTP (5 passing). On the included fixture: cold build ~580s, image 985MB, HTTP 200 served in ~12s post-start. --- NAMESPACE | 2 + R/shiny2docker_uvr.R | 67 ++++ R/utils_uvr.R | 198 +++++++++++ man/shiny2docker_uvr.Rd | 60 ++++ tests/testthat/fixtures/uvr-app/.gitignore | 1 + tests/testthat/fixtures/uvr-app/.r-version | 1 + tests/testthat/fixtures/uvr-app/app.R | 9 + tests/testthat/fixtures/uvr-app/uvr.lock | 329 ++++++++++++++++++ tests/testthat/fixtures/uvr-app/uvr.toml | 5 + .../test-shiny2docker_uvr-integration.R | 111 ++++++ tests/testthat/test-shiny2docker_uvr.R | 108 ++++++ 11 files changed, 891 insertions(+) create mode 100644 R/shiny2docker_uvr.R create mode 100644 R/utils_uvr.R create mode 100644 man/shiny2docker_uvr.Rd create mode 100644 tests/testthat/fixtures/uvr-app/.gitignore create mode 100644 tests/testthat/fixtures/uvr-app/.r-version create mode 100644 tests/testthat/fixtures/uvr-app/app.R create mode 100644 tests/testthat/fixtures/uvr-app/uvr.lock create mode 100644 tests/testthat/fixtures/uvr-app/uvr.toml create mode 100644 tests/testthat/test-shiny2docker_uvr-integration.R create mode 100644 tests/testthat/test-shiny2docker_uvr.R diff --git a/NAMESPACE b/NAMESPACE index 1f3e0a1..ac8bf2d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,10 +3,12 @@ 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..6c384b2 --- /dev/null +++ b/R/shiny2docker_uvr.R @@ -0,0 +1,67 @@ +#' 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 --frozen`. +#' +#' Lifecycle: experimental. Requires the project to already be uvr-managed +#' (`uvr.toml`, `uvr.lock`, and `.r-version` present at the project root). +#' +#' @param path Character. Path to the folder containing the Shiny application. +#' @param output Character. Path to the generated Dockerfile. +#' @param uvr_toml Path to the `uvr.toml` manifest. Defaults to `/uvr.toml`. +#' @param uvr_lock Path to the `uvr.lock` lockfile. Defaults to `/uvr.lock`. +#' @param r_version_file Path to the R version pin. Defaults to `/.r-version`. +#' @param base_image Character. Docker base image. Defaults to `"debian:stable-slim"`. +#' @param uvr_version Character. `"latest"` or a semver tag like `"v0.3.1"`. +#' Pinning a tag is recommended for reproducible builds. +#' @param port Integer. Shiny port to expose. Default `3838`. +#' @param host Character. Shiny host. Default `"0.0.0.0"`. +#' @param extra_sysreqs Character vector of extra debian packages to install +#' before `uvr sync` (escape hatch for system dependencies that uvr does not +#' detect on its own). +#' @param write Logical. Whether to write the Dockerfile to `output`. Default `TRUE`. +#' +#' @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"), + uvr_toml = file.path(path, "uvr.toml"), + uvr_lock = file.path(path, "uvr.lock"), + r_version_file = file.path(path, ".r-version"), + base_image = "debian:stable-slim", + uvr_version = "latest", + port = 3838, + host = "0.0.0.0", + extra_sysreqs = NULL, + write = TRUE) { + + uvr_assert_state( + uvr_toml = uvr_toml, + uvr_lock = uvr_lock, + r_version_file = r_version_file + ) + + if (!file.exists(file.path(dirname(output), ".dockerignore"))) { + create_dockerignore_uvr(path = file.path(dirname(output), ".dockerignore")) + } + + dock <- uvr_build_dockerfile( + base_image = base_image, + uvr_version = uvr_version, + port = port, + host = host, + extra_sysreqs = extra_sysreqs + ) + + if (isTRUE(write)) { + dock$write(output) + } + + invisible(dock) +} diff --git a/R/utils_uvr.R b/R/utils_uvr.R new file mode 100644 index 0000000..0258e63 --- /dev/null +++ b/R/utils_uvr.R @@ -0,0 +1,198 @@ +#' Write a uvr-aware .dockerignore +#' +#' Adds the entries that are specific to a uvr-managed project (`.uvr/`, +#' `.Rprofile` written by `uvr init`) 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/", + "renv/", + "renv.lock" + ) + writeLines(entries, con = path) +} + +#' 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 + ) + } + + 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) { + + 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/*" + )) + } + + # Note: ideally `uvr sync --frozen` for CI strictness, but as of uvr 0.2.15 + # the frozen check rejects locks generated on a different host than the + # container's target OS (e.g. host-side P3M URL vs CRAN source URL). Track + # upstream and switch back to --frozen once that's resolved. + dock$RUN("apt-get update && uvr sync && rm -rf /var/lib/apt/lists/*") + + 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. + dock$RUN(sprintf( + "printf '%%s\\n' \"shiny::runApp(appDir = '/srv/shiny-server', host = '%s', port = %s)\" > /srv/shiny-server/_uvr_start.R", + host, port + )) + + dock$EXPOSE(port) + + dock$CMD("uvr run /srv/shiny-server/_uvr_start.R") + + dock +} diff --git a/man/shiny2docker_uvr.Rd b/man/shiny2docker_uvr.Rd new file mode 100644 index 0000000..8e94b50 --- /dev/null +++ b/man/shiny2docker_uvr.Rd @@ -0,0 +1,60 @@ +% 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"), + uvr_toml = file.path(path, "uvr.toml"), + uvr_lock = file.path(path, "uvr.lock"), + r_version_file = file.path(path, ".r-version"), + base_image = "debian:stable-slim", + uvr_version = "latest", + port = 3838, + host = "0.0.0.0", + extra_sysreqs = NULL, + write = TRUE +) +} +\arguments{ +\item{path}{Character. Path to the folder containing the Shiny application.} + +\item{output}{Character. Path to the generated Dockerfile.} + +\item{uvr_toml}{Path to the \code{uvr.toml} manifest. Defaults to \verb{/uvr.toml}.} + +\item{uvr_lock}{Path to the \code{uvr.lock} lockfile. Defaults to \verb{/uvr.lock}.} + +\item{r_version_file}{Path to the R version pin. Defaults to \verb{/.r-version}.} + +\item{base_image}{Character. Docker base image. Defaults to \code{"debian:stable-slim"}.} + +\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. Shiny port to expose. Default \code{3838}.} + +\item{host}{Character. Shiny host. Default \code{"0.0.0.0"}.} + +\item{extra_sysreqs}{Character vector of extra debian packages to install +before \verb{uvr sync} (escape hatch for system dependencies that uvr does not +detect on its own).} + +\item{write}{Logical. Whether to write the Dockerfile to \code{output}. Default \code{TRUE}.} +} +\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 --frozen}. +} +\details{ +Lifecycle: experimental. Requires the project to already be uvr-managed +(\code{uvr.toml}, \code{uvr.lock}, and \code{.r-version} present at the project root). +} 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..a92004b --- /dev/null +++ b/tests/testthat/test-shiny2docker_uvr-integration.R @@ -0,0 +1,111 @@ +# 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") + } +} + +free_port <- function() { + con <- socketConnection(host = "127.0.0.1", port = 0L, + server = TRUE, blocking = FALSE) + on.exit(close(con)) + port <- as.integer(socketSelect(list(con)) || TRUE) + # fallback: pick a high random port if the above doesn't expose it + sample(20000:39999, 1) +} + +http_probe <- function(url, tries = 30L, delay = 2) { + for (i in seq_len(tries)) { + code <- tryCatch( + suppressWarnings({ + con <- url(url, "rb") + on.exit(close(con), add = TRUE) + readLines(con, n = 1, warn = FALSE) + 200L + }), + error = function(e) NA_integer_ + ) + if (isTRUE(code == 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). + src_fixture <- testthat::test_path("fixtures", "uvr-app") + 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")) == FALSE, + 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(sprintf("http://127.0.0.1:%d/", port), 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..ffa5816 --- /dev/null +++ b/tests/testthat/test-shiny2docker_uvr.R @@ -0,0 +1,108 @@ +# 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") +}) + +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 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 <- 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 <- 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") + # Use packages that are NOT in the baseline apt list so we can locate + # the dedicated RUN unambiguously. + dock <- 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) +}) From e11ea24d8f5c0d6af0d74b0717a7d7e9b7abbf43 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:00:59 +0200 Subject: [PATCH 02/11] fix(uvr): address Copilot review on PR #11 - Doc: drop the `--frozen` mention (we run plain `uvr sync`); document the Debian/Ubuntu + amd64/glibc base-image constraint and the `[1, 65535]` port range; clarify that `.dockerignore` is only written when `write = TRUE`. - Behaviour: gate `.dockerignore` creation behind `write` so `write = FALSE` is a true no-op on the filesystem. - Validation: add `uvr_validate_host_port()` and `uvr_validate_sysreqs()` to reject shell-unsafe inputs (host/port interpolated into Dockerfile commands; extra_sysreqs concatenated into apt-get install). Use `encodeString()` + `shQuote()` when building `_uvr_start.R` so quoted / multiline values can't escape the printf or the resulting R source. - Comment alignment: `.Rprofile` is now part of the generated .dockerignore (matches the helper's docstring). - Anchor the semver regex in `uvr_release_url()` so `v0.3.1junk` is rejected. - Tests: drop unused `free_port()` helper from the integration test and rewrite `http_probe()` to actually parse the HTTP status line over a socket connection (previously any TCP-connect succeeded was treated as HTTP 200, hiding 4xx/5xx). - Add unit tests for the new validators and for the regex anchoring. --- R/shiny2docker_uvr.R | 32 ++++++--- R/utils_uvr.R | 65 +++++++++++++++++-- man/shiny2docker_uvr.Rd | 25 ++++--- .../test-shiny2docker_uvr-integration.R | 47 ++++++++------ tests/testthat/test-shiny2docker_uvr.R | 51 ++++++++++++++- 5 files changed, 173 insertions(+), 47 deletions(-) diff --git a/R/shiny2docker_uvr.R b/R/shiny2docker_uvr.R index 6c384b2..43d780d 100644 --- a/R/shiny2docker_uvr.R +++ b/R/shiny2docker_uvr.R @@ -3,25 +3,34 @@ #' 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 --frozen`. +#' required) and packages are restored from `uvr.lock` via `uvr sync`. #' #' Lifecycle: experimental. Requires the project to already be uvr-managed #' (`uvr.toml`, `uvr.lock`, and `.r-version` present at the project root). #' +#' 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. #' @param output Character. Path to the generated Dockerfile. #' @param uvr_toml Path to the `uvr.toml` manifest. Defaults to `/uvr.toml`. #' @param uvr_lock Path to the `uvr.lock` lockfile. Defaults to `/uvr.lock`. #' @param r_version_file Path to the R version pin. Defaults to `/.r-version`. #' @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. Shiny port to expose. Default `3838`. -#' @param host Character. Shiny host. Default `"0.0.0.0"`. -#' @param extra_sysreqs Character vector of extra debian packages to install -#' before `uvr sync` (escape hatch for system dependencies that uvr does not -#' detect on its own). -#' @param write Logical. Whether to write the Dockerfile to `output`. Default `TRUE`. +#' @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 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`. #' #' @return Invisibly, an R6 `dockerfiler::Dockerfile` object that can be further #' customised before writing. @@ -47,9 +56,8 @@ shiny2docker_uvr <- function(path = ".", r_version_file = r_version_file ) - if (!file.exists(file.path(dirname(output), ".dockerignore"))) { - create_dockerignore_uvr(path = file.path(dirname(output), ".dockerignore")) - } + uvr_validate_host_port(host = host, port = port) + uvr_validate_sysreqs(extra_sysreqs) dock <- uvr_build_dockerfile( base_image = base_image, @@ -60,6 +68,10 @@ shiny2docker_uvr <- function(path = ".", ) if (isTRUE(write)) { + dockerignore <- file.path(dirname(output), ".dockerignore") + if (!file.exists(dockerignore)) { + create_dockerignore_uvr(path = dockerignore) + } dock$write(output) } diff --git a/R/utils_uvr.R b/R/utils_uvr.R index 0258e63..ac937a5 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -1,8 +1,8 @@ #' Write a uvr-aware .dockerignore #' #' Adds the entries that are specific to a uvr-managed project (`.uvr/`, -#' `.Rprofile` written by `uvr init`) on top of the defaults used by the -#' `renv` backend. +#' `.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 @@ -15,12 +15,58 @@ create_dockerignore_uvr <- function(path = ".dockerignore") { "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) +} + #' Assert that the project is uvr-ready #' #' Checks that `uvr.toml`, `uvr.lock` and a R version pin file all exist, and @@ -85,7 +131,7 @@ uvr_release_url <- function(uvr_version = "latest") { } tag <- if (startsWith(uvr_version, "v")) uvr_version else paste0("v", uvr_version) - if (!grepl("^v[0-9]+\\.[0-9]+\\.[0-9]+", tag)) { + 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, "'", @@ -184,10 +230,17 @@ uvr_build_dockerfile <- function(base_image = "debian:stable-slim", 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. + # 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' \"shiny::runApp(appDir = '/srv/shiny-server', host = '%s', port = %s)\" > /srv/shiny-server/_uvr_start.R", - host, port + "printf '%%s\\n' %s > /srv/shiny-server/_uvr_start.R", + shQuote(r_call) )) dock$EXPOSE(port) diff --git a/man/shiny2docker_uvr.Rd b/man/shiny2docker_uvr.Rd index 8e94b50..5133d50 100644 --- a/man/shiny2docker_uvr.Rd +++ b/man/shiny2docker_uvr.Rd @@ -29,20 +29,25 @@ shiny2docker_uvr( \item{r_version_file}{Path to the R version pin. Defaults to \verb{/.r-version}.} -\item{base_image}{Character. Docker base image. Defaults to \code{"debian:stable-slim"}.} +\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. Shiny port to expose. Default \code{3838}.} +\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"}.} +\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 packages to install -before \verb{uvr sync} (escape hatch for system dependencies that uvr does not -detect on its own).} +\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{write}{Logical. Whether to write the Dockerfile to \code{output}. Default \code{TRUE}.} +\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}.} } \value{ Invisibly, an R6 \code{dockerfiler::Dockerfile} object that can be further @@ -52,9 +57,13 @@ customised before writing. 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 --frozen}. +required) and packages are restored from \code{uvr.lock} via \verb{uvr sync}. } \details{ Lifecycle: experimental. Requires the project to already be uvr-managed (\code{uvr.toml}, \code{uvr.lock}, and \code{.r-version} present at the project root). + +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/test-shiny2docker_uvr-integration.R b/tests/testthat/test-shiny2docker_uvr-integration.R index a92004b..8f79fe4 100644 --- a/tests/testthat/test-shiny2docker_uvr-integration.R +++ b/tests/testthat/test-shiny2docker_uvr-integration.R @@ -22,27 +22,31 @@ skip_unless_docker_uvr_integration <- function() { } } -free_port <- function() { - con <- socketConnection(host = "127.0.0.1", port = 0L, - server = TRUE, blocking = FALSE) - on.exit(close(con)) - port <- as.integer(socketSelect(list(con)) || TRUE) - # fallback: pick a high random port if the above doesn't expose it - sample(20000:39999, 1) -} - -http_probe <- function(url, tries = 30L, delay = 2) { - for (i in seq_len(tries)) { - code <- tryCatch( - suppressWarnings({ - con <- url(url, "rb") - on.exit(close(con), add = TRUE) - readLines(con, n = 1, warn = FALSE) - 200L - }), - error = function(e) NA_integer_ +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 (isTRUE(code == 200L)) return(200L) + 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_ @@ -106,6 +110,7 @@ test_that("shiny2docker_uvr produces an image that serves a shiny app over HTTP" 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(sprintf("http://127.0.0.1:%d/", port), tries = 30L, delay = 2) + 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 index ffa5816..c413ef1 100644 --- a/tests/testthat/test-shiny2docker_uvr.R +++ b/tests/testthat/test-shiny2docker_uvr.R @@ -37,8 +37,55 @@ test_that("uvr_release_url accepts pinned tags with or without leading v", { }) 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("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") + 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", { From 50e6cfe3c9b48e6ede59535b14ca01c5fdf395c7 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:23:27 +0200 Subject: [PATCH 03/11] fix(uvr): tighten reproducibility, robustness, and validation Follow-up to PR #11 self-review (orange-tier items): - Add `frozen` argument to `shiny2docker_uvr()`. Default FALSE keeps the current behaviour green on hosts where uvr 0.2.15 cannot honour `uvr sync --frozen` cross-host; opt in with `frozen = TRUE` for strict CI builds. When FALSE, emit a `cli` warning so the relaxed strictness is explicit at Dockerfile-generation time. - Create `dirname(output)` recursively when missing. Previously `output = "deploy/sub/Dockerfile"` would crash on `.dockerignore` creation with a cryptic "cannot open file" error. - Validate the content of `.r-version` in `uvr_assert_state()`: must be a single MAJOR.MINOR.PATCH line. Empty / multi-line / garbage values now fail fast in R rather than producing an opaque Docker build error later (`uvr r install ""`). Tests: +13 assertions covering the new validation branches and the two new behaviours; existing tests wrapped with suppressMessages where they previously did not assert on the new warning. Total now 56 unit + 5 integration, all green. --- R/shiny2docker_uvr.R | 27 ++++++- R/utils_uvr.R | 25 +++++-- man/shiny2docker_uvr.Rd | 11 ++- tests/testthat/test-shiny2docker_uvr.R | 99 +++++++++++++++++++++++--- 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/R/shiny2docker_uvr.R b/R/shiny2docker_uvr.R index 43d780d..b60fa74 100644 --- a/R/shiny2docker_uvr.R +++ b/R/shiny2docker_uvr.R @@ -28,9 +28,16 @@ #' 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 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`, an `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`. +#' 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. @@ -48,6 +55,7 @@ shiny2docker_uvr <- function(path = ".", port = 3838, host = "0.0.0.0", extra_sysreqs = NULL, + frozen = FALSE, write = TRUE) { uvr_assert_state( @@ -59,16 +67,29 @@ shiny2docker_uvr <- function(path = ".", 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 + extra_sysreqs = extra_sysreqs, + frozen = frozen ) if (isTRUE(write)) { - dockerignore <- file.path(dirname(output), ".dockerignore") + 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) } diff --git a/R/utils_uvr.R b/R/utils_uvr.R index ac937a5..fdb1a08 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -109,6 +109,17 @@ uvr_assert_state <- function(uvr_toml, uvr_lock, r_version_file) { 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) } @@ -159,7 +170,8 @@ uvr_build_dockerfile <- function(base_image = "debian:stable-slim", uvr_version = "latest", port = 3838, host = "0.0.0.0", - extra_sysreqs = NULL) { + extra_sysreqs = NULL, + frozen = FALSE) { release <- uvr_release_url(uvr_version) @@ -221,11 +233,12 @@ uvr_build_dockerfile <- function(base_image = "debian:stable-slim", )) } - # Note: ideally `uvr sync --frozen` for CI strictness, but as of uvr 0.2.15 - # the frozen check rejects locks generated on a different host than the - # container's target OS (e.g. host-side P3M URL vs CRAN source URL). Track - # upstream and switch back to --frozen once that's resolved. - dock$RUN("apt-get update && uvr sync && 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(paste("apt-get update &&", sync_cmd, "&& rm -rf /var/lib/apt/lists/*")) dock$COPY(from = ".", to = "/srv/shiny-server/") diff --git a/man/shiny2docker_uvr.Rd b/man/shiny2docker_uvr.Rd index 5133d50..b402765 100644 --- a/man/shiny2docker_uvr.Rd +++ b/man/shiny2docker_uvr.Rd @@ -15,6 +15,7 @@ shiny2docker_uvr( port = 3838, host = "0.0.0.0", extra_sysreqs = NULL, + frozen = FALSE, write = TRUE ) } @@ -45,9 +46,17 @@ 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{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}.} +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 diff --git a/tests/testthat/test-shiny2docker_uvr.R b/tests/testthat/test-shiny2docker_uvr.R index c413ef1..9b4774a 100644 --- a/tests/testthat/test-shiny2docker_uvr.R +++ b/tests/testthat/test-shiny2docker_uvr.R @@ -83,7 +83,7 @@ test_that("shiny2docker_uvr propagates validation errors", { test_that("shiny2docker_uvr does not write a .dockerignore when write = FALSE", { tmp <- make_uvr_fixture() out_file <- file.path(tmp, "Dockerfile") - shiny2docker_uvr(path = tmp, output = out_file, write = FALSE) + suppressMessages(shiny2docker_uvr(path = tmp, output = out_file, write = FALSE)) expect_false(file.exists(out_file)) expect_false(file.exists(file.path(tmp, ".dockerignore"))) }) @@ -98,6 +98,55 @@ test_that("uvr_assert_state errors when files are missing", { ) }) +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) @@ -114,7 +163,9 @@ test_that("shiny2docker_uvr produces a Dockerfile with the expected steps", { tmp <- make_uvr_fixture() out_file <- file.path(tmp, "Dockerfile") - dock <- shiny2docker_uvr(path = tmp, output = out_file, uvr_version = "v0.3.1") + dock <- suppressMessages( + shiny2docker_uvr(path = tmp, output = out_file, uvr_version = "v0.3.1") + ) expect_s3_class(dock, "Dockerfile") expect_s3_class(dock, "R6") @@ -133,7 +184,9 @@ test_that("shiny2docker_uvr produces a Dockerfile with the expected steps", { test_that("shiny2docker_uvr does not write when write = FALSE", { tmp <- make_uvr_fixture() out_file <- file.path(tmp, "Dockerfile") - dock <- shiny2docker_uvr(path = tmp, output = out_file, write = FALSE) + dock <- suppressMessages( + shiny2docker_uvr(path = tmp, output = out_file, write = FALSE) + ) expect_false(file.exists(out_file)) expect_s3_class(dock, "Dockerfile") }) @@ -141,11 +194,11 @@ test_that("shiny2docker_uvr does not write when write = FALSE", { test_that("shiny2docker_uvr injects extra_sysreqs in a dedicated RUN before uvr sync", { tmp <- make_uvr_fixture() out_file <- file.path(tmp, "Dockerfile") - # Use packages that are NOT in the baseline apt list so we can locate - # the dedicated RUN unambiguously. - dock <- shiny2docker_uvr( - path = tmp, output = out_file, - extra_sysreqs = c("libsqlite3-dev", "libpq-dev") + 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") @@ -153,3 +206,33 @@ test_that("shiny2docker_uvr injects extra_sysreqs in a dedicated RUN before uvr 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, "uvr sync &&") + 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("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"))) +}) From 9df983744f6ee1b687e3a123297a2c7d83bc4fdc Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:38:40 +0200 Subject: [PATCH 04/11] refactor(uvr): remove configurable file-path args (Copilot review #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot pointed out that `uvr_toml`, `uvr_lock`, and `r_version_file` were public arguments but the generated Dockerfile still hard-codes `COPY uvr.toml`, `COPY uvr.lock`, `COPY .r-version` from the build context root. Any caller passing non-default paths would have a Dockerfile that does not match the validated inputs, and `docker build` would fail. Resolution: drop the three arguments. uvr itself enforces these exact filenames at the project root, so the configurability was illusory. The helper `uvr_assert_state()` keeps its path arguments (it's an internal helper) — paths are now computed inside the public function. Doc clarifications: - Make explicit that uvr.toml/uvr.lock/.r-version must live at `/` under those exact names. - Spell out exactly what `uvr_assert_state()` checks (existence, lock mtime >= toml mtime, .r-version content shape) and that we do *not* parse uvr.toml to cross-validate the [r] version constraint — uvr owns that contract (Copilot review #1). Re Copilot review #3 ("internal helpers won't resolve under R CMD check, use shiny2docker:::"): verified empirically — the package installs and `library(testthat); library(shiny2docker); test_check()` runs all 56 unit tests successfully without `:::`. testthat under `R CMD check` makes the package's namespace available to tests, so internal helpers resolve normally. No code change needed. --- R/shiny2docker_uvr.R | 45 ++++++++++++++++++++++------------------- man/shiny2docker_uvr.Rd | 24 +++++++++++----------- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/R/shiny2docker_uvr.R b/R/shiny2docker_uvr.R index b60fa74..cbbb330 100644 --- a/R/shiny2docker_uvr.R +++ b/R/shiny2docker_uvr.R @@ -5,18 +5,24 @@ #' 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. Requires the project to already be uvr-managed -#' (`uvr.toml`, `uvr.lock`, and `.r-version` present at the project root). +#' Lifecycle: experimental. Requires the project to already be uvr-managed — +#' `uvr.toml`, `uvr.lock`, and `.r-version` must live 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). +#' +#' What `uvr_assert_state()` checks before generation: those 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. +#' @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 uvr_toml Path to the `uvr.toml` manifest. Defaults to `/uvr.toml`. -#' @param uvr_lock Path to the `uvr.lock` lockfile. Defaults to `/uvr.lock`. -#' @param r_version_file Path to the R version pin. Defaults to `/.r-version`. #' @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"`. @@ -45,23 +51,20 @@ #' @export #' #' @importFrom dockerfiler Dockerfile -shiny2docker_uvr <- function(path = ".", - output = file.path(path, "Dockerfile"), - uvr_toml = file.path(path, "uvr.toml"), - uvr_lock = file.path(path, "uvr.lock"), - r_version_file = file.path(path, ".r-version"), - base_image = "debian:stable-slim", - uvr_version = "latest", - port = 3838, - host = "0.0.0.0", - extra_sysreqs = NULL, - frozen = FALSE, - write = TRUE) { +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, + frozen = FALSE, + write = TRUE) { uvr_assert_state( - uvr_toml = uvr_toml, - uvr_lock = uvr_lock, - r_version_file = r_version_file + uvr_toml = file.path(path, "uvr.toml"), + uvr_lock = file.path(path, "uvr.lock"), + r_version_file = file.path(path, ".r-version") ) uvr_validate_host_port(host = host, port = port) diff --git a/man/shiny2docker_uvr.Rd b/man/shiny2docker_uvr.Rd index b402765..6fcb9ec 100644 --- a/man/shiny2docker_uvr.Rd +++ b/man/shiny2docker_uvr.Rd @@ -7,9 +7,6 @@ shiny2docker_uvr( path = ".", output = file.path(path, "Dockerfile"), - uvr_toml = file.path(path, "uvr.toml"), - uvr_lock = file.path(path, "uvr.lock"), - r_version_file = file.path(path, ".r-version"), base_image = "debian:stable-slim", uvr_version = "latest", port = 3838, @@ -20,16 +17,11 @@ shiny2docker_uvr( ) } \arguments{ -\item{path}{Character. Path to the folder containing the Shiny application.} +\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{uvr_toml}{Path to the \code{uvr.toml} manifest. Defaults to \verb{/uvr.toml}.} - -\item{uvr_lock}{Path to the \code{uvr.lock} lockfile. Defaults to \verb{/uvr.lock}.} - -\item{r_version_file}{Path to the R version pin. Defaults to \verb{/.r-version}.} - \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).} @@ -69,8 +61,16 @@ 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. Requires the project to already be uvr-managed -(\code{uvr.toml}, \code{uvr.lock}, and \code{.r-version} present at the project root). +Lifecycle: experimental. Requires the project to already be uvr-managed — +\code{uvr.toml}, \code{uvr.lock}, and \code{.r-version} must live 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). + +What \code{uvr_assert_state()} checks before generation: those 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 From 8570e52f4dae8fb7d4febb3a04c54839c1926209 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:31:23 +0200 Subject: [PATCH 05/11] fix(uvr): clear R-CMD-check WARNING and NOTE on the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on the PR surfaced: - WARNING: non-ASCII character in R/utils_uvr.R — an em dash in the "uvr.lock is older than uvr.toml" stop() message. Replaced with `--`. - NOTE: hidden file `tests/testthat/fixtures/uvr-app/.r-version`. Added `^tests/testthat/fixtures$` to .Rbuildignore. The fixture is only needed by the env-gated integration test, which already skip_on_cran's, so excluding the dir from the package tarball is safe. --- .Rbuildignore | 1 + R/utils_uvr.R | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/R/utils_uvr.R b/R/utils_uvr.R index fdb1a08..e114c08 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -97,7 +97,7 @@ uvr_assert_state <- function(uvr_toml, uvr_lock, r_version_file) { } 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", + "uvr.lock is older than uvr.toml -- manifest changed since last lock.\n", "Run `uvr lock` to refresh it.", call. = FALSE ) From 0a7b2e5a15befdb07e17b631408f5ad5d341dc0e Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:44:19 +0200 Subject: [PATCH 06/11] docs(uvr): backtick the [1, 65535] range so roxygen stops parsing it as a markdown link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc was rendered with markdown mode, so `[1, 65535]` looked like a [topic] link with target "1, 65535" and devtools::document() warned: utils_uvr.R:31: @param Could not resolve link to topic "1, 65535" in the dependencies or base packages. Backticks turn the range into inline code, removing the ambiguity. The block is @noRd so no Rd file changes — pure document()-time warning cleanup. RoxygenNote bumped to 7.3.3 incidentally. --- DESCRIPTION | 2 +- R/utils_uvr.R | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 972e454..546df4a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -22,7 +22,7 @@ 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: diff --git a/R/utils_uvr.R b/R/utils_uvr.R index e114c08..f6d3e54 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -28,7 +28,7 @@ create_dockerignore_uvr <- function(path = ".dockerignore") { #' 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]. +#' @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) { From 6b295bcae9825c51f01570fd97ca487786f47176 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:54:04 +0200 Subject: [PATCH 07/11] feat(uvr): auto-bootstrap uvr.toml/uvr.lock/.r-version (parity with renv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a fresh `shiny2docker_uvr()` call in a project without uvr files errored out with "uvr.toml not found ...". The renv counterpart auto-creates `renv.lock` via `attachment::create_renv_for_prod()` when missing — this commit brings the same UX to the uvr backend. New `uvr_bootstrap()` helper: - if `uvr.toml` is missing: generate `renv.lock` via `attachment` (or reuse one if present), then `uvr init` + `uvr import` - if `.r-version` is missing: pin to the local R version (with a 4.4.2 fallback if uvr's catalog rejects the local one) - if `uvr.lock` is missing or stale: `uvr lock` All `uvr` calls run with `path` as cwd. Errors out cleanly with install instructions if the `uvr` CLI is not on `PATH`. `shiny2docker_uvr()` gains `bootstrap = TRUE` (default). When `FALSE`, missing files trigger an immediate error (CI-friendly). Tests: +2 unit tests for the no-bootstrap and no-CLI error paths (58 passing). End-to-end exercised manually on a `app.R`-only project: Dockerfile generated, all four artefacts (`renv.lock`, `uvr.toml`, `uvr.lock`, `.r-version`) created from scratch. --- R/shiny2docker_uvr.R | 53 ++++++++++-- R/utils_uvr.R | 110 +++++++++++++++++++++++++ man/shiny2docker_uvr.Rd | 29 +++++-- tests/testthat/test-shiny2docker_uvr.R | 19 +++++ 4 files changed, 198 insertions(+), 13 deletions(-) diff --git a/R/shiny2docker_uvr.R b/R/shiny2docker_uvr.R index cbbb330..8acb6e3 100644 --- a/R/shiny2docker_uvr.R +++ b/R/shiny2docker_uvr.R @@ -5,12 +5,23 @@ #' 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. Requires the project to already be uvr-managed — -#' `uvr.toml`, `uvr.lock`, and `.r-version` must live 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). +#' 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). #' -#' What `uvr_assert_state()` checks before generation: those three files exist, +#' 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 @@ -34,6 +45,12 @@ #' 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 @@ -58,13 +75,33 @@ shiny2docker_uvr <- function(path = ".", 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 = file.path(path, "uvr.toml"), - uvr_lock = file.path(path, "uvr.lock"), - r_version_file = file.path(path, ".r-version") + uvr_toml = uvr_toml, + uvr_lock = uvr_lock, + r_version_file = rver_file ) uvr_validate_host_port(host = host, port = port) diff --git a/R/utils_uvr.R b/R/utils_uvr.R index f6d3e54..b94f574 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -67,6 +67,116 @@ uvr_validate_sysreqs <- function(sysreqs) { invisible(TRUE) } +#' 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 <- Sys.which("uvr") + if (!nzchar(uvr_bin)) { + stop( + "`uvr` CLI not found on PATH; needed to bootstrap a uvr project.\n", + "Install it once: ", + "curl -fsSL https://raw.githubusercontent.com/nbafrank/uvr/main/install.sh | sh\n", + "Or from R: install.packages(\"uvr\"); uvr::install_uvr()\n", + "If you've already bootstrapped the project elsewhere, ", + "make sure `uvr.toml`, `uvr.lock` and `.r-version` are at '", path, "/'.", + call. = FALSE + ) + } + + 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 diff --git a/man/shiny2docker_uvr.Rd b/man/shiny2docker_uvr.Rd index 6fcb9ec..ca89099 100644 --- a/man/shiny2docker_uvr.Rd +++ b/man/shiny2docker_uvr.Rd @@ -12,6 +12,7 @@ shiny2docker_uvr( port = 3838, host = "0.0.0.0", extra_sysreqs = NULL, + bootstrap = TRUE, frozen = FALSE, write = TRUE ) @@ -38,6 +39,13 @@ 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 @@ -61,12 +69,23 @@ 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. Requires the project to already be uvr-managed — -\code{uvr.toml}, \code{uvr.lock}, and \code{.r-version} must live 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). +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 before generation: those three files exist, +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 diff --git a/tests/testthat/test-shiny2docker_uvr.R b/tests/testthat/test-shiny2docker_uvr.R index 9b4774a..0457be1 100644 --- a/tests/testthat/test-shiny2docker_uvr.R +++ b/tests/testthat/test-shiny2docker_uvr.R @@ -228,6 +228,25 @@ test_that("frozen = TRUE generates `uvr sync --frozen`; FALSE warns and runs pla 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") + expect_error( + shiny2docker_uvr(path = tmp, bootstrap = TRUE, write = FALSE), + "uvr.*CLI not found" + ) +}) + test_that("shiny2docker_uvr creates the parent directory of `output` if missing", { tmp <- make_uvr_fixture() nested <- file.path(tmp, "deploy", "subdir", "Dockerfile") From 8e315640dac9ed0477513895da7e9197d7edf931 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:50:17 +0200 Subject: [PATCH 08/11] feat(uvr): provide `install_uvr()` and offer to install on first call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh shiny2docker users hit "uvr CLI not found" with a misleading message that mentioned `install.packages('uvr')` (which does not exist on CRAN — uvr is on GitHub). Replace it with a real, self-contained solution: - New exported `install_uvr(dest = '~/.local/bin', version, force)`: detects the host platform (Linux/macOS/Windows × x86_64/arm64), downloads the matching release asset from github.com/nbafrank/uvr/releases, extracts the binary into `dest`, chmods +x. Returns the absolute path. Warns if `dest` is not on PATH for future sessions. - New helper `uvr_locate_or_install()`: looks for `uvr` on PATH, then in `~/.local/bin`, and in interactive sessions prompts the user to run `install_uvr()` automatically. In non-interactive sessions, raises an actionable error that now points at `shiny2docker::install_uvr()` (no more bogus install.packages line). - `uvr_bootstrap()` now uses the located absolute path, so it works even when `~/.local/bin` is not yet on the running R session's PATH (typical first-install scenario). Tested manually end-to-end on a sandbox with no `uvr` on PATH: install_uvr() downloads + extracts + writes uvr v0.2.15 to a custom HOME, and `system2(, '--version')` reports `uvr 0.2.15`. +2 unit tests (60 passing total). --- NAMESPACE | 1 + R/utils_uvr.R | 161 +++++++++++++++++++++++-- man/install_uvr.Rd | 29 +++++ tests/testthat/test-shiny2docker_uvr.R | 15 ++- 4 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 man/install_uvr.Rd diff --git a/NAMESPACE b/NAMESPACE index ac8bf2d..19099e2 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(install_uvr) export(set_github_action) export(set_gitlab_ci) export(shiny2docker) diff --git a/R/utils_uvr.R b/R/utils_uvr.R index b94f574..d9d9d94 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -67,6 +67,154 @@ uvr_validate_sysreqs <- function(sysreqs) { invisible(TRUE) } +#' 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(Sys.getenv("HOME"), ".local", "bin", bin_name) + if (file.exists(candidate)) return(candidate) + + if (isTRUE(interactive_install)) { + cli::cli_alert_info(paste( + "{.code uvr} CLI is required to bootstrap a uvr project but isn't", + "installed yet." + )) + ans <- tolower(trimws(readline( + "Download and install it now to ~/.local/bin? [Y/n] " + ))) + 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. +#' Defaults to `~/.local/bin/`. +#' @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 = file.path(Sys.getenv("HOME"), ".local", "bin"), + 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. ", + "Add this line to your shell rc to make it permanent: ", + "export PATH=\"", dest, ":$PATH\"" + )) + } + invisible(out) +} + #' Bootstrap a uvr project layout if files are missing #' #' Mirrors what `shiny2docker()` does for `renv` (auto-creating `renv.lock` via @@ -95,18 +243,7 @@ uvr_bootstrap <- function(path, document = TRUE, folder_to_exclude = c("renv", ".uvr")) { - uvr_bin <- Sys.which("uvr") - if (!nzchar(uvr_bin)) { - stop( - "`uvr` CLI not found on PATH; needed to bootstrap a uvr project.\n", - "Install it once: ", - "curl -fsSL https://raw.githubusercontent.com/nbafrank/uvr/main/install.sh | sh\n", - "Or from R: install.packages(\"uvr\"); uvr::install_uvr()\n", - "If you've already bootstrapped the project elsewhere, ", - "make sure `uvr.toml`, `uvr.lock` and `.r-version` are at '", path, "/'.", - call. = FALSE - ) - } + uvr_bin <- uvr_locate_or_install() uvr_toml <- file.path(path, "uvr.toml") uvr_lock <- file.path(path, "uvr.lock") diff --git a/man/install_uvr.Rd b/man/install_uvr.Rd new file mode 100644 index 0000000..511d147 --- /dev/null +++ b/man/install_uvr.Rd @@ -0,0 +1,29 @@ +% 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 = file.path(Sys.getenv("HOME"), ".local", "bin"), + version = "latest", + force = FALSE +) +} +\arguments{ +\item{dest}{Directory where the binary should land. Created if missing. +Defaults to \verb{~/.local/bin/}.} + +\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/tests/testthat/test-shiny2docker_uvr.R b/tests/testthat/test-shiny2docker_uvr.R index 0457be1..b09b680 100644 --- a/tests/testthat/test-shiny2docker_uvr.R +++ b/tests/testthat/test-shiny2docker_uvr.R @@ -240,13 +240,22 @@ test_that("bootstrap = FALSE errors immediately when uvr files are 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") + withr::local_envvar(PATH = "/nonexistent", + HOME = tempfile("fake_home_")) + # Non-interactive locate path: we expect a clean error pointing at install_uvr() expect_error( - shiny2docker_uvr(path = tmp, bootstrap = TRUE, write = FALSE), - "uvr.*CLI not found" + 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") From d82174433a2890a813cb5a45a1862366a9df8431 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:54:00 +0200 Subject: [PATCH 09/11] fix(uvr): make install_uvr() default and PATH hint platform-aware `~/.local/bin` only makes sense on Unix; the previous version used it unconditionally, and printed an `export PATH=...` shell hint that is wrong on Windows. Both are now platform-aware: - Default `dest`: - Unix: `~/.local/bin/` (matches uvr's install.sh) - Windows: `%LOCALAPPDATA%/shiny2docker/bin/` - PATH hint after install: - Unix: `export PATH=...` (with bashrc/zshrc note) - Windows: `setx PATH ...` (with restart-shell note) Both `install_uvr()` and the interactive prompt in `uvr_locate_or_install()` use the new helper, so Windows users hitting "uvr CLI not installed yet" get a sensible default location and a correct hint. --- R/utils_uvr.R | 48 ++++++++++++++++++++++++++++++++++++++-------- man/install_uvr.Rd | 5 +++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/R/utils_uvr.R b/R/utils_uvr.R index d9d9d94..2d356c6 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -67,6 +67,36 @@ uvr_validate_sysreqs <- function(sysreqs) { 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 @@ -103,17 +133,18 @@ 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(Sys.getenv("HOME"), ".local", "bin", bin_name) + 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( - "Download and install it now to ~/.local/bin? [Y/n] " - ))) + 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()) } @@ -139,14 +170,15 @@ uvr_locate_or_install <- function(interactive_install = interactive()) { #' [shiny2docker_uvr()] picks it up automatically. #' #' @param dest Directory where the binary should land. Created if missing. -#' Defaults to `~/.local/bin/`. +#' 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 = file.path(Sys.getenv("HOME"), ".local", "bin"), +install_uvr <- function(dest = uvr_default_install_dir(), version = "latest", force = FALSE) { @@ -208,8 +240,8 @@ install_uvr <- function(dest = file.path(Sys.getenv("HOME"), ".local", "bin") if (!nzchar(Sys.which("uvr"))) { cli::cli_alert_warning(paste0( dest, " is not on your PATH for future R sessions. ", - "Add this line to your shell rc to make it permanent: ", - "export PATH=\"", dest, ":$PATH\"" + "Make it permanent with: ", + uvr_path_hint(dest) )) } invisible(out) diff --git a/man/install_uvr.Rd b/man/install_uvr.Rd index 511d147..4a6eed6 100644 --- a/man/install_uvr.Rd +++ b/man/install_uvr.Rd @@ -5,14 +5,15 @@ \title{Install the \code{uvr} CLI binary for the current platform} \usage{ install_uvr( - dest = file.path(Sys.getenv("HOME"), ".local", "bin"), + dest = uvr_default_install_dir(), version = "latest", force = FALSE ) } \arguments{ \item{dest}{Directory where the binary should land. Created if missing. -Defaults to \verb{~/.local/bin/}.} +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"}.} From a83445e9b8c9e121cdc3586d951d97c032e252f4 Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:11:30 +0200 Subject: [PATCH 10/11] docs(uvr): add README section for the experimental uvr backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents: - shiny2docker_uvr() generates a Dockerfile that installs R via uvr inside the container, restores packages from uvr.lock at build time, and supports any Debian/Ubuntu amd64 base image. - install_uvr() downloads the uvr CLI binary for the current platform (Linux/macOS/Windows × x86_64/aarch64) into a sensible default path and prints a platform-correct PATH hint. - Bootstrapping: missing uvr.toml/uvr.lock/.r-version are auto-created from a renv.lock generated via attachment, mirroring the renv backend's UX. Interactive prompt to install uvr if absent. `bootstrap = FALSE` for fail-fast CI usage. - The `frozen` argument: explains the manifest-vs-lockfile distinction, why frozen = FALSE is the current default (uvr 0.2.15 has a known cross-host false positive on `uvr sync --frozen`), and when to flip it to TRUE for strict reproducibility. - Lifecycle disclaimer + pointer to upstream nbafrank/uvr. README.md regenerated from README.Rmd. --- README.Rmd | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) 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**: From 93cac672bcceb7073090a57396efac3184ff754f Mon Sep 17 00:00:00 2001 From: Vincent Guyader <10470699+VincentGuyader@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:16:30 +0200 Subject: [PATCH 11/11] fix(uvr): address Copilot review #3 + restore CI CI on PR #11 was red because the unit tests use `withr::local_envvar()` but `withr` was not declared in DESCRIPTION. Add it under Suggests so `R CMD check` stops complaining about the unstated dependency. Also pick up the four most recent Copilot comments: - `R/utils_uvr.R` -- drop the gratuitous `apt-get update && ... && rm -rf /var/lib/apt/lists/*` around `uvr sync`. uvr sync does not invoke apt at this point, so the prefix added latency and a network dependency for no benefit. The RUN is now just `RUN uvr sync` (or `uvr sync --frozen` when `frozen = TRUE`). Test updated to assert the new shape via an anchored regex. - `R/shiny2docker_uvr.R` -- grammar: "an `cli` warning" -> "a `cli` warning" in the `frozen` argument doc. - `tests/testthat/test-shiny2docker_uvr-integration.R` -- replace the convoluted `!is.null(attr(build_rc, "status")) == FALSE` with the equivalent `is.null(attr(build_rc, "status"))`. Also skip cleanly when the fixture dir is absent (e.g. running on an installed package after `R CMD check` -- the dir is excluded via .Rbuildignore) rather than crashing on `file.copy()`. `.Rbuildignore` is intentionally left as is: the fixture is only used by the env-gated integration test, which now skips with a clear reason when run outside the source repo. --- DESCRIPTION | 5 +++-- R/shiny2docker_uvr.R | 2 +- R/utils_uvr.R | 2 +- tests/testthat/test-shiny2docker_uvr-integration.R | 11 ++++++++++- tests/testthat/test-shiny2docker_uvr.R | 2 +- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 546df4a..9bcf3f0 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -25,12 +25,13 @@ Roxygen: list(markdown = TRUE) 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/R/shiny2docker_uvr.R b/R/shiny2docker_uvr.R index 8acb6e3..95dcc45 100644 --- a/R/shiny2docker_uvr.R +++ b/R/shiny2docker_uvr.R @@ -55,7 +55,7 @@ #' `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`, an `cli` warning is +#' 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 diff --git a/R/utils_uvr.R b/R/utils_uvr.R index 2d356c6..bca5359 100644 --- a/R/utils_uvr.R +++ b/R/utils_uvr.R @@ -517,7 +517,7 @@ uvr_build_dockerfile <- function(base_image = "debian:stable-slim", # (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(paste("apt-get update &&", sync_cmd, "&& rm -rf /var/lib/apt/lists/*")) + dock$RUN(sync_cmd) dock$COPY(from = ".", to = "/srv/shiny-server/") diff --git a/tests/testthat/test-shiny2docker_uvr-integration.R b/tests/testthat/test-shiny2docker_uvr-integration.R index 8f79fe4..496c632 100644 --- a/tests/testthat/test-shiny2docker_uvr-integration.R +++ b/tests/testthat/test-shiny2docker_uvr-integration.R @@ -57,7 +57,16 @@ test_that("shiny2docker_uvr produces an image that serves a shiny app over HTTP" # 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), @@ -89,7 +98,7 @@ test_that("shiny2docker_uvr produces an image that serves a shiny app over HTTP" stdout = TRUE, stderr = TRUE ) build_dt <- as.numeric(difftime(Sys.time(), t0, units = "secs")) - expect_true(!is.null(attr(build_rc, "status")) == FALSE, + 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)) diff --git a/tests/testthat/test-shiny2docker_uvr.R b/tests/testthat/test-shiny2docker_uvr.R index b09b680..bda5f14 100644 --- a/tests/testthat/test-shiny2docker_uvr.R +++ b/tests/testthat/test-shiny2docker_uvr.R @@ -217,7 +217,7 @@ test_that("frozen = TRUE generates `uvr sync --frozen`; FALSE warns and runs pla "frozen = FALSE" ) contents_loose <- paste(readLines(out_file), collapse = "\n") - expect_match(contents_loose, "uvr sync &&") + 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