Deploy a Micronaut HTTP API Gateway Function (Serverless) application to Oracle Cloud

Learn how to deploy a Micronaut HTTP API Gateway Function (Serverless) application to Oracle Cloud.

Authors: Burt Beckwith

Micronaut Version: 4.6.3

1. Getting Started

In this guide, we will create a Micronaut application written in Java.

2. What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 11 or greater installed with JAVA_HOME configured appropriately

  • Docker installed

  • A paid or free trial Oracle Cloud account (create an account at signup.oraclecloud.com)

  • Oracle Cloud CLI installed with local access to Oracle Cloud configured by running oci setup config

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the Application

Create an application using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app --features=oracle-function,oracle-cloud-sdk,graalvm example.micronaut.micronautguide --build=gradle --lang=java --jdk=17
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.
If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.

If you use Micronaut Launch, select "Micronaut Application" as application type, JDK version 11 or higher, and add the oracle-function, oracle-cloud-sdk, and graalvm features.

The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.

If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application.

4.1. Dependencies

Add a dependency for oci-java-sdk-core so we have access to classes for managing compute instances:

build.gradle
implementation("com.oracle.oci.sdk:oci-java-sdk-core")

4.2. InstanceData

Then create an InstanceData DTO class to represent Compute Instance properties:

src/main/java/example/micronaut/InstanceData.java
package example.micronaut;

import com.oracle.bmc.core.model.Instance;
import com.oracle.bmc.core.model.Instance.LifecycleState;
import io.micronaut.serde.annotation.Serdeable;

import java.util.Date;

@Serdeable
public class InstanceData {

    private final String availabilityDomain;
    private final String compartmentOcid;
    private final String displayName;
    private final LifecycleState lifecycleState;
    private final String ocid;
    private final String region;
    private final Date timeCreated;

    public InstanceData(Instance instance) {
        availabilityDomain = instance.getAvailabilityDomain();
        compartmentOcid = instance.getCompartmentId();
        displayName = instance.getDisplayName();
        lifecycleState = instance.getLifecycleState();
        ocid = instance.getId();
        region = instance.getRegion();
        timeCreated = instance.getTimeCreated();
    }

    public String getAvailabilityDomain() {
        return availabilityDomain;
    }

    public String getCompartmentOcid() {
        return compartmentOcid;
    }

    public String getDisplayName() {
        return displayName;
    }

    public LifecycleState getLifecycleState() {
        return lifecycleState;
    }

    public String getOcid() {
        return ocid;
    }

    public String getRegion() {
        return region;
    }

    public Date getTimeCreated() {
        return timeCreated;
    }
}

4.3. MicronautguideController

The generated application contains a MicronautguideController class which is good for getting started, but we’ll update it to demonstrate working with OCI SDK APIs, in this case working with Compute Instances.

Replace the generated MicronautguideController with this:

src/main/java/example/micronaut/MicronautguideController.java
package example.micronaut;

import com.oracle.bmc.core.ComputeClient;
import com.oracle.bmc.core.model.Instance;
import com.oracle.bmc.core.requests.GetInstanceRequest;
import com.oracle.bmc.core.requests.InstanceActionRequest;
import com.oracle.bmc.core.responses.InstanceActionResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Post;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static com.oracle.bmc.core.model.Instance.LifecycleState.Running;
import static com.oracle.bmc.core.model.Instance.LifecycleState.Stopped;

@Controller("/compute") (1)
public class MicronautguideController {

    private final Logger log = LoggerFactory.getLogger(getClass().getName());

    private static final String START = "START";
    private static final String STOP = "STOP";

    private final ComputeClient computeClient;

    public MicronautguideController(ComputeClient computeClient) { (2)
        this.computeClient = computeClient;
    }

    @Get("/status/{ocid}") (3)
    public InstanceData status(String ocid) {
        return new InstanceData(getInstance(ocid));
    }

    @Post("/start/{ocid}") (4)
    public InstanceData start(String ocid) {
        log.info("Starting Instance: {}", ocid);

        Instance instance = getInstance(ocid);
        if (instance.getLifecycleState() == Stopped) {
            InstanceActionResponse response = instanceAction(ocid, START);
            log.info("Start response code: {}", response.get__httpStatusCode__());
            instance = response.getInstance();
        } else {
            log.info("The instance was in the incorrect state ({}) to start: {}",
                    instance.getLifecycleState(), ocid);
        }

        log.info("Started Instance: {}", ocid);
        return new InstanceData(instance);
    }

    @Post("/stop/{ocid}") (5)
    public InstanceData stop(String ocid) {
        log.info("Stopping Instance: {}", ocid);

        Instance instance = getInstance(ocid);
        if (instance.getLifecycleState() == Running) {
            InstanceActionResponse response = instanceAction(ocid, STOP);
            log.info("Stop response code: {}", response.get__httpStatusCode__());
            instance = response.getInstance();
        } else {
            log.info("The instance was in the incorrect state ({}) to stop: {}",
                    instance.getLifecycleState(), ocid);
        }

        log.info("Stopped Instance: {}", ocid);
        return new InstanceData(instance);
    }

    private InstanceActionResponse instanceAction(String ocid, String action) {
        InstanceActionRequest request = InstanceActionRequest.builder()
                .instanceId(ocid)
                .action(action)
                .build();
        return computeClient.instanceAction(request);
    }

    private Instance getInstance(String ocid) {
        GetInstanceRequest getInstanceRequest = GetInstanceRequest.builder()
                .instanceId(ocid)
                .build();
        return computeClient.getInstance(getInstanceRequest).getInstance();
    }
}
1 The controller’s root URI is /compute
2 Here we dependency-inject the SDK ComputeClient instance
3 This endpoint accepts GET requests and returns current instance properties for the specified instance OCID
4 This endpoint accepts POST requests and starts the specified instance if it is stopped
5 This endpoint accepts POST requests and stops the specified instance if it is running

5. Testing the Application

The code in this guide should work with recent versions of the Micronaut framework 2.5+, but the tests require at least version 2.5.7

We need to update the generated MicronautguideControllerTest to test the changes we made in MicronautguideController.

This will be a unit test, so we’ll need some mock beans to replace the beans auto-registered by the oracle-cloud-sdk module which make requests to Oracle Cloud. We also need to take into consideration the strict class loader isolation used by Fn Project (which powers Oracle Cloud Functions).

When running our tests, the test and mock classes are loaded by the regular Micronaut framework classloader, but the function invocations are made in a custom Fn classloader, so it is not directly possible to share state between the two, which complicates traditional approaches to mocking. There is support in Fn for sharing classes however, so instead of setting values in mock instances from the test to be used by the controller we’ll set values in a MockData helper class that’s registered as a class that’s shared between classloaders.

Create the MockData class:

src/test/java/example/micronaut/mock/MockData.java
package example.micronaut.mock;

import com.oracle.bmc.core.model.Instance.LifecycleState;

public class MockData {

    public static String instanceOcid = "test-instance-id";
    public static LifecycleState instanceLifecycleState;

    public static void reset() {
        instanceOcid = "test-instance-id";
        instanceLifecycleState = null;
    }
}

Next create the MockAuthenticationDetailsProvider class. The methods all return null since they won’t be called; the bean merely needs to exist for dependency injection:

src/test/java/example/micronaut/mock/MockAuthenticationDetailsProvider.java
package example.micronaut.mock;

import com.oracle.bmc.auth.AuthCachingPolicy;
import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider;
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider;
import io.micronaut.context.annotation.Replaces;
import jakarta.inject.Singleton;

import java.io.InputStream;

@AuthCachingPolicy(cacheKeyId = false, cachePrivateKey = false) (1)
@Singleton
@Replaces(ConfigFileAuthenticationDetailsProvider.class) (2)
public class MockAuthenticationDetailsProvider implements BasicAuthenticationDetailsProvider {

    @Override
    public String getKeyId() {
        return null;
    }

    @Override
    public InputStream getPrivateKey() {
        return null;
    }

    @Override
    public String getPassPhrase() {
        return null;
    }

    @Override
    public char[] getPassphraseCharacters() {
        return null;
    }
}
1 The AuthCachingPolicy annotation disables caching; without this we would need to provide a valid private key since the provider methods would be invoked when constructing SDK client classes
2 We use @Replaces to replace the previously auto-configuration Oracle Cloud authentication method

Next create the MockComputeClient class which will replace the real ComputeClient bean:

src/test/java/example/micronaut/mock/MockComputeClient.java
package example.micronaut.mock;

import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider;
import com.oracle.bmc.core.ComputeClient;
import com.oracle.bmc.core.model.Instance;
import com.oracle.bmc.core.model.Instance.LifecycleState;
import com.oracle.bmc.core.requests.GetInstanceRequest;
import com.oracle.bmc.core.requests.InstanceActionRequest;
import com.oracle.bmc.core.responses.GetInstanceResponse;
import com.oracle.bmc.core.responses.InstanceActionResponse;
import io.micronaut.context.annotation.Replaces;
import jakarta.inject.Singleton;

import static com.oracle.bmc.core.model.Instance.LifecycleState.Starting;
import static com.oracle.bmc.core.model.Instance.LifecycleState.Stopping;

@Singleton
@Replaces(ComputeClient.class) (1)
public class MockComputeClient extends ComputeClient { (2)

    public MockComputeClient(BasicAuthenticationDetailsProvider provider) { (3)
        super(provider);
    }

    @Override
    public GetInstanceResponse getInstance(GetInstanceRequest request) {
        return GetInstanceResponse.builder()
                .instance(buildInstance(MockData.instanceLifecycleState))
                .build();
    }

    @Override
    public InstanceActionResponse instanceAction(InstanceActionRequest request) {
        LifecycleState lifecycleState = "START".equals(request.getAction()) ? Starting : Stopping;
        return InstanceActionResponse.builder()
                .instance(buildInstance(lifecycleState))
                .build();
    }

    private Instance buildInstance(LifecycleState lifecycleState) {
        return Instance.builder()
                .id(MockData.instanceOcid)
                .lifecycleState(lifecycleState)
                .build();
    }
}
1 We use @Replaces to replace a previously auto-registered ComputeClient bean
2 The mock class subclasses the real ComputeClient class and overrides only the methods used by the controller
3 The BasicAuthenticationDetailsProvider bean (in this case the MockAuthenticationDetailsProvider bean created earlier) is dependency-injected because it’s needed by the ComputeClient constructor

Finally, replace the generated MicronautguideControllerTest with this:

src/test/java/example/micronaut/MicronautguideControllerTest.java
package example.micronaut;

import com.oracle.bmc.core.model.Instance.LifecycleState;
import example.micronaut.mock.MockData;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.oraclecloud.function.http.test.FnHttpTest;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.Arrays;
import java.util.List;
import java.util.UUID;

import static com.oracle.bmc.core.model.Instance.LifecycleState.Running;
import static com.oracle.bmc.core.model.Instance.LifecycleState.Stopped;
import static io.micronaut.http.HttpStatus.OK;
import static org.junit.jupiter.api.Assertions.assertEquals;

@MicronautTest
class MicronautguideControllerTest {

    private static final List<Class<?>> SHARED_CLASSES = Arrays.asList( (1)
            MockData.class,
            LifecycleState.class);

    @Test
    void testStatus() {

        String instanceOcid = UUID.randomUUID().toString();
        MockData.instanceOcid = instanceOcid; (2)
        MockData.instanceLifecycleState = Running;

        HttpResponse<String> response = FnHttpTest.invoke( (3)
                HttpRequest.GET("/compute/status/" + instanceOcid),
                SHARED_CLASSES);

        assertEquals(OK, response.status());
        assertEquals(
                "{\"lifecycleState\":\"Running\",\"ocid\":\"" + instanceOcid + "\"}", (4)
                response.body());
    }

    @Test
    void testStart() {

        String instanceOcid = UUID.randomUUID().toString();
        MockData.instanceOcid = instanceOcid;
        MockData.instanceLifecycleState = Stopped;

        HttpResponse<String> response = FnHttpTest.invoke(
                HttpRequest.POST("/compute/start/" + instanceOcid, null),
                SHARED_CLASSES);

        assertEquals(OK, response.status());
        assertEquals(
                "{\"lifecycleState\":\"Starting\",\"ocid\":\"" + instanceOcid + "\"}",
                response.body());
    }

    @Test
    void testStop() {

        String instanceOcid = UUID.randomUUID().toString();
        MockData.instanceOcid = instanceOcid;
        MockData.instanceLifecycleState = Running;

        HttpResponse<String> response = FnHttpTest.invoke(
                HttpRequest.POST("/compute/stop/" + instanceOcid, null),
                SHARED_CLASSES);

        assertEquals(OK, response.status());
        assertEquals(
                "{\"lifecycleState\":\"Stopping\",\"ocid\":\"" + instanceOcid + "\"}",
                response.body());
    }

    @AfterEach
    void cleanup() {
        MockData.reset();
    }
}
1 The MockData and LifecycleState need to be passed to test function invocations as shared classes
2 Here we set data to be used by the mocks in the MockData class
3 The controller is invoked with the FnHttpTest class that, along with MockFnHttpServer, provides a bridge between Micronaut controllers and an Fn Project gateway in tests
4 We expect the String response to be the JSON generated from the InstanceData returned by the controller

To run the tests:

./gradlew test

Then open build/reports/tests/test/index.html in a browser to see the results.

6. Configuring Oracle Cloud Resources

We need to configure some cloud infrastructure to support deploying functions.

Initially, do all the configuration steps described in the Deploy a Micronaut Function (Serverless) application to Oracle Cloud guide’s "Configuring Oracle Cloud Resources" section since they’re the same as for HTTP Gateway functions. To summarize, do the following (unless a resource exists and can be used):

  • create a compartment

  • create a function user and group

  • create an auth token

  • configure the OCIR repository in your build script and authenticate to OCIR

  • create a VCN and subnet

  • create policies

There is some more infrastructure configuration to do, but we’ll need to create the function first.

7. Creating the function

First, build the function as a Docker image and push it to the OCIR repository by running:

./gradlew dockerPush

Once you’ve pushed the Docker container, create the function in the console. First, log out from your administrator account and log in as the user created above.

Open the Oracle Cloud Menu and click "Developer Services", and then "Applications" under "Functions":

function1

Click "Create Application":

function2

Choose a name for the application, e.g. mn-guide-http-function-app, and select the VCN created earlier. Select the private subnet, and click "Create":

function3

Click "Functions" under "Resources" on the left, and then click "Create Function":

function4

Choose a name for the function, e.g. mn-guide-http-function, select the repository where you pushed the Docker image, and select the uploaded image. Select 512MB memory and click "Create":

function5

8. Configuring Oracle Cloud Resources (continued)

Like earlier, do all the configuration steps described in the Deploy a Micronaut Function (Serverless) application to Oracle Cloud guide’s "Enable Tracing and Logs" section since they’re the same as for HTTP Gateway functions. To summarize, do the following (unless a resource exists and can be used):

  • create an APM domain

  • enable logs for your HTTP function

  • enable traces for your HTTP function

Next we’ll create an API Gateway, plus a few smaller tasks.

8.1. API Gateway

Create an API gateway by clicking the Oracle Cloud menu and selecting "Developer Services", and then click "Gateways":

gateway1

Click "Create Gateway"

gateway2

then choose a name, e.g. mn-guide-gateway, then choose a compartment, VCN, and subnet as before:

gateway3

Click "Deployments", then "Create Deployment":

gateway4

Choose a name for the deployment (e.g. mn-guide-deployment), and use the controller’s root URI (/compute) as the "Path Prefix" value, then click "Next".

gateway5

Enter /{path*} as the "Path" value to capture all incoming requests; the Micronaut router will match the incoming path and request method with the proper controller method. Choose ANY under "Methods", and Oracle Functions as the "Type". Choose mn-guide-http-function-app as the "Application" and mn-guide-http-function as the "Function Name", then click "Next":

gateway6

Verify that everything looks ok and click "Create":

gateway7

Click the "Copy" link in the "Endpoint" column; this is the base controller URL which will be needed later when testing the function:

gateway8

See the API Gateway docs for more information.

8.2. Remaining Configuration

8.2.1. Ingress Rule

First, add an ingress rule for HTTPS on port 443. Open the Oracle Cloud Menu and click "Networking", then "Virtual Cloud Networks":

vcn1

Click the link for mn-functions-vcn:

ingress1

Then click "Security Lists", and click the link for "Default Security List for mn-functions-vcn":

ingress2

Then click "Add Ingress Rules":

ingress3

Enter 0.0.0.0/0 for the source CIDR value, and 433 for the destination port range, and click "Add Ingress Rules":

ingress4

Next we need to grant the function permission to access other cloud resources, in this case compute instances. That will involve creating a dynamic group and adding a new policy statement.

8.2.2. Dynamic Group

Create a Dynamic Group by clicking the Oracle Cloud menu and selecting "Identity & Security", and then click "Dynamic Groups":

dynamicgroup1

Click "Create Dynamic Group":

dynamicgroup2

Then enter a name and description for the group, e.g. "mn-guide-dg", and a matching rule, i.e. the logic that will be used to determine group membership. We’ll make the rule fairly broad - enter ALL {resource.type = 'fnfunc', resource.compartment.id = 'ocid1.compartment.oc1..aaaaaxxxxx'} replacing ocid1.compartment.oc1..aaaaaxxxxx with the compartment OCID where you’re defining your functions, and click "Create":

dynamicgroup3

See the Dynamic Group docs for more information.

8.2.3. Dynamic Group Policy Statement

Next create a policy statement granting members of the dynamic group permission to manage compute instances. Open the Oracle Cloud Menu and click "Identity & Security", and then "Policies":

policy1

Click the link for the Policy you created earlier (i.e. mn-functions-compartment-policy):

policy2

Then click "Edit Policy Statements":

policy3

Click "+ Another Statement":

policy4

and enter Allow dynamic-group mn-guide-dg to manage instances in compartment <compartment-name>, replacing <compartment-name> with the compartment OCID where you’re defining your functions, and click "Save Changes":

policy5

9. Invoking the function

Since the function works with Compute Instances, make sure you have at least one running. If you don’t have any, one easy option is with the Deploy a Micronaut application to Oracle Cloud guide.

Now is when you need the base controller URL that you copied when creating the API Gateway; it should look something like https://cjrgh5e3lfqz…​.apigateway.us-ashburn-1.oci.customer-oci.com/compute and end in /compute since that’s the root URI of the controller.

First, get the status of an instance in a web browser or with cURL by appending /status/INSTANCE_OCID to the base controller URL, replacing INSTANCE_OCID with the OCID of the Compute Instance to query:

curl -i https://cjrgh5e3lfqz....apigateway.us-ashburn-1.oci.customer-oci.com/compute/status/ocid1.instance.oc1.iad.anuwcljrbnqp5k...

and the output should look something like this:

{
"availabilityDomain":"nFuS:US-ASHBURN-AD-1",
"compartmentOcid":"ocid1.compartment.oc1..aaaaaaaarkh3s2wcxbbmqnj...",
"displayName":"dribneb",
"lifecycleState":"RUNNING",
"ocid":"ocid1.instance.oc1.iad.anuwcljrbnqp5k...",
"region":"iad",
"timeCreated":1624594779093
}
You can also invoke the /status action in a web browser since it’s a GET method, but the others require cURL or some other application that can make POST requests

The first invocation ("cold start") will take a while as the infrastructure is configured, probably 10-20 seconds or more but subsequent invocations should return in 1-2 seconds.

Next, stop the instance with the same URL, except replace /status/ with /stop/:

curl -i -H "Content-Type: application/json" -X POST https://cjrgh5e3lfqz....apigateway.us-ashburn-1.oci.customer-oci.com/compute/stop/ocid1.instance.oc1.iad.anuwcljrbnqp5k...

and the output should look something like this (it should be the same as before except lifecycleState should be STOPPING):

{
"availabilityDomain":"nFuS:US-ASHBURN-AD-1",
"compartmentOcid":"ocid1.compartment.oc1..aaaaaaaarkh3s2wcxbbmqnj...",
"displayName":"dribneb",
"lifecycleState":"STOPPING",
"ocid":"ocid1.instance.oc1.iad.anuwcljrbnqp5k...",
"region":"iad",
"timeCreated":1624594779093
}

Once the status is STOPPED you can start it again with the same URL, except replace /stop/ with /start/:

curl -i -H "Content-Type: application/json" -X POST https://cjrgh5e3lfqz....apigateway.us-ashburn-1.oci.customer-oci.com/compute/start/ocid1.instance.oc1.iad.anuwcljrbnqp5k...

and the output should look something like this (it should be the same as before except lifecycleState should be STARTING):

{
"availabilityDomain":"nFuS:US-ASHBURN-AD-1",
"compartmentOcid":"ocid1.compartment.oc1..aaaaaaaarkh3s2wcxbbmqnj...",
"displayName":"dribneb",
"lifecycleState":"STARTING",
"ocid":"ocid1.instance.oc1.iad.anuwcljrbnqp5k...",
"region":"iad",
"timeCreated":1624594779093
}

10. Deploying as a Native Executable

10.1. Install GraalVM

We will use GraalVM, an advanced JDK with ahead-of-time Native Image compilation, to generate a native executable of this Micronaut application.

Compiling Micronaut applications ahead of time with GraalVM significantly improves startup time and reduces the memory footprint of JVM-based applications.

Only Java and Kotlin projects support using GraalVM’s native-image tool. Groovy relies heavily on reflection, which is only partially supported by GraalVM.

The easiest way to install GraalVM on Linux or Mac is to use SDKMan.io.

Java 21
sdk install java 21.0.5-graal

For installation on Windows, or for a manual installation on Linux or Mac, see the GraalVM Getting Started documentation.

The previous command installs Oracle GraalVM, which is free to use in production and free to redistribute, at no cost, under the GraalVM Free Terms and Conditions.

Alternatively, you can use the GraalVM Community Edition:

Java 21
sdk install java 21.0.2-graalce

10.2. Building and deploying the native executable

Deploying the function as a native executable is similar to the earlier deployment above.

First you need to update your build script with the location to deploy the native executable Docker container.

Edit build.gradle like before, but set the images property in the dockerBuildNative block this time, replacing REGION, TENANCY, and REPO as before:

build.gradle
dockerBuildNative {
    images = ["[REGION].ocir.io/[TENANCY]/[REPO]/$project.name-native:$project.version"]
}

Since it’s unlikely that you’ll be deploying both jar-based containers and native executable-based containers, you can use the same repo:

build.gradle
dockerBuildNative {
    images = ["[REGION].ocir.io/[TENANCY]/[REPO]/$project.name:$project.version"]
}

Next, update the version.

Edit build.gradle and increment the version to 0.2:

build.gradle
version = "0.2"

Depending on the Micronaut version you’re using, you might also need to update some properties in your build script to update the Docker configuration.

In your build.gradle, change the base image to gcr.io/distroless/cc-debian10 in the dockerfileNative block:

build.gradle
dockerfileNative {
    args("-XX:MaximumHeapSizePercent=80")
    baseImage('gcr.io/distroless/cc-debian10')
}

Then from the demo project directory, run:

./gradlew dockerPushNative

Once you’ve pushed the Docker container, edit the function in the console to use the new container, and to reduce the memory to 128MB:

editfunction

Use the same OCI command as before to invoke the function. No changes are needed because the function OCID doesn’t change when deploying new containers.

11. Next Steps

Explore more features with Micronaut Guides.

Read more about the Micronaut Oracle Cloud integration.

Also check out the Oracle Cloud Function documentation for more information on the available functionality.

12. License

All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…​).