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