Skip to content

Planner

The Planner takes the diff output and the dependency graph and produces an ordered execution plan. It groups changes by graph level for parallel execution.

class Planner {
generatePlan(config: Config, state: StateFile, graph: Graph, diffs: DiffResult): Plan
}
Input Source Description
config User’s infrastructure.ts Desired resource definitions
state .kyku/state.<env>.json Last known cloud state
graph GraphBuilder.build() Dependency graph with levels
diffs DiffEngine.diff() Changes per resource
interface Plan {
changes: Change[]; // Ordered by graph level
counters: PlanCounters; // Summary: creates, updates, replaces, destroys
graph: Graph; // Dependency graph for reference
environment: string; // Workspace name
}
interface PlanCounters {
create: number;
update: number;
replace: number;
destroy: number;
noop: number;
}

The planner decides what’s new vs existing based on state:

  • Has providerId in state → queries cloud via provider.readState(id, providerId) to detect drift
  • No providerId in state → always shows create (never queries cloud)

This is critical for idempotency. Querying the cloud for resources not in state would cause spurious “no-op” results.

Changes are ordered by graph level:

Level 0: [create Vpc, create SshKey] ← parallel
Level 1: [create Subnet, create SecurityGroup]
Level 2: [create Vm, create Database]
Level 3: [create LoadBalancer]

Each level waits for all lower levels to complete. Resources within a level execute in parallel (up to --parallelism limit).

Destroy follows reverse level order:

Level 3: [destroy LoadBalancer]
Level 2: [destroy Vm, destroy Database]
Level 1: [destroy SecurityGroup, destroy Subnet]
Level 0: [destroy Vpc, destroy SshKey]

Plans can be serialized to JSON files and loaded later:

Terminal window
# Save plan
kyku plan --out plan.json
# Apply from saved plan
kyku apply --plan plan.json

The engine’s apply() accepts an optional plan?: Plan 4th parameter — if provided, it uses pre-computed changes and skips the plan() call.

Every provider create() method checks if the resource already exists before creating:

async create(resource: Vpc): Promise<CloudState> {
const existing = await client.get(`/resources?name=${resource.name}`);
if (existing.resources?.length > 0) {
return this.read(resource, String(existing.resources[0].id));
}
// ... create new
}

Exception: Purely logical resources (e.g., TargetGroup that just defines a label) don’t need this check.

Never pass Kyku resource UUIDs to cloud APIs.

ID Type Format Example
Kyku ID UUID/string "hetzner-test" or "p1s9hutn"
Cloud ID Provider-native 12224210 (Hetzner), vpc-0abc123 (AWS)

The readState by name is the fallback for resolving cloud IDs from Kyku references.

If stateResource.providerId is non-numeric (stale Kyku ID from early versions), look it up via provider.readState(stub) before passing to provider.destroy().

Human-readable plan output:

Plan: 3 to create, 1 to update, 0 to replace, 1 to destroy, 2 no-op
Changes:
+ create Vpc "main-vpc" (level 0)
+ create SecurityGroup "web-sg" (level 1)
+ create Vm "web-server" (level 2)
~ update LoadBalancer "web-lb" (level 3)
port: 80 → 443
- destroy Vm "old-worker" (level 2)
✓ no-op Database "app-db"

JSON output with --json flag:

{
"changes": [
{ "action": "create", "resourceId": "vpc-main", "level": 0 },
{ "action": "create", "resourceId": "web-sg", "level": 1 }
],
"counters": { "create": 3, "update": 1, "destroy": 1, "replace": 0, "noop": 2 }
}