Shebang script files with Java and Docker

Shebang script files with Java and Docker

The implementation of the JEP 330: Launch Single-File Source-Code Programs available from Java 11 allows us to write a script in Java and run it as a shebang file. This is very useful for developers like me, who are not very familiar with scripting with other languages like bash or python.

In this post, I’ll show you how to write a single-file script in Java and run it from the command line. As an extra topic, you’ll learn how to put this file in a Docker image for even easier distribution of your script.

Table of Contents

‘Launch single-file source-code programs’ in Java 11

This new feature introduced in the JDK 11 wants to facilitate running single-file programs in Java without needing the classic two steps: first compile, then run. On top of that, it makes possible to use your script as a shebang file by adding a line like this:

#!/path/to/java --source version

Why am I scripting with Java?

To be honest, I didn’t know about this new feature until late 2019, one year after JDK 11 was released. I didn’t need it before that. In the past, most of the times I’ve needed a quick script I’ve put some bash commands in a .sh file and that made the trick. It’s also true that I spent a lot of time on StackOverflow looking for help to write these bash scripts.

So, when I was thinking about making a docker image with a script that could be used by CI tools to talk to the Quboo API, I first think of creating some bash code. Then I searched online for Java scripting and found this feature and some articles about it.

The main reason for me to use Java in this scenario was my familiarity with the programming language. I can just code in Java without extra help. And, in this case, my requirements were basically reading some environment variables and executing an HTTP request. All of that is possible in vanilla Java.

Writing a script in Java

The goal of the script

As introduced before, the goal of the script is to facilitate the usage of the Quboo API from the command line. If you’re curious about Quboo, just check its website.

The idea is writing a script that can be executed this way:

quboo release "New backend service 'users' released"
(out)OK. Score increased.

Therefore, we want to invoke our script by calling quboo from the command line.

Get the book Practical Software Architecture

The Java file

The full source code is available on Github: quboo-cli project. Did you learn with it? Give it a star!

Below is a representative extract of the Java logic in QubooScriptPlugin.java. Most of the code is not relevant for the sake of this post, but you can get an idea of what the script does.

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Optional;
import java.util.Scanner;

public class QubooScriptPlugin {

    private static final String ENV_ACCESS_KEY = "QUBOO_ACCESS_KEY";
    private static final String ENV_SECRET_KEY = "QUBOO_SECRET_KEY";

    private static final String ENV_QUBOO_USERNAME = "QUBOO_PLAYER_USERNAME";
    private static final String ENV_GITLAB_USERNAME = "GITLAB_USER_LOGIN";
    private static final String ENV_CIRCLE_USERNAME = "CIRCLE_USERNAME";
    private static final String ENV_QUBOO_UNIQUE_ID = "QUBOO_UNIQUE_ID";

    // Pass this variable as 'true' if you want to use always git usernames in Quboo
    private static final String ENV_ALWAYS_USE_GIT = "QUBOO_CONFIG_ALWAYS_USE_GIT";

    private static final String QUBOO_API_SERVER = "https://api.quboo.io";

    public static void main(String[] args) {
        // Check if the auth env vars are properly set
        final String accessKey = System.getenv(ENV_ACCESS_KEY);
        final String secretKey = System.getenv(ENV_SECRET_KEY);
        if (accessKey == null || secretKey == null || accessKey.isBlank() || secretKey.isBlank()) {
            System.err.println("You have to provide valid Quboo access and secret keys via environment variables " +
                    ENV_ACCESS_KEY + " and " + ENV_SECRET_KEY);
            System.exit(1);
        }

        // Configuration
        final boolean alwaysUseGit = Optional.ofNullable(System.getenv(ENV_ALWAYS_USE_GIT))
                .map(Boolean::parseBoolean).orElse(false);

        // Detect the player and unique id using the CI tool vars or manual env var
        final String genericUsername = System.getenv(ENV_QUBOO_USERNAME);
        final String gitlabUsername = System.getenv(ENV_GITLAB_USERNAME);
        final String circleUsername = System.getenv(ENV_CIRCLE_USERNAME);

        String playerName;
        if (alwaysUseGit) { // if set to true, it overrides any other env var
            playerName = getLastGitCommitterFromGit();
        } else if (genericUsername != null) {
            playerName = genericUsername;
        } else if (gitlabUsername != null && !gitlabUsername.isBlank()) {
            playerName = gitlabUsername;
        } else if (circleUsername != null && !circleUsername.isBlank()) {
            // In CircleCI it's possible that you get a blank user if the committer is not a CircleCI user as well
            playerName = circleUsername;
        } else {
            playerName = getLastGitCommitterFromGit();
        }

        String uniqueId;
        final String qubooUniqueId = System.getenv(ENV_QUBOO_UNIQUE_ID);
        if (qubooUniqueId != null && !qubooUniqueId.isBlank()) {
            uniqueId = qubooUniqueId;
        } else {
            uniqueId = getLastGitHashFromGit();
        }

        if (playerName == null || playerName.isBlank()) {
            System.out.println("The script could not derive the player name from environment variables. Please use " +
                    ENV_QUBOO_USERNAME + " to specify the player that will get the Quboo score.");
            System.exit(1);
        }
        if (uniqueId == null || uniqueId.isBlank()) {
            uniqueId = String.valueOf(System.nanoTime());
            System.out.println("WARNING: The script could not get a unique id from environment variable or git. Please use " +
                    ENV_QUBOO_UNIQUE_ID + ", otherwise you will be duplicating the score in future executions.");
        }

        // Check if args are properly set
        String typeOrScore = null, description = null;
        if (args.length == 2) {
            typeOrScore = args[0];
            description = args[1];
        } else {
            System.out.println("Wrong parameters. Usage: \n\n" +
                    "quboo [release|doc|(numeric score)] \"description\"\n\n" +
                    "Examples: \n" +
                    "  - quboo release \"Backend release\"\n" +
                    "  - quboo 50 \"Helping a buddy\"");
            System.exit(1);
        }

        // The type of score determines the endpoint to call
        final String endpoint;
        boolean isScore = false;
        if (typeOrScore.equalsIgnoreCase("release")) {
            endpoint = QUBOO_API_SERVER + "/score/release";
        } else if (typeOrScore.equalsIgnoreCase("doc")) {
            endpoint = QUBOO_API_SERVER + "/score/documentation";
        } else {
            endpoint = QUBOO_API_SERVER + "/score/generic";
            isScore = true;
        }

        // Calls Quboo Server
        final HttpClient http = HttpClient.newHttpClient();
        final HttpRequest request = HttpRequest.newBuilder().header("x-quboo-access-key", accessKey)
                .header("x-quboo-secret-key", secretKey)
                .header("Content-Type", "application/json")
                .uri(URI.create(endpoint))
                .PUT(HttpRequest.BodyPublishers.ofString(
                        "{ \"playerLogin\":\"" + playerName + "\"," +
                                " \"uniqueId\":\"" + uniqueId + "\"," +
                                " \"description\":\"" + description + "\"," +
                                " \"score\":\"" + (isScore ? typeOrScore : 1) + "\"" + // score is ignored if not generic
                                "}"
                )).build();
        try {
            System.out.println("Sending request to " + request.uri());
            final HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() == 200) {
                System.out.println("Score added succesfully. Response: " + response.body());
            } else if (response.statusCode() == 403) {
                System.err.println("Forbidden access to Quboo server. Please verify your access and secret keys.");
            } else {
                System.err.println("Got error response from server (" + response.statusCode() + "). Body: " + response.body());
                System.exit(1);
            }
        } catch (final Exception e) {
            System.err.println("Could not send score to Quboo. Please check your connectivity and also proper usage of CLI.");
            System.err.println("Reason: " + e.getMessage());
            System.exit(1);
        }
    }
    
    // [...] Some other auxiliary methods
}

There are only two important requirements that you have to fulfill if you want to execute this as a single-file script in Java:

  • All the logic has to be in a single file.
  • It needs a public static void main(String[] args) method.

As you may have guessed, you can’t use any extra libraries or frameworks. Just plain Java.

Running the Java script

Using the java command

The most boring way of running this script is using java from the command line:

java --source 13 src/QubooScriptPlugin.java \
(out)> release "An example release"
(out)You have to provide valid Quboo access and secret keys via environment 
(out)variables QUBOO_ACCESS_KEY and QUBOO_SECRET_KEY

Note that you have to use the --source parameter to run this code in script mode, without needing to compile it separately.

Convert the java file into a shebang file

Let’s make it more interesting. Edit your Java script file and add a line like the first one in this fragment (the path to the java executable can be different in your case):

#!/usr/java/openjdk-13/bin/java --source 13
import ...

public class QubooScriptPlugin {
    // ...
}

This makes the Java file a shebang file, and now we can run it without specifying the path to the Java executable. If you’re running on a Windows machine this won’t work for you, but keep reading.

We also need to make the file executable and we can also get rid of the .java extension.

chmod +x QubooScriptPlugin.java
mv QubooScriptPlugin.java quboo

And now we can run our Java shebang file just by using the quboo command.

./quboo release "An example release"

Additionally, we could add this quboo command to one of the default folders for executables in our system. This way we can run our new command from any folder.

cp quboo /usr/local/bin
quboo release "An example release"

This is how you can use a Java script file as a command in your system, using the feature JEP 330: Launch Single-File Source-Code Programs. But, given that the distribution of the script might be a bit tricky, the next section goes about using Docker to make it simpler.

Hey, my IDE went bananas!

At the time of writing this post, IntelliJ IDEA is not able to parse properly the shebang line when you add it to the Java file. This makes coding your script quite inconvenient since you lose a lot of the nice features like the code assistant. Not to mention the screaming red marks from the linter.

This is an extra reason to keep your script as a Java file and post-process it to package it as a shebang file. In my case, I’m wrapping everything together as a Docker image.

Get the book Practical Software Architecture

Dockerize your Java shebang file

If you already had a look at the Quboo CLI Github repository, you noticed that there is a classic Java file there and a Dockerfile that transforms it when packaging it as a Docker image.

This approach is useful for several reasons:

  • The environment is already setup. The script comes together with the JDK version needed and you know in advance where the java binaries are located. I used as a base image the official openjdk:13-slim Docker image.
  • You can keep your script as a Java file to make it easier to edit in your preferred IDE. The Dockerfile commands will take care of transforming it into a shebang file.
The Dockerfile
FROM openjdk:13-slim

RUN apt-get update && apt-get install -y git

COPY src/QubooScriptPlugin.java /usr/src/
WORKDIR /usr/src/
RUN { echo -n '#!/usr/java/openjdk-13/bin/java --source 13 \n'; cat QubooScriptPlugin.java; } > quboo && \
        chmod +x quboo && \
        mv quboo /usr/local/bin

As you can see, the last RUN command takes care of:

  • Assembling the pieces by generating a new file with the #! line plus the Java sources, into a new file named quboo.
  • Making the quboo file executable.
  • Moving it to one of the folders in the path, so this command can be called from any folder.

With that Dockerfile and our Java script in the src folder, we can build the image and run it as follows:

docker build -t quboo-script:1.0 .
(out)...
(out)Successfully tagged quboo-script:1.0
docker run -it quboo-script:1.0 quboo release "An example release"
The full source code is available on Github: quboo-cli project. Did you learn something today? Give it a star!

Common objections about using Java over classic scripting

If we compare the Java language with Bash or Python, it’s fair to say that Java is more verbose. You could reproduce the same functionality shown here in one of those languages using fewer lines of code (even after optimizing some parts). However, if your code has to be read and maintain mainly by Java developers, you shouldn’t care about lines of code.

Another negative point is that you need to have the JDK installed to run a Java script. Bash scripts can be executed in practically any UNIX-based system. On the other hand, if you’re running these scripts in a Docker container and you don’t mind adding JDK to your image, this becomes less of a problem.

An important issue for some people is that you’re limited to the modules included in the JDK. You can’t use any external libraries in your script. Vanilla Java is getting more powerful with every JDK release but many will miss some Apache Commons libraries, just to give an example.

Conclusions

In this post, we covered a real-life example of a shebang file using Java and its Single-File Source-Code Programs feature. We also built it as a Docker image for easier distribution.

Right now, it’s hard to find real-life examples of these scripts. Over time, scripting with Java may become more popular because of the trend in the IT industry to go more and more towards DevOps. Java developers can leverage their expertise and use simple Java scripts around CI/CD tools, inside Docker images, etc.

Get the book Practical Software Architecture

I hope you found this post useful. If you did, please star the Github repo. You may as well consider:

Moisés Macero's Picture

About Moisés Macero

Software Developer, Architect, and Author.
Are you interested in my workshops?

Málaga, Spain https://thepracticaldeveloper.com

Comments