diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 6d39b50b..2fd76e28 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -34,6 +34,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `bigtable` | — | — | [Bigtable](./gcp/bigtable.md) | — | 38 | | `cache` | [ElastiCache](./aws/elasticache.md) | [Cache](./azure/cache.md) | [Memorystore](./gcp/memorystore.md) | — | 17 | | `certificatemanager` | — | — | [CertificateManager](./gcp/certificatemanager.md) | — | 16 | +| `chaosstudio` | — | [ChaosStudio](./azure/chaosstudio.md) | — | — | 8 | | `cloudasset` | — | — | [Cloudasset](./gcp/cloudasset.md) | — | 11 | | `cloudbilling` | — | — | [Cloudbilling](./gcp/cloudbilling.md) | — | 14 | | `clouddeploy` | — | — | [CloudDeploy](./gcp/clouddeploy.md) | — | 11 | diff --git a/docs/coverage/azure/README.md b/docs/coverage/azure/README.md index e4182415..e3583990 100644 --- a/docs/coverage/azure/README.md +++ b/docs/coverage/azure/README.md @@ -14,6 +14,7 @@ Services cloudemu emulates for Azure, by native name. Back to the [cross-provide | [Bastion](./bastion.md) | `bastion` | 4 | | [BlobStorage](./blobstorage.md) | `storage` | 35 | | [Cache](./cache.md) | `cache` | 17 | +| [ChaosStudio](./chaosstudio.md) | — (provider-native) | 8 | | [Communication](./communication.md) | — (provider-native) | 10 | | [ContainerApps](./containerapps.md) | — (provider-native) | 19 | | [ContainerInstances](./containerinstances.md) | `containerinstances` | 10 | diff --git a/docs/coverage/azure/chaosstudio.md b/docs/coverage/azure/chaosstudio.md new file mode 100644 index 00000000..870f90c0 --- /dev/null +++ b/docs/coverage/azure/chaosstudio.md @@ -0,0 +1,21 @@ + +# ChaosStudio + +provider-native `chaosstudio` wire service (Azure-only) · no portable driver · [Azure index](./README.md) + +## Operations (8) + +| Operation | Description | +| --- | --- | +| `ARMID` | | +| `CreateOrUpdate` | | +| `Delete` | | +| `DiscoverExperiments` | | +| `Get` | | +| `ListByResourceGroup` | | +| `ListBySubscription` | | +| `PurgeResourceGroup` | | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index b7c728c5..31adcca0 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -2478,6 +2478,39 @@ "gcp": "CertificateManager" } }, + { + "service": "chaosstudio", + "interface": "provider-native", + "operations": [ + { + "name": "ARMID" + }, + { + "name": "CreateOrUpdate" + }, + { + "name": "Delete" + }, + { + "name": "DiscoverExperiments" + }, + { + "name": "Get" + }, + { + "name": "ListByResourceGroup" + }, + { + "name": "ListBySubscription" + }, + { + "name": "PurgeResourceGroup" + } + ], + "providers": { + "azure": "ChaosStudio" + } + }, { "service": "cloudasset", "interface": "provider-native", diff --git a/providers/azure/azure.go b/providers/azure/azure.go index 65aee0a4..cdbcde7d 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -16,6 +16,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/azure/bastion" "github.com/stackshy/cloudemu/v2/providers/azure/blobstorage" "github.com/stackshy/cloudemu/v2/providers/azure/cache" + "github.com/stackshy/cloudemu/v2/providers/azure/chaosstudio" "github.com/stackshy/cloudemu/v2/providers/azure/communication" "github.com/stackshy/cloudemu/v2/providers/azure/containerapps" "github.com/stackshy/cloudemu/v2/providers/azure/containerinstances" @@ -185,6 +186,7 @@ type Provider struct { ManagedGrafana *managedgrafana.Mock DevCenter *devcenter.Mock Purview *purview.Mock + ChaosStudio *chaosstudio.Mock ResourceDiscovery *resourcediscovery.Engine @@ -256,6 +258,7 @@ func New(opts ...config.Option) *Provider { ManagedGrafana: managedgrafana.New(o), DevCenter: devcenter.New(o), Purview: purview.New(o), + ChaosStudio: chaosstudio.New(o), SubscriptionID: o.AccountID, Region: o.Region, EnforceAuth: o.EnforceAuth, @@ -299,6 +302,7 @@ func New(opts ...config.Option) *Provider { managedGrafanaDiscovery{p.ManagedGrafana}, devCenterDiscovery{p.DevCenter}, purviewDiscovery{p.Purview}, + chaosStudioDiscovery{p.ChaosStudio}, }, }, ) diff --git a/providers/azure/chaosstudio/chaosstudio.go b/providers/azure/chaosstudio/chaosstudio.go new file mode 100644 index 00000000..4eca35f0 --- /dev/null +++ b/providers/azure/chaosstudio/chaosstudio.go @@ -0,0 +1,336 @@ +// Package chaosstudio provides an in-memory mock of Azure Chaos Studio +// (Microsoft.Chaos/experiments) — the ARM control plane only. It manages the +// experiment resource lifecycle (create/update/get/delete/list); no faults are +// ever injected (Chaos Studio's data plane — starting/canceling executions and +// the targets/capabilities resources nested under other resources — is out of +// scope). This package is unrelated to CloudEmu's features/chaos fault-injection +// engine; it emulates the Azure Chaos Studio ARM product surface. +// +// The resource carries a set of computed, service-minted fields that MUST stay +// stable for the lifetime of the resource so infrastructure-as-code tools +// (Terraform's azurerm_chaos_studio_experiment) see no drift on re-plan: +// - provisioningState: "Succeeded" once provisioning completes. +// - identity.principalId / identity.tenantId for a system-assigned identity. +// +// The rich selectors and steps configuration is carried verbatim as raw JSON, so +// the deeply-nested branch/action blocks round-trip byte-for-byte and cannot +// drift. Every computed field is derived deterministically from the resource +// identity, so the same resource always reports the same values — across gets, +// updates and a snapshot/restore. +package chaosstudio + +import ( + "context" + "encoding/json" + "maps" + "sort" + "strings" + "sync" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" +) + +const ( + // providerNamespace is the ARM provider namespace. + providerNamespace = "Microsoft.Chaos" + // resourceType is the ARM resource type segment. + resourceType = "experiments" + // stateSucceeded is the terminal provisioning state a synchronous ARM PUT + // reaches immediately. + stateSucceeded = "Succeeded" +) + +// emptyArray is the default value for the required selectors/steps arrays when a +// create omits them, so the wire response always carries a valid JSON array. +func emptyArray() json.RawMessage { return json.RawMessage("[]") } + +// UserAssignedValue is the pair of ids Azure mints for a user-assigned identity +// once it is attached to a resource. +type UserAssignedValue struct { + PrincipalID string `json:"principalId"` + ClientID string `json:"clientId"` +} + +// Identity is a managed identity attached to an experiment. Type is one of +// SystemAssigned, UserAssigned or "SystemAssigned,UserAssigned". PrincipalID and +// TenantID are populated only for a system-assigned identity. +type Identity struct { + Type string `json:"type"` + PrincipalID string `json:"principalId,omitempty"` + TenantID string `json:"tenantId,omitempty"` + UserAssigned map[string]UserAssignedValue `json:"userAssignedIdentities,omitempty"` +} + +// Experiment is a stored Microsoft.Chaos/experiments resource. Subscription, +// ResourceGroup and Name preserve the caller's casing; the computed fields are +// minted at create and never regenerated on a read. Selectors and Steps are held +// verbatim as raw JSON so the nested blocks round-trip exactly. +type Experiment struct { + Subscription string `json:"subscription"` + ResourceGroup string `json:"resourceGroup"` + Name string `json:"name"` + Location string `json:"location"` + Tags map[string]string `json:"tags,omitempty"` + Identity *Identity `json:"identity,omitempty"` + + // Writable properties, carried verbatim as raw JSON arrays. + Selectors json.RawMessage `json:"selectors,omitempty"` + Steps json.RawMessage `json:"steps,omitempty"` + + // Computed, stable field. + ProvisioningState string `json:"provisioningState"` +} + +// ARMID returns the fully-qualified ARM resource id. +func (s *Experiment) ARMID() string { + return idgen.AzureID(s.Subscription, s.ResourceGroup, providerNamespace, resourceType, s.Name) +} + +// Input carries the mutable fields of a create/update request. A nil pointer or +// nil raw message means "not supplied" (preserve existing), so a PATCH overlays +// only what it names. +type Input struct { + Tags map[string]string + Identity *Identity + Selectors json.RawMessage + Steps json.RawMessage +} + +// Mock is the in-memory backend for experiment resources. +type Mock struct { + mu sync.RWMutex + store *memstore.Store[*Experiment] + + // tenantID is the single AAD tenant this estate belongs to; every + // system-assigned identity reports it. Deterministic, so it survives a + // restart without being persisted. + tenantID string +} + +// New creates an empty experiment mock. +func New(_ *config.Options) *Mock { + return &Mock{ + store: memstore.New[*Experiment](), + tenantID: idgen.SyntheticGUID("cloudemu/azure/tenant"), + } +} + +// key is the case-insensitive store key for a resource. +func key(sub, rg, name string) string { + return strings.ToLower(idgen.AzureID(sub, rg, providerNamespace, resourceType, name)) +} + +// CreateOrUpdate creates a new experiment or updates an existing one. The +// computed fields (identity ids) are minted deterministically so they stay +// stable across updates. Location is immutable and preserved on update. It +// returns the stored resource and whether it was newly created. +func (m *Mock) CreateOrUpdate(_ context.Context, sub, rg, name, location string, in *Input) (Experiment, bool, error) { + if err := validate(sub, rg, name); err != nil { + return Experiment{}, false, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + k := key(sub, rg, name) + + existing, existed := m.store.Get(k) + created := !existed + + var s Experiment + if existed { + s = *existing + } else { + s = newExperiment(sub, rg, name, location) + } + + applyInput(&s, in) + + // Identity is re-resolved only when the request supplies one; a PATCH that + // omits identity preserves the stored value (an explicit "None" clears it). + if in.Identity != nil { + s.Identity = m.resolveIdentity(in.Identity, sub, rg, name) + } + + s.ProvisioningState = stateSucceeded + + m.store.Set(k, &s) + + return clone(&s), created, nil +} + +// newExperiment seeds a fresh resource with its immutable identity, location and +// empty (but valid) required arrays. +func newExperiment(sub, rg, name, location string) Experiment { + return Experiment{ + Subscription: sub, + ResourceGroup: rg, + Name: name, + Location: location, + Selectors: emptyArray(), + Steps: emptyArray(), + ProvisioningState: stateSucceeded, + } +} + +// Get returns the resource, or a NotFound error. +func (m *Mock) Get(_ context.Context, sub, rg, name string) (Experiment, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, ok := m.store.Get(key(sub, rg, name)) + if !ok { + return Experiment{}, cerrors.Newf(cerrors.NotFound, "chaos experiment %q not found", name) + } + + return clone(s), nil +} + +// Delete removes the resource, reporting whether it existed. +func (m *Mock) Delete(_ context.Context, sub, rg, name string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return m.store.Delete(key(sub, rg, name)), nil +} + +// ListByResourceGroup returns every resource in the group, sorted by name. +func (m *Mock) ListByResourceGroup(_ context.Context, sub, rg string) ([]Experiment, error) { + return m.filter(func(s *Experiment) bool { + return strings.EqualFold(s.Subscription, sub) && strings.EqualFold(s.ResourceGroup, rg) + }), nil +} + +// ListBySubscription returns every resource in the subscription, sorted by name. +func (m *Mock) ListBySubscription(_ context.Context, sub string) ([]Experiment, error) { + return m.filter(func(s *Experiment) bool { + return strings.EqualFold(s.Subscription, sub) + }), nil +} + +// DiscoverExperiments returns every stored resource, for the inventory walk. +func (m *Mock) DiscoverExperiments(_ context.Context) ([]Experiment, error) { + return m.filter(func(*Experiment) bool { return true }), nil +} + +// PurgeResourceGroup deletes every experiment under sub/rg, so a resource-group +// delete cascades into its experiments. +func (m *Mock) PurgeResourceGroup(_ context.Context, sub, rg string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for k, s := range m.store.All() { + if strings.EqualFold(s.Subscription, sub) && strings.EqualFold(s.ResourceGroup, rg) { + m.store.Delete(k) + } + } + + return nil +} + +// filter returns the resources matching pred, sorted by name for a stable order. +func (m *Mock) filter(pred func(*Experiment) bool) []Experiment { + m.mu.RLock() + defer m.mu.RUnlock() + + var out []Experiment + + for _, s := range m.store.All() { + if pred(s) { + out = append(out, clone(s)) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + + return out +} + +// applyInput overlays the mutable request fields onto s, leaving the computed +// field and the immutable location untouched. A nil pointer / nil raw message +// means "not supplied": the stored value is preserved. +func applyInput(s *Experiment, in *Input) { + if in.Tags != nil { + s.Tags = maps.Clone(in.Tags) + } + + if in.Selectors != nil { + s.Selectors = cloneRaw(in.Selectors) + } + + if in.Steps != nil { + s.Steps = cloneRaw(in.Steps) + } +} + +// resolveIdentity normalizes an incoming managed identity, synthesizing the +// deterministic ids Azure mints on assignment. A nil or "None" identity resolves +// to nil. +func (m *Mock) resolveIdentity(in *Identity, sub, rg, name string) *Identity { + if in == nil || in.Type == "" || strings.EqualFold(in.Type, "None") { + return nil + } + + out := &Identity{Type: in.Type} + + if strings.Contains(strings.ToLower(in.Type), "systemassigned") { + id := strings.ToLower(idgen.AzureID(sub, rg, providerNamespace, resourceType, name)) + out.PrincipalID = idgen.SyntheticGUID("principal/" + id) + out.TenantID = m.tenantID + } + + if len(in.UserAssigned) > 0 { + out.UserAssigned = make(map[string]UserAssignedValue, len(in.UserAssigned)) + for id := range in.UserAssigned { + out.UserAssigned[id] = UserAssignedValue{ + PrincipalID: idgen.SyntheticGUID("ua-principal/" + strings.ToLower(id)), + ClientID: idgen.SyntheticGUID("ua-client/" + strings.ToLower(id)), + } + } + } + + return out +} + +// validate rejects a create/update with missing required identity fields. +func validate(sub, rg, name string) error { + switch { + case sub == "": + return cerrors.New(cerrors.InvalidArgument, "subscription is required") + case rg == "": + return cerrors.New(cerrors.InvalidArgument, "resource group is required") + case name == "": + return cerrors.New(cerrors.InvalidArgument, "experiment name is required") + default: + return nil + } +} + +// clone deep-copies a stored resource so callers never alias the backing store. +func clone(s *Experiment) Experiment { + out := *s + out.Tags = maps.Clone(s.Tags) + out.Selectors = cloneRaw(s.Selectors) + out.Steps = cloneRaw(s.Steps) + + if s.Identity != nil { + id := *s.Identity + id.UserAssigned = maps.Clone(s.Identity.UserAssigned) + out.Identity = &id + } + + return out +} + +// cloneRaw copies a raw JSON message so a stored resource never aliases the +// caller's byte slice. A nil input clones to nil. +func cloneRaw(in json.RawMessage) json.RawMessage { + if in == nil { + return nil + } + + return append(json.RawMessage(nil), in...) +} diff --git a/providers/azure/chaosstudio/chaosstudio_test.go b/providers/azure/chaosstudio/chaosstudio_test.go new file mode 100644 index 00000000..24e0b088 --- /dev/null +++ b/providers/azure/chaosstudio/chaosstudio_test.go @@ -0,0 +1,275 @@ +package chaosstudio_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/azure/chaosstudio" +) + +func newMock() *chaosstudio.Mock { + return chaosstudio.New(config.NewOptions()) +} + +const ( + selectorsJSON = `[{"id":"Selector1","type":"List","filter":null,` + + `"targets":[{"id":"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm1` + + `/providers/Microsoft.Chaos/targets/Microsoft-VirtualMachine","type":"ChaosTarget"}]}]` + stepsJSON = `[{"name":"step1","branches":[{"name":"branch1","actions":[` + + `{"type":"continuous","name":"urn:csci:microsoft:virtualMachine:shutdown/1.0","selectorId":"Selector1",` + + `"duration":"PT10M","parameters":[{"key":"abruptShutdown","value":"false"}]}]}]}]` +) + +func standardInput() *chaosstudio.Input { + return &chaosstudio.Input{ + Tags: map[string]string{"env": "dev"}, + Identity: &chaosstudio.Identity{Type: "SystemAssigned"}, + Selectors: json.RawMessage(selectorsJSON), + Steps: json.RawMessage(stepsJSON), + } +} + +func createStd(t *testing.T, m *chaosstudio.Mock) chaosstudio.Experiment { + t.Helper() + + s, isNew, err := m.CreateOrUpdate(context.Background(), "sub", "rg", "exp1", "West US", standardInput()) + if err != nil || !isNew { + t.Fatalf("create: err=%v isNew=%v", err, isNew) + } + + return s +} + +func TestCreateComputesStableFields(t *testing.T) { + m := newMock() + created := createStd(t, m) + + if created.ProvisioningState != "Succeeded" { + t.Errorf("provisioningState = %q, want Succeeded", created.ProvisioningState) + } + + if created.Identity == nil || created.Identity.PrincipalID == "" || created.Identity.TenantID == "" { + t.Errorf("system identity ids not minted: %+v", created.Identity) + } + + if !bytes.Equal(created.Selectors, json.RawMessage(selectorsJSON)) { + t.Errorf("selectors not round-tripped verbatim:\n got=%s\nwant=%s", created.Selectors, selectorsJSON) + } + + if !bytes.Equal(created.Steps, json.RawMessage(stepsJSON)) { + t.Errorf("steps not round-tripped verbatim:\n got=%s\nwant=%s", created.Steps, stepsJSON) + } +} + +func TestDefaultSelectorsStepsAreEmptyArrays(t *testing.T) { + s, _, err := newMock().CreateOrUpdate(context.Background(), "sub", "rg", "exp0", "eastus", &chaosstudio.Input{}) + if err != nil { + t.Fatalf("create: %v", err) + } + + if string(s.Selectors) != "[]" || string(s.Steps) != "[]" { + t.Errorf("default selectors/steps = %s / %s, want [] / []", s.Selectors, s.Steps) + } +} + +func TestGetStableAcrossReadsAndUpdates(t *testing.T) { + ctx := context.Background() + m := newMock() + created := createStd(t, m) + + got1, err := m.Get(ctx, "sub", "rg", "exp1") + if err != nil { + t.Fatalf("get: %v", err) + } + + // A tag-only update must not move any computed field, nor the selectors/steps. + updated, _, err := m.CreateOrUpdate(ctx, "sub", "rg", "exp1", "West US", &chaosstudio.Input{ + Tags: map[string]string{"env": "prod"}, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + + for _, tc := range []struct { + name string + a, b, c string + }{ + {"principalId", created.Identity.PrincipalID, got1.Identity.PrincipalID, updated.Identity.PrincipalID}, + {"tenantId", created.Identity.TenantID, got1.Identity.TenantID, updated.Identity.TenantID}, + {"selectors", string(created.Selectors), string(got1.Selectors), string(updated.Selectors)}, + {"steps", string(created.Steps), string(got1.Steps), string(updated.Steps)}, + } { + if tc.a != tc.b || tc.b != tc.c { + t.Errorf("%s drifted: create=%q get=%q update=%q", tc.name, tc.a, tc.b, tc.c) + } + } + + if updated.Tags["env"] != "prod" || len(updated.Tags) != 1 { + t.Errorf("tags not replaced: %v", updated.Tags) + } + + if updated.Identity == nil { + t.Errorf("identity wiped by tag-only update") + } +} + +func TestUpdateReplacesSelectorsSteps(t *testing.T) { + ctx := context.Background() + m := newMock() + createStd(t, m) + + newSelectors := `[{"id":"S2","type":"List","filter":null,"targets":[]}]` + updated, _, err := m.CreateOrUpdate(ctx, "sub", "rg", "exp1", "West US", &chaosstudio.Input{ + Selectors: json.RawMessage(newSelectors), + }) + if err != nil { + t.Fatalf("update: %v", err) + } + + if string(updated.Selectors) != newSelectors { + t.Errorf("selectors = %s, want %s", updated.Selectors, newSelectors) + } + + // Steps omitted on the update → preserved. + if !bytes.Equal(updated.Steps, json.RawMessage(stepsJSON)) { + t.Errorf("steps not preserved on selector-only update: %s", updated.Steps) + } +} + +func TestExplicitNoneClearsIdentity(t *testing.T) { + ctx := context.Background() + m := newMock() + createStd(t, m) + + updated, _, err := m.CreateOrUpdate(ctx, "sub", "rg", "exp1", "West US", &chaosstudio.Input{ + Identity: &chaosstudio.Identity{Type: "None"}, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + + if updated.Identity != nil { + t.Errorf("identity = %+v, want nil after None", updated.Identity) + } +} + +func TestUserAssignedIdentity(t *testing.T) { + ctx := context.Background() + m := newMock() + + uaID := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uai1" + s, _, err := m.CreateOrUpdate(ctx, "sub", "rg", "exp2", "eastus", &chaosstudio.Input{ + Identity: &chaosstudio.Identity{ + Type: "UserAssigned", + UserAssigned: map[string]chaosstudio.UserAssignedValue{uaID: {}}, + }, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + v, ok := s.Identity.UserAssigned[uaID] + if !ok || v.PrincipalID == "" || v.ClientID == "" { + t.Errorf("user-assigned ids not minted: %+v", s.Identity.UserAssigned) + } + + if s.Identity.PrincipalID != "" || s.Identity.TenantID != "" { + t.Errorf("unexpected system ids on user-assigned identity: %+v", s.Identity) + } +} + +func TestGetNotFound(t *testing.T) { + _, err := newMock().Get(context.Background(), "sub", "rg", "missing") + if !cerrors.IsNotFound(err) { + t.Errorf("err = %v, want NotFound", err) + } +} + +func TestDeleteIdempotent(t *testing.T) { + ctx := context.Background() + m := newMock() + createStd(t, m) + + existed, err := m.Delete(ctx, "sub", "rg", "exp1") + if err != nil || !existed { + t.Fatalf("first delete: err=%v existed=%v", err, existed) + } + + existed, err = m.Delete(ctx, "sub", "rg", "exp1") + if err != nil || existed { + t.Fatalf("second delete: err=%v existed=%v, want existed=false", err, existed) + } +} + +func TestListAndPurge(t *testing.T) { + ctx := context.Background() + m := newMock() + + for _, n := range []string{"a", "b"} { + if _, _, err := m.CreateOrUpdate(ctx, "sub", "rg", n, "eastus", &chaosstudio.Input{}); err != nil { + t.Fatalf("create %s: %v", n, err) + } + } + + if _, _, err := m.CreateOrUpdate(ctx, "sub", "rg2", "c", "eastus", &chaosstudio.Input{}); err != nil { + t.Fatalf("create c: %v", err) + } + + byRG, _ := m.ListByResourceGroup(ctx, "sub", "rg") + if len(byRG) != 2 { + t.Errorf("ListByResourceGroup = %d, want 2", len(byRG)) + } + + bySub, _ := m.ListBySubscription(ctx, "sub") + if len(bySub) != 3 { + t.Errorf("ListBySubscription = %d, want 3", len(bySub)) + } + + if err := m.PurgeResourceGroup(ctx, "sub", "rg"); err != nil { + t.Fatalf("purge: %v", err) + } + + bySub, _ = m.ListBySubscription(ctx, "sub") + if len(bySub) != 1 { + t.Errorf("after purge ListBySubscription = %d, want 1", len(bySub)) + } +} + +func TestSnapshotRestore(t *testing.T) { + ctx := context.Background() + m := newMock() + created := createStd(t, m) + + data, err := m.Snapshot(ctx, false) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + + restored := newMock() + if err := restored.Restore(ctx, data); err != nil { + t.Fatalf("restore: %v", err) + } + + got, err := restored.Get(ctx, "sub", "rg", "exp1") + if err != nil { + t.Fatalf("get after restore: %v", err) + } + + if got.Identity.PrincipalID != created.Identity.PrincipalID || + !bytes.Equal(got.Selectors, created.Selectors) || !bytes.Equal(got.Steps, created.Steps) { + t.Errorf("restore lost stable fields") + } +} + +func TestValidation(t *testing.T) { + ctx := context.Background() + m := newMock() + + if _, _, err := m.CreateOrUpdate(ctx, "", "rg", "n", "eastus", &chaosstudio.Input{}); !cerrors.IsInvalidArgument(err) { + t.Errorf("empty sub err = %v, want InvalidArgument", err) + } +} diff --git a/providers/azure/chaosstudio/snapshot.go b/providers/azure/chaosstudio/snapshot.go new file mode 100644 index 00000000..b13ed29c --- /dev/null +++ b/providers/azure/chaosstudio/snapshot.go @@ -0,0 +1,43 @@ +package chaosstudio + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackshy/cloudemu/v2/internal/snapshot" +) + +var _ snapshot.Snapshottable = (*Mock)(nil) + +// Snapshot captures every experiment resource keyed by its (lowercased) resource +// id. The estate tenant id is deterministic (minted from a fixed seed in New), +// so it needs no persistence. includeAssets is unused — experiment resources +// hold no bulk object bodies. +func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + data, err := m.store.Snapshot() + if err != nil { + return nil, fmt.Errorf("chaosstudio: snapshot store: %w", err) + } + + return data, nil +} + +// Restore rebuilds every experiment resource under its original id. +func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { + m.mu.Lock() + defer m.mu.Unlock() + + if len(data) == 0 { + return nil + } + + if err := m.store.LoadSnapshot(data); err != nil { + return fmt.Errorf("chaosstudio: restore store: %w", err) + } + + return nil +} diff --git a/providers/azure/chaosstudio_discovery.go b/providers/azure/chaosstudio_discovery.go new file mode 100644 index 00000000..dc4287d0 --- /dev/null +++ b/providers/azure/chaosstudio_discovery.go @@ -0,0 +1,47 @@ +package azure + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/providers/azure/chaosstudio" + "github.com/stackshy/cloudemu/v2/services/resourcediscovery" +) + +// chaosStudioDiscovery projects Azure Chaos Studio resources +// (Microsoft.Chaos/experiments) into the cross-service inventory so they surface +// in Resource Graph / `az resource list`. Chaos Studio is Azure-only with no +// shared cross-cloud driver, so this rides the generic GenericResources +// projection (like devCenterDiscovery) rather than a shared walker. +type chaosStudioDiscovery struct{ m *chaosstudio.Mock } + +func (d chaosStudioDiscovery) DiscoverResources( + ctx context.Context, +) ([]resourcediscovery.DiscoveredResource, error) { + items, err := d.m.DiscoverExperiments(ctx) + if err != nil { + return nil, err + } + + return projectDiscovery(items, func(s *chaosstudio.Experiment) resourcediscovery.DiscoveredResource { + return resourcediscovery.DiscoveredResource{ + Service: resourcediscovery.ServiceChaosStudio, + Type: resourcediscovery.TypeChaosExperiment, + ID: s.Name, + ARN: s.ARMID(), + Region: s.Location, + Tags: s.Tags, + Attrs: resourcediscovery.Attributes{Properties: chaosStudioProps(s)}, + } + }), nil +} + +// chaosStudioProps projects an experiment's provisioning state into its +// inventory row's properties bag. Returns nil when unset so an empty properties +// block is omitted. +func chaosStudioProps(s *chaosstudio.Experiment) map[string]any { + if s.ProvisioningState == "" { + return nil + } + + return map[string]any{"provisioningState": s.ProvisioningState} +} diff --git a/server/azure/azure.go b/server/azure/azure.go index e8dfbbea..124c4419 100644 --- a/server/azure/azure.go +++ b/server/azure/azure.go @@ -21,6 +21,7 @@ import ( bastionsrv "github.com/stackshy/cloudemu/v2/server/azure/bastion" "github.com/stackshy/cloudemu/v2/server/azure/blobstorage" cachesrv "github.com/stackshy/cloudemu/v2/server/azure/cache" + chaosstudiosrv "github.com/stackshy/cloudemu/v2/server/azure/chaosstudio" communicationsrv "github.com/stackshy/cloudemu/v2/server/azure/communication" containerappssrv "github.com/stackshy/cloudemu/v2/server/azure/containerapps" containerinstancessrv "github.com/stackshy/cloudemu/v2/server/azure/containerinstances" @@ -177,6 +178,8 @@ type Drivers struct { DevCenter devcentersrv.Store // Purview serves Microsoft.Purview/accounts. Purview purviewsrv.Store + // ChaosStudio serves Microsoft.Chaos/experiments. + ChaosStudio chaosstudiosrv.Store // SQLVirtualMachine serves Microsoft.SqlVirtualMachine/sqlVirtualMachines — // the SQL-management overlay on a compute VM. SQLVirtualMachine sqlvirtualmachinesrv.Store @@ -503,6 +506,14 @@ func New(d Drivers) http.Handler { rgPurgers = append(rgPurgers, purviewHandler) } + // Chaos Studio: a resource-group-scoped resource, so its handler joins the + // purge cascade. Registered further below. + var chaosStudioHandler *chaosstudiosrv.Handler + if d.ChaosStudio != nil { + chaosStudioHandler = chaosstudiosrv.New(d.ChaosStudio) + rgPurgers = append(rgPurgers, chaosStudioHandler) + } + // SQL virtual machines: a resource-group-scoped resource, so its handler // joins the purge cascade. Deleting the group tears down the SQL-management // overlay records but never the paired compute VMs. Registered further below. @@ -908,6 +919,10 @@ func New(d Drivers) http.Handler { srv.Register(purviewHandler) } + if chaosStudioHandler != nil { + srv.Register(chaosStudioHandler) + } + // SQL virtual machines claim Microsoft.SqlVirtualMachine/sqlVirtualMachines — // a distinct ARM provider name from every other Azure handler, so registration // order is unconstrained. Registered before the BlobStorage fallback. diff --git a/server/azure/chaosstudio/handler.go b/server/azure/chaosstudio/handler.go new file mode 100644 index 00000000..9701463f --- /dev/null +++ b/server/azure/chaosstudio/handler.go @@ -0,0 +1,224 @@ +// Package chaosstudio serves the Azure Chaos Studio ARM API +// (Microsoft.Chaos/experiments). Real armchaos / go-azure-sdk experiments client +// requests hit this handler the same way they hit management.azure.com. +// +// Real Azure runs CreateOrUpdate/Delete as long-running operations; the emulator +// completes them synchronously (sync-200/201) with provisioningState=Succeeded, +// so there is no LRO plumbing to wire. This is the ARM control plane only — no +// faults are injected, and the targets/capabilities resources nested under other +// providers are out of scope. +package chaosstudio + +import ( + "context" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/providers/azure/chaosstudio" + "github.com/stackshy/cloudemu/v2/server/wire/azurearm" +) + +const ( + providerName = "Microsoft.Chaos" + resourceType = "experiments" + armType = providerName + "/" + resourceType +) + +// Store is the minimal experiment backend the handler needs. +// *chaosstudio.Mock satisfies it. +type Store interface { + CreateOrUpdate( + ctx context.Context, sub, rg, name, location string, in *chaosstudio.Input, + ) (chaosstudio.Experiment, bool, error) + Get(ctx context.Context, sub, rg, name string) (chaosstudio.Experiment, error) + Delete(ctx context.Context, sub, rg, name string) (bool, error) + ListByResourceGroup(ctx context.Context, sub, rg string) ([]chaosstudio.Experiment, error) + ListBySubscription(ctx context.Context, sub string) ([]chaosstudio.Experiment, error) + PurgeResourceGroup(ctx context.Context, sub, rg string) error +} + +// Handler serves Microsoft.Chaos/experiments ARM requests. +type Handler struct { + store Store +} + +// New returns an experiment handler backed by store. +func New(store Store) *Handler { + return &Handler{store: store} +} + +// Matches reports whether r targets an experiment ARM URL. The provider and type +// are matched case-insensitively because SDK URL templates and hand-written +// tooling differ in casing. +func (*Handler) Matches(r *http.Request) bool { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + return false + } + + return strings.EqualFold(rp.Provider, providerName) && + strings.EqualFold(rp.ResourceType, resourceType) +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rp, ok := azurearm.ParsePath(r.URL.Path) + if !ok { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "malformed ARM path") + return + } + + // A collection URL (no resource name) is a list — by resource group when the + // path carried one, otherwise by subscription. + if rp.ResourceName == "" { + h.list(w, r, &rp) + return + } + + switch r.Method { + case http.MethodPut: + h.createOrUpdate(w, r, &rp) + case http.MethodPatch: + h.update(w, r, &rp) + case http.MethodGet: + h.get(w, r, &rp) + case http.MethodDelete: + h.delete(w, r, &rp) + default: + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + } +} + +// PurgeResourceGroup deletes every experiment under sub/rg so a resource-group +// delete cascades into its experiments. +func (h *Handler) PurgeResourceGroup(ctx context.Context, subscription, resourceGroup string) error { + return h.store.PurgeResourceGroup(ctx, subscription, resourceGroup) +} + +func (h *Handler) createOrUpdate(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if rp.ResourceGroup == "" { + azurearm.WriteError(w, http.StatusBadRequest, "InvalidPath", "missing resourceGroups segment") + return + } + + var req experimentRequest + if !azurearm.DecodeJSON(w, r, &req) { + return + } + + in := inputFromRequest(&req) + + s, created, err := h.store.CreateOrUpdate(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName, req.Location, &in) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + status := http.StatusOK + if created { + status = http.StatusCreated + } + + azurearm.WriteJSON(w, status, toResponse(&s)) +} + +// update applies an ARM PATCH: only the supplied fields are overlaid onto the +// stored resource; the computed id, the immutable location and unmentioned +// fields are preserved. A PATCH on a missing resource is a 404. +func (h *Handler) update(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + existing, err := h.store.Get(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + var req experimentRequest + if !azurearm.DecodeJSON(w, r, &req) { + return + } + + in := inputFromRequest(&req) + + s, _, err := h.store.CreateOrUpdate( + r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName, existing.Location, &in) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toResponse(&s)) +} + +func (h *Handler) get(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + s, err := h.store.Get(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + azurearm.WriteJSON(w, http.StatusOK, toResponse(&s)) +} + +func (h *Handler) delete(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + existed, err := h.store.Delete(r.Context(), rp.Subscription, rp.ResourceGroup, rp.ResourceName) + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + // ARM DELETE is idempotent: a missing resource returns 204 No Content, a + // deleted one returns 200 OK. The experiments client accepts both. + if existed { + w.WriteHeader(http.StatusOK) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) list(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) { + if r.Method != http.MethodGet { + azurearm.WriteError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") + return + } + + var ( + items []chaosstudio.Experiment + err error + ) + + if rp.ResourceGroup != "" { + items, err = h.store.ListByResourceGroup(r.Context(), rp.Subscription, rp.ResourceGroup) + } else { + items, err = h.store.ListBySubscription(r.Context(), rp.Subscription) + } + + if err != nil { + azurearm.WriteCErr(w, err) + return + } + + out := listResponse{Value: make([]experimentResponse, 0, len(items))} + for i := range items { + out.Value = append(out.Value, toResponse(&items[i])) + } + + azurearm.WriteJSON(w, http.StatusOK, out) +} + +// inputFromRequest builds a create/update Input from a request body. The raw +// selectors/steps and pointer identity are carried through verbatim so an absent +// field falls back to the stored value in the driver — which makes a PATCH body, +// where every field is optional, merge correctly on its own. +func inputFromRequest(req *experimentRequest) chaosstudio.Input { + in := chaosstudio.Input{ + Tags: req.Tags, + Identity: toDriverIdentity(req.Identity), + } + + if req.Properties != nil { + in.Selectors = req.Properties.Selectors + in.Steps = req.Properties.Steps + } + + return in +} diff --git a/server/azure/chaosstudio/handler_test.go b/server/azure/chaosstudio/handler_test.go new file mode 100644 index 00000000..cbe2b440 --- /dev/null +++ b/server/azure/chaosstudio/handler_test.go @@ -0,0 +1,262 @@ +package chaosstudio_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/azure/chaosstudio" + chaosstudiosrv "github.com/stackshy/cloudemu/v2/server/azure/chaosstudio" +) + +const ( + apiVer = "?api-version=2023-11-01" + basePath = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Chaos/experiments/" +) + +type wireResp struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Location string `json:"location"` + Tags map[string]string `json:"tags"` + Identity *struct { + Type string `json:"type"` + PrincipalID string `json:"principalId"` + TenantID string `json:"tenantId"` + } `json:"identity"` + Properties struct { + ProvisioningState string `json:"provisioningState"` + Selectors json.RawMessage `json:"selectors"` + Steps json.RawMessage `json:"steps"` + } `json:"properties"` +} + +func newServer(t *testing.T) *httptest.Server { + t.Helper() + + mock := chaosstudio.New(config.NewOptions()) + h := chaosstudiosrv.New(mock) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + + return srv +} + +func do(t *testing.T, srv *httptest.Server, method, path, body string) (int, []byte) { + t.Helper() + + var rdr io.Reader + if body != "" { + rdr = bytes.NewBufferString(body) + } + + req, err := http.NewRequestWithContext(context.Background(), method, srv.URL+path, rdr) + if err != nil { + t.Fatalf("new request: %v", err) + } + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + raw, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, raw +} + +func decode(t *testing.T, raw []byte) wireResp { + t.Helper() + + var out wireResp + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v (%s)", err, raw) + } + + return out +} + +const createBody = `{ + "location": "West US", + "tags": {"env": "dev"}, + "identity": {"type": "SystemAssigned"}, + "properties": { + "selectors": [{"id":"Selector1","type":"List","filter":null,"targets":[{"id":"/subscriptions/s/resourceGroups/r/providers/Microsoft.Compute/virtualMachines/vm/providers/Microsoft.Chaos/targets/Microsoft-VirtualMachine","type":"ChaosTarget"}]}], + "steps": [{"name":"step1","branches":[{"name":"branch1","actions":[{"type":"continuous","name":"urn:csci:microsoft:virtualMachine:shutdown/1.0","selectorId":"Selector1","duration":"PT10M","parameters":[{"key":"abruptShutdown","value":"false"}]}]}]}] + } +}` + +func TestWireCreateAndComputedFields(t *testing.T) { + srv := newServer(t) + path := basePath + "exp1" + apiVer + + code, raw := do(t, srv, http.MethodPut, path, createBody) + if code != http.StatusCreated { + t.Fatalf("create status = %d, want 201 (%s)", code, raw) + } + + got := decode(t, raw) + if got.Type != "Microsoft.Chaos/experiments" { + t.Errorf("type = %q", got.Type) + } + + if got.Location != "West US" { + t.Errorf("location = %q, want West US", got.Location) + } + + if got.Properties.ProvisioningState != "Succeeded" { + t.Errorf("provisioningState = %q, want Succeeded", got.Properties.ProvisioningState) + } + + if got.Identity == nil || got.Identity.PrincipalID == "" || got.Identity.TenantID == "" { + t.Errorf("identity ids = %+v", got.Identity) + } + + // selectors/steps must round-trip so terraform's typed unmarshal succeeds. + var sel []struct { + ID string `json:"id"` + Type string `json:"type"` + } + if err := json.Unmarshal(got.Properties.Selectors, &sel); err != nil || len(sel) != 1 { + t.Fatalf("selectors did not round-trip: %v (%s)", err, got.Properties.Selectors) + } + + if sel[0].ID != "Selector1" || sel[0].Type != "List" { + t.Errorf("selector discriminator lost: %+v", sel[0]) + } +} + +func TestWireGetIsByteStable(t *testing.T) { + srv := newServer(t) + path := basePath + "exp1" + apiVer + + if code, raw := do(t, srv, http.MethodPut, path, createBody); code != http.StatusCreated { + t.Fatalf("create: %d (%s)", code, raw) + } + + _, a := do(t, srv, http.MethodGet, path, "") + _, b := do(t, srv, http.MethodGet, path, "") + + if !bytes.Equal(a, b) { + t.Errorf("GET not byte-stable:\n a=%s\n b=%s", a, b) + } +} + +func TestWirePatchMergesAndPreservesComputed(t *testing.T) { + srv := newServer(t) + path := basePath + "exp1" + apiVer + + _, raw := do(t, srv, http.MethodPut, path, createBody) + before := decode(t, raw) + + // PATCH mutates tags only. Everything else must survive. + patch := `{"tags": {"env": "prod"}}` + + code, praw := do(t, srv, http.MethodPatch, path, patch) + if code != http.StatusOK { + t.Fatalf("patch status = %d (%s)", code, praw) + } + + after := decode(t, praw) + if after.Tags["env"] != "prod" || len(after.Tags) != 1 { + t.Errorf("tags = %+v (want replaced)", after.Tags) + } + + if !bytes.Equal(after.Properties.Selectors, before.Properties.Selectors) { + t.Errorf("selectors drifted on patch:\n before=%s\n after=%s", before.Properties.Selectors, after.Properties.Selectors) + } + + if !bytes.Equal(after.Properties.Steps, before.Properties.Steps) { + t.Errorf("steps drifted on patch") + } + + if after.Identity == nil || after.Identity.PrincipalID != before.Identity.PrincipalID { + t.Errorf("identity drifted/wiped on patch: %+v", after.Identity) + } +} + +func TestWirePatchOnMissingIs404(t *testing.T) { + srv := newServer(t) + path := basePath + "nope" + apiVer + + if code, _ := do(t, srv, http.MethodPatch, path, `{"tags":{"a":"b"}}`); code != http.StatusNotFound { + t.Errorf("patch missing = %d, want 404", code) + } +} + +func TestWireDeleteIdempotent(t *testing.T) { + srv := newServer(t) + path := basePath + "exp1" + apiVer + + do(t, srv, http.MethodPut, path, createBody) + + if code, _ := do(t, srv, http.MethodDelete, path, ""); code != http.StatusOK { + t.Errorf("first delete = %d, want 200", code) + } + + if code, _ := do(t, srv, http.MethodDelete, path, ""); code != http.StatusNoContent { + t.Errorf("second delete = %d, want 204", code) + } + + if code, _ := do(t, srv, http.MethodGet, path, ""); code != http.StatusNotFound { + t.Errorf("get after delete = %d, want 404", code) + } +} + +func TestWireList(t *testing.T) { + srv := newServer(t) + + for _, n := range []string{"a", "b"} { + do(t, srv, http.MethodPut, basePath+n+apiVer, createBody) + } + + code, raw := do(t, srv, http.MethodGet, basePath+apiVer, "") + if code != http.StatusOK { + t.Fatalf("list status = %d", code) + } + + var out struct { + Value []wireResp `json:"value"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal list: %v", err) + } + + if len(out.Value) != 2 { + t.Errorf("list len = %d, want 2", len(out.Value)) + } +} + +func TestWireDefaultArrays(t *testing.T) { + srv := newServer(t) + path := basePath + "bare" + apiVer + + _, raw := do(t, srv, http.MethodPut, path, `{"location":"eastus","identity":{"type":"SystemAssigned"}}`) + got := decode(t, raw) + + if string(got.Properties.Selectors) != "[]" || string(got.Properties.Steps) != "[]" { + t.Errorf("bare create arrays = %s / %s, want [] / []", got.Properties.Selectors, got.Properties.Steps) + } +} + +func TestWireMatches(t *testing.T) { + h := chaosstudiosrv.New(chaosstudio.New(config.NewOptions())) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, basePath+"g1"+apiVer, nil) + if !h.Matches(req) { + t.Errorf("handler should match experiment path") + } + + other, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + "/subscriptions/s/resourceGroups/r/providers/Microsoft.Storage/storageAccounts/x"+apiVer, nil) + if h.Matches(other) { + t.Errorf("handler should not match storage path") + } +} diff --git a/server/azure/chaosstudio/types.go b/server/azure/chaosstudio/types.go new file mode 100644 index 00000000..4b9f9089 --- /dev/null +++ b/server/azure/chaosstudio/types.go @@ -0,0 +1,141 @@ +package chaosstudio + +import ( + "encoding/json" + + "github.com/stackshy/cloudemu/v2/providers/azure/chaosstudio" +) + +// experimentRequest is the ARM PUT/PATCH body. location, tags and identity are +// top-level; the experiment configuration lives under properties. selectors and +// steps are carried as raw JSON so the nested branch/action blocks round-trip +// byte-for-byte. +type experimentRequest struct { + Location string `json:"location,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Identity *identityRequest `json:"identity,omitempty"` + Properties *propertiesRequest `json:"properties,omitempty"` +} + +// identityRequest is the writable half of the identity block; the minted ids are +// read-only. userAssignedIdentities is a map of ARM ids to (empty) objects on +// input. +type identityRequest struct { + Type string `json:"type"` + UserAssigned map[string]json.RawMessage `json:"userAssignedIdentities,omitempty"` +} + +// propertiesRequest is the writable subset of properties. selectors and steps +// are raw so a PATCH that omits one preserves the stored value. +type propertiesRequest struct { + Selectors json.RawMessage `json:"selectors,omitempty"` + Steps json.RawMessage `json:"steps,omitempty"` +} + +// experimentResponse is the ARM representation of an experiment resource. +type experimentResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Location string `json:"location"` + Tags map[string]string `json:"tags,omitempty"` + Identity *identityResponse `json:"identity,omitempty"` + Properties propertiesResponse `json:"properties"` +} + +// identityResponse carries the identity block, including the service-minted ids. +type identityResponse struct { + Type string `json:"type"` + PrincipalID string `json:"principalId,omitempty"` + TenantID string `json:"tenantId,omitempty"` + UserAssigned map[string]userAssignedWireVal `json:"userAssignedIdentities,omitempty"` +} + +// userAssignedWireVal is the minted id pair for a user-assigned identity. +type userAssignedWireVal struct { + PrincipalID string `json:"principalId"` + ClientID string `json:"clientId"` +} + +// propertiesResponse is the properties block. provisioningState is the stable +// computed field; selectors and steps are echoed verbatim. +type propertiesResponse struct { + ProvisioningState string `json:"provisioningState"` + Selectors json.RawMessage `json:"selectors"` + Steps json.RawMessage `json:"steps"` +} + +// listResponse is the ARM list envelope. nextLink is omitted — the emulator +// returns a single page. +type listResponse struct { + Value []experimentResponse `json:"value"` +} + +// toResponse projects a stored resource onto its ARM wire representation. +func toResponse(s *chaosstudio.Experiment) experimentResponse { + return experimentResponse{ + ID: s.ARMID(), + Name: s.Name, + Type: armType, + Location: s.Location, + Tags: s.Tags, + Identity: toIdentityResponse(s.Identity), + Properties: toPropertiesResponse(s), + } +} + +func toPropertiesResponse(s *chaosstudio.Experiment) propertiesResponse { + out := propertiesResponse{ + ProvisioningState: s.ProvisioningState, + Selectors: s.Selectors, + Steps: s.Steps, + } + + // A resource restored from an older snapshot, or one created bare, may hold a + // nil array; emit a valid empty JSON array so the wire body always parses. + if out.Selectors == nil { + out.Selectors = json.RawMessage("[]") + } + + if out.Steps == nil { + out.Steps = json.RawMessage("[]") + } + + return out +} + +func toIdentityResponse(in *chaosstudio.Identity) *identityResponse { + if in == nil { + return nil + } + + out := &identityResponse{Type: in.Type, PrincipalID: in.PrincipalID, TenantID: in.TenantID} + + if len(in.UserAssigned) > 0 { + out.UserAssigned = make(map[string]userAssignedWireVal, len(in.UserAssigned)) + for id, v := range in.UserAssigned { + out.UserAssigned[id] = userAssignedWireVal{PrincipalID: v.PrincipalID, ClientID: v.ClientID} + } + } + + return out +} + +// toDriverIdentity maps a wire identity request onto the driver identity. Only +// the type and the user-assigned id keys are carried; the mock mints the ids. +func toDriverIdentity(in *identityRequest) *chaosstudio.Identity { + if in == nil { + return nil + } + + out := &chaosstudio.Identity{Type: in.Type} + + if len(in.UserAssigned) > 0 { + out.UserAssigned = make(map[string]chaosstudio.UserAssignedValue, len(in.UserAssigned)) + for id := range in.UserAssigned { + out.UserAssigned[id] = chaosstudio.UserAssignedValue{} + } + } + + return out +} diff --git a/server/azure/from_provider.go b/server/azure/from_provider.go index 8d2f4bdc..5b523ca8 100644 --- a/server/azure/from_provider.go +++ b/server/azure/from_provider.go @@ -47,6 +47,7 @@ func DriversFrom(p *azureprovider.Provider) Drivers { ManagedGrafana: p.ManagedGrafana, DevCenter: p.DevCenter, Purview: p.Purview, + ChaosStudio: p.ChaosStudio, SQLVirtualMachine: p.SQLVirtualMachine, ContainerApps: p.ContainerApps, IAM: p.IAM, diff --git a/server/azure/resourcegraph/handler.go b/server/azure/resourcegraph/handler.go index a0231353..e8a44657 100644 --- a/server/azure/resourcegraph/handler.go +++ b/server/azure/resourcegraph/handler.go @@ -483,6 +483,7 @@ var portableToAzureTypeMap = map[string]string{ //nolint:gochecknoglobals // sta "managedgrafana/Grafana": "microsoft.dashboard/grafana", "devcenter/DevCenter": "microsoft.devcenter/devcenters", "purview/Account": "microsoft.purview/accounts", + "chaosstudio/Experiment": "microsoft.chaos/experiments", } func portableToAzureType(service, typ string) string { diff --git a/server/azure/resourcegraph/kql.go b/server/azure/resourcegraph/kql.go index 92838ded..599456a1 100644 --- a/server/azure/resourcegraph/kql.go +++ b/server/azure/resourcegraph/kql.go @@ -89,6 +89,7 @@ const ( azureTypeGrafana = "microsoft.dashboard/grafana" azureTypeDevCenter = "microsoft.devcenter/devcenters" azureTypePurview = "microsoft.purview/accounts" + azureTypeChaosExp = "microsoft.chaos/experiments" ) // Portable service identifiers as emitted by the resourcediscovery walkers. @@ -123,6 +124,7 @@ const ( portableManagedGraf = "managedgrafana" portableDevCenter = "devcenter" portablePurview = "purview" + portableChaosStudio = "chaosstudio" ) // parsedKQL is the result of KQL parsing — an engine Query plus the limit @@ -412,6 +414,7 @@ var azureToPortableType = map[string]portableResourceType{ //nolint:gochecknoglo azureTypeGrafana: {portableManagedGraf, "Grafana"}, azureTypeDevCenter: {portableDevCenter, "DevCenter"}, azureTypePurview: {portablePurview, "Account"}, + azureTypeChaosExp: {portableChaosStudio, "Experiment"}, } // mapAzureType translates a fully-qualified Azure resource type to the diff --git a/services/resourcediscovery/walkers.go b/services/resourcediscovery/walkers.go index 68b267af..e88b65d6 100644 --- a/services/resourcediscovery/walkers.go +++ b/services/resourcediscovery/walkers.go @@ -79,6 +79,9 @@ const ( // ServicePurview buckets Microsoft Purview resources // (Microsoft.Purview/accounts). ServicePurview = "purview" + // ServiceChaosStudio buckets Azure Chaos Studio resources + // (Microsoft.Chaos/experiments). + ServiceChaosStudio = "chaosstudio" ) // Resource type constants emitted by the walkers. @@ -185,6 +188,10 @@ const TypeDevCenter = "DevCenter" // (Microsoft.Purview/accounts). Azure-only, so it lives in its own line. const TypePurviewAccount = "Account" +// TypeChaosExperiment is the portable type for an Azure Chaos Studio resource +// (Microsoft.Chaos/experiments). Azure-only, so it lives in its own line. +const TypeChaosExperiment = "Experiment" + // sqlVMOptInTagKey and sqlVMOptInTagValue mark a compute VM as opting in to a // paired Microsoft.SqlVirtualMachine overlay row in discovery. Only Azure VMs // carrying the tag get the overlay, so plain VMs — and every AWS/GCP VM — are