Enso Version Management in the Launcher (#1059)

- Adds support for downloading engine and runtime versions in the launcher.
- Adds functionality to install, list and uninstall engine components.
This commit is contained in:
Radosław Waśko 2020-08-10 12:14:39 +02:00 committed by GitHub
parent 65dec91bc0
commit 11868cb528
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 4808 additions and 709 deletions

6
.gitignore vendored
View File

@ -94,3 +94,9 @@ bench-report.xml
.editorconfig
.bloop/
#################
## Build Cache ##
#################
build-cache/

View File

@ -6,7 +6,7 @@ import com.typesafe.sbt.SbtLicenseReport.autoImportImpl.{
}
import org.enso.build.BenchTasks._
import org.enso.build.WithDebugCommand
import sbt.Keys.scalacOptions
import sbt.Keys.{libraryDependencies, scalacOptions}
import sbt.addCompilerPlugin
import sbtassembly.AssemblyPlugin.defaultUniversalScript
import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType}
@ -305,7 +305,9 @@ val zio = Seq(
// === Other ==================================================================
val apacheHttpClientVersion = "4.5.12"
val bcpkixJdk15Version = "1.65"
val bumpVersion = "0.1.3"
val declineVersion = "1.2.0"
val directoryWatcherVersion = "0.9.10"
val flatbuffersVersion = "1.12.0"
@ -797,7 +799,7 @@ lazy val ast = (project in file("lib/scala/ast"))
.settings(
version := ensoVersion,
GenerateAST.rustVersion := rustVersion,
Compile / sourceGenerators += GenerateAST.task,
Compile / sourceGenerators += GenerateAST.task
)
lazy val runtime = (project in file("engine/runtime"))
@ -974,7 +976,7 @@ lazy val runner = project
)
.settings(
buildNativeImage := NativeImage
.buildNativeImage(staticOnLinux = false)
.buildNativeImage("enso", staticOnLinux = false)
.value
)
.settings(
@ -992,13 +994,28 @@ lazy val launcher = project
.in(file("engine/launcher"))
.configs(Test)
.settings(
resolvers += Resolver.bintrayRepo("gn0s1s", "releases"),
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % scalatestVersion % Test,
"org.typelevel" %% "cats-core" % catsVersion
"org.scalatest" %% "scalatest" % scalatestVersion % Test,
"org.typelevel" %% "cats-core" % catsVersion,
"nl.gn0s1s" %% "bump" % bumpVersion,
"org.apache.commons" % "commons-compress" % commonsCompressVersion,
"org.apache.httpcomponents" % "httpclient" % apacheHttpClientVersion
)
)
.settings(
buildNativeImage := NativeImage.buildNativeImage(staticOnLinux = true).value
buildNativeImage := NativeImage
.buildNativeImage(
"enso",
staticOnLinux = true,
Seq(
"--enable-all-security-services", // Note [HTTPS in the Launcher]
"-Dorg.apache.commons.logging.Log=org.apache.commons.logging.impl.NoOpLog"
)
)
.value,
test in assembly := {},
assemblyOutputPath in assembly := file("launcher.jar")
)
.settings(
(Test / test) := (Test / test)
@ -1014,3 +1031,15 @@ lazy val launcher = project
.dependsOn(cli)
.dependsOn(`version-output`)
.dependsOn(pkg)
/* Note [HTTPS in the Launcher]
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* The launcher uses Apache HttpClient for making web requests. It does not use
* Java's stdlib implementation, because there is a bug (not fixed in JDK 11)
* (https://bugs.openjdk.java.net/browse/JDK-8231449) in its HTTPS handling that
* causes long running requests to freeze forever. However, Apache HttpClient
* still needs the stdlib's SSL implementation and it is not included in the
* Native Images by default (because of its size). The
* `--enable-all-security-services` flag is used to ensure it is available in
* the built executable.
*/

View File

@ -86,6 +86,120 @@ Copyright 2002-2017 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
-------------------------
Apache Commons Codec, licensed under Apache License Version 2.0
(see: components-licences/LICENSE-APACHE) is distributed with the launcher.
It requires the following notice:
Apache Commons Codec
Copyright 2002-2017 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
src/test/org/apache/commons/codec/language/DoubleMetaphoneTest.java
contains test data from http://aspell.net/test/orig/batch0.tab.
Copyright (C) 2002 Kevin Atkinson (kevina@gnu.org)
===============================================================================
The content of package org.apache.commons.codec.language.bm has been translated
from the original php source code available at http://stevemorse.org/phoneticinfo.htm
with permission from the original authors.
Original source copyright:
Copyright (c) 2008 Alexander Beider & Stephen P. Morse.
-----------------------
Apache Commons Compress, licensed under Apache License Version 2.0
(see: components-licences/LICENSE-APACHE) is distributed with the launcher.
It requires the following notice:
Apache Commons Compress
Copyright 2002-2020 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (https://www.apache.org/).
---
The files in the package org.apache.commons.compress.archivers.sevenz
were derived from the LZMA SDK, version 9.20 (C/ and CPP/7zip/),
which has been placed in the public domain:
"LZMA SDK is placed in the public domain." (http://www.7-zip.org/sdk.html)
---
The test file lbzip2_32767.bz2 has been copied from libbzip2's source
repository:
This program, "bzip2", the associated library "libbzip2", and all
documentation, are copyright (C) 1996-2019 Julian R Seward. All
rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. The origin of this software must not be misrepresented; you must
not claim that you wrote the original software. If you use this
software in a product, an acknowledgment in the product
documentation would be appreciated but is not required.
3. Altered source versions must be plainly marked as such, and must
not be misrepresented as being the original software.
4. The name of the author may not be used to endorse or promote
products derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.
Julian Seward, jseward@acm.org
-----------------------
Apache HttpClient, licensed under Apache License Version 2.0
(see: components-licences/LICENSE-APACHE) is distributed with the launcher.
It requires the following notice:
Apache HttpClient
Copyright 1999-2020 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
-----------------------
Apache HttpCore, licensed under Apache License Version 2.0
(see: components-licences/LICENSE-APACHE) is distributed with the launcher.
It requires the following notice:
Apache HttpCore
Copyright 2005-2020 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
-----------------------
=======================
As the launcher is written in Scala and built with Graal Native Image, its
@ -128,4 +242,50 @@ the launcher.
================
'snakeyaml', licensed under the Apache License, Version 2.0, is distributed with
the launcher.
the launcher.
================
'bump', licensed under the MIT License, is distributed with the launcher. It
requires the following notice:
MIT License
Copyright (c) 2018-2020 Philippus Baalman
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================
The launcher uses the 'Scala parser combinators' library, licensed under the
Apache License, Version 2.0. It requires the following notice:
Scala parser combinators
Copyright (c) 2002-2020 EPFL
Copyright (c) 2011-2020 Lightbend, Inc.
Scala includes software developed at
LAMP/EPFL (https://lamp.epfl.ch/) and
Lightbend, Inc. (https://www.lightbend.com/).
Licensed under the Apache License, Version 2.0 (the "License").
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================
The version of this launcher built for the Linux platform uses `zlib` created by
Jean-loup Gailly and Mark Adler.
================
The version of this launcher built for the Linux platform uses the `musl` libc
licensed under the MIT license. Its copyright notice is included in
`components-licences/COPYRIGHT-MUSL`.

View File

@ -0,0 +1,190 @@
musl as a whole is licensed under the following standard MIT license:
----------------------------------------------------------------------
Copyright © 2005-2020 Rich Felker, et al.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------
Authors/contributors include:
A. Wilcox
Ada Worcester
Alex Dowad
Alex Suykov
Alexander Monakov
Andre McCurdy
Andrew Kelley
Anthony G. Basile
Aric Belsito
Arvid Picciani
Bartosz Brachaczek
Benjamin Peterson
Bobby Bingham
Boris Brezillon
Brent Cook
Chris Spiegel
Clément Vasseur
Daniel Micay
Daniel Sabogal
Daurnimator
David Carlier
David Edelsohn
Denys Vlasenko
Dmitry Ivanov
Dmitry V. Levin
Drew DeVault
Emil Renner Berthing
Fangrui Song
Felix Fietkau
Felix Janda
Gianluca Anzolin
Hauke Mehrtens
He X
Hiltjo Posthuma
Isaac Dunham
Jaydeep Patil
Jens Gustedt
Jeremy Huntwork
Jo-Philipp Wich
Joakim Sindholt
John Spencer
Julien Ramseier
Justin Cormack
Kaarle Ritvanen
Khem Raj
Kylie McClain
Leah Neukirchen
Luca Barbato
Luka Perkov
M Farkas-Dyck (Strake)
Mahesh Bodapati
Markus Wichmann
Masanori Ogino
Michael Clark
Michael Forney
Mikhail Kremnyov
Natanael Copa
Nicholas J. Kain
orc
Pascal Cuoq
Patrick Oppenlander
Petr Hosek
Petr Skocik
Pierre Carrier
Reini Urban
Rich Felker
Richard Pennington
Ryan Fairfax
Samuel Holland
Segev Finer
Shiz
sin
Solar Designer
Stefan Kristiansson
Stefan O'Rear
Szabolcs Nagy
Timo Teräs
Trutz Behn
Valentin Ochs
Will Dietz
William Haddon
William Pitcock
Portions of this software are derived from third-party works licensed
under terms compatible with the above MIT license:
The TRE regular expression implementation (src/regex/reg* and
src/regex/tre*) is Copyright © 2001-2008 Ville Laurikari and licensed
under a 2-clause BSD license (license text in the source files). The
included version has been heavily modified by Rich Felker in 2012, in
the interests of size, simplicity, and namespace cleanliness.
Much of the math library code (src/math/* and src/complex/*) is
Copyright © 1993,2004 Sun Microsystems or
Copyright © 2003-2011 David Schultz or
Copyright © 2003-2009 Steven G. Kargl or
Copyright © 2003-2009 Bruce D. Evans or
Copyright © 2008 Stephen L. Moshier or
Copyright © 2017-2018 Arm Limited
and labelled as such in comments in the individual source files. All
have been licensed under extremely permissive terms.
The ARM memcpy code (src/string/arm/memcpy_el.S) is Copyright © 2008
The Android Open Source Project and is licensed under a two-clause BSD
license. It was taken from Bionic libc, used on Android.
The implementation of DES for crypt (src/crypt/crypt_des.c) is
Copyright © 1994 David Burren. It is licensed under a BSD license.
The implementation of blowfish crypt (src/crypt/crypt_blowfish.c) was
originally written by Solar Designer and placed into the public
domain. The code also comes with a fallback permissive license for use
in jurisdictions that may not recognize the public domain.
The smoothsort implementation (src/stdlib/qsort.c) is Copyright © 2011
Valentin Ochs and is licensed under an MIT-style license.
The x86_64 port was written by Nicholas J. Kain and is licensed under
the standard MIT terms.
The mips and microblaze ports were originally written by Richard
Pennington for use in the ellcc project. The original code was adapted
by Rich Felker for build system and code conventions during upstream
integration. It is licensed under the standard MIT terms.
The mips64 port was contributed by Imagination Technologies and is
licensed under the standard MIT terms.
The powerpc port was also originally written by Richard Pennington,
and later supplemented and integrated by John Spencer. It is licensed
under the standard MIT terms.
All other files which have no copyright comments are original works
produced specifically for use as part of this library, written either
by Rich Felker, the main author of the library, or by one or more
contibutors listed above. Details on authorship of individual files
can be found in the git version control history of the project. The
omission of copyright and license comments in each file is in the
interest of source tree size.
In addition, permission is hereby granted for all public header files
(include/* and arch/*/bits/*) and crt files intended to be linked into
applications (crt/*, ldso/dlstart.c, and arch/*/crt_arch.h) to omit
the copyright notice and permission notice otherwise required by the
license, and to use these files without any requirement of
attribution. These files include substantial contributions from:
Bobby Bingham
John Spencer
Nicholas J. Kain
Rich Felker
Richard Pennington
Stefan Kristiansson
Szabolcs Nagy
all of whom have explicitly granted such permission.
This file previously contained text expressing a belief that most of
the files covered by the above exception were sufficiently trivial not
to be subject to copyright, resulting in confusion over whether it
negated the permissions granted in the license. In the spirit of
permissive licensing, and of not having licensing issues being an
obstacle to adoption, that text has been removed.

View File

@ -124,6 +124,16 @@ compiler that they relate to.
### System Requirements
The following operating systems are supported for developing Enso:
- Windows 10
- macOS 10.14 and above
- Linux 4.4 and above
Currently only the x86_64 (amd64) architecture is supported. You may be able to
develop Enso on other systems, but issues arising from unsupported
configurations will not be fixed by the core team.
In order to build and run Enso you will need the following tools:
- [sbt](https://www.scala-sbt.org/) with the same version as specified in
@ -137,6 +147,8 @@ In order to build and run Enso you will need the following tools:
- [Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html),
the rust build tool.
- [Rustup](https://rustup.rs), the rust toolchain management utility.
- On MacOS and Linux, the `tar` command is required for running some tests. It
should be installed by default on most distributions.
Managing multiple JVM installations can be a pain, so some of the team use
[Jenv](http://www.jenv.be/): A useful tool for managing multiple JVMs.
@ -172,8 +184,8 @@ git clone git@github.com:enso-org/enso.git
### Getting Set Up (Rust)
The SBT project requires a specific nightly rust toolchain. To get it set up,
you will need to install [rustup](https://rustup.rs/) and then run the following
The SBT project requires a specific nightly rust toolchain. To get it set up,
you will need to install [rustup](https://rustup.rs/) and then run the following
commands:
```bash
@ -400,9 +412,9 @@ need to follow these steps:
placeholders that we don't use.
6. Alternatively, certain tasks, such as `run`, `benchOnly` and `testOnly` can
be used through the `withDebug` SBT command. For this to work, your remote
configuration must specify the host of `localhost` and the port `5005`.
The command syntax is `withDebug --debugger TASK_NAME -- TASK_PARAMETERS`,
e.g. `withDebug --debugger testOnly -- *AtomConstructors*`.
configuration must specify the host of `localhost` and the port `5005`. The
command syntax is `withDebug --debugger TASK_NAME -- TASK_PARAMETERS`, e.g.
`withDebug --debugger testOnly -- *AtomConstructors*`.
7. Now, when you want to debug something, you can place a breakpoint as usual in
IntelliJ, and then execute your remote debugging configuration. Now, in the
SBT shell, run a command to execute the code you want to debug (e.g.

View File

@ -110,7 +110,7 @@ For example:
```yaml
minimum-launcher-version: 0.0.1
graal-vm-version: 20.1.0
graal-java-version: java11
graal-java-version: 11
```
The `minimum-launcher-version` should be updated whenever a new version of Enso

View File

@ -15,3 +15,5 @@ up as follows:
- [**sbt:**](sbt.md) The build tools that are used for building the project.
- [**Java 11:**](java-11.md) Description of changes related to the Java 11
migration.
- [**Native Image:**](native-image.md) Description of the Native Image build
used for building the launcher native binary.

View File

@ -0,0 +1,116 @@
---
layout: developer-doc
title: Native Image
category: infrastructure
tags: [infrastructure, build, native, native-image]
order: 3
---
# Native Image
[`NativeImage`](../../project/NativeImage.scala) defines a task that is used for
compiling a project into a native binary using Graal's Native Image. It compiles
the project and runs the Native Image tool which builds the image. Currently,
Native Image is used for building the Launcher.
<!-- MarkdownTOC levels="2,3" autolink="true" -->
- [Requirements](#requirements)
- [Native Image Component](#native-image-component)
- [Additional Linux Dependencies](#additional-linux-dependencies)
- [Static Builds](#static-builds)
- [No Cross-Compilation](#no-cross-compilation)
<!-- /MarkdownTOC -->
## Requirements
### Native Image Component
The Native Image component has to be installed within the used GraalVM
distribution. It can be installed by running `gu install native-image`.
### Additional Linux Dependencies
To be able to [link statically](#static-builds) on Linux, we need to link
against a `libc` implementation. The default `glibc` contains
[a bug](https://sourceware.org/bugzilla/show_bug.cgi?id=10652) that would cause
crashes when downloading files form the internet, which is a crucial Launcher
functionality. Instead, [`musl`](https://musl.libc.org/) implementation is
suggested by Graal as an alternative. The sbt task automatically downloads a
bundle containing all requirements for a static build with `musl`. It only
requires a `tar` command to be available to extract the bundle.
Currently, to enable compiling with `musl`, `-H:UseMuslC=/path/to/musl/bundle`
option is added to the `native-image` command. In the future, the command may
use a different option for enabling `musl` or even enable it by default, so the
Native Image task may need updating with a newer Graal release. More information
may be found in
[the Native Image documentation](https://github.com/oracle/graal/blob/master/substratevm/STATIC-IMAGES.md).
## Static Builds
The task is parametrized with `staticOnLinux` parameter which if set to `true`,
will statically link the built binary, to ensure portability between Linux
distributions. For Windows and MacOS, the binaries should generally be portable,
as described in [Launcher Portability](../distribution/launcher.md#portability).
## No Cross-Compilation
As Native Image does not support cross-compilation, the native binaries can only
be built for the platform and architecture that the build is running on.
## Configuration
As the Native Image builds a native binary, certain capabilities, like
[reflection](https://github.com/oracle/graal/blob/master/substratevm/REFLECTION.md),
may be limited. The build system tries to automatically detect some reflective
accesses, but it cannot detect everything. It is possible for the built binary
to fail with the following error:
```
java.lang.InstantiationException: Type `XYZ` can not be instantiated reflectively as it does not have a no-parameter constructor or the no-parameter constructor has not been added explicitly to the native image.`
```
To avoid such issues, additional configuration has to be added to the Native
Image build so that it can include the missing constructors.
This can be done manually by creating a file `reflect-config.json`. The build
task looks for the configuration files in a directory called
`native-image-config` inside the root of the compiled sub-project.
Creating the configuration manually may be tedious and error-prone, so GraalVM
includes
[a tool for assisted configuration](https://github.com/oracle/graal/blob/master/substratevm/CONFIGURE.md).
The link describes in detail how the tool can be used. The gist is, the JVM
version of the application can be run with a special agentlib in order to trace
reflective accesses and save the generated configuration to the provided
directory. To run the tool it is easiest to assemble the application into a JAR
and run it with the following command:
```bash
java \
-agentlib:native-image-agent=config-merge-dir=/path/to/native-image-config \
-jar /path/to/application.jar \
<application arguments>
```
For example, to update settings for the Launcher:
```bash
java -agentlib:native-image-agent=config-merge-dir=engine/launcher/native-image-config -jar launcher.jar <arguments>
```
The command may need to be re-run with different arguments to ensure that all
execution paths that use reflection are covered. The configuration files between
consecutive runs will be merged (a warning may be issued for the first run if
the configuration files did not exist, this is not a problem).
It is possible that different classes are reflectively accessed on different
platforms. In that case it may be necessary to run the agent on multiple
platforms and merge the configs. If the conflicts were conflicting (i.e. some
reflectively accessed classes existed only on one platform), it may be necessary
to maintain separate configs for each platform. Currently in the Launcher this
is not the case - the reflective accesses seem to be platform independent, as
the launcher built with a config created on Linux runs successfully on other
platforms.

View File

@ -26,6 +26,8 @@ compilation. The build configuration is defined in
- [Flatbuffers Generation](#flatbuffers-generation)
- [Ensuring JARs Were Loaded](#ensuring-jars-were-loaded)
- [Debugging Command](#debugging-command)
- [Recompile Parser](#recompile-parser)
- [Native Image](#native-image)
<!-- /MarkdownTOC -->
@ -189,16 +191,5 @@ This task ensures that the `syntax` project is recompiled whenever
## Native Image
[`NativeImage`](../../project/NativeImage.scala) defines a task that can compile
a project into a native binary using Graal's Native Image. It compiles the
project and runs the Native Image tool which builds the image. To be able to use
it, the Native Image component has to be installed within the used GraalVM
distribution. It can be installed by running `gu install native-image`.
The task is parametrized with `staticOnLinux` parameter which if set to `true`,
will statically link the built binary, to ensure portability between Linux
distributions. For Windows and MacOS, the binaries should generally be portable,
as described in [Launcher Portability](../distribution/launcher.md#portability).
As Native Image does not support cross-compilation, the native binaries can only
be built for the platform and architecture that the build is running on.
[`NativeImage`](../../project/NativeImage.scala) task is described at
[Native Image](native-image.md).

View File

@ -66,8 +66,8 @@ Vec => Vector,
Uuid => UUID,
```
*Note: It is assumed, that Enso runs on 64bit platforms. Therefore, `usize` and
`isize` are converted to `Long`.*
> Note: It is assumed, that Enso runs on 64bit platforms. Therefore, `usize` and
> `isize` are converted to `Long`.
##### Structures With Named Fields

View File

@ -0,0 +1,15 @@
[
{
"name": "java.lang.ClassLoader",
"methods": [
{ "name": "getPlatformClassLoader", "parameterTypes": [] },
{ "name": "loadClass", "parameterTypes": ["java.lang.String"] }
]
},
{
"name": "java.lang.ClassNotFoundException"
},
{
"name": "java.lang.NoSuchMethodError"
}
]

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1,88 @@
[
{
"name": "java.lang.String"
},
{
"name": "java.lang.String[]"
},
{
"name": "java.lang.invoke.VarHandle"
},
{
"name": "java.sql.Date"
},
{
"name": "java.sql.Timestamp"
},
{
"name": "org.apache.commons.compress.archivers.zip.AsiExtraField",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.JarMarker",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.ResourceAlignmentExtraField",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.UnicodeCommentExtraField",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.UnicodePathExtraField",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X000A_NTFS",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X0014_X509Certificates",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X0015_CertificateIdForFile",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X0016_CertificateIdForCentralDirectory",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X0017_StrongEncryptionHeader",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X0019_EncryptionRecipientCertificateList",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X5455_ExtendedTimestamp",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.X7875_NewUnix",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.compress.archivers.zip.Zip64ExtendedInformationExtraField",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.logging.LogFactory"
},
{
"name": "org.apache.commons.logging.impl.LogFactoryImpl",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.apache.commons.logging.impl.NoOpLog",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.String"] }]
},
{
"name": "org.apache.commons.logging.impl.WeakHashtable",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
}
]

View File

@ -0,0 +1,8 @@
{
"resources": [
{ "pattern": "\\QMain.enso\\E" },
{ "pattern": "\\Qmozilla/public-suffix-list.txt\\E" },
{ "pattern": "\\Qorg/apache/http/client/version.properties\\E" }
],
"bundles": []
}

View File

@ -1,6 +1,159 @@
package org.enso.launcher
import java.io.File
import java.nio.file.Path
import scala.util.Try
/**
* The default [[internal.Environment]] implementation.
* Gathers some helper methods querying the system environment.
*
* The default implementations should be used most of the time, but it is a
* trait so that the functions can be overridden in tests.
*/
object Environment extends internal.Environment
trait Environment {
/**
* Returns a list of system-dependent plugin extensions.
*
* By default, on Unix plugins should have no extensions. On Windows, `.exe`
* `.bat` and `.cmd` are supported.
*/
def getPluginExtensions: Seq[String] =
if (OS.isWindows)
Seq(".exe", ".bat", ".cmd")
else Seq()
/**
* Returns a list of directories that can be ignored when traversing the
* system PATH looking for plugins.
*
* These could be system directories that should not contain plguins anyway,
* but traversing them would greatly slow down plugin discovery.
*/
def getIgnoredPathDirectories: Seq[Path] =
if (OS.isWindows) Seq(Path.of("C:\\Windows")) else Seq()
/**
* Queries the system environment for the given variable that should
* represent a valid filesystem path.
*
* If it is not defined or is not a valid path, returns None.
*/
def getEnvPath(key: String): Option[Path] = {
def parsePathWithWarning(str: String): Option[Path] = {
val result = safeParsePath(str)
if (result.isEmpty) {
Logger.warn(
s"System variable `$key` was set (to value `$str`), but it did not " +
s"represent a valid path, so it has been ignored."
)
}
result
}
getEnvVar(key).flatMap(parsePathWithWarning)
}
/**
* Returns the system PATH, if available.
*/
def getSystemPath: Seq[Path] =
getEnvVar("PATH")
.map(_.split(File.pathSeparatorChar).toSeq.flatMap(safeParsePath))
.getOrElse(Seq())
/**
* Returns the location of the HOME directory on Unix systems.
*
* Should not be called on Windows, as the concept of HOME should be handled
* differently there.
*/
def getHome: Path = {
if (OS.isWindows)
throw new IllegalStateException(
"fatal error: HOME should not be queried on Windows"
)
else {
getEnvVar("HOME").flatMap(safeParsePath) match {
case Some(path) => path
case None =>
throw new RuntimeException(
"fatal error: HOME environment variable is not defined."
)
}
}
}
/**
* Returns the location of the local application data directory
* (`%LocalAppData%`) on Windows.
*
* Should not be called on platforms other than Windows, as this concept is
* defined in different ways there.
*/
def getLocalAppData: Path = {
if (!OS.isWindows)
throw new IllegalStateException(
"fatal error: LocalAppData should be queried only on Windows"
)
else {
getEnvVar("LocalAppData").flatMap(safeParsePath) match {
case Some(path) => path
case None =>
throw new RuntimeException(
"fatal error: %LocalAppData% environment variable is not defined."
)
}
}
}
/**
* Queries the system environment for the given variable.
*
* If it is not defined or empty, returns None.
*/
def getEnvVar(key: String): Option[String] = {
val value = System.getenv(key)
if (value == null || value == "") None
else Some(value)
}
/**
* Tries to parse a path string and returns Some(path) on success.
*
* We prefer silent failures here (returning None and skipping that entry),
* as we don't want to fail the whole command if the PATH contains some
* unparseable entries.
*/
private def safeParsePath(str: String): Option[Path] =
Try(Path.of(str)).toOption
/**
* Returns the path to the running program.
*
* It is intended for usage in native binary builds, where it returns the
* path to the binary executable that is running. When running on the JVM,
* returns a path to the root of the classpath for the `org.enso.launcher`
* package or a built JAR.
*/
def getPathToRunningExecutable: Path = {
try {
val codeSource =
this.getClass.getProtectionDomain.getCodeSource
Path.of(codeSource.getLocation.toURI).toAbsolutePath
} catch {
case e: Exception =>
throw new IllegalStateException(
"Cannot locate the path of the launched executable",
e
)
}
}
}
/**
* The default [[Environment]] implementation.
*/
object Environment extends Environment

View File

@ -1,15 +1,15 @@
package org.enso.launcher
import java.io.PrintWriter
import java.nio.file.attribute.{PosixFilePermission, PosixFilePermissions}
import java.nio.file.{Files, Path, StandardCopyOption}
import java.util
import org.apache.commons.io.FileUtils
import java.nio.file.{Files, Path}
import org.enso.launcher.internal.OS
import scala.collection.Factory
import scala.jdk.StreamConverters._
import sys.process._
import scala.util.Using
/**
* Gathers some helper methods that are used for interaction with the
@ -25,19 +25,14 @@ object FileSystem {
*/
def listDirectory(dir: Path): Seq[Path] =
if (!Files.exists(dir)) Seq()
else Files.list(dir).toScala(Factory.arrayFactory).toSeq
else Using(Files.list(dir))(_.toScala(Factory.arrayFactory).toSeq).get
/**
* Writes a String to a file at the given `path`, creating the file if
* necessary.
*/
def writeTextFile(path: Path, content: String): Unit = {
val writer = new PrintWriter(path.toFile)
try {
writer.write(content)
} finally {
writer.close()
}
Using(new PrintWriter(path.toFile)) { writer => writer.write(content) }
}
/**
@ -46,12 +41,6 @@ object FileSystem {
def copyDirectory(source: Path, destination: Path): Unit =
FileUtils.copyDirectory(source.toFile, destination.toFile)
/**
* Removes a directory recursively.
*/
def removeDirectory(dir: Path): Unit =
FileUtils.deleteDirectory(dir.toFile)
/**
* Copies a file, overwriting the destination if it already existed.
*/
@ -63,18 +52,127 @@ object FileSystem {
*/
def ensureIsExecutable(file: Path): Unit = {
if (!Files.isExecutable(file)) {
def tryChmod(): Boolean = {
Seq("chmod", "+x", file.toAbsolutePath.toString).! == 0
}
if (OS.isWindows || !tryChmod()) {
if (OS.isWindows) {
Logger.error("Cannot ensure the launcher binary is executable.")
} else {
try {
Files.setPosixFilePermissions(
file.toAbsolutePath,
PosixFilePermissions.fromString("rwxrwxr-x")
)
} catch {
case e: Exception =>
Logger.error(
s"Cannot ensure the launcher binary is executable: $e",
e
)
}
}
}
}
/**
* Allows to write nested paths in a more readable and concise way.
* Parses POSIX file permissions stored in a binary format into a set of Java
* enumerations corresponding to these permissions.
*/
def decodePOSIXPermissions(mode: Int): java.util.Set[PosixFilePermission] = {
val res =
util.EnumSet.noneOf[PosixFilePermission](classOf[PosixFilePermission])
val others = mode & 7
val group = (mode >> 3) & 7
val owner = (mode >> 6) & 7
if ((owner & 4) != 0) {
res.add(PosixFilePermission.OWNER_READ)
}
if ((owner & 2) != 0) {
res.add(PosixFilePermission.OWNER_WRITE)
}
if ((owner & 1) != 0) {
res.add(PosixFilePermission.OWNER_EXECUTE)
}
if ((group & 4) != 0) {
res.add(PosixFilePermission.GROUP_READ)
}
if ((group & 2) != 0) {
res.add(PosixFilePermission.GROUP_WRITE)
}
if ((group & 1) != 0) {
res.add(PosixFilePermission.GROUP_EXECUTE)
}
if ((others & 4) != 0) {
res.add(PosixFilePermission.OTHERS_READ)
}
if ((others & 2) != 0) {
res.add(PosixFilePermission.OTHERS_WRITE)
}
if ((others & 1) != 0) {
res.add(PosixFilePermission.OTHERS_EXECUTE)
}
res
}
/**
* Runs the `action` with a parameter representing a temporary directory
* created for it.
*
* The temporary directory is removed afterwards.
*
* @param prefix prefix to use for the temporary directory name
* @param action action to execute with the directory
* @tparam T type of action's result
* @return result of running the `action`
*/
def withTemporaryDirectory[T](
prefix: String = "enso"
)(action: Path => T): T = {
val dir = Files.createTempDirectory(prefix)
try {
action(dir)
} finally {
removeDirectory(dir)
}
}
/**
* Removes a directory recursively.
*/
def removeDirectory(dir: Path): Unit =
FileUtils.deleteDirectory(dir.toFile)
/**
* Registers the directory to be removed when the program exits normally.
*
* The directory is only removed if it is empty.
*/
def removeEmptyDirectoryOnExit(dir: Path): Unit =
dir.toFile.deleteOnExit()
/**
* Checks if the directory contains any entries.
*/
def isDirectoryEmpty(dir: Path): Boolean = {
def hasEntries =
Using(Files.newDirectoryStream(dir))(_.iterator().hasNext).get
Files.isDirectory(dir) && !hasEntries
}
/**
* Tries to move a directory from `source` to `destination` atomically.
*
* May not be actually atomic.
*/
def atomicMove(source: Path, destination: Path): Unit = {
Files.createDirectories(destination.getParent)
Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE)
}
/**
* Syntax allowing to write nested paths in a more readable and concise way.
*/
implicit class PathSyntax(val path: Path) extends AnyVal {
def /(other: String): Path = path.resolve(other)

View File

@ -5,11 +5,121 @@ import java.nio.file.Path
import org.enso.launcher.installation.DistributionManager
import org.enso.pkg.PackageManager
import org.enso.version.{VersionDescription, VersionDescriptionParameter}
import buildinfo.Info
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.cli.GlobalCLIOptions
import org.enso.launcher.components.DefaultComponentsManager
/**
* Implements launcher commands that are run from CLI and can be affected by
* the global CLI options.
*
* @param cliOptions the global CLI options to use for the commands
*/
case class Launcher(cliOptions: GlobalCLIOptions) {
private lazy val componentsManager = DefaultComponentsManager(cliOptions)
/**
* Prints a list of installed engines.
*/
def listEngines(): Unit = {
for (engine <- componentsManager.listInstalledEngines()) {
println(engine.version.toString)
}
}
/**
* Prints a list of installed runtimes.
*/
def listRuntimes(): Unit = {
for (runtime <- componentsManager.listInstalledRuntimes()) {
val engines = componentsManager.findEnginesUsingRuntime(runtime)
val usedBy = {
val plural =
if (engines.length != 1) "s"
else ""
s"(used by ${engines.length} Enso installation$plural)"
}
println(s"$runtime $usedBy")
}
}
/**
* Prints a summary of installed components and their dependencies.
*/
def listSummary(): Unit = {
for (engine <- componentsManager.listInstalledEngines()) {
val runtime = componentsManager.findRuntime(engine)
val runtimeName = runtime
.map(_.toString)
.getOrElse("no runtime found for this distribution")
println(s"Enso ${engine.version} -> $runtimeName")
}
}
/**
* Installs the specified engine `version`.
*
* Also installs the required runtime if it wasn't already installed.
*/
def installEngine(version: SemVer): Unit = {
val existing = componentsManager.findEngine(version)
if (existing.isDefined) {
Logger.info(s"Engine $version is already installed.")
} else {
componentsManager.findOrInstallEngine(version, complain = false)
}
}
/**
* Installs the latest available version of the engine.
*
* Also installs the required runtime if it wasn't already installed.
*/
def installLatestEngine(): Unit = {
val latest = componentsManager.fetchLatestEngineVersion()
Logger.info(s"Installing Enso engine $latest")
installEngine(latest)
}
/**
* Uninstalls the specified engine `version`.
*
* If a runtime is not used by any engines anymore, it is also removed.
*/
def uninstallEngine(version: SemVer): Unit =
componentsManager.uninstallEngine(version)
def runRepl(
pathHint: Option[Path],
jvmArguments: Seq[(String, String)],
additionalArguments: Seq[String]
): Unit = {
// TODO [RW] this is just a stub, it will be implemented in #976
val path = pathHint.getOrElse(Path.of(".")).toAbsolutePath
val detectedVersion = SemVer(0, 1, 0) // TODO [RW] default version etc.
val engine = componentsManager.findOrInstallEngine(detectedVersion)
val runtime = componentsManager.findOrInstallRuntime(engine)
println(s"Will launch the REPL in $path")
println(s"with $engine with additional arguments $additionalArguments")
println(s"using $runtime with JVM arguments $jvmArguments")
}
}
/**
* Gathers launcher commands which do not depend on the global CLI options.
*/
object Launcher {
private val packageManager = PackageManager.Default
private def workingDirectory: Path = Path.of(".")
/**
* Version of the launcher.
*/
val version: SemVer = SemVer(Info.ensoVersion).getOrElse {
throw new IllegalStateException("Cannot parse the built-in version.")
}
private val packageManager = PackageManager.Default
private val workingDirectory: Path = Path.of(".")
/**
* Creates a new project with the given `name` in the given `path`.
@ -23,7 +133,7 @@ object Launcher {
def newProject(name: String, path: Option[Path]): Unit = {
val actualPath = path.getOrElse(workingDirectory.resolve(name))
packageManager.create(actualPath.toFile, name)
println(s"Project created in $actualPath")
Logger.info(s"Project created in $actualPath")
}
/**
@ -62,5 +172,4 @@ object Launcher {
sys.exit(1)
}
}
}

View File

@ -14,16 +14,26 @@ object Logger {
private val Warning = Level("warn", 3)
private val Error = Level("error", 4)
private val logLevel = Warning
private var logLevel = Info
private def log(level: Level, msg: => String): Unit =
if (level.level >= logLevel.level)
System.err.println(s"[${level.name}] $msg")
if (level.level >= logLevel.level) {
System.out.println(s"[${level.name}] $msg")
System.out.flush()
}
/**
* Logs a debug level message.
*/
def debug(msg: => String): Unit = log(Debug, msg)
/**
* Logs a debug level message and attaches a stack trace.
*/
def debug(msg: => String, throwable: => Throwable): Unit = {
log(Debug, msg)
trace(throwable)
}
/**
* Logs an info level message.
*/
@ -54,4 +64,18 @@ object Logger {
def trace(throwable: => Throwable): Unit =
if (Debug.level >= logLevel.level)
throwable.printStackTrace()
/**
* Runs the provided action with a log level that will allow only for errors
* and returns its result.
*/
def suppressWarnings[R](action: => R): R = {
val oldLevel = logLevel
try {
logLevel = Error
action
} finally {
logLevel = oldLevel
}
}
}

View File

@ -0,0 +1,93 @@
package org.enso.launcher
sealed trait OS {
def name: String
}
object OS {
case object Linux extends OS {
def name: String = "linux"
}
case object MacOS extends OS {
def name: String = "mac"
}
case object Windows extends OS {
def name: String = "windows"
}
/**
* Checks if the application is being run on Windows.
*/
def isWindows: Boolean =
operatingSystem == OS.Windows
def isUNIX: Boolean =
operatingSystem == OS.Linux || operatingSystem == OS.MacOS
/**
* Returns which [[OS]] this program is running on.
*/
lazy val operatingSystem: OS = detectOS
private val ENSO_OPERATING_SYSTEM = "ENSO_OPERATING_SYSTEM"
private def detectOS: OS = {
val knownOS = Seq(Linux, MacOS, Windows)
val overridenName = Option(System.getenv(ENSO_OPERATING_SYSTEM))
overridenName match {
case Some(value) =>
knownOS.find(_.name == value.toLowerCase) match {
case Some(overriden) =>
Logger.debug(
s"OS overriden by $ENSO_OPERATING_SYSTEM to $overriden."
)
return overriden
case None =>
Logger.warn(
s"$ENSO_OPERATING_SYSTEM is set to an unknown value $value, " +
s"ignoring."
)
}
case None =>
}
val name = System.getProperty("os.name").toLowerCase
def nameMatches(os: OS): Boolean = name.contains(os.name)
val possibleOS = knownOS.filter(nameMatches)
if (possibleOS.length == 1) {
possibleOS.head
} else {
Logger.error(
s"Could not determine a supported operating system. Please make sure " +
s"the OS you are running is supported. You can try to manually " +
s"override the operating system detection by setting an environment " +
s"variable `$ENSO_OPERATING_SYSTEM` to one of the possible values " +
s"`linux`, `mac`, `windows` depending on the system that your OS " +
s"most behaves like."
)
throw new IllegalStateException(
"fatal: Could not detect the operating system."
)
}
}
/**
* Name of the architecture that the program is running on.
*
* Currently the Launcher Native Image builds only support amd64
* architecture, so it is hardcoded here. In the future, more architectures
* may be supported. In that case, this will need to be updated to get the
* target architecture from the build settings.
*
* This property should not use `System.getProperty("os.arch")` directly
* because it can return different values for the same architecture (for
* example on some systems `amd64` is called `x86_64`).
*/
val architecture: String = "amd64"
/**
* Wraps the base executable name with an optional platform-dependent
* extension.
*/
def executableName(baseName: String): String =
if (isWindows) baseName + ".exe" else baseName
}

View File

@ -1,7 +0,0 @@
package org.enso.launcher
/**
* Default implementation of the [[internal.PluginManager]] using the default
* [[Environment]].
*/
object PluginManager extends internal.PluginManager(Environment)

View File

@ -0,0 +1,216 @@
package org.enso.launcher.archive
import java.io.BufferedInputStream
import java.nio.file.{Files, Path}
import org.apache.commons.compress.archivers.{ArchiveEntry, ArchiveInputStream}
import org.apache.commons.compress.archivers.tar.{
TarArchiveEntry,
TarArchiveInputStream
}
import org.apache.commons.compress.archivers.zip.{
ZipArchiveEntry,
ZipArchiveInputStream
}
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.apache.commons.io.IOUtils
import org.enso.cli.{TaskProgress, TaskProgressImplementation}
import org.enso.launcher.archive.internal.{ArchiveIterator, BaseRenamer}
import org.enso.launcher.internal.ReadProgress
import org.enso.launcher.{FileSystem, Logger, OS}
import scala.util.{Try, Using}
/**
* Contains utilities related to the extraction of various archive file
* formats.
*
* Currently it supports extracting ZIP files and gzipped TAR archives.
*/
object Archive {
/**
* Extracts the archive at `archivePath` to `destinationDirectory`.
*
* Behaves in the same way as the other [[extractArchive]] overload, but it
* tries to determine the archive format automatically based on the filename.
*
* @param archivePath path to the archive file
* @param destinationDirectory directory into which the archive contents
* will be extracted
* @param renameRootFolder if not None, the root folder of the archive will
* be renamed to the provided name. In such case the
* archive must contain only one root folder.
* @return an instance indicating the progress of extraction
*/
def extractArchive(
archivePath: Path,
destinationDirectory: Path,
renameRootFolder: Option[Path]
): TaskProgress[Unit] = {
val format = ArchiveFormat.detect(archivePath)
format
.map(
extractArchive(archivePath, _, destinationDirectory, renameRootFolder)
)
.getOrElse {
TaskProgress.immediateFailure(
new IllegalArgumentException(
s"Could not detect archive format for " +
s"${archivePath.getFileName}"
)
)
}
}
/**
* Extracts the archive at `archivePath` to `destinationDirectory`.
*
* The extraction is run in a background thread, the function returns
* immediately with a [[TaskProgress]] instance that can be used to track
* extraction progress (for example by displaying a progress bar or just
* waiting for it to complete).
*
* If `renameRootFolder` is provided, the root folder of the archive is
* renamed to the provided value. It is an error to request root folder
* rename for an archive that has more than one file in the root.
*
* @param archivePath path to the archive file
* @param format format of the archive
* @param destinationDirectory directory into which the archive contents
* will be extracted
* @param renameRootFolder if not None, the root folder of the archive will
* be renamed to the provided name. In such case the
* archive must contain only one root folder.
* @return an instance indicating the progress of extraction
*/
def extractArchive(
archivePath: Path,
format: ArchiveFormat,
destinationDirectory: Path,
renameRootFolder: Option[Path]
): TaskProgress[Unit] = {
val taskProgress = new TaskProgressImplementation[Unit]
def runExtraction(): Unit = {
val rewritePath: Path => Path = renameRootFolder match {
case Some(value) => new BaseRenamer(value)
case None => identity[Path]
}
Logger.debug(s"Extracting `$archivePath` to `$destinationDirectory`.")
var missingPermissions: Int = 0
val result = withOpenArchive(archivePath, format) { (archive, progress) =>
for (entry <- ArchiveIterator(archive)) {
if (!archive.canReadEntryData(entry)) {
throw new RuntimeException(
s"Cannot read ${entry.getName} from $archivePath. " +
s"The archive may be corrupted."
)
} else {
val path = parseArchiveEntryName(entry.getName)
val destinationPath =
destinationDirectory.resolve(rewritePath(path))
if (entry.isDirectory) {
Files.createDirectories(destinationPath)
} else {
val parent = destinationPath.getParent
Files.createDirectories(parent)
Using(Files.newOutputStream(destinationPath)) { out =>
IOUtils.copy(archive, out)
}
}
if (OS.isUNIX) {
getMode(entry) match {
case Some(mode) =>
val permissions = FileSystem.decodePOSIXPermissions(mode)
Files.setPosixFilePermissions(destinationPath, permissions)
case None =>
missingPermissions += 1
}
}
}
taskProgress.reportProgress(
progress.alreadyRead(),
progress.total()
)
}
}
if (missingPermissions > 0) {
Logger.warn(
s"Could not find permissions for $missingPermissions files in " +
s"archive `$archivePath`, some files may not have been marked as " +
s"executable."
)
}
taskProgress.setComplete(result)
}
val thread = new Thread(() => runExtraction(), "Extracting-Archive")
thread.start()
taskProgress
}
/**
* Tries to get the POSIX file permissions associated with that `entry`.
*/
private def getMode(entry: ArchiveEntry): Option[Int] =
entry match {
case entry: TarArchiveEntry =>
Some(entry.getMode)
case entry: ZipArchiveEntry =>
if (entry.getPlatform == ZipArchiveEntry.PLATFORM_UNIX)
Some(entry.getUnixMode)
else None
case _ =>
None
}
/**
* Opens the archive at `path` and executes the provided action.
*
* The action is given an [[ArchiveInputStream]] that can be used to read
* from the archive and a [[ReadProgress]] instance which indicates how many
* bytes have already been read at a given moment.
*
* The archive and the internal streams are closed when this function exits.
* The `action` can throw an exception, in which case a failure is returned.
*/
def withOpenArchive[R](path: Path, format: ArchiveFormat)(
action: (ArchiveInputStream, ReadProgress) => R
): Try[R] = {
Using(FileProgressInputStream(path)) { progressInputStream =>
Using(new BufferedInputStream(progressInputStream)) { buffered =>
format match {
case ArchiveFormat.ZIP =>
Using(new ZipArchiveInputStream(buffered))(
action(_, progressInputStream.progress)
).get
case ArchiveFormat.TarGz =>
Using(new GzipCompressorInputStream(buffered)) {
compressorInputStream =>
Using(new TarArchiveInputStream(compressorInputStream))(
action(_, progressInputStream.progress)
).get
}.get
}
}.get
}
}
/**
* Parses the name of the entry inside of the archive into a relative path.
*
* The specification does not restrict entry names to valid paths, but
* empirical testing shows that paths in the archives we need to support are
* usually separated by '/' which is correctly parsed into a system-dependent
* path both on UNIX and Windows platforms.
*/
private def parseArchiveEntryName(name: String): Path =
Path.of(name)
}

View File

@ -0,0 +1,33 @@
package org.enso.launcher.archive
import java.nio.file.Path
/**
* Enumeration of supported archive formats.
*/
sealed trait ArchiveFormat
object ArchiveFormat {
/**
* ZIP file format.
*/
case object ZIP extends ArchiveFormat
/**
* Represents a gzipped TAR archive.
*/
case object TarGz extends ArchiveFormat
/**
* Tries to infer one of the supported archive formats based on the filename.
*
* Returns None if it is unable to determine a known format.
*/
def detect(path: Path): Option[ArchiveFormat] = {
val fileName = path.getFileName.toString
if (fileName.endsWith(".zip")) Some(ZIP)
else if (fileName.endsWith(".tar.gz") || fileName.endsWith(".tgz"))
Some(TarGz)
else None
}
}

View File

@ -0,0 +1,29 @@
package org.enso.launcher.archive
import java.io.FileInputStream
import java.nio.file.{Files, Path}
import org.enso.launcher.internal.ProgressInputStream
/**
* A helper that allows to create a [[ProgressInputStream]] for a file located
* at the given path.
*/
object FileProgressInputStream {
/**
* Creates a [[ProgressInputStream]] reading from the file at `path`.
*
* The read progress depends on how many bytes have been read from the file.
* The total size is determined from the file size as returned by the
* filesystem.
*/
def apply(path: Path): ProgressInputStream = {
val size = Files.size(path)
new ProgressInputStream(
new FileInputStream(path.toFile),
Some(size),
_ => ()
)
}
}

View File

@ -0,0 +1,52 @@
package org.enso.launcher.archive.internal
import org.apache.commons.compress.archivers.{ArchiveEntry, ArchiveInputStream}
/**
* Wraps an [[ArchiveInputStream]] to get an [[Iterator]] which produces
* non-null archive entries.
*/
case class ArchiveIterator(
archiveInputStream: ArchiveInputStream
) extends Iterator[ArchiveEntry] {
/**
* @inheritdoc
*/
override def hasNext: Boolean = {
findNext()
nextEntry.isDefined
}
/**
* @inheritdoc
*/
override def next(): ArchiveEntry = {
findNext()
nextEntry match {
case Some(value) =>
nextEntry = None
value
case None =>
throw new NoSuchElementException("No more entries in the iterator.")
}
}
private var nextEntry: Option[ArchiveEntry] = None
private var finished: Boolean = false
/**
* Tries to move to the next entry. If it is `null` then it means the
* [[archiveInputStream]] has run out of entries.
*/
private def findNext(): Unit = {
if (nextEntry.isEmpty && !finished) {
val nextCandidate = archiveInputStream.getNextEntry
if (nextCandidate == null) {
finished = true
} else {
nextEntry = Some(nextCandidate)
}
}
}
}

View File

@ -0,0 +1,50 @@
package org.enso.launcher.archive.internal
import java.nio.file.Path
/**
* Acts as a function that renames the base of the provided paths to the
* `newBase`.
*
* It changes the first component of the provided paths to the provided
* `newBase`. It is meant to be used for renaming root directories of extracted
* archives. The provided paths should be relative.
*
* It ensures that all paths converted with this function have the same base,
* to avoid merging two base directories.
*/
class BaseRenamer(newBase: Path) extends (Path => Path) {
var lastRoot: Option[Path] = None
/**
* Changes the path by renaming its base component.
*/
override def apply(path: Path): Path = {
if (path.getNameCount < 1) {
throw new RuntimeException(
s"Unexpected archive structure - the file $path cannot " +
s"have its root renamed."
)
}
val oldRoot = path.getName(0)
lastRoot match {
case Some(lastRoot) =>
if (lastRoot != oldRoot) {
throw new RuntimeException(
s"Unexpected archive structure - the archive contains " +
s"more than one file in the root - $lastRoot, $oldRoot"
)
}
case None =>
lastRoot = Some(oldRoot)
}
if (path.getNameCount == 1)
newBase
else {
val remainingParts = path.subpath(1, path.getNameCount)
newBase.resolve(remainingParts)
}
}
}

View File

@ -0,0 +1,16 @@
package org.enso.launcher.cli
import nl.gn0s1s.bump.SemVer
import org.enso.cli.Argument
object Arguments {
/**
* [[Argument]] instance that tries to parse the String as a [[SemVer]]
* version string.
*/
implicit val semverArgument: Argument[SemVer] = (string: String) =>
SemVer(string).toRight(
List(s"`$string` is not a valid semantic version string.")
)
}

View File

@ -0,0 +1,12 @@
package org.enso.launcher.cli
/**
* Gathers settings set by the global CLI options.
*
* @param autoConfirm if this flag is set, the program should not ask the user
* any questions but proceed with the default values, that
* must be explained in the help text for each command
* @param hideProgress if this flag is set, progress bars should not be
* printed
*/
case class GlobalCLIOptions(autoConfirm: Boolean, hideProgress: Boolean)

View File

@ -1,4 +1,4 @@
package org.enso.launcher
package org.enso.launcher.cli
import java.io.IOException
import java.nio.file.{Files, NoSuchFileException, Path}
@ -6,8 +6,29 @@ import java.nio.file.{Files, NoSuchFileException, Path}
import org.enso.cli.Opts
import org.enso.cli.Opts.implicits._
import cats.implicits._
import org.enso.launcher.internal.OS
import org.enso.launcher.OS
/**
* Implements internal options that the launcher may use when running another
* instance of itself.
*
* These options are used primarily to implement workarounds for
* Windows-specific filesystem limitations. They should not be used by the
* users directly, so they are not displayed in the help text.
*
* The implemented workarounds are following:
*
* 1. Remove Old Executable
* On Windows, if an executable is running, its file is locked, so it is
* impossible to remove a file that is running. Thus it is not possible for
* the launcher to directly remove its old executable when it finishes the
* installation. Instead it launches the newly installed launcher, which
* tries to remove it, and terminates itself. Thus the executable lock is
* soon freed and the new launcher is able to remove the old one. The new
* launcher attempts several retries, because it may take some time for the
* executable to be unlocked (especially as on Windows software like an
* antivirus may block it for some more time after terminating).
*/
object InternalOpts {
private val REMOVE_OLD_EXECUTABLE = "internal-remove-old-executable"

View File

@ -1,25 +1,23 @@
package org.enso.launcher
package org.enso.launcher.cli
import java.nio.file.Path
import java.util.UUID
import org.enso.cli.{
Application,
Argument,
CLIOutput,
Command,
Opts,
Subcommand,
TopLevelBehavior
}
import org.enso.cli.Opts.implicits._
import cats.implicits._
import nl.gn0s1s.bump.SemVer
import org.enso.cli.Opts.implicits._
import org.enso.cli._
import org.enso.launcher.cli.Arguments._
import org.enso.launcher.installation.DistributionInstaller.BundleAction
import org.enso.launcher.installation.{
DistributionInstaller,
DistributionManager
}
import org.enso.launcher.installation.DistributionInstaller.BundleAction
import org.enso.launcher.{Launcher, Logger}
/**
* Defines the CLI commands and options for the program and its entry point.
*/
object Main {
private def jsonFlag(showInUsage: Boolean): Opts[Boolean] =
Opts.flag(
@ -28,7 +26,7 @@ object Main {
showInUsage
)
type Config = Unit
type Config = GlobalCLIOptions
private def versionCommand: Command[Config => Unit] =
Command(
@ -63,7 +61,9 @@ object Main {
private def runCommand: Command[Config => Unit] =
Command(
"run",
"Run a project or Enso script.",
"Run an Enso project or script. " +
"If `auto-confirm` is set, this will install missing engines or " +
"runtimes without asking.",
related = Seq("exec", "execute", "build")
) {
val pathOpt = Opts.optionalArgument[Path](
@ -86,7 +86,9 @@ object Main {
private def languageServerCommand: Command[Config => Unit] =
Command(
"language-server",
"Launch the Language Server for a given project.",
"Launch the Language Server for a given project." +
"If `auto-confirm` is set, this will install missing engines or " +
"runtimes without asking.",
related = Seq("server")
) {
val rootId = Opts.parameter[UUID]("root-id", "UUID", "Content root id.")
@ -131,13 +133,17 @@ object Main {
}
private def replCommand: Command[Config => Unit] =
Command("repl", "Launch an Enso REPL.") {
Command(
"repl",
"Launch an Enso REPL." +
"If `auto-confirm` is set, this will install missing engines or " +
"runtimes without asking."
) {
val path = Opts.optionalParameter[Path]("path", "PATH", "Project path.")
val additionalArgs = Opts.additionalArguments()
(path, jvmArgs, additionalArgs) mapN {
(path, jvmArgs, additionalArgs) => (_: Config) =>
println(s"Launch REPL in $path")
println(s"JVM=$jvmArgs, additionalArgs=$additionalArgs")
(path, jvmArgs, additionalArgs) => (config: Config) =>
Launcher(config).runRepl(path, jvmArgs, additionalArgs)
}
}
@ -173,23 +179,24 @@ object Main {
private def installEngineCommand: Subcommand[Config => Unit] =
Subcommand("engine") {
val version = Opts.positionalArgument[String]("VERSION")
version map { version => (_: Config) =>
println(s"Install $version")
val version = Opts.optionalArgument[SemVer](
"VERSION",
"The version to install. If not provided, the latest version is " +
"installed."
)
version map { version => (config: Config) =>
version match {
case Some(value) =>
Launcher(config).installEngine(value)
case None =>
Launcher(config).installLatestEngine()
}
}
}
private def installDistributionCommand: Subcommand[Config => Unit] =
Subcommand("distribution") {
val autoConfirm = Opts.flag(
"auto-confirm",
"Proceeds with installation without asking confirmation questions. " +
"If bundled components are present, this flag will move them by " +
"default, unless overridden by an explicit setting of " +
"`--install-bundle-mode`. On success, the installer will remove " +
"itself to avoid conflicts with the installed launcher executable.",
showInUsage = false
)
implicit val bundleActionParser: Argument[BundleAction] = {
case "move" => DistributionInstaller.MoveBundles.asRight
case "copy" => DistributionInstaller.CopyBundles.asRight
@ -207,14 +214,25 @@ object Main {
"If `auto-confirm` is set, defaults to move.",
showInUsage = false
)
(autoConfirm, bundleAction) mapN {
(autoConfirm, bundleAction) => (_: Config) =>
val doNotRemoveOldLauncher = Opts.flag(
"no-remove-old-launcher",
"If `auto-confirm` is set, the default behavior is to remove the old " +
"launcher after installing the distribution. Setting this flag may " +
"override this behavior to keep the original launcher.",
showInUsage = true
)
(bundleAction, doNotRemoveOldLauncher) mapN {
(bundleAction, doNotRemoveOldLauncher) => (config: Config) =>
new DistributionInstaller(
DistributionManager,
autoConfirm,
if (autoConfirm)
Some(bundleAction.getOrElse(DistributionInstaller.MoveBundles))
else bundleAction
config.autoConfirm,
removeOldLauncher = !doNotRemoveOldLauncher,
bundleActionOption =
if (config.autoConfirm)
Some(bundleAction.getOrElse(DistributionInstaller.MoveBundles))
else bundleAction
).install()
}
}
@ -227,39 +245,55 @@ object Main {
Opts.subcommands(installEngineCommand, installDistributionCommand)
}
private def uninstallCommand: Command[Config => Unit] =
Command("uninstall", "Uninstall an Enso version.") {
val version = Opts.positionalArgument[String]("VERSION")
version map { version => (_: Config) =>
println(s"Uninstall $version")
private def uninstallEngineCommand: Subcommand[Config => Unit] =
Subcommand("engine") {
val version = Opts.positionalArgument[SemVer]("VERSION")
version map { version => (config: Config) =>
Launcher(config).uninstallEngine(version)
}
}
private def uninstallDistributionCommand: Subcommand[Config => Unit] =
Subcommand("distribution") {
Opts.pure(()) map { (_: Unit) => (_: Config) =>
Logger.error("Not implemented yet.")
sys.exit(1)
}
}
private def uninstallCommand: Command[Config => Unit] =
Command(
"uninstall",
"Uninstall an Enso component."
) {
Opts.subcommands(uninstallEngineCommand, uninstallDistributionCommand)
}
private def listCommand: Command[Config => Unit] =
Command("list", "List installed components.") {
sealed trait Components
case object EnsoComponents extends Components
case object RuntimeComponents extends Components
implicit val argumentComponent: Argument[Components] = {
case "enso" => EnsoComponents.asRight
case "engine" => EnsoComponents.asRight
case "runtime" => RuntimeComponents.asRight
case other =>
List(
s"Unknown argument `$other` - expected `enso`, `runtime` " +
s"Unknown argument `$other` - expected `engine`, `runtime` " +
"or no argument to print a general summary."
).asLeft
}
val what = Opts.optionalArgument[Components](
"COMPONENT",
"COMPONENT can be either `enso`, `runtime` or none. " +
"COMPONENT can be either `engine`, `runtime` or none. " +
"If not specified, prints a summary of all installed components."
)
what map { what => (_: Config) =>
what map { what => (config: Config) =>
what match {
case Some(EnsoComponents) => println("List enso")
case Some(RuntimeComponents) => println("List runtime")
case None => println("List summary")
case Some(EnsoComponents) => Launcher(config).listEngines()
case Some(RuntimeComponents) => Launcher(config).listRuntimes()
case None => Launcher(config).listSummary()
}
}
}
@ -294,10 +328,39 @@ object Main {
"Ensures that the launcher is run in portable mode.",
showInUsage = false
)
val autoConfirm = Opts.flag(
"auto-confirm",
"Proceeds without asking confirmation questions. Please see the " +
"options for the specific subcommand you want to run for the defaults " +
"used by this option.",
showInUsage = false
)
val hideProgress = Opts.flag(
"hide-progress",
"Suppresses displaying progress bars for downloads and other long " +
"running actions. May be needed if program output is piped.",
showInUsage = false
)
val internalOpts = InternalOpts.topLevelOptions
(internalOpts, help, version, json, ensurePortable) mapN {
(_, help, version, useJSON, shouldEnsurePortable) => () =>
(
internalOpts,
help,
version,
json,
ensurePortable,
autoConfirm,
hideProgress
) mapN {
(
_,
help,
version,
useJSON,
shouldEnsurePortable,
autoConfirm,
hideProgress
) => () =>
if (shouldEnsurePortable) {
Launcher.ensurePortable()
}
@ -308,7 +371,13 @@ object Main {
} else if (version) {
Launcher.displayVersion(useJSON)
TopLevelBehavior.Halt
} else TopLevelBehavior.Continue(())
} else
TopLevelBehavior.Continue(
GlobalCLIOptions(
autoConfirm = autoConfirm,
hideProgress = hideProgress
)
)
}
}
@ -339,17 +408,29 @@ object Main {
CLIOutput.println(application.renderHelp())
}
private def setup(): Unit =
System.setProperty(
"org.apache.commons.logging.Log",
"org.apache.commons.logging.impl.NoOpLog"
)
def main(args: Array[String]): Unit = {
try {
application.run(args) match {
case Left(errors) =>
CLIOutput.println(errors.mkString("\n"))
sys.exit(1)
case Right(()) =>
setup()
val exitCode =
try {
application.run(args) match {
case Left(errors) =>
CLIOutput.println(errors.mkString("\n"))
1
case Right(()) =>
0
}
} catch {
case e: Exception =>
Logger.error(s"A fatal error has occurred: $e", e)
1
}
} catch {
case e: RuntimeException =>
Logger.error(s"A fatal error has occurred: $e", e)
}
sys.exit(exitCode)
}
}

View File

@ -1,9 +1,9 @@
package org.enso.launcher.internal
package org.enso.launcher.cli
import java.nio.file.{Files, Path}
import org.enso.cli.{CommandHelp, PluginBehaviour, PluginNotFound}
import org.enso.launcher.FileSystem
import org.enso.launcher.{Environment, FileSystem}
import scala.sys.process._
import scala.util.Try
@ -117,3 +117,9 @@ class PluginManager(env: Environment) extends org.enso.cli.PluginManager {
}
}
}
/**
* Default implementation of the [[PluginManager]] using the default
* [[Environment]].
*/
object PluginManager extends PluginManager(Environment)

View File

@ -0,0 +1,58 @@
package org.enso.launcher.components
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.Launcher
/**
* A base class for exceptions caused by [[ComponentsManager]] logic.
*/
sealed class ComponentsException(message: String, cause: Throwable = null)
extends RuntimeException(message, cause) {
/**
* @inheritdoc
*/
override def toString: String = {
val causeMessage = if (cause != null) s" (Caused by: $cause)" else ""
message + causeMessage
}
}
/**
* Represents an installation failure.
*/
case class InstallationError(message: String, cause: Throwable = null)
extends ComponentsException(message, cause) {
/**
* @inheritdoc
*/
override def toString: String = s"Installation failed: $message"
}
/**
* Indicates that a requested engine version requires a newer launcher version.
*/
case class LauncherUpgradeRequiredError(expectedVersion: SemVer)
extends ComponentsException(
s"Minimum launcher version required to use this engine is " +
s"$expectedVersion"
) {
/**
* @inheritdoc
*/
override def toString: String =
s"This launcher version is ${Launcher.version}, but $expectedVersion " +
s"is required to run this engine. If you want to use it, upgrade the " +
s"launcher with `enso upgrade`."
}
/**
* Indicates a component is not recognized.
*/
case class UnrecognizedComponentError(message: String, cause: Throwable = null)
extends ComponentsException(message, cause)
case class ComponentMissingError(message: String, cause: Throwable = null)
extends ComponentsException(message, cause)

View File

@ -0,0 +1,553 @@
package org.enso.launcher.components
import java.nio.file.{Files, Path}
import nl.gn0s1s.bump.SemVer
import org.enso.cli.CLIOutput
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.archive.Archive
import org.enso.launcher.cli.GlobalCLIOptions
import org.enso.launcher.installation.DistributionManager
import org.enso.launcher.releases.{
EngineReleaseProvider,
GraalCEReleaseProvider,
RuntimeReleaseProvider
}
import org.enso.launcher.{FileSystem, Launcher, Logger}
import scala.util.{Failure, Success, Try}
import scala.util.control.NonFatal
/**
* Represents a runtime component.
*
* @param version version of the component
* @param path path to the component
*/
case class Runtime(version: RuntimeVersion, path: Path) {
/**
* @inheritdoc
*/
override def toString: String =
s"GraalVM ${version.graal}-java${version.java}"
}
/**
* Represents an engine component.
*
* @param version version of the component
* @param path path to the component
* @param manifest manifest of the engine release
*/
case class Engine(version: SemVer, path: Path, manifest: Manifest) {
/**
* @inheritdoc
*/
override def toString: String =
s"Enso Engine $version"
}
/**
* Manages runtime and engine components.
*
* Allows to find, list, install and uninstall components.
*
* @param cliOptions options from the CLI setting verbosity of the executed
* actions
* @param distributionManager the [[DistributionManager]] to use
* @param engineReleaseProvider the provider of engine releases
* @param runtimeReleaseProvider the provider of runtime releases
*/
class ComponentsManager(
cliOptions: GlobalCLIOptions,
distributionManager: DistributionManager,
engineReleaseProvider: EngineReleaseProvider,
runtimeReleaseProvider: RuntimeReleaseProvider
) {
private val showProgress = !cliOptions.hideProgress
/**
* Tries to find runtime for the provided engine.
*
* Returns None if the runtime is missing.
*/
def findRuntime(engine: Engine): Option[Runtime] =
findRuntime(engine.manifest.runtimeVersion)
/**
* Finds an installed runtime with the given `version`.
*
* Returns None if that version is not installed.
*/
def findRuntime(version: RuntimeVersion): Option[Runtime] = {
val name = runtimeNameForVersion(version)
val path = distributionManager.paths.runtimes / name
if (Files.exists(path)) {
// TODO [RW] add a sanity check if runtime is in a working state - check
// if it at least has the `java` executable, in #976 do the check and
// throw an exception on failure, in #1052 offer to repair the broken
// installation
loadGraalRuntime(path)
} else None
}
/**
* Returns the runtime needed for the given engine, trying to install it if
* it is missing.
*
* @param engine the engine for which the runtime is requested
* @param complain if set and the runtime is missing, prints a warning and
* asks the user to install the missing runtime (unless
* [[cliOptions.autoConfirm]] is set, in which case it
* installs it without asking)
*/
def findOrInstallRuntime(
engine: Engine,
complain: Boolean = true
): Runtime =
findRuntime(engine) match {
case Some(found) => found
case None =>
def complainAndAsk(): Boolean = {
Logger.warn(
s"Runtime ${engine.manifest.runtimeVersion} required for $engine " +
s"is missing."
)
cliOptions.autoConfirm || CLIOutput.askConfirmation(
"Do you want to install the missing runtime?",
yesDefault = true
)
}
if (!complain || complainAndAsk()) {
installRuntime(engine.manifest.runtimeVersion)
} else {
throw ComponentMissingError(
s"No runtime for engine $engine. Cannot continue."
)
}
}
/**
* Finds an installed engine with the given `version` and reports any errors.
*/
def getEngine(version: SemVer): Try[Engine] = {
val name = engineNameForVersion(version)
val path = distributionManager.paths.engines / name
if (Files.exists(path)) {
// TODO [RW] right now we throw an exception, in the future (#1052) we
// will try recovery
loadEngine(path)
} else Failure(ComponentMissingError(s"Engine $version is not installed."))
}
/**
* Finds an engine with the given `version` or returns None if it is not
* installed.
*
* Any other errors regarding loading the engine are thrown.
*/
def findEngine(version: SemVer): Option[Engine] =
getEngine(version)
.map(Some(_))
.recoverWith {
case _: ComponentMissingError => Success(None)
case e: LauncherUpgradeRequiredError => Failure(e)
case e: Exception =>
Failure(
UnrecognizedComponentError(
s"The engine $version is already installed, but cannot be " +
s"loaded due to $e. Until the launcher gets an auto-repair " +
s"feature, please try running `enso uninstall engine $version` " +
s"followed by `enso install engine $version`.",
e
)
)
}
.get
/**
* Returns the engine needed with the given version, trying to install it if
* it is missing.
*
* @param version the requested engine version
* @param complain if set and the engine is missing, prints a warning and
* asks the user to install the missing engine (unless
* [[cliOptions.autoConfirm]] is set, in which case it
* installs it without asking)
*/
def findOrInstallEngine(version: SemVer, complain: Boolean = true): Engine =
findEngine(version) match {
case Some(found) => found
case None =>
def complainAndAsk(): Boolean = {
Logger.warn(s"Engine $version is missing.")
cliOptions.autoConfirm || CLIOutput.askConfirmation(
"Do you want to install the missing engine?",
yesDefault = true
)
}
if (!complain || complainAndAsk()) {
installEngine(version)
} else {
throw ComponentMissingError(s"No engine $version. Cannot continue.")
}
}
/**
* Finds installed engines that use the given `runtime`.
*/
def findEnginesUsingRuntime(runtime: Runtime): Seq[Engine] =
listInstalledEngines().filter(_.manifest.runtimeVersion == runtime.version)
/**
* Lists all installed runtimes.
*/
def listInstalledRuntimes(): Seq[Runtime] =
FileSystem
.listDirectory(distributionManager.paths.runtimes)
.flatMap(loadGraalRuntime)
/**
* Lists all installed engines.
* @return
*/
def listInstalledEngines(): Seq[Engine] = {
def handleErrorsAsWarnings(path: Path, result: Try[Engine]): Seq[Engine] =
result match {
case Failure(exception) =>
Logger.warn(
s"An engine at $path has been skipped due to the " +
s"following error: $exception"
)
Seq()
case Success(value) => Seq(value)
}
FileSystem
.listDirectory(distributionManager.paths.engines)
.map(path => (path, loadEngine(path)))
.flatMap((handleErrorsAsWarnings _).tupled)
}
/**
* Finds the latest released version of the engine, by asking the
* [[engineReleaseProvider]].
*/
def fetchLatestEngineVersion(): SemVer =
engineReleaseProvider.findLatest().get
/**
* Uninstalls the engine with the provided `version` (if it was installed).
*/
def uninstallEngine(version: SemVer): Unit = {
val engine = getEngine(version).getOrElse {
Logger.warn(s"Enso Engine $version is not installed.")
sys.exit(1)
}
safelyRemoveComponent(engine.path)
Logger.info(s"Uninstalled $engine.")
cleanupRuntimes()
}
/**
* Installs the engine with the provided version.
*
* Used internally by [[findOrInstallEngine]]. Does not check if the engine
* is already installed.
*
* The installation tries as much as possible to be robust - the downloaded
* package is extracted to a temporary directory next to the `engines`
* directory (to ensure that they are on the same filesystem) and is moved to
* the actual directory after doing simple sanity checks.
*/
private def installEngine(version: SemVer): Engine = {
val engineRelease = engineReleaseProvider.getRelease(version).get
FileSystem.withTemporaryDirectory("enso-install") { directory =>
Logger.debug(s"Downloading packages to $directory")
val enginePackage = directory / engineRelease.packageFileName
Logger.info(s"Downloading ${enginePackage.getFileName}")
engineReleaseProvider
.downloadPackage(engineRelease, enginePackage)
.waitForResult(showProgress)
.get
val engineDirectoryName =
engineDirectoryNameForVersion(engineRelease.version)
Logger.info(s"Extracting engine")
Archive
.extractArchive(
enginePackage,
distributionManager.paths.temporaryDirectory,
Some(engineDirectoryName)
)
.waitForResult(showProgress)
.get
val engineTemporaryPath =
distributionManager.paths.temporaryDirectory / engineDirectoryName
def undoTemporaryEngine(): Unit = {
if (Files.exists(engineTemporaryPath)) {
FileSystem.removeDirectory(engineTemporaryPath)
}
}
val temporaryEngine = loadEngine(engineTemporaryPath).getOrElse {
undoTemporaryEngine()
throw InstallationError(
"Cannot load downloaded engine. Installation reverted."
)
}
try {
if (temporaryEngine.manifest != engineRelease.manifest) {
undoTemporaryEngine()
throw InstallationError(
"Manifest of installed engine does not match the published " +
"manifest. This may lead to version inconsistencies; the package " +
"may possibly be corrupted. Reverting installation."
)
}
findOrInstallRuntime(temporaryEngine, complain = false)
val enginePath = distributionManager.paths.engines / engineDirectoryName
FileSystem.atomicMove(engineTemporaryPath, enginePath)
val engine = getEngine(version).getOrElse {
Logger.error(
"fatal: Could not load the installed engine." +
"Reverting the installation."
)
FileSystem.removeDirectory(enginePath)
cleanupRuntimes()
throw InstallationError(
"fatal: Could not load the installed engine"
)
}
Logger.info(s"Installed $engine.")
engine
} catch {
case e: Exception =>
undoTemporaryEngine()
throw e
}
}
}
/**
* Returns name of the directory containing the engine of that version.
*/
private def engineNameForVersion(version: SemVer): String =
version.toString
/**
* Returns name of the directory containing the runtime of that version.
*/
private def runtimeNameForVersion(version: RuntimeVersion): String =
s"graalvm-ce-java${version.java}-${version.graal}"
/**
* Loads the GraalVM runtime definition.
*
* Returns None on failure.
*/
private def loadGraalRuntime(path: Path): Option[Runtime] = {
val name = path.getFileName.toString
for {
version <- parseGraalRuntimeVersionString(name)
} yield Runtime(version, path)
}
/**
* Gets the runtime version from its name.
*/
private def parseGraalRuntimeVersionString(
name: String
): Option[RuntimeVersion] = {
val regex = """graalvm-ce-java(\d+)-(.+)""".r
name match {
case regex(javaVersionString, graalVersionString) =>
SemVer(graalVersionString) match {
case Some(graalVersion) =>
Some(RuntimeVersion(graalVersion, javaVersionString))
case None =>
Logger.warn(
s"Invalid runtime version string `$graalVersionString`."
)
None
}
case _ =>
Logger.warn(
s"Unrecognized runtime name `$name`."
)
None
}
}
/**
* Loads the engine definition.
*
* Returns None on failure.
*/
private def loadEngine(path: Path): Try[Engine] =
for {
version <- parseEngineVersion(path)
manifest <- loadAndCheckEngineManifest(path)
} yield Engine(version, path, manifest)
/**
* Gets the engine version from its path.
*/
private def parseEngineVersion(path: Path): Try[SemVer] = {
val name = path.getFileName.toString
SemVer(name)
.toRight(
UnrecognizedComponentError(
s"Invalid engine component version `$name`."
)
)
.toTry
}
/**
* Loads the engine manifest, checking if that release is compatible with the
* currently running launcher.
*/
private def loadAndCheckEngineManifest(path: Path): Try[Manifest] = {
Manifest.load(path / Manifest.DEFAULT_MANIFEST_NAME).flatMap { manifest =>
if (manifest.minimumLauncherVersion > Launcher.version) {
Failure(LauncherUpgradeRequiredError(manifest.minimumLauncherVersion))
} else Success(manifest)
}
}
/**
* Installs the runtime with the provided version.
*
* Used internally by [[findOrInstallRuntime]]. Does not check if the runtime
* is already installed.
*
* The installation tries as much as possible to be robust - the downloaded
* package is extracted to a temporary directory next to the `runtimes`
* directory (to ensure that they are on the same filesystem) and is moved to
* the actual directory after doing simple sanity checks.
*/
private def installRuntime(runtimeVersion: RuntimeVersion): Runtime =
FileSystem.withTemporaryDirectory("enso-install-runtime") { directory =>
val runtimePackage =
directory / runtimeReleaseProvider.packageFileName(runtimeVersion)
Logger.info(s"Downloading ${runtimePackage.getFileName}")
runtimeReleaseProvider
.downloadPackage(runtimeVersion, runtimePackage)
.waitForResult(showProgress)
.get
val runtimeDirectoryName = graalDirectoryForVersion(runtimeVersion)
Logger.info(s"Extracting runtime")
Archive
.extractArchive(
runtimePackage,
distributionManager.paths.temporaryDirectory,
Some(runtimeDirectoryName)
)
.waitForResult(showProgress)
.get
val runtimeTemporaryPath =
distributionManager.paths.temporaryDirectory / runtimeDirectoryName
def undoTemporaryRuntime(): Unit = {
if (Files.exists(runtimeTemporaryPath)) {
FileSystem.removeDirectory(runtimeTemporaryPath)
}
}
try {
val temporaryRuntime = loadGraalRuntime(runtimeTemporaryPath)
if (temporaryRuntime.isEmpty) {
throw InstallationError(
"Cannot load the installed runtime. The package may have been " +
"corrupted. Reverting installation."
)
}
val runtimePath =
distributionManager.paths.runtimes / runtimeDirectoryName
FileSystem.atomicMove(runtimeTemporaryPath, runtimePath)
val runtime = loadGraalRuntime(runtimePath).getOrElse {
FileSystem.removeDirectory(runtimePath)
throw InstallationError(
"fatal: Cannot load the installed runtime."
)
}
Logger.info(s"Installed $runtime.")
runtime
} catch {
case NonFatal(e) =>
undoTemporaryRuntime()
throw e
}
}
private def engineDirectoryNameForVersion(version: SemVer): Path =
Path.of(version.toString())
private def graalDirectoryForVersion(version: RuntimeVersion): Path =
Path.of(s"graalvm-ce-java${version.java}-${version.graal}")
/**
* Removes runtimes that are not used by any installed engines.
*/
def cleanupRuntimes(): Unit = {
for (runtime <- listInstalledRuntimes()) {
if (findEnginesUsingRuntime(runtime).isEmpty) {
Logger.info(
s"Removing $runtime, because it is not used by any installed Enso " +
s"versions."
)
safelyRemoveComponent(runtime.path)
}
}
}
/**
* Tries to remove a component in a safe way.
*
* The component is moved (hopefully atomically) to temporary directory next
* to the actual components directories and only then it is removed from
* there. As the move should be executed as a single operation, there is no
* risk that the system may leave the component corrupted if the deletion
* were abruptly terminated. The component may be corrupted while being
* removed, but it will already be in the temporary directory, so it will be
* unreachable. The temporary directory is cleaned when doing
* installation-related operations.
*/
private def safelyRemoveComponent(path: Path): Unit = {
val temporaryPath =
distributionManager.paths.temporaryDirectory / path.getFileName
FileSystem.atomicMove(path, temporaryPath)
FileSystem.removeDirectory(temporaryPath)
}
}
/**
* Default [[ComponentsManager]] using the default [[DistributionManager]] and
* release providers.
*
* @param cliOptions options from the CLI setting verbosity of the executed
* actions
*/
case class DefaultComponentsManager(cliOptions: GlobalCLIOptions)
extends ComponentsManager(
cliOptions,
DistributionManager,
EngineReleaseProvider,
GraalCEReleaseProvider
)

View File

@ -0,0 +1,122 @@
package org.enso.launcher.components
import java.io.FileReader
import java.nio.file.Path
import io.circe.{yaml, Decoder, DecodingFailure}
import nl.gn0s1s.bump.SemVer
import scala.util.{Failure, Try, Using}
/**
* Version information identifying the runtime that can be used with an engine
* release.
*
* @param graal version of the GraalVM
* @param java Java version of the GraalVM flavour that should be used
*/
case class RuntimeVersion(graal: SemVer, java: String) {
/**
* @inheritdoc
*/
override def toString: String = s"GraalVM $graal Java $java"
}
/**
* Contains release metadata read from the manifest file that is attached to
* each release.
*
* @param minimumLauncherVersion The minimum required version of the launcher
* that can be used to run this engine release.
* Earlier launcher versions should not be able
* to download this release, but print a message
* that the launcher needs an upgrade.
* @param graalVMVersion the version of the GraalVM runtime that has to be
* used with this engine
* @param graalJavaVersion the java version of that GraalVM runtime
*/
case class Manifest(
minimumLauncherVersion: SemVer,
graalVMVersion: SemVer,
graalJavaVersion: String
) {
/**
* Returns a [[RuntimeVersion]] which encapsulates all version information
* needed to find the runtime required for this release.
*/
def runtimeVersion: RuntimeVersion =
RuntimeVersion(graalVMVersion, graalJavaVersion)
}
object Manifest {
/**
* Defines the name under which the manifest is included in the releases.
*/
val DEFAULT_MANIFEST_NAME = "manifest.yaml"
/**
* Tries to load the manifest at the given path.
*
* Returns None if the manifest could not be opened or could not be parsed.
*/
def load(path: Path): Try[Manifest] =
Using(new FileReader(path.toFile)) { reader =>
yaml.parser
.parse(reader)
.flatMap(_.as[Manifest])
.toTry
.recoverWith { error => Failure(ManifestLoadingError(error)) }
}.flatten
/**
* Parses the manifest from a string containing a YAML definition.
*
* Returns None if the definition cannot be parsed.
*/
def fromYaml(yamlString: String): Try[Manifest] = {
yaml.parser
.parse(yamlString)
.flatMap(_.as[Manifest])
.toTry
.recoverWith { error => Failure(ManifestLoadingError(error)) }
}
case class ManifestLoadingError(cause: Throwable)
extends RuntimeException(s"Could not load the manifest: $cause", cause)
private object Fields {
val minimumLauncherVersion = "minimum-launcher-version"
val graalVMVersion = "graal-vm-version"
val graalJavaVersion = "graal-java-version"
}
implicit private val semverDecoder: Decoder[SemVer] = { json =>
for {
string <- json.as[String]
version <- SemVer(string).toRight(
DecodingFailure(
s"`$string` is not a valid semver version.",
json.history
)
)
} yield version
}
implicit private val decoder: Decoder[Manifest] = { json =>
for {
minimumLauncherVersion <- json.get[SemVer](Fields.minimumLauncherVersion)
graalVMVersion <- json.get[SemVer](Fields.graalVMVersion)
graalJavaVersion <-
json
.get[String](Fields.graalJavaVersion)
.orElse(json.get[Int](Fields.graalJavaVersion).map(_.toString))
} yield Manifest(
minimumLauncherVersion = minimumLauncherVersion,
graalVMVersion = graalVMVersion,
graalJavaVersion = graalJavaVersion
)
}
}

View File

@ -0,0 +1,175 @@
package org.enso.launcher.http
import java.io.FileOutputStream
import java.nio.charset.{Charset, StandardCharsets}
import java.nio.file.Path
import org.apache.commons.io.IOUtils
import org.apache.http.client.config.{CookieSpecs, RequestConfig}
import org.apache.http.client.methods.HttpUriRequest
import org.apache.http.impl.client.HttpClients
import org.apache.http.{Header, HttpResponse}
import org.enso.cli.{TaskProgress, TaskProgressImplementation}
import org.enso.launcher.Logger
import org.enso.launcher.internal.{ProgressInputStream, ReadProgress}
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Using}
/**
* Contains the response contents as a string alongside with the headers
* included in the response.
*
* @param content the response decoded as a string
* @param headers sequence of headers included in the response
*/
case class APIResponse(content: String, headers: Seq[Header])
/**
* Contains utility functions for fetching data using the HTTP(S) protocol.
*/
object HTTPDownload {
/**
* Fetches the `request` and tries to decode is as a [[String]].
*
* The request is executed in a separate thread. A [[TaskProgress]] instance
* is returned immediately which can be used to track progress of the
* download. The result contains the decoded response and included headers.
*
* @param request the request to send
* @param sizeHint an optional hint indicating the expected size of the
* response. It is used if the response does not include
* explicit Content-Length header.
* @param encoding the encoding used to decode the response content into a
* string. By default, UTF-8 is used.
* @return a [[TaskProgress]] that tracks progress of the download and can
* be used to get the final result
*/
def fetchString(
request: HttpUriRequest,
sizeHint: Option[Long] = None,
encoding: Charset = StandardCharsets.UTF_8
): TaskProgress[APIResponse] = {
Logger.debug(s"Fetching ${request.getURI.toASCIIString}")
runRequest(request, asStringResponseHandler(sizeHint, encoding))
}
/**
* Downloads the `request` and saves the response in the file pointed by the
* `destination`.
*
* The request is executed in a separate thread. A [[TaskProgress]] instance
* is returned immediately which can be used to track progress of the
* download. The result is the same path as `destination`. It is available
* only when the download has been completed successfully.
*
* @param request the request to send
* @param sizeHint an optional hint indicating the expected size of the
* response. It is used if the response does not include
* explicit Content-Length header.
* @return a [[TaskProgress]] that tracks progress of the download and can
* be used to wait for the completion of the download.
*/
def download(
request: HttpUriRequest,
destination: Path,
sizeHint: Option[Long] = None
): TaskProgress[Path] = {
Logger.debug(s"Downloading ${request.getURI.toASCIIString} to $destination")
runRequest(request, asFileHandler(destination, sizeHint))
}
/**
* Creates a new thread that will send the provided request and returns a
* [[TaskProgress]] monitoring progress of that request.
*
* @param request the request to send
* @param handler a handler that processes the the received response to
* produce some result. The second argument of the handler is
* a callback that should be used by the handler to report
* progress.
* @tparam A type of the result generated on success
* @return [[TaskProgress]] that monitors request progress and will return
* the response returned by the `handler`
*/
private def runRequest[A](
request: HttpUriRequest,
handler: (HttpResponse, ReadProgress => Unit) => A
): TaskProgress[A] = {
val task = new TaskProgressImplementation[A]
def update(progress: ReadProgress): Unit = {
task.reportProgress(progress.alreadyRead(), progress.total())
}
def run(): Unit = {
try {
val client = buildClient()
Using(client.execute(request)) { response =>
val result = handler(response, update)
task.setComplete(Success(result))
}
} catch {
case NonFatal(e) => task.setComplete(Failure(e))
}
}
val thread = new Thread(() => run(), "HTTP-Runner")
thread.start()
task
}
private def buildClient() =
HttpClients
.custom()
.setDefaultRequestConfig(
RequestConfig.custom().setCookieSpec(CookieSpecs.STANDARD).build()
)
.build()
/**
* Creates a handler that tries to decode the result content as a [[String]].
*/
private def asStringResponseHandler(
sizeHint: Option[Long],
charset: Charset
)(response: HttpResponse, update: ReadProgress => Unit): APIResponse =
Using(streamOfResponse(response, update, sizeHint)) { in =>
val bytes = in.readAllBytes()
val content = new String(bytes, charset)
APIResponse(content, response.getAllHeaders.toIndexedSeq)
}.get
/**
* Creates a handler that tries to save the result content into a file.
*/
private def asFileHandler(
path: Path,
sizeHint: Option[Long]
)(response: HttpResponse, update: ReadProgress => Unit): Path =
Using(streamOfResponse(response, update, sizeHint)) { in =>
Using(new FileOutputStream(path.toFile)) { out =>
IOUtils.copy(in, out)
path
}.get
}.get
/**
* Returns a progress-monitored stream that can be used to read the response
* content.
*/
private def streamOfResponse(
response: HttpResponse,
update: ReadProgress => Unit,
sizeHint: Option[Long]
): ProgressInputStream = {
val entity = response.getEntity
val size = {
val len = entity.getContentLength
if (len < 0) None
else Some(len)
}
new ProgressInputStream(entity.getContent, size.orElse(sizeHint), update)
}
}

View File

@ -0,0 +1,55 @@
package org.enso.launcher.http
import java.net.URI
import org.apache.http.client.methods.{HttpUriRequest, RequestBuilder}
/**
* A simple immutable builder for HTTP requests.
*
* It contains very limited functionality that is needed by the APIs used in
* the launcher. It can be easily extended if necessary.
*/
case class HTTPRequestBuilder private (
uri: URI,
headers: Vector[(String, String)]
) {
/**
* Builds a GET request with the specified settings.
*/
def GET: HttpUriRequest = build(RequestBuilder.get())
/**
* Adds an additional header that will be included in the request.
*
* @param name name of the header
* @param value the header value
*/
def addHeader(name: String, value: String): HTTPRequestBuilder =
copy(headers = headers.appended((name, value)))
private def build(requestBuilder: RequestBuilder): HttpUriRequest = {
val withUri = requestBuilder.setUri(uri)
val withHeaders = headers.foldLeft(withUri)((builder, header) =>
builder.addHeader(header._1, header._2)
)
withHeaders.build()
}
}
object HTTPRequestBuilder {
/**
* Creates a request builder that will send the request for the given URI.
*/
def fromURI(uri: URI): HTTPRequestBuilder =
new HTTPRequestBuilder(uri, Vector.empty)
/**
* Tries to parse the URI provided as a [[String]] and returns a request
* builder that will send the request to the given `uri`.
*/
def fromURIString(uri: String): HTTPRequestBuilder =
fromURI(new URI(uri))
}

View File

@ -0,0 +1,79 @@
package org.enso.launcher.http
import java.net.URI
import org.apache.http.client.utils.{URIBuilder => ApacheURIBuilder}
/**
* A simple immutable builder for URIs based on URLs.
*
* It contains very limited functionality that is needed by the APIs used in
* the launcher. It can be easily extended if necessary.
*
* As all APIs we use support HTTPS, it does not allow to create a non-HTTPS
* URL.
*/
case class URIBuilder private (
host: String,
segments: Vector[String],
queries: Vector[(String, String)]
) {
/**
* Resolve a segment over the path in the URI.
*
* For example adding `bar` to `http://example.com/foo` will result in
* `http://example.com/foo/bar`.
*/
def addPathSegment(segment: String): URIBuilder =
copy(segments = segments.appended(segment))
/**
* Add a query parameter to the URI.
*
* The query is appended at the end.
*/
def addQuery(key: String, value: String): URIBuilder =
copy(queries = queries.appended((key, value)))
/**
* Build the URI represented by this builder.
*/
def build(): URI = {
val base = (new ApacheURIBuilder)
.setScheme("https")
.setHost(host)
.setPathSegments(segments: _*)
val withQueries = queries.foldLeft(base)((builder, query) =>
builder.addParameter(query._1, query._2)
)
withQueries.build()
}
}
object URIBuilder {
/**
* Create a builder basing from a hostname.
*
* A builder created by `fromHost("example.com")` represents
* `https://example.com/`.
*/
def fromHost(host: String): URIBuilder =
new URIBuilder(
host = host,
segments = Vector.empty,
queries = Vector.empty
)
/**
* A simple DSL for the URIBuilder.
*/
implicit class URIBuilderSyntax(builder: URIBuilder) {
def /(part: String): URIBuilder =
builder.addPathSegment(part)
def ?(query: (String, String)): URIBuilder =
builder.addQuery(query._1, query._2)
}
}

View File

@ -4,14 +4,8 @@ import java.nio.file.Files
import org.enso.cli.CLIOutput
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.internal.OS
import org.enso.launcher.internal.installation.DistributionManager
import org.enso.launcher.{
FileSystem,
GlobalConfigurationManager,
InternalOpts,
Logger
}
import org.enso.launcher.cli.InternalOpts
import org.enso.launcher.{FileSystem, GlobalConfigurationManager, Logger, OS}
import scala.util.control.NonFatal
@ -23,6 +17,9 @@ import scala.util.control.NonFatal
* location
* @param autoConfirm if set to true, the installer will use defaults instead
* of asking questions
* @param removeOldLauncher if `autoConfirm` is set to true, specifies whether
* the old launcher should be removed after successful
* installation
* @param bundleActionOption defines how bundled components are added, if
* [[autoConfirm]] is set, defaults to a move,
* otherwise explicitly asks the user
@ -30,6 +27,7 @@ import scala.util.control.NonFatal
class DistributionInstaller(
manager: DistributionManager,
autoConfirm: Boolean,
removeOldLauncher: Boolean,
bundleActionOption: Option[DistributionInstaller.BundleAction]
) {
final private val installed = manager.LocallyInstalledDirectories
@ -335,7 +333,11 @@ class DistributionInstaller(
)
if (installedLauncherPath != currentLauncherPath) {
if (autoConfirm || askForRemoval()) {
def shouldRemove(): Boolean =
if (autoConfirm) removeOldLauncher
else askForRemoval()
if (shouldRemove()) {
if (OS.isWindows) {
InternalOpts
.runWithNewLauncher(installedLauncherPath)
@ -371,22 +373,83 @@ object DistributionInstaller {
def delete: Boolean
}
/**
* The bundle action that will copy the bundles and keep the ones in the
* original location too.
*/
case object CopyBundles extends BundleAction {
override def key: String = "c"
/**
* @inheritdoc
*/
override def key: String = "c"
/**
* @inheritdoc
*/
override def description: String = "copy bundles"
def copy: Boolean = true
def delete: Boolean = false
/**
* @inheritdoc
*/
def copy: Boolean = true
/**
* @inheritdoc
*/
def delete: Boolean = false
}
/**
* The bundle action that will copy the bundles and remove the ones at the
* original location on success.
*/
case object MoveBundles extends BundleAction {
override def key: String = "m"
/**
* @inheritdoc
*/
override def key: String = "m"
/**
* @inheritdoc
*/
override def description: String = "move bundles"
def copy: Boolean = true
def delete: Boolean = true
/**
* @inheritdoc
*/
def copy: Boolean = true
/**
* @inheritdoc
*/
def delete: Boolean = true
}
/**
* The bundle action that ignores the bundles.
*/
case object IgnoreBundles extends BundleAction {
override def key: String = "i"
/**
* @inheritdoc
*/
override def key: String = "i"
/**
* @inheritdoc
*/
override def description: String = "ignore bundles"
def copy: Boolean = false
def delete: Boolean = false
/**
* @inheritdoc
*/
def copy: Boolean = false
/**
* @inheritdoc
*/
def delete: Boolean = false
}
}

View File

@ -1,7 +1,271 @@
package org.enso.launcher.installation
import org.enso.launcher.Environment
import org.enso.launcher.internal.installation.DistributionManager
import java.nio.file.{Files, Path}
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.{Environment, FileSystem, Logger, OS}
import scala.util.Try
/**
* Gathers filesystem paths used by the launcher.
*
* @param dataRoot the root of the data directory; for a portable distribution
* this is the root of the distribution, for a locally
* installed distribution, it corresponds to `ENSO_DATA_DIR`
* @param runtimes location of runtimes, corresponding to `runtime` directory
* @param engines location of engine versions, corresponding to `dist`
* directory
* @param config location of configuration
* @param tmp a directory for storing temporary files that is located on the
* same filesystem as `runtimes` and `engines`, used during
* installation to decrease the possibility of getting a broken
* installation if the installation process has been abruptly
* terminated. The directory is created on demand (when its path is
* requested for the first time) and is removed if the application
* exits normally (as long as it is empty, but normal termination of
* the installation process should ensure that).
*/
case class DistributionPaths(
dataRoot: Path,
runtimes: Path,
engines: Path,
config: Path,
private val tmp: Path
) {
/**
* @inheritdoc
*/
override def toString: String =
s"""DistributionPaths(
| dataRoot = $dataRoot,
| runtimes = $runtimes,
| engines = $engines,
| config = $config,
| tmp = $tmp
|)""".stripMargin
lazy val temporaryDirectory: Path = {
runCleanup()
tmp
}
private def runCleanup(): Unit = {
if (Files.exists(tmp)) {
if (!FileSystem.isDirectoryEmpty(tmp)) {
Logger.info("Cleaning up temporary files from a previous installation.")
}
FileSystem.removeDirectory(tmp)
Files.createDirectories(tmp)
FileSystem.removeEmptyDirectoryOnExit(tmp)
}
}
}
/**
* A helper class that detects if a portable or installed distribution is run
* and encapsulates management of paths to components of the distribution.
*/
class DistributionManager(val env: Environment) {
/**
* Specifies whether the launcher has been run as a portable distribution or
* it is a locally installed distribution.
*/
lazy val isRunningPortable: Boolean = {
val portable = detectPortable()
Logger.debug(s"Launcher portable mode = $portable")
if (portable && LocallyInstalledDirectories.installedDistributionExists) {
val installedRoot = LocallyInstalledDirectories.dataDirectory
val installedBinary = LocallyInstalledDirectories.binaryExecutable
Logger.debug(
s"The launcher is run in portable mode, but an installed distribution" +
s" is available at $installedRoot."
)
if (Files.exists(installedBinary)) {
if (installedBinary == env.getPathToRunningExecutable) {
Logger.debug(
"That distribution seems to be corresponding to this launcher " +
"executable, that is running in portable mode."
)
} else {
Logger.debug(
s"However, that installed distribution most likely uses another " +
s"launcher executable, located at $installedBinary."
)
}
}
}
portable
}
/**
* Determines paths that should be used by the launcher.
*/
lazy val paths: DistributionPaths = {
val paths = detectPaths()
Logger.debug(s"Detected paths are: $paths")
paths
}
private val PORTABLE_MARK_FILENAME = ".enso.portable"
val ENGINES_DIRECTORY = "dist"
val RUNTIMES_DIRECTORY = "runtime"
val CONFIG_DIRECTORY = "config"
val BIN_DIRECTORY = "bin"
private val TMP_DIRECTORY = "tmp"
private def detectPortable(): Boolean = Files.exists(portableMarkFilePath)
private def possiblePortableRoot: Path =
env.getPathToRunningExecutable.getParent.getParent
private def portableMarkFilePath: Path =
possiblePortableRoot / PORTABLE_MARK_FILENAME
private def detectPaths(): DistributionPaths =
if (isRunningPortable) {
val root = env.getPathToRunningExecutable.getParent.getParent
DistributionPaths(
dataRoot = root,
runtimes = root / RUNTIMES_DIRECTORY,
engines = root / ENGINES_DIRECTORY,
config = root / CONFIG_DIRECTORY,
tmp = root / TMP_DIRECTORY
)
} else {
val dataRoot = LocallyInstalledDirectories.dataDirectory
val configRoot = LocallyInstalledDirectories.configDirectory
DistributionPaths(
dataRoot = dataRoot,
runtimes = dataRoot / RUNTIMES_DIRECTORY,
engines = dataRoot / ENGINES_DIRECTORY,
config = configRoot,
tmp = dataRoot / TMP_DIRECTORY
)
}
/**
* A helper for managing directories of the non-portable installation.
*
* It returns paths of the non-portable installation even if the launcher is
* running in portable mode, so that this helper can be used by the installer
* to determine destination for installed files.
*/
object LocallyInstalledDirectories {
val ENSO_DATA_DIRECTORY = "ENSO_DATA_DIRECTORY"
val ENSO_CONFIG_DIRECTORY = "ENSO_CONFIG_DIRECTORY"
val ENSO_BIN_DIRECTORY = "ENSO_BIN_DIRECTORY"
private val XDG_DATA_DIRECTORY = "XDG_DATA_DIRECTORY"
private val XDG_CONFIG_DIRECTORY = "XDG_CONFIG_DIRECTORY"
private val XDG_BIN_DIRECTORY = "XDG_BIN_DIRECTORY"
private val LINUX_ENSO_DIRECTORY = "enso"
private val MACOS_ENSO_DIRECTORY = "org.enso"
private val WINDOWS_ENSO_DIRECTORY = "enso"
/**
* Data directory for an installed distribution.
*/
def dataDirectory: Path =
env
.getEnvPath(ENSO_DATA_DIRECTORY)
.getOrElse {
OS.operatingSystem match {
case OS.Linux =>
env
.getEnvPath(XDG_DATA_DIRECTORY)
.map(_ / LINUX_ENSO_DIRECTORY)
.getOrElse {
env.getHome / ".local" / "share" / LINUX_ENSO_DIRECTORY
}
case OS.MacOS =>
env.getHome / "Library" / "Application Support" / MACOS_ENSO_DIRECTORY
case OS.Windows =>
env.getLocalAppData / WINDOWS_ENSO_DIRECTORY
}
}
.toAbsolutePath
/**
* Config directory for an installed distribution.
*/
def configDirectory: Path =
env
.getEnvPath(ENSO_CONFIG_DIRECTORY)
.getOrElse {
OS.operatingSystem match {
case OS.Linux =>
env
.getEnvPath(XDG_CONFIG_DIRECTORY)
.map(_ / LINUX_ENSO_DIRECTORY)
.getOrElse {
env.getHome / ".config" / LINUX_ENSO_DIRECTORY
}
case OS.MacOS =>
env.getHome / "Library" / "Preferences" / MACOS_ENSO_DIRECTORY
case OS.Windows =>
env.getLocalAppData / WINDOWS_ENSO_DIRECTORY / CONFIG_DIRECTORY
}
}
.toAbsolutePath
/**
* The directory where the launcher binary will be placed for an installed
* distribution.
*/
def binDirectory: Path =
env
.getEnvPath(ENSO_BIN_DIRECTORY)
.getOrElse {
OS.operatingSystem match {
case OS.Linux =>
env
.getEnvPath(XDG_BIN_DIRECTORY)
.getOrElse {
env.getHome / ".local" / "bin"
}
case OS.MacOS =>
env.getHome / ".local" / "bin"
case OS.Windows =>
env.getLocalAppData / WINDOWS_ENSO_DIRECTORY / BIN_DIRECTORY
}
}
.toAbsolutePath
private def executableName: String =
OS.executableName("enso")
/**
* The path where the binary executable of the installed distribution
* should be placed by default.
*/
def binaryExecutable: Path = {
binDirectory / executableName
}
/**
* The safe version of [[dataDirectory]] which returns None if the
* directory cannot be determined.
*
* Should be used in places where not being able to determine the data
* directory is not a fatal error.
*/
def safeDataDirectory: Option[Path] =
Try(dataDirectory).toOption
/**
* Determines whether a locally installed distribution exists on the
* system.
*/
def installedDistributionExists: Boolean =
safeDataDirectory.exists(Files.isDirectory(_))
}
}
/**
* A default DistributionManager using the default environment.

View File

@ -1,156 +0,0 @@
package org.enso.launcher.internal
import java.io.File
import java.nio.file.Path
import org.enso.launcher.Logger
import scala.util.Try
/**
* Gathers some helper methods querying the system environment.
*
* The default implementations should be used most of the time, but it is a
* trait so that the functions can be overridden in tests.
*/
trait Environment {
/**
* Returns a list of system-dependent plugin extensions.
*
* By default, on Unix plugins should have no extensions. On Windows, `.exe`
* `.bat` and `.cmd` are supported.
*/
def getPluginExtensions: Seq[String] =
if (OS.isWindows)
Seq(".exe", ".bat", ".cmd")
else Seq()
/**
* Returns a list of directories that can be ignored when traversing the
* system PATH looking for plugins.
*
* These could be system directories that should not contain plguins anyway,
* but traversing them would greatly slow down plugin discovery.
*/
def getIgnoredPathDirectories: Seq[Path] =
if (OS.isWindows) Seq(Path.of("C:\\Windows")) else Seq()
/**
* Queries the system environment for the given variable that should
* represent a valid filesystem path.
*
* If it is not defined or is not a valid path, returns None.
*/
def getEnvPath(key: String): Option[Path] = {
def parsePathWithWarning(str: String): Option[Path] = {
val result = safeParsePath(str)
if (result.isEmpty) {
Logger.warn(
s"System variable `$key` was set (to value `$str`), but it did not " +
s"represent a valid path, so it has been ignored."
)
}
result
}
getEnvVar(key).flatMap(parsePathWithWarning)
}
/**
* Returns the system PATH, if available.
*/
def getSystemPath: Seq[Path] =
getEnvVar("PATH")
.map(_.split(File.pathSeparatorChar).toSeq.flatMap(safeParsePath))
.getOrElse(Seq())
/**
* Returns the location of the HOME directory on Unix systems.
*
* Should not be called on Windows, as the concept of HOME should be handled
* differently there.
*/
def getHome: Path = {
if (OS.isWindows)
throw new IllegalStateException(
"fatal error: HOME should not be queried on Windows"
)
else {
getEnvVar("HOME").flatMap(safeParsePath) match {
case Some(path) => path
case None =>
throw new RuntimeException(
"fatal error: HOME environment variable is not defined."
)
}
}
}
/**
* Returns the location of the local application data directory
* (`%LocalAppData%`) on Windows.
*
* Should not be called on platforms other than Windows, as this concept is
* defined in different ways there.
*/
def getLocalAppData: Path = {
if (!OS.isWindows)
throw new IllegalStateException(
"fatal error: LocalAppData should be queried only on Windows"
)
else {
getEnvVar("LocalAppData").flatMap(safeParsePath) match {
case Some(path) => path
case None =>
throw new RuntimeException(
"fatal error: %LocalAppData% environment variable is not defined."
)
}
}
}
/**
* Queries the system environment for the given variable.
*
* If it is not defined or empty, returns None.
*/
def getEnvVar(key: String): Option[String] = {
val value = System.getenv(key)
if (value == null || value == "") None
else Some(value)
}
/**
* Tries to parse a path string and returns Some(path) on success.
*
* We prefer silent failures here (returning None and skipping that entry),
* as we don't want to fail the whole command if the PATH contains some
* unparseable entries.
*/
private def safeParsePath(str: String): Option[Path] =
Try(Path.of(str)).toOption
/**
* Returns the path to the running program.
*
* It is intended for usage in native binary builds, where it returns the
* path to the binary executable that is running. When running on the JVM,
* returns a path to the root of the classpath for the `org.enso.launcher`
* package or a built JAR.
*/
def getPathToRunningExecutable: Path = {
try {
val codeSource =
this.getClass.getProtectionDomain.getCodeSource
Path.of(codeSource.getLocation.toURI).toAbsolutePath
} catch {
case e: Exception =>
throw new IllegalStateException(
"Cannot locate the path of the launched executable",
e
)
}
}
}

View File

@ -1,58 +0,0 @@
package org.enso.launcher.internal
import org.enso.launcher.Logger
sealed trait OS
object OS {
case object Linux extends OS
case object MacOS extends OS
case object Windows extends OS
/**
* The special case for when the OS is not detected.
*
* It is treated as a UNIX system.
*/
case object Unknown extends OS
/**
* Checks if the application is being run on Windows.
*/
def isWindows: Boolean =
operatingSystem == OS.Windows
/**
* Returns which [[OS]] this program is running on.
*/
lazy val operatingSystem: OS = detectOS
private def detectOS: OS = {
val name = System.getProperty("os.name").toLowerCase
def nameMatches(os: OS): Boolean =
os match {
case Linux => name.contains("linux")
case MacOS => name.contains("mac")
case Windows => name.contains("windows")
case Unknown => false
}
val knownOS = Seq(Linux, MacOS, Windows)
val possibleOS = knownOS.filter(nameMatches)
if (possibleOS.length == 1) {
possibleOS.head
} else {
Logger.warn(
s"Could not determine a supported operating system. Assuming a UNIX " +
s"system. Things may not work correctly."
)
Unknown
}
}
/**
* Wraps the base executable name with an optional platform-dependent
* extension.
*/
def executableName(baseName: String): String =
if (isWindows) baseName + ".exe" else baseName
}

View File

@ -0,0 +1,100 @@
package org.enso.launcher.internal
import java.io.InputStream
/**
* Represents a *mutable* progress status.
*/
trait ReadProgress {
/**
* Specifies how many units have already been read.
*
* Querying this property over time may give different results as the task
* progresses.
*/
def alreadyRead(): Long
/**
* Specifies how many units in total are expected, if known.
*/
def total(): Option[Long]
}
/**
* A wrapper for an [[InputStream]] that tracks the read progresss.
*
* @param in the base stream to wrap
* @param totalSize total amount of bytes that are expected to be available in
* that stream
* @param updated a callback that is called whenever progress is made
*/
class ProgressInputStream(
in: InputStream,
totalSize: Option[Long],
updated: ReadProgress => Unit
) extends InputStream {
private var bytesRead: Long = 0
private val readProgress = new ReadProgress {
override def alreadyRead(): Long = bytesRead
override def total(): Option[Long] = totalSize
}
/**
* Returns the [[ReadProgress]] instance that can be queried to check how
* many bytes have been read already.
*/
def progress: ReadProgress = readProgress
/**
* @inheritdoc
*/
override def available: Int =
in.available()
/**
* @inheritdoc
*/
override def read: Int = {
bytesRead += 1
updated(readProgress)
in.read()
}
/**
* @inheritdoc
*/
override def read(b: Array[Byte]): Int = {
val bytes = in.read(b)
bytesRead += bytes
updated(readProgress)
bytes
}
/**
* @inheritdoc
*/
override def read(b: Array[Byte], off: Int, len: Int): Int = {
val bytes = in.read(b, off, len)
bytesRead += bytes
updated(readProgress)
bytes
}
/**
* @inheritdoc
*/
override def skip(n: Long): Long = {
val skipped = in.skip(n)
bytesRead += skipped
updated(readProgress)
skipped
}
/**
* @inheritdoc
*/
override def close(): Unit =
in.close()
}

View File

@ -1,253 +0,0 @@
package org.enso.launcher.internal.installation
import java.nio.file.{Files, Path}
import org.enso.launcher.Logger
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.internal.{Environment, OS}
import scala.util.Try
/**
* Gathers filesystem paths used by the launcher.
*
* @param runtimes location of runtimes, corresponding to `runtime` directory
* @param engines location of engine versions, corresponding to `dist`
* directory
* @param config location of configuration
* @param dataRoot the root of the data directory; for a portable distribution
* this is the root of the distribution, for a locally
* installed distribution, it corresponds to `ENSO_DATA_DIR`
*/
case class DistributionPaths(
dataRoot: Path,
runtimes: Path,
engines: Path,
config: Path
) {
override def toString: String =
s"""DistributionPaths(
| dataRoot = $dataRoot,
| runtimes = $runtimes,
| engines = $engines,
| config = $config
|)""".stripMargin
}
/**
* A helper class that detects if a portable or installed distribution is run
* and encapsulates management of paths to components of the distribution.
*/
class DistributionManager(val env: Environment) {
/**
* Specifies whether the launcher has been run as a portable distribution or
* it is a locally installed distribution.
*/
lazy val isRunningPortable: Boolean = {
val portable = detectPortable()
Logger.debug(s"Launcher portable mode = $portable")
if (portable && LocallyInstalledDirectories.installedDistributionExists) {
val installedRoot = LocallyInstalledDirectories.dataDirectory
val installedBinary = LocallyInstalledDirectories.binaryExecutable
Logger.debug(
s"The launcher is run in portable mode, but an installed distribution" +
s" is available at $installedRoot."
)
if (Files.exists(installedBinary)) {
if (installedBinary == env.getPathToRunningExecutable) {
Logger.debug(
"That distribution seems to be corresponding to this launcher " +
"executable, that is running in portable mode."
)
} else {
Logger.debug(
s"However, that installed distribution most likely uses another " +
s"launcher executable, located at $installedBinary."
)
}
}
}
portable
}
/**
* Determines paths that should be used by the launcher.
*/
lazy val paths: DistributionPaths = {
val paths = detectPaths()
Logger.debug(s"Detected paths are: $paths")
paths
}
private val PORTABLE_MARK_FILENAME = ".enso.portable"
val ENGINES_DIRECTORY = "dist"
val RUNTIMES_DIRECTORY = "runtime"
val CONFIG_DIRECTORY = "config"
val BIN_DIRECTORY = "bin"
private def detectPortable(): Boolean = Files.exists(portableMarkFilePath)
private def possiblePortableRoot: Path =
env.getPathToRunningExecutable.getParent.getParent
private def portableMarkFilePath: Path =
possiblePortableRoot / PORTABLE_MARK_FILENAME
private def detectPaths(): DistributionPaths =
if (isRunningPortable) {
val root = env.getPathToRunningExecutable.getParent.getParent
DistributionPaths(
dataRoot = root,
runtimes = root / RUNTIMES_DIRECTORY,
engines = root / ENGINES_DIRECTORY,
config = root / CONFIG_DIRECTORY
)
} else {
val dataRoot = LocallyInstalledDirectories.dataDirectory
val configRoot = LocallyInstalledDirectories.configDirectory
DistributionPaths(
dataRoot = dataRoot,
runtimes = dataRoot / RUNTIMES_DIRECTORY,
engines = dataRoot / ENGINES_DIRECTORY,
config = configRoot
)
}
/**
* A helper for managing directories of the local installation.
*
* It returns paths of the local installation even if the launcher is running
* in portable mode, so that this helper can be used by the installer to
* determine destination for installed files.
*/
object LocallyInstalledDirectories {
val ENSO_DATA_DIRECTORY = "ENSO_DATA_DIRECTORY"
val ENSO_CONFIG_DIRECTORY = "ENSO_CONFIG_DIRECTORY"
val ENSO_BIN_DIRECTORY = "ENSO_BIN_DIRECTORY"
private val XDG_DATA_DIRECTORY = "XDG_DATA_DIRECTORY"
private val XDG_CONFIG_DIRECTORY = "XDG_CONFIG_DIRECTORY"
private val XDG_BIN_DIRECTORY = "XDG_BIN_DIRECTORY"
private val LINUX_ENSO_DIRECTORY = "enso"
private val MACOS_ENSO_DIRECTORY = "org.enso"
private val WINDOWS_ENSO_DIRECTORY = "enso"
/**
* Data directory for an installed distribution.
*/
def dataDirectory: Path =
env
.getEnvPath(ENSO_DATA_DIRECTORY)
.getOrElse {
OS.operatingSystem match {
case OS.Linux =>
env
.getEnvPath(XDG_DATA_DIRECTORY)
.map(_ / LINUX_ENSO_DIRECTORY)
.getOrElse {
env.getHome / ".local" / "share" / LINUX_ENSO_DIRECTORY
}
case OS.MacOS =>
env.getHome / "Library" / "Application Support" / MACOS_ENSO_DIRECTORY
case OS.Windows =>
env.getLocalAppData / WINDOWS_ENSO_DIRECTORY
case OS.Unknown =>
throw new RuntimeException(
"Could not determine the default installation location as " +
s"the operating system is not recognized. Please set the" +
s"`$ENSO_DATA_DIRECTORY` environment variable."
)
}
}
.toAbsolutePath
/**
* Config directory for an installed distribution.
*/
def configDirectory: Path =
env
.getEnvPath(ENSO_CONFIG_DIRECTORY)
.getOrElse {
OS.operatingSystem match {
case OS.Linux =>
env
.getEnvPath(XDG_CONFIG_DIRECTORY)
.map(_ / LINUX_ENSO_DIRECTORY)
.getOrElse {
env.getHome / ".config" / LINUX_ENSO_DIRECTORY
}
case OS.MacOS =>
env.getHome / "Library" / "Preferences" / MACOS_ENSO_DIRECTORY
case OS.Windows =>
env.getLocalAppData / WINDOWS_ENSO_DIRECTORY / CONFIG_DIRECTORY
case OS.Unknown =>
throw new RuntimeException(
"Could not determine the default installation location as " +
s"the operating system is not recognized. Please set the" +
s"`$ENSO_CONFIG_DIRECTORY` environment variable."
)
}
}
.toAbsolutePath
/**
* The directory where the launcher binary will be placed for an installed
* distribution.
*/
def binDirectory: Path =
env
.getEnvPath(ENSO_BIN_DIRECTORY)
.getOrElse {
OS.operatingSystem match {
case OS.Linux =>
env
.getEnvPath(XDG_BIN_DIRECTORY)
.getOrElse {
env.getHome / ".local" / "bin"
}
case OS.MacOS =>
env.getHome / ".local" / "bin"
case OS.Windows =>
env.getLocalAppData / WINDOWS_ENSO_DIRECTORY / BIN_DIRECTORY
case OS.Unknown =>
throw new RuntimeException(
"Could not determine the default installation location as " +
s"the operating system is not recognized. Please set the" +
s"`$ENSO_BIN_DIRECTORY` environment variable."
)
}
}
.toAbsolutePath
private def executableName: String =
OS.executableName("enso")
/**
* The path where the binary executable of the installed distribution
* should be placed by default.
*/
def binaryExecutable: Path = {
binDirectory / executableName
}
/**
* The safe version of [[dataDirectory]] which returns None if the
* directory cannot be determined.
*
* Should be used in places where not being able to determine the data
* directory is not a fatal error.
*/
def safeDataDirectory: Option[Path] =
Try(dataDirectory).toOption
/**
* Determines whether a locally installed distribution exists on the
* system.
*/
def installedDistributionExists: Boolean =
safeDataDirectory.exists(Files.isDirectory(_))
}
}

View File

@ -0,0 +1,137 @@
package org.enso.launcher.releases
object Placeholder2
import java.nio.file.Path
import nl.gn0s1s.bump.SemVer
import org.enso.cli.TaskProgress
import org.enso.launcher.OS
import org.enso.launcher.releases.github.GithubReleaseProvider
import org.enso.launcher.components.Manifest
import scala.util.{Failure, Success, Try}
/**
* Represents an engine release.
*
* @param version engine version
* @param manifest manifest associated with the release
* @param release a [[Release]] that allows to download assets
*/
case class EngineRelease(
version: SemVer,
manifest: Manifest,
release: Release
) {
/**
* Determines the filename of the package that should be downloaded from this
* release.
*
* That filename may be platform specific.
*/
def packageFileName: String = {
val os = OS.operatingSystem match {
case OS.Linux => "linux"
case OS.MacOS => "macos"
case OS.Windows => "windows"
}
val arch = OS.architecture
val extension = OS.operatingSystem match {
case OS.Linux => ".tar.gz"
case OS.MacOS => ".tar.gz"
case OS.Windows => ".zip"
}
s"enso-engine-$version-$os-$arch$extension"
}
}
/**
* Wraps a generic [[ReleaseProvider]] to provide engine releases from it.
*/
class EngineReleaseProvider(releaseProvider: ReleaseProvider) {
private val tagPrefix = "enso-"
/**
* Returns the version of the most recent engine release.
*/
def findLatest(): Try[SemVer] =
releaseProvider.listReleases().flatMap { releases =>
val versions =
releases.map(_.tag.stripPrefix(tagPrefix)).flatMap(SemVer(_))
versions.sorted.lastOption.map(Success(_)).getOrElse {
Failure(ReleaseProviderException("No valid engine versions were found"))
}
}
/**
* Fetches release metadata for a given version.
*/
def getRelease(version: SemVer): Try[EngineRelease] = {
val tag = tagPrefix + version.toString
for {
release <- releaseProvider.releaseForTag(tag)
manifestAsset <-
release.assets
.find(_.fileName == Manifest.DEFAULT_MANIFEST_NAME)
.toRight(
ReleaseProviderException(
s"${Manifest.DEFAULT_MANIFEST_NAME} file is mising from " +
s"release assets."
)
)
.toTry
manifestContent <- manifestAsset.fetchAsText().waitForResult()
manifest <-
Manifest
.fromYaml(manifestContent)
.recoverWith(error =>
Failure(
ReleaseProviderException(
"Cannot parse attached manifest file.",
error
)
)
)
} yield EngineRelease(version, manifest, release)
}
/**
* Downloads the package associated with the given release into
* `destination`.
*
* @param release the release to download the package from
* @param destination name of the file that will be created to contain the
* downloaded package
*/
def downloadPackage(
release: EngineRelease,
destination: Path
): TaskProgress[Unit] = {
val packageName = release.packageFileName
release.release.assets
.find(_.fileName == packageName)
.map(_.downloadTo(destination))
.getOrElse {
TaskProgress.immediateFailure(
ReleaseProviderException(
s"Cannot find package `$packageName` in the release."
)
)
}
}
}
/**
* Default [[EngineReleaseProvider]] that uses the GitHub Release API.
*/
object EngineReleaseProvider
extends EngineReleaseProvider(
new GithubReleaseProvider(
"enso-org",
"enso-staging" // TODO [RW] The release provider will be moved from
// staging to the main repository, when the first official Enso release
// is released.
)
)

View File

@ -0,0 +1,72 @@
package org.enso.launcher.releases
import java.nio.file.Path
import org.enso.cli.TaskProgress
import org.enso.launcher.OS
import org.enso.launcher.components.RuntimeVersion
import org.enso.launcher.releases.github.GithubReleaseProvider
import scala.util.{Failure, Success}
/**
* [[RuntimeReleaseProvider]] implementation providing Graal Community Edition
* releases from the given [[ReleaseProvider]].
*/
class GraalCEReleaseProvider(releaseProvider: ReleaseProvider)
extends RuntimeReleaseProvider {
/**
* @inheritdoc
*/
override def packageFileName(version: RuntimeVersion): String = {
val os = OS.operatingSystem match {
case OS.Linux => "linux"
case OS.MacOS => "darwin"
case OS.Windows => "windows"
}
val arch = OS.architecture
val extension = OS.operatingSystem match {
case OS.Linux => ".tar.gz"
case OS.MacOS => ".tar.gz"
case OS.Windows => ".zip"
}
s"graalvm-ce-java${version.java}-$os-$arch-${version.graal}$extension"
}
/**
* @inheritdoc
*/
override def downloadPackage(
version: RuntimeVersion,
destination: Path
): TaskProgress[Unit] = {
val tagName = s"vm-${version.graal}"
val packageName = packageFileName(version)
val release = releaseProvider.releaseForTag(tagName)
release match {
case Failure(exception) =>
TaskProgress.immediateFailure(exception)
case Success(release) =>
release.assets
.find(_.fileName == packageName)
.map(_.downloadTo(destination))
.getOrElse {
TaskProgress.immediateFailure(
ReleaseProviderException(
s"Cannot find package `$packageName` in the release."
)
)
}
}
}
}
/**
* Default [[RuntimeReleaseProvider]] that provides Graal CE releases using the
* GitHub Release API.
*/
object GraalCEReleaseProvider
extends GraalCEReleaseProvider(
new GithubReleaseProvider("graalvm", "graalvm-ce-builds")
)

View File

@ -0,0 +1,58 @@
package org.enso.launcher.releases
import java.nio.file.Path
import org.enso.cli.TaskProgress
import scala.util.Try
/**
* Represents a downloadable release asset.
*/
trait Asset {
def fileName: String
def downloadTo(path: Path): TaskProgress[Unit]
def fetchAsText(): TaskProgress[String]
}
/**
* Wraps a generic release returned by [[ReleaseProvider]].
*/
trait Release {
/**
* The tag identifying this release.
*/
def tag: String
/**
* The sequence of assets available in this release.
*/
def assets: Seq[Asset]
}
/**
* A generic release provider that allows to list and download releases.
*/
trait ReleaseProvider {
/**
* Finds a release for the given tag.
*/
def releaseForTag(tag: String): Try[Release]
/**
* Fetches a list of all releases.
*/
def listReleases(): Try[Seq[Release]]
}
case class ReleaseProviderException(message: String, cause: Throwable = null)
extends RuntimeException(message, cause) {
/**
* @inheritdoc
*/
override def toString: String =
s"A problem occurred when trying to find the release: $message"
}

View File

@ -0,0 +1,34 @@
package org.enso.launcher.releases
import java.nio.file.Path
import org.enso.cli.TaskProgress
import org.enso.launcher.components.RuntimeVersion
/**
* Interface for a service providing runtime releases.
*/
trait RuntimeReleaseProvider {
/**
* Determines filename of the package that should be downloaded from the
* release for a given version.
*
* The result of this function may be system specific (the package name may
* include the OS, for example).
*/
def packageFileName(version: RuntimeVersion): String
/**
* Downloads a package for the given version to the provided location.
* @param version runtime version to download
* @param destination name of the file that will be created to contain the
* downloaded package
* @return [[TaskProgress]] allowing to track progress of the download and
* wait for its completion
*/
def downloadPackage(
version: RuntimeVersion,
destination: Path
): TaskProgress[Unit]
}

View File

@ -0,0 +1,199 @@
package org.enso.launcher.releases.github
import java.nio.file.Path
import io.circe._
import io.circe.parser._
import org.apache.http.Header
import org.enso.cli.TaskProgress
import org.enso.launcher.http.{
APIResponse,
HTTPDownload,
HTTPRequestBuilder,
URIBuilder
}
import org.enso.launcher.releases.ReleaseProviderException
import scala.util.{Success, Try}
/**
* Contains functions used to query the GitHubAPI endpoints.
*/
object GithubAPI {
/**
* Represents a GitHub repository.
*
* @param owner owner of the repository
* @param name name of the repository
*/
case class Repository(owner: String, name: String)
/**
* Represents a GitHub release.
*
* @param tag tag associated with the release
* @param assets sequence of assets present in this release
*/
case class Release(tag: String, assets: Seq[Asset])
/**
* Represents an asset available in a [[Release]]
*
* @param name filename of that asset
* @param url URL that can be used to download this asset
* @param size size of the asset in bytes
*/
case class Asset(name: String, url: String, size: Long)
/**
* Returns a list of all releases in the repository.
*
* It fetches all available pages of releases, to make sure all releases are
* included. This is necessary, because the GitHub API does not guarantee
* that the releases are returned in order. The 'empirical' order seems to be
* 'latest first', but even assuming that is a too weak guarantee, because
* *theoretically* so many patches for an earlier release could be released,
* that the latest release in the semver sense will not make it to the first
* page. So to be absolutely sure that we can find the latest release, we
* need to list all of them.
*
* The endpoint is blocking, because we cannot estimate how many requests
* will be necessary and each individual request should be quick as it just
* downloads some text, so there is not much gain in displaying a progress
* bar for this task.
*/
def listReleases(repository: Repository): Try[Seq[Release]] = {
val perPage = 100
def listPage(page: Int): Try[Seq[Release]] = {
val uri = (projectURI(repository) / "releases") ?
("per_page" -> perPage.toString) ? ("page" -> page.toString)
HTTPDownload
.fetchString(HTTPRequestBuilder.fromURI(uri.build()).GET)
.flatMap(response =>
parse(response.content)
.flatMap(
_.as[Seq[Release]].left
.map(err =>
handleError(
response,
ReleaseProviderException(s"Cannot fetch release list.", err)
)
)
)
.toTry
)
.waitForResult()
}
def listAllPages(from: Int): Try[Seq[Release]] =
listPage(from).flatMap { current =>
if (current.length == perPage)
listAllPages(from + 1).map(current ++ _)
else
Success(current)
}
listAllPages(1)
}
/**
* Fetches release metadata for the release associated with the given tag.
*/
def getRelease(repo: Repository, tag: String): TaskProgress[Release] = {
val uri = projectURI(repo) / "releases" / "tags" / tag
HTTPDownload
.fetchString(HTTPRequestBuilder.fromURI(uri.build()).GET)
.flatMap(response =>
parse(response.content)
.flatMap(_.as[Release])
.left
.map(err =>
handleError(
response,
ReleaseProviderException(s"Cannot find release `$tag`.", err)
)
)
.toTry
)
}
/**
* A helper function that detecte a rate-limit error and tries to make a more
* friendly user message.
*
* If the rate-limit is hit, an error message is returned by the API which is
* of course not parsed correctly by a reader that expects a normal response.
* The rate-limit is detected and if it caused the error, the error is
* overridden with a message explaining the rate-limit.
*/
private def handleError(
response: APIResponse,
defaultError: => Throwable
): Throwable = {
def isLimitExceeded(header: Header): Boolean =
header.getValue.toIntOption.contains(0)
response.headers.find(_.getName == "X-RateLimit-Remaining") match {
case Some(header) if isLimitExceeded(header) =>
ReleaseProviderException(
"GitHub Release API rate limit exceeded for your IP address. " +
"Please try again in a while."
)
case _ => defaultError
}
}
/**
* Fetches an asset as text.
*
* Returns a [[TaskProgress]] that will return the asset contents as a
* [[String]] on success.
*/
def fetchTextAsset(asset: Asset): TaskProgress[String] = {
val request = HTTPRequestBuilder
.fromURIString(asset.url)
.addHeader("Accept", "application/octet-stream")
.GET
HTTPDownload.fetchString(request, Some(asset.size)).map(_.content)
}
/**
* Downloads the asset to the provided `destination`.
*
* The returned [[TaskProgress]] succeeds iff the download was successful.
*/
def downloadAsset(asset: Asset, destination: Path): TaskProgress[Unit] = {
val request = HTTPRequestBuilder
.fromURIString(asset.url)
.addHeader("Accept", "application/octet-stream")
.GET
HTTPDownload
.download(request, destination, Some(asset.size))
.map(_ => ())
}
private val baseUrl =
URIBuilder.fromHost("api.github.com")
private def projectURI(project: Repository) =
baseUrl / "repos" / project.owner / project.name
implicit private val assetDecoder: Decoder[Asset] = { json =>
for {
url <- json.get[String]("browser_download_url")
name <- json.get[String]("name")
size <- json.get[Long]("size")
} yield Asset(name = name, url = url, size = size)
}
implicit private val releaseDecoder: Decoder[Release] = { json =>
for {
tag <- json.get[String]("tag_name")
assets <- json.get[Seq[Asset]]("assets")
} yield Release(tag, assets)
}
}

View File

@ -0,0 +1,26 @@
package org.enso.launcher.releases.github
import java.nio.file.Path
import org.enso.cli.TaskProgress
import org.enso.launcher.releases.Asset
case class GithubAsset(asset: GithubAPI.Asset) extends Asset {
/**
* @inheritdoc
*/
override def fileName: String = asset.name
/**
* @inheritdoc
*/
override def downloadTo(path: Path): TaskProgress[Unit] =
GithubAPI.downloadAsset(asset, path)
/**
* @inheritdoc
*/
override def fetchAsText(): TaskProgress[String] =
GithubAPI.fetchTextAsset(asset)
}

View File

@ -0,0 +1,16 @@
package org.enso.launcher.releases.github
import org.enso.launcher.releases.{Asset, Release}
case class GithubRelease(release: GithubAPI.Release) extends Release {
/**
* @inheritdoc
*/
override def tag: String = release.tag
/**
* @inheritdoc
*/
override def assets: Seq[Asset] = release.assets.map(GithubAsset)
}

View File

@ -0,0 +1,31 @@
package org.enso.launcher.releases.github
import org.enso.launcher.releases.{Release, ReleaseProvider}
import scala.util.Try
/**
* Implements [[ReleaseProvider]] providing releases from a specified GitHub
* repository using the GitHub Release API.
*
* @param owner owner of the repository
* @param repositoryName name of the repository
*/
class GithubReleaseProvider(
owner: String,
repositoryName: String
) extends ReleaseProvider {
private val repo = GithubAPI.Repository(owner, repositoryName)
/**
* @inheritdoc
*/
override def releaseForTag(tag: String): Try[Release] =
GithubAPI.getRelease(repo, tag).waitForResult().map(GithubRelease)
/**
* @inheritdoc
*/
override def listReleases(): Try[Seq[Release]] =
GithubAPI.listReleases(repo).map(_.map(GithubRelease))
}

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,3 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -0,0 +1,53 @@
package org.enso.launcher
import java.nio.file.{Files, Path}
import org.enso.launcher.FileSystem.PathSyntax
/**
* A test-suite mixin that adds helper functions that create a fake environment
* which points to an Enso installation inside the temporary directory
* generated for the test.
*/
trait FakeEnvironment { self: WithTemporaryDirectory =>
/**
* Returns a fake path to the Enso executable that is inside the temporary
* directory for the test.
*
* @param portable specifies whether the distribution should be marked as
* portable
*/
def fakeExecutablePath(portable: Boolean = false): Path = {
val fakeBin = getTestDirectory / "bin"
Files.createDirectories(fakeBin)
if (portable) {
FileSystem.writeTextFile(getTestDirectory / ".enso.portable", "mark")
}
fakeBin / "enso"
}
/**
* Returns an [[Environment]] instance that overrides the `ENSO_*`
* directories to be inside the temporary directory for the test.
*/
def fakeInstalledEnvironment(): Environment = {
val executable = fakeExecutablePath()
val dataDir = getTestDirectory / "test_data"
val configDir = getTestDirectory / "test_config"
val binDir = getTestDirectory / "test_bin"
val fakeEnvironment = new Environment {
override def getPathToRunningExecutable: Path = executable
override def getEnvVar(key: String): Option[String] =
key match {
case "ENSO_DATA_DIRECTORY" => Some(dataDir.toString)
case "ENSO_CONFIG_DIRECTORY" => Some(configDir.toString)
case "ENSO_BIN_DIRECTORY" => Some(binDir.toString)
case _ => super.getEnvVar(key)
}
}
fakeEnvironment
}
}

View File

@ -0,0 +1,30 @@
package org.enso.launcher
import java.nio.file.attribute.PosixFilePermissions
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import scala.jdk.CollectionConverters._
class FileSystemSpec extends AnyWordSpec with Matchers {
"decodePOSIXPermissions" should {
def assertPair(octal: String, textual: String): Unit = {
val mode = Integer.parseInt(octal, 8)
val decoded = FileSystem.decodePOSIXPermissions(mode)
val reference = PosixFilePermissions.fromString(textual)
decoded.asScala.toSet shouldEqual reference.asScala.toSet
}
"decode permissions correctly" in {
assertPair("000", "---------")
assertPair("111", "--x--x--x")
assertPair("222", "-w--w--w-")
assertPair("444", "r--r--r--")
assertPair("777", "rwxrwxrwx")
assertPair("644", "rw-r--r--")
assertPair("012", "-----x-w-")
assertPair("421", "r---w---x")
}
}
}

View File

@ -10,10 +10,12 @@ class LauncherSpec
"new command" should {
"create a new project with correct structure" in {
val projectDir = getTestDirectory.resolve("proj1")
Launcher.newProject("TEST", Some(projectDir))
projectDir.toFile should exist
projectDir.resolve("src").resolve("Main.enso").toFile should exist
Logger.suppressWarnings {
val projectDir = getTestDirectory.resolve("proj1")
Launcher.newProject("TEST", Some(projectDir))
projectDir.toFile should exist
projectDir.resolve("src").resolve("Main.enso").toFile should exist
}
}
}

View File

@ -1,6 +1,7 @@
package org.enso.launcher
import java.nio.file.{Files, Path}
import java.lang.{ProcessBuilder => JProcessBuilder}
import org.scalatest.concurrent.{Signaler, TimeLimitedTests}
import org.scalatest.matchers.should.Matchers
@ -8,10 +9,11 @@ import org.scalatest.matchers.{MatchResult, Matcher}
import org.scalatest.time.Span
import org.scalatest.wordspec.AnyWordSpec
import scala.sys.process._
import org.scalatest.time.SpanSugar._
import org.enso.launcher.internal.OS
import scala.collection.Factory
import scala.jdk.CollectionConverters._
import scala.jdk.StreamConverters._
/**
* Contains helper methods for creating tests that need to run the native
@ -109,6 +111,12 @@ trait NativeTest extends AnyWordSpec with Matchers with TimeLimitedTests {
/**
* Creates a copy of the tested launcher binary at the specified location.
*
* It waits a 100ms delay after creating the copy to ensure that the copy can
* be called right away after calling this function. It is not absolutely
* certain that this is helpful, but from time to time, the tests fail
* because the filesystem does not allow to access the executable as
* 'not-ready'. This delay is an attempt to make the tests more stable.
*/
def copyLauncherTo(path: Path): Unit = {
val parent = path.getParent
@ -117,6 +125,7 @@ trait NativeTest extends AnyWordSpec with Matchers with TimeLimitedTests {
if (!Files.isExecutable(path)) {
throw new RuntimeException("Failed to make it executable...")
}
Thread.sleep(100)
}
/**
@ -139,43 +148,63 @@ trait NativeTest extends AnyWordSpec with Matchers with TimeLimitedTests {
}
/**
* This property can be temporarily set to true to allow for easier debugging
* of native tests.
* Runs the provided `command`.
*
* `extraEnv` may be provided to extend the environment. Care must be taken
* on Windows where environment variables are (mostly) case-insensitive.
*
* If `waitForDescendants` is set, tries to wait for descendants of the
* launched process to finish too. Especially important on Windows where
* child processes may run after the launcher parent has been terminated.
*/
private val launcherDebugLogging = false
private def run(
command: Seq[String],
extraEnv: Seq[(String, String)]
extraEnv: Seq[(String, String)],
waitForDescendants: Boolean = true
): RunResult = {
val stdout = new StringBuilder
val stderr = new StringBuilder
val logger = new ProcessLogger {
override def out(s: => String): Unit = {
if (launcherDebugLogging) {
System.err.println(s)
}
stdout.append(s + "\n")
}
val builder = new JProcessBuilder(command: _*)
val newKeys = extraEnv.map(_._1.toLowerCase)
if (newKeys.distinct.size < newKeys.size) {
throw new IllegalArgumentException(
"The extra environment keys have to be unique"
)
}
override def err(s: => String): Unit = {
if (launcherDebugLogging) {
System.err.println(s)
}
stderr.append(s + "\n")
}
lazy val existingKeys =
builder.environment().keySet().asScala
for ((key, value) <- extraEnv) {
if (OS.isWindows) {
def shadows(key1: String, key2: String): Boolean =
key1.toLowerCase == key2.toLowerCase && key1 != key2
override def buffer[T](f: => T): T = f
existingKeys.find(shadows(_, key)) match {
case Some(oldKey) =>
throw new IllegalArgumentException(
s"The environment key `$key` may be shadowed by `$oldKey` " +
s"already existing in the environment. Please use `$oldKey`."
)
case None =>
}
}
builder.environment().put(key, value)
}
try {
val process = Process(command, None, extraEnv: _*).run(logger)
val process = builder.start()
try {
RunResult(process.exitValue(), stdout.toString(), stderr.toString())
val exitCode = process.waitFor()
if (waitForDescendants) {
val descendants = process.descendants().toScala(Factory.arrayFactory)
descendants.foreach(_.onExit().join())
}
val stdout = new String(process.getInputStream.readAllBytes())
val stderr = new String(process.getErrorStream.readAllBytes())
RunResult(exitCode, stdout, stderr)
} catch {
case e: InterruptedException =>
if (process.isAlive()) {
println("Killing the timed-out process")
if (process.isAlive) {
println(s"Killing the timed-out process: ${command.mkString(" ")}")
process.destroy()
}
throw e

View File

@ -6,21 +6,41 @@ import java.io.{File, IOException}
import org.apache.commons.io.FileUtils
import org.scalatest.{BeforeAndAfterEach, Suite}
/**
* Creates a separate temporary directory for each test.
*/
trait WithTemporaryDirectory extends Suite with BeforeAndAfterEach {
private var testDirectory: Path = _
/**
* @inheritdoc
*/
override def beforeEach(): Unit = {
super.beforeEach()
testDirectory = Files.createTempDirectory("tmptest")
testDirectory = Files.createTempDirectory("enso-test")
}
/**
* @inheritdoc
*/
override def afterEach(): Unit = {
super.afterEach()
robustDeleteDirectory(testDirectory.toFile)
}
/**
* Returns the temporary directory for this test.
*/
def getTestDirectory: Path = testDirectory.toAbsolutePath
/**
* Tries to remove the directory, retrying every 100ms for 3 seconds.
*
* This is used because there may be some lag between the test finalizing and
* the filesystem allowing to remove the files (especially on Windows,
* because if the test runs other executables, they may take a moment to
* terminate even after the test completed).
*/
private def robustDeleteDirectory(dir: File): Unit = {
def tryRemoving(retry: Int): Unit = {
try {

View File

@ -0,0 +1,153 @@
package org.enso.launcher.components
import java.nio.file.Path
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.cli.GlobalCLIOptions
import org.enso.launcher.installation.DistributionManager
import org.enso.launcher.releases.{
EngineReleaseProvider,
GraalCEReleaseProvider
}
import org.enso.launcher.{FakeEnvironment, Logger, WithTemporaryDirectory}
import org.scalatest.OptionValues
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
class ComponentsManagerSpec
extends AnyWordSpec
with Matchers
with OptionValues
with WithTemporaryDirectory
with FakeEnvironment {
private def makeManagers(): (DistributionManager, ComponentsManager) = {
val distributionManager = new DistributionManager(
fakeInstalledEnvironment()
)
val fakeReleasesRoot =
Path.of(
getClass
.getResource("fake-releases")
.toURI
)
val engineProvider = new EngineReleaseProvider(
FakeReleaseProvider(fakeReleasesRoot.resolve("enso"))
)
val runtimeProvider = new GraalCEReleaseProvider(
FakeReleaseProvider(fakeReleasesRoot.resolve("graalvm"))
)
val componentsManager = new ComponentsManager(
GlobalCLIOptions(autoConfirm = true, hideProgress = true),
distributionManager,
engineProvider,
runtimeProvider
)
(distributionManager, componentsManager)
}
def makeComponentsManager(): ComponentsManager = makeManagers()._2
"ComponentsManager" should {
"find the latest engine version in semver ordering" in {
Logger.suppressWarnings {
val componentsManager = makeComponentsManager()
componentsManager.fetchLatestEngineVersion() shouldEqual SemVer(0, 0, 1)
}
}
"install the engine and a matching runtime for it" in {
Logger.suppressWarnings {
val (distributionManager, componentsManager) = makeManagers()
val version = SemVer(0, 0, 1)
val engine = componentsManager.findOrInstallEngine(SemVer(0, 0, 1))
engine.version shouldEqual version
assert(
engine.path.startsWith(distributionManager.paths.engines),
"Engine should be installed in the engines directory."
)
val runtime = componentsManager.findRuntime(engine)
runtime.value.version shouldEqual RuntimeVersion(SemVer(2, 0, 0), "11")
assert(
runtime.value.path.startsWith(distributionManager.paths.runtimes),
"Engine should be installed in the engines directory."
)
}
}
"list installed engines and runtimes" in {
Logger.suppressWarnings {
val componentsManager = makeComponentsManager()
val engineVersions =
Set(SemVer(0, 0, 0), SemVer(0, 0, 1), SemVer(0, 0, 1, Some("pre")))
val runtimeVersions =
Set(
RuntimeVersion(SemVer(1, 0, 0), "11"),
RuntimeVersion(SemVer(2, 0, 0), "11")
)
engineVersions.map(
componentsManager.findOrInstallEngine(_, complain = false)
)
componentsManager
.listInstalledEngines()
.map(_.version)
.toSet shouldEqual engineVersions
componentsManager
.listInstalledRuntimes()
.map(_.version)
.toSet shouldEqual runtimeVersions
val runtime2 =
componentsManager
.findRuntime(RuntimeVersion(SemVer(2, 0, 0), "11"))
.value
componentsManager.findEnginesUsingRuntime(runtime2) should have length 2
}
}
"uninstall the runtime iff it is not used by any engines" in {
Logger.suppressWarnings {
val componentsManager = makeComponentsManager()
val engineVersions =
Seq(SemVer(0, 0, 0), SemVer(0, 0, 1), SemVer(0, 0, 1, Some("pre")))
engineVersions.map(
componentsManager.findOrInstallEngine(_, complain = false)
)
componentsManager.listInstalledEngines() should have length 3
componentsManager.listInstalledRuntimes() should have length 2
// remove the engine that shares the runtime with another one
val version1 = SemVer(0, 0, 1, Some("pre"))
componentsManager.uninstallEngine(version1)
val engines1 = componentsManager.listInstalledEngines()
engines1 should have length 2
engines1.map(_.version) should not contain version1
componentsManager.listInstalledRuntimes() should have length 2
// remove the second engine that shared the runtime
val version2 = SemVer(0, 0, 1)
componentsManager.uninstallEngine(version2)
val engines2 = componentsManager.listInstalledEngines()
engines2 should have length 1
engines2.map(_.version) should not contain version2
val runtimes2 = componentsManager.listInstalledRuntimes()
runtimes2 should have length 1
runtimes2.map(_.version).head shouldEqual RuntimeVersion(
SemVer(1, 0, 0),
"11"
)
// remove the last engine
componentsManager.uninstallEngine(SemVer(0, 0, 0))
componentsManager.listInstalledEngines() should have length 0
componentsManager.listInstalledRuntimes() should have length 0
}
}
}
}

View File

@ -0,0 +1,136 @@
package org.enso.launcher.components
import java.nio.file.{Files, Path}
import org.enso.cli.{ProgressListener, TaskProgress}
import org.enso.launcher.{FileSystem, OS}
import org.enso.launcher.releases.{
Asset,
Release,
ReleaseProvider,
ReleaseProviderException
}
import scala.io.Source
import scala.util.{Success, Try, Using}
import sys.process._
case class FakeReleaseProvider(releasesRoot: Path) extends ReleaseProvider {
private val releases = FileSystem.listDirectory(releasesRoot).map(FakeRelease)
/**
* @inheritdoc
*/
override def releaseForTag(tag: String): Try[Release] =
releases
.find(_.tag == tag)
.toRight(ReleaseProviderException(s"Release $tag does not exist."))
.toTry
/**
* @inheritdoc
*/
override def listReleases(): Try[Seq[Release]] = Success(releases)
}
case class FakeRelease(path: Path) extends Release {
/**
* @inheritdoc
*/
override def tag: String = path.getFileName.toString
/**
* @inheritdoc
*/
override def assets: Seq[Asset] =
FileSystem.listDirectory(path).map(FakeAsset)
}
case class FakeAsset(source: Path) extends Asset {
/**
* @inheritdoc
*/
override def fileName: String = source.getFileName.toString
/**
* @inheritdoc
*/
override def downloadTo(path: Path): TaskProgress[Unit] = {
val result = Try(copyFakeAsset(path))
new TaskProgress[Unit] {
override def addProgressListener(
listener: ProgressListener[Unit]
): Unit = {
listener.done(result)
}
}
}
private def copyFakeAsset(destination: Path): Unit =
if (Files.isDirectory(source)) {
val directoryName = source.getFileName.toString
if (directoryName.endsWith(".tar.gz") && OS.isUNIX)
packTarGz(source, destination)
else if (directoryName.endsWith(".zip") && OS.isWindows)
packZip(source, destination)
else {
throw new IllegalArgumentException(
s"Fake-archive format $directoryName is not supported on " +
s"${OS.operatingSystem}."
)
}
} else {
FileSystem.copyFile(source, destination)
}
/**
* @inheritdoc
*/
override def fetchAsText(): TaskProgress[String] = {
val txt = Using(Source.fromFile(source.toFile)) { src =>
src.getLines().mkString("\n")
}
(listener: ProgressListener[String]) => {
listener.done(txt)
}
}
private def packTarGz(source: Path, destination: Path): Unit = {
val files = FileSystem.listDirectory(source)
val exitCode = Process(
Seq(
"tar",
"-czf",
destination.toAbsolutePath.toString
) ++ files.map(_.getFileName.toString),
source.toFile
).!
if (exitCode != 0) {
throw new RuntimeException(
s"tar failed. Cannot create fake-archive for $source"
)
}
}
private def packZip(source: Path, destination: Path): Unit = {
val files = FileSystem.listDirectory(source)
val exitCode = Process(
Seq(
"powershell",
"Compress-Archive",
"-Path",
files.map(_.getFileName.toString).mkString(","),
"-DestinationPath",
destination.toAbsolutePath.toString
),
source.toFile
).!
if (exitCode != 0) {
throw new RuntimeException(
s"tar failed. Cannot create fake-archive for $source"
)
}
}
}

View File

@ -1,32 +1,24 @@
package org.enso.launcher.installation
import java.nio.file.{Files, Path}
import java.nio.file.Path
import org.enso.launcher.{FileSystem, WithTemporaryDirectory}
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.{Environment, FakeEnvironment, WithTemporaryDirectory}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.internal.Environment
import org.enso.launcher.internal.installation.DistributionManager
class DistributionManagerSpec
extends AnyWordSpec
with Matchers
with WithTemporaryDirectory {
def fakeExecutablePath(): Path = {
val fakeBin = getTestDirectory / "bin"
Files.createDirectories(fakeBin)
fakeBin / "enso"
}
with WithTemporaryDirectory
with FakeEnvironment {
"DistributionManager" should {
"detect portable distribution" in {
val executable = fakeExecutablePath()
val executable = fakeExecutablePath(portable = true)
val fakeEnvironment = new Environment {
override def getPathToRunningExecutable: Path = executable
}
FileSystem.writeTextFile(getTestDirectory / ".enso.portable", "mark")
val distributionManager = new DistributionManager(fakeEnvironment)
distributionManager.isRunningPortable shouldEqual true
@ -49,23 +41,12 @@ class DistributionManagerSpec
"respect environment variable overrides " +
"for installed distribution location" in {
val executable = fakeExecutablePath()
val dataDir = getTestDirectory / "test_data"
val configDir = getTestDirectory / "test_config"
val binDir = getTestDirectory / "test_bin"
val fakeEnvironment = new Environment {
override def getPathToRunningExecutable: Path = executable
val dataDir = getTestDirectory / "test_data"
val configDir = getTestDirectory / "test_config"
val binDir = getTestDirectory / "test_bin"
override def getEnvVar(key: String): Option[String] =
key match {
case "ENSO_DATA_DIRECTORY" => Some(dataDir.toString)
case "ENSO_CONFIG_DIRECTORY" => Some(configDir.toString)
case "ENSO_BIN_DIRECTORY" => Some(binDir.toString)
case _ => super.getEnvVar(key)
}
}
val distributionManager = new DistributionManager(fakeEnvironment)
val distributionManager =
new DistributionManager(fakeInstalledEnvironment())
distributionManager.paths.dataRoot shouldEqual dataDir
distributionManager.paths.config shouldEqual configDir
distributionManager.LocallyInstalledDirectories.binDirectory shouldEqual

View File

@ -2,9 +2,8 @@ package org.enso.launcher.installation
import java.nio.file.{Files, Path}
import org.enso.launcher.{FileSystem, NativeTest, WithTemporaryDirectory}
import org.enso.launcher.{FileSystem, NativeTest, OS, WithTemporaryDirectory}
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.internal.OS
import scala.io.Source
@ -51,7 +50,14 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
}
}
private def notExistsAfterSomeTime(path: Path, retry: Int = 5): Boolean = {
/**
* Checks if the file does not exist, retrying `retry` times with a 200ms
* delay between retries.
*
* Useful to check if the file was removed, but the removal action is not
* blocking and may take more time.
*/
def notExistsAfterSomeTime(path: Path, retry: Int = 5): Boolean = {
if (Files.notExists(path)) true
else if (retry > 0) {
Thread.sleep(200)
@ -64,7 +70,7 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
preparePortableDistribution()
runLauncherAt(
portableLauncher,
Seq("install", "distribution", "--auto-confirm"),
Seq("--auto-confirm", "install", "distribution"),
env
)
@ -79,17 +85,34 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
readFileContent(config).stripTrailing() shouldEqual "what: ever"
assert(
notExistsAfterSomeTime(portableLauncher),
Files.notExists(portableLauncher),
"The installer should remove itself."
)
}
"not remove old launcher if asked" in {
preparePortableDistribution()
runLauncherAt(
portableLauncher,
Seq(
"--auto-confirm",
"install",
"distribution",
"--no-remove-old-launcher"
),
env
)
(installedRoot / "bin" / OS.executableName("enso")).toFile should exist
portableLauncher.toFile should exist
}
"move bundles by default" in {
preparePortableDistribution()
prepareBundles()
runLauncherAt(
portableLauncher,
Seq("install", "distribution", "--auto-confirm"),
Seq("--auto-confirm", "install", "distribution"),
env
)
@ -114,9 +137,9 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
runLauncherAt(
portableLauncher,
Seq(
"--auto-confirm",
"install",
"distribution",
"--auto-confirm",
"--bundle-install-mode=copy"
),
env
@ -139,9 +162,9 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
runLauncherAt(
portableLauncher,
Seq(
"--auto-confirm",
"install",
"distribution",
"--auto-confirm",
"--bundle-install-mode=ignore"
),
env

View File

@ -0,0 +1,56 @@
package org.enso.cli
import java.util.concurrent.LinkedTransferQueue
import scala.util.Try
/**
* Allows to display a progress bar in a terminal.
*/
object ProgressBar {
/**
* Displays a progressbar tracking progress of the provided task and waits
* for its completion.
*
* The progress bar is displayed as long as the task is in progress. The
* function blocks until the task is completed, and then it returns the
* result of the task.
*
* If the total amount of work is unknown and progress cannot be estimated,
* an in-progress animation is shown which is updated on each progress
* update.
*/
def waitWithProgress[A](task: TaskProgress[A]): Try[A] = {
val progressBar = new internal.ProgressBar
progressBar.start()
sealed trait Update
case class Progress(done: Long, total: Option[Long]) extends Update
case class Done(result: Try[A]) extends Update
val queue = new LinkedTransferQueue[Update]()
task.addProgressListener(new ProgressListener[A] {
override def progressUpdate(done: Long, total: Option[Long]): Unit =
queue.put(Progress(done, total))
override def done(result: Try[A]): Unit =
queue.put(Done(result))
})
var result: Option[Try[A]] = None
while (result.isEmpty) {
queue.take() match {
case Progress(done, Some(total)) =>
progressBar.updateProgress(100.0f * done / total)
case Progress(_, None) =>
progressBar.showUnknownProgress()
case Done(incomingResult) =>
progressBar.hide()
result = Some(incomingResult)
}
}
result.get
}
}

View File

@ -0,0 +1,180 @@
package org.enso.cli
import java.util.concurrent.LinkedTransferQueue
import scala.util.{Failure, Try}
/**
* Clients can implement this trait to get progress updates.
*/
trait ProgressListener[A] {
def progressUpdate(done: Long, total: Option[Long]): Unit
def done(result: Try[A]): Unit
}
/**
* Represents a long-running background task.
*/
trait TaskProgress[A] {
/**
* Adds a progress listener to this task.
*
* Even if the task is already finished, the [[ProgressListener.done]]
* method should be fired with the result. This way, `done` is fired
* exactly once for each attached listener. There are no guarantees on how
* often [[ProgressListener.progressUpdate]] is called.
*/
def addProgressListener(listener: ProgressListener[A]): Unit
/**
* Blocks and waits for the completion of the task.
*
* Optionally displays a progress bar in the terminal. Returns a [[Try]]
* value that wraps the result.
*
* @param showProgress whether or not to show a progress bar while waiting
*/
def waitForResult(showProgress: Boolean = false): Try[A] =
if (showProgress) ProgressBar.waitWithProgress(this)
else TaskProgress.waitForTask(this)
/**
* Alters the task by transforming its result with a function `f` that may
* fail.
*
* The progress of applying `f` is not monitored - it is meant for functions
* that do not take a very long time in comparison with the base task.
*
* @param f the function that can transform the original result
* @tparam B the type that `f` returns wrapped in a [[Try]]
* @return a new [[TaskProgress]] that will succeed if the original one
* succeeded and the transformation succeeded too
*/
def flatMap[B](f: A => Try[B]): TaskProgress[B] =
new MappedTask(this, f)
/**
* Alters the task by transforming its result with a function `f`.
*
* The progress of applying `f` is not monitored - it is meant for functions
* that do not take a very long time in comparison with the base task.
*
* If an exception is thrown by `f`, the altered task returns a [[Failure]]
* with that exception.
*
* @param f the function that can transform the original result
* @tparam B resulting type of `f`
* @return a new [[TaskProgress]] that will succeed if the original one
* succeeded and the transformation succeeded too
*/
def map[B](f: A => B): TaskProgress[B] = flatMap(a => Try(f(a)))
}
object TaskProgress {
/**
* Creates a task that fails immediately.
*
* Useful for reporting early errors (before a background thread has even
* been started for the task).
*
* @param throwable the error to complete the task with
* @tparam A type of the task that is failed
*/
def immediateFailure[A](throwable: Throwable): TaskProgress[A] =
new TaskProgress[A] {
override def addProgressListener(
listener: ProgressListener[A]
): Unit = {
listener.done(Failure(throwable))
}
}
/**
* Blocks and waits for the task to complete.
*/
def waitForTask[A](task: TaskProgress[A]): Try[A] = {
val queue = new LinkedTransferQueue[Try[A]]()
task.addProgressListener(new ProgressListener[A] {
override def progressUpdate(done: Long, total: Option[Long]): Unit = {}
override def done(result: Try[A]): Unit =
queue.put(result)
})
queue.take()
}
}
/**
* Transforms the result of the `source` task with `f`.
*
* Used internally by [[TaskProgress.flatMap]].
*/
private class MappedTask[A, B](source: TaskProgress[A], f: A => Try[B])
extends TaskProgress[B] {
override def addProgressListener(
listener: ProgressListener[B]
): Unit =
source.addProgressListener(new ProgressListener[A] {
override def progressUpdate(done: Long, total: Option[Long]): Unit =
listener.progressUpdate(done, total)
override def done(result: Try[A]): Unit =
listener.done(result.flatMap(f))
})
}
/**
* A simple implementation of [[TaskProgress]] that can be used to report
* progress updates and mark completion of a task.
*/
class TaskProgressImplementation[A] extends TaskProgress[A] {
@volatile private var listeners: List[ProgressListener[A]] = Nil
private var result: Option[Try[A]] = None
override def addProgressListener(
listener: ProgressListener[A]
): Unit = {
this.synchronized {
result match {
case Some(value) =>
listener.done(value)
case None =>
}
listeners ::= listener
}
}
/**
* Marks the completion of this task.
*
* All registered listeners are immediately notified. Any listeners added
* later will also be notified as soon as they are added.
*
* Can be called only once per task.
* @param result the result to complete the task with
*/
def setComplete(result: Try[A]): Unit = {
this.synchronized {
if (this.result.isDefined) {
throw new IllegalStateException(
"A task has been completed more than once."
)
}
this.result = Some(result)
listeners.foreach(_.done(result))
}
}
/**
* Report a progress update to all registered listeners.
*
* This operation is not synchronized, as it is not a problem if a just added
* listener does not get the latest progress update.
*/
def reportProgress(done: Long, total: Option[Long]): Unit = {
listeners.foreach(_.progressUpdate(done, total))
}
}

View File

@ -0,0 +1,72 @@
package org.enso.cli.internal
/**
* Allows to display a progress bar in a terminal.
*/
class ProgressBar {
/**
* Begins drawing the progressbar.
*/
def start(): Unit = {
drawProgressBar(0, "")
}
/**
* Updates the progressbar with a percentage.
*/
def updateProgress(percentage: Float): Unit = {
drawProgressBar(
(percentage / 100.0f * totalStates).floor.toInt,
s"${percentage.toInt}%"
)
}
/**
* Clears the progressbar.
*/
def hide(): Unit = {
print("\r" + " " * (paddingLength + 2) + "\r")
}
/**
* Displays a next step of animation indicating progress of a task with an
* unknown total.
*/
def showUnknownProgress(): Unit = {
state += 1
val pos = state % progressWidth
val prefix = " " * pos
val suffix = " " * (progressWidth - pos - 1)
val bar = s" [$prefix?$suffix]\r"
print(bar)
}
private var state = 0
private var longestComment: Int = 0
def paddingLength: Int = progressWidth + 4 + longestComment
private val progressWidth = 20
private val progressStates = Seq(".", "#")
private val totalStates = progressWidth * progressStates.length
private def drawProgressBar(state: Int, comment: String): Unit = {
longestComment = Seq(longestComment, comment.length).max
val stateClamped = Seq(Seq(state, 0).max, totalStates).min
val full =
progressStates.last * (stateClamped / progressStates.length)
val partial = {
val idx = stateClamped % progressStates.length
if (idx > 0) {
progressStates(idx - 1)
} else ""
}
val bar = full + partial
val rest = " " * (progressWidth - bar.length)
val line = s"[$bar$rest] $comment"
val padding = " " * (paddingLength - line.length)
print(" " + line + padding + "\r")
}
}

View File

@ -3,7 +3,6 @@ import java.io.IOException
import sbt.Keys._
import sbt._
import sbt.internal.util.ManagedLogger
import sbt.util.FilesInfo
import scala.sys.process._
@ -51,8 +50,8 @@ object GenerateFlatbuffers {
val projectName = name.value
log.info(
"*** Flatbuffers code generation generated " +
s"${generatedSources.size} files in project $projectName"
s"Flatbuffers code generation generated ${generatedSources.size} " +
s"files in project $projectName."
)
}
}

View File

@ -1,16 +1,37 @@
import java.io.File
import java.nio.file.Path
import sbt.{File, _}
import sbt.{Def, File, _}
import sbt.Keys._
import sbt.internal.util.ManagedLogger
import scala.sys.process._
object NativeImage {
def buildNativeImage(staticOnLinux: Boolean): Def.Initialize[Task[Unit]] =
/**
* Specifies whether the build executable should include debug symbols. Should
* be set to false for production builds. May work only on Linux.
*/
private val includeDebugInfo: Boolean = false
/**
* Creates a task that builds a native image for the current project.
*
* @param artifactName name of the artifact to create
* @param staticOnLinux specifies whether to link statically (applies only
* on Linux)
*/
def buildNativeImage(
artifactName: String,
staticOnLinux: Boolean,
additionalOptions: Seq[String] = Seq.empty
): Def.Initialize[Task[Unit]] =
Def
.task {
val log = state.value.log
val javaHome = System.getProperty("java.home")
val log = state.value.log
val javaHome = System.getProperty("java.home")
val subProjectRoot = baseDirectory.value
val nativeImagePath =
if (isWindows)
s"$javaHome\\bin\\native-image.cmd"
@ -18,17 +39,6 @@ object NativeImage {
val classPath =
(Runtime / fullClasspath).value.files.mkString(File.pathSeparator)
val additionalParameters =
if (staticOnLinux && isLinux)
"--static"
else ""
val resourcesGlobOpt = "-H:IncludeResources=.*Main.enso$"
val cmd =
s"$nativeImagePath $additionalParameters $resourcesGlobOpt " +
s"--no-fallback --initialize-at-build-time" +
s" -cp $classPath ${(Compile / mainClass).value.get} enso"
if (!file(nativeImagePath).exists()) {
log.error("Native Image component not found in the JVM distribution.")
log.error(
@ -40,6 +50,43 @@ object NativeImage {
)
}
val debugParameters =
if (includeDebugInfo) "-H:GenerateDebugInfo=1" else ""
val staticParameters =
if (staticOnLinux && isLinux) {
// Note [Static Build On Linux]
val buildCache =
subProjectRoot / "build-cache"
val path = ensureMuslIsInstalled(buildCache, log)
s"--static -H:UseMuslC=$path"
} else ""
val resourcesGlobOpt = "-H:IncludeResources=.*Main.enso$"
val configLocation =
subProjectRoot / "native-image-config"
val configs =
if (configLocation.exists()) {
val path = configLocation.toPath.toAbsolutePath
log.debug(s"Picking up Native Image configuration from `$path`.")
s"-H:ConfigurationFileDirectories=$path"
} else {
log.debug(
"No Native Image configuration found, proceeding without it."
)
""
}
val cmd =
s"$nativeImagePath $staticParameters $debugParameters " +
s"$resourcesGlobOpt $configs " +
s"--no-fallback --initialize-at-build-time " +
s"${additionalOptions.mkString(" ")} " +
s"-cp $classPath ${(Compile / mainClass).value.get} enso"
log.debug(cmd)
if (cmd.! != 0) {
log.error("Native Image build failed.")
throw new RuntimeException("Native Image build failed")
@ -49,10 +96,20 @@ object NativeImage {
}
.dependsOn(Compile / compile)
/**
* Creates a task which watches for changes of any compiled files or
* dependencies and triggers a rebuild if and only if there are any changes.
*
* @param actualBuild reference to the task doing the actual Native Image
* build, usually one returned by [[buildNativeImage]]
* @param artifactName name of the artifact that is expected to be created
* by the native image build
* @return
*/
def incrementalNativeImageBuild(
actualBuild: TaskKey[Unit],
artifactName: String
) =
): Def.Initialize[Task[Unit]] =
Def.taskDyn {
def rebuild(reason: String) = {
streams.value.log.info(
@ -92,4 +149,87 @@ object NativeImage {
private def artifactFile(name: String): File =
if (isWindows) file(name + ".exe")
else file(name)
private val muslBundleUrl =
"https://github.com/gradinac/musl-bundle-example/releases/download/v1.0/musl.tar.gz"
/**
* Ensures that the `musl` bundle is installed.
*
* Checks for existence of its directory and if it does not exist, downloads
* and extracts the bundle. `musl` is needed for static builds on Linux.
*
* @param buildCache build-cache directory for the current project
* @param log a logger instance
* @return path to the `musl` bundle that can be passed to the Native Image
* as a parameter
*/
private def ensureMuslIsInstalled(
buildCache: File,
log: ManagedLogger
): Path = {
val muslRoot = buildCache / "musl-1.2.0"
val bundleLocation = muslRoot / "bundle"
if (!bundleLocation.exists()) {
log.info(
"`musl` is required for a static build, but it is not installed for " +
"this subproject."
)
try {
log.info("A `musl` bundle will be downloaded.")
buildCache.mkdirs()
val bundle = buildCache / "musl-bundle.tar.gz"
val downloadExitCode = (url(muslBundleUrl) #> bundle).!
if (downloadExitCode != 0) {
log.error("Cannot download `musl` bundle.")
throw new RuntimeException(s"Cannot download `$muslBundleUrl`.")
}
muslRoot.mkdirs()
val tarExitCode = Seq(
"tar",
"xf",
bundle.toPath.toAbsolutePath.toString,
"-C",
muslRoot.toPath.toAbsolutePath.toString
).!
if (tarExitCode != 0) {
log.error(
"An error occurred when extracting the `musl` library bundle."
)
throw new RuntimeException(s"Cannot extract $bundle.")
}
log.info("Installed `musl`.")
} catch {
case e: Exception =>
throw new RuntimeException(
"`musl` installation failed. Cannot proceed with a static " +
"Native Image build.",
e
)
}
}
bundleLocation.toPath.toAbsolutePath
}
}
/* Note [Static Build On Linux]
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* The default `glibc` contains a bug that would cause crashes when downloading
* files form the internet, which is a crucial Launcher functionality. Instead,
* `musl` is suggested by Graal as an alternative libc. The sbt task
* automatically downloads a bundle containing all requirements for a static
* build with `musl`.
*
* Currently, to use `musl`, the `-H:UseMuslC=/path/to/musl/bundle` option has
* to be added to the build. In the future, a `--libc=musl` option may be
* preferred instead, as described at
* https://github.com/oracle/graal/blob/master/substratevm/STATIC-IMAGES.md
* or even `musl` may be included by default. This task may thus need an update
* when moving to a newer version of Graal.
*/