Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/main/java/io/floci/az/core/arm/ArmResources.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.</p>
*/
public static Map<String, Object> indexEntry(String id, String name, String type, String location,
Map<String, String> tags) {
Map<String, Object> 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<String, Object> stripInternal(Map<String, Object> resource) {
Map<String, Object> copy = new LinkedHashMap<>(resource);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
* <p>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
* <p>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<ResourceIndexContributor>} (the same pattern as {@code Resettable} /
* {@code AdminController}), so contributing costs one interface — not an ArmHandler constructor
* change per service.</p>
*
* <p>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.</p>
*/
public interface ResourceIndexContributor {

Expand All @@ -22,6 +28,9 @@ public interface ResourceIndexContributor {
*/
List<Map<String, Object>> listRgResources(String sub, String rg);

/** The same maps for every one of this service's resources in the subscription. */
List<Map<String, Object>> listSubscriptionResources(String sub);

/** Whether this contributor's service is enabled; disabled services contribute nothing. */
default boolean indexEnabled() {
return true;
Expand Down
12 changes: 10 additions & 2 deletions src/main/java/io/floci/az/services/aci/AciHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,17 @@ public boolean indexEnabled() {

@Override
public List<Map<String, Object>> listRgResources(String sub, String rg) {
String prefix = (sub + "/" + rg + "/").toLowerCase();
return indexEntries((sub + "/" + rg + "/").toLowerCase());
}

@Override
public List<Map<String, Object>> listSubscriptionResources(String sub) {
return indexEntries((sub + "/").toLowerCase());
}

private List<Map<String, Object>> indexEntries(String storageKeyPrefix) {
return scanAll().stream()
.filter(group -> group.storageKey().toLowerCase().startsWith(prefix))
.filter(group -> group.storageKey().toLowerCase().startsWith(storageKeyPrefix))
.map(ContainerGroup::indexEntry)
.toList();
}
Expand Down
13 changes: 3 additions & 10 deletions src/main/java/io/floci/az/services/aci/AciModels.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -80,15 +80,8 @@ public String storageKey() {

/** Minimal ARM resource map for the resource-group {@code /resources} index. */
public Map<String, Object> indexEntry() {
Map<String, Object> 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);
}
}

Expand Down
33 changes: 31 additions & 2 deletions src/main/java/io/floci/az/services/acr/AcrHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,14 +64,16 @@
* and {@code loginServer} is the cosmetic {@code {name}.azurecr.io} for management-plane fidelity.</p>
*/
@ApplicationScoped
public class AcrHandler implements AzureServiceHandler, Resettable {
public class AcrHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor {

private static final Logger LOG = Logger.getLogger(AcrHandler.class);

private static final ObjectMapper MAPPER = new ObjectMapper()
.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";
Expand Down Expand Up @@ -459,7 +463,7 @@ private Map<String, Object> toArmResponse(Registry registry) {
Map<String, Object> 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());
Expand Down Expand Up @@ -555,4 +559,29 @@ private static Response methodNotAllowed() {
public void clear() {
storage.clear();
}

// ── ResourceIndexContributor ────────────────────────────────────────────────

@Override
public boolean indexEnabled() {
return config.services().acr().enabled();
}

@Override
public List<Map<String, Object>> listRgResources(String sub, String rg) {
return indexEntries((sub + "/" + rg + "/").toLowerCase());
}

@Override
public List<Map<String, Object>> listSubscriptionResources(String sub) {
return indexEntries((sub + "/").toLowerCase());
}

private List<Map<String, Object>> indexEntries(String storageKeyPrefix) {
return scanAll().stream()
.filter(registry -> registry.storageKey().toLowerCase().startsWith(storageKeyPrefix))
.map(registry -> ArmResources.indexEntry(registry.armId(), registry.getName(), TYPE,
registry.getLocation(), registry.getTags()))
.toList();
}
}
33 changes: 31 additions & 2 deletions src/main/java/io/floci/az/services/aks/AksHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,14 +58,16 @@
* Clusters transition immediately to "Succeeded" with a synthetic kubeconfig pointing at localhost.</p>
*/
@ApplicationScoped
public class AksHandler implements AzureServiceHandler, Resettable {
public class AksHandler implements AzureServiceHandler, Resettable, ResourceIndexContributor {

private static final Logger LOG = Logger.getLogger(AksHandler.class);

private static final ObjectMapper MAPPER = new ObjectMapper()
.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<String, StoredObject> storage;
Expand Down Expand Up @@ -514,7 +518,7 @@ private Map<String, Object> toArmResponse(ManagedCluster cluster) {
Map<String, Object> 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());
Expand Down Expand Up @@ -640,4 +644,29 @@ private static Response methodNotAllowed() {
public void clear() {
storage.clear();
}

// ── ResourceIndexContributor ────────────────────────────────────────────────

@Override
public boolean indexEnabled() {
return config.services().aks().enabled();
}

@Override
public List<Map<String, Object>> listRgResources(String sub, String rg) {
return indexEntries((sub + "/" + rg + "/").toLowerCase());
}

@Override
public List<Map<String, Object>> listSubscriptionResources(String sub) {
return indexEntries((sub + "/").toLowerCase());
}

private List<Map<String, Object>> indexEntries(String storageKeyPrefix) {
return scanAll().stream()
.filter(cluster -> cluster.storageKey().toLowerCase().startsWith(storageKeyPrefix))
.map(cluster -> ArmResources.indexEntry(cluster.armId(), cluster.getName(), TYPE,
cluster.getLocation(), cluster.getTags()))
.toList();
}
}
87 changes: 51 additions & 36 deletions src/main/java/io/floci/az/services/arm/ArmHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, Object>> 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 ───────────────────────────────────────────────────
Expand Down Expand Up @@ -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<Map<String, Object>> 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/...
Expand All @@ -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.
*
* <p>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}.</p>
*/
private List<Map<String, Object>> indexedResources(String sub, String rg) {
List<Map<String, Object>> 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<String, Map<String, Object>> state, String sub, String rg,
List<Map<String, Object>> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ public List<Map<String, Object>> listResources(String sub, String rg) {
return result;
}

public List<Map<String, Object>> listResources(String sub) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> 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) {
Expand Down
Loading