fix(services): clean up dependent resources during service deletion
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user