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: 3.6.0

1. Getting Started

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

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=kotlin --jdk=11
If you don’t specify the --build argument, Gradle is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.

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. Enable annotation Processing

If you use Java or Kotlin and IntelliJ IDEA, make sure to enable annotation processing.

annotationprocessorsintellij

4.2. 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.3. InstanceData

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

src/main/kotlin/example/micronaut/InstanceData.kt
package example.micronaut

import com.oracle.bmc.core.model.Instance
import com.oracle.bmc.core.model.Instance.LifecycleState
import io.micronaut.core.annotation.Introspected
import java.util.Date

@Introspected
class InstanceData(instance: Instance) {
    val availabilityDomain: String
    val compartmentOcid: String
    val displayName: String
    val lifecycleState: LifecycleState
    val ocid: String
    val region: String
    val timeCreated: Date

    init {
        availabilityDomain = instance.availabilityDomain
        compartmentOcid = instance.compartmentId
        displayName = instance.displayName
        lifecycleState = instance.lifecycleState
        ocid = instance.id
        region = instance.region
        timeCreated = instance.timeCreated
    }
}

4.4. 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/kotlin/example/micronaut/MicronautguideController.kt
package example.micronaut

import com.oracle.bmc.core.ComputeClient
import com.oracle.bmc.core.model.Instance.LifecycleState.Running
import com.oracle.bmc.core.model.Instance.LifecycleState.Stopped
import com.oracle.bmc.core.requests.GetInstanceRequest
import com.oracle.bmc.core.requests.InstanceActionRequest
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import org.slf4j.LoggerFactory

@Controller("/compute") (1)
class MicronautguideController(private val computeClient: ComputeClient) { (2)

    private val log = LoggerFactory.getLogger(javaClass.name)

    @Get("/status/{ocid}") (3)
    fun status(ocid: String) = InstanceData(getInstance(ocid))

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

        var instance = getInstance(ocid)
        if (instance.lifecycleState == Stopped) {
            val response = instanceAction(ocid, START)
            log.info("Start response code: {}", response.__httpStatusCode__)
            instance = response.instance
        } else {
            log.info("The instance was in the incorrect state ({}) to start: {}",
                    instance.lifecycleState, ocid)
        }

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

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

        var instance = getInstance(ocid)
        if (instance.lifecycleState == Running) {
            val response = instanceAction(ocid, STOP)
            log.info("Stop response code: {}", response.__httpStatusCode__)
            instance = response.instance
        } else {
            log.info("The instance was in the incorrect state ({}) to stop: {}",
                    instance.lifecycleState, ocid)
        }

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

    private fun instanceAction(ocid: String, action: String) =
            computeClient.instanceAction(InstanceActionRequest.builder()
                    .instanceId(ocid)
                    .action(action)
                    .build())

    private fun getInstance(ocid: String) =
            computeClient.getInstance(GetInstanceRequest.builder()
                    .instanceId(ocid)
                    .build()).instance

    companion object {
        private const val START = "START"
        private const val STOP = "STOP"
    }
}
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/kotlin/example/micronaut/mock/MockData.kt
package example.micronaut.mock

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

object MockData {

    var instanceOcid = "test-instance-id"
    var instanceLifecycleState: LifecycleState? = null

    fun 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/kotlin/example/micronaut/mock/MockAuthenticationDetailsProvider.kt
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 java.io.InputStream
import jakarta.inject.Singleton

@AuthCachingPolicy(cacheKeyId = false, cachePrivateKey = false) (1)
@Singleton
@Replaces(ConfigFileAuthenticationDetailsProvider::class) (2)
class MockAuthenticationDetailsProvider : BasicAuthenticationDetailsProvider {
    override fun getKeyId(): String? = null
    override fun getPrivateKey(): InputStream? = null
    override fun getPassPhrase(): String? = null
    override fun getPassphraseCharacters(): CharArray? = 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/kotlin/example/micronaut/mock/MockComputeClient.kt
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.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

@Singleton
@Replaces(ComputeClient::class) (1)
class MockComputeClient(provider: BasicAuthenticationDetailsProvider) (3)
    : ComputeClient(provider) { (2)

    override fun getInstance(request: GetInstanceRequest) =
            GetInstanceResponse.builder()
                    .instance(buildInstance(MockData.instanceLifecycleState!!))
                    .build()

    override fun instanceAction(request: InstanceActionRequest): InstanceActionResponse {
        val lifecycleState = if ("START" == request.action)
            Instance.LifecycleState.Starting else
            Instance.LifecycleState.Stopping
        return InstanceActionResponse.builder()
                .instance(buildInstance(lifecycleState))
                .build()
    }

    private fun buildInstance(lifecycleState: Instance.LifecycleState) =
            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/kotlin/example/micronaut/MicronautguideControllerTest.kt
package example.micronaut

import com.oracle.bmc.core.model.Instance.LifecycleState
import com.oracle.bmc.core.model.Instance.LifecycleState.Running
import com.oracle.bmc.core.model.Instance.LifecycleState.Stopped
import example.micronaut.mock.MockData
import example.micronaut.mock.MockData.reset
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus.OK
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.Assertions.assertEquals
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import java.util.UUID

@MicronautTest
@Disabled("Kotlin tests currently fail due to issue with Fn")
class MicronautguideControllerTest {

    @Test
    fun testStatus() {
        val instanceOcid = UUID.randomUUID().toString()
        MockData.instanceOcid = instanceOcid; (2)
        MockData.instanceLifecycleState = Running;

        val response = FnHttpTest.invoke( (3)
                HttpRequest.GET<Any>("/compute/status/$instanceOcid"),
                SHARED_CLASSES)
        assertEquals(OK, response.status())
        assertEquals(
                "{\"lifecycleState\":\"Running\",\"ocid\":\"$instanceOcid\"}", (4)
                response.body())
    }

    @Test
    fun testStart() {
        val instanceOcid = UUID.randomUUID().toString()
        MockData.instanceOcid = instanceOcid;
        MockData.instanceLifecycleState = Stopped;

        val response = FnHttpTest.invoke(
                HttpRequest.POST<Any?>("/compute/start/$instanceOcid", null),
                SHARED_CLASSES)

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

    @Test
    fun testStop() {
        val instanceOcid = UUID.randomUUID().toString()
        MockData.instanceOcid = instanceOcid;
        MockData.instanceLifecycleState = Running;

        val response = FnHttpTest.invoke(
                HttpRequest.POST<Any?>("/compute/stop/$instanceOcid", null),
                SHARED_CLASSES)

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

    @AfterEach
    fun cleanup() {
        reset()
    }

    companion object {
        private val SHARED_CLASSES = listOf( (1)
                MockData::class.java,
                LifecycleState::class.java)
    }
}
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, the polyglot embeddable virtual machine, to generate a Native executable of our function.

Compiling native executables ahead-of-time with GraalVM improves startup time and reduces the memory footprint of JVM-based applications and functions.

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 11
sdk install java 22.1.0.r11-grl
If you still use Java 8, use the JDK11 version of GraalVM.
Java 17
sdk install java 22.1.0.r17-grl

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

After installing GraalVM, install the native-image component, which is not installed by default:

gu install native-image

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.