From 9fa4d03d4bbe37aeea7591b6152c0a2c12ac6f4c Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Tue, 1 Sep 2026 21:54:21 +0530 Subject: [PATCH 1/3] docs: fix wrong pulumi import resource tokens, stale cluster token-rotation claim --- docs/resources/cluster.md | 8 ++++---- docs/resources/node_policy.md | 2 +- docs/resources/node_policy_target.md | 4 ++-- docs/resources/workload_policy.md | 2 +- docs/resources/workload_policy_target.md | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/resources/cluster.md b/docs/resources/cluster.md index 29f5320..788e79e 100644 --- a/docs/resources/cluster.md +++ b/docs/resources/cluster.md @@ -74,20 +74,20 @@ const euWest = new devzero.Cluster("eu-west", { name: "production-eu-west-1" }); | Name | Type | Description | |---------|--------|-------------------------------------------------------------------------------------------| | `id` | string | Unique identifier of the cluster. Managed by the provider. | -| `token` | string | **(Secret)** Authentication token for the cluster agent. Stored encrypted in Pulumi state. Automatically rotated when the resource is imported and then updated. | +| `token` | string | **(Secret)** Authentication token for the cluster agent. Stored encrypted in Pulumi state. Not retrievable via `Read`/import — see the Import note below. | ## Import An existing cluster can be imported using its cluster ID: ```shell -pulumi import devzero:index/cluster:Cluster my-cluster +pulumi import devzero:resources:Cluster my-cluster # Example -pulumi import devzero:index/cluster:Cluster production "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +pulumi import devzero:resources:Cluster production "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ``` -> **Note:** After importing, the `token` field will be empty in state. The next `pulumi up` will automatically call `ResetClusterToken` to obtain a fresh token. +> **Note:** After importing, the `token` field will be empty in state and stays that way — the provider does not rotate it automatically on update (doing so used to silently invalidate the credential the running in-cluster agent was using whenever an unrelated field changed). If you need the token in state, rotate it deliberately from the DevZero UI, or recreate the resource. ## Notes diff --git a/docs/resources/node_policy.md b/docs/resources/node_policy.md index ca1e1ce..fd34817 100644 --- a/docs/resources/node_policy.md +++ b/docs/resources/node_policy.md @@ -313,7 +313,7 @@ Used by all instance/zone/architecture selectors: ## Import ```shell -pulumi import devzero:index/nodePolicy:NodePolicy my-policy +pulumi import devzero:resources:NodePolicy my-policy ``` > **Note:** Because there is no delete API, `pulumi destroy` only removes the resource from Pulumi state. The policy continues to exist on the DevZero platform. diff --git a/docs/resources/node_policy_target.md b/docs/resources/node_policy_target.md index bfff4f6..723ef7b 100644 --- a/docs/resources/node_policy_target.md +++ b/docs/resources/node_policy_target.md @@ -127,10 +127,10 @@ func main() { An existing node policy target can be imported using its target ID: ```shell -pulumi import devzero:index/nodePolicyTarget:NodePolicyTarget my-target +pulumi import devzero:resources:NodePolicyTarget my-target # Example -pulumi import devzero:index/nodePolicyTarget:NodePolicyTarget production "c84ccd96-d3f6-439d-9976-360577123fe0" +pulumi import devzero:resources:NodePolicyTarget production "c84ccd96-d3f6-439d-9976-360577123fe0" ``` ## Notes diff --git a/docs/resources/workload_policy.md b/docs/resources/workload_policy.md index f0189da..a9ee90b 100644 --- a/docs/resources/workload_policy.md +++ b/docs/resources/workload_policy.md @@ -162,5 +162,5 @@ Each of `cpuVerticalScaling`, `memoryVerticalScaling`, `gpuVerticalScaling`, and ## Import ```shell -pulumi import devzero:index/workloadPolicy:WorkloadPolicy my-policy +pulumi import devzero:resources:WorkloadPolicy my-policy ``` diff --git a/docs/resources/workload_policy_target.md b/docs/resources/workload_policy_target.md index 338ff5f..8b57a9f 100644 --- a/docs/resources/workload_policy_target.md +++ b/docs/resources/workload_policy_target.md @@ -124,5 +124,5 @@ target = devzero.WorkloadPolicyTarget("production-target", ## Import ```shell -pulumi import devzero:index/workloadPolicyTarget:WorkloadPolicyTarget my-target +pulumi import devzero:resources:WorkloadPolicyTarget my-target ``` From 6307515f17439a9c4c10f6a9bbb14289e2c1405d Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Tue, 1 Sep 2026 21:54:33 +0530 Subject: [PATCH 2/3] docs: add import documentation for WorkloadRule --- docs/resources/workload_rule.md | 194 ++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/resources/workload_rule.md diff --git a/docs/resources/workload_rule.md b/docs/resources/workload_rule.md new file mode 100644 index 0000000..1b5dc5e --- /dev/null +++ b/docs/resources/workload_rule.md @@ -0,0 +1,194 @@ +# devzero:resources:WorkloadRule + +Pins explicit resource rules directly to a single Kubernetes workload (a specific `kind`/`namespace`/`name` on a cluster). Unlike `WorkloadPolicy`, which applies a shared policy to many workloads via a `WorkloadPolicyTarget`, a `WorkloadRule` targets one workload and lets you override CPU, memory, GPU, and HPA settings with precise values — or set `autoGenerate: true` to let the engine compute them from observed usage. + +## Example Usage + +### Minimal example (auto-generated) + +```typescript +import * as devzero from "@devzero/pulumi-provider-devzero"; + +const rule = new devzero.WorkloadRule("my-app-rule", { + clusterId: "cluster-abc123", + namespace: "production", + kind: "Deployment", + name: "my-api", + autoGenerate: true, +}); +``` + +### Manual CPU, memory, and emergency-response rules + +```typescript +import * as devzero from "@devzero/pulumi-provider-devzero"; + +const rule = new devzero.WorkloadRule("my-app-rule", { + clusterId: "cluster-abc123", + namespace: "production", + kind: "Deployment", + name: "my-api", + + actionTriggers: ["on_schedule", "on_detection"], + cronSchedule: "0 2 * * *", + detectionTriggers: ["pod_creation", "pod_update"], + + cpuRule: { + enabled: true, + minRequest: 10, // millicores + maxRequest: 32000, // millicores (32 cores) + targetPercentile: 0.95, // P95 of observed CPU usage + limitsAdjustmentEnabled: true, + limitMultiplier: 1.0, + }, + memoryRule: { + enabled: true, + minRequest: 67108864, // 64 MiB + maxRequest: 68719476736, // 64 GiB + targetPercentile: 0.95, + limitsAdjustmentEnabled: true, + }, + emergencyResponse: { + oomEnabled: true, + oomMemoryMultiplier: 1.5, + cpuThrottlingEnabled: true, + cpuThrottlingThreshold: 0.20, + cpuThrottlingMultiplier: 1.25, + }, +}); + +export const ruleId = rule.id; +``` + +```python +import pulumi_devzero as devzero + +rule = devzero.WorkloadRule( + "my-app-rule", + cluster_id="cluster-abc123", + namespace="production", + kind="Deployment", + name="my-api", + cpu_rule=devzero.ResourceRuleConfigArgs( + enabled=True, + min_request=10, + max_request=32000, + target_percentile=0.95, + limits_adjustment_enabled=True, + ), +) +``` + +## Schema + +### Required + +| Name | Type | Description | +|-------------|--------|---------------------------------------------------------------------------------------| +| `clusterId` | string | ID of the cluster the workload lives in. | +| `namespace` | string | Kubernetes namespace of the workload. | +| `kind` | string | Workload kind. Values: `"Deployment"`, `"StatefulSet"`, `"DaemonSet"`, `"CronJob"`, `"Job"`. | +| `name` | string | Name of the Kubernetes workload. | + +### Optional — Rules + +| Name | Type | Description | +|---------------------|----------------------------|----------------------------------------------------------------------------------| +| `autoGenerate` | `boolean` | When `true`, the engine fills all rule fields from observed usage; manual field overrides below are ignored. | +| `cpuRule` | `ResourceRuleConfigArgs` | CPU vertical scaling rule. | +| `memoryRule` | `ResourceRuleConfigArgs` | Memory vertical scaling rule. | +| `gpuRule` | `ResourceRuleConfigArgs` | GPU vertical scaling rule (units: GPU millicores). | +| `hpaRule` | `HPARuleConfigArgs` | Horizontal (replica) scaling rule. | +| `emergencyResponse` | `EmergencyResponseConfigArgs` | OOM and CPU-throttle emergency reactions. | +| `containers` | `ContainerResourceRuleConfigArgs[]` | Per-container resource overrides. When empty, workload-level rules apply to all containers. | +| `disabled` | `boolean` | Whether the rule is currently disabled. | + +### Optional — Triggers & Timing + +| Name | Type | Description | +|---------------------------|------------|-----------------------------------------------------------------------------------| +| `actionTriggers` | `string[]` | When to apply recommendations. Values: `"on_detection"`, `"on_schedule"`. | +| `cronSchedule` | `string` | Cron expression for scheduled application (5-field UTC). Required when `actionTriggers` includes `"on_schedule"`. | +| `detectionTriggers` | `string[]` | Events that trigger a recommendation. Values: `"pod_creation"`, `"pod_update"`, `"pod_reschedule"`. | +| `startupPeriodSeconds` | `number` | Seconds after workload start to exclude from usage data. | +| `cooldownMinutes` | `number` | Minimum minutes between consecutive recommendation applications. | +| `lookbackPeriodSeconds` | `number` | Seconds to look back for resource usage data. | + +### Optional — Scaling Behaviour + +| Name | Type | Description | +|-----------------------------|-----------|--------------------------------------------------------------------------| +| `schedulerPlugins` | `string[]`| Kubernetes scheduler plugins to activate. Example: `["binpacking"]`. | +| `defragmentationSchedule` | `string` | Cron expression for background node defragmentation. | +| `liveMigrationEnabled` | `boolean` | Allow live pod migration when applying recommendations without a restart.| +| `useInPlaceVerticalScaling` | `boolean` | Use in-place pod vertical scaling instead of pod restarts. | + +### `ResourceRuleConfigArgs` + +Used for `cpuRule`, `memoryRule`, and `gpuRule` at both the workload and per-container level. `maxScaleUpPercent`/`maxScaleDownPercent` are **not** supported on per-container rules. + +| Field | Type | Description | +|---------------------------|-----------|---------------------------------------------------------------------| +| `enabled` | `boolean` | Enable this resource axis rule. | +| `minRequest` | `number` | Minimum resource request (millicores for CPU, bytes for memory/GPU).| +| `maxRequest` | `number` | Maximum resource request. | +| `targetPercentile` | `number` | Percentile of observed usage to target (0–1). Example: `0.95`. | +| `maxScaleUpPercent` | `number` | Maximum % to scale up in one step *(workload-level only)*. | +| `maxScaleDownPercent` | `number` | Maximum % to scale down in one step *(workload-level only)*. | +| `limitsAdjustmentEnabled` | `boolean` | Whether to also adjust resource limits. | +| `limitMultiplier` | `number` | Limits = request × `limitMultiplier`. | +| `limitsRemovalEnabled` | `boolean` | Actively remove limits from workloads (CPU only). | + +### `HPARuleConfigArgs` + +| Field | Type | Description | +|----------------------------|-----------|------------------------------------------------------------------------------------------------| +| `enabled` | `boolean` | Enable horizontal (replica) scaling. | +| `minReplicas` | `number` | Minimum number of replicas. | +| `maxReplicas` | `number` | Maximum number of replicas. | +| `maxReplicaChangePercent` | `number` | Maximum percentage change in replica count per cycle. | +| `scaleDownCooldownSeconds` | `number` | Seconds to wait between scale-down events. | +| `metrics` | `HPAMetricTriggerArgs[]` | External metric triggers only (Prometheus, queue depth, etc). CPU/Memory/Network are engine-generated. | +| `compositeFormula` | `string` | Expression combining multiple metric ratios into one scaling signal. Example: `"0.6*cpu + 0.4*memory"`. | +| `behavior` | `HPABehaviorArgs` | Fine-grained scale-up and scale-down behavior policies. | +| `fallback` | `HPAFallbackArgs` | Replica fallback when metrics become unavailable. | + +### `EmergencyResponseConfigArgs` + +| Field | Type | Description | +|---------------------------|-----------|---------------------------------------------------------------------| +| `oomEnabled` | `boolean` | React to OOM kills by increasing the memory request. | +| `oomMemoryMultiplier` | `number` | Multiplier applied to memory on each OOM event. | +| `oomMaxReactions` | `number` | Maximum OOM reactions before giving up. | +| `oomCooldownSeconds` | `number` | Seconds to wait between OOM reactions. | +| `cpuThrottlingEnabled` | `boolean` | React to CPU throttling by increasing the CPU request. | +| `cpuThrottlingThreshold` | `number` | Throttle ratio threshold that triggers a reaction (0–1). | +| `cpuThrottlingMultiplier` | `number` | Multiplier applied to the CPU request on a throttle reaction. | + +### `ContainerResourceRuleConfigArgs` + +| Field | Type | Description | +|-----------------|--------------------------|-----------------------------------------------------------| +| `containerName` | `string` | Name of the container this config applies to. | +| `cpuRule` | `ResourceRuleConfigArgs` | CPU resource rule for this container. | +| `memoryRule` | `ResourceRuleConfigArgs` | Memory resource rule for this container. | +| `gpuRule` | `ResourceRuleConfigArgs` | GPU resource rule for this container. | + +### Read-Only + +| Name | Type | Description | +|------|--------|------------------------------------------------------------| +| `id` | string | Unique identifier of the rule. Managed by the provider. | + +## Import + +An existing workload rule can be imported using its rule ID: + +```shell +pulumi import devzero:resources:WorkloadRule my-app-rule + +# Example +pulumi import devzero:resources:WorkloadRule production-api "b7f3c1a2-9d4e-4a1b-8c3f-2e5d6f7a8b90" +``` + +> **Note:** `autoGenerate` is not stored as its own field on the API side — it is inferred on every `Read` (including at import time) from the rule's `current_source`: `true` when the rule is engine-managed (`auto_optimization`), unset otherwise. This keeps an imported rule's mode consistent with how it actually behaves on the platform. From 4c47bf619d411c5a0fa16350da22424d8c90b835 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Tue, 1 Sep 2026 21:54:48 +0530 Subject: [PATCH 3/3] test: cover pulumi-import lifecycle for all six resource types --- provider/pkg/resources/fake_server_test.go | 14 + provider/pkg/resources/import_test.go | 406 +++++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 provider/pkg/resources/import_test.go diff --git a/provider/pkg/resources/fake_server_test.go b/provider/pkg/resources/fake_server_test.go index 347b5de..902ea65 100644 --- a/provider/pkg/resources/fake_server_test.go +++ b/provider/pkg/resources/fake_server_test.go @@ -172,6 +172,20 @@ func (f *fakeBackend) GetWorkloadRecommendationPolicy(_ context.Context, req *co return connect.NewResponse(&apiv1.GetWorkloadRecommendationPolicyResponse{Policy: p}), nil } +func (f *fakeBackend) UpdateWorkloadRecommendationPolicy(_ context.Context, req *connect.Request[apiv1.UpdateWorkloadRecommendationPolicyRequest]) (*connect.Response[apiv1.UpdateWorkloadRecommendationPolicyResponse], error) { + f.mu.Lock() + defer f.mu.Unlock() + p := req.Msg.Policy + if p == nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("policy required")) + } + if _, ok := f.wp[p.PolicyId]; !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("workload policy not found")) + } + f.wp[p.PolicyId] = p + return connect.NewResponse(&apiv1.UpdateWorkloadRecommendationPolicyResponse{Policy: p}), nil +} + func (f *fakeBackend) DeleteWorkloadRecommendationPolicy(_ context.Context, req *connect.Request[apiv1.DeleteWorkloadRecommendationPolicyRequest]) (*connect.Response[apiv1.DeleteWorkloadRecommendationPolicyResponse], error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/provider/pkg/resources/import_test.go b/provider/pkg/resources/import_test.go new file mode 100644 index 0000000..05ada9b --- /dev/null +++ b/provider/pkg/resources/import_test.go @@ -0,0 +1,406 @@ +package resources + +import ( + "context" + "reflect" + "testing" + + "connectrpc.com/connect" + "github.com/pulumi/pulumi-go-provider/infer" + + apiv1 "github.com/devzero-inc/pulumi-provider-devzero/internal/gen/api/v1" +) + +// --------------------------------------------------------------------------- +// Import-lifecycle harness. +// +// `pulumi import` calls Read with only an ID — Inputs and State are the +// zero value, since there is no prior Pulumi state for a resource created +// out-of-band (e.g. via the DevZero UI). These tests simulate exactly that +// entry point, then prove the three things the import ticket cares about: +// +// 1. hydration — every writable field the backend holds comes back in Inputs. +// 2. clean plan — feeding the imported Inputs/State back into Read is a +// fixed point, i.e. a `pulumi preview` run immediately after import +// would compute no diff (the default infer.Diff is a structural +// comparison of Inputs, so a fixed point on Read is exactly what a +// clean plan requires). WorkloadRule additionally gets a direct Diff() +// check since it defines a custom Diff. +// 3. round-trip — modifying one field and calling Update propagates to +// the backend. +// --------------------------------------------------------------------------- + +func TestImport_NodePolicy(t *testing.T) { + ctx := context.Background() + fake := withFakeServer(t) + n := &NodePolicy{} + + var seed NodePolicyArgs + populateArgs(t, reflect.ValueOf(&seed).Elem(), "") + seed.Raw = nil // write-only passthrough, excluded like the round-trip test + + seedProto := nodePolicyArgsToProto("team-1", "np-import", seed) + fake.mu.Lock() + fake.nodePol[seedProto.Id] = seedProto + fake.mu.Unlock() + + imported, err := n.Read(ctx, infer.ReadRequest[NodePolicyArgs, NodePolicyState]{ID: seedProto.Id}) + if err != nil { + t.Fatalf("Read (import): %v", err) + } + want := nodePolicyProtoToArgs(seedProto) + if !reflect.DeepEqual(imported.Inputs, want) { + t.Fatalf("import did not hydrate all fields:\n got: %+v\n want: %+v", imported.Inputs, want) + } + + again, err := n.Read(ctx, infer.ReadRequest[NodePolicyArgs, NodePolicyState]{ + ID: seedProto.Id, Inputs: imported.Inputs, State: imported.State, + }) + if err != nil { + t.Fatalf("Read (post-import): %v", err) + } + if !reflect.DeepEqual(again.Inputs, imported.Inputs) { + t.Fatalf("post-import plan is not clean — Read is not a fixed point:\n first: %+v\n second: %+v", imported.Inputs, again.Inputs) + } + + changed := imported.Inputs + changed.Name = "renamed-after-import" + updated, err := n.Update(ctx, infer.UpdateRequest[NodePolicyArgs, NodePolicyState]{ + ID: seedProto.Id, Inputs: changed, State: imported.State, + }) + if err != nil { + t.Fatalf("Update (after import): %v", err) + } + if updated.Output.Name != "renamed-after-import" { + t.Fatalf("update after import did not apply: %+v", updated.Output) + } + fake.mu.Lock() + stored := fake.nodePol[seedProto.Id] + fake.mu.Unlock() + if stored.Name != "renamed-after-import" { + t.Fatalf("update after import was not persisted backend-side: %+v", stored) + } +} + +func TestImport_NodePolicyTarget(t *testing.T) { + ctx := context.Background() + fake := withFakeServer(t) + n := &NodePolicyTarget{} + + var seed NodePolicyTargetArgs + populateArgs(t, reflect.ValueOf(&seed).Elem(), "") + seed.ClusterIds = []string{"cluster-1"} // node policy targets accept at most one cluster + + seedProto := nodePolicyTargetArgsToProto("team-1", "npt-import", seed) + fake.mu.Lock() + fake.nodeTgt[seedProto.TargetId] = seedProto + fake.mu.Unlock() + + imported, err := n.Read(ctx, infer.ReadRequest[NodePolicyTargetArgs, NodePolicyTargetState]{ID: seedProto.TargetId}) + if err != nil { + t.Fatalf("Read (import): %v", err) + } + want := nodePolicyTargetProtoToArgs(seedProto) + if !reflect.DeepEqual(imported.Inputs, want) { + t.Fatalf("import did not hydrate all fields:\n got: %+v\n want: %+v", imported.Inputs, want) + } + + again, err := n.Read(ctx, infer.ReadRequest[NodePolicyTargetArgs, NodePolicyTargetState]{ + ID: seedProto.TargetId, Inputs: imported.Inputs, State: imported.State, + }) + if err != nil { + t.Fatalf("Read (post-import): %v", err) + } + if !reflect.DeepEqual(again.Inputs, imported.Inputs) { + t.Fatalf("post-import plan is not clean:\n first: %+v\n second: %+v", imported.Inputs, again.Inputs) + } + + changed := imported.Inputs + changed.Name = "renamed-after-import" + updated, err := n.Update(ctx, infer.UpdateRequest[NodePolicyTargetArgs, NodePolicyTargetState]{ + ID: seedProto.TargetId, Inputs: changed, State: imported.State, + }) + if err != nil { + t.Fatalf("Update (after import): %v", err) + } + if updated.Output.Name != "renamed-after-import" { + t.Fatalf("update after import did not apply: %+v", updated.Output) + } + fake.mu.Lock() + stored := fake.nodeTgt[seedProto.TargetId] + fake.mu.Unlock() + if stored.Name != "renamed-after-import" { + t.Fatalf("update after import was not persisted backend-side: %+v", stored) + } +} + +func TestImport_WorkloadPolicy(t *testing.T) { + ctx := context.Background() + fake := withFakeServer(t) + w := &WorkloadPolicy{} + + var seed WorkloadPolicyArgs + populateArgs(t, reflect.ValueOf(&seed).Elem(), "") + + seedProto := argsToProto("team-1", "wp-import", seed) + fake.mu.Lock() + fake.wp[seedProto.PolicyId] = seedProto + fake.mu.Unlock() + + imported, err := w.Read(ctx, infer.ReadRequest[WorkloadPolicyArgs, WorkloadPolicyState]{ID: seedProto.PolicyId}) + if err != nil { + t.Fatalf("Read (import): %v", err) + } + want := protoToArgs(seedProto) + if !reflect.DeepEqual(imported.Inputs, want) { + t.Fatalf("import did not hydrate all fields:\n got: %+v\n want: %+v", imported.Inputs, want) + } + + again, err := w.Read(ctx, infer.ReadRequest[WorkloadPolicyArgs, WorkloadPolicyState]{ + ID: seedProto.PolicyId, Inputs: imported.Inputs, State: imported.State, + }) + if err != nil { + t.Fatalf("Read (post-import): %v", err) + } + if !reflect.DeepEqual(again.Inputs, imported.Inputs) { + t.Fatalf("post-import plan is not clean:\n first: %+v\n second: %+v", imported.Inputs, again.Inputs) + } + + changed := imported.Inputs + changed.Name = "renamed-after-import" + updated, err := w.Update(ctx, infer.UpdateRequest[WorkloadPolicyArgs, WorkloadPolicyState]{ + ID: seedProto.PolicyId, Inputs: changed, State: imported.State, + }) + if err != nil { + t.Fatalf("Update (after import): %v", err) + } + if updated.Output.Name != "renamed-after-import" { + t.Fatalf("update after import did not apply: %+v", updated.Output) + } + fake.mu.Lock() + stored := fake.wp[seedProto.PolicyId] + fake.mu.Unlock() + if stored.Name != "renamed-after-import" { + t.Fatalf("update after import was not persisted backend-side: %+v", stored) + } +} + +func TestImport_WorkloadPolicyTarget(t *testing.T) { + ctx := context.Background() + fake := withFakeServer(t) + w := &WorkloadPolicyTarget{} + + var seed WorkloadPolicyTargetArgs + populateArgs(t, reflect.ValueOf(&seed).Elem(), "") + + req := targetArgsToCreateRequest("team-1", seed) + seedProto := &apiv1.WorkloadPolicyTarget{ + TargetId: "wpt-import", + PolicyId: req.PolicyId, + TeamId: req.TeamId, + Name: req.Name, + Description: req.Description, + Priority: req.Priority, + Enabled: req.Enabled, + NamespaceSelector: req.NamespaceSelector, + WorkloadSelector: req.WorkloadSelector, + AnnotationSelector: req.AnnotationSelector, + KindFilter: req.KindFilter, + KindFilterNotIn: req.KindFilterNotIn, + NamePattern: req.NamePattern, + NamespacePattern: req.NamespacePattern, + WorkloadNames: req.WorkloadNames, + WorkloadNamesNotIn: req.WorkloadNamesNotIn, + ClusterIds: req.ClusterIds, + } + seedProto.NodeGroupNames = req.NodeGroupNames //nolint:staticcheck // deprecated upstream but still round-tripped + fake.mu.Lock() + fake.wpt[seedProto.TargetId] = seedProto + fake.mu.Unlock() + + imported, err := w.Read(ctx, infer.ReadRequest[WorkloadPolicyTargetArgs, WorkloadPolicyTargetState]{ID: seedProto.TargetId}) + if err != nil { + t.Fatalf("Read (import): %v", err) + } + want := targetProtoToArgs(seedProto) + if !reflect.DeepEqual(imported.Inputs, want) { + t.Fatalf("import did not hydrate all fields:\n got: %+v\n want: %+v", imported.Inputs, want) + } + + again, err := w.Read(ctx, infer.ReadRequest[WorkloadPolicyTargetArgs, WorkloadPolicyTargetState]{ + ID: seedProto.TargetId, Inputs: imported.Inputs, State: imported.State, + }) + if err != nil { + t.Fatalf("Read (post-import): %v", err) + } + if !reflect.DeepEqual(again.Inputs, imported.Inputs) { + t.Fatalf("post-import plan is not clean:\n first: %+v\n second: %+v", imported.Inputs, again.Inputs) + } + + changed := imported.Inputs + changed.Name = "renamed-after-import" + updated, err := w.Update(ctx, infer.UpdateRequest[WorkloadPolicyTargetArgs, WorkloadPolicyTargetState]{ + ID: seedProto.TargetId, Inputs: changed, State: imported.State, + }) + if err != nil { + t.Fatalf("Update (after import): %v", err) + } + if updated.Output.Name != "renamed-after-import" { + t.Fatalf("update after import did not apply: %+v", updated.Output) + } + fake.mu.Lock() + stored := fake.wpt[seedProto.TargetId] + fake.mu.Unlock() + if stored.Name != "renamed-after-import" { + t.Fatalf("update after import was not persisted backend-side: %+v", stored) + } +} + +func TestImport_WorkloadRule(t *testing.T) { + ctx := context.Background() + fake := withFakeServer(t) + w := &WorkloadRule{} + + var seed WorkloadRuleArgs + populateArgs(t, reflect.ValueOf(&seed).Elem(), "") + auto := false + seed.AutoGenerate = &auto // manual mode, so Fields get populated below + + req := ruleArgsToUpsertRequest("team-1", seed, true) + f := req.Fields + seedProto := &apiv1.WorkloadRule{ + RuleId: "wr-import", + ClusterId: req.ClusterId, + Namespace: req.Namespace, + Kind: req.Kind, + Name: req.Name, + CurrentSource: "pulumi_manual", + CpuRule: f.CpuRule, + MemoryRule: f.MemoryRule, + GpuRule: f.GpuRule, + HpaRule: f.HpaRule, + EmergencyResponse: f.EmergencyResponse, + ActionTriggers: f.ActionTriggers, + DetectionTriggers: f.DetectionTriggers, + SchedulerPlugins: f.SchedulerPlugins, + LiveMigrationEnabled: f.LiveMigrationEnabled, + UseInPlaceVerticalScaling: f.UseInPlaceVerticalScaling, + Containers: f.Containers, + StartupPeriodSeconds: f.StartupPeriodSeconds, + CronSchedule: f.CronSchedule, + CooldownMinutes: f.CooldownMinutes, + DefragmentationSchedule: f.DefragmentationSchedule, + LookbackPeriodSeconds: f.LookbackPeriodSeconds, + } + if f.Disabled != nil { + seedProto.Disabled = *f.Disabled + } + fake.mu.Lock() + fake.rules[seedProto.RuleId] = seedProto + fake.mu.Unlock() + + imported, err := w.Read(ctx, infer.ReadRequest[WorkloadRuleArgs, WorkloadRuleState]{ID: seedProto.RuleId}) + if err != nil { + t.Fatalf("Read (import): %v", err) + } + want := ruleProtoToArgs(seedProto) + if !reflect.DeepEqual(imported.Inputs, want) { + t.Fatalf("import did not hydrate all fields:\n got: %+v\n want: %+v", imported.Inputs, want) + } + + // Clean-plan check via the resource's own Diff (WorkloadRule overrides + // the default structural diff), not just a second Read. + diffResp, err := w.Diff(ctx, infer.DiffRequest[WorkloadRuleArgs, WorkloadRuleState]{ + ID: seedProto.RuleId, State: imported.State, Inputs: imported.Inputs, + }) + if err != nil { + t.Fatalf("Diff (post-import): %v", err) + } + if diffResp.HasChanges { + t.Fatalf("post-import plan is not clean: %+v", diffResp.DetailedDiff) + } + + changed := imported.Inputs + cron := "*/15 * * * *" + changed.CronSchedule = &cron + updated, err := w.Update(ctx, infer.UpdateRequest[WorkloadRuleArgs, WorkloadRuleState]{ + ID: seedProto.RuleId, Inputs: changed, State: imported.State, + }) + if err != nil { + t.Fatalf("Update (after import): %v", err) + } + if updated.Output.CronSchedule == nil || *updated.Output.CronSchedule != cron { + t.Fatalf("update after import did not apply: %+v", updated.Output) + } + fake.mu.Lock() + stored := fake.rules[seedProto.RuleId] + fake.mu.Unlock() + if stored.CronSchedule == nil || *stored.CronSchedule != cron { + t.Fatalf("update after import was not persisted backend-side: %+v", stored) + } +} + +func TestImport_Cluster(t *testing.T) { + ctx := context.Background() + c := &Cluster{} + + const clusterID = "cl-import" + backend := map[string]*apiv1.Cluster{ + clusterID: {Id: clusterID, CustomName: "ui-created-cluster"}, + } + + k8s := &mockK8SClient{ + getClusterFn: func(_ context.Context, req *connect.Request[apiv1.GetClusterRequest]) (*connect.Response[apiv1.GetClusterResponse], error) { + cl, ok := backend[req.Msg.ClusterId] + if !ok { + return nil, connect.NewError(connect.CodeNotFound, nil) + } + return connect.NewResponse(&apiv1.GetClusterResponse{Cluster: cl}), nil + }, + } + mut := &mockMutationClient{ + updateFn: func(_ context.Context, req *connect.Request[apiv1.UpdateClusterRequest]) (*connect.Response[apiv1.UpdateClusterResponse], error) { + cl := backend[req.Msg.ClusterId] + cl.CustomName = req.Msg.ClusterName + return connect.NewResponse(&apiv1.UpdateClusterResponse{Cluster: cl}), nil + }, + } + withMockClientSet(t, mut, k8s) + + imported, err := c.Read(ctx, infer.ReadRequest[ClusterArgs, ClusterState]{ID: clusterID}) + if err != nil { + t.Fatalf("Read (import): %v", err) + } + if imported.Inputs.Name != "ui-created-cluster" { + t.Fatalf("import did not hydrate name: %+v", imported.Inputs) + } + if imported.State.Token != "" { + t.Fatalf("expected empty token on a freshly imported cluster, got %q", imported.State.Token) + } + + again, err := c.Read(ctx, infer.ReadRequest[ClusterArgs, ClusterState]{ + ID: clusterID, Inputs: imported.Inputs, State: imported.State, + }) + if err != nil { + t.Fatalf("Read (post-import): %v", err) + } + if !reflect.DeepEqual(again.Inputs, imported.Inputs) || again.State.Token != imported.State.Token { + t.Fatalf("post-import plan is not clean:\n first: %+v/%q\n second: %+v/%q", + imported.Inputs, imported.State.Token, again.Inputs, again.State.Token) + } + + changed := imported.Inputs + changed.Name = "renamed-after-import" + updated, err := c.Update(ctx, infer.UpdateRequest[ClusterArgs, ClusterState]{ + ID: clusterID, Inputs: changed, State: imported.State, + }) + if err != nil { + t.Fatalf("Update (after import): %v", err) + } + if updated.Output.Name != "renamed-after-import" { + t.Fatalf("update after import did not apply: %+v", updated.Output) + } + if backend[clusterID].CustomName != "renamed-after-import" { + t.Fatalf("update after import was not persisted backend-side: %+v", backend[clusterID]) + } +}