From 85010d3d2d5ae3afc063cdcc7ba4636b5f0f2463 Mon Sep 17 00:00:00 2001 From: Colin Casey Date: Fri, 14 Apr 2023 14:51:42 -0300 Subject: [PATCH] Scala CNB (#430) * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb * Scala CNB The initial implementation for the Scala CNB. * [GUS epics/work report](https://gus.lightning.force.com/lightning/r/ADM_Epic__c/a3QEE0000002X7x2AE/related/Work__r/view) (internal) * Upstream Cloud Native Buildpacks information * https://buildpacks.io/docs/ * https://github.com/buildpacks/spec/blob/main/buildpack.md * [libcnb.rs documentation](https://docs.rs/libcnb/latest/libcnb/) * Prior art: * https://github.com/heroku/heroku-buildpack-scala (the classic Heroku Scala buildpack) * Other `libcnb.rs`-based Heroku CNBs * https://github.com/heroku/buildpacks-go * https://github.com/heroku/buildpacks-nodejs * https://github.com/heroku/procfile-cnb --- Cargo.lock | 24 + Cargo.toml | 1 + buildpacks/sbt/CHANGELOG.md | 7 + buildpacks/sbt/Cargo.toml | 22 + buildpacks/sbt/README.md | 122 +++ .../heroku_buildpack_plugin_sbt_v0.scala | 10 + .../heroku_buildpack_plugin_sbt_v1.scala | 10 + buildpacks/sbt/assets/sbt-extras.sh | 664 +++++++++++++++++ buildpacks/sbt/assets/sbt-wrapper.sh | 19 + buildpacks/sbt/build.sh | 15 + buildpacks/sbt/buildpack.toml | 27 + buildpacks/sbt/package.toml | 2 + buildpacks/sbt/src/build_configuration.rs | 704 ++++++++++++++++++ buildpacks/sbt/src/errors.rs | 275 +++++++ buildpacks/sbt/src/file_tree.rs | 187 +++++ buildpacks/sbt/src/layers/coursier_cache.rs | 102 +++ buildpacks/sbt/src/layers/ivy_cache.rs | 95 +++ buildpacks/sbt/src/layers/mod.rs | 3 + buildpacks/sbt/src/layers/sbt.rs | 341 +++++++++ buildpacks/sbt/src/main.rs | 474 ++++++++++++ buildpacks/sbt/tests/integration_tests.rs | 115 +++ meta-buildpacks/scala/CHANGELOG.md | 6 + meta-buildpacks/scala/README.md | 1 + meta-buildpacks/scala/buildpack.toml | 32 + meta-buildpacks/scala/package.toml | 11 + .../scala-app-using-coursier/.gitignore | 9 + .../scala-app-using-coursier/Procfile | 1 + .../scala-app-using-coursier/build.sbt | 13 + .../project/build.properties | 1 + .../project/plugins.sbt | 1 + .../src/main/scala/com/example/Server.scala | 17 + .../system.properties | 1 + test-fixtures/scala-app-using-ivy/.gitignore | 9 + test-fixtures/scala-app-using-ivy/Procfile | 1 + test-fixtures/scala-app-using-ivy/build.sbt | 15 + .../project/build.properties | 1 + .../scala-app-using-ivy/project/plugins.sbt | 1 + .../src/main/scala/com/example/Https.scala | 51 ++ .../src/main/scala/com/example/Server.scala | 25 + .../scala-app-using-ivy/system.properties | 1 + test-fixtures/scala-play-app-2.7/.gitignore | 9 + test-fixtures/scala-play-app-2.7/Procfile | 1 + .../app/controllers/HomeController.scala | 24 + test-fixtures/scala-play-app-2.7/build.sbt | 17 + .../scala-play-app-2.7/conf/application.conf | 4 + test-fixtures/scala-play-app-2.7/conf/routes | 8 + .../project/build.properties | 1 + .../scala-play-app-2.7/project/plugins.sbt | 2 + test-fixtures/scala-play-app-2.8/.gitignore | 9 + test-fixtures/scala-play-app-2.8/Procfile | 1 + .../app/controllers/HomeController.scala | 24 + test-fixtures/scala-play-app-2.8/build.sbt | 17 + .../scala-play-app-2.8/conf/application.conf | 4 + test-fixtures/scala-play-app-2.8/conf/routes | 8 + .../project/build.properties | 1 + .../scala-play-app-2.8/project/plugins.sbt | 2 + 56 files changed, 3548 insertions(+) create mode 100644 buildpacks/sbt/CHANGELOG.md create mode 100644 buildpacks/sbt/Cargo.toml create mode 100644 buildpacks/sbt/README.md create mode 100644 buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v0.scala create mode 100644 buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v1.scala create mode 100644 buildpacks/sbt/assets/sbt-extras.sh create mode 100644 buildpacks/sbt/assets/sbt-wrapper.sh create mode 100755 buildpacks/sbt/build.sh create mode 100644 buildpacks/sbt/buildpack.toml create mode 100644 buildpacks/sbt/package.toml create mode 100644 buildpacks/sbt/src/build_configuration.rs create mode 100644 buildpacks/sbt/src/errors.rs create mode 100644 buildpacks/sbt/src/file_tree.rs create mode 100644 buildpacks/sbt/src/layers/coursier_cache.rs create mode 100644 buildpacks/sbt/src/layers/ivy_cache.rs create mode 100644 buildpacks/sbt/src/layers/mod.rs create mode 100644 buildpacks/sbt/src/layers/sbt.rs create mode 100644 buildpacks/sbt/src/main.rs create mode 100644 buildpacks/sbt/tests/integration_tests.rs create mode 100644 meta-buildpacks/scala/CHANGELOG.md create mode 100644 meta-buildpacks/scala/README.md create mode 100644 meta-buildpacks/scala/buildpack.toml create mode 100644 meta-buildpacks/scala/package.toml create mode 100644 test-fixtures/scala-app-using-coursier/.gitignore create mode 100644 test-fixtures/scala-app-using-coursier/Procfile create mode 100644 test-fixtures/scala-app-using-coursier/build.sbt create mode 100644 test-fixtures/scala-app-using-coursier/project/build.properties create mode 100644 test-fixtures/scala-app-using-coursier/project/plugins.sbt create mode 100644 test-fixtures/scala-app-using-coursier/src/main/scala/com/example/Server.scala create mode 100644 test-fixtures/scala-app-using-coursier/system.properties create mode 100644 test-fixtures/scala-app-using-ivy/.gitignore create mode 100644 test-fixtures/scala-app-using-ivy/Procfile create mode 100644 test-fixtures/scala-app-using-ivy/build.sbt create mode 100644 test-fixtures/scala-app-using-ivy/project/build.properties create mode 100644 test-fixtures/scala-app-using-ivy/project/plugins.sbt create mode 100644 test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Https.scala create mode 100644 test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Server.scala create mode 100644 test-fixtures/scala-app-using-ivy/system.properties create mode 100644 test-fixtures/scala-play-app-2.7/.gitignore create mode 100644 test-fixtures/scala-play-app-2.7/Procfile create mode 100644 test-fixtures/scala-play-app-2.7/app/controllers/HomeController.scala create mode 100644 test-fixtures/scala-play-app-2.7/build.sbt create mode 100644 test-fixtures/scala-play-app-2.7/conf/application.conf create mode 100644 test-fixtures/scala-play-app-2.7/conf/routes create mode 100644 test-fixtures/scala-play-app-2.7/project/build.properties create mode 100644 test-fixtures/scala-play-app-2.7/project/plugins.sbt create mode 100644 test-fixtures/scala-play-app-2.8/.gitignore create mode 100644 test-fixtures/scala-play-app-2.8/Procfile create mode 100644 test-fixtures/scala-play-app-2.8/app/controllers/HomeController.scala create mode 100644 test-fixtures/scala-play-app-2.8/build.sbt create mode 100644 test-fixtures/scala-play-app-2.8/conf/application.conf create mode 100644 test-fixtures/scala-play-app-2.8/conf/routes create mode 100644 test-fixtures/scala-play-app-2.8/project/build.properties create mode 100644 test-fixtures/scala-play-app-2.8/project/plugins.sbt diff --git a/Cargo.lock b/Cargo.lock index 4b89e80d..8d8fd33c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "h2" version = "0.3.17" @@ -1094,6 +1100,24 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" +[[package]] +name = "sbt" +version = "0.0.0" +dependencies = [ + "glob", + "indoc", + "java-properties", + "libcnb", + "libcnb-test", + "libherokubuildpack", + "semver", + "serde", + "shell-words", + "tempfile", + "thiserror", + "ureq", +] + [[package]] name = "scratch" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 1d8dde9c..6be0b569 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "buildpacks/jvm", "buildpacks/jvm-function-invoker", "buildpacks/maven", + "buildpacks/sbt", ] [workspace.package] diff --git a/buildpacks/sbt/CHANGELOG.md b/buildpacks/sbt/CHANGELOG.md new file mode 100644 index 00000000..3676ee19 --- /dev/null +++ b/buildpacks/sbt/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +* Initial release diff --git a/buildpacks/sbt/Cargo.toml b/buildpacks/sbt/Cargo.toml new file mode 100644 index 00000000..5bdebfa2 --- /dev/null +++ b/buildpacks/sbt/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "sbt" +version.workspace = true +rust-version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +libcnb.workspace = true +libherokubuildpack.workspace = true +indoc = "2" +java-properties = "1" +serde = { version = "1", features = ["derive"] } +tempfile = "3" +thiserror = "1" +semver="1" +shell-words = "1" +glob = "0.3" + +[dev-dependencies] +libcnb-test.workspace = true +ureq = "2" diff --git a/buildpacks/sbt/README.md b/buildpacks/sbt/README.md new file mode 100644 index 00000000..f97b8d0f --- /dev/null +++ b/buildpacks/sbt/README.md @@ -0,0 +1,122 @@ +# Heroku Cloud Native sbt Buildpack +[![CI](https://github.com/heroku/buildpacks-jvm/actions/workflows/ci.yml/badge.svg)](https://github.com/heroku/buildpacks-jvm/actions/workflows/ci.yml) + +Heroku's official Cloud Native Buildpack for [sbt](https://www.scala-sbt.org/) usage in [Scala](https://www.scala-lang.org/) applications. + +## How it works + +The buildpack will detect if your application requires `sbt` if any one of the following file patterns match: +- `project/build.properties` +- `project/*.scala` +- `*.sbt` +- `.sbt/*.scala` + +### Step 1: Download sbt + +An [sbt wrapper script](https://github.com/dwijnand/sbt-extras) is written into its own layer that will contain the sbt +build tooling. This wrapper script then executes to download the sbt version specified in your `project/build.properties` file. + +### Step 2: Run sbt + +By default, the [sbt](https://www.scala-sbt.org/index.html) command used to build the application is `sbt compile stage`. +Applications that require customizations to this process to build successfully should refer to the [Customizing](#customizing) +section of this document. + +## Customizing + +This buildpack exposes the following configurable settings. The order they appear in the table indicates their precedence. + +> For example, if your application contains: +> * a `system.properties` file with the `sbt.project` property set +> * and the environment variable `SBT_PROJECT` is set +> +> Then the value from `system.properties` will be used. + +### Configure a sbt subproject build + +When this setting is configured, the default build tasks will be prepended with the supplied project name. E.g.; the default +`compile` and `stage` tasks would become `{subproject}/compile` and `{subproject}/stage`. + +| From | Path | Name | +|--------------------|---------------------|---------------| +| Java property file | `system.properties` | `sbt.project` | +| Environment | | `SBT_PROJECT` | + +### Specify tasks to execute before building + +This setting will prepend the supplied tasks to the list of tasks to run during the build. These must be supplied as +a string of space-separated task names. E.g.; a value of `task1 task2` would cause the build step to be invoked with +`sbt task1 task2 compile stage`. + +| From | Path | Name | +|--------------------|---------------------|-----------------| +| Java property file | `system.properties` | `sbt.pre-tasks` | +| Environment | | `SBT_PRE_TASKS` | + +### Specify which build tasks to use + +This setting will override the default build tasks of `compile` and `stage`. These must be supplied as +a string of space-separated task names. E.g.; a value of `mybuild` would cause the build step to be invoked with +`sbt mybuild`. + +| From | Path | Name | +|--------------------|---------------------|-------------| +| Java property file | `system.properties` | `sbt.tasks` | +| Environment | | `SBT_TASKS` | + +### Cleaning the project before build + +This setting will prepend a `clean` task to before all other tasks to run during the build. This must be supplied as a +value of either `true` or `false`. E.g.; setting this value to `true` would cause the build step to be invoked with +`sbt clean compile stage`. + +| From | Path | Name | +|--------------------|---------------------|-------------| +| Java property file | `system.properties` | `sbt.clean` | +| Environment | | `SBT_CLEAN` | + +### Making sbt available at launch + +By default, the `sbt` executable as well as its caches are only available during the build process. If you need +`sbt` to launch your application you can configure this setting with a value of `true`. + +| From | Path | Name | +|--------------------|---------------------|---------------------------| +| Java property file | `system.properties` | `sbt.available-at-launch` | +| Environment | | `SBT_AVAILABLE_AT_LAUNCH` | + +### Adding custom sbt options + +If the `SBT_OPTS` environment variable is defined when sbt starts, its content are passed as command line arguments to +the JVM running sbt. + +If a file named `.sbtopts` exists, its content is appended to `SBT_OPTS`. + +When passing options to the underlying sbt JVM, you must prefix them with `-J`. Thus, setting stack size for the compile +process would look like this: + +```sh +heroku config:set SBT_OPTS="-J-Xss4m" +``` + +| From | Path | Name | +|--------------|---------------------|------------| +| Environment | | `SBT_OPTS` | +| Options file | `.sbtopts` | | + + +## Build Plan + +### Requires + +* `jdk`: To compile Java sources a JDK is required. It can be provided by the `heroku/jvm` ([Source](/buildpacks/jvm), +[Readme](/buildpacks/jvm/README.md)) buildpack. +* `jvm-application`: This is not a strict requirement of the buildpack. Requiring `jvm-application` ensures that this +buildpack can be used even when no other buildpack requires `jvm-application`. + +### Provides + +* `jvm-application`: Allows other buildpacks to depend on a compiled JVM application. + +## License +See [LICENSE](../../LICENSE) file. diff --git a/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v0.scala b/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v0.scala new file mode 100644 index 00000000..721931fe --- /dev/null +++ b/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v0.scala @@ -0,0 +1,10 @@ +import sbt._ +import Keys._ + +object HerokuBuildpackPlugin extends Plugin { + override def settings = Seq( + sources in doc in Compile := List(), + publishArtifact in packageDoc := false, + publishArtifact in packageSrc := false + ) +} diff --git a/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v1.scala b/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v1.scala new file mode 100644 index 00000000..923a6c03 --- /dev/null +++ b/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v1.scala @@ -0,0 +1,10 @@ +import sbt._ +import Keys._ + +object HerokuBuildpackPlugin extends AutoPlugin { + override lazy val projectSettings = Seq( + Compile / doc / sources := List(), + packageDoc / publishArtifact := false, + packageSrc / publishArtifact := false + ) +} diff --git a/buildpacks/sbt/assets/sbt-extras.sh b/buildpacks/sbt/assets/sbt-extras.sh new file mode 100644 index 00000000..0c5112ba --- /dev/null +++ b/buildpacks/sbt/assets/sbt-extras.sh @@ -0,0 +1,664 @@ +#!/usr/bin/env bash +# +# A more capable sbt runner, coincidentally also called sbt. +# Author: Paul Phillips +# https://github.com/paulp/sbt-extras +# +# Generated from http://www.opensource.org/licenses/bsd-license.php +# Copyright (c) 2011, Paul Phillips. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +set -o pipefail + +declare -r sbt_release_version="1.8.2" +declare -r sbt_unreleased_version="1.8.2" + +declare -r latest_213="2.13.10" +declare -r latest_212="2.12.17" +declare -r latest_211="2.11.12" +declare -r latest_210="2.10.7" +declare -r latest_29="2.9.3" +declare -r latest_28="2.8.2" + +declare -r buildProps="project/build.properties" + +declare -r sbt_launch_ivy_release_repo="https://repo.typesafe.com/typesafe/ivy-releases" +declare -r sbt_launch_ivy_snapshot_repo="https://repo.scala-sbt.org/scalasbt/ivy-snapshots" +declare -r sbt_launch_mvn_release_repo="https://repo1.maven.org/maven2" +declare -r sbt_launch_mvn_snapshot_repo="https://repo.scala-sbt.org/scalasbt/maven-snapshots" + +declare -r default_jvm_opts_common="-Xms512m -Xss2m -XX:MaxInlineLevel=18" +declare -r noshare_opts="-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy -Dsbt.coursier.home=project/.coursier" + +declare sbt_jar sbt_dir sbt_create sbt_version sbt_script sbt_new +declare sbt_explicit_version +declare verbose noshare batch trace_level + +declare java_cmd="java" +declare sbt_launch_dir="$HOME/.sbt/launchers" +declare sbt_launch_repo + +# pull -J and -D options to give to java. +declare -a java_args scalac_args sbt_commands residual_args + +# args to jvm/sbt via files or environment variables +declare -a extra_jvm_opts extra_sbt_opts + +echoerr() { echo >&2 "$@"; } +vlog() { [[ -n "$verbose" ]] && echoerr "$@"; } +die() { + echo "Aborting: $*" + exit 1 +} + +setTrapExit() { + # save stty and trap exit, to ensure echo is re-enabled if we are interrupted. + SBT_STTY="$(stty -g 2>/dev/null)" + export SBT_STTY + + # restore stty settings (echo in particular) + onSbtRunnerExit() { + [ -t 0 ] || return + vlog "" + vlog "restoring stty: $SBT_STTY" + stty "$SBT_STTY" + } + + vlog "saving stty: $SBT_STTY" + trap onSbtRunnerExit EXIT +} + +# this seems to cover the bases on OSX, and someone will +# have to tell me about the others. +get_script_path() { + local path="$1" + [[ -L "$path" ]] || { + echo "$path" + return + } + + local -r target="$(readlink "$path")" + if [[ "${target:0:1}" == "/" ]]; then + echo "$target" + else + echo "${path%/*}/$target" + fi +} + +script_path="$(get_script_path "${BASH_SOURCE[0]}")" +declare -r script_path +script_name="${script_path##*/}" +declare -r script_name + +init_default_option_file() { + local overriding_var="${!1}" + local default_file="$2" + if [[ ! -r "$default_file" && "$overriding_var" =~ ^@(.*)$ ]]; then + local envvar_file="${BASH_REMATCH[1]}" + if [[ -r "$envvar_file" ]]; then + default_file="$envvar_file" + fi + fi + echo "$default_file" +} + +sbt_opts_file="$(init_default_option_file SBT_OPTS .sbtopts)" +sbtx_opts_file="$(init_default_option_file SBTX_OPTS .sbtxopts)" +jvm_opts_file="$(init_default_option_file JVM_OPTS .jvmopts)" + +build_props_sbt() { + [[ -r "$buildProps" ]] && + grep '^sbt\.version' "$buildProps" | tr '=\r' ' ' | awk '{ print $2; }' +} + +set_sbt_version() { + sbt_version="${sbt_explicit_version:-$(build_props_sbt)}" + [[ -n "$sbt_version" ]] || sbt_version=$sbt_release_version + export sbt_version +} + +url_base() { + local version="$1" + + case "$version" in + 0.7.*) echo "https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/simple-build-tool" ;; + 0.10.*) echo "$sbt_launch_ivy_release_repo" ;; + 0.11.[12]) echo "$sbt_launch_ivy_release_repo" ;; + 0.*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) # ie "*-yyyymmdd-hhMMss" + echo "$sbt_launch_ivy_snapshot_repo" ;; + 0.*) echo "$sbt_launch_ivy_release_repo" ;; + *-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]) # ie "*-yyyymmddThhMMss" + echo "$sbt_launch_mvn_snapshot_repo" ;; + *) echo "$sbt_launch_mvn_release_repo" ;; + esac +} + +make_url() { + local version="$1" + + local base="${sbt_launch_repo:-$(url_base "$version")}" + + case "$version" in + 0.7.*) echo "$base/sbt-launch-0.7.7.jar" ;; + 0.10.*) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; + 0.11.[12]) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; + 0.*) echo "$base/org.scala-sbt/sbt-launch/$version/sbt-launch.jar" ;; + *) echo "$base/org/scala-sbt/sbt-launch/$version/sbt-launch-${version}.jar" ;; + esac +} + +addJava() { + vlog "[addJava] arg = '$1'" + java_args+=("$1") +} +addSbt() { + vlog "[addSbt] arg = '$1'" + sbt_commands+=("$1") +} +addScalac() { + vlog "[addScalac] arg = '$1'" + scalac_args+=("$1") +} +addResidual() { + vlog "[residual] arg = '$1'" + residual_args+=("$1") +} + +addResolver() { addSbt "set resolvers += $1"; } + +addDebugger() { addJava "-Xdebug" && addJava "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=$1"; } + +setThisBuild() { + vlog "[addBuild] args = '$*'" + local key="$1" && shift + addSbt "set $key in ThisBuild := $*" +} +setScalaVersion() { + [[ "$1" == *"-SNAPSHOT" ]] && addResolver 'Resolver.sonatypeRepo("snapshots")' + addSbt "++ $1" +} +setJavaHome() { + java_cmd="$1/bin/java" + setThisBuild javaHome "_root_.scala.Some(file(\"$1\"))" + export JAVA_HOME="$1" + export JDK_HOME="$1" + export PATH="$JAVA_HOME/bin:$PATH" +} + +getJavaVersion() { + local -r str=$("$1" -version 2>&1 | grep -E -e '(java|openjdk) version' | awk '{ print $3 }' | tr -d '"') + + # java -version on java8 says 1.8.x + # but on 9 and 10 it's 9.x.y and 10.x.y. + if [[ "$str" =~ ^1\.([0-9]+)(\..*)?$ ]]; then + echo "${BASH_REMATCH[1]}" + # Fixes https://github.com/dwijnand/sbt-extras/issues/326 + elif [[ "$str" =~ ^([0-9]+)(\..*)?(-ea)?$ ]]; then + echo "${BASH_REMATCH[1]}" + elif [[ -n "$str" ]]; then + echoerr "Can't parse java version from: $str" + fi +} + +checkJava() { + # Warn if there is a Java version mismatch between PATH and JAVA_HOME/JDK_HOME + + [[ -n "$JAVA_HOME" && -e "$JAVA_HOME/bin/java" ]] && java="$JAVA_HOME/bin/java" + [[ -n "$JDK_HOME" && -e "$JDK_HOME/lib/tools.jar" ]] && java="$JDK_HOME/bin/java" + + if [[ -n "$java" ]]; then + pathJavaVersion=$(getJavaVersion java) + homeJavaVersion=$(getJavaVersion "$java") + if [[ "$pathJavaVersion" != "$homeJavaVersion" ]]; then + echoerr "Warning: Java version mismatch between PATH and JAVA_HOME/JDK_HOME, sbt will use the one in PATH" + echoerr " Either: fix your PATH, remove JAVA_HOME/JDK_HOME or use -java-home" + echoerr " java version from PATH: $pathJavaVersion" + echoerr " java version from JAVA_HOME/JDK_HOME: $homeJavaVersion" + fi + fi +} + +java_version() { + local -r version=$(getJavaVersion "$java_cmd") + vlog "Detected Java version: $version" + echo "$version" +} + +is_apple_silicon() { [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; } + +# MaxPermSize critical on pre-8 JVMs but incurs noisy warning on 8+ +default_jvm_opts() { + local -r v="$(java_version)" + if [[ $v -ge 17 ]]; then + echo "$default_jvm_opts_common" + elif [[ $v -ge 10 ]]; then + if is_apple_silicon; then + # As of Dec 2020, JVM for Apple Silicon (M1) doesn't support JVMCI + echo "$default_jvm_opts_common" + else + echo "$default_jvm_opts_common -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler" + fi + elif [[ $v -ge 8 ]]; then + echo "$default_jvm_opts_common" + else + echo "-XX:MaxPermSize=384m $default_jvm_opts_common" + fi +} + +execRunner() { + # print the arguments one to a line, quoting any containing spaces + vlog "# Executing command line:" && { + for arg; do + if [[ -n "$arg" ]]; then + if printf "%s\n" "$arg" | grep -q ' '; then + printf >&2 "\"%s\"\n" "$arg" + else + printf >&2 "%s\n" "$arg" + fi + fi + done + vlog "" + } + + setTrapExit + + if [[ -n "$batch" ]]; then + "$@" /dev/null 2>&1; then + curl --fail --silent --location "$url" --output "$jar" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$jar" "$url" + fi + } && [[ -r "$jar" ]] +} + +acquire_sbt_jar() { + { + sbt_jar="$(jar_file "$sbt_version")" + [[ -r "$sbt_jar" ]] + } || { + sbt_jar="$HOME/.ivy2/local/org.scala-sbt/sbt-launch/$sbt_version/jars/sbt-launch.jar" + [[ -r "$sbt_jar" ]] + } || { + sbt_jar="$(jar_file "$sbt_version")" + jar_url="$(make_url "$sbt_version")" + + echoerr "Downloading sbt launcher for ${sbt_version}:" + echoerr " From ${jar_url}" + echoerr " To ${sbt_jar}" + + download_url "${jar_url}" "${sbt_jar}" + + case "${sbt_version}" in + 0.*) + vlog "SBT versions < 1.0 do not have published MD5 checksums, skipping check" + echo "" + ;; + *) verify_sbt_jar "${sbt_jar}" ;; + esac + } +} + +verify_sbt_jar() { + local jar="${1}" + local md5="${jar}.md5" + md5url="$(make_url "${sbt_version}").md5" + + echoerr "Downloading sbt launcher ${sbt_version} md5 hash:" + echoerr " From ${md5url}" + echoerr " To ${md5}" + + download_url "${md5url}" "${md5}" >/dev/null 2>&1 + + if command -v md5sum >/dev/null 2>&1; then + if echo "$(cat "${md5}") ${jar}" | md5sum -c -; then + rm -rf "${md5}" + return 0 + else + echoerr "Checksum does not match" + return 1 + fi + elif command -v md5 >/dev/null 2>&1; then + if [ "$(md5 -q "${jar}")" == "$(cat "${md5}")" ]; then + rm -rf "${md5}" + return 0 + else + echoerr "Checksum does not match" + return 1 + fi + elif command -v openssl >/dev/null 2>&1; then + if [ "$(openssl md5 -r "${jar}" | awk '{print $1}')" == "$(cat "${md5}")" ]; then + rm -rf "${md5}" + return 0 + else + echoerr "Checksum does not match" + return 1 + fi + else + echoerr "Could not find an MD5 command" + return 1 + fi +} + +usage() { + set_sbt_version + cat < display stack traces with a max of frames (default: -1, traces suppressed) + -debug-inc enable debugging log for the incremental compiler + -no-colors disable ANSI color codes + -sbt-create start sbt even if current directory contains no sbt project + -sbt-dir path to global settings/plugins directory (default: ~/.sbt/) + -sbt-boot path to shared boot directory (default: ~/.sbt/boot in 0.11+) + -ivy path to local Ivy repository (default: ~/.ivy2) + -no-share use all local caches; no sharing + -offline put sbt in offline mode + -jvm-debug Turn on JVM debugging, open at the given port. + -batch Disable interactive mode + -prompt Set the sbt prompt; in expr, 's' is the State and 'e' is Extracted + -script Run the specified file as a scala script + + # sbt version (default: sbt.version from $buildProps if present, otherwise $sbt_release_version) + -sbt-version use the specified version of sbt (default: $sbt_release_version) + -sbt-force-latest force the use of the latest release of sbt: $sbt_release_version + -sbt-dev use the latest pre-release version of sbt: $sbt_unreleased_version + -sbt-jar use the specified jar as the sbt launcher + -sbt-launch-dir directory to hold sbt launchers (default: $sbt_launch_dir) + -sbt-launch-repo repo url for downloading sbt launcher jar (default: $(url_base "$sbt_version")) + + # scala version (default: as chosen by sbt) + -28 use $latest_28 + -29 use $latest_29 + -210 use $latest_210 + -211 use $latest_211 + -212 use $latest_212 + -213 use $latest_213 + -scala-home use the scala build at the specified directory + -scala-version use the specified version of scala + -binary-version use the specified scala version when searching for dependencies + + # java version (default: java from PATH, currently $(java -version 2>&1 | grep version)) + -java-home alternate JAVA_HOME + + # passing options to the jvm - note it does NOT use JAVA_OPTS due to pollution + # The default set is used if JVM_OPTS is unset and no -jvm-opts file is found + $(default_jvm_opts) + JVM_OPTS environment variable holding either the jvm args directly, or + the reference to a file containing jvm args if given path is prepended by '@' (e.g. '@/etc/jvmopts') + Note: "@"-file is overridden by local '.jvmopts' or '-jvm-opts' argument. + -jvm-opts file containing jvm args (if not given, .jvmopts in project root is used if present) + -Dkey=val pass -Dkey=val directly to the jvm + -J-X pass option -X directly to the jvm (-J is stripped) + + # passing options to sbt, OR to this runner + SBT_OPTS environment variable holding either the sbt args directly, or + the reference to a file containing sbt args if given path is prepended by '@' (e.g. '@/etc/sbtopts') + Note: "@"-file is overridden by local '.sbtopts' or '-sbt-opts' argument. + -sbt-opts file containing sbt args (if not given, .sbtopts in project root is used if present) + -S-X add -X to sbt's scalacOptions (-S is stripped) + + # passing options exclusively to this runner + SBTX_OPTS environment variable holding either the sbt-extras args directly, or + the reference to a file containing sbt-extras args if given path is prepended by '@' (e.g. '@/etc/sbtxopts') + Note: "@"-file is overridden by local '.sbtxopts' or '-sbtx-opts' argument. + -sbtx-opts file containing sbt-extras args (if not given, .sbtxopts in project root is used if present) +EOM + exit 0 +} + +process_args() { + require_arg() { + local type="$1" + local opt="$2" + local arg="$3" + + if [[ -z "$arg" ]] || [[ "${arg:0:1}" == "-" ]]; then + die "$opt requires <$type> argument" + fi + } + while [[ $# -gt 0 ]]; do + case "$1" in + -h | -help) usage ;; + -v) verbose=true && shift ;; + -d) addSbt "--debug" && shift ;; + -w) addSbt "--warn" && shift ;; + -q) addSbt "--error" && shift ;; + -x) shift ;; # currently unused + -trace) require_arg integer "$1" "$2" && trace_level="$2" && shift 2 ;; + -debug-inc) addJava "-Dxsbt.inc.debug=true" && shift ;; + + -no-colors) addJava "-Dsbt.log.noformat=true" && addJava "-Dsbt.color=false" && shift ;; + -sbt-create) sbt_create=true && shift ;; + -sbt-dir) require_arg path "$1" "$2" && sbt_dir="$2" && shift 2 ;; + -sbt-boot) require_arg path "$1" "$2" && addJava "-Dsbt.boot.directory=$2" && shift 2 ;; + -ivy) require_arg path "$1" "$2" && addJava "-Dsbt.ivy.home=$2" && shift 2 ;; + -no-share) noshare=true && shift ;; + -offline) addSbt "set offline in Global := true" && shift ;; + -jvm-debug) require_arg port "$1" "$2" && addDebugger "$2" && shift 2 ;; + -batch) batch=true && shift ;; + -prompt) require_arg "expr" "$1" "$2" && setThisBuild shellPrompt "(s => { val e = Project.extract(s) ; $2 })" && shift 2 ;; + -script) require_arg file "$1" "$2" && sbt_script="$2" && addJava "-Dsbt.main.class=sbt.ScriptMain" && shift 2 ;; + + -sbt-version) require_arg version "$1" "$2" && sbt_explicit_version="$2" && shift 2 ;; + -sbt-force-latest) sbt_explicit_version="$sbt_release_version" && shift ;; + -sbt-dev) sbt_explicit_version="$sbt_unreleased_version" && shift ;; + -sbt-jar) require_arg path "$1" "$2" && sbt_jar="$2" && shift 2 ;; + -sbt-launch-dir) require_arg path "$1" "$2" && sbt_launch_dir="$2" && shift 2 ;; + -sbt-launch-repo) require_arg path "$1" "$2" && sbt_launch_repo="$2" && shift 2 ;; + + -28) setScalaVersion "$latest_28" && shift ;; + -29) setScalaVersion "$latest_29" && shift ;; + -210) setScalaVersion "$latest_210" && shift ;; + -211) setScalaVersion "$latest_211" && shift ;; + -212) setScalaVersion "$latest_212" && shift ;; + -213) setScalaVersion "$latest_213" && shift ;; + + -scala-version) require_arg version "$1" "$2" && setScalaVersion "$2" && shift 2 ;; + -binary-version) require_arg version "$1" "$2" && setThisBuild scalaBinaryVersion "\"$2\"" && shift 2 ;; + -scala-home) require_arg path "$1" "$2" && setThisBuild scalaHome "_root_.scala.Some(file(\"$2\"))" && shift 2 ;; + -java-home) require_arg path "$1" "$2" && setJavaHome "$2" && shift 2 ;; + -sbt-opts) require_arg path "$1" "$2" && sbt_opts_file="$2" && shift 2 ;; + -sbtx-opts) require_arg path "$1" "$2" && sbtx_opts_file="$2" && shift 2 ;; + -jvm-opts) require_arg path "$1" "$2" && jvm_opts_file="$2" && shift 2 ;; + + -D*) addJava "$1" && shift ;; + -J*) addJava "${1:2}" && shift ;; + -S*) addScalac "${1:2}" && shift ;; + + new) sbt_new=true && : ${sbt_explicit_version:=$sbt_release_version} && addResidual "$1" && shift ;; + + *) addResidual "$1" && shift ;; + esac + done +} + +# process the direct command line arguments +process_args "$@" + +# skip #-styled comments and blank lines +readConfigFile() { + local end=false + until $end; do + read -r || end=true + [[ $REPLY =~ ^# ]] || [[ -z $REPLY ]] || echo "$REPLY" + done <"$1" +} + +# if there are file/environment sbt_opts, process again so we +# can supply args to this runner +if [[ -r "$sbt_opts_file" ]]; then + vlog "Using sbt options defined in file $sbt_opts_file" + while read -r opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbt_opts_file") +elif [[ -n "$SBT_OPTS" && ! ("$SBT_OPTS" =~ ^@.*) ]]; then + vlog "Using sbt options defined in variable \$SBT_OPTS" + IFS=" " read -r -a extra_sbt_opts <<<"$SBT_OPTS" +else + vlog "No extra sbt options have been defined" +fi + +# if there are file/environment sbtx_opts, process again so we +# can supply args to this runner +if [[ -r "$sbtx_opts_file" ]]; then + vlog "Using sbt options defined in file $sbtx_opts_file" + while read -r opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbtx_opts_file") +elif [[ -n "$SBTX_OPTS" && ! ("$SBTX_OPTS" =~ ^@.*) ]]; then + vlog "Using sbt options defined in variable \$SBTX_OPTS" + IFS=" " read -r -a extra_sbt_opts <<<"$SBTX_OPTS" +else + vlog "No extra sbt options have been defined" +fi + +[[ -n "${extra_sbt_opts[*]}" ]] && process_args "${extra_sbt_opts[@]}" + +# reset "$@" to the residual args +set -- "${residual_args[@]}" +argumentCount=$# + +# set sbt version +set_sbt_version + +checkJava + +# only exists in 0.12+ +setTraceLevel() { + case "$sbt_version" in + "0.7."* | "0.10."* | "0.11."*) echoerr "Cannot set trace level in sbt version $sbt_version" ;; + *) setThisBuild traceLevel "$trace_level" ;; + esac +} + +# set scalacOptions if we were given any -S opts +[[ ${#scalac_args[@]} -eq 0 ]] || addSbt "set scalacOptions in ThisBuild += \"${scalac_args[*]}\"" + +[[ -n "$sbt_explicit_version" && -z "$sbt_new" ]] && addJava "-Dsbt.version=$sbt_explicit_version" +vlog "Detected sbt version $sbt_version" + +if [[ -n "$sbt_script" ]]; then + residual_args=("$sbt_script" "${residual_args[@]}") +else + # no args - alert them there's stuff in here + ((argumentCount > 0)) || { + vlog "Starting $script_name: invoke with -help for other options" + residual_args=(shell) + } +fi + +# verify this is an sbt dir, -create was given or user attempts to run a scala script +[[ -r ./build.sbt || -d ./project || -n "$sbt_create" || -n "$sbt_script" || -n "$sbt_new" ]] || { + cat </dev/null 2>&1 && pwd)" + +pushd "${buildpack_dir}" + +cargo libcnb package --release + +mkdir -p target +cp -r ../../target/buildpack/release/heroku_sbt/* target/ +cp package.toml target/ + +popd diff --git a/buildpacks/sbt/buildpack.toml b/buildpacks/sbt/buildpack.toml new file mode 100644 index 00000000..b57494ba --- /dev/null +++ b/buildpacks/sbt/buildpack.toml @@ -0,0 +1,27 @@ +api = "0.8" + +[buildpack] +id = "heroku/sbt" +version = "0.0.1" +name = "sbt" +clear-env = true +homepage = "https://github.com/heroku/buildpacks-jvm" +description = "Official Heroku buildpack for sbt in Scala applications." +keywords = ["java", "scala", "sbt"] + +[[buildpack.licenses]] +type = "BSD-3-Clause" + +[[stacks]] +id = "heroku-18" + +[[stacks]] +id = "heroku-20" + +[[stacks]] +id = "heroku-22" + +[[stacks]] +id = "*" + + diff --git a/buildpacks/sbt/package.toml b/buildpacks/sbt/package.toml new file mode 100644 index 00000000..54b0d2e4 --- /dev/null +++ b/buildpacks/sbt/package.toml @@ -0,0 +1,2 @@ +[buildpack] +uri = "." diff --git a/buildpacks/sbt/src/build_configuration.rs b/buildpacks/sbt/src/build_configuration.rs new file mode 100644 index 00000000..bf0b0879 --- /dev/null +++ b/buildpacks/sbt/src/build_configuration.rs @@ -0,0 +1,704 @@ +use crate::errors::ScalaBuildpackError; +use crate::errors::ScalaBuildpackError::{ + CouldNotConvertEnvironmentValueIntoString, CouldNotParseBooleanFromEnvironment, + CouldNotParseBooleanFromProperty, CouldNotParseListConfigurationFromEnvironment, + CouldNotParseListConfigurationFromProperty, CouldNotParseListConfigurationFromSbtOptsFile, + CouldNotReadSbtOptsFile, InvalidSbtPropertiesFile, MissingDeclaredSbtVersion, + MissingSbtBuildPropertiesFile, SbtPropertiesFileReadError, SbtVersionNotInSemverFormat, + UnsupportedSbtVersion, +}; +use libcnb::Env; +use semver::{Version, VersionReq}; +use std::collections::HashMap; +use std::fs::{read_to_string, File}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub(crate) struct BuildConfiguration { + pub(crate) sbt_project: Option, + pub(crate) sbt_pre_tasks: Option>, + pub(crate) sbt_tasks: Option>, + pub(crate) sbt_clean: Option, + pub(crate) sbt_opts: Option>, + pub(crate) sbt_available_at_launch: Option, + pub(crate) sbt_version: Version, +} + +pub(crate) fn create_build_config>( + app_dir: P, + env: &Env, +) -> Result { + let app_dir = app_dir.into(); + let sbt_opts_file = app_dir.join(".sbtopts"); + let properties = read_system_properties(&app_dir); + Ok(BuildConfiguration { + sbt_project: read_string_config("sbt.project", &properties, "SBT_PROJECT", env)?, + sbt_pre_tasks: read_string_list_config("sbt.pre-tasks", &properties, "SBT_PRE_TASKS", env)?, + sbt_tasks: read_string_list_config("sbt.tasks", &properties, "SBT_TASKS", env)?, + sbt_clean: read_boolean_config("sbt.clean", &properties, "SBT_CLEAN", env)?, + sbt_available_at_launch: read_boolean_config( + "sbt.available-at-launch", + &properties, + "SBT_AVAILABLE_AT_LAUNCH", + env, + )?, + sbt_opts: read_sbt_opts(sbt_opts_file, env)?, + sbt_version: get_declared_sbt_version(&app_dir)?, + }) +} + +fn read_system_properties(app_dir: &Path) -> HashMap { + File::open(app_dir.join("system.properties")) + .map(|file| java_properties::read(file).unwrap_or_default()) + .unwrap_or_default() +} + +fn read_string_config( + property_name: &str, + system_properties: &HashMap, + environment_variable_name: &str, + env: &Env, +) -> Result, ScalaBuildpackError> { + if let Some(value) = system_properties.get(property_name) { + return Ok(Some(value.clone())); + } + + if let Some(value) = env.get(environment_variable_name) { + let value = value.into_string().map_err(|e| { + CouldNotConvertEnvironmentValueIntoString(environment_variable_name.to_string(), e) + })?; + return Ok(Some(value)); + } + + Ok(None) +} + +fn read_boolean_config( + property_name: &str, + system_properties: &HashMap, + environment_variable_name: &str, + env: &Env, +) -> Result, ScalaBuildpackError> { + if let Some(value) = system_properties.get(property_name) { + return value + .parse::() + .map(Some) + .map_err(|e| CouldNotParseBooleanFromProperty(property_name.to_string(), e)); + } + + if let Some(value) = env.get(environment_variable_name) { + let value = value.into_string().map_err(|e| { + CouldNotConvertEnvironmentValueIntoString(environment_variable_name.to_string(), e) + })?; + return value.parse::().map(Some).map_err(|e| { + CouldNotParseBooleanFromEnvironment(environment_variable_name.to_string(), e) + }); + } + + Ok(None) +} + +fn read_string_list_config( + property_name: &str, + system_properties: &HashMap, + environment_variable_name: &str, + env: &Env, +) -> Result>, ScalaBuildpackError> { + if let Some(value) = system_properties.get(property_name) { + return shell_words::split(value) + .map(Some) + .map_err(|e| CouldNotParseListConfigurationFromProperty(property_name.to_string(), e)); + } + + if let Some(value) = env.get(environment_variable_name) { + let value = value.into_string().map_err(|e| { + CouldNotConvertEnvironmentValueIntoString(environment_variable_name.to_string(), e) + })?; + return shell_words::split(&value).map(Some).map_err(|e| { + CouldNotParseListConfigurationFromEnvironment(environment_variable_name.to_string(), e) + }); + } + + Ok(None) +} + +fn read_sbt_opts( + opts_file: PathBuf, + env: &Env, +) -> Result>, ScalaBuildpackError> { + let mut sbt_opts: Vec = vec![]; + let mut configured = false; + + if opts_file.exists() { + let contents = read_to_string(opts_file).map_err(CouldNotReadSbtOptsFile)?; + let mut opts = + shell_words::split(&contents).map_err(CouldNotParseListConfigurationFromSbtOptsFile)?; + sbt_opts.append(&mut opts); + configured = true; + } + + if let Some(value) = env.get("SBT_OPTS") { + let value = value + .into_string() + .map_err(|e| CouldNotConvertEnvironmentValueIntoString("SBT_OPTS".to_string(), e))?; + let mut opts = shell_words::split(&value).map_err(|e| { + CouldNotParseListConfigurationFromEnvironment("SBT_OPTS".to_string(), e) + })?; + sbt_opts.append(&mut opts); + configured = true; + } + + if configured { + Ok(Some(sbt_opts)) + } else { + Ok(None) + } +} + +fn get_declared_sbt_version(app_dir: &Path) -> Result { + let build_properties_path = app_dir.join("project").join("build.properties"); + + if !build_properties_path.exists() { + return Err(MissingSbtBuildPropertiesFile); + } + + let build_properties_file = + File::open(build_properties_path).map_err(SbtPropertiesFileReadError)?; + + let properties = + java_properties::read(build_properties_file).map_err(InvalidSbtPropertiesFile)?; + + let declared_version = properties.get("sbt.version").cloned().unwrap_or_default(); + if declared_version.is_empty() { + return Err(MissingDeclaredSbtVersion); + } + + // Note: while sbt didn't officially adopt semver until the 1.x version, all the published + // versions listed in the repositories below do parse into the semver format: + // - https://scala.jfrog.io/ui/native/ivy-releases/org.scala-tools.sbt/sbt-launch/ + // - https://scala.jfrog.io/ui/native/ivy-releases/org.scala-sbt/sbt-launch/ + // - https://repo1.maven.org/maven2/org/scala-sbt/sbt-launch/ + let version = Version::parse(&declared_version) + .map_err(|error| SbtVersionNotInSemverFormat(declared_version, error))?; + + // this version range seemed odd to me but i think there's an upper-bound set to 0.13 because + // the maven listing (https://repo1.maven.org/maven2/org/scala-sbt/sbt-launch/) contains + // both a 0.99.2 and 0.99.4 release + let version_0_required = + VersionReq::parse(">=0.11, <=0.13").expect("Invalid version requirement"); + let version_1_required = VersionReq::parse(">=1, <2").expect("Invalid version requirement"); + let is_supported_version = + version_0_required.matches(&version) || version_1_required.matches(&version); + + if !is_supported_version { + return Err(UnsupportedSbtVersion(version.to_string())); + } + + Ok(version) +} + +#[cfg(test)] +mod create_build_config_tests { + use crate::build_configuration::{ + create_build_config, Env, File, HashMap, MissingDeclaredSbtVersion, + MissingSbtBuildPropertiesFile, UnsupportedSbtVersion, Version, + }; + use crate::errors::ScalaBuildpackError; + use std::ffi::{OsStr, OsString}; + use std::fs::{create_dir, write}; + use std::io::BufWriter; + use std::os::unix::ffi::OsStrExt; + use tempfile::{tempdir, TempDir}; + + macro_rules! assert_err { + ($expression:expr, $($pattern:tt)+) => { + match $expression { + $($pattern)+ => (), + ref e => panic!("expected `{}` but got `{:?}`", stringify!($($pattern)+), e), + } + } + } + + fn set_sbt_version(app_dir: &TempDir, version: &str) { + let sbt_project_path = app_dir.path().join("project"); + create_dir(&sbt_project_path).unwrap(); + let contents = format!("sbt.version={version}"); + write(sbt_project_path.join("build.properties"), contents).unwrap(); + } + + fn set_system_properties(app_dir: &TempDir, properties: HashMap<&str, &str>) { + let property_file = File::create(app_dir.path().join("system.properties")).unwrap(); + let writer = BufWriter::new(property_file); + let properties = properties + .into_iter() + .map(|(key, val)| (key.to_string(), val.to_string())) + .collect(); + java_properties::write(writer, &properties).unwrap(); + } + + fn invalid_unicode_os_string() -> OsString { + let invalid_unicode_sequence = [0x66, 0x6f, 0x80, 0x6f]; + OsStr::from_bytes(&invalid_unicode_sequence[..]).to_os_string() + } + + #[test] + fn create_build_config_raises_error_if_project_is_missing_the_sbt_build_properties_file() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + let error = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(error, MissingSbtBuildPropertiesFile); + } + + #[test] + fn create_build_config_raises_error_when_sbt_version_property_is_missing_from_the_sbt_build_properties_file( + ) { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + let sbt_project_path = app_dir.path().join("project"); + create_dir(&sbt_project_path).unwrap(); + write(sbt_project_path.join("build.properties"), "").unwrap(); + let error = create_build_config(app_dir.path().to_path_buf(), &env).unwrap_err(); + assert_err!(error, MissingDeclaredSbtVersion); + } + + #[test] + fn create_build_config_raises_error_when_sbt_version_property_is_declared_with_empty_value() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + let sbt_project_path = app_dir.path().join("project"); + create_dir(&sbt_project_path).unwrap(); + write(sbt_project_path.join("build.properties"), b"sbt.version=").unwrap(); + let error = create_build_config(app_dir.path().to_path_buf(), &env).unwrap_err(); + assert_err!(error, MissingDeclaredSbtVersion); + } + + #[test] + fn create_build_config_with_valid_sbt_version_when_version_has_garbage_whitespace() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + let sbt_project_path = app_dir.path().join("project"); + create_dir(&sbt_project_path).unwrap(); + write( + sbt_project_path.join("build.properties"), + b" sbt.version = 1.8.2\n\n", + ) + .unwrap(); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_version, Version::parse("1.8.2").unwrap()); + } + + #[test] + fn create_build_config_raises_error_when_sbt_version_outside_the_lower_bound_of_the_required_v0_range( + ) { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "0.10.99"); + let error = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(error, UnsupportedSbtVersion(version) if version == "0.10.99"); + } + + #[test] + fn create_build_config_with_sbt_version_within_the_lower_bound_of_the_required_v0_range() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "0.11.0"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_version, Version::parse("0.11.0").unwrap()); + } + + #[test] + fn create_build_config_with_sbt_version_within_the_upper_bound_of_the_required_v0_range() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "0.13.99"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_version, Version::parse("0.13.99").unwrap()); + } + + #[test] + fn create_build_config_raises_error_when_sbt_version_outside_the_upper_bound_of_the_required_v0_range( + ) { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "0.14.0"); + let error = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(error, UnsupportedSbtVersion(version) if version == "0.14.0"); + } + + #[test] + fn create_build_config_with_sbt_version_within_the_lower_bound_of_the_required_v1_range() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.0.0"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_version, Version::parse("1.0.0").unwrap()); + } + + #[test] + fn create_build_config_with_sbt_version_within_the_upper_bound_of_the_required_v1_range() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.99.99"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_version, Version::parse("1.99.99").unwrap()); + } + + #[test] + fn create_build_config_raises_error_when_sbt_version_outside_the_upper_bound_of_the_required_v1_range( + ) { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "2.0.0"); + let error = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(error, UnsupportedSbtVersion(version) if version == "2.0.0"); + } + + #[test] + fn create_build_config_when_sbt_project_is_not_configured() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_project, None); + } + + #[test] + fn create_build_config_when_sbt_project_is_configured_from_property() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties( + &app_dir, + HashMap::from([("sbt.project", "testProjectName")]), + ); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_project, Some(String::from("testProjectName"))); + } + + #[test] + fn create_build_config_when_sbt_project_is_configured_from_environment() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + env.insert("SBT_PROJECT", "testProjectName"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_project, Some(String::from("testProjectName"))); + } + + #[test] + fn create_build_config_raises_error_when_sbt_project_is_configured_from_environment_with_non_unicode_bytes( + ) { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + env.insert("SBT_PROJECT", invalid_unicode_os_string()); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotConvertEnvironmentValueIntoString(name, _) if name == "SBT_PROJECT"); + } + + #[test] + fn create_build_config_when_sbt_pre_tasks_is_not_configured() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_pre_tasks, None); + } + + #[test] + fn create_build_config_when_sbt_pre_tasks_is_configured_from_system_properties() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties(&app_dir, HashMap::from([("sbt.pre-tasks", "task1 task2")])); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_tasks: Vec = vec![String::from("task1"), String::from("task2")]; + assert_eq!(config.sbt_pre_tasks, Some(expected_tasks)); + } + + #[test] + fn create_build_config_when_sbt_pre_tasks_is_configured_from_env() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_PRE_TASKS", OsString::from("task1 task2")); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_tasks: Vec = vec![String::from("task1"), String::from("task2")]; + assert_eq!(config.sbt_pre_tasks, Some(expected_tasks)); + } + + #[test] + fn create_build_config_prefers_system_property_over_env_for_sbt_pre_tasks() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_PRE_TASKS", OsString::from("task1 task2")); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties(&app_dir, HashMap::from([("sbt.pre-tasks", "task3 task4")])); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_tasks: Vec = vec![String::from("task3"), String::from("task4")]; + assert_eq!(config.sbt_pre_tasks, Some(expected_tasks)); + } + + #[test] + fn create_build_config_raises_error_when_sbt_pre_tasks_property_cannot_be_split() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_system_properties( + &app_dir, + HashMap::from([("sbt.pre-tasks", "task1\" task2")]), + ); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotParseListConfigurationFromProperty(name, _) if name == "sbt.pre-tasks"); + } + + #[test] + fn create_build_config_raises_error_when_sbt_pre_tasks_environment_variable_cannot_be_split() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_PRE_TASKS", OsString::from("task1\" task2")); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotParseListConfigurationFromEnvironment(name, _) if name == "SBT_PRE_TASKS"); + } + + #[test] + fn create_build_config_raises_error_when_sbt_pre_tasks_environment_variable_contains_non_unicode_bytes( + ) { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_PRE_TASKS", invalid_unicode_os_string()); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotConvertEnvironmentValueIntoString(name, _) if name == "SBT_PRE_TASKS"); + } + + #[test] + fn create_build_config_when_sbt_clean_is_not_configured() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_clean, None); + } + + #[test] + fn create_build_config_when_sbt_clean_is_configured_from_system_properties() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties(&app_dir, HashMap::from([("sbt.clean", "true")])); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_clean, Some(true)); + } + + #[test] + fn create_build_config_when_sbt_clean_is_configured_from_system_properties_and_value_is_not_parsable_as_boolean( + ) { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties(&app_dir, HashMap::from([("sbt.clean", "")])); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotParseBooleanFromProperty(name, _) if name == "sbt.clean"); + } + + #[test] + fn create_build_config_when_sbt_clean_is_configured_from_env() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_CLEAN", OsString::from("true")); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_clean, Some(true)); + } + + #[test] + fn create_build_config_when_sbt_clean_is_configured_from_env_and_value_is_not_parsable_as_boolean( + ) { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_CLEAN", OsString::from("blah")); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotParseBooleanFromEnvironment(name, _) if name == "SBT_CLEAN"); + } + + #[test] + fn create_build_config_when_sbt_clean_is_configured_from_env_and_value_contains_non_unicode_bytes( + ) { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_CLEAN", invalid_unicode_os_string()); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotConvertEnvironmentValueIntoString(name, _) if name == "SBT_CLEAN"); + } + + #[test] + fn create_build_config_when_sbt_clean_prefers_system_property_over_env() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_CLEAN", OsString::from("false")); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties(&app_dir, HashMap::from([("sbt.clean", "true")])); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_clean, Some(true)); + } + + #[test] + fn create_build_config_when_sbt_tasks_is_not_configured() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_tasks, None); + } + + #[test] + fn create_build_config_when_sbt_tasks_is_configured_from_system_properties() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties(&app_dir, HashMap::from([("sbt.tasks", "task1 task2")])); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_tasks: Vec = vec![String::from("task1"), String::from("task2")]; + assert_eq!(config.sbt_tasks, Some(expected_tasks)); + } + + #[test] + fn create_build_config_when_sbt_tasks_is_configured_from_env() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_TASKS", OsString::from("task1 task2")); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_tasks: Vec = vec![String::from("task1"), String::from("task2")]; + assert_eq!(config.sbt_tasks, Some(expected_tasks)); + } + + #[test] + fn create_build_config_prefers_system_property_over_env_for_sbt_tasks() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_TASKS", OsString::from("task1 task2")); + set_sbt_version(&app_dir, "1.8.2"); + set_system_properties(&app_dir, HashMap::from([("sbt.tasks", "task3 task4")])); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_tasks: Vec = vec![String::from("task3"), String::from("task4")]; + assert_eq!(config.sbt_tasks, Some(expected_tasks)); + } + + #[test] + fn create_build_config_raises_error_when_sbt_tasks_property_cannot_be_split() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_system_properties(&app_dir, HashMap::from([("sbt.tasks", "task1\" task2")])); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotParseListConfigurationFromProperty(name, _) if name == "sbt.tasks"); + } + + #[test] + fn create_build_config_raises_error_when_sbt_tasks_environment_variable_cannot_be_split() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_TASKS", OsString::from("task1\" task2")); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotParseListConfigurationFromEnvironment(name, _) if name == "SBT_TASKS"); + } + + #[test] + fn create_build_config_raises_error_when_sbt_tasks_environment_variable_contains_non_unicode_bytes( + ) { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_TASKS", invalid_unicode_os_string()); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotConvertEnvironmentValueIntoString(name, _) if name == "SBT_TASKS"); + } + + #[test] + fn create_build_config_when_sbt_opts_is_not_configured() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + assert_eq!(config.sbt_opts, None); + } + + #[test] + fn create_build_config_when_sbt_opts_is_configured_from_env() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_OPTS", OsString::from("-J-Xfoo -J-Xbar")); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_opts: Vec = vec![String::from("-J-Xfoo"), String::from("-J-Xbar")]; + assert_eq!(config.sbt_opts, Some(expected_opts)); + } + + #[test] + fn create_build_config_when_sbt_opts_is_configured_from_sbtopts_file() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + write(app_dir.path().join(".sbtopts"), "-J-Xzip -J-Xzap").unwrap(); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_opts: Vec = vec![String::from("-J-Xzip"), String::from("-J-Xzap")]; + assert_eq!(config.sbt_opts, Some(expected_opts)); + } + + #[test] + fn create_build_config_when_sbt_opts_is_configured_from_both_env_and_sbtopts_file() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_OPTS", OsString::from("-J-Xfoo -J-Xbar")); + write(app_dir.path().join(".sbtopts"), "-J-Xzip -J-Xzap").unwrap(); + set_sbt_version(&app_dir, "1.8.2"); + let config = create_build_config(app_dir.path(), &env).unwrap(); + let expected_opts: Vec = vec![ + String::from("-J-Xzip"), + String::from("-J-Xzap"), + String::from("-J-Xfoo"), + String::from("-J-Xbar"), + ]; + assert_eq!(config.sbt_opts, Some(expected_opts)); + } + + #[test] + fn create_build_config_raises_error_when_sbtopts_file_values_cannot_be_split() { + let app_dir = tempdir().unwrap(); + let env = Env::new(); + write(app_dir.path().join(".sbtopts"), "-J-Xzip\" -J-Xzap").unwrap(); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!( + err, + ScalaBuildpackError::CouldNotParseListConfigurationFromSbtOptsFile(_) + ); + } + + #[test] + fn create_build_config_raises_error_when_sbt_opts_environment_variable_cannot_be_split() { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_OPTS", OsString::from("-J-Xfoo\" -J-Xbar")); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotParseListConfigurationFromEnvironment(name, _) if name == "SBT_OPTS"); + } + + #[test] + fn create_build_config_raises_error_when_sbt_opts_environment_variable_contains_non_unicode_bytes( + ) { + let app_dir = tempdir().unwrap(); + let mut env = Env::new(); + env.insert("SBT_OPTS", invalid_unicode_os_string()); + set_sbt_version(&app_dir, "1.8.2"); + let err = create_build_config(app_dir.path(), &env).unwrap_err(); + assert_err!(err, ScalaBuildpackError::CouldNotConvertEnvironmentValueIntoString(name, _) if name == "SBT_OPTS"); + } +} diff --git a/buildpacks/sbt/src/errors.rs b/buildpacks/sbt/src/errors.rs new file mode 100644 index 00000000..8021f662 --- /dev/null +++ b/buildpacks/sbt/src/errors.rs @@ -0,0 +1,275 @@ +use indoc::formatdoc; +use libcnb::Error; +use libherokubuildpack::log::log_error; +use std::ffi::OsString; +use std::fmt::Debug; +use std::process::ExitStatus; + +#[derive(Debug)] +pub(crate) enum ScalaBuildpackError { + CouldNotWriteSbtExtrasScript(std::io::Error), + CouldNotSetExecutableBitForSbtExtrasScript(std::io::Error), + CouldNotWriteSbtWrapperScript(std::io::Error), + CouldNotSetExecutableBitForSbtWrapperScript(std::io::Error), + MissingSbtBuildPropertiesFile, + SbtPropertiesFileReadError(std::io::Error), + InvalidSbtPropertiesFile(java_properties::PropertiesError), + MissingDeclaredSbtVersion, + UnsupportedSbtVersion(String), + SbtVersionNotInSemverFormat(String, semver::Error), + SbtBuildIoError(std::io::Error), + SbtBuildUnexpectedExitCode(ExitStatus), + SbtInstallIoError(std::io::Error), + SbtInstallUnexpectedExitCode(ExitStatus), + CouldNotWriteSbtPlugin(std::io::Error), + NoBuildpackPluginAvailable(String), + CouldNotParseBooleanFromProperty(String, std::str::ParseBoolError), + CouldNotParseBooleanFromEnvironment(String, std::str::ParseBoolError), + CouldNotParseListConfigurationFromProperty(String, shell_words::ParseError), + CouldNotParseListConfigurationFromEnvironment(String, shell_words::ParseError), + CouldNotConvertEnvironmentValueIntoString(String, OsString), + CouldNotReadSbtOptsFile(std::io::Error), + CouldNotParseListConfigurationFromSbtOptsFile(shell_words::ParseError), + MissingStageTask, + AlreadyDefinedAsObject, +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn log_user_errors(error: ScalaBuildpackError) { + match error { + ScalaBuildpackError::MissingDeclaredSbtVersion | + ScalaBuildpackError::MissingSbtBuildPropertiesFile => log_error( + "No sbt version defined", + formatdoc! { " + Your scala project must include project/build.properties and define a value for + the `sbt.version` property. + " }, + ), + + ScalaBuildpackError::UnsupportedSbtVersion(version) => log_error( + "Unsupported sbt version", + formatdoc! { " + You have defined an unsupported `sbt.version` ({version}) in the project/build.properties + file. You must use a version of sbt between 0.11.0 and 1.x. + " }, + ), + + ScalaBuildpackError::SbtBuildIoError(error) => log_error( + "Running sbt failed", + formatdoc! { " + We're sorry this build is failing! If you can't find the issue in application code, + please submit a ticket so we can help: https://help.heroku.com/ + + Details: {error} + " } + ), + + ScalaBuildpackError::SbtBuildUnexpectedExitCode(exit_status) => log_error( + "Running sbt failed", + formatdoc! { " + We're sorry this build is failing! If you can't find the issue in application code, + please submit a ticket so we can help: https://help.heroku.com/ + + sbt exit code was: {exit_code} + ", exit_code = get_exit_code(exit_status) } + ), + + ScalaBuildpackError::SbtVersionNotInSemverFormat(version, error) => log_error( + "Unexpected version parse error", + formatdoc! { " + Failed to read the `sbt.version` ({version}) declared in project/build.properties. Please + ensure this value is a valid semantic version identifier (see https://semver.org/). + + Details: {error} + " }, + ), + + ScalaBuildpackError::NoBuildpackPluginAvailable(version) => log_error( + "Failed to install Heroku plugin for sbt", + formatdoc! { " + No Heroku plugins supporting this version of sbt ({version}). + " }, + ), + + ScalaBuildpackError::CouldNotParseListConfigurationFromProperty(property_name, error) => log_error( + format!("Invalid {property_name} property"), + formatdoc! {" + Could not parse the value of the `{property_name}` property from the system.properties file into a list of words. + Please check the `{property_name}` property for quoting and escaping mistakes and try again. + + Details: {error} + " } + ), + + ScalaBuildpackError::CouldNotParseListConfigurationFromEnvironment(variable_name, error) => log_error( + format!("Invalid {variable_name} environment variable"), + formatdoc! {" + Could not parse the value of the {variable_name} environment variable into a list of words. + Please check {variable_name} for quoting and escaping mistakes and try again. + + Details: {error} + " } + ), + + ScalaBuildpackError::CouldNotParseBooleanFromProperty(property_name, error) => log_error( + format! ("Invalid {property_name} property"), + formatdoc! {" + Could not parse the value of the `{property_name}` property from the system.properties file into a 'true' or 'false' value. + Please check `{property_name}` for mistakes and try again. + + Details: {error} + " } + ), + + ScalaBuildpackError::CouldNotParseBooleanFromEnvironment(variable_name, error) => log_error( + format!("Invalid {variable_name} environment variable"), + formatdoc! {" + Could not parse the value of {variable_name} environment variable into a 'true' or 'false' value. + Please check {variable_name} for mistakes and try again. + + Details: {error} + " } + ), + + ScalaBuildpackError::CouldNotConvertEnvironmentValueIntoString(variable_name, value) => log_error( + format!("Invalid {variable_name} environment variable"), + formatdoc! {" + Could not convert the value of the environment variable {variable_name} into a string. Please + check that the value of {variable_name} only contains Unicode characters and try again. + + Value: {value} + ", value = value.to_string_lossy() } + ), + + ScalaBuildpackError::CouldNotParseListConfigurationFromSbtOptsFile(error) => log_error( + "Invalid .sbtopts file", + formatdoc! {" + Could not read the value of the .sbtopts file into a list of arguments. Please check + the file for mistakes and please try again. + + Details: {error} + " } + ), + + ScalaBuildpackError::MissingStageTask => log_error( + "Failed to run sbt!", + formatdoc! {" + It looks like your build.sbt does not have a valid 'stage' task. Please reference our Dev Center article for + information on how to create one: + + https://devcenter.heroku.com/articles/scala-support#build-behavior + "} + ), + + ScalaBuildpackError::AlreadyDefinedAsObject => log_error( + "Failed to run sbt!", + formatdoc! {" + We're sorry this build is failing. It looks like you may need to run a clean build to remove any + stale SBT caches. You can do this by setting a configuration variable like this: + + $ heroku config:set SBT_CLEAN=true + + Then deploy you application with 'git push' again. If the build succeeds you can remove the variable by running this command: + + $ heroku config:unset SBT_CLEAN + + If this does not resolve the problem, please submit a ticket so we can help: + https://help.heroku.com + "} + ), + + ScalaBuildpackError::CouldNotWriteSbtExtrasScript(error) => log_please_try_again_error( + "Failed to write sbt-extras script", + "An unexpected error occurred while writing the sbt-extras script.", + error, + ), + + ScalaBuildpackError::CouldNotSetExecutableBitForSbtExtrasScript(error) => log_please_try_again_error( + "Unexpected I/O error", + "Failed to set executable permissions for the sbt-extras script.", + error + ), + + ScalaBuildpackError::CouldNotWriteSbtWrapperScript(error) => log_please_try_again_error( + "Failed to write sbt-extras script", + "An unexpected error occurred while writing the sbt wrapper script.", + error, + ), + + ScalaBuildpackError::CouldNotSetExecutableBitForSbtWrapperScript(error) => log_please_try_again_error( + "Unexpected I/O error", + "Failed to set executable permissions for the sbt wrapper script.", + error + ), + + ScalaBuildpackError::SbtPropertiesFileReadError(error) => log_please_try_again_error( + "Unexpected I/O error", + "Could not read your application's system.properties file due to an unexpected I/O error.", + error + ), + + ScalaBuildpackError::InvalidSbtPropertiesFile(error) => log_please_try_again_error( + "Unexpected I/O error", + "Could not read your application's project/build.properties file due to an unexpected I/O error.", + error + ), + + ScalaBuildpackError::SbtInstallIoError(error) => log_please_try_again_error( + "Failed to install sbt", + "An unexpected error occurred while attempting to install sbt.", + error + ), + + ScalaBuildpackError::SbtInstallUnexpectedExitCode(exit_status) => log_please_try_again_error( + "Failed to install sbt", + formatdoc! { " + An unexpected exit code was reported while attempting to install sbt. + + sbt exit code was: {exit_code} + ", exit_code = get_exit_code(exit_status) }, + error + ), + + ScalaBuildpackError::CouldNotWriteSbtPlugin(error) => log_please_try_again_error( + "Failed to install Heroku plugin for sbt", + "An unexpected error occurred while attempting to install the Heroku plugin for sbt.", + error + ), + + ScalaBuildpackError::CouldNotReadSbtOptsFile(error) => log_please_try_again_error( + "Unexpected I/O error", + "Could not read your application's .sbtopts file due to an unexpected I/O error.", + error + ), + } +} + +fn log_please_try_again_error, M: AsRef, E: Debug>( + header: H, + message: M, + error: E, +) { + log_error( + header, + formatdoc! {" + {message} + + Please try again. If this error persists, please contact us: + https://help.heroku.com/ + + Details: {error:?} + ", message = message.as_ref(), error = error }, + ); +} + +fn get_exit_code(exit_status: ExitStatus) -> String { + exit_status + .code() + .map_or_else(|| String::from(""), |code| code.to_string()) +} + +impl From for Error { + fn from(value: ScalaBuildpackError) -> Self { + Error::BuildpackError(value) + } +} diff --git a/buildpacks/sbt/src/file_tree.rs b/buildpacks/sbt/src/file_tree.rs new file mode 100644 index 00000000..53d39836 --- /dev/null +++ b/buildpacks/sbt/src/file_tree.rs @@ -0,0 +1,187 @@ +use crate::file_tree::FileTreeError::{ + CannotConvertToPathWithNoPrefix, CouldNotDeleteFile, CouldNotRetrieveFileList, + InvalidExcludePattern, InvalidIncludePattern, +}; +use std::fs; +use std::path::PathBuf; + +pub(crate) struct FileTree { + root: PathBuf, + includes: Vec, + excludes: Vec, +} + +#[derive(Debug)] +pub(crate) enum FileTreeError { + CouldNotRetrieveFileList(glob::PatternError), + InvalidIncludePattern(glob::PatternError), + InvalidExcludePattern(glob::PatternError), + CannotConvertToPathWithNoPrefix(std::path::StripPrefixError), + CouldNotDeleteFile(std::io::Error), +} + +impl FileTree { + pub(crate) fn include>(&mut self, pattern: Pattern) -> &mut FileTree { + self.includes.push(pattern.into()); + self + } + + pub(crate) fn exclude>(&mut self, pattern: Pattern) -> &mut FileTree { + self.excludes.push(pattern.into()); + self + } + + pub(crate) fn get_files(&self) -> Result, FileTreeError> { + let entries = glob::glob(&self.root.join("**").join("*").to_string_lossy()) + .map_err(CouldNotRetrieveFileList)?; + + let mut include_patterns: Vec = vec![]; + for include in &self.includes { + let include_pattern = glob::Pattern::new(include).map_err(InvalidIncludePattern)?; + include_patterns.push(include_pattern); + } + + let mut exclude_patterns: Vec = vec![]; + for exclude in &self.excludes { + let exclude_pattern = glob::Pattern::new(exclude).map_err(InvalidExcludePattern)?; + exclude_patterns.push(exclude_pattern); + } + + let mut all_files: Vec = vec![]; + for file in entries + .filter_map(Result::ok) + .filter(|entry| entry.is_file()) + { + let path_without_prefix = file + .strip_prefix(&self.root) + .map_err(CannotConvertToPathWithNoPrefix)?; + all_files.push(path_without_prefix.to_path_buf()); + } + + let filter_files = all_files + .iter() + .filter(|file| { + if include_patterns.is_empty() { + return true; + } + include_patterns + .iter() + .any(|pattern| pattern.matches(&file.to_string_lossy())) + }) + .filter(|file| { + if exclude_patterns.is_empty() { + return true; + } + !exclude_patterns + .iter() + .any(|pattern| pattern.matches(&file.to_string_lossy())) + }) + .cloned() + .collect(); + + Ok(filter_files) + } + + pub(crate) fn delete(&self) -> Result<(), FileTreeError> { + let files = self.get_files()?; + for file in files { + fs::remove_file(self.root.join(file)).map_err(CouldNotDeleteFile)?; + } + Ok(()) + } +} + +pub(crate) fn create_file_tree(root: PathBuf) -> FileTree { + FileTree { + root, + includes: vec![], + excludes: vec![], + } +} + +#[cfg(test)] +mod file_tree_tests { + use crate::file_tree::create_file_tree; + use std::fs::{create_dir_all, write}; + use std::path::{Path, PathBuf}; + use tempfile::tempdir; + + fn create_file(root: &Path, file_path: &str) { + let path = root.join(file_path); + let dir = path.parent().unwrap(); + create_dir_all(dir).unwrap(); + write(path, "").unwrap(); + } + + #[test] + pub fn check_includes_excludes() { + let temp_dir = tempdir().unwrap(); + let root = temp_dir.path(); + + create_file(root, "scala-include0.java"); + create_file(root, "scala-dir/include1.txt"); + create_file(root, "streams/include2.yml"); + create_file(root, "resolution-cache/include3-test.xml"); + create_file(root, "resolution-cache/nested/include4.txt"); + create_file(root, "exclude0.java"); + create_file(root, "resolution-cache/exclude1-compile.xml"); + create_file(root, "resolution-cache/reports/exclude2.txt"); + + let files = create_file_tree(root.to_path_buf()) + .include("scala-*") + .include("streams/*") + .include("resolution-cache/*") + .exclude("resolution-cache/reports/*") + .exclude("resolution-cache/*-compile.xml") + .get_files() + .unwrap(); + + let mut expected_files = vec![ + "scala-include0.java", + "scala-dir/include1.txt", + "streams/include2.yml", + "resolution-cache/include3-test.xml", + "resolution-cache/nested/include4.txt", + ]; + expected_files.sort_unstable(); + let expected_files: Vec = expected_files.iter().map(PathBuf::from).collect(); + + assert_eq!(files, expected_files); + } + + #[test] + pub fn check_can_delete_file_tree() { + let temp_dir = tempdir().unwrap(); + let root = temp_dir.path(); + + create_file(root, "scala-include0.java"); + create_file(root, "scala-dir/include1.txt"); + create_file(root, "streams/include2.yml"); + create_file(root, "resolution-cache/include3-test.xml"); + create_file(root, "resolution-cache/nested/include4.txt"); + create_file(root, "exclude0.java"); + create_file(root, "resolution-cache/exclude1-compile.xml"); + create_file(root, "resolution-cache/reports/exclude2.txt"); + + create_file_tree(root.to_path_buf()) + .include("scala-*") + .include("streams/*") + .include("resolution-cache/*") + .exclude("resolution-cache/reports/*") + .exclude("resolution-cache/*-compile.xml") + .delete() + .unwrap(); + + let remaining_files = create_file_tree(root.to_path_buf()).get_files().unwrap(); + + let mut expected_files = vec![ + "exclude0.java", + "resolution-cache/exclude1-compile.xml", + "resolution-cache/reports/exclude2.txt", + ]; + expected_files.sort_unstable(); + let expected_files: Vec = expected_files.iter().map(PathBuf::from).collect(); + + assert_eq!(remaining_files, expected_files); + } +} diff --git a/buildpacks/sbt/src/layers/coursier_cache.rs b/buildpacks/sbt/src/layers/coursier_cache.rs new file mode 100644 index 00000000..f64ab0d8 --- /dev/null +++ b/buildpacks/sbt/src/layers/coursier_cache.rs @@ -0,0 +1,102 @@ +use crate::ScalaBuildpack; +use libcnb::build::BuildContext; +use libcnb::data::layer_content_metadata::LayerTypes; +use libcnb::generic::GenericMetadata; +use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; +use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; +use libcnb::Buildpack; +use libherokubuildpack::log::log_info; +use std::path::Path; + +pub(crate) struct CoursierCacheLayer { + pub(crate) available_at_launch: Option, +} + +// Coursier is used instead of Ivy for library management starting with sbt >= 1.3 +// https://www.scala-sbt.org/1.x/docs/sbt-1.3-Release-Notes.html +impl Layer for CoursierCacheLayer { + type Buildpack = ScalaBuildpack; + type Metadata = GenericMetadata; + + fn types(&self) -> LayerTypes { + LayerTypes { + build: true, + launch: self.available_at_launch.unwrap_or_default(), + cache: true, + } + } + + fn create( + &self, + _: &BuildContext, + layer_path: &Path, + ) -> Result, ::Error> { + log_info("Creating Coursier cache"); + LayerResultBuilder::new(GenericMetadata::default()) + .env(create_coursier_layer_env( + layer_path, + self.available_at_launch, + )) + .build() + } + + fn existing_layer_strategy( + &self, + _: &BuildContext, + _: &LayerData, + ) -> Result::Error> { + log_info("Using existing Coursier cache"); + Ok(ExistingLayerStrategy::Keep) + } +} + +fn create_coursier_layer_env(layer_path: &Path, available_at_launch: Option) -> LayerEnv { + // XXX: you may be wondering why JVM_OPTS is used here instead of the more obvious SBT_OPTS + // environment variable. due to either my general lack of shell scripting knowledge or a bug + // in the sbt-extras script, the SBT_OPTS settings never seem to make it through to the executing + // process. everything seem fine until about this point in the sbt-extras script: + // - https://github.com/dwijnand/sbt-extras/blob/master/sbt#L541-L565 + // + // no matter, setting the JVM_OPTS is just as valid as SBT_OPTS and it seems to be respected + // by the sbt-extras script so i'm using that instead: + // - https://www.scala-sbt.org/1.x/docs/Command-Line-Reference.html#sbt+JVM+options+and+system+properties + LayerEnv::new() + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Delimiter, + "JVM_OPTS", + " ", + ) + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Append, + "JVM_OPTS", + format!("-Dsbt.coursier.home={}", layer_path.to_string_lossy()), + ) +} + +fn get_layer_env_scope(available_at_launch: Option) -> Scope { + if available_at_launch.unwrap_or_default() { + Scope::All + } else { + Scope::Build + } +} + +#[cfg(test)] +mod ivy_cache_layer_tests { + use crate::layers::coursier_cache::create_coursier_layer_env; + use libcnb::layer_env::Scope; + use std::path::Path; + + #[test] + fn create_ivy_layer_env_sets_ivy_flag_in_jvm_opts() { + let layer_path = Path::new("./test_path"); + let layer_env = create_coursier_layer_env(layer_path, None); + let env = layer_env.apply_to_empty(Scope::Build); + assert_eq!( + env.get("JVM_OPTS").unwrap(), + "-Dsbt.coursier.home=./test_path" + ); + } +} diff --git a/buildpacks/sbt/src/layers/ivy_cache.rs b/buildpacks/sbt/src/layers/ivy_cache.rs new file mode 100644 index 00000000..16fc8142 --- /dev/null +++ b/buildpacks/sbt/src/layers/ivy_cache.rs @@ -0,0 +1,95 @@ +use crate::ScalaBuildpack; +use libcnb::build::BuildContext; +use libcnb::data::layer_content_metadata::LayerTypes; +use libcnb::generic::GenericMetadata; +use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; +use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; +use libcnb::Buildpack; +use libherokubuildpack::log::log_info; +use std::path::Path; + +pub(crate) struct IvyCacheLayer { + pub(crate) available_at_launch: Option, +} + +// Ivy is used as the default library management tool up for sbt < 1.3 +impl Layer for IvyCacheLayer { + type Buildpack = ScalaBuildpack; + type Metadata = GenericMetadata; + + fn types(&self) -> LayerTypes { + LayerTypes { + build: true, + launch: self.available_at_launch.unwrap_or_default(), + cache: true, + } + } + + fn create( + &self, + _: &BuildContext, + layer_path: &Path, + ) -> Result, ::Error> { + log_info("Creating Ivy cache"); + LayerResultBuilder::new(GenericMetadata::default()) + .env(create_ivy_layer_env(layer_path, self.available_at_launch)) + .build() + } + + fn existing_layer_strategy( + &self, + _: &BuildContext, + _: &LayerData, + ) -> Result::Error> { + log_info("Using existing Ivy cache"); + Ok(ExistingLayerStrategy::Keep) + } +} + +fn create_ivy_layer_env(layer_path: &Path, available_at_launch: Option) -> LayerEnv { + // XXX: you may be wondering why JVM_OPTS is used here instead of the more obvious SBT_OPTS + // environment variable. due to either my general lack of shell scripting knowledge or a bug + // in the sbt-extras script, the SBT_OPTS settings never seem to make it through to the executing + // process. everything seem fine until about this point in the sbt-extras script: + // - https://github.com/dwijnand/sbt-extras/blob/master/sbt#L541-L565 + // + // no matter, setting the JVM_OPTS is just as valid as SBT_OPTS and it seems to be respected + // by the sbt-extras script so i'm using that instead: + // - https://www.scala-sbt.org/1.x/docs/Command-Line-Reference.html#sbt+JVM+options+and+system+properties + LayerEnv::new() + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Delimiter, + "JVM_OPTS", + " ", + ) + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Append, + "JVM_OPTS", + format!("-Dsbt.ivy.home={}", layer_path.to_string_lossy()), + ) +} + +fn get_layer_env_scope(available_at_launch: Option) -> Scope { + if available_at_launch.unwrap_or_default() { + Scope::All + } else { + Scope::Build + } +} + +#[cfg(test)] +mod ivy_cache_layer_tests { + use crate::layers::ivy_cache::create_ivy_layer_env; + use libcnb::layer_env::Scope; + use std::path::Path; + + #[test] + fn create_ivy_layer_env_sets_ivy_flag_in_sbtx_opts() { + let layer_path = Path::new("./test_path"); + let layer_env = create_ivy_layer_env(layer_path, None); + let env = layer_env.apply_to_empty(Scope::Build); + assert_eq!(env.get("JVM_OPTS").unwrap(), "-Dsbt.ivy.home=./test_path"); + } +} diff --git a/buildpacks/sbt/src/layers/mod.rs b/buildpacks/sbt/src/layers/mod.rs new file mode 100644 index 00000000..e1e930d6 --- /dev/null +++ b/buildpacks/sbt/src/layers/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod coursier_cache; +pub(crate) mod ivy_cache; +pub(crate) mod sbt; diff --git a/buildpacks/sbt/src/layers/sbt.rs b/buildpacks/sbt/src/layers/sbt.rs new file mode 100644 index 00000000..808e16cd --- /dev/null +++ b/buildpacks/sbt/src/layers/sbt.rs @@ -0,0 +1,341 @@ +use crate::errors::ScalaBuildpackError; +use crate::errors::ScalaBuildpackError::{ + CouldNotSetExecutableBitForSbtExtrasScript, CouldNotSetExecutableBitForSbtWrapperScript, + CouldNotWriteSbtExtrasScript, CouldNotWriteSbtPlugin, CouldNotWriteSbtWrapperScript, + NoBuildpackPluginAvailable, SbtInstallIoError, SbtInstallUnexpectedExitCode, +}; +use crate::ScalaBuildpack; +use libcnb::build::BuildContext; +use libcnb::data::buildpack::StackId; +use libcnb::data::layer_content_metadata::LayerTypes; +use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; +use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; +use libcnb::{Buildpack, Env}; +use libherokubuildpack::log::log_info; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::fs::{create_dir_all, set_permissions, write, Permissions}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; + +pub(crate) struct SbtLayer { + pub(crate) sbt_version: Version, + pub(crate) env: Env, + pub(crate) sbt_opts: Option>, + pub(crate) available_at_launch: Option, +} + +impl Layer for SbtLayer { + type Buildpack = ScalaBuildpack; + type Metadata = SbtLayerMetadata; + + fn types(&self) -> LayerTypes { + LayerTypes { + build: true, + launch: self.available_at_launch.unwrap_or_default(), + cache: true, + } + } + + fn create( + &self, + context: &BuildContext, + layer_path: &Path, + ) -> Result, ::Error> { + log_info(format!("Setting up sbt {}", self.sbt_version)); + + write_sbt_extras_to_layer(layer_path)?; + write_sbt_wrapper_to_layer(layer_path)?; + + let layer_env = create_sbt_layer_env(layer_path, &self.sbt_opts, self.available_at_launch); + let env = layer_env.apply(Scope::Build, &self.env); + + install_sbt(&context.app_dir, layer_path, &env)?; + write_buildpack_plugin(layer_path, &self.sbt_version)?; + + LayerResultBuilder::new(SbtLayerMetadata::current(self, context)) + .env(layer_env) + .build() + } + + fn existing_layer_strategy( + &self, + context: &BuildContext, + layer_data: &LayerData, + ) -> Result::Error> { + if layer_data.content_metadata.metadata == SbtLayerMetadata::current(self, context) { + log_info(format!("Reusing sbt {}", self.sbt_version)); + return Ok(ExistingLayerStrategy::Keep); + } + Ok(ExistingLayerStrategy::Recreate) + } +} + +fn install_sbt( + app_dir: &PathBuf, + layer_path: &Path, + env: &Env, +) -> Result { + Command::new(sbt_path(layer_path)) + .current_dir(app_dir) + .args(["sbtVersion"]) + .envs(env) + .spawn() + .and_then(|mut child| child.wait()) + .map_err(SbtInstallIoError) + .and_then(|exit_status| { + if exit_status.success() { + Ok(exit_status) + } else { + Err(SbtInstallUnexpectedExitCode(exit_status)) + } + }) +} + +fn create_sbt_layer_env( + layer_path: &Path, + sbt_opts: &Option>, + available_at_launch: Option, +) -> LayerEnv { + let layer_env = LayerEnv::new() + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Override, + "SBT_HOME", + layer_path, + ) + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Delimiter, + "PATH", + ":", + ) + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Prepend, + "PATH", + layer_bin_dir(layer_path), + ) + // XXX: i wanted to pass these through using SBT_OPTS instead of SBTX_OPTS but everytime i + // tried this the settings were not respected. i believe this is a bug in this section + // of the sbt-extras script: + // - https://github.com/dwijnand/sbt-extras/blob/master/sbt#L541-L565 + // + // the SBTX_OPTS variable works fine though so that's being used to ensure that sbt is pointing + // at all the right folders + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Delimiter, + "SBTX_OPTS", + " ", + ) + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Append, + "SBTX_OPTS", + format!( + "-sbt-dir {} -sbt-boot {} -sbt-launch-dir {}", + sbt_global_dir(layer_path).to_string_lossy(), + sbt_boot_dir(layer_path).to_string_lossy(), + sbt_launch_dir(layer_path).to_string_lossy(), + ), + ); + + if let Some(opts) = sbt_opts { + // XXX: if you read the earlier comments you'll know i have been avoiding using SBT_OPTS to + // setup several key aspects of the running sbt process. but since the SBT_OPTS is configurable + // by the user this variable is created here to respect what they've set. + return layer_env + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Delimiter, + "SBT_OPTS", + " ", + ) + .chainable_insert( + get_layer_env_scope(available_at_launch), + ModificationBehavior::Append, + "SBT_OPTS", + shell_words::join(opts), + ); + } + + layer_env +} + +fn get_layer_env_scope(available_at_launch: Option) -> Scope { + if available_at_launch.unwrap_or_default() { + Scope::All + } else { + Scope::Build + } +} + +fn write_sbt_extras_to_layer(layer_path: &Path) -> Result<(), ScalaBuildpackError> { + let sbt_extras_path = sbt_extras_path(layer_path); + let contents = include_bytes!("../../assets/sbt-extras.sh"); + create_dir_all(layer_bin_dir(layer_path)).map_err(CouldNotWriteSbtExtrasScript)?; + write(&sbt_extras_path, contents).map_err(CouldNotWriteSbtExtrasScript)?; + set_permissions(&sbt_extras_path, Permissions::from_mode(0o755)) + .map_err(CouldNotSetExecutableBitForSbtExtrasScript)?; + Ok(()) +} + +fn write_sbt_wrapper_to_layer(layer_path: &Path) -> Result<(), ScalaBuildpackError> { + let sbt_path = sbt_path(layer_path); + let contents = include_bytes!("../../assets/sbt-wrapper.sh"); + create_dir_all(layer_bin_dir(layer_path)).map_err(CouldNotWriteSbtWrapperScript)?; + write(&sbt_path, contents).map_err(CouldNotWriteSbtWrapperScript)?; + set_permissions(&sbt_path, Permissions::from_mode(0o755)) + .map_err(CouldNotSetExecutableBitForSbtWrapperScript)?; + Ok(()) +} + +fn write_buildpack_plugin( + layer_path: &Path, + sbt_version: &Version, +) -> Result<(), ScalaBuildpackError> { + let plugin_directory = sbt_global_plugins_dir(layer_path); + create_dir_all(&plugin_directory).map_err(CouldNotWriteSbtPlugin)?; + + let contents = get_buildpack_plugin_contents(sbt_version)?; + write( + plugin_directory.join("HerokuBuildpackPlugin.scala"), + contents, + ) + .map_err(CouldNotWriteSbtPlugin)?; + + Ok(()) +} + +fn get_buildpack_plugin_contents( + sbt_version: &Version, +) -> Result<&'static [u8], ScalaBuildpackError> { + match sbt_version { + Version { major: 0, .. } => Ok(include_bytes!( + "../../assets/heroku_buildpack_plugin_sbt_v0.scala" + )), + Version { major: 1, .. } => Ok(include_bytes!( + "../../assets/heroku_buildpack_plugin_sbt_v1.scala" + )), + _ => Err(NoBuildpackPluginAvailable(sbt_version.to_string())), + } +} + +fn layer_bin_dir(layer_path: &Path) -> PathBuf { + layer_path.join("bin") +} + +fn sbt_extras_path(layer_path: &Path) -> PathBuf { + layer_bin_dir(layer_path).join("sbt-extras") +} + +fn sbt_path(layer_path: &Path) -> PathBuf { + layer_bin_dir(layer_path).join("sbt") +} + +fn sbt_boot_dir(layer_path: &Path) -> PathBuf { + layer_path.join("boot") +} + +fn sbt_global_dir(layer_path: &Path) -> PathBuf { + layer_path.join("global") +} + +fn sbt_global_plugins_dir(layer_path: &Path) -> PathBuf { + sbt_global_dir(layer_path).join("plugins") +} + +fn sbt_launch_dir(layer_path: &Path) -> PathBuf { + layer_path.join("launch") +} + +#[cfg(test)] +mod sbt_layer_tests { + use crate::layers::sbt::{ + create_sbt_layer_env, sbt_global_plugins_dir, write_buildpack_plugin, + write_sbt_extras_to_layer, + }; + use libcnb::layer_env::Scope; + use semver::Version; + use std::path::Path; + use tempfile::tempdir; + + #[test] + fn test_sbt_extras_is_added_to_layer() { + let tmp = tempdir().unwrap(); + let layer_path = tmp.path(); + let sbt_extras_path = layer_path.join("bin/sbt-extras"); + write_sbt_extras_to_layer(layer_path).unwrap(); + assert!(sbt_extras_path.exists()); + } + + #[test] + fn create_sbt_layer_env_sets_env_properly() { + let layer_path = Path::new("./test_layer"); + let layer_env = create_sbt_layer_env(layer_path, &None, None); + let env = layer_env.apply_to_empty(Scope::Build); + assert_eq!( + env.get("SBTX_OPTS").unwrap(), + "-sbt-dir ./test_layer/global -sbt-boot ./test_layer/boot -sbt-launch-dir ./test_layer/launch" + ); + assert_eq!(env.get("PATH").unwrap(), "./test_layer/bin"); + assert!(!env.contains_key("SBT_OPTS")); + } + + #[test] + fn create_sbt_layer_env_sets_env_properly_when_sbt_opts_are_present() { + let layer_path = Path::new("./test_layer"); + let sbt_opts = vec!["-J-Xfoo".to_string()]; + let layer_env = create_sbt_layer_env(layer_path, &Some(sbt_opts), None); + let env = layer_env.apply_to_empty(Scope::Build); + assert_eq!( + env.get("SBTX_OPTS").unwrap(), + "-sbt-dir ./test_layer/global -sbt-boot ./test_layer/boot -sbt-launch-dir ./test_layer/launch" + ); + assert_eq!(env.get("PATH").unwrap(), "./test_layer/bin"); + assert_eq!(env.get("SBT_OPTS").unwrap(), "-J-Xfoo"); + } + + #[test] + fn write_build_plugin_with_sbt_version_0x() { + let layer_path = tempdir().unwrap(); + let version = Version::parse("0.13.0").unwrap(); + write_buildpack_plugin(layer_path.path(), &version).unwrap(); + assert!(sbt_global_plugins_dir(layer_path.path()) + .join("HerokuBuildpackPlugin.scala") + .exists()); + } + + #[test] + fn write_build_plugin_with_sbt_version_1x() { + let layer_path = tempdir().unwrap(); + let version = Version::parse("1.8.3").unwrap(); + write_buildpack_plugin(layer_path.path(), &version).unwrap(); + assert!(sbt_global_plugins_dir(layer_path.path()) + .join("HerokuBuildpackPlugin.scala") + .exists()); + } +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq)] +pub(crate) struct SbtLayerMetadata { + sbt_version: Version, + sbt_opts: Option>, + layer_version: String, + stack_id: StackId, +} + +const LAYER_VERSION: &str = "v1"; + +impl SbtLayerMetadata { + fn current(layer: &SbtLayer, context: &BuildContext) -> Self { + SbtLayerMetadata { + sbt_version: layer.sbt_version.clone(), + stack_id: context.stack_id.clone(), + layer_version: String::from(LAYER_VERSION), + sbt_opts: layer.sbt_opts.clone(), + } + } +} diff --git a/buildpacks/sbt/src/main.rs b/buildpacks/sbt/src/main.rs new file mode 100644 index 00000000..8b1ce7e8 --- /dev/null +++ b/buildpacks/sbt/src/main.rs @@ -0,0 +1,474 @@ +// Enable rustc and Clippy lints that are disabled by default. +// https://rust-lang.github.io/rust-clippy/stable/index.html +#![warn(clippy::pedantic)] +// This lint is too noisy and enforces a style that reduces readability in many cases. +#![allow(clippy::module_name_repetitions)] + +mod build_configuration; +mod errors; +mod file_tree; +mod layers; + +use crate::build_configuration::{create_build_config, BuildConfiguration}; +use crate::errors::ScalaBuildpackError::{ + AlreadyDefinedAsObject, MissingStageTask, SbtBuildIoError, SbtBuildUnexpectedExitCode, +}; +use crate::errors::{log_user_errors, ScalaBuildpackError}; +use crate::file_tree::create_file_tree; +use crate::layers::coursier_cache::CoursierCacheLayer; +use crate::layers::ivy_cache::IvyCacheLayer; +use crate::layers::sbt::SbtLayer; +use indoc::formatdoc; +use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder}; +use libcnb::data::build_plan::{BuildPlan, BuildPlanBuilder}; +use libcnb::data::layer_name; +use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder}; +use libcnb::generic::{GenericMetadata, GenericPlatform}; +use libcnb::layer_env::Scope; +use libcnb::{buildpack_main, Buildpack, Env, Error, Platform}; +use libherokubuildpack::command::CommandExt; +use libherokubuildpack::error::on_error as on_buildpack_error; +use libherokubuildpack::log::{log_header, log_info, log_warning}; +use std::io::{stderr, stdout}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +pub(crate) struct ScalaBuildpack; + +impl Buildpack for ScalaBuildpack { + type Platform = GenericPlatform; + type Metadata = GenericMetadata; + type Error = ScalaBuildpackError; + + fn detect(&self, context: DetectContext) -> libcnb::Result { + if !detect_sbt(&context.app_dir) { + return DetectResultBuilder::fail().build(); + } + + DetectResultBuilder::pass() + .build_plan(create_scala_build_plan()) + .build() + } + + fn build(&self, context: BuildContext) -> libcnb::Result { + let build_config = create_build_config(&context.app_dir, context.platform.env())?; + + let env = Env::from_current(); + let env = create_coursier_cache_layer(&context, &env, &build_config)?; + let env = create_ivy_cache_layer(&context, &env, &build_config)?; + let env = create_sbt_layer(&context, &env, &build_config)?; + + cleanup_any_existing_native_packager_directories(&context.app_dir); + run_sbt_tasks(&context.app_dir, &build_config, &env)?; + cleanup_compilation_artifacts(&context.app_dir); + + BuildResultBuilder::new().build() + } + + fn on_error(&self, error: Error) { + on_buildpack_error(log_user_errors, error); + } +} + +buildpack_main!(ScalaBuildpack); + +fn detect_sbt(app_dir: &Path) -> bool { + !create_file_tree(app_dir.to_path_buf()) + .include("*.sbt") + .include("project/*.scala") + .include("project/build.properties") + .include(".sbt/*.scala") + .get_files() + .unwrap_or(vec![]) + .is_empty() +} + +fn create_scala_build_plan() -> BuildPlan { + BuildPlanBuilder::new() + .requires("jdk") + .provides("jvm-application") + .requires("jvm-application") + .build() +} + +fn create_coursier_cache_layer( + context: &BuildContext, + env: &Env, + build_config: &BuildConfiguration, +) -> Result> { + let coursier_cache_layer = context.handle_layer( + layer_name!("coursier_cache"), + CoursierCacheLayer { + available_at_launch: build_config.sbt_available_at_launch, + }, + )?; + Ok(coursier_cache_layer.env.apply(Scope::Build, env)) +} + +fn create_ivy_cache_layer( + context: &BuildContext, + env: &Env, + build_config: &BuildConfiguration, +) -> Result> { + let ivy_cache_layer = context.handle_layer( + layer_name!("ivy_cache"), + IvyCacheLayer { + available_at_launch: build_config.sbt_available_at_launch, + }, + )?; + Ok(ivy_cache_layer.env.apply(Scope::Build, env)) +} + +fn create_sbt_layer( + context: &BuildContext, + env: &Env, + build_config: &BuildConfiguration, +) -> Result> { + log_header("Installing sbt"); + let sbt_layer = context.handle_layer( + layer_name!("sbt"), + SbtLayer { + sbt_version: build_config.sbt_version.clone(), + sbt_opts: build_config.sbt_opts.clone(), + available_at_launch: build_config.sbt_available_at_launch, + env: env.clone(), + }, + )?; + Ok(sbt_layer.env.apply(Scope::Build, env)) +} + +// the native package plugin produces binaries in the target/universal/stage directory which is not included +// in the list of directories to clean up at the end of the build since a Procfile may reference this +// location to provide the entry point for an application. wiping the directory before the application build +// kicks off will ensure that no leftover artifacts are being carried around between builds. +fn cleanup_any_existing_native_packager_directories(app_dir: &Path) { + let native_package_directory = app_dir.join("target").join("universal").join("stage"); + if native_package_directory.exists() { + let delete_operation = create_file_tree(native_package_directory).delete(); + if let Err(error) = delete_operation { + log_warning( + "Removal of native package directory failed", + formatdoc! {" + This error should not affect your built application but it may cause the container image + to be larger than expected. + + Details: {error:?} + "}, + ); + } + } +} + +fn cleanup_compilation_artifacts(app_dir: &Path) { + log_info("Dropping compilation artifacts from the build"); + let delete_operation = create_file_tree(app_dir.join("target")) + .include("scala-*") + .include("streams") + .include("resolution-cache") + .exclude("resolution-cache/reports") + .exclude("resolution-cache/*-compile.xml") + .delete(); + + if let Err(error) = delete_operation { + log_warning( + "Removal of compilation artifacts failed", + formatdoc! {" + This error should not affect your built application but it may cause the container image + to be larger than expected. + + Details: {error:?} + " }, + ); + } +} + +fn run_sbt_tasks( + app_dir: &PathBuf, + build_config: &BuildConfiguration, + env: &Env, +) -> Result<(), ScalaBuildpackError> { + log_header("Building Scala project"); + + let tasks = get_sbt_build_tasks(build_config); + log_info(format!("Running: sbt {}", shell_words::join(&tasks))); + + let output = Command::new("sbt") + .current_dir(app_dir) + .args(tasks) + .envs(env) + .output_and_write_streams(stdout(), stderr()) + .map_err(SbtBuildIoError)?; + + if output.status.success() { + Ok(()) + } else { + Err(handle_sbt_error(&output)) + } +} + +fn handle_sbt_error(output: &Output) -> ScalaBuildpackError { + if let Ok(stdout) = std::str::from_utf8(&output.stdout) { + if stdout.contains("Not a valid key: stage") { + return MissingStageTask; + } + if stdout.contains("is already defined as object") { + return AlreadyDefinedAsObject; + } + } + SbtBuildUnexpectedExitCode(output.status) +} + +fn get_sbt_build_tasks(build_config: &BuildConfiguration) -> Vec { + let mut tasks: Vec = Vec::new(); + + if let Some(true) = &build_config.sbt_clean { + tasks.push(String::from("clean")); + } + + if let Some(sbt_pre_tasks) = &build_config.sbt_pre_tasks { + sbt_pre_tasks + .iter() + .for_each(|task| tasks.push(task.to_string())); + } + + if let Some(sbt_tasks) = &build_config.sbt_tasks { + sbt_tasks + .iter() + .for_each(|task| tasks.push(task.to_string())); + } else { + let default_tasks = vec![String::from("compile"), String::from("stage")]; + for default_task in &default_tasks { + tasks.push(match &build_config.sbt_project { + Some(project) => format!("{project}/{default_task}"), + None => default_task.to_string(), + }); + } + } + + tasks +} + +#[cfg(test)] +mod detect_sbt_tests { + use crate::detect_sbt; + use std::fs::{create_dir, write}; + use tempfile::tempdir; + + #[test] + fn detect_sbt_fails_when_no_sbt_files_in_application_directory() { + let app_dir = tempdir().unwrap(); + assert!(!detect_sbt(app_dir.path())); + } + + #[test] + fn detect_sbt_passes_when_an_sbt_file_is_found_in_application_directory() { + let app_dir = tempdir().unwrap(); + write(app_dir.path().join("build.sbt"), "").unwrap(); + assert!(detect_sbt(app_dir.path())); + } + + #[test] + fn detect_sbt_passes_when_a_scala_file_is_found_in_the_sbt_project_directory() { + let app_dir = tempdir().unwrap(); + let sbt_project_path = app_dir.path().join("project"); + create_dir(&sbt_project_path).unwrap(); + write(sbt_project_path.join("some-file.scala"), "").unwrap(); + assert!(detect_sbt(app_dir.path())); + } + + #[test] + fn detect_sbt_passes_when_hidden_sbt_directory_is_found_in_application_directory() { + let app_dir = tempdir().unwrap(); + let dot_sbt = app_dir.path().join(".sbt"); + create_dir(&dot_sbt).unwrap(); + write(dot_sbt.join("some-file.scala"), "").unwrap(); + assert!(detect_sbt(app_dir.path())); + } + + #[test] + fn detect_sbt_passes_when_build_properties_file_is_found_in_the_sbt_project_directory() { + let app_dir = tempdir().unwrap(); + let sbt_project_path = app_dir.path().join("project"); + create_dir(&sbt_project_path).unwrap(); + write(sbt_project_path.join("build.properties"), "").unwrap(); + assert!(detect_sbt(app_dir.path())); + } +} + +#[cfg(test)] +mod handle_sbt_error_tests { + use crate::errors::ScalaBuildpackError; + use crate::errors::ScalaBuildpackError::MissingStageTask; + use crate::handle_sbt_error; + use indoc::formatdoc; + use std::os::unix::process::ExitStatusExt; + use std::process::{ExitStatus, Output}; + + #[test] + fn check_missing_stage_error_is_reported() { + let stdout = formatdoc! {" + [error] Expected ';' + [error] Not a valid command: stage (similar: last-grep, set, last) + [error] Not a valid project ID: stage + [error] Expected ':' + [error] Not a valid key: stage (similar: state, target, tags) + [error] stage + [error] ^ + "} + .into_bytes(); + + let output = Output { + stdout, + stderr: vec![], + status: ExitStatus::from_raw(0), + }; + let err = handle_sbt_error(&output); + match err { + MissingStageTask => {} + _ => panic!("expected MissingStageTask error"), + } + } + + #[test] + fn check_already_defined_as_error_is_reported() { + let stdout = formatdoc! {" + [error] Expected ';' + [error] Not a valid command: stage (similar: last-grep, set, last) + [error] Not a valid project ID: stage + [error] Expected ':' + [error] Blah is already defined as object Blah + "} + .into_bytes(); + + let output = Output { + stdout, + stderr: vec![], + status: ExitStatus::from_raw(0), + }; + let err = handle_sbt_error(&output); + match err { + ScalaBuildpackError::AlreadyDefinedAsObject => {} + _ => panic!("expected MissingStageTask error"), + } + } +} + +#[cfg(test)] +mod get_sbt_build_tasks_tests { + use crate::build_configuration::BuildConfiguration; + use crate::get_sbt_build_tasks; + use semver::Version; + + #[test] + fn get_sbt_build_tasks_with_no_configured_options() { + let config = BuildConfiguration { + sbt_project: None, + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: None, + sbt_opts: None, + sbt_available_at_launch: None, + sbt_version: Version::new(0, 0, 0), + }; + assert_eq!(get_sbt_build_tasks(&config), vec!["compile", "stage"]); + } + + #[test] + fn get_sbt_build_tasks_with_all_configured_options() { + let config = BuildConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: Some(vec!["preTask".to_string()]), + sbt_tasks: Some(vec!["task".to_string()]), + sbt_clean: Some(true), + sbt_opts: None, + sbt_available_at_launch: None, + sbt_version: Version::new(0, 0, 0), + }; + assert_eq!( + get_sbt_build_tasks(&config), + vec!["clean", "preTask", "task"] + ); + } + + #[test] + fn get_sbt_build_tasks_with_clean_set_to_true() { + let config = BuildConfiguration { + sbt_project: None, + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: Some(true), + sbt_opts: None, + sbt_available_at_launch: None, + sbt_version: Version::new(0, 0, 0), + }; + assert_eq!( + get_sbt_build_tasks(&config), + vec!["clean", "compile", "stage"] + ); + } + + #[test] + fn get_sbt_build_tasks_with_clean_set_to_false() { + let config = BuildConfiguration { + sbt_project: None, + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: Some(false), + sbt_opts: None, + sbt_available_at_launch: None, + sbt_version: Version::new(0, 0, 0), + }; + assert_eq!(get_sbt_build_tasks(&config), vec!["compile", "stage"]); + } + + #[test] + fn get_sbt_build_tasks_with_project_set() { + let config = BuildConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: None, + sbt_opts: None, + sbt_available_at_launch: None, + sbt_version: Version::new(0, 0, 0), + }; + assert_eq!( + get_sbt_build_tasks(&config), + vec!["projectName/compile", "projectName/stage"] + ); + } + + #[test] + fn get_sbt_build_tasks_with_project_and_pre_tasks_set() { + let config = BuildConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: Some(vec!["preTask".to_string()]), + sbt_tasks: None, + sbt_clean: None, + sbt_opts: None, + sbt_available_at_launch: None, + sbt_version: Version::new(0, 0, 0), + }; + assert_eq!( + get_sbt_build_tasks(&config), + vec!["preTask", "projectName/compile", "projectName/stage"] + ); + } + + #[test] + fn get_sbt_build_tasks_with_project_and_clean_set() { + let config = BuildConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: Some(true), + sbt_opts: None, + sbt_available_at_launch: None, + sbt_version: Version::new(0, 0, 0), + }; + assert_eq!( + get_sbt_build_tasks(&config), + vec!["clean", "projectName/compile", "projectName/stage"] + ); + } +} diff --git a/buildpacks/sbt/tests/integration_tests.rs b/buildpacks/sbt/tests/integration_tests.rs new file mode 100644 index 00000000..dd5ce972 --- /dev/null +++ b/buildpacks/sbt/tests/integration_tests.rs @@ -0,0 +1,115 @@ +use libcnb_test::{ + assert_contains, assert_not_contains, BuildConfig, BuildpackReference, ContainerConfig, + TestContext, TestRunner, +}; +use std::path::Path; +use std::thread; +use std::time::Duration; + +#[test] +#[ignore = "integration test"] +fn test_scala_application_with_ivy() { + test_scala_application("scala-app-using-ivy", |ctx| { + assert_health_check_responds(&ctx) + }); +} + +#[test] +#[ignore = "integration test"] +fn test_scala_application_with_ivy_uses_cache_on_rebuild() { + test_scala_application("scala-app-using-ivy", |ctx| { + assert_contains!(&ctx.pack_stdout, "Setting up sbt"); + assert_not_contains!(&ctx.pack_stdout, "Reusing sbt"); + ctx.rebuild(get_build_config("scala-app-using-ivy"), |rebuild_ctx| { + assert_contains!(&rebuild_ctx.pack_stdout, "Reusing sbt"); + assert_not_contains!(&rebuild_ctx.pack_stdout, "Setting up sbt"); + assert_health_check_responds(&rebuild_ctx); + }) + }) +} + +#[test] +#[ignore = "integration test"] +fn test_scala_application_with_coursier() { + test_scala_application("scala-app-using-coursier", |ctx| { + assert_health_check_responds(&ctx) + }); +} + +#[test] +#[ignore = "integration test"] +fn test_scala_application_with_coursier_uses_cache_on_rebuild() { + test_scala_application("scala-app-using-coursier", |ctx| { + assert_contains!(&ctx.pack_stdout, "Setting up sbt"); + assert_not_contains!(&ctx.pack_stdout, "Reusing sbt"); + ctx.rebuild( + get_build_config("scala-app-using-coursier"), + |rebuild_ctx| { + assert_contains!(&rebuild_ctx.pack_stdout, "Reusing sbt"); + assert_not_contains!(&rebuild_ctx.pack_stdout, "Setting up sbt"); + assert_health_check_responds(&rebuild_ctx); + }, + ) + }) +} + +#[test] +#[ignore = "integration test"] +fn test_play_support_for_v2_8() { + test_scala_application("scala-play-app-2.8", |ctx| { + assert_health_check_responds(&ctx); + }) +} + +#[test] +#[ignore = "integration test"] +fn test_play_support_for_v2_7() { + test_scala_application("scala-play-app-2.7", |ctx| { + assert_health_check_responds(&ctx); + }) +} + +fn test_scala_application(fixture_name: &str, test_body: fn(TestContext)) { + TestRunner::default().build(get_build_config(fixture_name), test_body); +} + +fn get_build_config(fixture_name: &str) -> BuildConfig { + let app_dir = Path::new("../../test-fixtures").join(fixture_name); + let builder_name = + std::env::var("INTEGRATION_TEST_CNB_BUILDER").unwrap_or("heroku/builder:22".into()); + BuildConfig::new(builder_name, app_dir) + .buildpacks(vec![ + BuildpackReference::Other(String::from("heroku/procfile")), + BuildpackReference::Other(String::from("heroku/jvm")), + BuildpackReference::Crate, + ]) + .to_owned() +} + +fn assert_health_check_responds(ctx: &TestContext) { + let port: u16 = 8080; + let timeout: u64 = 15; + + ctx.start_container( + ContainerConfig::new() + .env("PORT", port.to_string()) + .expose_port(port), + |container| { + // Give the application a little time to boot up: + // https://github.com/heroku/libcnb.rs/issues/280 + thread::sleep(Duration::from_secs(timeout)); + + let addr = container + .address_for_port(port) + .expect("couldn't get container address"); + + let res = ureq::get(&format!("http://{addr}")) + .call() + .expect("request to container failed") + .into_string() + .expect("response read error"); + + assert_eq!(res, "Hello from Scala!"); + }, + ) +} diff --git a/meta-buildpacks/scala/CHANGELOG.md b/meta-buildpacks/scala/CHANGELOG.md new file mode 100644 index 00000000..954d14ba --- /dev/null +++ b/meta-buildpacks/scala/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +* Initial release diff --git a/meta-buildpacks/scala/README.md b/meta-buildpacks/scala/README.md new file mode 100644 index 00000000..51ad8b6b --- /dev/null +++ b/meta-buildpacks/scala/README.md @@ -0,0 +1 @@ +# Heroku Cloud Native Scala Buildpack diff --git a/meta-buildpacks/scala/buildpack.toml b/meta-buildpacks/scala/buildpack.toml new file mode 100644 index 00000000..2524fe24 --- /dev/null +++ b/meta-buildpacks/scala/buildpack.toml @@ -0,0 +1,32 @@ +api = "0.8" + +[buildpack] +id = "heroku/scala" +version = "0.0.1" +name = "Scala" +homepage = "https://github.com/heroku/buildpacks-jvm" +description = "Official Heroku buildpack for Scala applications." +keywords = ["scala", "java"] + +[[buildpack.licenses]] +type = "BSD-3-Clause" + +[[order]] + +[[order.group]] +id = "heroku/jvm" +version = "1.0.6" + +[[order.group]] +id = "heroku/sbt" +version = "0.0.1" + +[[order.group]] +id = "heroku/procfile" +version = "2.0.0" +optional = true + +[metadata] +[metadata.release] +[metadata.release.docker] +repository = "public.ecr.aws/heroku-buildpacks/heroku-scala-buildpack" diff --git a/meta-buildpacks/scala/package.toml b/meta-buildpacks/scala/package.toml new file mode 100644 index 00000000..c75870ae --- /dev/null +++ b/meta-buildpacks/scala/package.toml @@ -0,0 +1,11 @@ +[buildpack] +uri = "." + +[[dependencies]] +uri = "docker://public.ecr.aws/heroku-buildpacks/heroku-jvm-buildpack@sha256:bc58cd36e77a1b16709218782fd865df63d3f3c2d25785c8e4a36b381c83a435" + +[[dependencies]] +#uri = "docker://public.ecr.aws/heroku-buildpacks/heroku-sbt-buildpack@sha256:[NEED TO PUBLISH SBT BUILDPACK FIRST]" + +[[dependencies]] +uri = "docker://docker.io/heroku/procfile-cnb:2.0.0" diff --git a/test-fixtures/scala-app-using-coursier/.gitignore b/test-fixtures/scala-app-using-coursier/.gitignore new file mode 100644 index 00000000..dce73038 --- /dev/null +++ b/test-fixtures/scala-app-using-coursier/.gitignore @@ -0,0 +1,9 @@ +logs +target +/.bsp +/.idea +/.idea_modules +/.classpath +/.project +/.settings +/RUNNING_PID diff --git a/test-fixtures/scala-app-using-coursier/Procfile b/test-fixtures/scala-app-using-coursier/Procfile new file mode 100644 index 00000000..df7893e5 --- /dev/null +++ b/test-fixtures/scala-app-using-coursier/Procfile @@ -0,0 +1 @@ +web: target/universal/stage/bin/scala-getting-started diff --git a/test-fixtures/scala-app-using-coursier/build.sbt b/test-fixtures/scala-app-using-coursier/build.sbt new file mode 100644 index 00000000..2e117c22 --- /dev/null +++ b/test-fixtures/scala-app-using-coursier/build.sbt @@ -0,0 +1,13 @@ +enablePlugins(JavaAppPackaging) + +name := """scala-getting-started""" + +version := "1.0" + +scalaVersion := "2.12.12" + +mainClass in Compile := Some("com.example.Server") + +libraryDependencies ++= Seq( + "com.twitter" %% "finagle-http" % "22.12.0" +) diff --git a/test-fixtures/scala-app-using-coursier/project/build.properties b/test-fixtures/scala-app-using-coursier/project/build.properties new file mode 100644 index 00000000..797e7ccf --- /dev/null +++ b/test-fixtures/scala-app-using-coursier/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.3.10 diff --git a/test-fixtures/scala-app-using-coursier/project/plugins.sbt b/test-fixtures/scala-app-using-coursier/project/plugins.sbt new file mode 100644 index 00000000..9a160549 --- /dev/null +++ b/test-fixtures/scala-app-using-coursier/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.4") diff --git a/test-fixtures/scala-app-using-coursier/src/main/scala/com/example/Server.scala b/test-fixtures/scala-app-using-coursier/src/main/scala/com/example/Server.scala new file mode 100644 index 00000000..323bad94 --- /dev/null +++ b/test-fixtures/scala-app-using-coursier/src/main/scala/com/example/Server.scala @@ -0,0 +1,17 @@ +package com.example + +import com.twitter.finagle.{Http, Service} +import com.twitter.finagle.http +import com.twitter.util.{Await, Future} + +object Server extends App { + val service = new Service[http.Request, http.Response] { + def apply(req: http.Request): Future[http.Response] = { + val response = http.Response() + response.setContentString("Hello from Scala!") + Future(response) + } + } + val server = Http.serve(":8080", service) + Await.ready(server) +} diff --git a/test-fixtures/scala-app-using-coursier/system.properties b/test-fixtures/scala-app-using-coursier/system.properties new file mode 100644 index 00000000..5e8606c8 --- /dev/null +++ b/test-fixtures/scala-app-using-coursier/system.properties @@ -0,0 +1 @@ +java.runtime.version=1.8 diff --git a/test-fixtures/scala-app-using-ivy/.gitignore b/test-fixtures/scala-app-using-ivy/.gitignore new file mode 100644 index 00000000..dce73038 --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/.gitignore @@ -0,0 +1,9 @@ +logs +target +/.bsp +/.idea +/.idea_modules +/.classpath +/.project +/.settings +/RUNNING_PID diff --git a/test-fixtures/scala-app-using-ivy/Procfile b/test-fixtures/scala-app-using-ivy/Procfile new file mode 100644 index 00000000..df7893e5 --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/Procfile @@ -0,0 +1 @@ +web: target/universal/stage/bin/scala-getting-started diff --git a/test-fixtures/scala-app-using-ivy/build.sbt b/test-fixtures/scala-app-using-ivy/build.sbt new file mode 100644 index 00000000..5a43be54 --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/build.sbt @@ -0,0 +1,15 @@ +import NativePackagerKeys._ + +packageArchetype.java_application + +name := """scala-getting-started""" + +version := "1.0" + +scalaVersion := "2.10.4" + +mainClass in Compile := Some("com.example.Server") + +libraryDependencies ++= Seq( + "com.twitter" % "finagle-http_2.10" % "6.18.0" +) diff --git a/test-fixtures/scala-app-using-ivy/project/build.properties b/test-fixtures/scala-app-using-ivy/project/build.properties new file mode 100644 index 00000000..8e682c52 --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.18 diff --git a/test-fixtures/scala-app-using-ivy/project/plugins.sbt b/test-fixtures/scala-app-using-ivy/project/plugins.sbt new file mode 100644 index 00000000..3a5cd87a --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "0.7.5-RC2") diff --git a/test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Https.scala b/test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Https.scala new file mode 100644 index 00000000..622d4fc8 --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Https.scala @@ -0,0 +1,51 @@ +package com.example + +import com.twitter.finagle.{Http, Service} +import com.twitter.util.{Await, Future} +import com.twitter.finagle.http.Response +import java.net.InetSocketAddress +import org.jboss.netty.handler.codec.http._ +import util.Properties + +import java.io.{InputStream, FileNotFoundException, InputStreamReader, BufferedReader} +import java.net.URL +import javax.net.ssl.HttpsURLConnection + +import scala.collection.JavaConversions._ + +object Https { + def main(args: Array[String]) { + val urlStr = "https://httpbin.org/get?show_env=1" + val url = new URL(urlStr) + val con = url.openConnection.asInstanceOf[HttpsURLConnection] + con.setDoInput(true) + con.setRequestMethod("GET") + + val r = handleResponse(con) + + println("Successfully invoked HTTPS Service.") + println(r) + } + + def handleResponse(con: HttpsURLConnection): String = { + try { + readStream(con.getInputStream) + } catch { + case e: Exception => + e.printStackTrace() + val output = readStream(con.getErrorStream) + throw new Exception("HTTP " + String.valueOf(con.getResponseCode) + ": " + e.getMessage) + } + } + + def readStream(is: InputStream): String = { + val reader = new BufferedReader(new InputStreamReader(is)) + var output = "" + var tmp = reader.readLine + while (tmp != null) { + output += tmp + tmp = reader.readLine + } + output + } +} diff --git a/test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Server.scala b/test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Server.scala new file mode 100644 index 00000000..de54f749 --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/src/main/scala/com/example/Server.scala @@ -0,0 +1,25 @@ +package com.example + +import com.twitter.finagle.{Http, Service} +import com.twitter.util.{Await, Future} +import com.twitter.finagle.http.Response +import org.jboss.netty.handler.codec.http._ +import util.Properties + +object Server { + def main(args: Array[String]) { + val port = Properties.envOrElse("PORT", "8080").toInt + println("Starting on port: "+port) + + val server = Http.serve(s":$port", new Hello) + Await.ready(server) + } +} + +class Hello extends Service[HttpRequest, HttpResponse] { + def apply(request: HttpRequest): Future[HttpResponse] = { + val response = Response() + response.setContentString("Hello from Scala!") + Future(response) + } +} diff --git a/test-fixtures/scala-app-using-ivy/system.properties b/test-fixtures/scala-app-using-ivy/system.properties new file mode 100644 index 00000000..5e8606c8 --- /dev/null +++ b/test-fixtures/scala-app-using-ivy/system.properties @@ -0,0 +1 @@ +java.runtime.version=1.8 diff --git a/test-fixtures/scala-play-app-2.7/.gitignore b/test-fixtures/scala-play-app-2.7/.gitignore new file mode 100644 index 00000000..dce73038 --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/.gitignore @@ -0,0 +1,9 @@ +logs +target +/.bsp +/.idea +/.idea_modules +/.classpath +/.project +/.settings +/RUNNING_PID diff --git a/test-fixtures/scala-play-app-2.7/Procfile b/test-fixtures/scala-play-app-2.7/Procfile new file mode 100644 index 00000000..2a2c26eb --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/Procfile @@ -0,0 +1 @@ +web: target/universal/stage/bin/play-scala-seed diff --git a/test-fixtures/scala-play-app-2.7/app/controllers/HomeController.scala b/test-fixtures/scala-play-app-2.7/app/controllers/HomeController.scala new file mode 100644 index 00000000..cd25c1f7 --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/app/controllers/HomeController.scala @@ -0,0 +1,24 @@ +package controllers + +import javax.inject._ +import play.api._ +import play.api.mvc._ + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class HomeController @Inject()(cc: ControllerComponents) extends AbstractController(cc) { + + /** + * Create an Action to render an HTML page. + * + * The configuration in the `routes` file means that this method + * will be called when the application receives a `GET` request with + * a path of `/`. + */ + def index() = Action { implicit request: Request[AnyContent] => + Ok("Hello from Scala!") + } +} diff --git a/test-fixtures/scala-play-app-2.7/build.sbt b/test-fixtures/scala-play-app-2.7/build.sbt new file mode 100644 index 00000000..038219e0 --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/build.sbt @@ -0,0 +1,17 @@ +name := """play-scala-seed""" +organization := "com.example" + +version := "1.0-SNAPSHOT" + +lazy val root = (project in file(".")).enablePlugins(PlayScala) + +scalaVersion := "2.13.1" + +libraryDependencies += guice +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "4.0.3" % Test + +// Adds additional packages into Twirl +//TwirlKeys.templateImports += "com.example.controllers._" + +// Adds additional packages into conf/routes +// play.sbt.routes.RoutesKeys.routesImport += "com.example.binders._" diff --git a/test-fixtures/scala-play-app-2.7/conf/application.conf b/test-fixtures/scala-play-app-2.7/conf/application.conf new file mode 100644 index 00000000..ccd6a80d --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/conf/application.conf @@ -0,0 +1,4 @@ +# https://www.playframework.com/documentation/latest/Configuration +play.http.secret.key="changethissosomethingsecret" +play.filters.disabled += play.filters.hosts.AllowedHostsFilter +http.port=${?PORT} diff --git a/test-fixtures/scala-play-app-2.7/conf/routes b/test-fixtures/scala-play-app-2.7/conf/routes new file mode 100644 index 00000000..0dbcdee4 --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/conf/routes @@ -0,0 +1,8 @@ +# Routes +# This file defines all application routes (Higher priority routes first) +# https://www.playframework.com/documentation/latest/ScalaRouting +# ~~~~ + +# An example controller showing a sample home page +GET / controllers.HomeController.index() + diff --git a/test-fixtures/scala-play-app-2.7/project/build.properties b/test-fixtures/scala-play-app-2.7/project/build.properties new file mode 100644 index 00000000..6adcdc75 --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.3.3 diff --git a/test-fixtures/scala-play-app-2.7/project/plugins.sbt b/test-fixtures/scala-play-app-2.7/project/plugins.sbt new file mode 100644 index 00000000..ff7c9f5c --- /dev/null +++ b/test-fixtures/scala-play-app-2.7/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.9") +addSbtPlugin("org.foundweekends.giter8" % "sbt-giter8-scaffold" % "0.11.0") diff --git a/test-fixtures/scala-play-app-2.8/.gitignore b/test-fixtures/scala-play-app-2.8/.gitignore new file mode 100644 index 00000000..dce73038 --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/.gitignore @@ -0,0 +1,9 @@ +logs +target +/.bsp +/.idea +/.idea_modules +/.classpath +/.project +/.settings +/RUNNING_PID diff --git a/test-fixtures/scala-play-app-2.8/Procfile b/test-fixtures/scala-play-app-2.8/Procfile new file mode 100644 index 00000000..2a2c26eb --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/Procfile @@ -0,0 +1 @@ +web: target/universal/stage/bin/play-scala-seed diff --git a/test-fixtures/scala-play-app-2.8/app/controllers/HomeController.scala b/test-fixtures/scala-play-app-2.8/app/controllers/HomeController.scala new file mode 100644 index 00000000..d3a984be --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/app/controllers/HomeController.scala @@ -0,0 +1,24 @@ +package controllers + +import javax.inject._ +import play.api._ +import play.api.mvc._ + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController { + + /** + * Create an Action to render an HTML page. + * + * The configuration in the `routes` file means that this method + * will be called when the application receives a `GET` request with + * a path of `/`. + */ + def index() = Action { implicit request: Request[AnyContent] => + Ok("Hello from Scala!") + } +} diff --git a/test-fixtures/scala-play-app-2.8/build.sbt b/test-fixtures/scala-play-app-2.8/build.sbt new file mode 100644 index 00000000..818eeda4 --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/build.sbt @@ -0,0 +1,17 @@ +name := """play-scala-seed""" +organization := "com.example" + +version := "1.0-SNAPSHOT" + +lazy val root = (project in file(".")).enablePlugins(PlayScala) + +scalaVersion := "2.13.10" + +libraryDependencies += guice +libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "5.0.0" % Test + +// Adds additional packages into Twirl +//TwirlKeys.templateImports += "com.example.controllers._" + +// Adds additional packages into conf/routes +// play.sbt.routes.RoutesKeys.routesImport += "com.example.binders._" diff --git a/test-fixtures/scala-play-app-2.8/conf/application.conf b/test-fixtures/scala-play-app-2.8/conf/application.conf new file mode 100644 index 00000000..ccd6a80d --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/conf/application.conf @@ -0,0 +1,4 @@ +# https://www.playframework.com/documentation/latest/Configuration +play.http.secret.key="changethissosomethingsecret" +play.filters.disabled += play.filters.hosts.AllowedHostsFilter +http.port=${?PORT} diff --git a/test-fixtures/scala-play-app-2.8/conf/routes b/test-fixtures/scala-play-app-2.8/conf/routes new file mode 100644 index 00000000..0dbcdee4 --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/conf/routes @@ -0,0 +1,8 @@ +# Routes +# This file defines all application routes (Higher priority routes first) +# https://www.playframework.com/documentation/latest/ScalaRouting +# ~~~~ + +# An example controller showing a sample home page +GET / controllers.HomeController.index() + diff --git a/test-fixtures/scala-play-app-2.8/project/build.properties b/test-fixtures/scala-play-app-2.8/project/build.properties new file mode 100644 index 00000000..563a014d --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.7.2 diff --git a/test-fixtures/scala-play-app-2.8/project/plugins.sbt b/test-fixtures/scala-play-app-2.8/project/plugins.sbt new file mode 100644 index 00000000..8846622e --- /dev/null +++ b/test-fixtures/scala-play-app-2.8/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.19") +addSbtPlugin("org.foundweekends.giter8" % "sbt-giter8-scaffold" % "0.13.1")