Helidon Integration with Oracle Cloud Infrastructure

Tomas Langer
Helidon
Published in
7 min readMay 19, 2021

Cloud integration is part of microservices development, and for Helidon it was only natural that we would start with Oracle Cloud Infrastructure (OCI).

Note: OCI integration with Helidon is still experimental and not intended for production use. Some APIs and features are not yet fully tested and are subject to change.

When designing our integration with OCI, we considered two options — use the existing OCI SDK or develop a custom integration (using OCI REST API). Ultimately we chose to build a custom integration, as we need non-blocking, reactive implementation for our reactive libraries (Helidon SE). We also want to prevent dependency conflicts, as SDK depends on Jersey, and Jersey is one of the main building blocks of our MicroProfile-based libraries (Helidon MP).

The first (and experimental) version available with Helidon 2.3.0 supports OCI config’s automatic discovery (using ~/.oci/configto read the information). We are also looking into Instance principal security (when running on VMs) and Resource principal security (when running in Function), which will be available either with 2.3.0 or the next release.

We have chosen the following features of OCI to implement initially:

  • OCI Vault — supports secrets, encryption/decryption, and signature/verification
  • OCI Object Storage — supports uploading, downloading and deleting objects
  • OCI Telemetry Metrics — supports posting of metrics to OCI

Now we can look at integration in both reactive Helidon SE and MP worlds!

Connectivity Setup

Integration module needs information for connectivity, such as tenancy OCID, private key (for request signatures) and other parameters that are available in the file ~/.oci/config. See Required Keys and OCIDs for more information on generating an API signing key for console.

The same information can also be provided in configuration:

oci.user=User OCID
oci.fingerprint=Key Fingerprint
oci.tenancy=Tenancy OCID
oci.region=Region, such as eu-frankfurt-1
oci.key-pem=PEM encoded private key

Dependencies

The following modules form the features for OCI integration (both for Helidon MP and SE). Examples can be seen below for Object Storage and Vault in both Helidon MP and SE.

  • Object Storage
<dependency>
<groupId>io.helidon.integrations.oci</groupId>
<artifactId>helidon-integrations-oci-objectstorage</artifactId>
</dependency>
  • Vault
<dependency>
<groupId>io.helidon.integrations.oci</groupId>
<artifactId>helidon-integrations-oci-vault</artifactId>
</dependency>
  • Telemetry — Metrics
<dependency>
<groupId>io.helidon.integrations.oci</groupId>
<artifactId>helidon-integrations-oci-telemetry</artifactId>
</dependency>

Helidon MP

The following dependency is required when using OCI integration in Helidon MP:

<dependency>
<groupId>io.helidon.integrations.oci</groupId>
<artifactId>helidon-integrations-oci-cdi</artifactId>
</dependency>

Object Storage

Let's start with the required configuration (using META-INF/microprofile-config.properties format):

Bucket name and object storage namespace from OCI
oci.objectstorage.namespace=namespace from OCI Object Storage
oci.objectstorage.bucket=bucket name from OCI Object Storage

And injection into our JAX-RS resource class:

private final OciObjectStorage objectStorage;
private final String bucketName;

@Inject
ObjectStorageResource(OciObjectStorage objectStorage,
@ConfigProperty(name = "oci.objectstorage.bucket")
String bucketName) {
this.objectStorage = objectStorage;
this.bucketName = bucketName;
}

The namespace is automatically picked-up by the implementation, the bucket is used explicitly in API.

JAX-RS method that will download an object as a file. This takes into account the possibility the object does not exist (so we return a 404):

@GET
@Path("/file/{file-name}")
public Response download(@PathParam("file-name") String fileName) {
var ociResponse = objectStorage.getObject(GetObject.Request.builder()
.bucket(bucketName)
.objectName(fileName));
Optional<GetObject.Response> entity = ociResponse.entity();

if (entity.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND).build();
}

GetObject.Response response = entity.get();

StreamingOutput stream = output -> response.writeTo(Channels.newChannel(output));

Response.ResponseBuilder ok = Response.ok(stream, MediaType.APPLICATION_OCTET_STREAM_TYPE)
.header(Http.Header.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.header("opc-request-id", ociResponse.headers().first("opc-request-id").orElse(""))
.header("request-id", ociResponse.requestId());

ociResponse.headers()
.first(Http.Header.CONTENT_TYPE)
.ifPresent(ok::type);

ociResponse.headers()
.first(Http.Header.CONTENT_LENGTH)
.ifPresent(it -> ok.header(Http.Header.CONTENT_LENGTH, it));

return ok.build();
}

In this example we are using a few of the headers returned by OCI to correctly set up the response headers, so if you open this request from a browser, the file would be offered for download.

Vault

Let’s start with the required configuration (using META-INF/microprofile-config.properties format). You can collect this information from this screen in the OCI Console:

The first section is the required section to set up OCI integration with Vault. This endpoint is needed for encryption and signatures, but not required for secrets.

oci.vault.cryptographic-endpoint=Vault's crypto endpoint

The second section defines application configuration. Encryption key OCID is only required when using encryption, signature key OCID is required when using signatures.

app.vault.vault-ocid=Vault OCID
app.vault.compartment-ocid=Compartment OCID
app.vault.encryption-key-ocid=Encryption key OCID
app.vault.signature-key-ocid=Signature key OCID

Now let's add vault into a JAX-RS resource (this is not updating the resource from object storage):

private final OciVault vault;
private final String vaultOcid;
private final String compartmentOcid;
private final String encryptionKeyOcid;
private final String signatureKeyOcid;

@Inject
VaultResource(OciVault vault,
@ConfigProperty(name = "app.vault.vault-ocid")
String vaultOcid,
@ConfigProperty(name = "app.vault.compartment-ocid")
String compartmentOcid,
@ConfigProperty(name = "app.vault.encryption-key-ocid")
String encryptionKeyOcid,
@ConfigProperty(name = "app.vault.signature-key-ocid")
String signatureKeyOcid) {
this.vault = vault;
this.vaultOcid = vaultOcid;
this.compartmentOcid = compartmentOcid;
this.encryptionKeyOcid = encryptionKeyOcid;
this.signatureKeyOcid = signatureKeyOcid;
}

The examples below show how to invoke each Vault feature — I will not re-create the full JAX-RS methods, just the API calls.

Encryption:

String cipherText = vault.encrypt(Encrypt.Request.builder()
.keyId(encryptionKeyOcid)
.data(Base64Value.create(secret)))
.cipherText();

Decryption:

String originalText = vault.decrypt(Decrypt.Request.builder()
.keyId(encryptionKeyOcid)
.cipherText(cipherText))
.decrypted()
.toDecodedString()

The “decrypted” method returns a Base64Value, so the secret can be binary as well.

Signature:

String signature = vault.sign(Sign.Request.builder()
.keyId(signatureKeyOcid)
.algorithm(Sign.Request.ALGORITHM_SHA_224_RSA_PKCS_PSS)
.message(Base64Value.create(dataToSign)))
.signature()
.toBase64();

Verification of signature (dataToSign in previous method must be the same as dataToVerify in this method):

boolean valid = vault.verify(Verify.Request.builder()
.keyId(signatureKeyOcid)
.message(Base64Value.create(dataToVerify))
.algorithm(Sign.Request.ALGORITHM_SHA_224_RSA_PKCS_PSS)
.signature(Base64Value.createFromEncoded(signature)))
.isValid();

Getting a secret string:

Optional<String> secret = vault.getSecretBundle(GetSecretBundle.Request.builder()
.secretId(secretOcid))
.entity()
.flatMap(GetSecretBundle.Response::secretString);

Getting secret bytes:

Optional<byte[]> secret = vault.getSecretBundle(GetSecretBundle.Request.builder()
.secretId(secretOcid))
.entity()
.flatMap(GetSecretBundle.Response::secretBytes);

Advanced Configuration

The CDI integration also supports multiple configurations, and injection using @Named annotation.

Configuration example:

oci.default.vault.cryptographic-endpoint=...
oci.custom.vault.cryptographic-endpoint=...

Then injection can be done as follows for these two Vaults:

@Inject
@Named("custom")
private OciVault customVault;
@Inject
private OciVault defaultVault;

Helidon SE (Reactive)

Reactive implementation in Helidon SE usually uses application.yaml for configuration (properties, json, hoconcan be used as well). The code examples below are based on a WebServer's ServerRequest (req), ServerResponse (res)classes, to use these in a properly reactive environment.

Object Storage

Let’s start with the required configuration (using META-INF/microprofile-config.properties format):

Object storage namespace and bucket name from OCI
oci:
objectstorage:
namespace: "namespace from OCI Object Storage"
bucket: "bucket name from OCI Object Storage"

The next step is to create an ObjectStorageRx instance, so we can interact with OCI (expects the ociConfig file in the usual location):

Config ociConfig = Config.create().get("oci");
OciObjectStorageRx os = OciObjectStorageRx.create(ociConfig);

Once we have an instance, we can do a download with Web Server, once again we consider the cases when the object does not exist, and return a correct 404 Not Found code:

String objectName = req.path().param("file-name");

os.getObject(GetObject.Request.builder()
.bucket(bucketName)
.objectName(objectName))
.forSingle(apiResponse -> {
var entity = apiResponse.entity();
if (entity.isEmpty()) {
res.status(Http.Status.NOT_FOUND_404).send();
} else {
GetObjectRx.Response response = entity.get();
// copy the content length header to response
apiResponse.headers()
.first(Http.Header.CONTENT_LENGTH)
.ifPresent(res.headers()::add);
res.send(response.publisher());
}
})
.exceptionally(res::send);

Vault

The configuration required for Vault integration includes:

  • Vault OCID — to use the correct Vault, as you can have more than one configured
  • Compartment OCID — to use the correct Vault, as you can have more than one configured
  • Encryption Key OCID — required when doing encryption/decryption
  • Signature Key OCID — required when doing signatures/verification
  • Cryptographic endpoint — required for all except secrets
Click on each key to get the OCID values.

The code below will use the following configuration structure (be sure to enter the correct values from your OCI configuration):

oci:
vault:
vault-ocid: "${oci.properties.vault-ocid}"
compartment-ocid: "${oci.properties.compartment-ocid}"
encryption-key-ocid: "${oci.properties.vault-key-ocid}"
signature-key-ocid: "${oci.properties.vault-rsa-key-ocid}"
cryptographic-endpoint: "${oci.properties.crypto-endpoint}"

First step is again to create an instance of the API and prepare the configuration options:

Config ociConfig = Config.create().get("oci");
OciVaultRx ociVault = OciVaultRx.create(config.get("oci"));
Config vaultConfig = ociConifg.get("vault");
// the following three parameters are required - we just call get
String vaultOcid = vaultConfig.get("vault-ocid").asString().get();
String compartmentOcid = vaultConfig.get("compartment-ocid").asString().get();
String encryptionKey = vaultConfig.get("encryption-key-ocid").asString().get();
String signatureKey = vaultConfig.get("signature-key-ocid").asString().get();

And we can now use the API.

Encryption:

Gets path parameter text and encrypts it.

vault.encrypt(Encrypt.Request.builder()
.keyId(encryptionKeyOcid)
.data(Base64Value.create(req.path().param("text"))))
.map(Encrypt.Response::cipherText)
.forSingle(res::send)
.exceptionally(res::send);

Decryption:

Gets path parameter text and decrypts it.

vault.decrypt(Decrypt.Request.builder()
.keyId(encryptionKeyOcid)
.cipherText(req.path().param("text")))
.map(Decrypt.Response::decrypted)
.map(Base64Value::toDecodedString)
.forSingle(res::send)
.exceptionally(res::send);

Signature:

Gets path parameter text and signs it.

vault.sign(Sign.Request.builder()
.keyId(signatureKeyOcid)
.algorithm(Sign.Request.ALGORITHM_SHA_224_RSA_PKCS_PSS)
.message(Base64Value.create(req.path().param("text"))))
.map(Sign.Response::signature)
.map(Base64Value::toBase64)
.forSingle(res::send)
.exceptionally(res::send);

Verification of signature:

Gets path parameters text and signature and verifies the signature is valid for the text

String text = req.path().param("text");
String signature = req.path().param("signature");

vault.verify(Verify.Request.builder()
.keyId(signatureKeyOcid)
.algorithm(Sign.Request.ALGORITHM_SHA_224_RSA_PKCS_PSS)
.message(Base64Value.create(text))
.signature(Base64Value.createFromEncoded(signature)))
.map(Verify.Response::isValid)
.map(it -> it ? "Signature Valid" : "Signature Invalid")
.forSingle(res::send)
.exceptionally(res::send);

Getting a secret:

Gets path parameter id and requests a secret with that OCID

vault.getSecretBundle(GetSecretBundle.Request.create(req.path().param("id")))
.forSingle(apiResponse -> {
Optional<GetSecretBundle.Response> entity = apiResponse.entity();
if (entity.isEmpty()) {
res.status(Http.Status.NOT_FOUND_404).send();
} else {
GetSecretBundle.Response response = entity.get();
res.send(response.secretString().orElse(""));
}
})
.exceptionally(res::send);

If you are interested in our integration with HashiCorp Cloud Platform Vault, please see https://medium.com/helidon/helidon-integration-with-vault-8b98641bc519 (this article used to be incorrectly included here)

Learn More

For more information about Helidon, see our main project site at https://helidon.io and our GitHub repository at https://github.com/oracle/helidon.

--

--