diff --git a/.gitattributes b/.gitattributes index 6c074fe..2d80dd0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ *.jar binary * text eol=lf +*.patch text eol=lf diff --git a/.github/workflows/build_config_server.yaml b/.github/workflows/build_config_server.yaml index 995323b..998dc2f 100644 --- a/.github/workflows/build_config_server.yaml +++ b/.github/workflows/build_config_server.yaml @@ -2,18 +2,18 @@ name: Build Config Server on: pull_request: - branches: - - main paths: - '.github/workflows/build_config_server.yaml' - - 'config-server/**' + - 'config-server/metadata/*' + - 'config-server/patches/*' - 'build.ps1' push: branches: - main paths: - '.github/workflows/build_config_server.yaml' - - 'config-server/**' + - 'config-server/metadata/*' + - 'config-server/patches/*' - 'build.ps1' concurrency: diff --git a/.github/workflows/build_eureka_server.yaml b/.github/workflows/build_eureka_server.yaml index 5777ecc..6ac942a 100644 --- a/.github/workflows/build_eureka_server.yaml +++ b/.github/workflows/build_eureka_server.yaml @@ -2,18 +2,18 @@ name: Build Eureka Server on: pull_request: - branches: - - main paths: - '.github/workflows/build_eureka_server.yaml' - - 'eureka-server/**' + - 'eureka-server/metadata/*' + - 'eureka-server/patches/*' - 'build.ps1' push: branches: - main paths: - '.github/workflows/build_eureka_server.yaml' - - 'eureka-server/**' + - 'eureka-server/metadata/*' + - 'eureka-server/patches/*' - 'build.ps1' concurrency: diff --git a/.github/workflows/build_springboot_admin_server.yaml b/.github/workflows/build_springboot_admin_server.yaml index e47e85b..d2ba5cf 100644 --- a/.github/workflows/build_springboot_admin_server.yaml +++ b/.github/workflows/build_springboot_admin_server.yaml @@ -2,18 +2,18 @@ name: Build Spring Boot Admin Server on: pull_request: - branches: - - main paths: - '.github/workflows/build_springboot_admin_server.yaml' - - 'spring-boot-admin/**' + - 'spring-boot-admin/metadata/*' + - 'spring-boot-admin/patches/*' - 'build.ps1' push: branches: - main paths: - '.github/workflows/build_springboot_admin_server.yaml' - - 'spring-boot-admin/**' + - 'spring-boot-admin/metadata/*' + - 'spring-boot-admin/patches/*' - 'build.ps1' concurrency: diff --git a/.github/workflows/build_uaa_server.yaml b/.github/workflows/build_uaa_server.yaml index faff82c..eb7504a 100644 --- a/.github/workflows/build_uaa_server.yaml +++ b/.github/workflows/build_uaa_server.yaml @@ -2,18 +2,22 @@ name: Build UAA Server on: pull_request: - branches: - - main paths: - '.github/workflows/build_uaa_server.yaml' - - 'uaa-server/**' + - 'uaa-server/Dockerfile' + - 'uaa-server/metadata/*' + - 'uaa-server/*.yml' + - 'uaa-server/*.properties' - 'build.ps1' push: branches: - main paths: - '.github/workflows/build_uaa_server.yaml' - - 'uaa-server/**' + - 'uaa-server/Dockerfile' + - 'uaa-server/metadata/*' + - 'uaa-server/*.yml' + - 'uaa-server/*.properties' - 'build.ps1' concurrency: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f5e7a80 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Agent Instructions and Reminders + +This file contains important reminders and guidelines for AI agents working on this codebase. + +## Build Script + +### Avoid `-DisableCache` Flag + +**Do NOT use `-DisableCache`** when running `build.ps1` from agentic contexts. The `start.spring.io` service may block or rate-limit automated traffic, causing connection failures. + +Instead, to get a fresh build: + +1. Delete the expanded project folder (e.g., `workspace/springbootadmin/`) +2. Run `.\build.ps1 ` without the flag + +### Testing Changes + +Before submitting patch changes: + +1. Run a dry-run of each patch: `git apply --check ` +2. If dry-run succeeds, run the full build and verify Java compilation +3. Test the resulting Docker image with a real client app + +## Patch Files + +The build script uses `git apply --unidiff-zero --recount --ignore-whitespace` to apply patches, which is more forgiving than the traditional `patch` command. + +### Patch Format Rules + +1. **Hunk headers should be accurate**: The format is `@@ -old_start,old_count +new_start,new_count @@` + - `old_count` is the number of lines in the hunk from the old file (context lines plus lines with `-` prefix) + - `new_count` is the number of lines in the hunk in the new file (context lines plus lines with `+` prefix) + - For new file patches (`--- /dev/null`), `old_count` is 0 and `new_count` is the total number of lines in the new-file hunk + - Note: `--recount` will automatically correct line counts, but keeping them accurate is still good practice +2. **Trailing newlines are required**: Patch files must end with a newline character. +3. **Preserve exact whitespace**: Context lines must match the target file exactly, including trailing spaces and tabs. The `--ignore-whitespace` flag provides some tolerance but exact matches are preferred. +4. **New file patches**: Use `/dev/null` as the old file: + + ```diff + --- /dev/null + +++ ./path/to/NewFile.java 2026-01-27 00:00:00.000000000 +0000 + @@ -0,0 +1,N @@ + +line 1 + +line 2 + ... + ``` + +### Example + +If a patch adds 1 line, the hunk header should reflect this: + +```diff +-@@ -37,3 +37,10 @@ ++@@ -37,3 +37,11 @@ +``` + +### Why This Matters + +While `git apply --recount` can fix minor line count issues, keeping patches accurate ensures reliable application and easier debugging. diff --git a/build.ps1 b/build.ps1 index 9182d20..a2eb292 100755 --- a/build.ps1 +++ b/build.ps1 @@ -64,7 +64,7 @@ try { $DockerOrg = $Registry } else { - $DockerOrg = "steeltoeoss" + $DockerOrg = "steeltoe.azurecr.io" } if ($Help) { @@ -112,11 +112,11 @@ try { if (!$Tag) { if ($env:GITHUB_ACTIONS -eq "true") { $ImageNameWithTag = "$DockerOrg/${Name}:$Version" - $Revision = Get-Content (Join-Path $ImageDirectory "metadata" "IMAGE_REVISION") - if ($Revision) { + $Revision = (Get-Content (Join-Path $ImageDirectory "metadata" "IMAGE_REVISION") -ErrorAction SilentlyContinue | ForEach-Object { $_.Trim() }) -join "" + if ($Revision -and $Revision -ne "") { $ImageNameWithTag += "-$Revision" } - $AdditionalTags = "$(Get-Content (Join-Path $ImageDirectory "metadata" "ADDITIONAL_TAGS") | ForEach-Object { $_.replace("$Name","$DockerOrg/$Name") })" + $AdditionalTags = "$(Get-Content (Join-Path $ImageDirectory "metadata" "ADDITIONAL_TAGS") -ErrorAction SilentlyContinue | ForEach-Object { $_.replace("$Name","$DockerOrg/$Name") })" } else { $ImageNameWithTag = "$DockerOrg/${Name}:dev" @@ -149,14 +149,8 @@ try { Invoke-Expression $docker_command } else { - if (!(Get-Command "patch" -ErrorAction SilentlyContinue)) { - if (Test-Path "$Env:ProgramFiles\Git\usr\bin\patch.exe") { - Write-Host "'patch' command not found, but Git is installed; adding Git usr\bin to PATH" - $env:Path += ";$Env:ProgramFiles\Git\usr\bin" - } - else { - throw "'patch' command not found" - } + if (!(Get-Command "git" -ErrorAction SilentlyContinue)) { + throw "'git' command not found" } switch ($Name) { @@ -207,7 +201,7 @@ try { # Scaffold project on start.spring.io if (!(Test-Path "$artifactName")) { - Write-Host "Using start.spring.io to create project" + Write-Host "Using start.spring.io to create project with dependencies: $dependencies" Invoke-WebRequest ` -Uri "https://start.spring.io/starter.zip" ` -Method Post ` @@ -238,10 +232,11 @@ try { # Apply patches foreach ($patch in Get-ChildItem -Path (Join-Path $ImageDirectory patches) -Filter "*.patch") { Write-Host "Applying patch $($patch.Name)" - Get-Content $patch | & patch -p1 + git apply --unidiff-zero --recount --ignore-whitespace $patch.FullName if ($LASTEXITCODE -ne 0) { - throw "Patch failed with exit code $LASTEXITCODE" + throw "Patch $($patch.Name) failed with exit code $LASTEXITCODE" } + Write-Host "Patch $($patch.Name) applied successfully" } # Build the image diff --git a/config-server/README.md b/config-server/README.md index 0c2107d..269e66d 100644 --- a/config-server/README.md +++ b/config-server/README.md @@ -40,6 +40,6 @@ docker run --publish 8888:8888 steeltoe.azurecr.io/config-server \ | ---- | ----------- | | /_{app}_/_{profile}_ | Configuration data for app in Spring profile | | /_{app}_/_{profile}_/_{label}_ | Add a git label | -| /_{app}_/_{profiles}/{label}_/_{path}_ | Environment-specific plain text config file at _{path}_| +| /_{app}_/_{profiles}/{label}_/_{path}_ | Environment-specific plain text config file at _{path}_ | _Example:_ diff --git a/config-server/metadata/IMAGE_REVISION b/config-server/metadata/IMAGE_REVISION index d00491f..8b13789 100644 --- a/config-server/metadata/IMAGE_REVISION +++ b/config-server/metadata/IMAGE_REVISION @@ -1 +1 @@ -1 + diff --git a/config-server/metadata/IMAGE_VERSION b/config-server/metadata/IMAGE_VERSION index 8089590..f77856a 100644 --- a/config-server/metadata/IMAGE_VERSION +++ b/config-server/metadata/IMAGE_VERSION @@ -1 +1 @@ -4.3.0 +4.3.1 diff --git a/config-server/metadata/SPRING_BOOT_VERSION b/config-server/metadata/SPRING_BOOT_VERSION index c492825..efe3085 100644 --- a/config-server/metadata/SPRING_BOOT_VERSION +++ b/config-server/metadata/SPRING_BOOT_VERSION @@ -1 +1 @@ -3.5.6 +3.5.10 diff --git a/config-server/patches/build.gradle.patch b/config-server/patches/build.gradle.patch index b6d01c6..e72ffbe 100644 --- a/config-server/patches/build.gradle.patch +++ b/config-server/patches/build.gradle.patch @@ -1,6 +1,6 @@ --- ./build.gradle 2025-09-30 14:48:20.000000000 -0500 +++ ./build.gradle 2025-09-30 14:49:16.584226000 -0500 -@@ -41,3 +41,10 @@ +@@ -41,3 +41,11 @@ tasks.named('test') { useJUnitPlatform() } @@ -8,6 +8,7 @@ +bootBuildImage { + createdDate = "now" + environment = [ -+ "BP_SPRING_CLOUD_BINDINGS_DISABLED": "true" ++ "BP_SPRING_CLOUD_BINDINGS_DISABLED": "true", ++ "BP_JVM_AOTCACHE_ENABLED": "true" + ] +} diff --git a/eureka-server/metadata/IMAGE_REVISION b/eureka-server/metadata/IMAGE_REVISION index e69de29..d00491f 100644 --- a/eureka-server/metadata/IMAGE_REVISION +++ b/eureka-server/metadata/IMAGE_REVISION @@ -0,0 +1 @@ +1 diff --git a/eureka-server/metadata/SPRING_BOOT_VERSION b/eureka-server/metadata/SPRING_BOOT_VERSION index c492825..efe3085 100644 --- a/eureka-server/metadata/SPRING_BOOT_VERSION +++ b/eureka-server/metadata/SPRING_BOOT_VERSION @@ -1 +1 @@ -3.5.6 +3.5.10 diff --git a/eureka-server/patches/application.properties.patch b/eureka-server/patches/application.properties.patch index b48a685..ec109cb 100644 --- a/eureka-server/patches/application.properties.patch +++ b/eureka-server/patches/application.properties.patch @@ -1,10 +1,15 @@ --- eurekaserver/src/main/resources/application.properties 2024-02-21 15:43:09.000000000 -0600 -+++ eurekaserver/src/main/resources/application.properties 2024-04-02 13:15:18.461432100 -0500 -@@ -0,0 +1,9 @@ ++++ eurekaserver/src/main/resources/application.properties 2026-01-27 00:00:00.000000000 -0500 +@@ -0,0 +1,14 @@ +server.port = 8761 +eureka.client.fetch-registry = false +eureka.client.register-with-eureka = false ++eureka.client.serviceUrl.defaultZone = ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://localhost:8761/eureka/} ++eureka.instance.hostname = localhost ++# Set myUrl to match defaultZone so Eureka recognizes itself and skips self-replication ++eureka.server.myUrl = ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://localhost:8761/eureka/} +eureka.server.enable-self-preservation = false ++eureka.server.numberOfReplicationRetries = 0 +eureka.server.evictionIntervalTimerInMs = 1000 +eureka.server.responseCacheUpdateIntervalMs = 1000 +eureka.server.wait-time-in-ms-when-sync-empty = 0 diff --git a/eureka-server/patches/build.gradle.patch b/eureka-server/patches/build.gradle.patch index b6d01c6..e72ffbe 100644 --- a/eureka-server/patches/build.gradle.patch +++ b/eureka-server/patches/build.gradle.patch @@ -1,6 +1,6 @@ --- ./build.gradle 2025-09-30 14:48:20.000000000 -0500 +++ ./build.gradle 2025-09-30 14:49:16.584226000 -0500 -@@ -41,3 +41,10 @@ +@@ -41,3 +41,11 @@ tasks.named('test') { useJUnitPlatform() } @@ -8,6 +8,7 @@ +bootBuildImage { + createdDate = "now" + environment = [ -+ "BP_SPRING_CLOUD_BINDINGS_DISABLED": "true" ++ "BP_SPRING_CLOUD_BINDINGS_DISABLED": "true", ++ "BP_JVM_AOTCACHE_ENABLED": "true" + ] +} diff --git a/spring-boot-admin/metadata/IMAGE_VERSION b/spring-boot-admin/metadata/IMAGE_VERSION index 7d280e2..3cf5751 100644 --- a/spring-boot-admin/metadata/IMAGE_VERSION +++ b/spring-boot-admin/metadata/IMAGE_VERSION @@ -1 +1 @@ -3.5.5 +3.5.7 diff --git a/spring-boot-admin/metadata/SPRING_BOOT_VERSION b/spring-boot-admin/metadata/SPRING_BOOT_VERSION index c492825..efe3085 100644 --- a/spring-boot-admin/metadata/SPRING_BOOT_VERSION +++ b/spring-boot-admin/metadata/SPRING_BOOT_VERSION @@ -1 +1 @@ -3.5.6 +3.5.10 diff --git a/spring-boot-admin/patches/application.properties.patch b/spring-boot-admin/patches/application.properties.patch index 4963c92..6b737a6 100644 --- a/spring-boot-admin/patches/application.properties.patch +++ b/spring-boot-admin/patches/application.properties.patch @@ -1,5 +1,6 @@ --- ./src/main/resources/application.properties 2025-10-01 14:13:49.968047867 -0500 -+++ ./src/main/resources/application.properties 2025-10-01 14:13:24.727639700 -0500 -@@ -0,0 +1,2 @@ ++++ ./src/main/resources/application.properties 2026-01-27 00:00:00.000000000 -0500 +@@ -0,0 +1,3 @@ +server.port=9099 +spring.thymeleaf.check-template-location=false ++logging.level.io.steeltoe.docker=INFO diff --git a/spring-boot-admin/patches/build.gradle.patch b/spring-boot-admin/patches/build.gradle.patch index afc78c3..3b19e03 100644 --- a/spring-boot-admin/patches/build.gradle.patch +++ b/spring-boot-admin/patches/build.gradle.patch @@ -1,6 +1,6 @@ --- ./build.gradle 2025-09-22 14:48:20.000000000 -0500 +++ ./build.gradle 2026-01-27 00:00:00.000000000 -0500 -@@ -38,3 +38,10 @@ +@@ -38,3 +38,11 @@ tasks.named('test') { useJUnitPlatform() } @@ -8,6 +8,7 @@ +bootBuildImage { + createdDate = "now" + environment = [ -+ "BP_SPRING_CLOUD_BINDINGS_DISABLED": "true" ++ "BP_SPRING_CLOUD_BINDINGS_DISABLED": "true", ++ "BP_NATIVE_IMAGE_BUILD_ARGUMENTS": "-H:+UnlockExperimentalVMOptions" + ] +} diff --git a/spring-boot-admin/patches/enable-springbootadmin.patch b/spring-boot-admin/patches/enable-springbootadmin.patch index ebd9f14..38e5c2a 100644 --- a/spring-boot-admin/patches/enable-springbootadmin.patch +++ b/spring-boot-admin/patches/enable-springbootadmin.patch @@ -1,13 +1,13 @@ --- ./src/main/java/io/steeltoe/docker/springbootadmin/SpringBootAdmin.java 2024-09-20 12:49:35.099908129 -0500 -+++ ./src/main/java/io/steeltoe/docker/springbootadmin/SpringBootAdmin.java 2024-09-20 12:49:59.410273961 -0500 -@@ -2,8 +2,10 @@ ++++ ./src/main/java/io/steeltoe/docker/springbootadmin/SpringBootAdmin.java 2026-01-27 00:00:00.000000000 -0500 +@@ -2,7 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; ++import org.springframework.context.annotation.Import; +import de.codecentric.boot.admin.server.config.EnableAdminServer; @SpringBootApplication +@EnableAdminServer ++@Import(SteeltoeAdminConfiguration.class) public class SpringBootAdmin { - - public static void main(String[] args) { diff --git a/spring-boot-admin/patches/spring-boot-admin-ssl-config.patch b/spring-boot-admin/patches/spring-boot-admin-ssl-config.patch new file mode 100644 index 0000000..a161228 --- /dev/null +++ b/spring-boot-admin/patches/spring-boot-admin-ssl-config.patch @@ -0,0 +1,82 @@ +--- /dev/null ++++ ./src/main/java/io/steeltoe/docker/springbootadmin/SpringBootAdminSslConfiguration.java 2026-01-27 00:00:00.000000000 +0000 +@@ -0,0 +1,79 @@ ++package io.steeltoe.docker.springbootadmin; ++ ++import org.slf4j.Logger; ++import org.slf4j.LoggerFactory; ++import org.springframework.beans.factory.ObjectProvider; ++import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; ++import org.springframework.context.annotation.Bean; ++import org.springframework.context.annotation.Configuration; ++import org.springframework.http.client.reactive.ClientHttpConnector; ++import org.springframework.http.client.reactive.ReactorClientHttpConnector; ++import io.netty.handler.ssl.SslContext; ++import reactor.netty.http.client.HttpClient; ++import reactor.netty.tcp.SslProvider; ++import reactor.netty.tcp.TcpSslContextSpec; ++ ++import javax.net.ssl.X509TrustManager; ++ ++/** ++ * Spring Boot Admin SSL Configuration ++ * ++ * Configures Spring Boot Admin's WebClient to use the shared SSL trust manager ++ * for trusting development certificates (e.g., ASP.NET Core development certificates). ++ * ++ * Uses ObjectProvider for AOT compatibility - the trust manager is resolved at runtime ++ * when the bean method is called, not at configuration class construction time. ++ */ ++@Configuration ++public class SpringBootAdminSslConfiguration { ++ ++ private static final Logger logger = LoggerFactory.getLogger(SpringBootAdminSslConfiguration.class); ++ private final ObjectProvider trustManagerProvider; ++ ++ public SpringBootAdminSslConfiguration(ObjectProvider trustManagerProvider) { ++ this.trustManagerProvider = trustManagerProvider; ++ } ++ ++ /** ++ * Provides a ClientHttpConnector with custom SSL trust for Spring Boot Admin's WebClient. ++ * ++ * Uses ObjectProvider to defer trust manager resolution until runtime, making this ++ * AOT-compatible. The trust manager is resolved when this bean method is called, ++ * not during AOT processing at build time. ++ */ ++ @Bean ++ @ConditionalOnMissingBean(ClientHttpConnector.class) ++ public ClientHttpConnector clientHttpConnector() { ++ logger.info("Configuring Spring Boot Admin WebClient with SSL trust support"); ++ X509TrustManager trustManager = trustManagerProvider.getIfAvailable(); ++ ++ if (trustManager == null) { ++ logger.debug("No custom X509TrustManager available, using default SSL configuration"); ++ return new ReactorClientHttpConnector(HttpClient.create()); ++ } ++ ++ try { ++ logger.info("Using custom X509TrustManager for Spring Boot Admin WebClient"); ++ // Build SslContext first to avoid deprecated sslContext(ProtocolSslContextSpec) method ++ SslContext sslContext = TcpSslContextSpec.forClient() ++ .configure(sslContextBuilder -> { ++ sslContextBuilder.trustManager(trustManager); ++ }) ++ .sslContext(); ++ ++ SslProvider sslProvider = SslProvider.builder() ++ .sslContext(sslContext) ++ .build(); ++ ++ HttpClient httpClient = HttpClient.create() ++ .secure(sslProvider); ++ ++ logger.debug("Configured Spring Boot Admin WebClient with custom SSL trust"); ++ return new ReactorClientHttpConnector(httpClient); ++ } catch (Exception e) { ++ logger.error("Failed to configure SSL trust for Spring Boot Admin WebClient, using default", e); ++ // Fall back to default connector if SSL configuration fails ++ return new ReactorClientHttpConnector(HttpClient.create()); ++ } ++ } ++} diff --git a/spring-boot-admin/patches/ssl-trust-config.patch b/spring-boot-admin/patches/ssl-trust-config.patch new file mode 100644 index 0000000..6b061ab --- /dev/null +++ b/spring-boot-admin/patches/ssl-trust-config.patch @@ -0,0 +1,201 @@ +--- /dev/null ++++ ./src/main/java/io/steeltoe/docker/springbootadmin/SslTrustConfiguration.java 2026-01-27 00:00:00.000000000 +0000 +@@ -0,0 +1,198 @@ ++package io.steeltoe.docker.springbootadmin; ++ ++import org.slf4j.Logger; ++import org.slf4j.LoggerFactory; ++import org.springframework.context.annotation.Bean; ++import org.springframework.context.annotation.Configuration; ++ ++import javax.net.ssl.TrustManagerFactory; ++import javax.net.ssl.X509TrustManager; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.nio.file.Paths; ++import java.security.KeyStore; ++import java.security.cert.CertificateException; ++import java.security.cert.CertificateFactory; ++import java.security.cert.X509Certificate; ++import java.util.ArrayList; ++import java.util.List; ++import javax.security.auth.x500.X500Principal; ++ ++/** ++ * SSL Trust Configuration ++ * ++ *

This configuration class provides SSL certificate trust support for development environments. ++ * It automatically loads certificates from environment variables or standard locations and ++ * creates a custom TrustManager that trusts both standard CA certificates and development ++ * certificates (e.g., ASP.NET Core development certificates).

++ * ++ *

Certificate locations checked:

++ *
    ++ *
  • {@code SSL_CERT_DIR} environment variable - colon-separated list of directories containing certificates
  • ++ *
  • {@code SSL_CERT_FILE} environment variable - path to a single certificate file
  • ++ *
++ * ++ *

Supported certificate formats: .pem, .crt, .cer

++ */ ++@Configuration ++public class SslTrustConfiguration { ++ ++ private static final Logger logger = LoggerFactory.getLogger(SslTrustConfiguration.class); ++ ++ @Bean ++ public X509TrustManager sslTrustManager() { ++ try { ++ TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); ++ defaultTmf.init((KeyStore) null); ++ javax.net.ssl.TrustManager[] trustManagers = defaultTmf.getTrustManagers(); ++ if (trustManagers == null || trustManagers.length == 0 || !(trustManagers[0] instanceof X509TrustManager)) { ++ throw new IllegalStateException("No X509TrustManager available from default TrustManagerFactory"); ++ } ++ X509TrustManager defaultTrustManager = (X509TrustManager) trustManagers[0]; ++ ++ List devCerts = loadDevelopmentCertificates(); ++ if (devCerts.isEmpty()) { ++ logger.info("SSL trust: Using default trust manager (no development certificates found)"); ++ return defaultTrustManager; ++ } ++ ++ logger.info("SSL trust: Loaded {} development certificate(s)", devCerts.size()); ++ return new X509TrustManager() { ++ @Override ++ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { ++ defaultTrustManager.checkClientTrusted(chain, authType); ++ } ++ ++ @Override ++ public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { ++ try { ++ defaultTrustManager.checkServerTrusted(chain, authType); ++ } catch (CertificateException e) { ++ // If default validation fails, check if any certificate in the chain ++ // is signed by or matches a development certificate ++ logger.debug("Default trust validation failed, checking development certificates..."); ++ for (X509Certificate cert : chain) { ++ X500Principal certSubject = cert.getSubjectX500Principal(); ++ logger.trace("Checking certificate: {}", certSubject); ++ ++ // Check if this certificate matches or is signed by a dev cert ++ for (X509Certificate devCert : devCerts) { ++ // First check for exact match ++ if (cert.equals(devCert)) { ++ logger.debug("Trusting certificate (exact match with development cert): {}", certSubject); ++ return; ++ } ++ ++ // Then verify cryptographic signature ++ // Only trust certs signed by dev CAs if the dev cert is actually a CA ++ try { ++ // Check if dev cert has CA basic constraints ++ boolean isCA = devCert.getBasicConstraints() != -1; ++ if (!isCA) { ++ logger.trace("Development cert is not a CA, skipping signature verification: {}", devCert.getSubjectX500Principal()); ++ continue; ++ } ++ ++ // Verify that the cert was signed by the dev cert ++ cert.verify(devCert.getPublicKey()); ++ logger.debug("Trusting certificate signed by development CA: {}", certSubject); ++ return; // Trusted by development CA ++ } catch (Exception verifyException) { ++ // Signature verification failed, continue checking other dev certs ++ logger.trace("Signature verification failed for cert {} with dev cert {}: {}", ++ certSubject, devCert.getSubjectX500Principal(), verifyException.getMessage()); ++ } ++ } ++ } ++ // If we get here, the certificate chain doesn't include any development certificates ++ logger.warn("Certificate validation failed and no development certificate found in chain"); ++ throw e; ++ } ++ } ++ ++ @Override ++ public X509Certificate[] getAcceptedIssuers() { ++ X509Certificate[] defaultCerts = defaultTrustManager.getAcceptedIssuers(); ++ X509Certificate[] allCerts = new X509Certificate[defaultCerts.length + devCerts.size()]; ++ System.arraycopy(defaultCerts, 0, allCerts, 0, defaultCerts.length); ++ System.arraycopy(devCerts.toArray(new X509Certificate[0]), 0, allCerts, defaultCerts.length, devCerts.size()); ++ return allCerts; ++ } ++ }; ++ } catch (Exception e) { ++ logger.error("Failed to create SSL trust manager, using default", e); ++ try { ++ TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); ++ defaultTmf.init((KeyStore) null); ++ javax.net.ssl.TrustManager[] trustManagers = defaultTmf.getTrustManagers(); ++ if (trustManagers == null || trustManagers.length == 0 || !(trustManagers[0] instanceof X509TrustManager)) { ++ throw new IllegalStateException("No X509TrustManager available from default TrustManagerFactory"); ++ } ++ return (X509TrustManager) trustManagers[0]; ++ } catch (Exception ex) { ++ logger.error("Failed to create default trust manager", ex); ++ throw new RuntimeException("Failed to create trust manager", ex); ++ } ++ } ++ } ++ ++ private List loadDevelopmentCertificates() { ++ List certificates = new ArrayList<>(); ++ logger.debug("Loading development certificates..."); ++ try { ++ CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); ++ List certPaths = new ArrayList<>(); ++ ++ // SSL_CERT_DIR can be colon-separated list of directories (OpenSSL standard) ++ String sslCertDir = System.getenv("SSL_CERT_DIR"); ++ if (sslCertDir != null && !sslCertDir.isEmpty()) { ++ for (String dir : sslCertDir.split(":")) { ++ if (!dir.isEmpty()) { ++ certPaths.add(dir); ++ } ++ } ++ } ++ ++ // SSL_CERT_FILE for single certificate file ++ String sslCertFile = System.getenv("SSL_CERT_FILE"); ++ if (sslCertFile != null && !sslCertFile.isEmpty()) { ++ certPaths.add(sslCertFile); ++ } ++ ++ logger.debug("Checking certificate paths: {}", certPaths); ++ ++ for (String certPath : certPaths) { ++ Path path = Paths.get(certPath); ++ if (Files.isDirectory(path)) { ++ logger.debug("Scanning directory for certificates: {}", certPath); ++ try (var stream = Files.walk(path)) { ++ stream.filter(Files::isRegularFile) ++ .filter(p -> p.toString().matches(".*\\.(pem|crt|cer)$")) ++ .forEach(p -> { ++ try { ++ try (var inputStream = Files.newInputStream(p)) { ++ certificates.add((X509Certificate) certFactory.generateCertificate(inputStream)); ++ logger.debug("Loaded certificate: {}", p); ++ } ++ } catch (Exception e) { ++ logger.warn("Failed to load certificate {}: {}", p, e.getMessage()); ++ } ++ }); ++ } ++ } else if (Files.isRegularFile(path)) { ++ try (var inputStream = Files.newInputStream(path)) { ++ certificates.add((X509Certificate) certFactory.generateCertificate(inputStream)); ++ logger.debug("Loaded certificate: {}", path); ++ } catch (Exception e) { ++ logger.warn("Failed to load certificate {}: {}", path, e.getMessage()); ++ } ++ } else { ++ logger.trace("Path does not exist or is not accessible: {}", certPath); ++ } ++ } ++ } catch (Exception e) { ++ logger.error("Error loading development certificates", e); ++ } ++ return certificates; ++ } ++} diff --git a/spring-boot-admin/patches/steeltoe-admin-config.patch b/spring-boot-admin/patches/steeltoe-admin-config.patch new file mode 100644 index 0000000..b4f74cd --- /dev/null +++ b/spring-boot-admin/patches/steeltoe-admin-config.patch @@ -0,0 +1,129 @@ +--- /dev/null ++++ ./src/main/java/io/steeltoe/docker/springbootadmin/SteeltoeAdminConfiguration.java 2026-01-27 00:00:00.000000000 +0000 +@@ -0,0 +1,126 @@ ++package io.steeltoe.docker.springbootadmin; ++ ++import com.fasterxml.jackson.core.JsonParser; ++import com.fasterxml.jackson.databind.DeserializationContext; ++import com.fasterxml.jackson.databind.JsonDeserializer; ++import com.fasterxml.jackson.databind.JsonNode; ++import com.fasterxml.jackson.databind.ObjectMapper; ++import com.fasterxml.jackson.databind.module.SimpleModule; ++import de.codecentric.boot.admin.server.web.client.InstanceWebClientCustomizer; ++import org.slf4j.Logger; ++import org.slf4j.LoggerFactory; ++import org.springframework.beans.factory.ObjectProvider; ++import org.springframework.context.annotation.Bean; ++import org.springframework.context.annotation.Configuration; ++import org.springframework.core.annotation.Order; ++import org.springframework.http.client.reactive.ClientHttpConnector; ++import org.springframework.http.codec.json.Jackson2JsonDecoder; ++import org.springframework.http.codec.json.Jackson2JsonEncoder; ++import org.springframework.web.reactive.function.client.ExchangeStrategies; ++import org.springframework.web.reactive.function.client.WebClient; ++ ++import java.io.IOException; ++import java.util.HashMap; ++import java.util.Map; ++ ++/** ++ * Configuration to make Spring Boot Admin compatible with Steeltoe actuator responses. ++ * ++ * Steeltoe adds a "type":"Steeltoe" property to its actuator index response, which causes ++ * deserialization failures in AOT-compiled Spring Boot Admin. This configuration provides ++ * a custom WebClient with a manual deserializer that only extracts the _links field. ++ * ++ * @see GraalVM Reflection Metadata ++ */ ++@Configuration(proxyBeanMethods = false) ++public class SteeltoeAdminConfiguration { ++ ++ private static final Logger log = LoggerFactory.getLogger(SteeltoeAdminConfiguration.class); ++ ++ @Bean ++ @Order(-100) ++ public InstanceWebClientCustomizer steeltoeInstanceWebClientCustomizer( ++ ObjectMapper objectMapper, ++ ObjectProvider clientHttpConnectorProvider) { ++ ++ log.info("Configuring Spring Boot Admin WebClient for Steeltoe compatibility"); ++ ++ return (builder) -> { ++ try { ++ Class responseClass = Class.forName( ++ "de.codecentric.boot.admin.server.services.endpoints.QueryIndexEndpointStrategy$Response"); ++ Class endpointRefClass = Class.forName( ++ "de.codecentric.boot.admin.server.services.endpoints.QueryIndexEndpointStrategy$Response$EndpointRef"); ++ ++ // Copy the base ObjectMapper to preserve other configuration (date formats, etc.) ++ // while adding our custom deserializer for the actuator index response ++ ObjectMapper mapper = objectMapper.copy(); ++ SimpleModule module = new SimpleModule("SteeltoeCompatibility"); ++ @SuppressWarnings({"unchecked", "rawtypes"}) ++ JsonDeserializer deserializer = new ActuatorIndexResponseDeserializer(responseClass, endpointRefClass); ++ module.addDeserializer((Class) responseClass, deserializer); ++ mapper.registerModule(module); ++ ++ ExchangeStrategies strategies = ExchangeStrategies.builder() ++ .codecs(configurer -> { ++ configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper)); ++ configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper)); ++ }) ++ .build(); ++ ++ WebClient.Builder webClientBuilder = WebClient.builder().exchangeStrategies(strategies); ++ clientHttpConnectorProvider.ifAvailable(connector -> { ++ webClientBuilder.clientConnector(connector); ++ log.info("Using custom ClientHttpConnector for SSL trust"); ++ }); ++ ++ builder.webClient(webClientBuilder); ++ log.info("Steeltoe-compatible WebClient configuration complete"); ++ ++ } catch (ClassNotFoundException e) { ++ log.error("Failed to load SBA Response class: {}", e.getMessage()); ++ } ++ }; ++ } ++ ++ @SuppressWarnings("rawtypes") ++ public static class ActuatorIndexResponseDeserializer extends JsonDeserializer { ++ private static final Logger log = LoggerFactory.getLogger(ActuatorIndexResponseDeserializer.class); ++ private final Class responseClass; ++ private final Class endpointRefClass; ++ ++ public ActuatorIndexResponseDeserializer(Class responseClass, Class endpointRefClass) { ++ this.responseClass = responseClass; ++ this.endpointRefClass = endpointRefClass; ++ } ++ ++ @Override ++ public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { ++ JsonNode node = p.getCodec().readTree(p); ++ try { ++ Object response = responseClass.getDeclaredConstructor().newInstance(); ++ Map links = new HashMap<>(); ++ if (node.has("_links")) { ++ node.get("_links").fields().forEachRemaining(entry -> { ++ try { ++ JsonNode linkNode = entry.getValue(); ++ String href = linkNode.has("href") ? linkNode.get("href").asText() : null; ++ boolean templated = linkNode.has("templated") && linkNode.get("templated").asBoolean(); ++ java.lang.reflect.Constructor ctor = endpointRefClass.getDeclaredConstructor(String.class, boolean.class); ++ ctor.setAccessible(true); ++ Object ref = ctor.newInstance(href, templated); ++ links.put(entry.getKey(), ref); ++ } catch (Exception e) { ++ log.warn("Failed to parse link '{}': {}", entry.getKey(), e.getMessage()); ++ } ++ }); ++ } ++ responseClass.getMethod("setLinks", Map.class).invoke(response, links); ++ log.debug("Deserialized {} endpoint links", links.size()); ++ return response; ++ } catch (Exception e) { ++ throw new IOException("Failed to deserialize actuator index response", e); ++ } ++ } ++ } ++} diff --git a/uaa-server/README.md b/uaa-server/README.md index 5bd6e0a..3a12b70 100644 --- a/uaa-server/README.md +++ b/uaa-server/README.md @@ -7,26 +7,26 @@ This directory contains resources for building a [CloudFoundry User Account and To run this image locally: ```shell -docker run -it -p 8080:8080 --name steeltoe-uaa steeltoe.azurecr.io/uaa-server:77 +docker run -it -p 8080:8080 --name steeltoe-uaa steeltoe.azurecr.io/uaa-server ``` To run this image locally, overwriting the included `uaa.yml` file: ```shell -docker run -it -p 8080:8080 --name steeltoe-uaa -v $pwd/uaa.yml:/uaa/uaa.yml steeltoe.azurecr.io/uaa-server:77 +docker run -it -p 8080:8080 --name steeltoe-uaa -v $pwd/uaa.yml:/uaa/uaa.yml steeltoe.azurecr.io/uaa-server ``` ## Customizing for your Cloud Foundry environment -These instructions will help you build and deploy a custom image to use as an identity provider for [Single Sign-On for VMware Tanzu Application Service](https://docs.vmware.com/en/Single-Sign-On-for-VMware-Tanzu-Application-Service/index.html): +These instructions will help you build and deploy a custom image to use as an identity provider for [Single Sign-On for VMware Tanzu Application Service](https://techdocs.broadcom.com/us/en/vmware-tanzu/platform-services/single-sign-on-for-tanzu/1-16/sso-tanzu/index.html): 1. Clone this repository. -1. (Operator task) Create an [identity zone](https://docs.vmware.com/en/VMware-Tanzu-Application-Service/6.0/tas-for-vms/uaa-concepts.html#identity-zones-0) +1. (Operator task) Create an [identity zone](https://techdocs.broadcom.com/us/en/vmware-tanzu/platform/tanzu-platform-for-cloud-foundry/10-3/tpcf/t-uaa-uaa-concepts.html) 1. Change the `redirect-uri` entry for `ssotile` in [uaa.yml](uaa.yml#132) to match your identity zone. 1. (OPTIONAL) Customize the name of the image you're about to build by renaming the `uaa-server` directory 1. `.\build.ps1 uaa-server`. 1. Push the image to an image repository accessible from your Cloud Foundry environment. 1. Deploy the image with a command similar to this: - * `cf push steeltoe-uaa --docker-image steeltoe.azurecr.io/uaa-server:77` -1. (Operator task) [Add the new identity provider with OpenID Connect](https://docs.vmware.com/en/Single-Sign-On-for-VMware-Tanzu-Application-Service/1.14/sso/GUID-configure-external-id.html#config-ext-oidc) + * `cf push steeltoe-uaa --docker-image steeltoe.azurecr.io/uaa-server` +1. (Operator task) [Add the new identity provider with OpenID Connect](https://techdocs.broadcom.com/us/en/vmware-tanzu/platform/single-sign-on/1-16/sso/configure-external-id.html#config-ext-prov) * Use the `ssotile` credentials from uaa.yml