Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/recipe/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package recipe

// VerifyRPackagesCmd exposes verifyRPackagesCmd to the external test package.
func VerifyRPackagesCmd() string { return verifyRPackagesCmd() }
50 changes: 46 additions & 4 deletions internal/recipe/r.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type rPackage struct {
srcDir string // e.g. "source-forecast-latest"
}

// rExplicitDeps are packages that devtools used to bundle as freebies. They are
// installed up front and stay in the artifact as R app runtime dependencies.
var rExplicitDeps = []string{"cli", "curl", "lifecycle", "R6", "rlang", "withr", "processx", "callr", "stringr"}

// rSubDeps lists the R packages to install, in the same order as the Ruby builder:
// Rserve, forecast, shiny, plumber.
var rSubDeps = []rPackage{
Expand Down Expand Up @@ -98,13 +102,20 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input,
// the remotes package separately. Use remotes directly instead.
// We also explicitly install packages that devtools used to bundle as freebies
// (stringr, cli, etc.) to avoid breaking R apps that depend on them implicitly.
//
// fs >= 2.0.0 (pulled in by shiny via sass/bslib) needs libuv headers, which the
// stacks do not ship. USE_BUNDLED_LIBUV makes it build its vendored libuv instead.
rEnv := map[string]string{"USE_BUNDLED_LIBUV": "1"}
remotesCmd := `/usr/local/lib/R/bin/R --vanilla -e 'install.packages("remotes", repos="https://cran.r-project.org")'`
if err := run.Run("sh", "-c", remotesCmd); err != nil {
if err := run.RunWithEnv(rEnv, "sh", "-c", remotesCmd); err != nil {
return fmt.Errorf("r: installing remotes: %w", err)
}

depsCmd := `/usr/local/lib/R/bin/R --vanilla -e 'install.packages(c("cli", "curl", "lifecycle", "R6", "rlang", "withr", "processx", "callr", "stringr"), repos="https://cran.r-project.org")'`
if err := run.Run("sh", "-c", depsCmd); err != nil {
depsCmd := fmt.Sprintf(
`/usr/local/lib/R/bin/R --vanilla -e 'install.packages(c(%s), repos="https://cran.r-project.org")'`,
quoteRNames(`"`, rExplicitDeps),
)
if err := run.RunWithEnv(rEnv, "sh", "-c", depsCmd); err != nil {
return fmt.Errorf("r: installing explicit r dependencies: %w", err)
}

Expand All @@ -129,7 +140,7 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input,
`/usr/local/lib/R/bin/R --vanilla -e "require('remotes'); remotes::install_version('%s', '%s', repos='https://cran.r-project.org', type='source', dependencies=TRUE)"`,
pkg.name, pkgVersion,
)
if err := run.Run("sh", "-c", rCmd); err != nil {
if err := run.RunWithEnv(rEnv, "sh", "-c", rCmd); err != nil {
return fmt.Errorf("r: installing R package %s: %w", pkg.name, err)
}

Expand All @@ -142,6 +153,12 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input,
}
}

// R exits 0 when install.packages fails for a dependency, so verify the
// packages (and the dependencies that the buildpack tests rely on) are present.
if err := run.Run("sh", "-c", verifyRPackagesCmd()); err != nil {
return fmt.Errorf("r: verifying installed R packages: %w", err)
}

// Step 7: Remove remotes after use. The explicitly pre-installed packages
// (cli, curl, stringr, etc.) are intentionally left in the artifact — they
// are R app runtime dependencies that devtools used to bundle as freebies.
Expand Down Expand Up @@ -178,6 +195,31 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input,
return nil
}

// quoteRNames wraps each name in the given quote character and joins them
// into the body of an R c(...) call.
func quoteRNames(quote string, names []string) string {
quoted := make([]string, len(names))
for i, n := range names {
quoted[i] = quote + n + quote
}
return strings.Join(quoted, ", ")
}

// verifyRPackagesCmd returns an R command that exits non-zero if fs, the
// explicitly installed dependencies or the sub-dependency packages cannot be
// loaded. Loading (not just being listed as installed) also catches a broken
// native build such as fs against the bundled libuv.
func verifyRPackagesCmd() string {
names := append([]string{"fs"}, rExplicitDeps...)
for _, pkg := range rSubDeps {
names = append(names, pkg.name)
}
return fmt.Sprintf(
`/usr/local/lib/R/bin/R --vanilla -e "pkgs <- c(%s); missing <- pkgs[!vapply(pkgs, requireNamespace, logical(1), quietly=TRUE)]; if (length(missing) > 0) stop('missing R packages: ', paste(missing, collapse=', '))"`,
quoteRNames("'", names),
)
}

// formatRserveVersion converts "1.8.14" → "1.8-14"
// (first two dot-separated parts joined by '.', remainder joined by '-').
func formatRserveVersion(v string) string {
Expand Down
68 changes: 68 additions & 0 deletions internal/recipe/recipe_compiled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,74 @@ func TestRRecipeRemovesRemotes(t *testing.T) {
"should call remove.packages(\"remotes\") after package installs")
}

func TestRRecipeUsesBundledLibuvForPackageInstalls(t *testing.T) {
useTempWorkDir(t)
writeRSubDepFiles(t)

r := &recipe.RRecipe{Fetcher: newFakeFetcher()}
run := runner.NewFakeRunner()
s := newCompiledStack(t)
s.AptPackages["r_build"] = []string{"r-base"}
src := newInput("r", "4.3.1", "https://cran.r-project.org/src/base/R-4.3.1.tar.gz")

require.NoError(t, r.Build(context.Background(), s, src, run, &output.OutData{}))

installs := 0
for _, c := range run.Calls {
joined := strings.Join(c.Args, " ")
if c.Name == "sh" && (strings.Contains(joined, "install.packages(") || strings.Contains(joined, "install_version")) {
installs++
assert.Equal(t, "1", c.Env["USE_BUNDLED_LIBUV"], "package install must set USE_BUNDLED_LIBUV: %s", joined)
}
}
assert.Greater(t, installs, 0, "expected package install calls")
}

func TestRRecipeVerifiesPackagesBeforeRemovingRemotes(t *testing.T) {
useTempWorkDir(t)
writeRSubDepFiles(t)

r := &recipe.RRecipe{Fetcher: newFakeFetcher()}
run := runner.NewFakeRunner()
s := newCompiledStack(t)
s.AptPackages["r_build"] = []string{"r-base"}
src := newInput("r", "4.3.1", "https://cran.r-project.org/src/base/R-4.3.1.tar.gz")

require.NoError(t, r.Build(context.Background(), s, src, run, &output.OutData{}))

verifyIdx, removeIdx := -1, -1
for i, c := range run.Calls {
joined := strings.Join(c.Args, " ")
if c.Name == "sh" && strings.Contains(joined, "requireNamespace") {
verifyIdx = i
for _, pkg := range []string{"'fs'", "'shiny'", "'forecast'", "'plumber'", "'Rserve'", "'cli'", "'curl'", "'processx'", "'stringr'"} {
assert.Contains(t, joined, pkg)
}
}
if c.Name == "sh" && strings.Contains(joined, `remove.packages("remotes")`) {
removeIdx = i
}
}
require.True(t, verifyIdx >= 0, "package verification call not found")
assert.Less(t, verifyIdx, removeIdx)
}

func TestRRecipeFailsWhenPackageVerificationFails(t *testing.T) {
useTempWorkDir(t)
writeRSubDepFiles(t)

s := newCompiledStack(t)
s.AptPackages["r_build"] = []string{"r-base"}
src := newInput("r", "4.3.1", "https://cran.r-project.org/src/base/R-4.3.1.tar.gz")

run := runner.NewFakeRunner()
verifyKey := "sh -c " + recipe.VerifyRPackagesCmd()
run.ErrorMap = map[string]error{verifyKey: fmt.Errorf("missing R packages: fs")}
err := (&recipe.RRecipe{Fetcher: newFakeFetcher()}).Build(context.Background(), s, src, run, &output.OutData{})
require.Error(t, err)
assert.Contains(t, err.Error(), "verifying installed R packages")
}

func TestRserveVersionFormatting(t *testing.T) {
useTempWorkDir(t)
writeRSubDepFiles(t)
Expand Down
Loading