diff --git a/README.md b/README.md index 989ee4a6..659a7ed6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ + + com.azure + azure-core + 1.57.0 + + + com.azure + azure-core-management + 1.19.2 + com.azure azure-messaging-servicebus diff --git a/compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/ContainerAppsCompatibilityTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/ContainerAppsCompatibilityTest.java new file mode 100644 index 00000000..315bb1a9 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/ContainerAppsCompatibilityTest.java @@ -0,0 +1,129 @@ +package io.floci.az.compat; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.management.AzureEnvironment; +import com.azure.core.management.profile.AzureProfile; +import com.azure.resourcemanager.appcontainers.ContainerAppsApiManager; +import com.azure.resourcemanager.appcontainers.fluent.models.ContainerAppInner; +import com.azure.resourcemanager.appcontainers.fluent.models.ManagedEnvironmentInner; +import com.azure.resourcemanager.appcontainers.models.ActiveRevisionsMode; +import com.azure.resourcemanager.appcontainers.models.Configuration; +import com.azure.resourcemanager.appcontainers.models.Container; +import com.azure.resourcemanager.appcontainers.models.ContainerApp; +import com.azure.resourcemanager.appcontainers.models.ContainerAppProvisioningState; +import com.azure.resourcemanager.appcontainers.models.ContainerResources; +import com.azure.resourcemanager.appcontainers.models.EnvironmentProvisioningState; +import com.azure.resourcemanager.appcontainers.models.EnvironmentVar; +import com.azure.resourcemanager.appcontainers.models.Ingress; +import com.azure.resourcemanager.appcontainers.models.ManagedEnvironment; +import com.azure.resourcemanager.appcontainers.models.Revision; +import com.azure.resourcemanager.appcontainers.models.Scale; +import com.azure.resourcemanager.appcontainers.models.Secret; +import com.azure.resourcemanager.appcontainers.models.Template; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Compatibility coverage using Microsoft's generated Container Apps management client. */ +@DisplayName("Azure Container Apps Java SDK Compatibility") +class ContainerAppsCompatibilityTest { + + private static final String ENDPOINT = EmulatorConfig.httpBase(); + private static final String SUBSCRIPTION = "00000000-0000-0000-0000-000000000001"; + private static final String TENANT = "00000000-0000-0000-0000-000000000002"; + private static final String RESOURCE_GROUP = "containerapps-rg-" + suffix(); + private static final String ENVIRONMENT = "env-" + suffix(); + private static final String APP = "app-" + suffix(); + + private static ContainerAppsApiManager manager; + + @BeforeAll + static void setup() { + EmulatorConfig.assumeEmulatorRunning(); + Map endpoints = new HashMap<>(AzureEnvironment.AZURE.getEndpoints()); + endpoints.put("resourceManagerEndpointUrl", ENDPOINT + "/"); + endpoints.put("managementEndpointUrl", ENDPOINT + "/"); + AzureProfile profile = new AzureProfile(TENANT, SUBSCRIPTION, new AzureEnvironment(endpoints)); + HttpPipeline pipeline = new HttpPipelineBuilder() + .httpClient(HttpClient.createDefault()) + .build(); + manager = ContainerAppsApiManager.authenticate(pipeline, profile); + } + + @Test + void fullLifecycleThroughOfficialSdk() { + ManagedEnvironmentInner environmentRequest = new ManagedEnvironmentInner() + .withLocation("eastus") + .withTags(Map.of("suite", "java-sdk")); + ManagedEnvironmentInner environment = manager.serviceClient().getManagedEnvironments() + .createOrUpdate(RESOURCE_GROUP, ENVIRONMENT, environmentRequest); + + assertEquals(EnvironmentProvisioningState.SUCCEEDED, environment.provisioningState()); + assertTrue(environment.defaultDomain().endsWith(".azurecontainerapps.io")); + + String environmentId = environment.id(); + Configuration configuration = new Configuration() + .withActiveRevisionsMode(ActiveRevisionsMode.SINGLE) + .withSecrets(List.of(new Secret().withName("message").withValue("hello"))) + .withIngress(new Ingress().withExternal(true).withTargetPort(80)); + Template template = new Template() + .withRevisionSuffix("v1") + .withContainers(List.of(new Container() + .withName("web") + .withImage("nginx:alpine") + .withEnv(List.of(new EnvironmentVar().withName("MESSAGE").withSecretRef("message"))) + .withResources(new ContainerResources().withCpu(0.25).withMemory("0.5Gi")))) + .withScale(new Scale().withMinReplicas(1).withMaxReplicas(2)); + ContainerAppInner appRequest = new ContainerAppInner() + .withLocation("eastus") + .withEnvironmentId(environmentId) + .withConfiguration(configuration) + .withTemplate(template) + .withTags(Map.of("suite", "java-sdk")); + + ContainerAppInner created = manager.serviceClient().getContainerApps() + .createOrUpdate(RESOURCE_GROUP, APP, appRequest); + assertEquals(ContainerAppProvisioningState.SUCCEEDED, created.provisioningState()); + assertEquals(APP + "--v1", created.latestRevisionName()); + assertEquals("message", manager.containerApps().listSecrets(RESOURCE_GROUP, APP) + .value().getFirst().name()); + + ContainerApp fetched = manager.containerApps().getByResourceGroup(RESOURCE_GROUP, APP); + assertEquals(APP, fetched.name()); + assertEquals(environmentId, fetched.environmentId()); + assertEquals(1, fetched.template().scale().minReplicas()); + assertEquals("java-sdk", fetched.tags().get("suite")); + + List revisions = new ArrayList<>(); + manager.containerAppsRevisions().listRevisions(RESOURCE_GROUP, APP).forEach(revisions::add); + assertEquals(1, revisions.size()); + assertEquals(APP + "--v1", revisions.getFirst().name()); + assertEquals(1, revisions.getFirst().replicas()); + assertTrue(revisions.getFirst().active()); + + List apps = new ArrayList<>(); + manager.containerApps().listByResourceGroup(RESOURCE_GROUP).forEach(apps::add); + assertTrue(apps.stream().anyMatch(app -> APP.equals(app.name()))); + List environments = new ArrayList<>(); + manager.managedEnvironments().listByResourceGroup(RESOURCE_GROUP).forEach(environments::add); + assertTrue(environments.stream().anyMatch(value -> ENVIRONMENT.equals(value.name()))); + + manager.serviceClient().getContainerApps().delete(RESOURCE_GROUP, APP); + manager.serviceClient().getManagedEnvironments().delete(RESOURCE_GROUP, ENVIRONMENT); + } + + private static String suffix() { + return UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/docs/configuration/advanced/application-yml.md b/docs/configuration/advanced/application-yml.md index 286daa46..d0577ee9 100644 --- a/docs/configuration/advanced/application-yml.md +++ b/docs/configuration/advanced/application-yml.md @@ -43,6 +43,9 @@ floci-az: app-config: # mode: persistent flush-interval-ms: 5000 + container-apps: + # mode: persistent + flush-interval-ms: 5000 dns: # When floci-az runs inside Docker, an embedded DNS server starts on UDP/53 @@ -75,6 +78,11 @@ floci-az: container-idle-timeout-seconds: 300 app-config: enabled: true + container-apps: + enabled: true + mocked: true + dns-suffix: azurecontainerapps.io + ingress-timeout-seconds: 60 ``` ## Key Environment Variables @@ -89,8 +97,10 @@ floci-az: | `FLOCI_AZ_STORAGE_SERVICES_QUEUE_MODE` | _(global)_ | Per-service queue mode | | `FLOCI_AZ_STORAGE_SERVICES_TABLE_MODE` | _(global)_ | Per-service table mode | | `FLOCI_AZ_STORAGE_SERVICES_APP_CONFIG_MODE` | _(global)_ | Per-service App Configuration mode | +| `FLOCI_AZ_STORAGE_SERVICES_CONTAINER_APPS_MODE` | _(global)_ | Per-service Container Apps mode | | `FLOCI_AZ_SERVICES_FUNCTIONS_EPHEMERAL` | `false` | Fresh container per invocation | | `FLOCI_AZ_SERVICES_FUNCTIONS_CONTAINER_IDLE_TIMEOUT_SECONDS` | `300` | Evict warm containers idle longer than this (seconds); `0` disables eviction | | `FLOCI_AZ_SERVICES_APP_CONFIG_ENABLED` | `true` | Enable/disable App Configuration | +| `FLOCI_AZ_SERVICES_CONTAINER_APPS_MOCKED` | `true` | Keep Container Apps ARM state without Docker runtimes | | `FLOCI_AZ_SERVICES_FUNCTIONS_CODE_PATH` | `~/.floci-az/functions` | Function code directory | | `FLOCI_AZ_DOCKER_DOCKER_HOST` | `unix:///var/run/docker.sock` | Docker daemon socket | diff --git a/docs/services/container-apps.md b/docs/services/container-apps.md new file mode 100644 index 00000000..d8f1c00b --- /dev/null +++ b/docs/services/container-apps.md @@ -0,0 +1,113 @@ +# Azure Container Apps + +Compatible with Azure Resource Manager clients using `Microsoft.App/managedEnvironments` and `Microsoft.App/containerApps`. + +Real mode runs each active revision replica as Docker containers. Mocked mode keeps full ARM and revision state without starting Docker. + +## Features + +- Managed Environment create, get, update, delete, and list +- Container App create, get, update, delete, and list +- Versioned templates with Single and Multiple active revision modes +- Revision list, get, activate, deactivate, and restart +- Container commands, arguments, environment variables, and secret references +- External and internal HTTP ingress forwarded to running revision containers +- Weighted revision traffic and round-robin replica selection with unhealthy replica failover +- `minReplicas` and `maxReplicas` validation; local replicas start at `minReplicas` +- Scale-to-zero apps start one replica on first ingress request when `maxReplicas` permits +- Mocked mode for Docker-free tests + +Scale rules beyond minimum/maximum replicas are retained in ARM responses but are not evaluated locally. +Internal ingress accepts only transport peers whose source IP belongs to a Docker IPAM subnet used by a running Container App replica. Forwarded headers cannot make a public caller internal. + +## ARM endpoints + +```text +PUT /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/managedEnvironments/{name} +GET /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/managedEnvironments/{name} +PATCH /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/managedEnvironments/{name} +DELETE /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/managedEnvironments/{name} +GET /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/managedEnvironments +GET /subscriptions/{sub}/providers/Microsoft.App/managedEnvironments +POST .../managedEnvironments/{name}/checkNameAvailability + +PUT /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} +GET /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} +PATCH /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} +DELETE /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} +POST .../containerApps/{name}/listSecrets +GET .../containerApps/{name}/revisions +GET .../containerApps/{name}/revisions/{revision} +POST .../containerApps/{name}/revisions/{revision}/activate +POST .../containerApps/{name}/revisions/{revision}/deactivate +POST .../containerApps/{name}/revisions/{revision}/restart +``` + +## Example + +Create an environment: + +```bash +curl -X PUT 'http://localhost:4577/subscriptions/dev/resourceGroups/apps/providers/Microsoft.App/managedEnvironments/local?api-version=2025-07-01' \ + -H 'Content-Type: application/json' \ + -d '{"location":"eastus","properties":{}}' +``` + +Create an externally accessible app: + +```bash +curl -X PUT 'http://localhost:4577/subscriptions/dev/resourceGroups/apps/providers/Microsoft.App/containerApps/hello?api-version=2025-07-01' \ + -H 'Content-Type: application/json' \ + -d '{ + "location":"eastus", + "properties":{ + "environmentId":"/subscriptions/dev/resourceGroups/apps/providers/Microsoft.App/managedEnvironments/local", + "configuration":{ + "activeRevisionsMode":"Single", + "secrets":[{"name":"token","value":"local-secret"}], + "ingress":{"external":true,"targetPort":80} + }, + "template":{ + "revisionSuffix":"v1", + "containers":[{ + "name":"web", + "image":"nginx:alpine", + "env":[{"name":"TOKEN","secretRef":"token"}] + }], + "scale":{"minReplicas":1,"maxReplicas":3} + } + } + }' +``` + +The response returns a globally unique value in `properties.configuration.ingress.fqdn`. Route that hostname to floci-az, then call it through port 4577: + +```bash +FQDN=$(curl -s 'http://localhost:4577/subscriptions/dev/resourceGroups/apps/providers/Microsoft.App/containerApps/hello?api-version=2025-07-01' | jq -r '.properties.configuration.ingress.fqdn') +curl -H "Host: $FQDN" http://localhost:4577/ +``` + +## Configuration + +```yaml +floci-az: + services: + container-apps: + enabled: true + mocked: true + dns-suffix: azurecontainerapps.io + ingress-timeout-seconds: 60 +``` + +| Environment variable | Default | Description | +|---|---:|---| +| `FLOCI_AZ_SERVICES_CONTAINER_APPS_ENABLED` | `true` | Enables `Microsoft.App` routing | +| `FLOCI_AZ_SERVICES_CONTAINER_APPS_MOCKED` | `true` | Keeps ARM state without Docker containers | +| `FLOCI_AZ_SERVICES_CONTAINER_APPS_DNS_SUFFIX` | `azurecontainerapps.io` | Suffix returned in environment and app FQDNs | +| `FLOCI_AZ_SERVICES_CONTAINER_APPS_INGRESS_TIMEOUT_SECONDS` | `60` | Backend connect/request timeout | + +Set `mocked: false` to enable real mode, which requires access to the Docker daemon. Template +containers in one replica share the leader container's network namespace, so sidecars can +communicate over `localhost`. The leader receives the dynamic host-port binding for the shared +ingress target port. A replica becomes healthy only after that port accepts TCP connections. +Requests still enter floci-az on port 4577. diff --git a/docs/services/index.md b/docs/services/index.md index bf6dcff2..a3b3a746 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -17,6 +17,7 @@ Floci-AZ provides emulation for several core Azure services. | **Service Bus** | `/{account}-servicebus/` + AMQP `:5673` | ✅ Queues, topics, subscriptions (dynamic); AMQP 1.0 via Artemis sidecar or mocked | | **Azure SQL Database** | ARM path + `/{account}-sql/` | ✅ Servers, databases, firewall rules; ARM-only by default, managed SQL Server opt-in | | **Azure Kubernetes Service** | ARM path (`Microsoft.ContainerService`) | ✅ Clusters, agent pools, credentials; real k3s containers or mocked | +| **Azure Container Apps** | ARM path (`Microsoft.App`) + FQDN ingress | ✅ Managed environments, apps, revisions, ingress, secrets, min/max replicas; Docker-backed or mocked | | **API Management** | ARM path (`Microsoft.ApiManagement`) + `/{account}-apim/` | ✅ APIs, operations, products, subscriptions, named values, backends, OpenAPI import; gateway routing + policy subset | | **Virtual Network** | ARM path (`Microsoft.Network`) | ✅ VNets, subnets, NICs, public IPs, NSGs, private DNS zones (+ virtual network links, record sets), private endpoints (+ private DNS zone groups), private link services; in-process ARM state for Terraform/OpenTofu and VM dependencies | | **Virtual Machines** | ARM path (`Microsoft.Compute`) | ✅ VM lifecycle (create/start/stop/deallocate/restart/delete/list), instanceView; mocked (Docker backing planned) | @@ -44,5 +45,6 @@ The following services spin up Docker containers on demand and require the Docke | **Azure SQL Database** | `mcr.microsoft.com/mssql/server:2025-latest` | Optional managed mode; TDS direct to container port | | **Cosmos DB engines** | Various (mongo, postgres, cassandra, …) | Protocol direct to container port | | **Azure Kubernetes Service** | `rancher/k3s:latest` | kubectl direct to k3s API server port | +| **Azure Container Apps** | User-provided images | HTTP ingress proxied through port 4577 | > These services **must** have access to the Docker daemon (`/var/run/docker.sock` mount in Docker Compose). diff --git a/mkdocs.yml b/mkdocs.yml index 98d3e647..1f37097e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,6 +84,7 @@ nav: - Azure SQL Database: services/sql.md - Azure Database for PostgreSQL: services/postgresql.md - Azure Kubernetes Service: services/aks.md + - Azure Container Apps: services/container-apps.md - API Management: services/apim.md - Virtual Network: services/network.md - Virtual Machines: services/vm.md diff --git a/src/main/java/io/floci/az/config/EmulatorConfig.java b/src/main/java/io/floci/az/config/EmulatorConfig.java index 9b041000..8856eef6 100644 --- a/src/main/java/io/floci/az/config/EmulatorConfig.java +++ b/src/main/java/io/floci/az/config/EmulatorConfig.java @@ -86,6 +86,7 @@ interface ServicesStorageConfig { ServiceStorageConfig serviceBus(); ServiceStorageConfig sql(); ServiceStorageConfig monitor(); + ServiceStorageConfig containerApps(); } interface ServiceStorageConfig { @@ -138,7 +139,7 @@ interface ServicesConfig { NetworkConfig network(); EventGridConfig eventGrid(); ManagedIdentityConfig managedIdentity(); - + ContainerAppsConfig containerApps(); /** Shared Docker network for sidecar containers (Artemis, Redpanda, etc.). */ Optional dockerNetwork(); @@ -372,6 +373,24 @@ interface AksConfig { boolean keepRunningOnShutdown(); } + /** Microsoft.App — managed environments and Docker-backed Container Apps. */ + interface ContainerAppsConfig { + @WithDefault("true") + boolean enabled(); + + /** When true, preserve ARM state and revisions without starting application containers. */ + @WithDefault("true") + boolean mocked(); + + /** DNS suffix used for emulated environment, app, and revision FQDNs. */ + @WithDefault("azurecontainerapps.io") + String dnsSuffix(); + + /** Timeout for proxied ingress requests to application containers. */ + @WithDefault("60") + int ingressTimeoutSeconds(); + } + interface ServiceBusConfig { @WithDefault("true") boolean enabled(); diff --git a/src/main/java/io/floci/az/core/AzureRequest.java b/src/main/java/io/floci/az/core/AzureRequest.java index cf6dfbac..8e89bf26 100644 --- a/src/main/java/io/floci/az/core/AzureRequest.java +++ b/src/main/java/io/floci/az/core/AzureRequest.java @@ -8,7 +8,7 @@ public record AzureRequest( String method, String accountName, - String serviceType, // "blob", "queue", "table" — resolved at dispatch + String serviceType, // "blob", "queue", "table": resolved at dispatch String resourcePath, // everything after /{accountName}/ HttpHeaders headers, InputStream bodyStream, @@ -16,9 +16,19 @@ public record AzureRequest( Map> queryParamsMulti, // repeated query params preserved (e.g. App Config `tags`) AuthContext authContext, boolean secure, // true when the request arrived over HTTPS - String host // host captured before async/blocking dispatch; may be null for direct/internal requests + String host, // host captured before async/blocking dispatch; may be null for direct/internal requests + String remoteAddress, // transport peer address; never derived from forwarded headers + String rawPath // original encoded request path, without a leading slash ) { + public AzureRequest(String method, String accountName, String serviceType, String resourcePath, + HttpHeaders headers, InputStream bodyStream, Map queryParams, + Map> queryParamsMulti, AuthContext authContext, + boolean secure) { + this(method, accountName, serviceType, resourcePath, headers, bodyStream, + queryParams, queryParamsMulti, authContext, secure, null, null, resourcePath); + } + /** * Backwards-compatible constructor for the majority of call sites that only ever read * single-valued query params. Repeated params collapse to {@code queryParamsMulti = {}}; @@ -28,29 +38,25 @@ public AzureRequest(String method, String accountName, String serviceType, Strin HttpHeaders headers, InputStream bodyStream, Map queryParams, AuthContext authContext, boolean secure) { this(method, accountName, serviceType, resourcePath, headers, bodyStream, - queryParams, Map.of(), authContext, secure, null); + queryParams, Map.of(), authContext, secure, null, null, resourcePath); } /** - * Constructor used by the routing layer when it has already captured the request host before - * switching to a blocking thread. Keeping the host on the immutable request avoids reading - * request-scoped JAX-RS headers after the thread switch. + * Keeps the host captured before blocking dispatch available without reading request-scoped headers. */ public AzureRequest(String method, String accountName, String serviceType, String resourcePath, HttpHeaders headers, InputStream bodyStream, Map queryParams, Map> queryParamsMulti, AuthContext authContext, boolean secure, String host) { - this.method = method; - this.accountName = accountName; - this.serviceType = serviceType; - this.resourcePath = resourcePath; - this.headers = headers; - this.bodyStream = bodyStream; - this.queryParams = queryParams; - this.queryParamsMulti = queryParamsMulti; - this.authContext = authContext; - this.secure = secure; - this.host = host; + this(method, accountName, serviceType, resourcePath, headers, bodyStream, + queryParams, queryParamsMulti, authContext, secure, host, null, resourcePath); + } + + public AzureRequest(String method, String accountName, String serviceType, String resourcePath, + HttpHeaders headers, InputStream bodyStream, Map queryParams, + AuthContext authContext, boolean secure, String remoteAddress) { + this(method, accountName, serviceType, resourcePath, headers, bodyStream, + queryParams, Map.of(), authContext, secure, null, remoteAddress, resourcePath); } /** @@ -60,6 +66,6 @@ public AzureRequest(String method, String accountName, String serviceType, Strin */ public AzureRequest withAuthContext(AuthContext resolved) { return new AzureRequest(method, accountName, serviceType, resourcePath, headers, bodyStream, - queryParams, queryParamsMulti, resolved, secure, host); + queryParams, queryParamsMulti, resolved, secure, host, remoteAddress, rawPath); } } diff --git a/src/main/java/io/floci/az/core/AzureRoutingFilter.java b/src/main/java/io/floci/az/core/AzureRoutingFilter.java index c91c5721..08be8ec0 100644 --- a/src/main/java/io/floci/az/core/AzureRoutingFilter.java +++ b/src/main/java/io/floci/az/core/AzureRoutingFilter.java @@ -5,6 +5,7 @@ import io.floci.az.services.monitor.MonitorHandler; import io.smallrye.mutiny.Uni; import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServerRequest; import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -93,9 +94,11 @@ private enum Fallthrough implements Outcome { private record RoutingContext( ContainerRequestContext requestContext, String path, + String rawPath, HttpHeaders headers, String host, - boolean secure + boolean secure, + String remoteAddress ) { String method() { return requestContext.getMethod(); @@ -319,9 +322,11 @@ Set routedServiceTypes() { // ── Filter entry point ────────────────────────────────────────────────────── @ServerRequestFilter(preMatching = true) - public Uni filter(ContainerRequestContext requestContext, @Context HttpHeaders httpHeaders) { + public Uni filter(ContainerRequestContext requestContext, @Context HttpHeaders httpHeaders, + @Context HttpServerRequest serverRequest) { // Capture context before switching threads String path0 = requestContext.getUriInfo().getPath(); + String rawPath0 = serverRequest.path(); HttpHeaders headers = httpHeaders; // Capture the request authority/host now (JAX-RS request scope may not propagate to the // blocking thread). Under HTTP/2 the wire protocol uses :authority instead of a Host @@ -335,16 +340,20 @@ public Uni filter(ContainerRequestContext requestContext, @Context Htt } } final String capturedHost = h; + String remoteAddress = serverRequest.remoteAddress() == null + ? null : serverRequest.remoteAddress().hostAddress(); return Uni.createFrom().completionStage( - vertx.executeBlocking(() -> doFilter(requestContext, path0, headers, capturedHost)) + vertx.executeBlocking(() -> doFilter( + requestContext, path0, rawPath0, headers, capturedHost, remoteAddress)) .toCompletionStage() ); } - private Response doFilter(ContainerRequestContext requestContext, String rawPath, HttpHeaders headers, - String capturedHost) { - String path = rawPath.startsWith("/") ? rawPath.substring(1) : rawPath; + private Response doFilter(ContainerRequestContext requestContext, String decodedPath, String rawPath, + HttpHeaders headers, String capturedHost, String remoteAddress) { + String path = trimLeadingSlash(decodedPath); + String encodedPath = trimLeadingSlash(rawPath); if (isEmulatorAdminPath(path)) { return null; @@ -352,8 +361,8 @@ private Response doFilter(ContainerRequestContext requestContext, String rawPath LOGGER.infof("Incoming request: %s %s", requestContext.getMethod(), path); - RoutingContext ctx = new RoutingContext(requestContext, path, headers, hostWithoutPort(capturedHost), - requestContext.getSecurityContext().isSecure()); + RoutingContext ctx = new RoutingContext(requestContext, path, encodedPath, headers, + hostWithoutPort(capturedHost), requestContext.getSecurityContext().isSecure(), remoteAddress); for (Function stage : stages) { Outcome outcome = stage.apply(ctx); @@ -374,6 +383,10 @@ private static boolean isEmulatorAdminPath(String path) { || path.startsWith("_floci/") || path.startsWith("_admin"); } + private static String trimLeadingSlash(String path) { + return path.startsWith("/") ? path.substring(1) : path; + } + /** * Strips the port and lowercases: hostnames are case-insensitive (RFC 4343), so every host * comparison — production suffixes and service markers alike — happens on the lowercase form. @@ -730,7 +743,7 @@ private Outcome dispatchWithoutAuth(RoutingContext ctx, String serviceType, Stri } AzureRequest request = new AzureRequest(ctx.method(), serviceType, serviceType, ctx.path(), ctx.headers(), ctx.requestContext().getEntityStream(), singleValueQueryParams(ctx.requestContext()), - Map.of(), null, ctx.secure(), ctx.host()); + Map.of(), null, ctx.secure(), ctx.host(), ctx.remoteAddress(), ctx.rawPath()); LOGGER.infof("Dispatching %s request to %s: %s %s", label, handler.get().getClass().getSimpleName(), ctx.method(), ctx.path()); return new Handled(handler.get().handle(request)); @@ -746,7 +759,8 @@ private AzureRequest buildRequest(RoutingContext ctx, String account, String ser }); AzureRequest request = new AzureRequest(ctx.method(), account, serviceType, path, ctx.headers(), - ctx.requestContext().getEntityStream(), queryParams, queryParamsMulti, null, ctx.secure(), ctx.host()); + ctx.requestContext().getEntityStream(), queryParams, queryParamsMulti, null, ctx.secure(), + ctx.host(), ctx.remoteAddress(), ctx.rawPath()); return request.withAuthContext(authPipeline.resolve(request)); } diff --git a/src/main/java/io/floci/az/core/BannerLogger.java b/src/main/java/io/floci/az/core/BannerLogger.java index 4bc9d3b8..f82378e2 100644 --- a/src/main/java/io/floci/az/core/BannerLogger.java +++ b/src/main/java/io/floci/az/core/BannerLogger.java @@ -111,6 +111,13 @@ void onStart(@Observes StartupEvent ev) { : "mocked (container-backed mode not yet available)"; sb.append(serviceStatusDocker("aci", true, aciInfo)); } + if (config.services().containerApps().enabled()) { + String containerAppsInfo = config.services().containerApps().mocked() + ? "mocked (no docker)" + : "revisions dns:" + config.services().containerApps().dnsSuffix(); + containerAppsInfo += " storage:" + getStorageMode("containerapps"); + sb.append(serviceStatusDocker("containerapps", true, containerAppsInfo)); + } if (config.services().vm().enabled()) { String vmInfo = config.services().vm().mocked() ? "mocked (no docker)" @@ -174,6 +181,7 @@ private String getStorageMode(String service) { case "keyvault" -> config.storage().services().keyVault().mode().orElse(config.storage().mode()); case "servicebus" -> config.storage().services().serviceBus().mode().orElse(config.storage().mode()); case "sql" -> config.storage().services().sql().mode().orElse(config.storage().mode()); + case "containerapps" -> config.storage().services().containerApps().mode().orElse(config.storage().mode()); default -> config.storage().mode(); }; } diff --git a/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java b/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java index cccce00c..b6edce66 100644 --- a/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java +++ b/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java @@ -28,7 +28,12 @@ import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -159,6 +164,69 @@ public ContainerInfo startCreated(String containerId, ContainerSpec spec) { return new ContainerInfo(containerId, endpoints); } + /** Returns IP addresses assigned to a container's Docker network namespace. */ + public List containerAddresses(String containerId) { + try { + InspectContainerResponse inspect = dockerClient.inspectContainerCmd(containerId).exec(); + if (inspect.getNetworkSettings() == null) { + return List.of(); + } + var networks = inspect.getNetworkSettings().getNetworks(); + if (networks == null || networks.isEmpty()) { + return List.of(); + } + List addresses = new ArrayList<>(); + for (ContainerNetwork network : networks.values()) { + if (network.getIpAddress() != null && !network.getIpAddress().isBlank()) { + addresses.add(network.getIpAddress()); + } + if (network.getGlobalIPv6Address() != null && !network.getGlobalIPv6Address().isBlank()) { + addresses.add(network.getGlobalIPv6Address()); + } + } + return List.copyOf(addresses); + } catch (NotFoundException e) { + LOG.debugv("Container {0} disappeared before its addresses could be inspected", containerId); + return List.of(); + } catch (RuntimeException e) { + LOG.warnv("Could not inspect Docker addresses for container {0}: {1}", + containerId, e.getMessage()); + return List.of(); + } + } + + /** Returns IP addresses for running containers carrying every required label. */ + public List runningContainerAddresses(Map requiredLabels) { + try { + return dockerClient.listContainersCmd().withShowAll(false).exec().stream() + .filter(container -> hasRequiredLabels(container, requiredLabels)) + .flatMap(container -> containerAddresses(container.getId()).stream()) + .distinct() + .toList(); + } catch (RuntimeException e) { + LOG.warnv("Could not inspect running Docker containers by label: {0}", e.getMessage()); + return List.of(); + } + } + + /** Tests whether an IP literal exactly matches one of the supplied IP literals. */ + public static boolean matchesAnyAddress(String address, Collection addresses) { + if (address == null || address.isBlank()) { + return false; + } + try { + byte[] candidate = InetAddress.getByName(address).getAddress(); + for (String expected : addresses) { + if (Arrays.equals(candidate, InetAddress.getByName(expected).getAddress())) { + return true; + } + } + } catch (UnknownHostException e) { + return false; + } + return false; + } + /** * Stops and removes a container, closing any associated log stream. * diff --git a/src/main/java/io/floci/az/core/storage/StorageFactory.java b/src/main/java/io/floci/az/core/storage/StorageFactory.java index f9b0ee9a..4de1a426 100644 --- a/src/main/java/io/floci/az/core/storage/StorageFactory.java +++ b/src/main/java/io/floci/az/core/storage/StorageFactory.java @@ -144,6 +144,7 @@ private Optional serviceConfig(String serviceName) { case "servicebus" -> Optional.of(config.storage().services().serviceBus()); case "sql" -> Optional.of(config.storage().services().sql()); case "monitor" -> Optional.of(config.storage().services().monitor()); + case "containerapps" -> Optional.of(config.storage().services().containerApps()); default -> Optional.empty(); }; } diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppIngressProxy.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppIngressProxy.java new file mode 100644 index 00000000..86c841e7 --- /dev/null +++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppIngressProxy.java @@ -0,0 +1,97 @@ +package io.floci.az.services.containerapps; + +import io.floci.az.config.EmulatorConfig; +import io.floci.az.core.AzureRequest; +import io.floci.az.core.docker.ContainerLifecycleManager; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** Proxies public Container App ingress requests to a running local revision replica. */ +@ApplicationScoped +public class ContainerAppIngressProxy { + + private static final Set HOP_BY_HOP = Set.of( + "connection", "content-length", "expect", "host", "keep-alive", + "proxy-authenticate", "proxy-authorization", "te", "trailer", + "transfer-encoding", "upgrade"); + + private final HttpClient httpClient; + private final Duration timeout; + + @Inject + public ContainerAppIngressProxy(EmulatorConfig config) { + this.timeout = Duration.ofSeconds(config.services().containerApps().ingressTimeoutSeconds()); + this.httpClient = HttpClient.newBuilder().connectTimeout(timeout).build(); + } + + public Response proxy(AzureRequest request, ContainerLifecycleManager.EndpointInfo endpoint) { + try { + URI target = URI.create("http://" + endpoint.host() + ":" + endpoint.port() + + "/" + trimLeadingSlash(request.rawPath()) + queryString(request.queryParamsMulti())); + byte[] body = request.bodyStream() == null ? new byte[0] : request.bodyStream().readAllBytes(); + HttpRequest.Builder outgoing = HttpRequest.newBuilder(target) + .timeout(timeout) + .method(request.method(), body.length == 0 + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofByteArray(body)); + + if (request.headers() != null) { + request.headers().getRequestHeaders().forEach((name, values) -> { + if (!HOP_BY_HOP.contains(name.toLowerCase(Locale.ROOT))) { + values.forEach(value -> outgoing.header(name, value)); + } + }); + } + + HttpResponse backend = httpClient.send( + outgoing.build(), HttpResponse.BodyHandlers.ofByteArray()); + Response.ResponseBuilder response = Response.status(backend.statusCode()).entity(backend.body()); + backend.headers().map().forEach((name, values) -> { + if (!HOP_BY_HOP.contains(name.toLowerCase(Locale.ROOT))) { + values.forEach(value -> response.header(name, value)); + } + }); + return response.build(); + } catch (Exception e) { + return Response.status(502).entity(Map.of("error", Map.of( + "code", "ContainerAppUnavailable", + "message", e.getMessage() == null ? "Container App ingress is unavailable" : e.getMessage() + ))).type("application/json").build(); + } + } + + private static String queryString(Map> parameters) { + if (parameters == null || parameters.isEmpty()) { + return ""; + } + StringBuilder query = new StringBuilder("?"); + parameters.forEach((name, values) -> values.forEach(value -> { + if (query.length() > 1) { + query.append('&'); + } + query.append(encode(name)).append('=').append(encode(value)); + })); + return query.toString(); + } + + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String trimLeadingSlash(String path) { + return path == null ? "" : path.replaceFirst("^/+", ""); + } +} diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java new file mode 100644 index 00000000..5934966a --- /dev/null +++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java @@ -0,0 +1,307 @@ +package io.floci.az.services.containerapps; + +import com.fasterxml.jackson.databind.JsonNode; +import io.floci.az.config.EmulatorConfig; +import io.floci.az.core.docker.ContainerBuilder; +import io.floci.az.core.docker.ContainerLifecycleManager; +import io.floci.az.core.docker.ContainerSpec; +import io.floci.az.core.docker.ContainerStorageHelper; +import io.floci.az.services.containerapps.ContainerAppsModels.ContainerAppState; +import io.floci.az.services.containerapps.ContainerAppsModels.RevisionState; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** Runs Container App revision replicas through shared Docker lifecycle infrastructure. */ +@ApplicationScoped +public class ContainerAppRuntimeManager { + + private static final Logger LOG = Logger.getLogger(ContainerAppRuntimeManager.class); + private static final String SERVICE_LABEL = "floci_service"; + private static final String SERVICE_LABEL_VALUE = "containerapps"; + private static final String ENVIRONMENT_LABEL = "floci_containerapps_environment"; + + private final ContainerBuilder containerBuilder; + private final ContainerLifecycleManager lifecycleManager; + private final EmulatorConfig config; + private final Map runtimes = new ConcurrentHashMap<>(); + + @Inject + public ContainerAppRuntimeManager(ContainerBuilder containerBuilder, + ContainerLifecycleManager lifecycleManager, + EmulatorConfig config) { + this.containerBuilder = containerBuilder; + this.lifecycleManager = lifecycleManager; + this.config = config; + } + + public synchronized void startRevision(ContainerAppState app, RevisionState revision, + JsonNode configuration, int replicaCount, int targetPort) { + stopRevision(app, revision.getName()); + + List replicas = new ArrayList<>(); + try { + for (int replica = 0; replica < replicaCount; replica++) { + replicas.add(startReplica(app, revision, configuration, replica, targetPort)); + } + runtimes.put(runtimeKey(app, revision.getName()), + new RevisionRuntime(normalizedEnvironmentId(app), replicas)); + LOG.infov("Started Container App revision {0} with {1} replicas", + revision.getName(), replicaCount); + } catch (RuntimeException e) { + replicas.forEach(this::stopReplica); + throw e; + } + } + + public synchronized void stopRevision(ContainerAppState app, String revisionName) { + RevisionRuntime runtime = runtimes.remove(runtimeKey(app, revisionName)); + if (runtime != null) { + runtime.replicas().forEach(this::stopReplica); + LOG.infov("Stopped Container App revision {0}", revisionName); + } + } + + public synchronized void stopApp(ContainerAppState app) { + app.getRevisions().forEach(revision -> stopRevision(app, revision.getName())); + } + + public synchronized void stopAll() { + runtimes.values().forEach(runtime -> runtime.replicas().forEach(this::stopReplica)); + runtimes.clear(); + } + + public Optional endpoint(ContainerAppState app, + String revisionName) { + RevisionRuntime runtime = runtimes.get(runtimeKey(app, revisionName)); + if (runtime == null || runtime.replicas().isEmpty()) { + return Optional.empty(); + } + int start = Math.floorMod(runtime.nextReplica().getAndIncrement(), runtime.replicas().size()); + for (int offset = 0; offset < runtime.replicas().size(); offset++) { + ReplicaRuntime replica = runtime.replicas().get((start + offset) % runtime.replicas().size()); + if (replica.ingressEndpoint() != null && isReachable(replica.ingressEndpoint(), 250)) { + return Optional.of(replica.ingressEndpoint()); + } + } + return Optional.empty(); + } + + public boolean isInternalCaller(String remoteAddress, String environmentId) { + String normalizedEnvironment = normalizeEnvironmentId(environmentId); + if (normalizedEnvironment.isBlank()) { + return false; + } + boolean matchesCurrentRuntime = runtimes.values().stream() + .filter(runtime -> normalizedEnvironment.equals(runtime.environmentId())) + .flatMap(runtime -> runtime.replicas().stream()) + .anyMatch(replica -> ContainerLifecycleManager.matchesAnyAddress( + remoteAddress, replica.networkAddresses())); + if (matchesCurrentRuntime) { + return true; + } + return ContainerLifecycleManager.matchesAnyAddress(remoteAddress, + lifecycleManager.runningContainerAddresses(environmentLabels(normalizedEnvironment))); + } + + private ReplicaRuntime startReplica(ContainerAppState app, RevisionState revision, + JsonNode configuration, int replicaIndex, int targetPort) { + JsonNode containers = revision.getTemplate().path("containers"); + if (!containers.isArray() || containers.isEmpty()) { + throw new IllegalArgumentException("properties.template.containers must contain at least one container"); + } + + Map secrets = secrets(configuration); + List containerIds = new ArrayList<>(); + ContainerLifecycleManager.EndpointInfo ingressEndpoint = null; + String networkNamespace = null; + List networkAddresses = List.of(); + Map labels = environmentLabels(normalizedEnvironmentId(app)); + + try { + for (int containerIndex = 0; containerIndex < containers.size(); containerIndex++) { + JsonNode container = containers.get(containerIndex); + String image = requiredText(container, "image"); + String containerName = containerName(app, revision, replicaIndex, containerIndex); + lifecycleManager.removeIfExists(containerName); + + ContainerBuilder.Builder builder = containerBuilder.newContainer(image) + .withName(containerName) + .withEnv(environment(container.path("env"), secrets)) + .withLabels(labels) + .withLogRotation(); + if (networkNamespace == null) { + builder.withDockerNetwork(config.services().dockerNetwork()); + } else { + builder.withNetworkMode("container:" + networkNamespace); + } + + List command = stringList(container.path("command")); + List args = stringList(container.path("args")); + if (!command.isEmpty()) { + builder.withEntrypoint(command); + } + if (!args.isEmpty()) { + builder.withCmd(args); + } + if (containerIndex == 0 && targetPort > 0) { + builder.withDynamicPort(targetPort); + } + + ContainerSpec spec = builder.build(); + ContainerLifecycleManager.ContainerInfo info = lifecycleManager.createAndStart(spec); + containerIds.add(info.containerId()); + if (networkNamespace == null) { + networkNamespace = info.containerId(); + networkAddresses = lifecycleManager.containerAddresses(networkNamespace); + } + if (containerIndex == 0 && targetPort > 0) { + ingressEndpoint = info.getEndpoint(targetPort); + } + } + if (ingressEndpoint != null) { + waitUntilReady(ingressEndpoint, revision.getName(), replicaIndex); + } + return new ReplicaRuntime(List.copyOf(containerIds), ingressEndpoint, networkAddresses); + } catch (RuntimeException e) { + stopReplica(new ReplicaRuntime(List.copyOf(containerIds), ingressEndpoint, networkAddresses)); + throw e; + } + } + + private void stopReplica(ReplicaRuntime replica) { + for (int index = replica.containerIds().size() - 1; index >= 0; index--) { + lifecycleManager.stopAndRemove(replica.containerIds().get(index), null); + } + } + + private void waitUntilReady(ContainerLifecycleManager.EndpointInfo endpoint, + String revisionName, int replicaIndex) { + Duration timeout = Duration.ofSeconds(config.services().containerApps().ingressTimeoutSeconds()); + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + if (isReachable(endpoint, 500)) { + return; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for Container App readiness", e); + } + } + throw new IllegalStateException("Container App revision '" + revisionName + "' replica " + + replicaIndex + " did not become ready on targetPort " + endpoint.port() + + " within " + timeout.toSeconds() + " seconds"); + } + + private static boolean isReachable(ContainerLifecycleManager.EndpointInfo endpoint, int timeoutMillis) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(endpoint.host(), endpoint.port()), timeoutMillis); + return true; + } catch (IOException e) { + return false; + } + } + + private static Map secrets(JsonNode configuration) { + Map result = new HashMap<>(); + for (JsonNode secret : configuration.path("secrets")) { + if (secret.hasNonNull("name") && secret.has("value")) { + result.put(secret.path("name").asText(), secret.path("value").asText()); + } + } + return result; + } + + private static List environment(JsonNode envNode, Map secrets) { + List result = new ArrayList<>(); + for (JsonNode variable : envNode) { + String name = variable.path("name").asText(); + if (name.isBlank()) { + continue; + } + if (variable.hasNonNull("secretRef")) { + String secretName = variable.path("secretRef").asText(); + if (!secrets.containsKey(secretName)) { + throw new IllegalArgumentException("Secret '" + secretName + "' was not found"); + } + result.add(name + "=" + secrets.get(secretName)); + } else { + result.add(name + "=" + variable.path("value").asText("")); + } + } + return result; + } + + private String containerName(ContainerAppState app, RevisionState revision, + int replicaIndex, int containerIndex) { + String raw = "ca-" + app.getName() + "-" + revision.getName() + + "-" + replicaIndex + "-" + containerIndex; + String sanitized = raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_.-]", "-"); + if (sanitized.length() > 55) { + sanitized = sanitized.substring(0, 46) + "-" + Integer.toHexString(raw.hashCode()); + } + return ContainerStorageHelper.dockerName(config, sanitized); + } + + private static String runtimeKey(ContainerAppState app, String revisionName) { + return app.storageKey() + "/" + revisionName.toLowerCase(Locale.ROOT); + } + + private static String normalizedEnvironmentId(ContainerAppState app) { + return normalizeEnvironmentId( + app.getDocument().path("properties").path("environmentId").asText()); + } + + private static String normalizeEnvironmentId(String environmentId) { + return environmentId == null ? "" : environmentId.toLowerCase(Locale.ROOT); + } + + private static Map environmentLabels(String environmentId) { + return Map.of( + SERVICE_LABEL, SERVICE_LABEL_VALUE, + ENVIRONMENT_LABEL, environmentId); + } + + private static String requiredText(JsonNode node, String field) { + String value = node.path(field).asText(); + if (value.isBlank()) { + throw new IllegalArgumentException("Container " + field + " is required"); + } + return value; + } + + private static List stringList(JsonNode node) { + List values = new ArrayList<>(); + if (node.isArray()) { + node.forEach(value -> values.add(value.asText())); + } + return values; + } + + private record RevisionRuntime(String environmentId, List replicas, + AtomicInteger nextReplica) { + private RevisionRuntime(String environmentId, List replicas) { + this(environmentId, List.copyOf(replicas), new AtomicInteger()); + } + } + + private record ReplicaRuntime(List containerIds, + ContainerLifecycleManager.EndpointInfo ingressEndpoint, + List networkAddresses) { + } +} diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java new file mode 100644 index 00000000..3c0c7fe5 --- /dev/null +++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java @@ -0,0 +1,961 @@ +package io.floci.az.services.containerapps; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.floci.az.config.EmulatorConfig; +import io.floci.az.core.AzureRequest; +import io.floci.az.core.AzureServiceHandler; +import io.floci.az.core.Resettable; +import io.floci.az.core.ServiceRoutes; +import io.floci.az.core.StoredObject; +import io.floci.az.core.arm.ArmErrors; +import io.floci.az.core.arm.ArmPaths; +import io.floci.az.core.arm.ResourceIndexContributor; +import io.floci.az.core.docker.ContainerLifecycleManager; +import io.floci.az.core.storage.StorageBackend; +import io.floci.az.core.storage.StorageFactory; +import io.floci.az.services.containerapps.ContainerAppsModels.ContainerAppState; +import io.floci.az.services.containerapps.ContainerAppsModels.ManagedEnvironmentState; +import io.floci.az.services.containerapps.ContainerAppsModels.RevisionState; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** Azure Container Apps management plane and HTTP ingress. */ +@ApplicationScoped +public class ContainerAppsHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { + + private static final Logger LOG = Logger.getLogger(ContainerAppsHandler.class); + private static final String PROVIDER = "/providers/Microsoft.App/"; + private static final String ENV_PREFIX = "environment/"; + private static final String APP_PREFIX = "app/"; + private static final ObjectMapper MAPPER = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private final EmulatorConfig config; + private final ContainerAppRuntimeManager runtimeManager; + private final ContainerAppIngressProxy ingressProxy; + private final StorageBackend storage; + private final Map trafficCounters = new ConcurrentHashMap<>(); + + @Inject + public ContainerAppsHandler(EmulatorConfig config, + ContainerAppRuntimeManager runtimeManager, + ContainerAppIngressProxy ingressProxy, + StorageFactory storageFactory) { + this.config = config; + this.runtimeManager = runtimeManager; + this.ingressProxy = ingressProxy; + this.storage = storageFactory.create("containerapps"); + } + + @Override + public String getServiceType() { + return "containerapps"; + } + + @Override + public boolean enabled(String serviceType) { + return config.services().containerApps().enabled(); + } + + @Override + public ServiceRoutes routes() { + return ServiceRoutes.builder() + .provider("Microsoft.App") + .host("." + config.services().containerApps().dnsSuffix()) + .build(); + } + + @Override + public boolean canHandle(AzureRequest request) { + return "containerapps".equals(request.serviceType()); + } + + @Override + public Response handle(AzureRequest request) { + try { + if (request.resourcePath().contains(PROVIDER)) { + return handleArm(request); + } + return handleIngress(request); + } catch (InvalidRequestException e) { + return ArmErrors.error(400, e.code(), e.getMessage()); + } catch (IOException e) { + return ArmErrors.error(400, "InvalidRequestContent", + "The request content was invalid and could not be deserialized."); + } + } + + private Response handleArm(AzureRequest request) throws IOException { + String path = request.resourcePath(); + String tail = providerTail(path); + String method = request.method().toUpperCase(Locale.ROOT); + String subscription = ArmPaths.subscription(path, "default"); + String resourceGroup = ArmPaths.resourceGroup(path, "default"); + + LOG.debugv("Container Apps ARM request: {0} {1}", method, path); + + if (tail.matches("managedEnvironments/[^/]+/checkNameAvailability")) { + return checkNameAvailability(method, subscription, resourceGroup, segment(tail, 1), request); + } + if ("managedEnvironments".equalsIgnoreCase(tail)) { + return handleEnvironmentCollection(method, subscription, resourceGroup, + path.contains("/resourceGroups/")); + } + if (tail.matches("managedEnvironments/[^/]+/storages")) { + return Response.ok(Map.of("value", List.of())).build(); + } + if (tail.matches("managedEnvironments/[^/]+")) { + return handleEnvironment(method, subscription, resourceGroup, segment(tail, 1), request); + } + if ("containerApps".equalsIgnoreCase(tail)) { + return handleAppCollection(method, subscription, resourceGroup, + path.contains("/resourceGroups/")); + } + if (tail.matches("containerApps/[^/]+/listSecrets") && "POST".equals(method)) { + return listSecrets(subscription, resourceGroup, segment(tail, 1)); + } + if (tail.matches("containerApps/[^/]+/revisions")) { + return listRevisions(method, subscription, resourceGroup, segment(tail, 1)); + } + if (tail.matches("containerApps/[^/]+/revisions/[^/]+/(activate|deactivate|restart)")) { + return revisionAction(method, subscription, resourceGroup, segment(tail, 1), + segment(tail, 3), segment(tail, 4)); + } + if (tail.matches("containerApps/[^/]+/revisions/[^/]+")) { + return getRevision(method, subscription, resourceGroup, + segment(tail, 1), segment(tail, 3)); + } + if (tail.matches("containerApps/[^/]+")) { + return handleApp(method, subscription, resourceGroup, segment(tail, 1), request); + } + return ArmErrors.notFound("Unknown Microsoft.App path: " + tail); + } + + private Response handleEnvironmentCollection(String method, String subscription, + String resourceGroup, boolean resourceGroupScoped) { + if (!"GET".equals(method)) { + return methodNotAllowed(); + } + List environments = environments().stream() + .filter(environment -> subscription.equalsIgnoreCase(environment.getSubscriptionId())) + .filter(environment -> !resourceGroupScoped + || resourceGroup.equalsIgnoreCase(environment.getResourceGroup())) + .map(this::environmentResponse) + .toList(); + return Response.ok(Map.of("value", environments)).build(); + } + + private Response checkNameAvailability(String method, String subscription, String resourceGroup, + String environmentName, AzureRequest request) throws IOException { + if (!"POST".equals(method)) { + return methodNotAllowed(); + } + if (read(environmentKey(subscription, resourceGroup, environmentName), + ManagedEnvironmentState.class).isEmpty()) { + return ArmErrors.notFound("Managed Environment '" + environmentName + "' was not found."); + } + ObjectNode body = readObject(request); + String name = body.path("name").asText(); + String type = body.path("type").asText(); + boolean available = !"Microsoft.App/containerApps".equalsIgnoreCase(type) + || apps().stream().noneMatch(app -> app.getName().equalsIgnoreCase(name) + && environmentId(app).equalsIgnoreCase( + environmentId(subscription, resourceGroup, environmentName))); + return Response.ok(Map.of( + "nameAvailable", available, + "reason", available ? "None" : "AlreadyExists", + "message", available ? "" : "Container App '" + name + "' already exists.")) + .build(); + } + + private Response handleEnvironment(String method, String subscription, String resourceGroup, + String name, AzureRequest request) throws IOException { + String key = environmentKey(subscription, resourceGroup, name); + return switch (method) { + case "GET" -> getEnvironment(key, name); + case "PUT", "PATCH" -> putEnvironment(key, subscription, resourceGroup, name, request, method); + case "DELETE" -> deleteEnvironment(key, subscription, resourceGroup, name); + default -> methodNotAllowed(); + }; + } + + private Response getEnvironment(String key, String name) { + return read(key, ManagedEnvironmentState.class) + .map(environment -> Response.ok(environmentResponse(environment)).build()) + .orElseGet(() -> ArmErrors.notFound("Managed Environment '" + name + "' was not found.")); + } + + private Response putEnvironment(String key, String subscription, String resourceGroup, + String name, AzureRequest request, String method) throws IOException { + Optional existing = read(key, ManagedEnvironmentState.class); + ObjectNode incoming = readObject(request); + ObjectNode document = "PATCH".equals(method) && existing.isPresent() + ? deepMerge((ObjectNode) existing.get().getDocument().deepCopy(), incoming) + : incoming; + if (!document.hasNonNull("location")) { + document.put("location", existing.map(value -> value.getDocument().path("location").asText("eastus")) + .orElse("eastus")); + } + + ManagedEnvironmentState environment = existing.orElseGet(() -> + new ManagedEnvironmentState(subscription, resourceGroup, name, document, Instant.now())); + environment.setDocument(document); + if (environment.getDefaultDomain() == null || environment.getDefaultDomain().isBlank()) { + environment.setDefaultDomain(generateDefaultDomain(subscription, resourceGroup, name)); + } + write(key, environment); + return Response.status(existing.isPresent() ? 200 : 201) + .entity(environmentResponse(environment)).build(); + } + + private Response deleteEnvironment(String key, String subscription, String resourceGroup, String name) { + String environmentId = environmentId(subscription, resourceGroup, name); + boolean inUse = apps().stream().anyMatch(app -> environmentId.equalsIgnoreCase(environmentId(app))); + if (inUse) { + return ArmErrors.error(409, "ManagedEnvironmentInUse", + "Managed Environment '" + name + "' still contains Container Apps."); + } + storage.delete(key); + return Response.noContent().build(); + } + + private Response handleAppCollection(String method, String subscription, + String resourceGroup, boolean resourceGroupScoped) { + if (!"GET".equals(method)) { + return methodNotAllowed(); + } + List containerApps = apps().stream() + .filter(app -> subscription.equalsIgnoreCase(app.getSubscriptionId())) + .filter(app -> !resourceGroupScoped || resourceGroup.equalsIgnoreCase(app.getResourceGroup())) + .map(this::appResponse) + .toList(); + return Response.ok(Map.of("value", containerApps)).build(); + } + + private Response handleApp(String method, String subscription, String resourceGroup, + String name, AzureRequest request) throws IOException { + String key = appKey(subscription, resourceGroup, name); + return switch (method) { + case "GET" -> getApp(key, name); + case "PUT", "PATCH" -> putApp(key, subscription, resourceGroup, name, request, method); + case "DELETE" -> deleteApp(key, name); + default -> methodNotAllowed(); + }; + } + + private Response getApp(String key, String name) { + return read(key, ContainerAppState.class) + .map(app -> Response.ok(appResponse(app)).build()) + .orElseGet(() -> ArmErrors.notFound("Container App '" + name + "' was not found.")); + } + + private synchronized Response putApp(String key, String subscription, String resourceGroup, String name, + AzureRequest request, String method) throws IOException { + Optional existing = read(key, ContainerAppState.class); + ObjectNode incoming = readObject(request); + ObjectNode document = "PATCH".equals(method) && existing.isPresent() + ? deepMerge((ObjectNode) existing.get().getDocument().deepCopy(), incoming) + : incoming; + applyAppDefaults(document, existing); + validateApp(document); + + ContainerAppState app = existing.orElseGet(() -> + new ContainerAppState(subscription, resourceGroup, name, document, Instant.now())); + String previousMode = app.getDocument() == null ? "Single" + : activeRevisionsMode(app.getDocument()); + JsonNode previousTemplate = app.getDocument() == null + ? null : app.getDocument().path("properties").path("template"); + JsonNode newTemplate = document.path("properties").path("template"); + boolean templateChanged = existing.isEmpty() || !newTemplate.equals(previousTemplate); + String newMode = activeRevisionsMode(document); + + app.setDocument(document); + if (templateChanged) { + createRevision(app); + } else if (!previousMode.equalsIgnoreCase(newMode) && "Single".equalsIgnoreCase(newMode)) { + enforceSingleRevisionMode(app); + } + write(key, app); + return Response.status(existing.isPresent() ? 200 : 201).entity(appResponse(app)).build(); + } + + private void applyAppDefaults(ObjectNode document, Optional existing) { + if (!document.hasNonNull("location")) { + document.put("location", existing.map(app -> app.getDocument().path("location").asText("eastus")) + .orElse("eastus")); + } + ObjectNode properties = document.withObject("/properties"); + if (!properties.hasNonNull("environmentId") && properties.hasNonNull("managedEnvironmentId")) { + properties.set("environmentId", properties.get("managedEnvironmentId")); + } + ObjectNode configuration = properties.withObject("/configuration"); + if (!configuration.hasNonNull("activeRevisionsMode")) { + configuration.put("activeRevisionsMode", "Single"); + } + } + + private void validateApp(ObjectNode document) { + JsonNode properties = document.path("properties"); + String environmentId = properties.path("environmentId").asText(); + if (environmentId.isBlank()) { + throw new InvalidRequestException("InvalidParameter", "properties.environmentId is required"); + } + if (environmentById(environmentId).isEmpty()) { + throw new InvalidRequestException("ManagedEnvironmentNotFound", + "Managed Environment '" + environmentId + "' was not found."); + } + JsonNode containers = properties.path("template").path("containers"); + if (!containers.isArray() || containers.isEmpty()) { + throw new InvalidRequestException("InvalidParameter", + "properties.template.containers must contain at least one container"); + } + containers.forEach(container -> { + if (container.path("name").asText().isBlank() || container.path("image").asText().isBlank()) { + throw new InvalidRequestException("InvalidParameter", "Container name and image are required"); + } + }); + + JsonNode scale = properties.path("template").path("scale"); + int minReplicas = scale.path("minReplicas").asInt(1); + int maxReplicas = scale.path("maxReplicas").asInt(Math.max(10, minReplicas)); + if (minReplicas < 0 || maxReplicas < 1 || minReplicas > maxReplicas) { + throw new InvalidRequestException("InvalidScaleRule", + "Scale requires 0 <= minReplicas <= maxReplicas and maxReplicas >= 1"); + } + + JsonNode ingress = properties.path("configuration").path("ingress"); + if (!ingress.isMissingNode() && !ingress.isNull()) { + int targetPort = ingress.path("targetPort").asInt(0); + if (targetPort < 1 || targetPort > 65535) { + throw new InvalidRequestException("InvalidParameter", + "properties.configuration.ingress.targetPort must be between 1 and 65535"); + } + validateTrafficWeights(ingress.path("traffic")); + } + } + + private static void validateTrafficWeights(JsonNode traffic) { + if (!traffic.isArray() || traffic.isEmpty()) { + return; + } + int totalWeight = 0; + for (JsonNode target : traffic) { + JsonNode weightNode = target.path("weight"); + int weight = weightNode.asInt(-1); + if (!weightNode.canConvertToInt() || weight < 0 || weight > 100) { + throw new InvalidRequestException("InvalidParameter", + "Ingress traffic weights must be integers between 0 and 100"); + } + totalWeight += weight; + } + if (totalWeight != 100) { + throw new InvalidRequestException("InvalidParameter", "Ingress traffic weights must total 100"); + } + } + + private void createRevision(ContainerAppState app) { + JsonNode properties = app.getDocument().path("properties"); + JsonNode template = properties.path("template").deepCopy(); + String suffix = template.path("revisionSuffix").asText(); + if (suffix.isBlank()) { + suffix = String.format("%06d", app.getNextRevision()); + app.setNextRevision(app.getNextRevision() + 1); + } + String revisionName = app.getName() + "--" + suffix; + if (findRevision(app, revisionName).isPresent()) { + throw new InvalidRequestException("ContainerAppRevisionAlreadyExists", + "Revision '" + revisionName + "' already exists."); + } + + ManagedEnvironmentState environment = environmentById(environmentId(app)).orElseThrow(); + String fqdn = revisionName + "." + defaultDomain(environment); + int replicaCount = desiredReplicas(template); + RevisionState revision = new RevisionState(revisionName, template, false, 0, fqdn); + revision.setProvisioningState("Provisioning"); + app.getRevisions().add(revision); + + try { + if (!config.services().containerApps().mocked()) { + runtimeManager.startRevision(app, revision, properties.path("configuration"), + replicaCount, targetPort(properties)); + } + markRevisionReady(revision, replicaCount); + if ("Single".equalsIgnoreCase(activeRevisionsMode(app.getDocument()))) { + deactivateOtherRevisions(app, revision); + } + } catch (RuntimeException e) { + LOG.errorf(e, "Failed to start Container App revision %s", revisionName); + markRevisionFailed(revision); + } + } + + private Response deleteApp(String key, String name) { + Optional existing = read(key, ContainerAppState.class); + existing.ifPresent(runtimeManager::stopApp); + storage.delete(key); + trafficCounters.remove(key); + LOG.infov("Deleted Container App {0}", name); + return Response.noContent().build(); + } + + private Response listSecrets(String subscription, String resourceGroup, String appName) { + return read(appKey(subscription, resourceGroup, appName), ContainerAppState.class) + .map(app -> { + ArrayNode secrets = MAPPER.createArrayNode(); + app.getDocument().path("properties").path("configuration").path("secrets") + .forEach(secret -> secrets.add(secret.deepCopy())); + ObjectNode response = MAPPER.createObjectNode(); + response.set("value", secrets); + return Response.ok(response).build(); + }) + .orElseGet(() -> ArmErrors.notFound("Container App '" + appName + "' was not found.")); + } + + private Response listRevisions(String method, String subscription, String resourceGroup, String appName) { + if (!"GET".equals(method)) { + return methodNotAllowed(); + } + return read(appKey(subscription, resourceGroup, appName), ContainerAppState.class) + .map(app -> Response.ok(Map.of("value", app.getRevisions().stream() + .map(revision -> revisionResponse(app, revision)).toList())).build()) + .orElseGet(() -> ArmErrors.notFound("Container App '" + appName + "' was not found.")); + } + + private Response getRevision(String method, String subscription, String resourceGroup, + String appName, String revisionName) { + if (!"GET".equals(method)) { + return methodNotAllowed(); + } + Optional app = read(appKey(subscription, resourceGroup, appName), ContainerAppState.class); + if (app.isEmpty()) { + return ArmErrors.notFound("Container App '" + appName + "' was not found."); + } + return findRevision(app.get(), revisionName) + .map(revision -> Response.ok(revisionResponse(app.get(), revision)).build()) + .orElseGet(() -> ArmErrors.notFound("Revision '" + revisionName + "' was not found.")); + } + + private Response revisionAction(String method, String subscription, String resourceGroup, + String appName, String revisionName, String action) { + if (!"POST".equals(method)) { + return methodNotAllowed(); + } + String key = appKey(subscription, resourceGroup, appName); + Optional appResult = read(key, ContainerAppState.class); + if (appResult.isEmpty()) { + return ArmErrors.notFound("Container App '" + appName + "' was not found."); + } + ContainerAppState app = appResult.get(); + Optional revisionResult = findRevision(app, revisionName); + if (revisionResult.isEmpty()) { + return ArmErrors.notFound("Revision '" + revisionName + "' was not found."); + } + RevisionState revision = revisionResult.get(); + + if ("deactivate".equals(action)) { + deactivate(app, revision); + } else if ("activate".equals(action)) { + activate(app, revision); + } else { + deactivate(app, revision); + activate(app, revision); + } + write(key, app); + return Response.ok().build(); + } + + private void deactivate(ContainerAppState app, RevisionState revision) { + runtimeManager.stopRevision(app, revision.getName()); + revision.setActive(false); + revision.setReplicas(0); + revision.setRunningState("Stopped"); + revision.setHealthState("None"); + revision.setLastActiveTime(Instant.now()); + } + + private void deactivateOtherRevisions(ContainerAppState app, RevisionState revisionToKeep) { + app.getRevisions().stream() + .filter(revision -> revision != revisionToKeep && revision.isActive()) + .forEach(revision -> deactivate(app, revision)); + } + + private void enforceSingleRevisionMode(ContainerAppState app) { + latestActiveRevision(app).ifPresent(revision -> deactivateOtherRevisions(app, revision)); + } + + private static void markRevisionReady(RevisionState revision, int replicaCount) { + revision.setActive(true); + revision.setLastActiveTime(null); + revision.setReplicas(replicaCount); + revision.setProvisioningState("Provisioned"); + revision.setRunningState("Running"); + revision.setHealthState("Healthy"); + } + + private static void markRevisionFailed(RevisionState revision) { + revision.setActive(false); + revision.setProvisioningState("Failed"); + revision.setRunningState("Failed"); + revision.setHealthState("Unhealthy"); + revision.setReplicas(0); + revision.setLastActiveTime(Instant.now()); + } + + private void activate(ContainerAppState app, RevisionState revision) { + JsonNode properties = app.getDocument().path("properties"); + boolean singleMode = "Single".equalsIgnoreCase( + properties.path("configuration").path("activeRevisionsMode").asText("Single")); + int replicaCount = desiredReplicas(revision.getTemplate()); + revision.setProvisioningState("Provisioning"); + try { + if (!config.services().containerApps().mocked()) { + runtimeManager.startRevision(app, revision, properties.path("configuration"), + replicaCount, targetPort(properties)); + } + markRevisionReady(revision, replicaCount); + if (singleMode) { + deactivateOtherRevisions(app, revision); + } + } catch (RuntimeException e) { + LOG.errorf(e, "Failed to activate Container App revision %s", revision.getName()); + markRevisionFailed(revision); + } + } + + private Response handleIngress(AzureRequest request) { + Optional appResult = apps().stream() + .filter(app -> appFqdn(app).equalsIgnoreCase( + request.accountName() + "." + config.services().containerApps().dnsSuffix())) + .findFirst(); + if (appResult.isEmpty()) { + return ArmErrors.notFound("Container App ingress host was not found."); + } + + ContainerAppState app = appResult.get(); + JsonNode properties = app.getDocument().path("properties"); + JsonNode ingress = properties.path("configuration").path("ingress"); + if (ingress.isMissingNode() || ingress.isNull()) { + return ArmErrors.notFound("Container App has no ingress configured."); + } + RevisionState revision = routeRevision(app, ingress).orElse(null); + if (revision == null) { + return ArmErrors.error(503, "ContainerAppUnavailable", "No active revision is available."); + } + if (config.services().containerApps().mocked()) { + if (!ingress.path("external").asBoolean(false) + && !runtimeManager.isInternalCaller(request.remoteAddress(), environmentId(app))) { + return ArmErrors.notFound("Container App ingress host was not found."); + } + return ArmErrors.error(503, "ContainerAppMocked", + "Container App is configured in mocked mode; ingress data plane is unavailable."); + } + if (!isIngressCallerAllowed(app, ingress, request)) { + return ArmErrors.notFound("Container App ingress host was not found."); + } + + Optional endpoint = + runtimeManager.endpoint(app, revision.getName()); + if (endpoint.isEmpty() && revision.getTemplate().path("scale").path("maxReplicas").asInt(10) > 0) { + try { + int replicaCount = Math.max(1, desiredReplicas(revision.getTemplate())); + runtimeManager.startRevision(app, revision, properties.path("configuration"), + replicaCount, targetPort(properties)); + revision.setReplicas(replicaCount); + write(appKey(app.getSubscriptionId(), app.getResourceGroup(), app.getName()), app); + endpoint = runtimeManager.endpoint(app, revision.getName()); + } catch (RuntimeException e) { + LOG.errorf(e, "Failed to scale Container App revision %s from zero", revision.getName()); + } + } + return endpoint.map(value -> ingressProxy.proxy(request, value)) + .orElseGet(() -> ArmErrors.error(503, "ContainerAppUnavailable", + "Active revision has no running ingress replica.")); + } + + private boolean isIngressCallerAllowed(ContainerAppState app, JsonNode ingress, AzureRequest request) { + return ingress.path("external").asBoolean(false) + || runtimeManager.isInternalCaller(request.remoteAddress(), environmentId(app)); + } + + Optional routeRevision(ContainerAppState app, JsonNode ingress) { + JsonNode traffic = ingress.path("traffic"); + if (traffic.isArray() && !traffic.isEmpty()) { + Map weighted = new LinkedHashMap<>(); + for (JsonNode target : traffic) { + Optional candidate = target.path("latestRevision").asBoolean(false) + ? latestActiveRevision(app) + : findRevision(app, target.path("revisionName").asText()); + int weight = target.path("weight").asInt(0); + if (candidate.isPresent() && candidate.get().isActive() && weight > 0) { + weighted.merge(candidate.get(), weight, Integer::sum); + } + } + int totalWeight = weighted.values().stream().mapToInt(Integer::intValue).sum(); + if (totalWeight > 0) { + String counterKey = appKey(app.getSubscriptionId(), app.getResourceGroup(), app.getName()); + AtomicInteger counter = trafficCounters.computeIfAbsent(counterKey, ignored -> new AtomicInteger()); + int selectedWeight = Math.floorMod(counter.getAndIncrement(), totalWeight); + int cumulativeWeight = 0; + for (Map.Entry target : weighted.entrySet()) { + cumulativeWeight += target.getValue(); + if (selectedWeight < cumulativeWeight) { + return Optional.of(target.getKey()); + } + } + } + } + return latestActiveRevision(app); + } + + private ObjectNode environmentResponse(ManagedEnvironmentState environment) { + ObjectNode response = (ObjectNode) environment.getDocument().deepCopy(); + response.put("id", environmentId(environment.getSubscriptionId(), environment.getResourceGroup(), + environment.getName())); + response.put("name", environment.getName()); + response.put("type", "Microsoft.App/managedEnvironments"); + ObjectNode properties = response.withObject("/properties"); + hideEnvironmentSecretValues(properties); + properties.put("provisioningState", "Succeeded"); + properties.put("defaultDomain", defaultDomain(environment)); + properties.put("staticIp", "127.0.0.1"); + if (!properties.has("zoneRedundant")) { + properties.put("zoneRedundant", false); + } + return response; + } + + private static void hideEnvironmentSecretValues(ObjectNode properties) { + properties.remove(List.of("daprAIConnectionString", "daprAIInstrumentationKey")); + JsonNode logAnalytics = properties.path("appLogsConfiguration").path("logAnalyticsConfiguration"); + if (logAnalytics instanceof ObjectNode configuration) { + configuration.remove("sharedKey"); + } + } + + private ObjectNode appResponse(ContainerAppState app) { + ObjectNode response = (ObjectNode) app.getDocument().deepCopy(); + response.put("id", appId(app)); + response.put("name", app.getName()); + response.put("type", "Microsoft.App/containerApps"); + ObjectNode properties = response.withObject("/properties"); + Optional latest = latestRevision(app); + Optional latestReady = latestReadyRevision(app); + String provisioningState = latest.map(RevisionState::getProvisioningState) + .filter("Failed"::equals).isPresent() ? "Failed" : "Succeeded"; + properties.put("provisioningState", provisioningState); + properties.put("runningStatus", app.getRevisions().stream().anyMatch(revision -> revision.isActive() + && "Running".equals(revision.getRunningState())) ? "Running" : "Stopped"); + properties.put("customDomainVerificationId", Integer.toHexString(appId(app).hashCode())); + latest.ifPresent(revision -> { + properties.put("latestRevisionName", revision.getName()); + properties.put("latestRevisionFqdn", revision.getFqdn()); + }); + if (latestReady.isPresent()) { + properties.put("latestReadyRevisionName", latestReady.get().getName()); + } else { + properties.remove("latestReadyRevisionName"); + } + properties.putArray("outboundIpAddresses").add("127.0.0.1"); + + ObjectNode configuration = properties.withObject("/configuration"); + hideSecretValues(configuration); + JsonNode ingressNode = configuration.get("ingress"); + if (ingressNode instanceof ObjectNode ingress) { + ingress.put("fqdn", appFqdn(app)); + } + return response; + } + + private ObjectNode revisionResponse(ContainerAppState app, RevisionState revision) { + ObjectNode response = MAPPER.createObjectNode(); + response.put("id", appId(app) + "/revisions/" + revision.getName()); + response.put("name", revision.getName()); + response.put("type", "Microsoft.App/containerApps/revisions"); + ObjectNode properties = response.putObject("properties"); + properties.put("active", revision.isActive()); + properties.put("createdTime", revision.getCreatedTime().toString()); + if (revision.getLastActiveTime() != null) { + properties.put("lastActiveTime", revision.getLastActiveTime().toString()); + } + properties.put("fqdn", revision.getFqdn()); + properties.put("healthState", revision.getHealthState()); + properties.put("provisioningState", revision.getProvisioningState()); + properties.put("runningState", revision.getRunningState()); + properties.put("replicas", revision.getReplicas()); + properties.put("trafficWeight", trafficWeight(app, revision)); + properties.set("template", revision.getTemplate().deepCopy()); + return response; + } + + private int trafficWeight(ContainerAppState app, RevisionState revision) { + JsonNode traffic = app.getDocument().path("properties").path("configuration") + .path("ingress").path("traffic"); + for (JsonNode target : traffic) { + if (revision.getName().equals(target.path("revisionName").asText())) { + return target.path("weight").asInt(0); + } + if (target.path("latestRevision").asBoolean(false) + && latestActiveRevision(app).filter(value -> value == revision).isPresent()) { + return target.path("weight").asInt(0); + } + } + return latestActiveRevision(app).filter(value -> value == revision).isPresent() ? 100 : 0; + } + + private void hideSecretValues(ObjectNode configuration) { + JsonNode secrets = configuration.path("secrets"); + if (secrets.isArray()) { + secrets.forEach(secret -> { + if (secret instanceof ObjectNode object) { + object.remove("value"); + } + }); + } + } + + private String appFqdn(ContainerAppState app) { + return environmentById(environmentId(app)) + .map(environment -> app.getName() + "." + defaultDomain(environment)) + .orElse(app.getName() + "." + config.services().containerApps().dnsSuffix()); + } + + private String defaultDomain(ManagedEnvironmentState environment) { + String persisted = environment.getDefaultDomain(); + return persisted == null || persisted.isBlank() + ? generateDefaultDomain(environment.getSubscriptionId(), environment.getResourceGroup(), + environment.getName()) + : persisted; + } + + private Optional environmentById(String id) { + return environments().stream() + .filter(environment -> environmentId(environment.getSubscriptionId(), + environment.getResourceGroup(), environment.getName()).equalsIgnoreCase(id)) + .findFirst(); + } + + private static Optional findRevision(ContainerAppState app, String name) { + return app.getRevisions().stream().filter(revision -> revision.getName().equalsIgnoreCase(name)).findFirst(); + } + + private static Optional latestRevision(ContainerAppState app) { + return app.getRevisions().stream().max(Comparator.comparing(RevisionState::getCreatedTime)); + } + + private static Optional latestActiveRevision(ContainerAppState app) { + return app.getRevisions().stream().filter(RevisionState::isActive) + .max(Comparator.comparing(RevisionState::getCreatedTime)); + } + + private static Optional latestReadyRevision(ContainerAppState app) { + return app.getRevisions().stream() + .filter(revision -> "Provisioned".equals(revision.getProvisioningState())) + .filter(revision -> "Healthy".equals(revision.getHealthState())) + .max(Comparator.comparing(RevisionState::getCreatedTime)); + } + + private static String activeRevisionsMode(JsonNode document) { + return document.path("properties").path("configuration") + .path("activeRevisionsMode").asText("Single"); + } + + private String generateDefaultDomain(String subscription, String resourceGroup, String name) { + String environmentId = environmentId(subscription, resourceGroup, name).toLowerCase(Locale.ROOT); + String uniqueLabel = UUID.nameUUIDFromBytes(environmentId.getBytes(StandardCharsets.UTF_8)) + .toString().replace("-", ""); + return name + "." + uniqueLabel + "." + config.services().containerApps().dnsSuffix(); + } + + private static int desiredReplicas(JsonNode template) { + JsonNode scale = template.path("scale"); + int min = scale.path("minReplicas").asInt(1); + int max = scale.path("maxReplicas").asInt(Math.max(10, min)); + return Math.min(min, max); + } + + private static int targetPort(JsonNode properties) { + return properties.path("configuration").path("ingress").path("targetPort").asInt(0); + } + + private static String environmentId(ContainerAppState app) { + return app.getDocument().path("properties").path("environmentId").asText(); + } + + private static String environmentId(String subscription, String resourceGroup, String name) { + return "/subscriptions/" + subscription + "/resourceGroups/" + resourceGroup + + "/providers/Microsoft.App/managedEnvironments/" + name; + } + + private static String appId(ContainerAppState app) { + return "/subscriptions/" + app.getSubscriptionId() + "/resourceGroups/" + app.getResourceGroup() + + "/providers/Microsoft.App/containerApps/" + app.getName(); + } + + private static String environmentKey(String subscription, String resourceGroup, String name) { + return ENV_PREFIX + subscription.toLowerCase(Locale.ROOT) + "/" + + resourceGroup.toLowerCase(Locale.ROOT) + "/" + name.toLowerCase(Locale.ROOT); + } + + private static String appKey(String subscription, String resourceGroup, String name) { + return APP_PREFIX + subscription.toLowerCase(Locale.ROOT) + "/" + + resourceGroup.toLowerCase(Locale.ROOT) + "/" + name.toLowerCase(Locale.ROOT); + } + + private List environments() { + return scan(ENV_PREFIX, ManagedEnvironmentState.class); + } + + private List apps() { + return scan(APP_PREFIX, ContainerAppState.class); + } + + @Override + public boolean indexEnabled() { + return config.services().containerApps().enabled(); + } + + @Override + public List> listRgResources(String subscription, String resourceGroup) { + List> resources = new ArrayList<>(); + environments().stream() + .filter(environment -> subscription.equalsIgnoreCase(environment.getSubscriptionId())) + .filter(environment -> resourceGroup.equalsIgnoreCase(environment.getResourceGroup())) + .map(environment -> indexEntry( + environmentId(subscription, resourceGroup, environment.getName()), + environment.getName(), "Microsoft.App/managedEnvironments", environment.getDocument())) + .forEach(resources::add); + apps().stream() + .filter(app -> subscription.equalsIgnoreCase(app.getSubscriptionId())) + .filter(app -> resourceGroup.equalsIgnoreCase(app.getResourceGroup())) + .map(app -> indexEntry(appId(app), app.getName(), "Microsoft.App/containerApps", app.getDocument())) + .forEach(resources::add); + return resources; + } + + private static Map indexEntry(String id, String name, String type, JsonNode document) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + entry.put("name", name); + entry.put("type", type); + entry.put("location", document.path("location").asText()); + JsonNode tags = document.get("tags"); + if (tags != null && tags.isObject()) { + entry.put("tags", MAPPER.convertValue(tags, Map.class)); + } + return entry; + } + + private List scan(String prefix, Class type) { + List values = new ArrayList<>(); + storage.scan(key -> key.startsWith(prefix)).forEach(stored -> { + try { + values.add(MAPPER.readValue(stored.data(), type)); + } catch (IOException e) { + LOG.warnv("Skipping unreadable Container Apps state {0}: {1}", stored.key(), e.getMessage()); + } + }); + return values; + } + + private Optional read(String key, Class type) { + return storage.get(key).flatMap(stored -> { + try { + return Optional.of(MAPPER.readValue(stored.data(), type)); + } catch (IOException e) { + LOG.warnv("Failed to deserialize Container Apps state {0}: {1}", key, e.getMessage()); + return Optional.empty(); + } + }); + } + + private void write(String key, Object value) { + try { + storage.put(key, new StoredObject(key, MAPPER.writeValueAsBytes(value), Map.of(), + Instant.now(), Integer.toHexString(value.hashCode()))); + } catch (IOException e) { + throw new IllegalStateException("Failed to serialize Container Apps state " + key, e); + } + } + + private static ObjectNode readObject(AzureRequest request) throws IOException { + JsonNode body = request.bodyStream() == null ? null : MAPPER.readTree(request.bodyStream()); + if (!(body instanceof ObjectNode object)) { + throw new IOException("Expected JSON object"); + } + return object; + } + + private static ObjectNode deepMerge(ObjectNode target, ObjectNode update) { + update.fields().forEachRemaining(entry -> { + JsonNode existing = target.get(entry.getKey()); + if (existing instanceof ObjectNode existingObject && entry.getValue() instanceof ObjectNode updateObject) { + deepMerge(existingObject, updateObject); + } else { + target.set(entry.getKey(), entry.getValue().deepCopy()); + } + }); + return target; + } + + private static String providerTail(String path) { + String tail = ArmPaths.afterSegment(path, PROVIDER, ""); + return tail.replaceAll("/+$", ""); + } + + private static String segment(String path, int index) { + String[] segments = path.split("/"); + return index < segments.length ? segments[index] : ""; + } + + private static Response methodNotAllowed() { + return ArmErrors.error(405, "MethodNotAllowed", "The requested method is not allowed."); + } + + @PreDestroy + void shutdown() { + runtimeManager.stopAll(); + } + + @Override + public void clear() { + runtimeManager.stopAll(); + trafficCounters.clear(); + storage.clear(); + } + + private static final class InvalidRequestException extends RuntimeException { + private final String code; + + private InvalidRequestException(String code, String message) { + super(message); + this.code = code; + } + + private String code() { + return code; + } + } +} diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java new file mode 100644 index 00000000..c8ac5929 --- /dev/null +++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java @@ -0,0 +1,143 @@ +package io.floci.az.services.containerapps; + +import com.fasterxml.jackson.databind.JsonNode; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +final class ContainerAppsModels { + + private ContainerAppsModels() { + } + + @RegisterForReflection + public static class ManagedEnvironmentState { + private String subscriptionId; + private String resourceGroup; + private String name; + private JsonNode document; + private String defaultDomain; + private Instant createdAt; + + public ManagedEnvironmentState() { + } + + ManagedEnvironmentState(String subscriptionId, String resourceGroup, String name, + JsonNode document, Instant createdAt) { + this.subscriptionId = subscriptionId; + this.resourceGroup = resourceGroup; + this.name = name; + this.document = document; + this.createdAt = createdAt; + } + + public String getSubscriptionId() { return subscriptionId; } + public void setSubscriptionId(String subscriptionId) { this.subscriptionId = subscriptionId; } + public String getResourceGroup() { return resourceGroup; } + public void setResourceGroup(String resourceGroup) { this.resourceGroup = resourceGroup; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public JsonNode getDocument() { return document; } + public void setDocument(JsonNode document) { this.document = document; } + public String getDefaultDomain() { return defaultDomain; } + public void setDefaultDomain(String defaultDomain) { this.defaultDomain = defaultDomain; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + } + + @RegisterForReflection + public static class ContainerAppState { + private String subscriptionId; + private String resourceGroup; + private String name; + private JsonNode document; + private List revisions = new ArrayList<>(); + private int nextRevision = 1; + private Instant createdAt; + + public ContainerAppState() { + } + + ContainerAppState(String subscriptionId, String resourceGroup, String name, + JsonNode document, Instant createdAt) { + this.subscriptionId = subscriptionId; + this.resourceGroup = resourceGroup; + this.name = name; + this.document = document; + this.createdAt = createdAt; + } + + public String getSubscriptionId() { return subscriptionId; } + public void setSubscriptionId(String subscriptionId) { this.subscriptionId = subscriptionId; } + public String getResourceGroup() { return resourceGroup; } + public void setResourceGroup(String resourceGroup) { this.resourceGroup = resourceGroup; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public JsonNode getDocument() { return document; } + public void setDocument(JsonNode document) { this.document = document; } + public List getRevisions() { return revisions; } + public void setRevisions(List revisions) { + this.revisions = revisions == null ? new ArrayList<>() : revisions; + } + public int getNextRevision() { return nextRevision; } + public void setNextRevision(int nextRevision) { this.nextRevision = nextRevision; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + String storageKey() { + return subscriptionId + "/" + resourceGroup.toLowerCase() + "/" + name.toLowerCase(); + } + } + + @RegisterForReflection + public static class RevisionState { + private String name; + private JsonNode template; + private boolean active; + private int replicas; + private String provisioningState; + private String runningState; + private String healthState; + private String fqdn; + private Instant createdTime; + private Instant lastActiveTime; + + public RevisionState() { + } + + RevisionState(String name, JsonNode template, boolean active, int replicas, String fqdn) { + this.name = name; + this.template = template; + this.active = active; + this.replicas = replicas; + this.fqdn = fqdn; + this.createdTime = Instant.now(); + this.provisioningState = "Provisioned"; + this.runningState = active ? "Running" : "Stopped"; + this.healthState = active ? "Healthy" : "None"; + } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public JsonNode getTemplate() { return template; } + public void setTemplate(JsonNode template) { this.template = template; } + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } + public int getReplicas() { return replicas; } + public void setReplicas(int replicas) { this.replicas = replicas; } + public String getProvisioningState() { return provisioningState; } + public void setProvisioningState(String provisioningState) { this.provisioningState = provisioningState; } + public String getRunningState() { return runningState; } + public void setRunningState(String runningState) { this.runningState = runningState; } + public String getHealthState() { return healthState; } + public void setHealthState(String healthState) { this.healthState = healthState; } + public String getFqdn() { return fqdn; } + public void setFqdn(String fqdn) { this.fqdn = fqdn; } + public Instant getCreatedTime() { return createdTime; } + public void setCreatedTime(Instant createdTime) { this.createdTime = createdTime; } + public Instant getLastActiveTime() { return lastActiveTime; } + public void setLastActiveTime(Instant lastActiveTime) { this.lastActiveTime = lastActiveTime; } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 216961ef..99ec99cb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -74,6 +74,8 @@ floci-az: flush-interval-ms: 5000 monitor: flush-interval-ms: 5000 + container-apps: + flush-interval-ms: 5000 dns: # When floci-az runs inside Docker, an embedded DNS server is started on port 53 @@ -197,6 +199,11 @@ floci-az: mocked: true # true = no Docker; container groups are pure ARM state (Succeeded/Running). false = back groups with real containers (PR 2) base-port: 7500 # host-port range for published group ports (clear of acr 5000-5099, redis 6379-6399, aks 6443-7443) max-port: 7599 + container-apps: + enabled: true + mocked: true # true = ARM state only. false = run revision replicas in Docker + dns-suffix: azurecontainerapps.io + ingress-timeout-seconds: 60 vm: enabled: true mocked: true # true = no Docker; VMs are pure ARM state. false = back VMs with containers (phase 2) diff --git a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java index cdb8fa9f..5ab564cf 100644 --- a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java +++ b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java @@ -26,13 +26,13 @@ * tried before the broader providers.

*/ @QuarkusTest -@DisplayName("AzureRoutingFilter — tables assembled from handlers match A4's static tables") +@DisplayName("AzureRoutingFilter — tables assembled from handlers match expected routes") class RoutingTableAssemblyTest { @Inject AzureRoutingFilter filter; - /** A4's HOST_ROUTES, plus {@code .table.core.windows.net} (host-style table addressing, #267). */ + /** A4's HOST_ROUTES plus routes introduced by later services. */ private static final Set> GOLDEN_HOST_ROUTES = Set.of( Map.entry(".vault.azure.net", "keyvault"), Map.entry(".communication.azure.com", "email"), @@ -40,7 +40,8 @@ class RoutingTableAssemblyTest { Map.entry(".dfs.core.windows.net", "blob"), Map.entry(".queue.core.windows.net", "queue"), Map.entry(".table.core.windows.net", "table"), - Map.entry(".servicebus.windows.net", "servicebus") + Map.entry(".servicebus.windows.net", "servicebus"), + Map.entry(".azurecontainerapps.io", "containerapps") ); /** A4's ACCOUNT_SUFFIX_ROUTES, verbatim (pre-sort). */ @@ -69,7 +70,7 @@ class RoutingTableAssemblyTest { ); /** - * A4's PROVIDER_ROUTES minus {@code Microsoft.EventGrid}: A6 moved Event Grid's control plane out + * A4's PROVIDER_ROUTES plus later services, minus {@code Microsoft.EventGrid}: A6 moved Event Grid's control plane out * of the filter's provider lane into the ArmHandler lane (it implements {@code ArmProviderService}), * so it is no longer a filter provider route. Every other entry is unchanged from A4. */ @@ -84,7 +85,8 @@ class RoutingTableAssemblyTest { Map.entry("/providers/Microsoft.DBforMariaDB/", "mariadb"), Map.entry("/providers/Microsoft.Compute/", "vm"), Map.entry("/providers/Microsoft.Cache/", "redis"), - Map.entry("/providers/Microsoft.Communication/", "email") + Map.entry("/providers/Microsoft.Communication/", "email"), + Map.entry("/providers/Microsoft.App/", "containerapps") ); private static Set> asEntries(List routes) { @@ -96,7 +98,7 @@ private static Set> asEntries(List routes @Test void hostRoutesMatchA4() { assertEquals(GOLDEN_HOST_ROUTES, asEntries(filter.hostRoutes())); - assertEquals(7, filter.hostRoutes().size(), "no duplicate host suffixes"); + assertEquals(GOLDEN_HOST_ROUTES.size(), filter.hostRoutes().size(), "no duplicate host suffixes"); } @Test diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java new file mode 100644 index 00000000..eaeadad3 --- /dev/null +++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java @@ -0,0 +1,55 @@ +package io.floci.az.services.containerapps; + +import com.sun.net.httpserver.HttpServer; +import io.floci.az.config.EmulatorConfig; +import io.floci.az.core.AzureRequest; +import io.floci.az.core.docker.ContainerLifecycleManager; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ContainerAppIngressProxyTest { + + @Test + void preservesEncodedIngressPath() throws Exception { + AtomicReference receivedPath = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + receivedPath.set(exchange.getRequestURI().getRawPath()); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + server.start(); + + try { + EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS); + when(config.services().containerApps().ingressTimeoutSeconds()).thenReturn(5); + HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getRequestHeaders()).thenReturn(new MultivaluedHashMap<>()); + AzureRequest request = new AzureRequest("GET", "app", "containerapps", "items/a/b c", + headers, null, Map.of(), Map.of(), null, false, "app.azurecontainerapps.io", "127.0.0.1", + "items/a%2Fb%20c%3Fvalue%23part").withAuthContext(null); + assertEquals("app.azurecontainerapps.io", request.host()); + assertEquals("127.0.0.1", request.remoteAddress()); + var endpoint = new ContainerLifecycleManager.EndpointInfo( + "127.0.0.1", server.getAddress().getPort()); + + Response response = new ContainerAppIngressProxy(config).proxy(request, endpoint); + + assertEquals(204, response.getStatus()); + assertEquals("/items/a%2Fb%20c%3Fvalue%23part", receivedPath.get()); + } finally { + server.stop(0); + } + } +} diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java new file mode 100644 index 00000000..82acc5b8 --- /dev/null +++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java @@ -0,0 +1,135 @@ +package io.floci.az.services.containerapps; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.floci.az.config.EmulatorConfig; +import io.floci.az.core.docker.ContainerBuilder; +import io.floci.az.core.docker.ContainerLifecycleManager; +import io.floci.az.core.docker.ContainerSpec; +import io.floci.az.services.containerapps.ContainerAppsModels.ContainerAppState; +import io.floci.az.services.containerapps.ContainerAppsModels.RevisionState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.RETURNS_SELF; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ContainerAppRuntimeManagerTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ENVIRONMENT_ID = "/subscriptions/sub/resourcegroups/rg/providers/" + + "microsoft.app/managedenvironments/env"; + private static final Map ENVIRONMENT_LABELS = Map.of( + "floci_service", "containerapps", + "floci_containerapps_environment", ENVIRONMENT_ID); + + private ContainerBuilder containerBuilder; + private ContainerBuilder.Builder builder; + private ContainerLifecycleManager lifecycleManager; + private ContainerAppRuntimeManager runtimeManager; + + @BeforeEach + void setUp() { + EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS); + when(config.services().dockerNetwork()).thenReturn(Optional.of("test-network")); + when(config.services().containerApps().ingressTimeoutSeconds()).thenReturn(1); + containerBuilder = mock(ContainerBuilder.class); + builder = mock(ContainerBuilder.Builder.class, RETURNS_SELF); + lifecycleManager = mock(ContainerLifecycleManager.class); + when(containerBuilder.newContainer(any())).thenReturn(builder); + runtimeManager = new ContainerAppRuntimeManager(containerBuilder, lifecycleManager, config); + } + + @Test + void replicaContainersShareLeaderNetworkNamespace() throws Exception { + ContainerSpec leaderSpec = new ContainerSpec("leader-image"); + ContainerSpec sidecarSpec = new ContainerSpec("sidecar-image"); + when(builder.build()).thenReturn(leaderSpec, sidecarSpec); + when(lifecycleManager.createAndStart(leaderSpec)) + .thenReturn(new ContainerLifecycleManager.ContainerInfo("leader-id", Map.of())); + when(lifecycleManager.createAndStart(sidecarSpec)) + .thenReturn(new ContainerLifecycleManager.ContainerInfo("sidecar-id", Map.of())); + when(lifecycleManager.containerAddresses("leader-id")).thenReturn(List.of("172.18.0.9")); + + ContainerAppState app = app(); + RevisionState revision = revision(""" + {"containers":[ + {"name":"main","image":"leader-image"}, + {"name":"metrics","image":"sidecar-image"} + ]} + """); + runtimeManager.startRevision(app, revision, MAPPER.readTree("{}"), 1, 0); + + verify(builder).withDockerNetwork(Optional.of("test-network")); + verify(builder).withNetworkMode("container:leader-id"); + verify(builder, times(2)).withLabels(ENVIRONMENT_LABELS); + assertTrue(runtimeManager.isInternalCaller("172.18.0.9", ENVIRONMENT_ID)); + assertFalse(runtimeManager.isInternalCaller("172.18.0.9", ENVIRONMENT_ID + "-other")); + assertFalse(runtimeManager.isInternalCaller("172.18.0.10", ENVIRONMENT_ID)); + assertFalse(runtimeManager.isInternalCaller("192.168.1.9", ENVIRONMENT_ID)); + } + + @Test + void rejectsCallerBeforeAnyManagedRuntimeStarts() { + assertFalse(runtimeManager.isInternalCaller("172.18.0.4", ENVIRONMENT_ID)); + } + + @Test + void authorizesLabeledEnvironmentCallerAfterManagerRestart() { + when(lifecycleManager.runningContainerAddresses(ENVIRONMENT_LABELS)) + .thenReturn(List.of("172.18.0.4")); + + assertTrue(runtimeManager.isInternalCaller("172.18.0.4", ENVIRONMENT_ID)); + assertFalse(runtimeManager.isInternalCaller("172.18.0.5", ENVIRONMENT_ID)); + } + + @Test + void endpointsRoundRobinAndSkipUnreachableReplicas() throws Exception { + try (ServerSocket firstServer = new ServerSocket(0); + ServerSocket secondServer = new ServerSocket(0)) { + ContainerSpec firstSpec = new ContainerSpec("image"); + ContainerSpec secondSpec = new ContainerSpec("image-replica-2"); + when(builder.build()).thenReturn(firstSpec, secondSpec); + var firstEndpoint = new ContainerLifecycleManager.EndpointInfo("localhost", firstServer.getLocalPort()); + var secondEndpoint = new ContainerLifecycleManager.EndpointInfo("localhost", secondServer.getLocalPort()); + when(lifecycleManager.createAndStart(firstSpec)).thenReturn( + new ContainerLifecycleManager.ContainerInfo("first-id", Map.of(8080, firstEndpoint))); + when(lifecycleManager.createAndStart(secondSpec)).thenReturn( + new ContainerLifecycleManager.ContainerInfo("second-id", Map.of(8080, secondEndpoint))); + + ContainerAppState app = app(); + RevisionState revision = revision("{\"containers\":[{\"name\":\"web\",\"image\":\"image\"}]}"); + runtimeManager.startRevision(app, revision, MAPPER.readTree("{}"), 2, 8080); + + assertEquals(firstEndpoint, runtimeManager.endpoint(app, revision.getName()).orElseThrow()); + assertEquals(secondEndpoint, runtimeManager.endpoint(app, revision.getName()).orElseThrow()); + + firstServer.close(); + assertEquals(secondEndpoint, runtimeManager.endpoint(app, revision.getName()).orElseThrow()); + } + } + + private static ContainerAppState app() throws Exception { + JsonNode document = MAPPER.readTree("{\"properties\":{\"environmentId\":\"" + + ENVIRONMENT_ID + "\"}}"); + return new ContainerAppState("sub", "rg", "app", document, Instant.now()); + } + + private static RevisionState revision(String template) throws Exception { + return new RevisionState("app--v1", MAPPER.readTree(template), false, 0, "app--v1.example"); + } +} diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerTest.java new file mode 100644 index 00000000..0ae81dc8 --- /dev/null +++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerTest.java @@ -0,0 +1,344 @@ +package io.floci.az.services.containerapps; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +@QuarkusTest +@TestProfile(ContainerAppsHandlerTest.MockedProfile.class) +@DisplayName("Container Apps ARM and revision behavior") +public class ContainerAppsHandlerTest { + + public static class MockedProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("floci-az.services.container-apps.mocked", "true"); + } + } + + private static final String SUB = "test-sub-containerapps"; + private static final String RG = "test-rg-containerapps"; + private static final String PROVIDER = "/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/providers/Microsoft.App"; + private static final String ENVIRONMENT = "local-env"; + private static final String ENVIRONMENT_ID = PROVIDER + "/managedEnvironments/" + ENVIRONMENT; + private static final String ENVIRONMENT_URL = ENVIRONMENT_ID + "?api-version=2025-07-01"; + + @BeforeEach + void reset() { + given().post("/_admin/reset").then().statusCode(204); + } + + @Test + void managedEnvironmentCrudAndLists() { + createEnvironment(); + + given().get(ENVIRONMENT_URL).then() + .statusCode(200) + .body("name", equalTo(ENVIRONMENT)) + .body("type", equalTo("Microsoft.App/managedEnvironments")) + .body("properties.provisioningState", equalTo("Succeeded")) + .body("properties.defaultDomain", startsWith("local-env.")) + .body("properties.defaultDomain", endsWith(".azurecontainerapps.io")); + + given().get(PROVIDER + "/managedEnvironments?api-version=2025-07-01").then() + .statusCode(200).body("value", hasSize(1)); + given().get("/subscriptions/" + SUB + + "/providers/Microsoft.App/managedEnvironments?api-version=2025-07-01").then() + .statusCode(200).body("value", hasSize(1)); + + given().delete(ENVIRONMENT_URL).then().statusCode(204); + given().get(ENVIRONMENT_URL).then().statusCode(404) + .body("error.code", equalTo("ResourceNotFound")); + } + + @Test + void managedEnvironmentResponsesHideWriteOnlySecrets() { + String body = """ + { + "location": "eastus", + "properties": { + "daprAIConnectionString": "InstrumentationKey=secret", + "daprAIInstrumentationKey": "secret-key", + "appLogsConfiguration": { + "logAnalyticsConfiguration": { + "customerId": "customer", + "sharedKey": "shared-secret" + } + } + } + } + """; + + given().contentType("application/json").body(body).put(ENVIRONMENT_URL).then() + .statusCode(201) + .body("properties.daprAIConnectionString", nullValue()) + .body("properties.daprAIInstrumentationKey", nullValue()) + .body("properties.appLogsConfiguration.logAnalyticsConfiguration.sharedKey", nullValue()) + .body("properties.appLogsConfiguration.logAnalyticsConfiguration.customerId", + equalTo("customer")); + + given().get(ENVIRONMENT_URL).then() + .statusCode(200) + .body("properties.daprAIConnectionString", nullValue()) + .body("properties.daprAIInstrumentationKey", nullValue()) + .body("properties.appLogsConfiguration.logAnalyticsConfiguration.sharedKey", nullValue()); + } + + @Test + void nameAvailabilityUsesManagedEnvironmentScope() { + createEnvironment(); + String body = "{\"name\":\"available-app\",\"type\":\"Microsoft.App/containerApps\"}"; + String endpoint = ENVIRONMENT_ID + "/checkNameAvailability?api-version=2025-07-01"; + + given().contentType("application/json").body(body).post(endpoint).then() + .statusCode(200) + .body("nameAvailable", equalTo(true)) + .body("reason", equalTo("None")) + .body("message", equalTo("")); + + createApp("available-app", "Single", "v1", 1); + given().contentType("application/json").body(body).post(endpoint).then() + .statusCode(200) + .body("nameAvailable", equalTo(false)) + .body("reason", equalTo("AlreadyExists")); + + given().contentType("application/json").body(body) + .post("/subscriptions/" + SUB + + "/providers/Microsoft.App/locations/eastus/checkNameAvailability" + + "?api-version=2025-07-01") + .then().statusCode(404); + } + + @Test + void resourcesAppearInResourceGroupIndex() { + createEnvironment(); + createApp("indexed-app", "Single", "v1", 1); + + given().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.name", hasItems(ENVIRONMENT, "indexed-app")) + .body("value.type", hasItems( + "Microsoft.App/managedEnvironments", "Microsoft.App/containerApps")); + } + + @Test + void appCreatePreservesTemplateHidesSecretsAndCreatesScaledRevision() { + createEnvironment(); + createApp("secret-app", "Single", "v1", 2); + + given().get(appUrl("secret-app")).then() + .statusCode(200) + .body("properties.provisioningState", equalTo("Succeeded")) + .body("properties.runningStatus", equalTo("Running")) + .body("properties.latestRevisionName", equalTo("secret-app--v1")) + .body("properties.configuration.ingress.fqdn", + equalTo("secret-app." + environmentDomain())) + .body("properties.configuration.secrets[0].name", equalTo("api-key")) + .body("properties.configuration.secrets[0].value", nullValue()) + .body("properties.template.containers[0].env[0].secretRef", equalTo("api-key")); + + given().post(appUrl("secret-app", "/listSecrets")).then() + .statusCode(200) + .body("value[0].name", equalTo("api-key")) + .body("value[0].value", equalTo("secret-value")); + + given().get(appUrl("secret-app", "/revisions")).then() + .statusCode(200) + .body("value", hasSize(1)) + .body("value[0].name", equalTo("secret-app--v1")) + .body("value[0].properties.active", equalTo(true)) + .body("value[0].properties.replicas", equalTo(2)) + .body("value[0].properties.trafficWeight", equalTo(100)); + } + + @Test + void singleAndMultipleRevisionModesFollowAzureSemantics() { + createEnvironment(); + createApp("single-app", "Single", "v1", 1); + updateTemplate("single-app", "Single", "v2"); + + given().get(appUrl("single-app", "/revisions")).then() + .statusCode(200) + .body("value", hasSize(2)) + .body("value[0].properties.active", equalTo(false)) + .body("value[0].properties.replicas", equalTo(0)) + .body("value[1].properties.active", equalTo(true)); + + createApp("multi-app", "Multiple", "blue", 1); + updateTemplate("multi-app", "Multiple", "green"); + given().get(appUrl("multi-app", "/revisions")).then() + .statusCode(200) + .body("value", hasSize(2)) + .body("value[0].properties.active", equalTo(true)) + .body("value[1].properties.active", equalTo(true)); + + given().post(appUrl("multi-app", "/revisions/multi-app--blue/deactivate")).then() + .statusCode(200); + given().get(appUrl("multi-app", "/revisions/multi-app--blue")).then() + .body("properties.active", equalTo(false)); + given().post(appUrl("multi-app", "/revisions/multi-app--blue/activate")).then() + .statusCode(200); + given().get(appUrl("multi-app", "/revisions/multi-app--blue")).then() + .body("properties.active", equalTo(true)); + } + + @Test + void validatesEnvironmentScaleAndIngress() { + given().contentType("application/json") + .body(appBody("/missing/environment", "Single", "v1", 1, 10, 8080)) + .put(appUrl("invalid-env")) + .then().statusCode(400).body("error.code", equalTo("ManagedEnvironmentNotFound")); + + createEnvironment(); + given().contentType("application/json") + .body(appBody(ENVIRONMENT_ID, "Single", "v1", 3, 2, 8080)) + .put(appUrl("invalid-scale")) + .then().statusCode(400).body("error.code", equalTo("InvalidScaleRule")); + + given().contentType("application/json") + .body(appBody(ENVIRONMENT_ID, "Single", "v1", 1, 2, 0)) + .put(appUrl("invalid-port")) + .then().statusCode(400).body("error.code", equalTo("InvalidParameter")); + } + + @Test + void externalIngressRoutesByAzureFqdnAndReportsMockedMode() { + createEnvironment(); + createApp("ingress-app", "Single", "v1", 1); + + given().header("Host", "ingress-app." + environmentDomain()) + .get("/hello") + .then().statusCode(503) + .body("error.code", equalTo("ContainerAppMocked")); + } + + @Test + void internalIngressRejectsPublicHostCaller() { + createEnvironment(); + given().contentType("application/json") + .body(appBody(ENVIRONMENT_ID, "Single", "v1", 1, 4, 8080) + .replace("\"external\": true", "\"external\": false")) + .put(appUrl("internal-app")).then().statusCode(201); + + given().header("Host", "internal-app." + environmentDomain()) + .get("/hello") + .then().statusCode(404) + .body("error.code", equalTo("ResourceNotFound")); + } + + @Test + void switchingMultipleModeToSingleDeactivatesOlderRevision() { + createEnvironment(); + createApp("mode-app", "Multiple", "blue", 1); + updateTemplate("mode-app", "Multiple", "green"); + + given().contentType("application/json") + .body("{\"properties\":{\"configuration\":{\"activeRevisionsMode\":\"Single\"}}}") + .patch(appUrl("mode-app")).then().statusCode(200); + + given().get(appUrl("mode-app", "/revisions")).then() + .statusCode(200) + .body("value", hasSize(2)) + .body("value[0].properties.active", equalTo(false)) + .body("value[1].properties.active", equalTo(true)); + } + + @Test + void deletingEnvironmentInUseReturnsConflict() { + createEnvironment(); + createApp("using-app", "Single", "v1", 1); + + given().delete(ENVIRONMENT_URL).then().statusCode(409) + .body("error.code", equalTo("ManagedEnvironmentInUse")); + given().delete(appUrl("using-app")).then().statusCode(204); + given().delete(ENVIRONMENT_URL).then().statusCode(204); + } + + private static void createEnvironment() { + given().contentType("application/json") + .body("{\"location\":\"eastus\",\"properties\":{\"zoneRedundant\":false}}") + .put(ENVIRONMENT_URL).then().statusCode(201) + .body("id", equalTo(ENVIRONMENT_ID)); + } + + private static void createApp(String name, String mode, String suffix, int minReplicas) { + given().contentType("application/json") + .body(appBody(ENVIRONMENT_ID, mode, suffix, minReplicas, 4, 8080)) + .put(appUrl(name)).then().statusCode(201) + .body("properties.latestReadyRevisionName", notNullValue()); + } + + private static void updateTemplate(String name, String mode, String suffix) { + given().contentType("application/json") + .body(""" + { + "properties": { + "configuration": {"activeRevisionsMode": "%s"}, + "template": { + "revisionSuffix": "%s", + "containers": [{"name": "web", "image": "nginx:alpine"}], + "scale": {"minReplicas": 1, "maxReplicas": 4} + } + } + } + """.formatted(mode, suffix)) + .patch(appUrl(name)).then().statusCode(200); + } + + private static String appBody(String environmentId, String mode, String suffix, + int minReplicas, int maxReplicas, int targetPort) { + return """ + { + "location": "eastus", + "tags": {"env": "test"}, + "properties": { + "environmentId": "%s", + "configuration": { + "activeRevisionsMode": "%s", + "secrets": [{"name": "api-key", "value": "secret-value"}], + "ingress": {"external": true, "targetPort": %d} + }, + "template": { + "revisionSuffix": "%s", + "containers": [{ + "name": "web", + "image": "nginx:alpine", + "env": [{"name": "API_KEY", "secretRef": "api-key"}] + }], + "scale": {"minReplicas": %d, "maxReplicas": %d, + "rules": [{"name": "http", "http": {"metadata": {"concurrentRequests": "10"}}}]} + } + } + } + """.formatted(environmentId, mode, targetPort, suffix, minReplicas, maxReplicas); + } + + private static String appUrl(String name) { + return appUrl(name, ""); + } + + private static String appUrl(String name, String child) { + return PROVIDER + "/containerApps/" + name + child + "?api-version=2025-07-01"; + } + + private static String environmentDomain() { + return given().get(ENVIRONMENT_URL).then().statusCode(200) + .extract().path("properties.defaultDomain"); + } +} diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java new file mode 100644 index 00000000..6fc6c58d --- /dev/null +++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java @@ -0,0 +1,215 @@ +package io.floci.az.services.containerapps; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.floci.az.config.EmulatorConfig; +import io.floci.az.core.AzureRequest; +import io.floci.az.core.StoredObject; +import io.floci.az.core.docker.ContainerLifecycleManager; +import io.floci.az.core.storage.InMemoryStorage; +import io.floci.az.core.storage.StorageBackend; +import io.floci.az.core.storage.StorageFactory; +import io.floci.az.services.containerapps.ContainerAppsModels.ContainerAppState; +import io.floci.az.services.containerapps.ContainerAppsModels.RevisionState; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ContainerAppsHandlerUnitTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String PROVIDER = "subscriptions/sub/resourceGroups/rg/providers/Microsoft.App/"; + private static final String ENVIRONMENT_ID = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.App/managedEnvironments/env"; + + private ContainerAppRuntimeManager runtimeManager; + private ContainerAppIngressProxy ingressProxy; + private ContainerAppsHandler handler; + + @BeforeEach + void setUp() { + EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS); + when(config.services().containerApps().dnsSuffix()).thenReturn("azurecontainerapps.io"); + when(config.services().containerApps().mocked()).thenReturn(false); + + runtimeManager = mock(ContainerAppRuntimeManager.class); + ingressProxy = mock(ContainerAppIngressProxy.class); + StorageFactory storageFactory = mock(StorageFactory.class); + StorageBackend storage = new InMemoryStorage<>(); + when(storageFactory.create("containerapps")).thenReturn(storage); + handler = new ContainerAppsHandler(config, runtimeManager, ingressProxy, storageFactory); + } + + @Test + void failedSingleModeReplacementLeavesReadyRevisionActive() { + doNothing().doThrow(new IllegalStateException("readiness failed")) + .when(runtimeManager).startRevision(any(), any(), any(), anyInt(), anyInt()); + createEnvironment("sub", "rg", "env"); + + assertEquals(201, handler.handle(request("PUT", PROVIDER + "containerApps/app", appBody("v1"))) + .getStatus()); + Response update = handler.handle(request("PATCH", PROVIDER + "containerApps/app", appBody("v2"))); + + assertEquals(200, update.getStatus()); + ObjectNode app = (ObjectNode) update.getEntity(); + assertEquals("app--v1", app.path("properties").path("latestReadyRevisionName").asText()); + assertEquals("Failed", app.path("properties").path("provisioningState").asText()); + + Response revisionsResponse = handler.handle(request("GET", + PROVIDER + "containerApps/app/revisions", null)); + @SuppressWarnings("unchecked") + List revisions = (List) ((Map) revisionsResponse.getEntity()).get("value"); + assertTrue(revisions.get(0).path("properties").path("active").asBoolean()); + assertFalse(revisions.get(1).path("properties").path("active").asBoolean()); + verify(runtimeManager, never()).stopRevision(any(), org.mockito.ArgumentMatchers.eq("app--v1")); + } + + @Test + void trafficWeightsSelectRevisionsProportionally() throws Exception { + ObjectNode document = (ObjectNode) MAPPER.readTree(""" + {"properties":{"configuration":{"ingress":{"traffic":[ + {"revisionName":"app--blue","weight":20}, + {"revisionName":"app--green","weight":80} + ]}}}} + """); + ContainerAppState app = new ContainerAppState("sub", "rg", "app", document, Instant.now()); + RevisionState blue = revision("app--blue", Instant.parse("2026-01-01T00:00:00Z")); + RevisionState green = revision("app--green", Instant.parse("2026-01-01T00:00:01Z")); + app.getRevisions().addAll(List.of(blue, green)); + + int blueSelections = 0; + for (int request = 0; request < 100; request++) { + if (handler.routeRevision(app, document.path("properties").path("configuration").path("ingress")) + .orElseThrow() == blue) { + blueSelections++; + } + } + + assertEquals(20, blueSelections); + } + + @Test + void internalIngressUsesExactManagedContainerAddresses() { + List addresses = List.of("172.18.0.4", "fd00::4"); + assertFalse(ContainerLifecycleManager.matchesAnyAddress(null, addresses)); + assertFalse(ContainerLifecycleManager.matchesAnyAddress("172.18.0.5", addresses)); + assertFalse(ContainerLifecycleManager.matchesAnyAddress("fd00::5", addresses)); + assertTrue(ContainerLifecycleManager.matchesAnyAddress("172.18.0.4", addresses)); + assertTrue(ContainerLifecycleManager.matchesAnyAddress("fd00:0:0:0:0:0:0:4", addresses)); + } + + @Test + void restoredInternalIngressAuthorizesCallerBeforeStartingRuntime() { + AtomicBoolean runtimeStarted = new AtomicBoolean(); + doAnswer(ignored -> { + runtimeStarted.set(true); + return null; + }).when(runtimeManager).startRevision(any(), any(), any(), anyInt(), anyInt()); + when(runtimeManager.isInternalCaller("172.18.0.4", ENVIRONMENT_ID)).thenReturn(true); + var endpoint = new ContainerLifecycleManager.EndpointInfo("localhost", 8080); + when(runtimeManager.endpoint(any(), any())) + .thenAnswer(ignored -> runtimeStarted.get() ? Optional.of(endpoint) : Optional.empty()); + when(ingressProxy.proxy(any(), any())).thenReturn(Response.noContent().build()); + + ObjectNode environment = (ObjectNode) createEnvironment("sub", "rg", "env").getEntity(); + String internalBody = appBody("v1").replace("\"external\":true", "\"external\":false"); + assertEquals(201, handler.handle(request("PUT", PROVIDER + "containerApps/internal-app", internalBody)) + .getStatus()); + runtimeStarted.set(false); + + Response response = handler.handle(new AzureRequest("GET", ingressAccountName(environment, "internal-app"), + "containerapps", "hello", + null, null, Map.of(), null, false, "172.18.0.4")); + + assertEquals(204, response.getStatus()); + assertTrue(runtimeStarted.get()); + } + + @Test + void restoredInternalIngressRejectsCallerWithoutStartingRuntime() { + ObjectNode environment = (ObjectNode) createEnvironment("sub", "rg", "env").getEntity(); + String internalBody = appBody("v1").replace("\"external\":true", "\"external\":false"); + assertEquals(201, handler.handle(request("PUT", PROVIDER + "containerApps/internal-app", internalBody)) + .getStatus()); + clearInvocations(runtimeManager); + + Response response = handler.handle(new AzureRequest("GET", ingressAccountName(environment, "internal-app"), + "containerapps", "hello", + null, null, Map.of(), null, false, "203.0.113.4")); + + assertEquals(404, response.getStatus()); + verify(runtimeManager, never()).startRevision(any(), any(), any(), anyInt(), anyInt()); + } + + @Test + void defaultDomainsRemainStableAndUniqueAcrossResourceGroups() { + ObjectNode first = (ObjectNode) createEnvironment("sub", "rg-a", "shared").getEntity(); + ObjectNode second = (ObjectNode) createEnvironment("sub", "rg-b", "shared").getEntity(); + String firstDomain = first.path("properties").path("defaultDomain").asText(); + String secondDomain = second.path("properties").path("defaultDomain").asText(); + + assertNotEquals(firstDomain, secondDomain); + ObjectNode persisted = (ObjectNode) handler.handle(request("GET", + "subscriptions/sub/resourceGroups/rg-a/providers/Microsoft.App/managedEnvironments/shared", null)) + .getEntity(); + assertEquals(firstDomain, persisted.path("properties").path("defaultDomain").asText()); + } + + private Response createEnvironment(String subscription, String resourceGroup, String name) { + return handler.handle(request("PUT", "subscriptions/" + subscription + "/resourceGroups/" + + resourceGroup + "/providers/Microsoft.App/managedEnvironments/" + name, + "{\"location\":\"eastus\",\"properties\":{}}")); + } + + private static String ingressAccountName(ObjectNode environment, String appName) { + String fqdn = appName + "." + environment.path("properties").path("defaultDomain").asText(); + return fqdn.substring(0, fqdn.length() - ".azurecontainerapps.io".length()); + } + + private static RevisionState revision(String name, Instant createdTime) throws Exception { + RevisionState revision = new RevisionState(name, MAPPER.readTree("{}"), true, 1, name + ".example"); + revision.setCreatedTime(createdTime); + return revision; + } + + private static AzureRequest request(String method, String path, String body) { + return new AzureRequest(method, "containerapps", "containerapps", path, null, + body == null ? null : new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)), + Map.of(), null, false); + } + + private static String appBody(String suffix) { + return """ + {"location":"eastus","properties":{ + "environmentId":"%s", + "configuration":{"activeRevisionsMode":"Single","ingress":{"external":true,"targetPort":8080}}, + "template":{"revisionSuffix":"%s","containers":[{"name":"web","image":"nginx:alpine"}], + "scale":{"minReplicas":1,"maxReplicas":2}} + }} + """.formatted(ENVIRONMENT_ID, suffix); + } +}