Skip to content

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.

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:

  • BaseResource objects → their .id string
  • BaseResource[] arrays → array of .id strings
  • string values → as-is
  • undefined values → as-is (both sides must agree on undefined)

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;
}

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 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.

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' },
};
}

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) ?? [];
}

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 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

When kyku plan runs:

  1. Resources with providerId in state → query cloud via readState()
  2. Resources without providerId → always show create
  3. 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.