From 38d03a77a611c32205d98b92320749b243c63ba8 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 2 Sep 2026 10:10:55 -0700 Subject: [PATCH 1/2] fix: list every service's resources in the resource-group index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET subscriptions/{sub}/resourceGroups/{rg}/resources returned only the subsystems ArmHandler knows inline (Storage, Key Vault, Web) plus Network, API Management, Managed Identity, and whatever implements the CDI ResourceIndexContributor interface — where AciHandler was the sole implementation. Every other service with a management plane was absent from its own resource group: a VM answered a direct GET with 200 and appeared under /providers/Microsoft.Compute/virtualMachines, yet the group listing came back without it. That listing is what the azurerm provider reads before deleting a resource group to verify it is empty (the reason ResourceIndexContributor exists), and what any caller enumerating a group generically — az resource list -g, the Resource Management SDKs, drift-checking tools — depends on. Registers the eight missing providers through the existing extension point: Compute/virtualMachines, ContainerService/managedClusters, ContainerRegistry/registries, Cache/Redis, DBforPostgreSQL/flexibleServers, DBforMySQL/flexibleServers, DBforMariaDB/servers and Sql/servers. Each contributes from the state its own list endpoint already reads, so the index cannot drift from the type-scoped listing. ArmResources.indexEntry centralises the entry shape Azure returns for a generic resource — id, name, type, location, tags, and no properties, which arrive only under $expand. AciModels now builds its entry through it, so the one pre-existing contributor and the eight new ones share a single definition. --- .../io/floci/az/core/arm/ArmResources.java | 21 ++++++++++++++ .../io/floci/az/services/aci/AciModels.java | 13 ++------- .../io/floci/az/services/acr/AcrHandler.java | 25 +++++++++++++++-- .../io/floci/az/services/aks/AksHandler.java | 25 +++++++++++++++-- .../az/services/mariadb/MariaDbHandler.java | 22 +++++++++++++-- .../floci/az/services/mysql/MySqlHandler.java | 22 +++++++++++++-- .../az/services/postgres/PostgresHandler.java | 22 +++++++++++++-- .../floci/az/services/redis/RedisHandler.java | 25 +++++++++++++++-- .../io/floci/az/services/sql/SqlHandler.java | 22 +++++++++++++-- .../io/floci/az/services/vm/VmHandler.java | 24 ++++++++++++++-- .../floci/az/services/acr/AcrHandlerTest.java | 11 ++++++++ .../floci/az/services/aks/AksHandlerTest.java | 28 +++++++++++++++++++ .../mariadb/MariaDbHandlerMockedTest.java | 11 ++++++++ .../mysql/MySqlHandlerMockedTest.java | 11 ++++++++ .../postgres/PostgresHandlerMockedTest.java | 11 ++++++++ .../az/services/redis/RedisHandlerTest.java | 14 ++++++++++ .../az/services/sql/SqlHandlerMockedTest.java | 17 +++++++++++ .../floci/az/services/vm/VmHandlerTest.java | 10 +++++++ 18 files changed, 308 insertions(+), 26 deletions(-) diff --git a/src/main/java/io/floci/az/core/arm/ArmResources.java b/src/main/java/io/floci/az/core/arm/ArmResources.java index 5d8c3d40..b7337877 100644 --- a/src/main/java/io/floci/az/core/arm/ArmResources.java +++ b/src/main/java/io/floci/az/core/arm/ArmResources.java @@ -15,6 +15,27 @@ public final class ArmResources { private ArmResources() { } + /** + * Minimal ARM resource entry for the resource-group {@code /resources} index — + * {@code id}, {@code name}, {@code type}, {@code location}, and {@code tags} when non-empty. + * + *

Azure's generic resource listing returns identity fields only; {@code properties} arrives + * solely under {@code $expand}. Contributors project their resource onto this shape rather than + * echoing the body their {@code GET} returns.

+ */ + public static Map indexEntry(String id, String name, String type, String location, + Map tags) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + entry.put("name", name); + entry.put("type", type); + entry.put("location", location); + if (tags != null && !tags.isEmpty()) { + entry.put("tags", tags); + } + return entry; + } + /** Copy of the resource without the internal routing keys. */ public static Map stripInternal(Map resource) { Map copy = new LinkedHashMap<>(resource); diff --git a/src/main/java/io/floci/az/services/aci/AciModels.java b/src/main/java/io/floci/az/services/aci/AciModels.java index 0c97fe36..50fd5e1f 100644 --- a/src/main/java/io/floci/az/services/aci/AciModels.java +++ b/src/main/java/io/floci/az/services/aci/AciModels.java @@ -2,10 +2,10 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import io.floci.az.core.arm.ArmResources; import io.quarkus.runtime.annotations.RegisterForReflection; import java.time.Instant; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -80,15 +80,8 @@ public String storageKey() { /** Minimal ARM resource map for the resource-group {@code /resources} index. */ public Map indexEntry() { - Map entry = new LinkedHashMap<>(); - entry.put("id", armId()); - entry.put("name", name); - entry.put("type", "Microsoft.ContainerInstance/containerGroups"); - entry.put("location", location); - if (tags != null && !tags.isEmpty()) { - entry.put("tags", tags); - } - return entry; + return ArmResources.indexEntry(armId(), name, + "Microsoft.ContainerInstance/containerGroups", location, tags); } } diff --git a/src/main/java/io/floci/az/services/acr/AcrHandler.java b/src/main/java/io/floci/az/services/acr/AcrHandler.java index 03d73985..13e0a200 100644 --- a/src/main/java/io/floci/az/services/acr/AcrHandler.java +++ b/src/main/java/io/floci/az/services/acr/AcrHandler.java @@ -15,6 +15,8 @@ import io.floci.az.services.acr.AcrModels.Registry; import io.floci.az.core.arm.ArmErrors; import io.floci.az.core.arm.ArmPaths; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; @@ -62,7 +64,7 @@ * and {@code loginServer} is the cosmetic {@code {name}.azurecr.io} for management-plane fidelity.

*/ @ApplicationScoped -public class AcrHandler implements AzureServiceHandler, Resettable { +public class AcrHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(AcrHandler.class); @@ -70,6 +72,8 @@ public class AcrHandler implements AzureServiceHandler, Resettable { .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private static final String TYPE = "Microsoft.ContainerRegistry/registries"; + private static final SecureRandom RANDOM = new SecureRandom(); private static final String ALNUM = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; @@ -459,7 +463,7 @@ private Map toArmResponse(Registry registry) { Map out = new LinkedHashMap<>(); out.put("id", registry.armId()); out.put("name", registry.getName()); - out.put("type", "Microsoft.ContainerRegistry/registries"); + out.put("type", TYPE); out.put("location", registry.getLocation()); if (registry.getTags() != null && !registry.getTags().isEmpty()) { out.put("tags", registry.getTags()); @@ -555,4 +559,21 @@ private static Response methodNotAllowed() { public void clear() { storage.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().acr().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + String prefix = (sub + "/" + rg + "/").toLowerCase(); + return scanAll().stream() + .filter(registry -> registry.storageKey().toLowerCase().startsWith(prefix)) + .map(registry -> ArmResources.indexEntry(registry.armId(), registry.getName(), TYPE, + registry.getLocation(), registry.getTags())) + .toList(); + } } diff --git a/src/main/java/io/floci/az/services/aks/AksHandler.java b/src/main/java/io/floci/az/services/aks/AksHandler.java index 7c2673a6..1e2f726c 100644 --- a/src/main/java/io/floci/az/services/aks/AksHandler.java +++ b/src/main/java/io/floci/az/services/aks/AksHandler.java @@ -16,6 +16,8 @@ import io.floci.az.services.aks.AksModels.ManagedCluster; import io.floci.az.core.arm.ArmErrors; import io.floci.az.core.arm.ArmPaths; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; @@ -56,7 +58,7 @@ * Clusters transition immediately to "Succeeded" with a synthetic kubeconfig pointing at localhost.

*/ @ApplicationScoped -public class AksHandler implements AzureServiceHandler, Resettable { +public class AksHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(AksHandler.class); @@ -64,6 +66,8 @@ public class AksHandler implements AzureServiceHandler, Resettable { .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private static final String TYPE = "Microsoft.ContainerService/managedClusters"; + private final EmulatorConfig config; private final AksClusterManager clusterManager; private final StorageBackend storage; @@ -514,7 +518,7 @@ private Map toArmResponse(ManagedCluster cluster) { Map out = new LinkedHashMap<>(); out.put("id", cluster.armId()); out.put("name", cluster.getName()); - out.put("type", "Microsoft.ContainerService/managedClusters"); + out.put("type", TYPE); out.put("location", cluster.getLocation()); if (cluster.getTags() != null && !cluster.getTags().isEmpty()) { out.put("tags", cluster.getTags()); @@ -640,4 +644,21 @@ private static Response methodNotAllowed() { public void clear() { storage.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().aks().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + String prefix = (sub + "/" + rg + "/").toLowerCase(); + return scanAll().stream() + .filter(cluster -> cluster.storageKey().toLowerCase().startsWith(prefix)) + .map(cluster -> ArmResources.indexEntry(cluster.armId(), cluster.getName(), TYPE, + cluster.getLocation(), cluster.getTags())) + .toList(); + } } diff --git a/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java b/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java index 15da4987..ed1ffd61 100644 --- a/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java +++ b/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java @@ -7,6 +7,8 @@ import io.floci.az.core.AzureServiceHandler; import io.floci.az.core.Resettable; import io.floci.az.core.ServiceRoutes; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -37,9 +39,10 @@ * {@code Microsoft.DBforMariaDB/servers}, not flexibleServers). */ @ApplicationScoped -public class MariaDbHandler implements AzureServiceHandler, Resettable { +public class MariaDbHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(MariaDbHandler.class); + private static final String TYPE = "Microsoft.DBforMariaDB/servers"; private static final String NS = "/providers/Microsoft.DBforMariaDB/"; @@ -511,7 +514,7 @@ private Map serverResponse(MariaDbState.ServerEntry s) { Map resp = new LinkedHashMap<>(); resp.put("id", s.armId()); resp.put("name", s.serverName()); - resp.put("type", "Microsoft.DBforMariaDB/servers"); + resp.put("type", TYPE); resp.put("location", s.location()); if (!s.tags().isEmpty()) resp.put("tags", s.tags()); resp.put("properties", props); @@ -640,4 +643,19 @@ public void clear() { state.clear(); startLocks.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().mariaDb().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + return state.listServersByResourceGroup(sub, rg).stream() + .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, + server.location(), server.tags())) + .toList(); + } } diff --git a/src/main/java/io/floci/az/services/mysql/MySqlHandler.java b/src/main/java/io/floci/az/services/mysql/MySqlHandler.java index 1178a65f..873b9df0 100644 --- a/src/main/java/io/floci/az/services/mysql/MySqlHandler.java +++ b/src/main/java/io/floci/az/services/mysql/MySqlHandler.java @@ -7,6 +7,8 @@ import io.floci.az.core.AzureServiceHandler; import io.floci.az.core.Resettable; import io.floci.az.core.ServiceRoutes; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -36,9 +38,10 @@ *

Mirrors {@link io.floci.az.services.postgres.PostgresHandler}. */ @ApplicationScoped -public class MySqlHandler implements AzureServiceHandler, Resettable { +public class MySqlHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(MySqlHandler.class); + private static final String TYPE = "Microsoft.DBforMySQL/flexibleServers"; private static final String NS = "/providers/Microsoft.DBforMySQL/"; @@ -516,7 +519,7 @@ private Map serverResponse(MySqlState.ServerEntry s) { Map resp = new LinkedHashMap<>(); resp.put("id", s.armId()); resp.put("name", s.serverName()); - resp.put("type", "Microsoft.DBforMySQL/flexibleServers"); + resp.put("type", TYPE); resp.put("location", s.location()); resp.put("sku", Map.of("name", s.skuName(), "tier", s.skuTier())); if (!s.tags().isEmpty()) resp.put("tags", s.tags()); @@ -646,4 +649,19 @@ public void clear() { state.clear(); startLocks.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().mysql().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + return state.listServersByResourceGroup(sub, rg).stream() + .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, + server.location(), server.tags())) + .toList(); + } } diff --git a/src/main/java/io/floci/az/services/postgres/PostgresHandler.java b/src/main/java/io/floci/az/services/postgres/PostgresHandler.java index b8fa4b87..09ab7944 100644 --- a/src/main/java/io/floci/az/services/postgres/PostgresHandler.java +++ b/src/main/java/io/floci/az/services/postgres/PostgresHandler.java @@ -9,6 +9,8 @@ import io.floci.az.core.Resettable; import io.floci.az.core.arm.ArmErrors; import io.floci.az.core.arm.ArmPaths; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -70,9 +72,10 @@ * returning 204, which both majors already accept.

*/ @ApplicationScoped -public class PostgresHandler implements AzureServiceHandler, Resettable { +public class PostgresHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(PostgresHandler.class); + private static final String TYPE = "Microsoft.DBforPostgreSQL/flexibleServers"; private static final String NS = "/providers/Microsoft.DBforPostgreSQL/"; @@ -518,7 +521,7 @@ private Map serverResponse(PostgresState.ServerEntry s) { Map resp = new LinkedHashMap<>(); resp.put("id", s.armId()); resp.put("name", s.serverName()); - resp.put("type", "Microsoft.DBforPostgreSQL/flexibleServers"); + resp.put("type", TYPE); resp.put("location", s.location()); resp.put("sku", Map.of("name", s.skuName(), "tier", s.skuTier())); if (!s.tags().isEmpty()) resp.put("tags", s.tags()); @@ -642,4 +645,19 @@ public void clear() { state.clear(); startLocks.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().postgres().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + return state.listServersByResourceGroup(sub, rg).stream() + .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, + server.location(), server.tags())) + .toList(); + } } diff --git a/src/main/java/io/floci/az/services/redis/RedisHandler.java b/src/main/java/io/floci/az/services/redis/RedisHandler.java index 39b32135..a99a8562 100644 --- a/src/main/java/io/floci/az/services/redis/RedisHandler.java +++ b/src/main/java/io/floci/az/services/redis/RedisHandler.java @@ -15,6 +15,8 @@ import io.floci.az.services.redis.RedisModels.RedisCache; import io.floci.az.core.arm.ArmErrors; import io.floci.az.core.arm.ArmPaths; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; @@ -60,7 +62,7 @@ * standard Redis clients can connect to the sidecar.

*/ @ApplicationScoped -public class RedisHandler implements AzureServiceHandler, Resettable { +public class RedisHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(RedisHandler.class); @@ -68,6 +70,8 @@ public class RedisHandler implements AzureServiceHandler, Resettable { .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private static final String TYPE = "Microsoft.Cache/Redis"; + private static final SecureRandom RANDOM = new SecureRandom(); private static final int SSL_PORT = 6380; @@ -421,7 +425,7 @@ private Map toArmResponse(RedisCache cache) { Map out = new LinkedHashMap<>(); out.put("id", cache.armId()); out.put("name", cache.getName()); - out.put("type", "Microsoft.Cache/Redis"); + out.put("type", TYPE); out.put("location", cache.getLocation()); if (cache.getTags() != null && !cache.getTags().isEmpty()) { out.put("tags", cache.getTags()); @@ -507,4 +511,21 @@ private static Response methodNotAllowed() { public void clear() { storage.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().redis().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + String prefix = (sub + "/" + rg + "/").toLowerCase(); + return scanAll().stream() + .filter(cache -> cache.storageKey().toLowerCase().startsWith(prefix)) + .map(cache -> ArmResources.indexEntry(cache.armId(), cache.getName(), TYPE, + cache.getLocation(), cache.getTags())) + .toList(); + } } diff --git a/src/main/java/io/floci/az/services/sql/SqlHandler.java b/src/main/java/io/floci/az/services/sql/SqlHandler.java index 6e516be9..d82b61f7 100644 --- a/src/main/java/io/floci/az/services/sql/SqlHandler.java +++ b/src/main/java/io/floci/az/services/sql/SqlHandler.java @@ -9,6 +9,8 @@ import io.floci.az.core.Resettable; import io.floci.az.core.arm.ArmErrors; import io.floci.az.core.arm.ArmPaths; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -47,9 +49,10 @@ * */ @ApplicationScoped -public class SqlHandler implements AzureServiceHandler, Resettable { +public class SqlHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(SqlHandler.class); + private static final String TYPE = "Microsoft.Sql/servers"; private final EmulatorConfig config; private final SqlState state; @@ -563,7 +566,7 @@ private Map serverResponse(SqlState.SqlServerEntry s) { Map resp = new LinkedHashMap<>(); resp.put("id", s.armId()); resp.put("name", s.serverName()); - resp.put("type", "Microsoft.Sql/servers"); + resp.put("type", TYPE); resp.put("location", s.location()); resp.put("kind", "v12.0"); if (!s.tags().isEmpty()) resp.put("tags", s.tags()); @@ -735,4 +738,19 @@ public synchronized void clear() { }); state.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().sql().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + return state.listServersByResourceGroup(sub, rg).stream() + .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, + server.location(), server.tags())) + .toList(); + } } diff --git a/src/main/java/io/floci/az/services/vm/VmHandler.java b/src/main/java/io/floci/az/services/vm/VmHandler.java index a91e888f..d69ea1de 100644 --- a/src/main/java/io/floci/az/services/vm/VmHandler.java +++ b/src/main/java/io/floci/az/services/vm/VmHandler.java @@ -16,6 +16,8 @@ import io.floci.az.services.vm.VmModels.VirtualMachine; import io.floci.az.core.arm.ArmErrors; import io.floci.az.core.arm.ArmPaths; +import io.floci.az.core.arm.ArmResources; +import io.floci.az.core.arm.ResourceIndexContributor; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; @@ -59,7 +61,7 @@ * Power actions are pure state transitions. This keeps the service usable in CI without Docker.

*/ @ApplicationScoped -public class VmHandler implements AzureServiceHandler, Resettable { +public class VmHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor { private static final Logger LOG = Logger.getLogger(VmHandler.class); @@ -69,6 +71,7 @@ public class VmHandler implements AzureServiceHandler, Resettable { private static final String COMPUTE_MARKER = "/providers/Microsoft.Compute/"; private static final String API_VERSION = "2024-11-01"; + private static final String TYPE = "Microsoft.Compute/virtualMachines"; private final EmulatorConfig config; private final VmContainerManager containerManager; @@ -360,7 +363,7 @@ private Map toArmResponse(VirtualMachine vm, boolean expandInsta Map out = new LinkedHashMap<>(); out.put("id", vm.armId()); out.put("name", vm.getName()); - out.put("type", "Microsoft.Compute/virtualMachines"); + out.put("type", TYPE); out.put("location", vm.getLocation()); if (vm.getTags() != null && !vm.getTags().isEmpty()) { out.put("tags", vm.getTags()); @@ -549,4 +552,21 @@ private static Response methodNotAllowed() { public void clear() { storage.clear(); } + + // ── ResourceIndexContributor ──────────────────────────────────────────────── + + @Override + public boolean indexEnabled() { + return config.services().vm().enabled(); + } + + @Override + public List> listRgResources(String sub, String rg) { + String prefix = (sub + "/" + rg + "/").toLowerCase(); + return scanAll().stream() + .filter(vm -> vm.storageKey().toLowerCase().startsWith(prefix)) + .map(vm -> ArmResources.indexEntry(vm.armId(), vm.getName(), TYPE, + vm.getLocation(), vm.getTags())) + .toList(); + } } diff --git a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java index 5e7e9463..5d58dcd1 100644 --- a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java +++ b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java @@ -152,4 +152,15 @@ void deleteRemovesTheRegistry() { given().when().delete(registry("acrdelete") + API).then().statusCode(202); given().when().get(registry("acrdelete") + API).then().statusCode(404); } + + @Test + void registryAppearsInRgResourceIndex() { + createRegistry("acridx"); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'acridx' }.type", + is("Microsoft.ContainerRegistry/registries")); + } } diff --git a/src/test/java/io/floci/az/services/aks/AksHandlerTest.java b/src/test/java/io/floci/az/services/aks/AksHandlerTest.java index 57338ba4..90a5caae 100644 --- a/src/test/java/io/floci/az/services/aks/AksHandlerTest.java +++ b/src/test/java/io/floci/az/services/aks/AksHandlerTest.java @@ -264,4 +264,32 @@ void getAgentPool() { .body("properties.count", equalTo(3)) .body("properties.vmSize", equalTo("Standard_D4s_v3")); } + + @Test + @DisplayName("Clusters appear in the resource-group /resources index") + void clusterAppearsInRgResourceIndex() { + given() + .contentType("application/json") + .body(""" + { + "location": "eastus", + "properties": { + "kubernetesVersion": "1.29", + "dnsPrefix": "idx-aks-dns", + "agentPoolProfiles": [ + {"name": "nodepool1", "count": 1, "vmSize": "Standard_DS2_v2", + "osType": "Linux", "mode": "System"} + ] + } + } + """) + .when().put(BASE + "/managedClusters/aks-idx?api-version=2024-04-01") + .then().statusCode(201); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'aks-idx' }.type", + equalTo("Microsoft.ContainerService/managedClusters")); + } } diff --git a/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java b/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java index 3a0123a9..f85d3c8e 100644 --- a/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java @@ -200,4 +200,15 @@ void checkNameAvailability() { .then().statusCode(200) .body("nameAvailable", equalTo(true)); } + + @Test + @DisplayName("Servers appear in the resource-group /resources index") + void serverAppearsInRgResourceIndex() { + createServer("mariadb-idx"); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'mariadb-idx' }.type", equalTo("Microsoft.DBforMariaDB/servers")); + } } diff --git a/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java b/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java index 5960e628..eca79084 100644 --- a/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java @@ -213,4 +213,15 @@ void checkNameAvailabilityTaken() { .then().statusCode(200) .body("nameAvailable", equalTo(false)); } + + @Test + @DisplayName("Servers appear in the resource-group /resources index") + void serverAppearsInRgResourceIndex() { + createServer("mysql-idx"); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'mysql-idx' }.type", equalTo("Microsoft.DBforMySQL/flexibleServers")); + } } diff --git a/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java b/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java index 809bc690..3be6ed30 100644 --- a/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java @@ -236,4 +236,15 @@ void connectReturnsStrings() { .body("jdbcUrl", containsString("jdbc:postgresql://")) .body("uri", containsString("postgresql://")); } + + @Test + @DisplayName("Servers appear in the resource-group /resources index") + void serverAppearsInRgResourceIndex() { + createServer("pg-idx"); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'pg-idx' }.type", equalTo("Microsoft.DBforPostgreSQL/flexibleServers")); + } } diff --git a/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java b/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java index 324187bc..3b8cb566 100644 --- a/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java +++ b/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java @@ -194,4 +194,18 @@ void patchTags() { .then().statusCode(200) .body("tags.env", equalTo("test")); } + + @Test + @DisplayName("Caches appear in the resource-group /resources index") + void cacheAppearsInRgResourceIndex() { + given() + .contentType("application/json").body(CREATE_BODY) + .when().put(BASE + "/redis/redis-idx" + API) + .then().statusCode(201); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'redis-idx' }.type", equalTo("Microsoft.Cache/Redis")); + } } diff --git a/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java b/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java index 4b34d60c..9a40dbe8 100644 --- a/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java @@ -92,4 +92,21 @@ void masterDatabaseAutoCreated() { .when().get(BASE + "/servers/dbhost/databases/master?api-version=2021-11-01") .then().statusCode(200); } + + @Test + @DisplayName("Servers appear in the resource-group /resources index") + void serverAppearsInRgResourceIndex() { + given() + .contentType("application/json") + .body("{\"location\":\"eastus\",\"properties\":{" + + "\"administratorLogin\":\"sa\"," + + "\"administratorLoginPassword\":\"FlociAz_Strong123!\"}}") + .when().put(BASE + "/servers/sql-idx?api-version=2021-11-01") + .then().statusCode(201); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'sql-idx' }.type", equalTo("Microsoft.Sql/servers")); + } } diff --git a/src/test/java/io/floci/az/services/vm/VmHandlerTest.java b/src/test/java/io/floci/az/services/vm/VmHandlerTest.java index 76a78e5c..511f84f9 100644 --- a/src/test/java/io/floci/az/services/vm/VmHandlerTest.java +++ b/src/test/java/io/floci/az/services/vm/VmHandlerTest.java @@ -186,4 +186,14 @@ void networkInterfaceStub() { .body("properties.provisioningState", equalTo("Succeeded")) .body("properties.ipConfigurations[0].properties.privateIPAddress", not(emptyOrNullString())); } + + @Test + @DisplayName("VMs appear in the resource-group /resources index") + void vmAppearsInRgResourceIndex() { + createVm("vm-idx"); + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + "/resources" + API) + .then().statusCode(200) + .body("value.find { it.name == 'vm-idx' }.type", + equalTo("Microsoft.Compute/virtualMachines")); + } } From 8afffde23d1e9861fcfecd89223a891fbdca7c5d Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 2 Sep 2026 11:51:01 -0700 Subject: [PATCH 2/2] fix: aggregate the subscription resource listing the same way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET subscriptions/{sub}/resources returned only Key Vaults and API Management services, so a subscription holding neither answered with an empty list while its resource groups held VMs, virtual networks, storage accounts and database servers. Azure's subscription-scoped listing returns everything the subscription holds. Widens ResourceIndexContributor with listSubscriptionResources(sub) — no default implementation, so a service cannot appear in one listing while falling silently out of the other, which is the failure this interface exists to prevent — and gives Network and Managed Identity the subscription-scoped overload they lacked beside their resource-group one. Both ARM listings now answer from one assembly, indexedResources(sub, rg), where a null resource group means subscription scope. They cannot disagree about what the estate holds. ArmHandler's own Storage / Key Vault / Web state keeps contributing its full stored body rather than the trimmed index entry: the azurerm provider reads this listing to populate its Key Vault cache and looks vaults up by properties.vaultUri, which a properties-free entry would not carry. That deviation from Azure — which returns properties only under $expand — is now recorded where the assembly happens, and pinned by a test. --- .../az/core/arm/ResourceIndexContributor.java | 19 ++- .../io/floci/az/services/aci/AciHandler.java | 12 +- .../io/floci/az/services/acr/AcrHandler.java | 12 +- .../io/floci/az/services/aks/AksHandler.java | 12 +- .../io/floci/az/services/arm/ArmHandler.java | 87 ++++++----- .../ManagedIdentityHandler.java | 10 ++ .../az/services/mariadb/MariaDbHandler.java | 11 +- .../floci/az/services/mysql/MySqlHandler.java | 11 +- .../az/services/network/NetworkHandler.java | 4 + .../az/services/network/NetworkService.java | 7 + .../az/services/postgres/PostgresHandler.java | 11 +- .../floci/az/services/redis/RedisHandler.java | 12 +- .../io/floci/az/services/sql/SqlHandler.java | 11 +- .../io/floci/az/services/vm/VmHandler.java | 12 +- .../floci/az/services/aci/AciHandlerTest.java | 8 +- .../floci/az/services/acr/AcrHandlerTest.java | 6 +- .../floci/az/services/aks/AksHandlerTest.java | 8 +- .../az/services/arm/ArmResourceIndexTest.java | 143 ++++++++++++++++++ .../mariadb/MariaDbHandlerMockedTest.java | 8 +- .../mysql/MySqlHandlerMockedTest.java | 8 +- .../postgres/PostgresHandlerMockedTest.java | 8 +- .../az/services/redis/RedisHandlerTest.java | 8 +- .../az/services/sql/SqlHandlerMockedTest.java | 8 +- .../floci/az/services/vm/VmHandlerTest.java | 8 +- 24 files changed, 372 insertions(+), 72 deletions(-) create mode 100644 src/test/java/io/floci/az/services/arm/ArmResourceIndexTest.java diff --git a/src/main/java/io/floci/az/core/arm/ResourceIndexContributor.java b/src/main/java/io/floci/az/core/arm/ResourceIndexContributor.java index fdf307c9..92edc9ee 100644 --- a/src/main/java/io/floci/az/core/arm/ResourceIndexContributor.java +++ b/src/main/java/io/floci/az/core/arm/ResourceIndexContributor.java @@ -4,15 +4,21 @@ import java.util.Map; /** - * A service that registers its resources in ARM's resource-group index — - * {@code GET subscriptions/{sub}/resourceGroups/{rg}/resources}. + * A service that registers its resources in ARM's generic resource index — both + * {@code GET subscriptions/{sub}/resourceGroups/{rg}/resources} and + * {@code GET subscriptions/{sub}/resources}. * - *

The azurerm provider calls that listing before deleting a resource group to verify it is - * empty; a service that skips registration lets {@code terraform destroy} remove a group whose - * resources still exist. {@code ArmHandler} discovers implementations via CDI + *

The azurerm provider calls the resource-group listing before deleting a resource group to + * verify it is empty; a service that skips registration lets {@code terraform destroy} remove a + * group whose resources still exist. Anything enumerating an estate generically rather than + * per-type — {@code az resource list}, the Resource Management SDKs, drift checkers — reads one + * of these two listings too. {@code ArmHandler} discovers implementations via CDI * {@code Instance} (the same pattern as {@code Resettable} / * {@code AdminController}), so contributing costs one interface — not an ArmHandler constructor * change per service.

+ * + *

Both methods are abstract on purpose. A default returning nothing would let a new service + * fall out of one listing silently, which is the bug this interface exists to prevent.

*/ public interface ResourceIndexContributor { @@ -22,6 +28,9 @@ public interface ResourceIndexContributor { */ List> listRgResources(String sub, String rg); + /** The same maps for every one of this service's resources in the subscription. */ + List> listSubscriptionResources(String sub); + /** Whether this contributor's service is enabled; disabled services contribute nothing. */ default boolean indexEnabled() { return true; diff --git a/src/main/java/io/floci/az/services/aci/AciHandler.java b/src/main/java/io/floci/az/services/aci/AciHandler.java index 2487ea1e..83c0e4f7 100644 --- a/src/main/java/io/floci/az/services/aci/AciHandler.java +++ b/src/main/java/io/floci/az/services/aci/AciHandler.java @@ -513,9 +513,17 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - String prefix = (sub + "/" + rg + "/").toLowerCase(); + return indexEntries((sub + "/" + rg + "/").toLowerCase()); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries((sub + "/").toLowerCase()); + } + + private List> indexEntries(String storageKeyPrefix) { return scanAll().stream() - .filter(group -> group.storageKey().toLowerCase().startsWith(prefix)) + .filter(group -> group.storageKey().toLowerCase().startsWith(storageKeyPrefix)) .map(ContainerGroup::indexEntry) .toList(); } diff --git a/src/main/java/io/floci/az/services/acr/AcrHandler.java b/src/main/java/io/floci/az/services/acr/AcrHandler.java index 13e0a200..be647372 100644 --- a/src/main/java/io/floci/az/services/acr/AcrHandler.java +++ b/src/main/java/io/floci/az/services/acr/AcrHandler.java @@ -569,9 +569,17 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - String prefix = (sub + "/" + rg + "/").toLowerCase(); + return indexEntries((sub + "/" + rg + "/").toLowerCase()); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries((sub + "/").toLowerCase()); + } + + private List> indexEntries(String storageKeyPrefix) { return scanAll().stream() - .filter(registry -> registry.storageKey().toLowerCase().startsWith(prefix)) + .filter(registry -> registry.storageKey().toLowerCase().startsWith(storageKeyPrefix)) .map(registry -> ArmResources.indexEntry(registry.armId(), registry.getName(), TYPE, registry.getLocation(), registry.getTags())) .toList(); diff --git a/src/main/java/io/floci/az/services/aks/AksHandler.java b/src/main/java/io/floci/az/services/aks/AksHandler.java index 1e2f726c..3f25a373 100644 --- a/src/main/java/io/floci/az/services/aks/AksHandler.java +++ b/src/main/java/io/floci/az/services/aks/AksHandler.java @@ -654,9 +654,17 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - String prefix = (sub + "/" + rg + "/").toLowerCase(); + return indexEntries((sub + "/" + rg + "/").toLowerCase()); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries((sub + "/").toLowerCase()); + } + + private List> indexEntries(String storageKeyPrefix) { return scanAll().stream() - .filter(cluster -> cluster.storageKey().toLowerCase().startsWith(prefix)) + .filter(cluster -> cluster.storageKey().toLowerCase().startsWith(storageKeyPrefix)) .map(cluster -> ArmResources.indexEntry(cluster.armId(), cluster.getName(), TYPE, cluster.getLocation(), cluster.getTags())) .toList(); diff --git a/src/main/java/io/floci/az/services/arm/ArmHandler.java b/src/main/java/io/floci/az/services/arm/ArmHandler.java index 95781b96..aa510f2e 100644 --- a/src/main/java/io/floci/az/services/arm/ArmHandler.java +++ b/src/main/java/io/floci/az/services/arm/ArmHandler.java @@ -209,16 +209,8 @@ private Response dispatch(AzureRequest req) { } // ── Subscription-level resource listing ────────────────────────────── - // azurerm provider calls this to populate its Key Vault cache so it can - // look up a vault by its vaultUri. Return all key vaults for the subscription. if (path.matches("subscriptions/[^/?]+/resources([?].*)?")) { - String sub = extractSub(path); - List> resources = new ArrayList<>(keyVaults.values().stream() - .filter(v -> sub.equals(v.get("_sub"))) - .map(ArmHandler::stripInternal) - .toList()); - resources.addAll(apiManagementHandler.listSubscriptionServices(sub)); - return Response.ok(Map.of("value", resources)).build(); + return Response.ok(Map.of("value", indexedResources(extractSub(path), null))).build(); } // ── Resource Groups ─────────────────────────────────────────────────── @@ -255,33 +247,7 @@ private Response handleResourceGroupBranch(AzureRequest req, String path, String // subscriptions/{sub}/resourceGroups/{rg}/resources (list resources in RG) // azurerm provider calls this before deleting a resource group to verify it is empty. if (lc.matches("subscriptions/[^/]+/resourcegroups/[^/]+/resources([?].*)?")) { - String rg = extractRg(path); - List> resources = new ArrayList<>(); - storageAccounts.values().stream() - .filter(a -> sub.equals(a.get("_sub")) && rg.equals(a.get("_rg"))) - .map(ArmHandler::stripInternal) - .forEach(resources::add); - keyVaults.values().stream() - .filter(v -> sub.equals(v.get("_sub")) && rg.equals(v.get("_rg"))) - .map(ArmHandler::stripInternal) - .forEach(resources::add); - webApps.values().stream() - .filter(v -> sub.equals(v.get("_sub")) && rg.equals(v.get("_rg"))) - .map(ArmHandler::stripInternal) - .forEach(resources::add); - if (config.services().network().enabled()) { - resources.addAll(networkHandler.listResources(sub, rg)); - } - resources.addAll(apiManagementHandler.listServices(sub, rg)); - if (config.services().managedIdentity().enabled()) { - resources.addAll(managedIdentityHandler.listResources(sub, rg)); - } - for (ResourceIndexContributor contributor : resourceIndexContributors) { - if (contributor.indexEnabled()) { - resources.addAll(contributor.listRgResources(sub, rg)); - } - } - return Response.ok(Map.of("value", resources)).build(); + return Response.ok(Map.of("value", indexedResources(sub, extractRg(path)))).build(); } // subscriptions/{sub}/resourceGroups/{rg}/providers/... @@ -292,6 +258,55 @@ private Response handleResourceGroupBranch(AzureRequest req, String path, String return armNotFound(path); } + /** + * The generic resource index for a scope: the whole subscription when {@code rg} is null, one + * resource group otherwise. Both ARM listings answer from this one assembly so they cannot + * disagree about what the estate holds. + * + *

ArmHandler's own Storage / Key Vault / Web state contributes the full stored body rather + * than the trimmed {@code ArmResources.indexEntry} shape: the azurerm provider reads this + * listing to populate its Key Vault cache and looks vaults up by {@code properties.vaultUri}, + * which a properties-free entry would not carry. A deliberate deviation from Azure, which + * returns properties only under {@code $expand}.

+ */ + private List> indexedResources(String sub, String rg) { + List> resources = new ArrayList<>(); + collectOwn(storageAccounts, sub, rg, resources); + collectOwn(keyVaults, sub, rg, resources); + collectOwn(webApps, sub, rg, resources); + if (config.services().network().enabled()) { + resources.addAll(rg == null + ? networkHandler.listResources(sub) + : networkHandler.listResources(sub, rg)); + } + resources.addAll(rg == null + ? apiManagementHandler.listSubscriptionServices(sub) + : apiManagementHandler.listServices(sub, rg)); + if (config.services().managedIdentity().enabled()) { + resources.addAll(rg == null + ? managedIdentityHandler.listResources(sub) + : managedIdentityHandler.listResources(sub, rg)); + } + for (ResourceIndexContributor contributor : resourceIndexContributors) { + if (contributor.indexEnabled()) { + resources.addAll(rg == null + ? contributor.listSubscriptionResources(sub) + : contributor.listRgResources(sub, rg)); + } + } + return resources; + } + + /** ArmHandler's own ARM state for a scope, stripped of the internal routing keys. */ + private static void collectOwn(Map> state, String sub, String rg, + List> out) { + state.values().stream() + .filter(r -> sub.equals(r.get(ArmResources.SUB_KEY)) + && (rg == null || rg.equals(r.get(ArmResources.RG_KEY)))) + .map(ArmHandler::stripInternal) + .forEach(out::add); + } + // ── Provider-level routing ──────────────────────────────────────────────── private Response handleProviders(AzureRequest req, String path, String method, String sub) { diff --git a/src/main/java/io/floci/az/services/managedidentity/ManagedIdentityHandler.java b/src/main/java/io/floci/az/services/managedidentity/ManagedIdentityHandler.java index b74124e1..4b6eef93 100644 --- a/src/main/java/io/floci/az/services/managedidentity/ManagedIdentityHandler.java +++ b/src/main/java/io/floci/az/services/managedidentity/ManagedIdentityHandler.java @@ -149,6 +149,16 @@ public List> listResources(String sub, String rg) { return result; } + public List> listResources(String sub) { + List> result = new ArrayList<>(); + for (Map resource : store.listIdentities()) { + if (sub.equalsIgnoreCase(String.valueOf(resource.get("_sub")))) { + result.add(ArmResources.stripInternal(resource)); + } + } + return result; + } + // ── ARM: userAssignedIdentities ───────────────────────────────────────────── private Response handleIdentity(AzureRequest req, String path, String method) { diff --git a/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java b/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java index ed1ffd61..eab252e1 100644 --- a/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java +++ b/src/main/java/io/floci/az/services/mariadb/MariaDbHandler.java @@ -653,7 +653,16 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - return state.listServersByResourceGroup(sub, rg).stream() + return indexEntries(state.listServersByResourceGroup(sub, rg)); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries(state.listServersBySubscription(sub)); + } + + private static List> indexEntries(List servers) { + return servers.stream() .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, server.location(), server.tags())) .toList(); diff --git a/src/main/java/io/floci/az/services/mysql/MySqlHandler.java b/src/main/java/io/floci/az/services/mysql/MySqlHandler.java index 873b9df0..987d2263 100644 --- a/src/main/java/io/floci/az/services/mysql/MySqlHandler.java +++ b/src/main/java/io/floci/az/services/mysql/MySqlHandler.java @@ -659,7 +659,16 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - return state.listServersByResourceGroup(sub, rg).stream() + return indexEntries(state.listServersByResourceGroup(sub, rg)); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries(state.listServersBySubscription(sub)); + } + + private static List> indexEntries(List servers) { + return servers.stream() .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, server.location(), server.tags())) .toList(); diff --git a/src/main/java/io/floci/az/services/network/NetworkHandler.java b/src/main/java/io/floci/az/services/network/NetworkHandler.java index fb974789..03c1b5f0 100644 --- a/src/main/java/io/floci/az/services/network/NetworkHandler.java +++ b/src/main/java/io/floci/az/services/network/NetworkHandler.java @@ -34,6 +34,10 @@ public List> listResources(String sub, String rg) { return service.listResources(sub, rg); } + public List> listResources(String sub) { + return service.listResources(sub); + } + @Override public void clear() { service.clear(); diff --git a/src/main/java/io/floci/az/services/network/NetworkService.java b/src/main/java/io/floci/az/services/network/NetworkService.java index 60aa0e7e..7838a791 100644 --- a/src/main/java/io/floci/az/services/network/NetworkService.java +++ b/src/main/java/io/floci/az/services/network/NetworkService.java @@ -92,6 +92,13 @@ public List> listResources(String sub, String rg) { .toList(); } + public List> listResources(String sub) { + return resources.values().stream() + .filter(r -> sub.equals(r.get("_sub"))) + .map(NetworkService::stripInternal) + .toList(); + } + public List> listResources(String sub, String rg, String type) { return resources.values().stream() .filter(r -> sub.equals(r.get("_sub")) && rg.equals(r.get("_rg")) && type.equals(r.get("type"))) diff --git a/src/main/java/io/floci/az/services/postgres/PostgresHandler.java b/src/main/java/io/floci/az/services/postgres/PostgresHandler.java index 09ab7944..28ea9d9f 100644 --- a/src/main/java/io/floci/az/services/postgres/PostgresHandler.java +++ b/src/main/java/io/floci/az/services/postgres/PostgresHandler.java @@ -655,7 +655,16 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - return state.listServersByResourceGroup(sub, rg).stream() + return indexEntries(state.listServersByResourceGroup(sub, rg)); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries(state.listServersBySubscription(sub)); + } + + private static List> indexEntries(List servers) { + return servers.stream() .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, server.location(), server.tags())) .toList(); diff --git a/src/main/java/io/floci/az/services/redis/RedisHandler.java b/src/main/java/io/floci/az/services/redis/RedisHandler.java index a99a8562..e9980c05 100644 --- a/src/main/java/io/floci/az/services/redis/RedisHandler.java +++ b/src/main/java/io/floci/az/services/redis/RedisHandler.java @@ -521,9 +521,17 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - String prefix = (sub + "/" + rg + "/").toLowerCase(); + return indexEntries((sub + "/" + rg + "/").toLowerCase()); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries((sub + "/").toLowerCase()); + } + + private List> indexEntries(String storageKeyPrefix) { return scanAll().stream() - .filter(cache -> cache.storageKey().toLowerCase().startsWith(prefix)) + .filter(cache -> cache.storageKey().toLowerCase().startsWith(storageKeyPrefix)) .map(cache -> ArmResources.indexEntry(cache.armId(), cache.getName(), TYPE, cache.getLocation(), cache.getTags())) .toList(); diff --git a/src/main/java/io/floci/az/services/sql/SqlHandler.java b/src/main/java/io/floci/az/services/sql/SqlHandler.java index d82b61f7..1ce69184 100644 --- a/src/main/java/io/floci/az/services/sql/SqlHandler.java +++ b/src/main/java/io/floci/az/services/sql/SqlHandler.java @@ -748,7 +748,16 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - return state.listServersByResourceGroup(sub, rg).stream() + return indexEntries(state.listServersByResourceGroup(sub, rg)); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries(state.listServersBySubscription(sub)); + } + + private static List> indexEntries(List servers) { + return servers.stream() .map(server -> ArmResources.indexEntry(server.armId(), server.serverName(), TYPE, server.location(), server.tags())) .toList(); diff --git a/src/main/java/io/floci/az/services/vm/VmHandler.java b/src/main/java/io/floci/az/services/vm/VmHandler.java index d69ea1de..86aa5d3b 100644 --- a/src/main/java/io/floci/az/services/vm/VmHandler.java +++ b/src/main/java/io/floci/az/services/vm/VmHandler.java @@ -562,9 +562,17 @@ public boolean indexEnabled() { @Override public List> listRgResources(String sub, String rg) { - String prefix = (sub + "/" + rg + "/").toLowerCase(); + return indexEntries((sub + "/" + rg + "/").toLowerCase()); + } + + @Override + public List> listSubscriptionResources(String sub) { + return indexEntries((sub + "/").toLowerCase()); + } + + private List> indexEntries(String storageKeyPrefix) { return scanAll().stream() - .filter(vm -> vm.storageKey().toLowerCase().startsWith(prefix)) + .filter(vm -> vm.storageKey().toLowerCase().startsWith(storageKeyPrefix)) .map(vm -> ArmResources.indexEntry(vm.armId(), vm.getName(), TYPE, vm.getLocation(), vm.getTags())) .toList(); diff --git a/src/test/java/io/floci/az/services/aci/AciHandlerTest.java b/src/test/java/io/floci/az/services/aci/AciHandlerTest.java index db7c35a7..ba3f6904 100644 --- a/src/test/java/io/floci/az/services/aci/AciHandlerTest.java +++ b/src/test/java/io/floci/az/services/aci/AciHandlerTest.java @@ -359,13 +359,17 @@ void locationCatalogsAndOutboundDeps() { } @Test - @DisplayName("Groups appear in the resource-group /resources index") - void groupAppearsInRgResourceIndex() { + @DisplayName("Groups appear in the resource-group and subscription /resources indexes") + void groupAppearsInResourceIndexes() { createGroup("cg-idx"); given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + "/resources" + API) .then().statusCode(200) .body("value.find { it.name == 'cg-idx' }.type", equalTo("Microsoft.ContainerInstance/containerGroups")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'cg-idx' }.type", equalTo("Microsoft.ContainerInstance/containerGroups")); } @Test diff --git a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java index 5d58dcd1..d4b4e567 100644 --- a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java +++ b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java @@ -154,7 +154,7 @@ void deleteRemovesTheRegistry() { } @Test - void registryAppearsInRgResourceIndex() { + void registryAppearsInResourceIndexes() { createRegistry("acridx"); given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG @@ -162,5 +162,9 @@ void registryAppearsInRgResourceIndex() { .then().statusCode(200) .body("value.find { it.name == 'acridx' }.type", is("Microsoft.ContainerRegistry/registries")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'acridx' }.type", is("Microsoft.ContainerRegistry/registries")); } } diff --git a/src/test/java/io/floci/az/services/aks/AksHandlerTest.java b/src/test/java/io/floci/az/services/aks/AksHandlerTest.java index 90a5caae..bffb8151 100644 --- a/src/test/java/io/floci/az/services/aks/AksHandlerTest.java +++ b/src/test/java/io/floci/az/services/aks/AksHandlerTest.java @@ -266,8 +266,8 @@ void getAgentPool() { } @Test - @DisplayName("Clusters appear in the resource-group /resources index") - void clusterAppearsInRgResourceIndex() { + @DisplayName("Clusters appear in the resource-group and subscription /resources indexes") + void clusterAppearsInResourceIndexes() { given() .contentType("application/json") .body(""" @@ -291,5 +291,9 @@ void clusterAppearsInRgResourceIndex() { .then().statusCode(200) .body("value.find { it.name == 'aks-idx' }.type", equalTo("Microsoft.ContainerService/managedClusters")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'aks-idx' }.type", equalTo("Microsoft.ContainerService/managedClusters")); } } diff --git a/src/test/java/io/floci/az/services/arm/ArmResourceIndexTest.java b/src/test/java/io/floci/az/services/arm/ArmResourceIndexTest.java new file mode 100644 index 00000000..c1a4cd9f --- /dev/null +++ b/src/test/java/io/floci/az/services/arm/ArmResourceIndexTest.java @@ -0,0 +1,143 @@ +package io.floci.az.services.arm; + +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.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.emptyOrNullString; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.oneOf; + +/** + * The two generic ARM resource listings — {@code /subscriptions/{sub}/resources} and + * {@code .../resourceGroups/{rg}/resources} — across an estate that spans two resource groups + * and mixes a provider ArmHandler holds itself (Storage) with one that registers through + * {@code ResourceIndexContributor} (Compute). + * + *

Per-service tests can only show that a service reaches the index. Only a test that owns the + * whole estate can show the scoping: that the resource-group listing is confined to its group + * while the subscription listing spans every group.

+ */ +@QuarkusTest +@TestProfile(ArmResourceIndexTest.MockedProfile.class) +@DisplayName("ARM resource index — resource-group and subscription scope") +class ArmResourceIndexTest { + + public static class MockedProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("floci-az.services.vm.mocked", "true"); + } + } + + private static final String SUB = "test-sub-index"; + private static final String RG_A = "test-rg-index-a"; + private static final String RG_B = "test-rg-index-b"; + + private static final String VM_BODY = """ + { + "location": "eastus", + "properties": { + "hardwareProfile": {"vmSize": "Standard_D2s_v3"}, + "storageProfile": { + "imageReference": {"publisher": "Canonical", "offer": "0001-com-ubuntu-server-jammy", + "sku": "22_04-lts", "version": "latest"}, + "osDisk": {"createOption": "FromImage", "name": "osdisk"} + }, + "osProfile": {"adminUsername": "azureuser", "computerName": "indexvm"} + } + } + """; + + @BeforeEach + void seedEstate() { + given().post("/_admin/reset").then().statusCode(204); + createGroup(RG_A); + createGroup(RG_B); + createVm(RG_A, "vm-a"); + createVm(RG_B, "vm-b"); + createStorageAccount(RG_A, "indexsaa"); + createKeyVault(RG_A, "indexkv"); + } + + private void createGroup(String rg) { + given().contentType("application/json").body("{\"location\":\"eastus\"}") + .when().put("/subscriptions/" + SUB + "/resourceGroups/" + rg + "?api-version=2021-04-01") + .then().statusCode(oneOf(200, 201)); + } + + private void createVm(String rg, String name) { + given().contentType("application/json").body(VM_BODY) + .when().put("/subscriptions/" + SUB + "/resourceGroups/" + rg + + "/providers/Microsoft.Compute/virtualMachines/" + name + "?api-version=2024-11-01") + .then().statusCode(201); + } + + private void createKeyVault(String rg, String name) { + given().contentType("application/json") + .body("{\"location\":\"eastus\",\"properties\":{\"tenantId\":\"t\"," + + "\"sku\":{\"family\":\"A\",\"name\":\"standard\"}}}") + .when().put("/subscriptions/" + SUB + "/resourceGroups/" + rg + + "/providers/Microsoft.KeyVault/vaults/" + name + "?api-version=2023-07-01") + .then().statusCode(oneOf(200, 201)); + } + + private void createStorageAccount(String rg, String name) { + given().contentType("application/json").body("{\"location\":\"eastus\"}") + .when().put("/subscriptions/" + SUB + "/resourceGroups/" + rg + + "/providers/Microsoft.Storage/storageAccounts/" + name + "?api-version=2023-01-01") + .then().statusCode(oneOf(200, 201)); + } + + @Test + @DisplayName("A resource-group listing carries that group's resources and no other group's") + void resourceGroupListingIsScopedToItsGroup() { + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG_A + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.name", containsInAnyOrder("indexsaa", "indexkv", "vm-a")); + + given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG_B + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.name", contains("vm-b")); + } + + @Test + @DisplayName("The subscription listing spans every resource group") + void subscriptionListingSpansGroups() { + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.name", containsInAnyOrder("indexsaa", "indexkv", "vm-a", "vm-b")) + .body("value.findAll { it.type == 'Microsoft.Compute/virtualMachines' }", hasSize(2)); + } + + @Test + @DisplayName("A contributor's index entry carries identity only, without properties") + void contributorEntriesCarryIdentityOnly() { + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'vm-a' }.type", equalTo("Microsoft.Compute/virtualMachines")) + .body("value.find { it.name == 'vm-a' }.location", equalTo("eastus")) + .body("value.find { it.name == 'vm-a' }.id", + equalTo("/subscriptions/" + SUB + "/resourceGroups/" + RG_A + + "/providers/Microsoft.Compute/virtualMachines/vm-a")) + .body("value.find { it.name == 'vm-a' }.properties", equalTo(null)); + } + + @Test + @DisplayName("Key vaults keep their properties so azurerm can look one up by vaultUri") + void keyVaultEntriesKeepVaultUri() { + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'indexkv' }.properties.vaultUri", not(emptyOrNullString())); + } +} diff --git a/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java b/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java index f85d3c8e..a8fb40d3 100644 --- a/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/mariadb/MariaDbHandlerMockedTest.java @@ -202,13 +202,17 @@ void checkNameAvailability() { } @Test - @DisplayName("Servers appear in the resource-group /resources index") - void serverAppearsInRgResourceIndex() { + @DisplayName("Servers appear in the resource-group and subscription /resources indexes") + void serverAppearsInResourceIndexes() { createServer("mariadb-idx"); given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + "/resources?api-version=2021-04-01") .then().statusCode(200) .body("value.find { it.name == 'mariadb-idx' }.type", equalTo("Microsoft.DBforMariaDB/servers")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'mariadb-idx' }.type", equalTo("Microsoft.DBforMariaDB/servers")); } } diff --git a/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java b/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java index eca79084..dbc43031 100644 --- a/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/mysql/MySqlHandlerMockedTest.java @@ -215,13 +215,17 @@ void checkNameAvailabilityTaken() { } @Test - @DisplayName("Servers appear in the resource-group /resources index") - void serverAppearsInRgResourceIndex() { + @DisplayName("Servers appear in the resource-group and subscription /resources indexes") + void serverAppearsInResourceIndexes() { createServer("mysql-idx"); given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + "/resources?api-version=2021-04-01") .then().statusCode(200) .body("value.find { it.name == 'mysql-idx' }.type", equalTo("Microsoft.DBforMySQL/flexibleServers")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'mysql-idx' }.type", equalTo("Microsoft.DBforMySQL/flexibleServers")); } } diff --git a/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java b/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java index 3be6ed30..f336bf21 100644 --- a/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/postgres/PostgresHandlerMockedTest.java @@ -238,13 +238,17 @@ void connectReturnsStrings() { } @Test - @DisplayName("Servers appear in the resource-group /resources index") - void serverAppearsInRgResourceIndex() { + @DisplayName("Servers appear in the resource-group and subscription /resources indexes") + void serverAppearsInResourceIndexes() { createServer("pg-idx"); given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + "/resources?api-version=2021-04-01") .then().statusCode(200) .body("value.find { it.name == 'pg-idx' }.type", equalTo("Microsoft.DBforPostgreSQL/flexibleServers")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'pg-idx' }.type", equalTo("Microsoft.DBforPostgreSQL/flexibleServers")); } } diff --git a/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java b/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java index 3b8cb566..572c9799 100644 --- a/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java +++ b/src/test/java/io/floci/az/services/redis/RedisHandlerTest.java @@ -196,8 +196,8 @@ void patchTags() { } @Test - @DisplayName("Caches appear in the resource-group /resources index") - void cacheAppearsInRgResourceIndex() { + @DisplayName("Caches appear in the resource-group and subscription /resources indexes") + void cacheAppearsInResourceIndexes() { given() .contentType("application/json").body(CREATE_BODY) .when().put(BASE + "/redis/redis-idx" + API) @@ -207,5 +207,9 @@ void cacheAppearsInRgResourceIndex() { + "/resources?api-version=2021-04-01") .then().statusCode(200) .body("value.find { it.name == 'redis-idx' }.type", equalTo("Microsoft.Cache/Redis")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'redis-idx' }.type", equalTo("Microsoft.Cache/Redis")); } } diff --git a/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java b/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java index 9a40dbe8..791208ec 100644 --- a/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java +++ b/src/test/java/io/floci/az/services/sql/SqlHandlerMockedTest.java @@ -94,8 +94,8 @@ void masterDatabaseAutoCreated() { } @Test - @DisplayName("Servers appear in the resource-group /resources index") - void serverAppearsInRgResourceIndex() { + @DisplayName("Servers appear in the resource-group and subscription /resources indexes") + void serverAppearsInResourceIndexes() { given() .contentType("application/json") .body("{\"location\":\"eastus\",\"properties\":{" @@ -108,5 +108,9 @@ void serverAppearsInRgResourceIndex() { + "/resources?api-version=2021-04-01") .then().statusCode(200) .body("value.find { it.name == 'sql-idx' }.type", equalTo("Microsoft.Sql/servers")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'sql-idx' }.type", equalTo("Microsoft.Sql/servers")); } } diff --git a/src/test/java/io/floci/az/services/vm/VmHandlerTest.java b/src/test/java/io/floci/az/services/vm/VmHandlerTest.java index 511f84f9..14fc1711 100644 --- a/src/test/java/io/floci/az/services/vm/VmHandlerTest.java +++ b/src/test/java/io/floci/az/services/vm/VmHandlerTest.java @@ -188,12 +188,16 @@ void networkInterfaceStub() { } @Test - @DisplayName("VMs appear in the resource-group /resources index") - void vmAppearsInRgResourceIndex() { + @DisplayName("VMs appear in the resource-group and subscription /resources indexes") + void vmAppearsInResourceIndexes() { createVm("vm-idx"); given().when().get("/subscriptions/" + SUB + "/resourceGroups/" + RG + "/resources" + API) .then().statusCode(200) .body("value.find { it.name == 'vm-idx' }.type", equalTo("Microsoft.Compute/virtualMachines")); + + given().when().get("/subscriptions/" + SUB + "/resources?api-version=2021-04-01") + .then().statusCode(200) + .body("value.find { it.name == 'vm-idx' }.type", equalTo("Microsoft.Compute/virtualMachines")); } }