From f590909ed648125df3440797d0663dfd92819870 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:43:56 +0100
Subject: [PATCH 01/11] feat(containerapps): add service support
Implement Azure-compatible managed environments, Container Apps, revisions, scaling, and Docker-backed ingress.
Closes #62
---
README.md | 7 +-
compatibility-tests/sdk-test-java/pom.xml | 16 +
.../ContainerAppsCompatibilityTest.java | 129 +++
.../configuration/advanced/application-yml.md | 10 +
docs/services/container-apps.md | 108 +++
docs/services/index.md | 2 +
mkdocs.yml | 1 +
.../io/floci/az/config/EmulatorConfig.java | 21 +-
.../java/io/floci/az/core/AzureRequest.java | 41 +-
.../io/floci/az/core/AzureRoutingFilter.java | 19 +-
.../java/io/floci/az/core/BannerLogger.java | 7 +
.../docker/ContainerLifecycleManager.java | 89 ++
.../floci/az/core/storage/StorageFactory.java | 1 +
.../ContainerAppIngressProxy.java | 97 ++
.../ContainerAppRuntimeManager.java | 275 ++++++
.../containerapps/ContainerAppsHandler.java | 884 ++++++++++++++++++
.../containerapps/ContainerAppsModels.java | 143 +++
src/main/resources/application.yml | 7 +
.../az/core/RoutingTableAssemblyTest.java | 12 +-
.../ContainerAppRuntimeManagerTest.java | 110 +++
.../ContainerAppsHandlerTest.java | 272 ++++++
.../ContainerAppsHandlerUnitTest.java | 163 ++++
22 files changed, 2381 insertions(+), 33 deletions(-)
create mode 100644 compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/ContainerAppsCompatibilityTest.java
create mode 100644 docs/services/container-apps.md
create mode 100644 src/main/java/io/floci/az/services/containerapps/ContainerAppIngressProxy.java
create mode 100644 src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
create mode 100644 src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
create mode 100644 src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java
create mode 100644 src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
create mode 100644 src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerTest.java
create mode 100644 src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
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..e0436683 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: false
+ 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` | `false` | 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..befa873e
--- /dev/null
+++ b/docs/services/container-apps.md
@@ -0,0 +1,108 @@
+# 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
+
+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: false
+ 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` | `false` | 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 |
+
+Real mode requires access to 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..e0bc4871 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("false")
+ 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..543c0b43 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,18 @@ 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
) {
+ 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);
+ }
+
/**
* 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 +37,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);
}
/**
- * 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);
+ }
+
+ 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);
}
/**
@@ -60,6 +65,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);
}
}
diff --git a/src/main/java/io/floci/az/core/AzureRoutingFilter.java b/src/main/java/io/floci/az/core/AzureRoutingFilter.java
index c91c5721..54ca2002 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;
@@ -95,7 +96,8 @@ private record RoutingContext(
String path,
HttpHeaders headers,
String host,
- boolean secure
+ boolean secure,
+ String remoteAddress
) {
String method() {
return requestContext.getMethod();
@@ -319,7 +321,8 @@ 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();
HttpHeaders headers = httpHeaders;
@@ -335,15 +338,17 @@ 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, headers, capturedHost, remoteAddress))
.toCompletionStage()
);
}
private Response doFilter(ContainerRequestContext requestContext, String rawPath, HttpHeaders headers,
- String capturedHost) {
+ String capturedHost, String remoteAddress) {
String path = rawPath.startsWith("/") ? rawPath.substring(1) : rawPath;
if (isEmulatorAdminPath(path)) {
@@ -353,7 +358,7 @@ 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());
+ requestContext.getSecurityContext().isSecure(), remoteAddress);
for (Function stage : stages) {
Outcome outcome = stage.apply(ctx);
@@ -730,7 +735,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());
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 +751,7 @@ 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());
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..c29e241e 100644
--- a/src/main/java/io/floci/az/core/BannerLogger.java
+++ b/src/main/java/io/floci/az/core/BannerLogger.java
@@ -111,6 +111,12 @@ 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();
+ sb.append(serviceStatusDocker("containerapps", true, containerAppsInfo));
+ }
if (config.services().vm().enabled()) {
String vmInfo = config.services().vm().mocked()
? "mocked (no docker)"
@@ -174,6 +180,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..f928d6dc 100644
--- a/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java
+++ b/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java
@@ -16,6 +16,7 @@
import com.github.dockerjava.api.model.HostConfig;
import com.github.dockerjava.api.model.Mount;
import com.github.dockerjava.api.model.MountType;
+import com.github.dockerjava.api.model.Network;
import com.github.dockerjava.api.model.Ports;
import com.github.dockerjava.core.command.WaitContainerResultCallback;
import jakarta.enterprise.context.ApplicationScoped;
@@ -28,7 +29,11 @@
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.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -159,6 +164,90 @@ public ContainerInfo startCreated(String containerId, ContainerSpec spec) {
return new ContainerInfo(containerId, endpoints);
}
+ /** Returns Docker IPAM subnets attached to a container. */
+ public List networkSubnets(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 subnets = new ArrayList<>();
+ for (String networkName : networks.keySet()) {
+ try {
+ Network network = dockerClient.inspectNetworkCmd()
+ .withNetworkId(networkName)
+ .exec();
+ if (network.getIpam() == null || network.getIpam().getConfig() == null) {
+ continue;
+ }
+ network.getIpam().getConfig().stream()
+ .map(Network.Ipam.Config::getSubnet)
+ .filter(subnet -> subnet != null && !subnet.isBlank())
+ .forEach(subnets::add);
+ } catch (NotFoundException e) {
+ LOG.debugv("Docker network {0} disappeared while inspecting container {1}",
+ networkName, containerId);
+ } catch (DockerException e) {
+ LOG.warnv("Could not inspect Docker network {0} for container {1}: {2}",
+ networkName, containerId, e.getMessage());
+ }
+ }
+ return List.copyOf(subnets);
+ } catch (NotFoundException e) {
+ LOG.debugv("Container {0} disappeared before its network could be inspected", containerId);
+ return List.of();
+ } catch (DockerException e) {
+ LOG.warnv("Could not inspect Docker networks for container {0}: {1}",
+ containerId, e.getMessage());
+ return List.of();
+ }
+ }
+
+ /** Tests an IP literal against Docker IPAM CIDR subnets without trusting forwarded headers. */
+ public static boolean isAddressInSubnets(String address, Collection subnets) {
+ if (address == null || address.isBlank()) {
+ return false;
+ }
+ try {
+ byte[] candidate = InetAddress.getByName(address).getAddress();
+ for (String subnet : subnets) {
+ String[] parts = subnet.split("/", 2);
+ if (parts.length != 2) {
+ continue;
+ }
+ byte[] network = InetAddress.getByName(parts[0]).getAddress();
+ int prefixLength = Integer.parseInt(parts[1]);
+ if (network.length == candidate.length
+ && prefixLength >= 0 && prefixLength <= network.length * Byte.SIZE
+ && matchesPrefix(candidate, network, prefixLength)) {
+ return true;
+ }
+ }
+ } catch (UnknownHostException | NumberFormatException e) {
+ return false;
+ }
+ return false;
+ }
+
+ private static boolean matchesPrefix(byte[] address, byte[] network, int prefixLength) {
+ int wholeBytes = prefixLength / Byte.SIZE;
+ int remainingBits = prefixLength % Byte.SIZE;
+ for (int index = 0; index < wholeBytes; index++) {
+ if (address[index] != network[index]) {
+ return false;
+ }
+ }
+ if (remainingBits == 0) {
+ return true;
+ }
+ int mask = 0xff & (0xff << (Byte.SIZE - remainingBits));
+ return (address[wholeBytes] & mask) == (network[wholeBytes] & mask);
+ }
+
/**
* 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..6e7636c3
--- /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.resourcePath()) + 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..0c917d23
--- /dev/null
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
@@ -0,0 +1,275 @@
+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 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(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) {
+ return runtimes.values().stream()
+ .flatMap(runtime -> runtime.replicas().stream())
+ .anyMatch(replica -> ContainerLifecycleManager.isAddressInSubnets(
+ remoteAddress, replica.networkSubnets()));
+ }
+
+ 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 networkSubnets = List.of();
+
+ 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))
+ .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();
+ networkSubnets = lifecycleManager.networkSubnets(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, networkSubnets);
+ } catch (RuntimeException e) {
+ stopReplica(new ReplicaRuntime(List.copyOf(containerIds), ingressEndpoint, networkSubnets));
+ 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 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(List replicas, AtomicInteger nextReplica) {
+ private RevisionRuntime(List replicas) {
+ this(List.copyOf(replicas), new AtomicInteger());
+ }
+ }
+
+ private record ReplicaRuntime(List containerIds,
+ ContainerLifecycleManager.EndpointInfo ingressEndpoint,
+ List networkSubnets) {
+ }
+}
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..7ae7cc77
--- /dev/null
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
@@ -0,0 +1,884 @@
+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.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 {
+
+ 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("locations/[^/]+/checkNameAvailability") && "POST".equals(method)) {
+ return Response.ok(Map.of("nameAvailable", true)).build();
+ }
+ 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 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.");
+ }
+ if (!ingress.path("external").asBoolean(false)
+ && !runtimeManager.isInternalCaller(request.remoteAddress())) {
+ return ArmErrors.notFound("Container App ingress host was not found.");
+ }
+ 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()) {
+ return ArmErrors.error(503, "ContainerAppMocked",
+ "Container App is configured in mocked mode; ingress data plane is unavailable.");
+ }
+
+ 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."));
+ }
+
+ 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");
+ 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 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);
+ }
+
+ 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..d92854c9 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: false # false = run revision replicas in Docker. true = ARM state only
+ 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..6dc6f427 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) {
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..c91f22f0
--- /dev/null
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
@@ -0,0 +1,110 @@
+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.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.verify;
+import static org.mockito.Mockito.when;
+
+class ContainerAppRuntimeManagerTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ 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.networkSubnets("leader-id")).thenReturn(java.util.List.of("172.18.0.0/16"));
+
+ 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");
+ assertTrue(runtimeManager.isInternalCaller("172.18.0.9"));
+ assertFalse(runtimeManager.isInternalCaller("192.168.1.9"));
+ }
+
+ @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\":{}}");
+ 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..a452334b
--- /dev/null
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerTest.java
@@ -0,0 +1,272 @@
+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.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 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..515c7b87
--- /dev/null
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
@@ -0,0 +1,163 @@
+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 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.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 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);
+ StorageFactory storageFactory = mock(StorageFactory.class);
+ StorageBackend storage = new InMemoryStorage<>();
+ when(storageFactory.create("containerapps")).thenReturn(storage);
+ handler = new ContainerAppsHandler(config, runtimeManager,
+ mock(ContainerAppIngressProxy.class), 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 internalIngressUsesExactDockerNetworkSubnets() {
+ List subnets = List.of("172.18.0.0/16", "fd00::/64");
+ assertFalse(ContainerLifecycleManager.isAddressInSubnets(null, subnets));
+ assertFalse(ContainerLifecycleManager.isAddressInSubnets("127.0.0.1", subnets));
+ assertFalse(ContainerLifecycleManager.isAddressInSubnets("172.19.0.4", subnets));
+ assertFalse(ContainerLifecycleManager.isAddressInSubnets("8.8.8.8", subnets));
+ assertTrue(ContainerLifecycleManager.isAddressInSubnets("172.18.0.4", subnets));
+ assertTrue(ContainerLifecycleManager.isAddressInSubnets("fd00::4", subnets));
+ }
+
+ @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 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);
+ }
+}
From 4fb38cbcd5016d128c4d337b02861ce383cc269c Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:53:08 +0100
Subject: [PATCH 02/11] fix(containerapps): preserve encoded paths
---
.../java/io/floci/az/core/AzureRequest.java | 13 ++---
.../io/floci/az/core/AzureRoutingFilter.java | 24 ++++++---
.../ContainerAppIngressProxy.java | 2 +-
.../ContainerAppIngressProxyTest.java | 53 +++++++++++++++++++
4 files changed, 77 insertions(+), 15 deletions(-)
create mode 100644 src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java
diff --git a/src/main/java/io/floci/az/core/AzureRequest.java b/src/main/java/io/floci/az/core/AzureRequest.java
index 543c0b43..8e89bf26 100644
--- a/src/main/java/io/floci/az/core/AzureRequest.java
+++ b/src/main/java/io/floci/az/core/AzureRequest.java
@@ -17,7 +17,8 @@ public record AzureRequest(
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 remoteAddress // transport peer address; never derived from forwarded headers
+ 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,
@@ -25,7 +26,7 @@ public AzureRequest(String method, String accountName, String serviceType, Strin
Map> queryParamsMulti, AuthContext authContext,
boolean secure) {
this(method, accountName, serviceType, resourcePath, headers, bodyStream,
- queryParams, queryParamsMulti, authContext, secure, null, null);
+ queryParams, queryParamsMulti, authContext, secure, null, null, resourcePath);
}
/**
@@ -37,7 +38,7 @@ 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, null);
+ queryParams, Map.of(), authContext, secure, null, null, resourcePath);
}
/**
@@ -48,14 +49,14 @@ public AzureRequest(String method, String accountName, String serviceType, Strin
Map> queryParamsMulti, AuthContext authContext, boolean secure,
String host) {
this(method, accountName, serviceType, resourcePath, headers, bodyStream,
- queryParams, queryParamsMulti, authContext, secure, host, null);
+ 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);
+ queryParams, Map.of(), authContext, secure, null, remoteAddress, resourcePath);
}
/**
@@ -65,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, remoteAddress);
+ 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 54ca2002..2d66e17d 100644
--- a/src/main/java/io/floci/az/core/AzureRoutingFilter.java
+++ b/src/main/java/io/floci/az/core/AzureRoutingFilter.java
@@ -94,6 +94,7 @@ private enum Fallthrough implements Outcome {
private record RoutingContext(
ContainerRequestContext requestContext,
String path,
+ String rawPath,
HttpHeaders headers,
String host,
boolean secure,
@@ -325,6 +326,7 @@ public Uni filter(ContainerRequestContext requestContext, @Context Htt
@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
@@ -342,14 +344,16 @@ public Uni filter(ContainerRequestContext requestContext, @Context Htt
? null : serverRequest.remoteAddress().hostAddress();
return Uni.createFrom().completionStage(
- vertx.executeBlocking(() -> doFilter(requestContext, path0, headers, capturedHost, remoteAddress))
+ vertx.executeBlocking(() -> doFilter(
+ requestContext, path0, rawPath0, headers, capturedHost, remoteAddress))
.toCompletionStage()
);
}
- private Response doFilter(ContainerRequestContext requestContext, String rawPath, HttpHeaders headers,
- String capturedHost, String remoteAddress) {
- 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;
@@ -357,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(), remoteAddress);
+ RoutingContext ctx = new RoutingContext(requestContext, path, encodedPath, headers,
+ hostWithoutPort(capturedHost), requestContext.getSecurityContext().isSecure(), remoteAddress);
for (Function stage : stages) {
Outcome outcome = stage.apply(ctx);
@@ -379,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.
@@ -735,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(), ctx.remoteAddress());
+ 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));
@@ -751,7 +759,7 @@ 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.remoteAddress());
+ 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/services/containerapps/ContainerAppIngressProxy.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppIngressProxy.java
index 6e7636c3..86c841e7 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppIngressProxy.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppIngressProxy.java
@@ -40,7 +40,7 @@ public ContainerAppIngressProxy(EmulatorConfig config) {
public Response proxy(AzureRequest request, ContainerLifecycleManager.EndpointInfo endpoint) {
try {
URI target = URI.create("http://" + endpoint.host() + ":" + endpoint.port()
- + "/" + trimLeadingSlash(request.resourcePath()) + queryString(request.queryParamsMulti()));
+ + "/" + 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)
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..607c0247
--- /dev/null
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java
@@ -0,0 +1,53 @@
+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, "127.0.0.1",
+ "items/a%2Fb%20c%3Fvalue%23part");
+ 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);
+ }
+ }
+}
From abb9808fc96083e21c357d8d84cd765cf040bb45 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 28 Aug 2026 00:45:54 +0100
Subject: [PATCH 03/11] fix: restore internal ingress runtimes
---
.../containerapps/ContainerAppsHandler.java | 15 ++++++--
.../ContainerAppsHandlerUnitTest.java | 37 ++++++++++++++++++-
2 files changed, 46 insertions(+), 6 deletions(-)
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
index 7ae7cc77..cfea0b93 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
@@ -536,15 +536,14 @@ private Response handleIngress(AzureRequest request) {
if (ingress.isMissingNode() || ingress.isNull()) {
return ArmErrors.notFound("Container App has no ingress configured.");
}
- if (!ingress.path("external").asBoolean(false)
- && !runtimeManager.isInternalCaller(request.remoteAddress())) {
- return ArmErrors.notFound("Container App ingress host was not found.");
- }
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 (!isIngressCallerAllowed(ingress, request)) {
+ 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.");
}
@@ -563,11 +562,19 @@ private Response handleIngress(AzureRequest request) {
LOG.errorf(e, "Failed to scale Container App revision %s from zero", revision.getName());
}
}
+ if (!isIngressCallerAllowed(ingress, request)) {
+ return ArmErrors.notFound("Container App ingress host was not found.");
+ }
return endpoint.map(value -> ingressProxy.proxy(request, value))
.orElseGet(() -> ArmErrors.error(503, "ContainerAppUnavailable",
"Active revision has no running ingress replica."));
}
+ private boolean isIngressCallerAllowed(JsonNode ingress, AzureRequest request) {
+ return ingress.path("external").asBoolean(false)
+ || runtimeManager.isInternalCaller(request.remoteAddress());
+ }
+
Optional routeRevision(ContainerAppState app, JsonNode ingress) {
JsonNode traffic = ingress.path("traffic");
if (traffic.isArray() && !traffic.isEmpty()) {
diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
index 515c7b87..c8744c83 100644
--- a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
@@ -20,6 +20,8 @@
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;
@@ -28,6 +30,7 @@
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.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
@@ -43,6 +46,7 @@ class ContainerAppsHandlerUnitTest {
+ "Microsoft.App/managedEnvironments/env";
private ContainerAppRuntimeManager runtimeManager;
+ private ContainerAppIngressProxy ingressProxy;
private ContainerAppsHandler handler;
@BeforeEach
@@ -52,11 +56,11 @@ void setUp() {
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,
- mock(ContainerAppIngressProxy.class), storageFactory);
+ handler = new ContainerAppsHandler(config, runtimeManager, ingressProxy, storageFactory);
}
@Test
@@ -118,6 +122,35 @@ void internalIngressUsesExactDockerNetworkSubnets() {
assertTrue(ContainerLifecycleManager.isAddressInSubnets("fd00::4", subnets));
}
+ @Test
+ void restoredInternalIngressStartsRuntimeBeforeAuthorizingCaller() {
+ 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"))
+ .thenAnswer(ignored -> runtimeStarted.get());
+ 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);
+ String fqdn = "internal-app." + environment.path("properties").path("defaultDomain").asText();
+ String accountName = fqdn.substring(0, fqdn.length() - ".azurecontainerapps.io".length());
+
+ Response response = handler.handle(new AzureRequest("GET", accountName, "containerapps", "hello",
+ null, null, Map.of(), null, false, "172.18.0.4"));
+
+ assertEquals(204, response.getStatus());
+ assertTrue(runtimeStarted.get());
+ }
+
@Test
void defaultDomainsRemainStableAndUniqueAcrossResourceGroups() {
ObjectNode first = (ObjectNode) createEnvironment("sub", "rg-a", "shared").getEntity();
From 27fb8eeb3a88757001af036b1461edf9054ec92c Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 28 Aug 2026 00:51:46 +0100
Subject: [PATCH 04/11] fix: authorize restored internal ingress
---
.../ContainerAppRuntimeManager.java | 4 ++++
.../containerapps/ContainerAppsHandler.java | 16 ++++++++++------
.../containerapps/ContainerAppsModels.java | 5 +++++
.../ContainerAppRuntimeManagerTest.java | 2 ++
.../ContainerAppsHandlerUnitTest.java | 4 +++-
5 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
index 0c917d23..0a810607 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
@@ -55,6 +55,10 @@ public synchronized void startRevision(ContainerAppState app, RevisionState revi
replicas.add(startReplica(app, revision, configuration, replica, targetPort));
}
runtimes.put(runtimeKey(app, revision.getName()), new RevisionRuntime(replicas));
+ revision.setNetworkSubnets(replicas.stream()
+ .flatMap(replica -> replica.networkSubnets().stream())
+ .distinct()
+ .toList());
LOG.infov("Started Container App revision {0} with {1} replicas",
revision.getName(), replicaCount);
} catch (RuntimeException e) {
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
index cfea0b93..04147a3c 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
@@ -541,12 +541,16 @@ private Response handleIngress(AzureRequest request) {
return ArmErrors.error(503, "ContainerAppUnavailable", "No active revision is available.");
}
if (config.services().containerApps().mocked()) {
- if (!isIngressCallerAllowed(ingress, request)) {
+ if (!ingress.path("external").asBoolean(false)
+ && !runtimeManager.isInternalCaller(request.remoteAddress())) {
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(ingress, request, revision)) {
+ return ArmErrors.notFound("Container App ingress host was not found.");
+ }
Optional endpoint =
runtimeManager.endpoint(app, revision.getName());
@@ -562,17 +566,17 @@ private Response handleIngress(AzureRequest request) {
LOG.errorf(e, "Failed to scale Container App revision %s from zero", revision.getName());
}
}
- if (!isIngressCallerAllowed(ingress, request)) {
- return ArmErrors.notFound("Container App ingress host was not found.");
- }
return endpoint.map(value -> ingressProxy.proxy(request, value))
.orElseGet(() -> ArmErrors.error(503, "ContainerAppUnavailable",
"Active revision has no running ingress replica."));
}
- private boolean isIngressCallerAllowed(JsonNode ingress, AzureRequest request) {
+ private boolean isIngressCallerAllowed(
+ JsonNode ingress, AzureRequest request, RevisionState revision) {
return ingress.path("external").asBoolean(false)
- || runtimeManager.isInternalCaller(request.remoteAddress());
+ || runtimeManager.isInternalCaller(request.remoteAddress())
+ || ContainerLifecycleManager.isAddressInSubnets(
+ request.remoteAddress(), revision.getNetworkSubnets());
}
Optional routeRevision(ContainerAppState app, JsonNode ingress) {
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java
index c8ac5929..9cb97e3a 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java
@@ -103,6 +103,7 @@ public static class RevisionState {
private String fqdn;
private Instant createdTime;
private Instant lastActiveTime;
+ private List networkSubnets = List.of();
public RevisionState() {
}
@@ -139,5 +140,9 @@ public RevisionState() {
public void setCreatedTime(Instant createdTime) { this.createdTime = createdTime; }
public Instant getLastActiveTime() { return lastActiveTime; }
public void setLastActiveTime(Instant lastActiveTime) { this.lastActiveTime = lastActiveTime; }
+ public List getNetworkSubnets() { return networkSubnets; }
+ public void setNetworkSubnets(List networkSubnets) {
+ this.networkSubnets = networkSubnets == null ? List.of() : List.copyOf(networkSubnets);
+ }
}
}
diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
index c91f22f0..7666bfe0 100644
--- a/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
@@ -13,6 +13,7 @@
import java.net.ServerSocket;
import java.time.Instant;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -69,6 +70,7 @@ void replicaContainersShareLeaderNetworkNamespace() throws Exception {
verify(builder).withDockerNetwork(Optional.of("test-network"));
verify(builder).withNetworkMode("container:leader-id");
+ assertEquals(List.of("172.18.0.0/16"), revision.getNetworkSubnets());
assertTrue(runtimeManager.isInternalCaller("172.18.0.9"));
assertFalse(runtimeManager.isInternalCaller("192.168.1.9"));
}
diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
index c8744c83..ac1e0adc 100644
--- a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
@@ -125,8 +125,10 @@ void internalIngressUsesExactDockerNetworkSubnets() {
@Test
void restoredInternalIngressStartsRuntimeBeforeAuthorizingCaller() {
AtomicBoolean runtimeStarted = new AtomicBoolean();
- doAnswer(ignored -> {
+ doAnswer(invocation -> {
runtimeStarted.set(true);
+ RevisionState revision = invocation.getArgument(1);
+ revision.setNetworkSubnets(List.of("172.18.0.0/16"));
return null;
}).when(runtimeManager).startRevision(any(), any(), any(), anyInt(), anyInt());
when(runtimeManager.isInternalCaller("172.18.0.4"))
From 761902ba6635707e51874fc8675625cb4fa7be0e Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 28 Aug 2026 00:57:33 +0100
Subject: [PATCH 05/11] fix: inspect live ingress network subnets
---
.../docker/ContainerLifecycleManager.java | 44 +++++++++++--------
.../ContainerAppRuntimeManager.java | 19 +++++---
.../containerapps/ContainerAppsHandler.java | 9 ++--
.../containerapps/ContainerAppsModels.java | 5 ---
.../ContainerAppRuntimeManagerTest.java | 17 ++++++-
.../ContainerAppsHandlerUnitTest.java | 36 +++++++++++----
6 files changed, 85 insertions(+), 45 deletions(-)
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 f928d6dc..f941e657 100644
--- a/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java
+++ b/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java
@@ -177,24 +177,7 @@ public List networkSubnets(String containerId) {
}
List subnets = new ArrayList<>();
for (String networkName : networks.keySet()) {
- try {
- Network network = dockerClient.inspectNetworkCmd()
- .withNetworkId(networkName)
- .exec();
- if (network.getIpam() == null || network.getIpam().getConfig() == null) {
- continue;
- }
- network.getIpam().getConfig().stream()
- .map(Network.Ipam.Config::getSubnet)
- .filter(subnet -> subnet != null && !subnet.isBlank())
- .forEach(subnets::add);
- } catch (NotFoundException e) {
- LOG.debugv("Docker network {0} disappeared while inspecting container {1}",
- networkName, containerId);
- } catch (DockerException e) {
- LOG.warnv("Could not inspect Docker network {0} for container {1}: {2}",
- networkName, containerId, e.getMessage());
- }
+ subnets.addAll(networkSubnetsForNetwork(networkName));
}
return List.copyOf(subnets);
} catch (NotFoundException e) {
@@ -207,6 +190,31 @@ public List networkSubnets(String containerId) {
}
}
+ /** Returns the current Docker IPAM subnets for a named network. */
+ public List networkSubnetsForNetwork(String networkName) {
+ if (networkName == null || networkName.isBlank()) {
+ return List.of();
+ }
+ try {
+ Network network = dockerClient.inspectNetworkCmd()
+ .withNetworkId(networkName)
+ .exec();
+ if (network.getIpam() == null || network.getIpam().getConfig() == null) {
+ return List.of();
+ }
+ return network.getIpam().getConfig().stream()
+ .map(Network.Ipam.Config::getSubnet)
+ .filter(subnet -> subnet != null && !subnet.isBlank())
+ .toList();
+ } catch (NotFoundException e) {
+ LOG.debugv("Docker network {0} was not found", networkName);
+ return List.of();
+ } catch (DockerException e) {
+ LOG.warnv("Could not inspect Docker network {0}: {1}", networkName, e.getMessage());
+ return List.of();
+ }
+ }
+
/** Tests an IP literal against Docker IPAM CIDR subnets without trusting forwarded headers. */
public static boolean isAddressInSubnets(String address, Collection subnets) {
if (address == null || address.isBlank()) {
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
index 0a810607..43ef9486 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppRuntimeManager.java
@@ -6,6 +6,7 @@
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.core.docker.CurrentContainerNetworkResolver;
import io.floci.az.services.containerapps.ContainerAppsModels.ContainerAppState;
import io.floci.az.services.containerapps.ContainerAppsModels.RevisionState;
import jakarta.enterprise.context.ApplicationScoped;
@@ -33,15 +34,18 @@ public class ContainerAppRuntimeManager {
private final ContainerBuilder containerBuilder;
private final ContainerLifecycleManager lifecycleManager;
+ private final CurrentContainerNetworkResolver currentContainerNetworkResolver;
private final EmulatorConfig config;
private final Map runtimes = new ConcurrentHashMap<>();
@Inject
public ContainerAppRuntimeManager(ContainerBuilder containerBuilder,
ContainerLifecycleManager lifecycleManager,
+ CurrentContainerNetworkResolver currentContainerNetworkResolver,
EmulatorConfig config) {
this.containerBuilder = containerBuilder;
this.lifecycleManager = lifecycleManager;
+ this.currentContainerNetworkResolver = currentContainerNetworkResolver;
this.config = config;
}
@@ -55,10 +59,6 @@ public synchronized void startRevision(ContainerAppState app, RevisionState revi
replicas.add(startReplica(app, revision, configuration, replica, targetPort));
}
runtimes.put(runtimeKey(app, revision.getName()), new RevisionRuntime(replicas));
- revision.setNetworkSubnets(replicas.stream()
- .flatMap(replica -> replica.networkSubnets().stream())
- .distinct()
- .toList());
LOG.infov("Started Container App revision {0} with {1} replicas",
revision.getName(), replicaCount);
} catch (RuntimeException e) {
@@ -101,10 +101,19 @@ public Optional endpoint(ContainerAppSta
}
public boolean isInternalCaller(String remoteAddress) {
- return runtimes.values().stream()
+ boolean matchesRunningRevision = runtimes.values().stream()
.flatMap(runtime -> runtime.replicas().stream())
.anyMatch(replica -> ContainerLifecycleManager.isAddressInSubnets(
remoteAddress, replica.networkSubnets()));
+ if (matchesRunningRevision) {
+ return true;
+ }
+ String networkName = config.services().dockerNetwork()
+ .filter(name -> !name.isBlank())
+ .or(currentContainerNetworkResolver::resolveNetworkName)
+ .orElse("bridge");
+ return ContainerLifecycleManager.isAddressInSubnets(
+ remoteAddress, lifecycleManager.networkSubnetsForNetwork(networkName));
}
private ReplicaRuntime startReplica(ContainerAppState app, RevisionState revision,
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
index 04147a3c..56ede96d 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
@@ -548,7 +548,7 @@ private Response handleIngress(AzureRequest request) {
return ArmErrors.error(503, "ContainerAppMocked",
"Container App is configured in mocked mode; ingress data plane is unavailable.");
}
- if (!isIngressCallerAllowed(ingress, request, revision)) {
+ if (!isIngressCallerAllowed(ingress, request)) {
return ArmErrors.notFound("Container App ingress host was not found.");
}
@@ -571,12 +571,9 @@ private Response handleIngress(AzureRequest request) {
"Active revision has no running ingress replica."));
}
- private boolean isIngressCallerAllowed(
- JsonNode ingress, AzureRequest request, RevisionState revision) {
+ private boolean isIngressCallerAllowed(JsonNode ingress, AzureRequest request) {
return ingress.path("external").asBoolean(false)
- || runtimeManager.isInternalCaller(request.remoteAddress())
- || ContainerLifecycleManager.isAddressInSubnets(
- request.remoteAddress(), revision.getNetworkSubnets());
+ || runtimeManager.isInternalCaller(request.remoteAddress());
}
Optional routeRevision(ContainerAppState app, JsonNode ingress) {
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java
index 9cb97e3a..c8ac5929 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsModels.java
@@ -103,7 +103,6 @@ public static class RevisionState {
private String fqdn;
private Instant createdTime;
private Instant lastActiveTime;
- private List networkSubnets = List.of();
public RevisionState() {
}
@@ -140,9 +139,5 @@ public RevisionState() {
public void setCreatedTime(Instant createdTime) { this.createdTime = createdTime; }
public Instant getLastActiveTime() { return lastActiveTime; }
public void setLastActiveTime(Instant lastActiveTime) { this.lastActiveTime = lastActiveTime; }
- public List getNetworkSubnets() { return networkSubnets; }
- public void setNetworkSubnets(List networkSubnets) {
- this.networkSubnets = networkSubnets == null ? List.of() : List.copyOf(networkSubnets);
- }
}
}
diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
index 7666bfe0..23d50a74 100644
--- a/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppRuntimeManagerTest.java
@@ -6,6 +6,7 @@
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.CurrentContainerNetworkResolver;
import io.floci.az.services.containerapps.ContainerAppsModels.ContainerAppState;
import io.floci.az.services.containerapps.ContainerAppsModels.RevisionState;
import org.junit.jupiter.api.BeforeEach;
@@ -34,6 +35,7 @@ class ContainerAppRuntimeManagerTest {
private ContainerBuilder containerBuilder;
private ContainerBuilder.Builder builder;
private ContainerLifecycleManager lifecycleManager;
+ private CurrentContainerNetworkResolver currentContainerNetworkResolver;
private ContainerAppRuntimeManager runtimeManager;
@BeforeEach
@@ -44,8 +46,10 @@ void setUp() {
containerBuilder = mock(ContainerBuilder.class);
builder = mock(ContainerBuilder.Builder.class, RETURNS_SELF);
lifecycleManager = mock(ContainerLifecycleManager.class);
+ currentContainerNetworkResolver = mock(CurrentContainerNetworkResolver.class);
when(containerBuilder.newContainer(any())).thenReturn(builder);
- runtimeManager = new ContainerAppRuntimeManager(containerBuilder, lifecycleManager, config);
+ runtimeManager = new ContainerAppRuntimeManager(
+ containerBuilder, lifecycleManager, currentContainerNetworkResolver, config);
}
@Test
@@ -70,11 +74,20 @@ void replicaContainersShareLeaderNetworkNamespace() throws Exception {
verify(builder).withDockerNetwork(Optional.of("test-network"));
verify(builder).withNetworkMode("container:leader-id");
- assertEquals(List.of("172.18.0.0/16"), revision.getNetworkSubnets());
assertTrue(runtimeManager.isInternalCaller("172.18.0.9"));
assertFalse(runtimeManager.isInternalCaller("192.168.1.9"));
}
+ @Test
+ void refreshesCurrentNetworkSubnetsBeforeRuntimeStarts() {
+ when(lifecycleManager.networkSubnetsForNetwork("test-network"))
+ .thenReturn(List.of("172.18.0.0/16"), List.of("172.19.0.0/16"));
+
+ assertTrue(runtimeManager.isInternalCaller("172.18.0.4"));
+ assertTrue(runtimeManager.isInternalCaller("172.19.0.4"));
+ assertFalse(runtimeManager.isInternalCaller("172.18.0.4"));
+ }
+
@Test
void endpointsRoundRobinAndSkipUnreachableReplicas() throws Exception {
try (ServerSocket firstServer = new ServerSocket(0);
diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
index ac1e0adc..1ac403f4 100644
--- a/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppsHandlerUnitTest.java
@@ -30,6 +30,7 @@
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;
@@ -123,16 +124,13 @@ void internalIngressUsesExactDockerNetworkSubnets() {
}
@Test
- void restoredInternalIngressStartsRuntimeBeforeAuthorizingCaller() {
+ void restoredInternalIngressAuthorizesCallerBeforeStartingRuntime() {
AtomicBoolean runtimeStarted = new AtomicBoolean();
- doAnswer(invocation -> {
+ doAnswer(ignored -> {
runtimeStarted.set(true);
- RevisionState revision = invocation.getArgument(1);
- revision.setNetworkSubnets(List.of("172.18.0.0/16"));
return null;
}).when(runtimeManager).startRevision(any(), any(), any(), anyInt(), anyInt());
- when(runtimeManager.isInternalCaller("172.18.0.4"))
- .thenAnswer(ignored -> runtimeStarted.get());
+ when(runtimeManager.isInternalCaller("172.18.0.4")).thenReturn(true);
var endpoint = new ContainerLifecycleManager.EndpointInfo("localhost", 8080);
when(runtimeManager.endpoint(any(), any()))
.thenAnswer(ignored -> runtimeStarted.get() ? Optional.of(endpoint) : Optional.empty());
@@ -143,16 +141,31 @@ void restoredInternalIngressStartsRuntimeBeforeAuthorizingCaller() {
assertEquals(201, handler.handle(request("PUT", PROVIDER + "containerApps/internal-app", internalBody))
.getStatus());
runtimeStarted.set(false);
- String fqdn = "internal-app." + environment.path("properties").path("defaultDomain").asText();
- String accountName = fqdn.substring(0, fqdn.length() - ".azurecontainerapps.io".length());
- Response response = handler.handle(new AzureRequest("GET", accountName, "containerapps", "hello",
+ 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();
@@ -173,6 +186,11 @@ private Response createEnvironment(String subscription, String resourceGroup, St
"{\"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);
From 855e0449b9cea1bd2b86873f654206dd3b62f80e Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:47:02 +0100
Subject: [PATCH 06/11] fix(containerapps): show storage in banner
---
src/main/java/io/floci/az/core/BannerLogger.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/main/java/io/floci/az/core/BannerLogger.java b/src/main/java/io/floci/az/core/BannerLogger.java
index c29e241e..f82378e2 100644
--- a/src/main/java/io/floci/az/core/BannerLogger.java
+++ b/src/main/java/io/floci/az/core/BannerLogger.java
@@ -115,6 +115,7 @@ void onStart(@Observes StartupEvent ev) {
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()) {
From 9642054ffff106e865de70cf9ef53122188e26e0 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:47:02 +0100
Subject: [PATCH 07/11] fix(containerapps): tolerate Docker inspect failure
---
.../io/floci/az/core/docker/ContainerLifecycleManager.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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 f941e657..0ff0d290 100644
--- a/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java
+++ b/src/main/java/io/floci/az/core/docker/ContainerLifecycleManager.java
@@ -183,7 +183,7 @@ public List networkSubnets(String containerId) {
} catch (NotFoundException e) {
LOG.debugv("Container {0} disappeared before its network could be inspected", containerId);
return List.of();
- } catch (DockerException e) {
+ } catch (RuntimeException e) {
LOG.warnv("Could not inspect Docker networks for container {0}: {1}",
containerId, e.getMessage());
return List.of();
@@ -209,7 +209,7 @@ public List networkSubnetsForNetwork(String networkName) {
} catch (NotFoundException e) {
LOG.debugv("Docker network {0} was not found", networkName);
return List.of();
- } catch (DockerException e) {
+ } catch (RuntimeException e) {
LOG.warnv("Could not inspect Docker network {0}: {1}", networkName, e.getMessage());
return List.of();
}
From a6f622ecaf026b1d92819d21e3aa2adb39cd6b67 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 29 Aug 2026 19:31:27 +0100
Subject: [PATCH 08/11] fix(containerapps): align ARM behavior
---
.../configuration/advanced/application-yml.md | 4 +-
docs/services/container-apps.md | 11 ++-
.../io/floci/az/config/EmulatorConfig.java | 2 +-
.../containerapps/ContainerAppsHandler.java | 75 ++++++++++++++++++-
src/main/resources/application.yml | 2 +-
.../ContainerAppsHandlerTest.java | 72 ++++++++++++++++++
6 files changed, 156 insertions(+), 10 deletions(-)
diff --git a/docs/configuration/advanced/application-yml.md b/docs/configuration/advanced/application-yml.md
index e0436683..d0577ee9 100644
--- a/docs/configuration/advanced/application-yml.md
+++ b/docs/configuration/advanced/application-yml.md
@@ -80,7 +80,7 @@ floci-az:
enabled: true
container-apps:
enabled: true
- mocked: false
+ mocked: true
dns-suffix: azurecontainerapps.io
ingress-timeout-seconds: 60
```
@@ -101,6 +101,6 @@ floci-az:
| `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` | `false` | Keep Container Apps ARM state without Docker runtimes |
+| `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
index befa873e..d8f1c00b 100644
--- a/docs/services/container-apps.md
+++ b/docs/services/container-apps.md
@@ -29,6 +29,7 @@ PATCH /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/managedE
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}
@@ -93,7 +94,7 @@ floci-az:
services:
container-apps:
enabled: true
- mocked: false
+ mocked: true
dns-suffix: azurecontainerapps.io
ingress-timeout-seconds: 60
```
@@ -101,8 +102,12 @@ floci-az:
| Environment variable | Default | Description |
|---|---:|---|
| `FLOCI_AZ_SERVICES_CONTAINER_APPS_ENABLED` | `true` | Enables `Microsoft.App` routing |
-| `FLOCI_AZ_SERVICES_CONTAINER_APPS_MOCKED` | `false` | Keeps ARM state without Docker containers |
+| `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 |
-Real mode requires access to 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.
+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/src/main/java/io/floci/az/config/EmulatorConfig.java b/src/main/java/io/floci/az/config/EmulatorConfig.java
index e0bc4871..8856eef6 100644
--- a/src/main/java/io/floci/az/config/EmulatorConfig.java
+++ b/src/main/java/io/floci/az/config/EmulatorConfig.java
@@ -379,7 +379,7 @@ interface ContainerAppsConfig {
boolean enabled();
/** When true, preserve ARM state and revisions without starting application containers. */
- @WithDefault("false")
+ @WithDefault("true")
boolean mocked();
/** DNS suffix used for emulated environment, app, and revision FQDNs. */
diff --git a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
index 56ede96d..88fa6651 100644
--- a/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
+++ b/src/main/java/io/floci/az/services/containerapps/ContainerAppsHandler.java
@@ -14,6 +14,7 @@
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;
@@ -42,7 +43,7 @@
/** Azure Container Apps management plane and HTTP ingress. */
@ApplicationScoped
-public class ContainerAppsHandler implements AzureServiceHandler, Resettable {
+public class ContainerAppsHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor {
private static final Logger LOG = Logger.getLogger(ContainerAppsHandler.class);
private static final String PROVIDER = "/providers/Microsoft.App/";
@@ -116,8 +117,8 @@ private Response handleArm(AzureRequest request) throws IOException {
LOG.debugv("Container Apps ARM request: {0} {1}", method, path);
- if (tail.matches("locations/[^/]+/checkNameAvailability") && "POST".equals(method)) {
- return Response.ok(Map.of("nameAvailable", true)).build();
+ if (tail.matches("managedEnvironments/[^/]+/checkNameAvailability")) {
+ return checkNameAvailability(method, subscription, resourceGroup, segment(tail, 1), request);
}
if ("managedEnvironments".equalsIgnoreCase(tail)) {
return handleEnvironmentCollection(method, subscription, resourceGroup,
@@ -167,6 +168,29 @@ private Response handleEnvironmentCollection(String method, String subscription,
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);
@@ -613,6 +637,7 @@ private ObjectNode environmentResponse(ManagedEnvironmentState environment) {
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");
@@ -622,6 +647,14 @@ private ObjectNode environmentResponse(ManagedEnvironmentState environment) {
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));
@@ -799,6 +832,42 @@ private List apps() {
return scan(APP_PREFIX, ContainerAppState.class);
}
+ @Override
+ public boolean indexEnabled() {
+ return config.services().containerApps().enabled();
+ }
+
+ @Override
+ public List