Quarkus Extension Custom Dev Service - Partie II

In the previous article, we saw how to automatically launch a database using a Quarkus extension. Now, we will see how to start our service in the same way and make it use the database we just deployed. I won’t go over the points covered in the previous article, so I encourage you to read it before continuing.

We take the same ones and start again.

The creation of our container for our service happens in the same way as the database. So, we’ll start by adding the necessary configurations to our DevServicesConfig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
    /**
     * The container image name to use, for container based DevServices providers.
     * If you want to use a specific version of demo, use:
     * {@code demo:<version>}.
     */
    @WithDefault("scandinave/demo")
    String imageName();

    /**
     * Optional fixed port the dev service will listen to.
     * <p>
     * If not defined, the port will be chosen randomly.
     */
    OptionalInt port();

    /**
     * The value of the {@code quarkus-dev-service-demo} label attached to the started container.
     * This property is used when {@code shared} is set to {@code true}.
     * In this case, before starting a container, Dev Services for Demo looks for a container with the
     * {@code quarkus-dev-service-demo} label set to the configured value. 
     * If found, it will use this container instead of starting a new one. Otherwise, it
     * starts a new container with the {@code quarkus-dev-service-demo} label set to the specified value.
     * <p>
     */
    @WithDefault("demo")
    String serviceName();

    /**
     * Environment variables that are passed to the container.
     */
    Map<String, String> containerEnv();

We begin by adding an option for the Docker image name that will be used to start our container. This way, the user can choose a specific version if they wish. The port is random by default but can also be specified. Finally, the label applied to the container can also be customized.

DemoExtensionProcessor

In our DemoExtensionProcessor, we add the code to initialize the container of our service.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
  @BuildSteps(onlyIf = {GlobalDevServicesConfig.Enabled.class})
  public class DemoExtensionProcessor {
  
    /** stuff from previous article 
     * ...
     */

    private static volatile RunningDevService demoApiDevService;

    @BuildStep
    public void createContainer(DockerStatusBuildItem dockerStatusBuildItem,
                                List<DevServicesSharedNetworkBuildItem> devServicesSharedNetworkBuildItem,
                                DemoBuildTimeConfig buildTimeConfig,
                                GlobalDevServicesConfig devServicesConfig,
                                BuildProducer<DevServicesResultBuildItem> devServicesResult) {

      runningConfiguration = buildTimeConfig.devservices();

      try {
          /** 
           * DemoApiRunningDevService newAPIDevService = startDemoContainer...
           */

           DemoApiRunningDevService newAPIDevService = startDemoContainer(
                    dockerStatusBuildItem,
                    !devServicesSharedNetworkBuildItem.isEmpty(),
                    devServicesConfig.timeout.orElse(Duration.of(0, ChronoUnit.SECONDS)),
                    runningConfiguration,
                    newDatabaseDevService,
                    errors
            );
          // Get the RunningDevService instance.
          demoApiDevService = newAPIDevService.getRunningDevService();
      } catch (Throwable t) {
          throw new RuntimeException(t);
      }
      Log.info("Dev Services for Demo started.");

      devServicesResult.produce(demoApiDevService.toBuildItem());
    }
  }

Our startDemoContainer method also takes as a parameter the result of the call to the startDatabaseContainer method, which contains an instance of a DevRunningDevService. In this instance, we stored the URL for accessing our database. I mentioned earlier that this would be useful later.

1
2
3
4
5
6
7
return new DevServicesResultBuildItem.RunningDevService(feature, databaseContainer.getContainerId(),
        databaseContainer::close,
        Map.of(
            "url", databaseContainer.getHost() + ":" + databaseContainer.getPort(),
            "url-internal", databaseContainer.getContainerName().substring(1) + ":" + POSTGRES_INTERNAL_PORT
        )
);

startDemoContainer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

private DemoApiRunningDevService startDemoContainer(DockerStatusBuildItem dockerStatusBuildItem, boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, DemoDatabaseRunningDevService runningService, List<String> errors) {
    if (!devServicesConfig.enabled()) {
        // explicitly disabled
        Log.debug("Not starting Dev Services for Demo as it has been disabled in the config");
        return null;
    }


    if (!dockerStatusBuildItem.isDockerAvailable()) {
        Log.warn("Please get a working docker instance");
        errors.add("Please get a working docker instance");
        return null;
    }

    String dbUrl = Optional.ofNullable(runningService.getRunningDevService().getConfig()).orElseThrow(() -> new IllegalStateException("Database does not start properly")).get("url-internal");

    final Optional<ContainerAddress> maybeExistingContainer = demoAPIDevModeContainerLocator.locateContainer(
            devServicesConfig.serviceName(),
            devServicesConfig.shared(),
            LaunchMode.current());

    String imageName = devServicesConfig.imageName();
    DockerImageName dockerImageName = DockerImageName.parse(imageName).asCompatibleSubstituteFor(imageName);


    return maybeExistingContainer
            .map(existingContainer -> new DemoApiRunningDevService(new RunningDevService(DemoApiContainer.CONTAINER_NAME, existingContainer.getId(),
                    null, Map.of("url", existingContainer.getHost() + ":" + existingContainer.getPort()))))
            .orElseGet(() -> new DemoApiRunningDevService(useSharedNetwork, timeout, devServicesConfig, dbUrl, dockerImageName, runningService));
}
}

We start with the same checks regarding the presence of Docker and the activation of dev services, then we retrieve the internal Docker connection URL for the database from our instance of RunningDevService. The method then checks if another container already exists using a new ContainerLocator:

1
    private static final ContainerLocator demoAPIDevModeContainerLocator = new ContainerLocator(DemoApiContainer.DEV_SERVICE_LABEL, DEMO_INTERNAL_PORT);

If a container for our service is already running, the method returns a RunningDevService with the information of the existing container. Otherwise, a new RunningDevService for the new container is returned. This new RunningDevService takes the database URL as a parameter.

DemoApiRunningDevService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class DemoApiRunningDevService {

    private DemoApiContainer demoContainer;
    private final DevServicesResultBuildItem.RunningDevService runningDevServiceSupplier;

    public DemoApiRunningDevService(boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, String dbUrl, DockerImageName
            dockerImageName, DemoDatabaseRunningDevService runningService) {
        runningDevServiceSupplier = this.run(useSharedNetwork, timeout, devServicesConfig, dbUrl, dockerImageName, runningService);
    }

    public DemoApiRunningDevService(DevServicesResultBuildItem.RunningDevService runningDevService) {
        runningDevServiceSupplier = runningDevService;
        demoContainer = null;
    }


    public DevServicesResultBuildItem.RunningDevService run(boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, String dbUrl, DockerImageName dockerImageName, DemoDatabaseRunningDevService runningService) {
        if (devServicesConfig.port().isPresent()) {
            try (DemoApiContainer container = new DemoApiContainer(dockerImageName, devServicesConfig.port().getAsInt(), useSharedNetwork, devServicesConfig.shared(), devServicesConfig.serviceName())) {
                demoContainer = container;
            }
        } else {
            try (DemoApiContainer container = new DemoApiContainer(dockerImageName, useSharedNetwork, devServicesConfig.shared(), devServicesConfig.serviceName())) {
                demoContainer = container;
            }
        }

        Optional.ofNullable(timeout).ifPresent(demoContainer::withStartupTimeout);
        demoContainer.withEnv(devServicesConfig.containerEnv());


        demoContainer.withEnv("POSTGRES_USER", devServicesConfig.db().username());
        demoContainer.withEnv("POSTGRES_PASSWORD", devServicesConfig.db().password());
        demoContainer.withEnv("DB_URL", dbUrl + "/" + devServicesConfig.db().name());
        if (runningService.getDatabaseContainer().isPresent()) { // Wait for database container to start. If container is null, it means that a database was already started so no need to wait.
            demoContainer.withNetwork(runningService.getDatabaseContainer().get().getNetwork());
            demoContainer.dependsOn(runningService.getDatabaseContainer().get());
        }

        demoContainer.start();

        return new DevServicesResultBuildItem.RunningDevService(DemoApiContainer.CONTAINER_NAME, demoContainer.getContainerId(),
                demoContainer::close, Map.of("url", demoContainer.getHost() + ":" + demoContainer.getPort()));

    }

    public Optional<GenericContainer<DemoApiContainer>> getDatabaseContainer() {
        return Optional.ofNullable(demoContainer);
    }

    public DevServicesResultBuildItem.RunningDevService getRunningDevService() {
        return runningDevServiceSupplier;
    }
}

Our class defines two constructors: the first that simply returns the existing RunningDevService if the container already exists, and the second that creates a new RunningDevService. Our run method first checks if a port is specified in the dev-service configuration. If it is, our container will be started on that port; otherwise, an available random port will be allocated (the default behavior).

Once the instance of our DemoApiContainer is created, we configure it by passing the expected environment variables through the withEnv method. We use our devServiceConfig to retrieve the database connection credentials and the URL passed as a parameter. Then, we configure the container to be on the same network as the database and wait for it to finish starting.

Finally, we return our RunningDevService, containing the ID of our container as well as the configuration that includes the URL for accessing our API, which will also be useful for the next steps.

DemoApiContainer

All that remains is to look at the DemoApiContainer class to complete this second post on creating a DevService with Quarkus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import io.quarkus.devservices.common.ConfigureUtil;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;

public class DemoApiContainer extends GenericContainer<DemoApiContainer> {

    public static final String CONTAINER_NAME = "demo";
    public static final String DEV_SERVICE_LABEL="quarkus-dev-service-demo";
    private final Integer exposedPort;
    public static final int DEMO_INTERNAL_PORT = 8600;
    private final boolean useSharedNetwork;
    private final String serviceName;
    private String hostname;
    private final boolean sharedContainer;

    public DemoApiContainer(DockerImageName image, boolean useSharedNetwork, boolean isShared, String serviceName) {
        this(image, null, useSharedNetwork, isShared, serviceName);
    }

    public DemoApiContainer(DockerImageName image, Integer exposedPort, boolean useSharedNetwork, boolean isShared, String serviceName) {
        super(image);
        this.exposedPort = exposedPort;
        this.useSharedNetwork = useSharedNetwork;
        this.sharedContainer = isShared;
        this.serviceName = serviceName;


    }

    @Override
    protected void configure() {
        super.configure();

        if (this.useSharedNetwork) {
            hostname = ConfigureUtil.configureSharedNetwork(this, "demo");
        }

        if(this.exposedPort != null) {
          addFixedExposedPort(this.exposedPort, DEMO_INTERNAL_PORT);
          if(useSharedNetwork) {
              // expose random port for which we are able to ask Testcontainers for the actual mapped port at runtime
              // as from the host's perspective Testcontainers actually expose container ports on random host port
              addExposedPort(DEMO_INTERNAL_PORT);
          }
        } else {
            addExposedPort(DEMO_INTERNAL_PORT);
        }

        withLabel(DEV_SERVICE_LABEL, serviceName);

        // Tell the dev service how to know the container is ready
        waitingFor(Wait.forLogMessage(".*demo.*started.*", 1));
    }

    @Override
    public String getHost() {
        if(this.useSharedNetwork) {
            return hostname;
        }
        return super.getHost();
    }

    public int getPort() {
        if (useSharedNetwork) {
            return DEMO_INTERNAL_PORT;
        }
        if (exposedPort != null) {
            return exposedPort;
        }
        return getFirstMappedPort();
    }
}

I won’t dwell on this class as it is identical to that of our DemoDatabaseContainer. The only difference is the wait for the event indicating the end of the process startup in the container. Here, we wait for a log in our application containing the words demo and started.

Conclusion

Our extension now launches a dockerized service along with its database, all automatically configured to work together. Since we already included the extension in the previous step, there is nothing more we need to do to benefit from this update.

In a future article, we will see how to interact with our API using the URL saved in our RunningDevService and how to provide an SDK that allows this API to be exploited through a simple dependency injection into our client application.

0%