Diff Engine
The DiffEngine compares the desired configuration (defined in your infrastructure.ts) against the actual cloud state (returned by provider.readState()). It produces a set of changes that the planner uses to build the execution plan.
How It Works
Section titled “How It Works”1. resourceToConfig()
Section titled “1. resourceToConfig()”The engine extracts a plain config object from each BaseResource, excluding “known” keys that are not part of the resource’s configuration:
// KNOWN_RESOURCE_KEYS = {outputs, type, id, name, provider, tags, labels}// These are SKIPPED — don't bother returning them from read()/create()
// For Vm, resourceToConfig() produces:config: { instanceType: vm.instanceType, // abstract string image: vm.image, // abstract string network: typeof vm.network === 'string' ? vm.network : vm.network.id, subnet: typeof vm.subnet === 'string' ? vm.subnet : vm.subnet?.id, securityGroups: vm.securityGroups?.map(sg => typeof sg === 'string' ? sg : sg.id ) ?? [], sshPublicKey: vm.sshPublicKey, sshKey: typeof vm.sshKey === 'string' ? vm.sshKey : vm.sshKey?.id, userData: vm.userData, distributeAcrossAzs: vm.distributeAcrossAzs,}Conversion rules:
BaseResourceobjects → their.idstringBaseResource[]arrays → array of.idstringsstringvalues → as-isundefinedvalues → as-is (both sides must agree on undefined)
2. diff()
Section titled “2. diff()”The diff() method calls resourceToConfig() on the desired resource and compares it via JSON.stringify against the actual config from provider.readState().
diff(resource: BaseResource, actual: CloudState): Change[] { const desired = this.resourceToConfig(resource); const actualConfig = actual.config;
if (JSON.stringify(desired) === JSON.stringify(actualConfig)) { return []; // no-op }
// Detect property-level changes const changes: PropertyChange[] = []; for (const key of Object.keys(desired)) { if (JSON.stringify(desired[key]) !== JSON.stringify(actualConfig[key])) { changes.push({ property: key, oldValue: actualConfig[key], newValue: desired[key], action: this.isImmutable(resource.type, key) ? 'replace' : 'update', }); } } return changes;}3. Immutable Property Detection
Section titled “3. Immutable Property Detection”Some properties cannot be changed in-place. The diff engine detects these and marks them as replace (destroy + recreate):
| Resource | Immutable Properties |
|---|---|
| Vpc | cidr, region |
| Subnet | cidr, vpc |
| Vm | image, instanceType, network |
| SecurityGroup | name (some providers) |
| LoadBalancer | lbType, vpc |
| Database | engine, version, vpc |
| Identity | name |
| Role | name |
Provider Integration
Section titled “Provider Integration”Provider read() Must Match resourceToConfig()
Section titled “Provider read() Must Match resourceToConfig()”This is the most important contract in the provider interface:
// Provider.read() must return a config with identical keys to resourceToConfig()async read(resource: Vpc, providerId: string): Promise<CloudState> { return { providerId: 'vpc-0abc123', config: { cidr: '10.0.0.0/16', // matches region: 'us-east', // matches distributeAcrossAzs: 2, // matches name: 'main-vpc', // SKIPPED by diff (KNOWN_RESOURCE_KEYS) }, };}If read() returns fewer keys than resourceToConfig(), the next plan will show drift and trigger a spurious update.
Provider create() Must Also Match
Section titled “Provider create() Must Also Match”Both read() and create() return CloudState with a config property. Both must use the same config shape:
async create(resource: Vpc): Promise<CloudState> { // ... create resource ... return { providerId: 'vpc-0abc123', config: { cidr: '10.0.0.0/16', // SAME keys as read() region: 'us-east', distributeAcrossAzs: 2, name: 'main-vpc', }, outputs: { vpcId: 'vpc-0abc123', cidr: '10.0.0.0/16' }, };}refId / refIds Helpers
Section titled “refId / refIds Helpers”Every resource manager with BaseResource references needs the same “convert to string ID” logic. Use these lightweight helpers:
function refId(ref: unknown): string | undefined { if (typeof ref === 'object' && ref !== null && 'id' in ref) return (ref as { id: string }).id; if (typeof ref === 'string') return ref; return undefined;}
function refIds(refs: unknown[] | undefined): string[] { return refs?.map(r => refId(r)!).filter(Boolean) ?? [];}provider.plan() Fallback
Section titled “provider.plan() Fallback”The planner does not call provider.plan() — it uses readState() + DiffEngine.diff(). Provider plan() methods should still be implemented following the same pattern as a fallback for direct provider testing.
Change Types
Section titled “Change Types”| Change | Description | Action |
|---|---|---|
create |
In config, not in state | Call provider.createResource() |
update |
In both, config differs | Call provider.updateResource() |
replace |
Immutable property changed | destroy() + create() |
destroy |
In state, not in config | Call provider.destroyResource() |
no-op |
Config matches state | Skip |
Drift Detection
Section titled “Drift Detection”When kyku plan runs:
- Resources with
providerIdin state → query cloud viareadState() - Resources without
providerId→ always showcreate - If cloud state differs from desired config → show as
update
Never query the cloud for resources not in state — this breaks idempotency and causes spurious “no-op” results.