diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 67a5866..35a5712 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -4,6 +4,10 @@ on: release: types: [ published ] +permissions: + contents: write + packages: write + jobs: deploy: name: Publish @@ -11,22 +15,17 @@ jobs: 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 }} @@ -42,4 +41,4 @@ jobs: git checkout main git add ./gradle.properties git commit -m "Automated commit by GitHub Actions" - git push + git push \ No newline at end of file diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 844c752..98754e8 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -4,6 +4,10 @@ on: push: branches: [ main ] +permissions: + contents: read + packages: write + jobs: deploy: name: Publish @@ -11,21 +15,16 @@ jobs: 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 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 25e900c..427ff41 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,9 +12,6 @@ plugins { id("maven-publish") } -java.sourceCompatibility = JavaVersion.VERSION_21 -java.targetCompatibility = JavaVersion.VERSION_21 - allprojects { repositories { mavenCentral() @@ -29,6 +26,8 @@ allprojects { } java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 withSourcesJar() withJavadocJar() } @@ -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") ?: "" } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index d5675db..074f786 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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", diff --git a/spring-boot-starter-waffle-oci-vault/README.md b/spring-boot-starter-waffle-oci-vault/README.md new file mode 100644 index 0000000..f7d8340 --- /dev/null +++ b/spring-boot-starter-waffle-oci-vault/README.md @@ -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. diff --git a/spring-boot-starter-waffle-oci-vault/build.gradle.kts b/spring-boot-starter-waffle-oci-vault/build.gradle.kts new file mode 100644 index 0000000..59ce227 --- /dev/null +++ b/spring-boot-starter-waffle-oci-vault/build.gradle.kts @@ -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")) +} diff --git a/spring-boot-starter-waffle-oci-vault/src/main/kotlin/com/wafflestudio/spring/ocivault/config/OciVaultEnvironmentPostProcessor.kt b/spring-boot-starter-waffle-oci-vault/src/main/kotlin/com/wafflestudio/spring/ocivault/config/OciVaultEnvironmentPostProcessor.kt new file mode 100644 index 0000000..d5c171a --- /dev/null +++ b/spring-boot-starter-waffle-oci-vault/src/main/kotlin/com/wafflestudio/spring/ocivault/config/OciVaultEnvironmentPostProcessor.kt @@ -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("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("oci.retry.max-attempts", 2).coerceAtLeast(1) + val retryDelayMillis = environment.getProperty("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() + + client.use { client -> + secretIds.forEach { secretId -> + val secretString = getSecretString(client, secretId) + val parsedSecrets = objectMapper.readValue>(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("oci.auth.timeout-for-each-retry-millis", ociTimeout.toMillis().toInt()) + val detectEndpointRetries = environment.getProperty("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)) + } +} diff --git a/spring-boot-starter-waffle-oci-vault/src/main/resources/META-INF/spring.factories b/spring-boot-starter-waffle-oci-vault/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..a83c88c --- /dev/null +++ b/spring-boot-starter-waffle-oci-vault/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.EnvironmentPostProcessor=com.wafflestudio.spring.ocivault.config.OciVaultEnvironmentPostProcessor \ No newline at end of file