fix(services): clean up dependent resources during service deletion

This commit is contained in:
2026-05-28 16:13:06 +00:00
parent 7f0c968b5c
commit 966c626d36
9 changed files with 691 additions and 8 deletions
+86
View File
@@ -0,0 +1,86 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CloudlyRegistryManager } from '../ts/manager.registry/classes.registrymanager.js';
const digest = (fillArg: string): string => `sha256:${fillArg.repeat(64)}`;
class FakeRegistryStorage {
public objects = new Map<string, Buffer>();
public async getObject(pathArg: string): Promise<Buffer | null> {
return this.objects.get(pathArg) || null;
}
public async putObject(pathArg: string, dataArg: Buffer): Promise<void> {
this.objects.set(pathArg, dataArg);
}
public async deleteObject(pathArg: string): Promise<void> {
this.objects.delete(pathArg);
}
public async objectExists(pathArg: string): Promise<boolean> {
return this.objects.has(pathArg);
}
public async listObjects(prefixArg: string): Promise<string[]> {
return Array.from(this.objects.keys()).filter((pathArg) => pathArg.startsWith(prefixArg));
}
public async getOciManifest(repositoryArg: string, digestArg: string): Promise<Buffer | null> {
return this.getObject(`oci/manifests/${repositoryArg}/${digestArg.slice('sha256:'.length)}`);
}
}
const putJson = (storageArg: FakeRegistryStorage, pathArg: string, dataArg: unknown): void => {
storageArg.objects.set(pathArg, Buffer.from(JSON.stringify(dataArg)));
};
tap.test('should delete Cloudly service-owned OCI repository without deleting shared blobs', async () => {
const storage = new FakeRegistryStorage();
const manager = Object.create(CloudlyRegistryManager.prototype) as any;
const service = {
id: 'service-1',
data: {
registryTarget: { repository: 'workloads/ghost-service-1' },
},
};
manager.started = true;
manager.smartRegistry = { getStorage: () => storage };
manager.recordedTagDigests = new Map([
['workloads/ghost-service-1:latest', digest('a')],
]);
manager.cloudlyRef = {
serviceManager: {
CService: { getInstances: async () => [service] },
},
};
const targetDigest = digest('a');
const otherDigest = digest('b');
const sharedLayerDigest = digest('c');
const targetOnlyLayerDigest = digest('d');
const otherOnlyLayerDigest = digest('e');
putJson(storage, 'oci/tags/workloads/ghost-service-1/tags.json', { latest: targetDigest });
putJson(storage, 'oci/tags/workloads/other-service/tags.json', { latest: otherDigest });
putJson(storage, `oci/manifests/workloads/ghost-service-1/${targetDigest.slice('sha256:'.length)}`, {
layers: [{ digest: sharedLayerDigest }, { digest: targetOnlyLayerDigest }],
});
putJson(storage, `oci/manifests/workloads/other-service/${otherDigest.slice('sha256:'.length)}`, {
layers: [{ digest: sharedLayerDigest }, { digest: otherOnlyLayerDigest }],
});
for (const blobDigest of [sharedLayerDigest, targetOnlyLayerDigest, otherOnlyLayerDigest]) {
storage.objects.set(`oci/blobs/sha256/${blobDigest.slice('sha256:'.length)}`, Buffer.from(blobDigest));
}
await manager.deleteServiceRepository(service);
expect(storage.objects.has('oci/tags/workloads/ghost-service-1/tags.json')).toBeFalse();
expect(storage.objects.has(`oci/manifests/workloads/ghost-service-1/${targetDigest.slice('sha256:'.length)}`)).toBeFalse();
expect(storage.objects.has(`oci/blobs/sha256/${targetOnlyLayerDigest.slice('sha256:'.length)}`)).toBeFalse();
expect(storage.objects.has(`oci/blobs/sha256/${sharedLayerDigest.slice('sha256:'.length)}`)).toBeTrue();
expect(storage.objects.has(`oci/blobs/sha256/${otherOnlyLayerDigest.slice('sha256:'.length)}`)).toBeTrue();
expect(manager.recordedTagDigests.has('workloads/ghost-service-1:latest')).toBeFalse();
});
export default tap.start();
+137
View File
@@ -0,0 +1,137 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ServiceManager } from '../ts/manager.service/classes.servicemanager.js';
const createDeleteable = (labelArg: string, callsArg: string[]) => ({
id: labelArg,
delete: async () => callsArg.push(`delete:${labelArg}`),
});
const createManager = (optionsArg: {
calls: string[];
failBackups?: boolean;
}) => {
const calls = optionsArg.calls;
const service = {
id: 'service-1',
data: {
name: 'ghost',
imageId: 'image-1',
appTemplateId: 'ghost',
secretBundleId: 'bundle-1',
registryTarget: { repository: 'workloads/ghost-service-1' },
domains: [{ name: 'ghost.example.com' }],
},
removeDnsEntries: async () => calls.push('delete:dns'),
delete: async () => calls.push('delete:service'),
};
const manager = Object.create(ServiceManager.prototype) as any;
manager.CService = {
getInstance: async () => service,
};
manager.cloudlyRef = {
appStoreManager: {
clearOperationsForService: (serviceIdArg: string) => calls.push(`clear-upgrades:${serviceIdArg}`),
},
deploymentManager: {
CDeployment: {
getInstances: async () => [createDeleteable('deployment-1', calls)],
},
},
platformManager: {
CPlatformBinding: {
getInstances: async (queryArg: { serviceId: string }) => queryArg.serviceId === 'service-1'
? [createDeleteable('platform-binding-1', calls)]
: [],
},
},
backupManager: {
deleteBackupsForService: async (serviceIdArg: string) => {
calls.push(`delete-backups:${serviceIdArg}`);
if (optionsArg.failBackups) {
throw new Error('backup cleanup failed');
}
},
},
registryManager: {
deleteServiceRepository: async () => calls.push('delete:registry-repository'),
},
secretManager: {
CSecretBundle: {
getInstance: async () => ({
id: 'bundle-1',
data: {
serviceId: 'service-1',
includedSecretGroupIds: ['group-1'],
},
delete: async () => calls.push('delete:secret-bundle'),
}),
getInstances: async () => [],
},
CSecretGroup: {
getInstance: async () => createDeleteable('secret-group-1', calls),
},
},
imageManager: {
deleteImageIfUnreferenced: async (imageIdArg: string, serviceIdArg: string) => {
calls.push(`delete-image:${imageIdArg}:${serviceIdArg}`);
return true;
},
},
settingsManager: {
getSettings: async () => ({
dcrouterGatewayUrl: 'https://dcrouter.example.com',
dcrouterGatewayApiToken: 'token',
dcrouterWorkHosterId: 'cloudly-main',
}),
},
clusterManager: {
getAllClusters: async () => [],
},
coreflowManager: {
pushClusterConfigToConnectedCoreflows: async () => calls.push('push:coreflow'),
},
};
manager.fireDcRouterRequest = async (_url: string, methodArg: string, payloadArg: any) => {
calls.push(`${methodArg}:${payloadArg.ownership.hostname}:${payloadArg.ownership.workHosterId}`);
return { success: true, action: 'deleted' };
};
return { manager, service };
};
tap.test('should delete Cloudly service-owned resources before deleting the service row', async () => {
const calls: string[] = [];
const { manager } = createManager({ calls });
await manager.deleteServiceById('service-1');
expect(calls).toContain('syncWorkAppRoute:ghost.example.com:cloudly-main');
expect(calls).toContain('clear-upgrades:service-1');
expect(calls).toContain('delete:deployment-1');
expect(calls).toContain('delete:dns');
expect(calls).toContain('delete:platform-binding-1');
expect(calls).toContain('delete-backups:service-1');
expect(calls).toContain('delete:registry-repository');
expect(calls).toContain('delete:secret-bundle');
expect(calls).toContain('delete:secret-group-1');
expect(calls).toContain('delete-image:image-1:service-1');
expect(calls.at(-2)).toEqual('delete:service');
expect(calls.at(-1)).toEqual('push:coreflow');
});
tap.test('should keep Cloudly service row when required cleanup fails', async () => {
const calls: string[] = [];
const { manager } = createManager({ calls, failBackups: true });
let errorMessage = '';
try {
await manager.deleteServiceById('service-1');
} catch (error) {
errorMessage = (error as Error).message;
}
expect(errorMessage).toEqual('backup cleanup failed');
expect(calls).not.toContain('delete:service');
expect(calls).not.toContain('push:coreflow');
});
export default tap.start();