Skip to content
Merged
19 changes: 9 additions & 10 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,28 @@ on:
release:
types: [ published ]

permissions:
contents: write
packages: write

jobs:
deploy:
name: Publish
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v2

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Publish release
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
./gradlew clean publish -Pversion=${{ github.event.release.tag_name }}

Expand All @@ -42,4 +41,4 @@ jobs:
git checkout main
git add ./gradle.properties
git commit -m "Automated commit by GitHub Actions"
git push
git push
19 changes: 9 additions & 10 deletions .github/workflows/publish-snapshot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,27 @@ on:
push:
branches: [ main ]

permissions:
contents: read
packages: write

jobs:
deploy:
name: Publish
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v2

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Publish snapshot
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
./gradlew clean publish
./gradlew clean publish
17 changes: 5 additions & 12 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ plugins {
id("maven-publish")
}

java.sourceCompatibility = JavaVersion.VERSION_21
java.targetCompatibility = JavaVersion.VERSION_21

allprojects {
repositories {
mavenCentral()
Expand All @@ -29,6 +26,8 @@ allprojects {
}

java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
withSourcesJar()
withJavadocJar()
}
Expand All @@ -48,16 +47,10 @@ allprojects {
publishing {
repositories {
maven {
val authToken =
properties["codeArtifactAuthToken"] as String? ?: ProcessBuilder(
"aws", "codeartifact", "get-authorization-token",
"--domain", "wafflestudio", "--domain-owner", "405906814034",
"--query", "authorizationToken", "--region", "ap-northeast-1", "--output", "text",
).start().inputStream.bufferedReader().readText().trim()
url = uri("https://wafflestudio-405906814034.d.codeartifact.ap-northeast-1.amazonaws.com/maven/spring-waffle/")
url = uri("https://maven.pkg.github.com/wafflestudio/spring-waffle")
credentials {
username = "aws"
password = authToken
username = "wafflestudio"
password = findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN") ?: ""
}
}
}
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ rootProject.name = "waffle-spring"
include(
"spring-boot-starter-waffle",
"spring-boot-starter-waffle-secret-manager",
"spring-boot-starter-waffle-oci-vault",
"truffle",
"truffle:truffle-core",
"truffle:truffle-logback",
Expand Down
27 changes: 27 additions & 0 deletions spring-boot-starter-waffle-oci-vault/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# spring-boot-starter-waffle-oci-vault

Loads OCI Vault Secrets into Spring `Environment` at startup (as an `EnvironmentPostProcessor`).

## Properties

Required:
- `oci.vault.secret-ids`: Comma-separated secret OCIDs (`ocid1.vaultsecret...`).
- `oci.vault.region`: Region id (default: `ap-chuncheon-1`).

Auth:
- `oci.auth.type`: `auto` (default), `instance_principal`, or `config`.
- `config`: Uses OCI config file credentials.
- `instance_principal`: Uses Instance Principal (Dynamic Group) credentials.
- `auto`: Tries config file credentials first; if it fails, falls back to Instance Principal.

Config-file auth options:
- `oci.config.path`: Path to OCI config file (default: `~/.oci/config`).
- `oci.config.profile`: Profile name (default: `DEFAULT`).

## Secret Format

Each secret is expected to be a JSON object. Keys from the JSON are added as properties only if they are not already set in the environment.

## Notes

- OKE Workload Identity is an Enhanced cluster feature and not implemented by this starter at the moment.
10 changes: 10 additions & 0 deletions spring-boot-starter-waffle-oci-vault/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
group = "com.wafflestudio.spring"

dependencies {
implementation("org.springframework.boot:spring-boot")

implementation("com.oracle.oci.sdk:oci-java-sdk-secrets:3.80.1")
implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.80.1")

testImplementation(kotlin("test"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package com.wafflestudio.spring.ocivault.config

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.oracle.bmc.ClientConfiguration
import com.oracle.bmc.ConfigFileReader
import com.oracle.bmc.Region
import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider
import com.oracle.bmc.auth.InstancePrincipalsAuthenticationDetailsProvider
import com.oracle.bmc.http.ClientConfigurator
import com.oracle.bmc.http.client.StandardClientProperties
import com.oracle.bmc.retrier.RetryConfiguration
import com.oracle.bmc.secrets.SecretsClient
import com.oracle.bmc.secrets.model.Base64SecretBundleContentDetails
import com.oracle.bmc.secrets.requests.GetSecretBundleRequest
import com.oracle.bmc.waiter.FixedTimeDelayStrategy
import com.oracle.bmc.waiter.MaxAttemptsTerminationStrategy
import org.slf4j.LoggerFactory
import org.springframework.boot.EnvironmentPostProcessor
import org.springframework.boot.SpringApplication
import org.springframework.core.env.ConfigurableEnvironment
import org.springframework.core.env.MapPropertySource
import org.springframework.core.env.getProperty
import java.time.Duration
import java.util.Base64

class OciVaultEnvironmentPostProcessor : EnvironmentPostProcessor {
private val log = LoggerFactory.getLogger(javaClass)
private val objectMapper = jacksonObjectMapper()

private val ociTimeout: Duration = Duration.ofSeconds(10)

override fun postProcessEnvironment(
environment: ConfigurableEnvironment,
application: SpringApplication,
) {
val isAotProcessing = environment.getProperty<Boolean>("spring.aot.processing", false)
if (isAotProcessing) {
return
}
val secretIdsProperty = environment.getProperty("oci.vault.secret-ids") ?: return
val secretIds = secretIdsProperty.split(",").map { it.trim() }
val region =
Region.fromRegionId(
environment.getProperty("oci.vault.region", "ap-chuncheon-1"),
)

val maxAttempts = environment.getProperty<Int>("oci.retry.max-attempts", 2).coerceAtLeast(1)
val retryDelayMillis = environment.getProperty<Int>("oci.retry.delay-millis", 0).coerceAtLeast(0).toLong()
val retryConfiguration =
RetryConfiguration.builder()
.terminationStrategy(MaxAttemptsTerminationStrategy(maxAttempts))
.delayStrategy(FixedTimeDelayStrategy(retryDelayMillis))
.build()

val authProvider = createAuthProvider(environment)
val client =
SecretsClient
.builder()
.configuration(
ClientConfiguration
.builder()
.connectionTimeoutMillis(ociTimeout.toMillis().toInt())
.readTimeoutMillis(ociTimeout.toMillis().toInt())
.retryConfiguration(retryConfiguration)
.build(),
)
.region(region)
.build(authProvider)
val secrets = mutableMapOf<String, Any>()

client.use { client ->
secretIds.forEach { secretId ->
val secretString = getSecretString(client, secretId)
val parsedSecrets = objectMapper.readValue<Map<String, Any>>(secretString)
secrets.putAll(
parsedSecrets.filterKeys {
environment.getProperty(it).isNullOrEmpty()
},
)
}
}

if (secrets.isNotEmpty()) {
environment.propertySources.addFirst(
MapPropertySource("oci-vault-secrets", secrets),
)
}
}

private fun createAuthProvider(environment: ConfigurableEnvironment): BasicAuthenticationDetailsProvider {
val instancePrincipalTimeoutConfigurator =
ClientConfigurator { builder ->
builder.property(StandardClientProperties.CONNECT_TIMEOUT, ociTimeout)
builder.property(StandardClientProperties.READ_TIMEOUT, ociTimeout)
}

val timeoutForEachRetryMillis =
environment.getProperty<Int>("oci.auth.timeout-for-each-retry-millis", ociTimeout.toMillis().toInt())
val detectEndpointRetries = environment.getProperty<Int>("oci.auth.detect-endpoint-retries", 1)

return when (val authType = environment.getProperty("oci.auth.type", "auto").trim().lowercase()) {
"auto" -> {
try {
createConfigAuthProvider(environment)
} catch (e: Exception) {
log.info("OCI config file auth failed; falling back to instance principal auth (oci.auth.type=auto).", e)
InstancePrincipalsAuthenticationDetailsProvider
.builder()
.federationClientConfigurator(instancePrincipalTimeoutConfigurator)
.detectEndpointRetries(detectEndpointRetries)
.timeoutForEachRetry(timeoutForEachRetryMillis)
.build()
}
}

"config",
"configfile",
"config_file",
"config-file",
-> createConfigAuthProvider(environment)

"instance_principal",
"instanceprincipal",
"instance-principal",
"ip",
->
InstancePrincipalsAuthenticationDetailsProvider
.builder()
.federationClientConfigurator(instancePrincipalTimeoutConfigurator)
.detectEndpointRetries(detectEndpointRetries)
.timeoutForEachRetry(timeoutForEachRetryMillis)
.build()

else ->
throw IllegalArgumentException(
"Unsupported oci.auth.type='$authType'. Supported: config, instance_principal, auto",
)
}
}

private fun createConfigAuthProvider(environment: ConfigurableEnvironment): BasicAuthenticationDetailsProvider {
val profile = environment.getProperty("oci.config.profile", "DEFAULT").trim().ifEmpty { "DEFAULT" }
val configPath =
environment.getProperty("oci.config.path")
?.trim()
?.ifEmpty { null }
?.let { expandHome(it) }

if (configPath == null) {
return ConfigFileAuthenticationDetailsProvider(profile)
}

val configFile = ConfigFileReader.parse(configPath, profile)
return ConfigFileAuthenticationDetailsProvider(configFile)
}

private fun expandHome(path: String): String {
val home = System.getProperty("user.home")
return if (path == "~") home else path.replaceFirst(Regex("^~(?=/|$)"), home)
}

private fun getSecretString(
client: SecretsClient,
secretId: String,
): String {
val request =
GetSecretBundleRequest.builder()
.secretId(secretId)
.build()
val response = client.getSecretBundle(request)
val content = (response.secretBundle.secretBundleContent as Base64SecretBundleContentDetails).content
return String(Base64.getDecoder().decode(content))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.springframework.boot.EnvironmentPostProcessor=com.wafflestudio.spring.ocivault.config.OciVaultEnvironmentPostProcessor