Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cac2e616f | |||
| fd0653788e | |||
| c6b8cbbe51 | |||
| 966c626d36 | |||
| 7f0c968b5c | |||
| 78d7479b4a | |||
| 9bac0a5f71 | |||
| 26256c92bd |
@@ -3,6 +3,56 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-28 - 6.4.3
|
||||
|
||||
- label Valkey App Store platform requirements in the dashboard
|
||||
- Displays Valkey using the canonical platform name when templates declare that requirement.
|
||||
|
||||
### Fixes
|
||||
|
||||
- label App Store platform requirement badges with canonical names (appstore)
|
||||
- Displays Valkey with its canonical platform name when templates declare that requirement.
|
||||
- Adds display labels for MongoDB, S3, ClickHouse, Valkey, and MariaDB platform requirement badges.
|
||||
- Bumps appstore, interfaces, and tsbuild patch dependencies.
|
||||
|
||||
## 2026-05-28 - 6.4.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- make service deletion clean up dependent control-plane state (services)
|
||||
- Delete appstore operations, deployments, DNS entries, platform bindings, backups, registry repositories, owned secrets, and unreferenced App Store images before removing the service row.
|
||||
- Preserve shared OCI blobs while removing service-owned registry repositories.
|
||||
- Keep the service row when required cleanup fails so deletion remains retryable.
|
||||
- clean up dependent resources during service deletion (services)
|
||||
- Remove dependent deployments, DNS entries, platform bindings, backups, registry repositories, app store operations, owned secret bundles, and unreferenced App Store images before deleting the service row.
|
||||
- Delete service-owned backup prefixes and OCI registry repositories while preserving shared OCI blobs.
|
||||
- Keep the service row when required cleanup fails so deletion remains retryable.
|
||||
|
||||
## 2026-05-27 - 6.4.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- return safe App Store backend errors for template, config, and install failures
|
||||
- guard App Store client actions against empty typed RPC responses
|
||||
- bump `@api.global/typedrequest` to `3.3.2` and `@serve.zone/api` to `^5.3.9`
|
||||
- handle App Store backend failures and empty RPC responses (appstore)
|
||||
- return sanitized App Store backend errors for template, config, and install failures
|
||||
- validate App Store typed RPC responses before updating client state or returning results
|
||||
- bump typedrequest and serve.zone API dependencies
|
||||
|
||||
## 2026-05-26 - 6.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- add hosted Cloudly parent upgrade controls (hostedapp)
|
||||
- Proxy admin upgrade status and start requests to the parent hosted-app runtime with the service-scoped runtime identity.
|
||||
- Add a Settings hosted runtime panel for status refresh, parent upgrade start, and running-upgrade polling.
|
||||
- Update `@serve.zone/interfaces` to `^6.2.0` for the parent upgrade contracts.
|
||||
- add hosted Cloudly parent upgrade controls (hostedapp)
|
||||
- Proxy admin upgrade status and start requests to the parent hosted-app runtime using the service runtime identity.
|
||||
- Add Settings hosted runtime status, refresh, upgrade start, and running-upgrade polling UI.
|
||||
- Update @serve.zone/interfaces to ^6.2.0 for parent upgrade request contracts.
|
||||
|
||||
## 2026-05-26 - 6.3.1
|
||||
|
||||
- remove redundant card wrappers around Cloudly tables (ui)
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/cloudly",
|
||||
"version": "6.3.1",
|
||||
"version": "6.4.3",
|
||||
"private": true,
|
||||
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
|
||||
"type": "module",
|
||||
@@ -23,7 +23,7 @@
|
||||
"docs": "tsdoc aidoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.1",
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdoc": "^2.0.5",
|
||||
"@git.zone/tsdocker": "^2.3.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "3.3.1",
|
||||
"@api.global/typedrequest": "3.3.2",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
@@ -78,9 +78,9 @@
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@serve.zone/api": "^5.3.8",
|
||||
"@serve.zone/appstore": "^0.2.0",
|
||||
"@serve.zone/interfaces": "^6.1.0",
|
||||
"@serve.zone/api": "^5.3.9",
|
||||
"@serve.zone/appstore": "^0.2.3",
|
||||
"@serve.zone/interfaces": "^6.2.1",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
Generated
+33
-33
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@api.global/typedrequest':
|
||||
specifier: 3.3.1
|
||||
version: 3.3.1
|
||||
specifier: 3.3.2
|
||||
version: 3.3.2
|
||||
'@api.global/typedrequest-interfaces':
|
||||
specifier: ^3.0.19
|
||||
version: 3.0.19
|
||||
@@ -141,21 +141,21 @@ importers:
|
||||
specifier: ^1.0.10
|
||||
version: 1.0.10
|
||||
'@serve.zone/api':
|
||||
specifier: ^5.3.8
|
||||
version: 5.3.8(@push.rocks/smartserve@2.0.4)
|
||||
specifier: ^5.3.9
|
||||
version: 5.3.9(@push.rocks/smartserve@2.0.4)
|
||||
'@serve.zone/appstore':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
'@git.zone/tsbundle':
|
||||
specifier: ^2.10.4
|
||||
version: 2.10.4
|
||||
@@ -262,8 +262,8 @@ packages:
|
||||
'@api.global/typedrequest-interfaces@3.0.19':
|
||||
resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==}
|
||||
|
||||
'@api.global/typedrequest@3.3.1':
|
||||
resolution: {integrity: sha512-uJ8uGS7T4OvnpvKlc1T6ML/CHOGKZIrgRFYYxnPKho2SZGnBFEfazWKshxlgqPsiWMZDFwX9i8c8sp+l3AGI2w==}
|
||||
'@api.global/typedrequest@3.3.2':
|
||||
resolution: {integrity: sha512-a48z7i9UaP48ru/LzDwPBENHOzn8maHW61rh5g3yGvnIkSWgGGPSGWFDrB44O6jE+2tTr0twh1B+zzNqI4hlIA==}
|
||||
|
||||
'@api.global/typedserver@8.4.6':
|
||||
resolution: {integrity: sha512-kSzjzM0TenzRL73rmDiwsJR/SFJ3nPI7zFC9KWxO7nIhyMo5wgO7UMVCpjXrTYMK6c4HwbhBxEPIJb4prqakww==}
|
||||
@@ -834,8 +834,8 @@ packages:
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==}
|
||||
'@git.zone/tsbuild@4.4.2':
|
||||
resolution: {integrity: sha512-v2m0fFYFt3vJZMvNAlrNChHYjZZNOf4iyO0mNNiHeO+sTR3cddkYb++zO/GL3v2UkG3nDRwfEkwUS4UzuXBEWw==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsbundle@2.10.4':
|
||||
@@ -1829,17 +1829,17 @@ packages:
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
'@serve.zone/api@5.3.8':
|
||||
resolution: {integrity: sha512-k3IU4mcHuk5pKB+X7rhYWGK+j5hyyDzFoqR3ytzG1iidvgDEIIToQJq+mB3E1v6X1+tI3WyYUaMN/TaZRz0l0w==}
|
||||
'@serve.zone/api@5.3.9':
|
||||
resolution: {integrity: sha512-H5T5jPhUrlZFVZLJif8HMKek1dSJ5gzWrj3cDaGj1XXfi/Ca4IJfM9qMwlIJ2CB5SLGl0Y2SlFW5wQJ8N9X9jA==}
|
||||
|
||||
'@serve.zone/appstore@0.2.0':
|
||||
resolution: {integrity: sha512-qt2LVaRpzfJdUywllm+F0njwnN3aHc2aZHEcjc9REn1VDT47UuUEGaKkfNiosGK0GJqb1hPI/GwyuGMe4H4q7w==}
|
||||
'@serve.zone/appstore@0.2.3':
|
||||
resolution: {integrity: sha512-UuzbhEV5o4M5iw4R+2ZpNDEryLslB6oLbsa3kH/cGOMpeWp88GGTEJVo7OOjrsQcnYsQes62yeF1giC2yJONnw==}
|
||||
|
||||
'@serve.zone/interfaces@5.10.0':
|
||||
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
|
||||
|
||||
'@serve.zone/interfaces@6.1.0':
|
||||
resolution: {integrity: sha512-nhxMmMfemBaGM1xxFpbNM8/zPM4Y59mVsgz9XBNGZr6n7kn81QsY+Xcn5HnLywztuGHqgEZRWGmI4MPzORRktw==}
|
||||
'@serve.zone/interfaces@6.2.1':
|
||||
resolution: {integrity: sha512-t2wrpBmd8zDdnyeeY/LG2hfjCXdm/uTHB6oovJ/xHgOws1E2VimYJPFiN7zqs1aEJAmFukfgOq79+eZeq3hfWw==}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
@@ -4643,7 +4643,7 @@ snapshots:
|
||||
|
||||
'@api.global/typedrequest-interfaces@3.0.19': {}
|
||||
|
||||
'@api.global/typedrequest@3.3.1':
|
||||
'@api.global/typedrequest@3.3.2':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/isounique': 1.0.5
|
||||
@@ -4657,7 +4657,7 @@ snapshots:
|
||||
|
||||
'@api.global/typedserver@8.4.6(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
|
||||
'@cloudflare/workers-types': 4.20260507.1
|
||||
@@ -4703,7 +4703,7 @@ snapshots:
|
||||
|
||||
'@api.global/typedsocket@4.1.3(@push.rocks/smartserve@2.0.4)':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/isohash': 2.0.1
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
@@ -5270,14 +5270,14 @@ snapshots:
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
broadcast-channel: 7.3.0
|
||||
|
||||
'@design.estate/dees-domtools@2.5.6':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
@@ -5524,7 +5524,7 @@ snapshots:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
'@git.zone/tsbuild@4.4.2':
|
||||
dependencies:
|
||||
'@git.zone/tspublish': 1.11.7
|
||||
'@push.rocks/early': 4.0.4
|
||||
@@ -6435,7 +6435,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/qenv@6.1.4':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@configvault.io/interfaces': 1.0.17
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -7039,7 +7039,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartserve@2.0.4':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartenv': 6.1.0
|
||||
@@ -7348,9 +7348,9 @@ snapshots:
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@serve.zone/api@5.3.8(@push.rocks/smartserve@2.0.4)':
|
||||
'@serve.zone/api@5.3.9(@push.rocks/smartserve@2.0.4)':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@api.global/typedrequest': 3.3.2
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
|
||||
'@push.rocks/smartexpect': 2.5.0
|
||||
@@ -7362,9 +7362,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@push.rocks/smartserve'
|
||||
|
||||
'@serve.zone/appstore@0.2.0':
|
||||
'@serve.zone/appstore@0.2.3':
|
||||
dependencies:
|
||||
'@serve.zone/interfaces': 6.1.0
|
||||
'@serve.zone/interfaces': 6.2.1
|
||||
|
||||
'@serve.zone/interfaces@5.10.0':
|
||||
dependencies:
|
||||
@@ -7372,7 +7372,7 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/interfaces@6.1.0':
|
||||
'@serve.zone/interfaces@6.2.1':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- '@api.global/typedrequest'
|
||||
- '@serve.zone/api'
|
||||
- '@serve.zone/appstore'
|
||||
- '@serve.zone/interfaces'
|
||||
|
||||
@@ -16,6 +16,28 @@ const logErrorDetails = (errorArg: unknown) => {
|
||||
console.error(` - Error:`, errorArg);
|
||||
};
|
||||
|
||||
const withParentRuntimeEnvCleared = async <T>(callbackArg: () => Promise<T>): Promise<T> => {
|
||||
const previousEnv = {
|
||||
SERVEZONE_RUNTIME_URL: process.env.SERVEZONE_RUNTIME_URL,
|
||||
SERVEZONE_APP_INSTANCE_ID: process.env.SERVEZONE_APP_INSTANCE_ID,
|
||||
SERVEZONE_APP_CONTROL_TOKEN: process.env.SERVEZONE_APP_CONTROL_TOKEN,
|
||||
};
|
||||
delete process.env.SERVEZONE_RUNTIME_URL;
|
||||
delete process.env.SERVEZONE_APP_INSTANCE_ID;
|
||||
delete process.env.SERVEZONE_APP_CONTROL_TOKEN;
|
||||
try {
|
||||
return await callbackArg();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tap.preTask('should start cloudly', async () => {
|
||||
testCloudly = await helpers.createCloudly();
|
||||
await testCloudly.start();
|
||||
@@ -92,6 +114,24 @@ tap.test('should get an identity', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should report parent hosted upgrade unavailable when not hosted', async () => {
|
||||
await withParentRuntimeEnvCleared(async () => {
|
||||
const statusRequest = testClient.typedsocketClient.createTypedRequest<any>('getHostedAppParentUpgradeStatus');
|
||||
const statusResponse = await statusRequest.fire({ identity: testClient.identity });
|
||||
expect(statusResponse.isHosted).toBeFalse();
|
||||
expect(statusResponse.unavailableReason).toEqual('SERVEZONE_RUNTIME_URL is not configured.');
|
||||
expect(statusResponse.upgradeState.status).toEqual('unknown');
|
||||
|
||||
const startRequest = testClient.typedsocketClient.createTypedRequest<any>('startHostedAppParentUpgrade');
|
||||
const startResponse = await startRequest.fire({
|
||||
identity: testClient.identity,
|
||||
targetVersion: '0.0.0-test',
|
||||
});
|
||||
expect(startResponse.isHosted).toBeFalse();
|
||||
expect(startResponse.upgradeState.status).toEqual('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should create and consume node jump codes', async () => {
|
||||
const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster');
|
||||
const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest<any>('createNodeJumpCommand');
|
||||
|
||||
@@ -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();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '6.3.1',
|
||||
version: '6.4.3',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
@@ -84,13 +84,53 @@ export class CloudlyAppStoreManager {
|
||||
public async start() {}
|
||||
public async stop() {}
|
||||
|
||||
private getErrorMessage(errorArg: unknown): string {
|
||||
if (errorArg instanceof Error) return errorArg.message;
|
||||
return String(errorArg);
|
||||
}
|
||||
|
||||
private getSafeAppStoreErrorMessage(errorArg: unknown): string {
|
||||
const message = this.getErrorMessage(errorArg);
|
||||
const lowerMessage = message.toLowerCase();
|
||||
if (
|
||||
lowerMessage.includes('fetch') ||
|
||||
lowerMessage.includes('connect') ||
|
||||
lowerMessage.includes('connection refused') ||
|
||||
lowerMessage.includes('network') ||
|
||||
/http \d+/.test(lowerMessage)
|
||||
) {
|
||||
return 'The App Store backend is currently unreachable. Please retry later.';
|
||||
}
|
||||
if (
|
||||
lowerMessage.includes('domain is required') ||
|
||||
lowerMessage.includes('missing required app env var') ||
|
||||
lowerMessage.includes('unsupported platform requirement') ||
|
||||
lowerMessage.includes('published port') ||
|
||||
lowerMessage.includes('app requires cloudly')
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
return 'The App Store request failed. Please retry later.';
|
||||
}
|
||||
|
||||
private createSafeAppStoreTypedError(actionArg: string, errorArg: unknown): plugins.typedrequest.TypedResponseError {
|
||||
console.warn(`${actionArg}: ${this.getErrorMessage(errorArg)}`);
|
||||
return new plugins.typedrequest.TypedResponseError(
|
||||
`${actionArg}: ${this.getSafeAppStoreErrorMessage(errorArg)}`,
|
||||
);
|
||||
}
|
||||
|
||||
private registerHandlers() {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreTemplates>(
|
||||
'getAppStoreTemplates',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return { apps: await this.getApps() };
|
||||
try {
|
||||
return { apps: await this.getApps() };
|
||||
} catch (error) {
|
||||
throw this.createSafeAppStoreTypedError('Could not load App Store templates', error);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -100,10 +140,17 @@ export class CloudlyAppStoreManager {
|
||||
'getAppStoreConfig',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return {
|
||||
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
|
||||
appMeta: await this.getAppMeta(dataArg.appId),
|
||||
};
|
||||
try {
|
||||
return {
|
||||
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
|
||||
appMeta: await this.getAppMeta(dataArg.appId),
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.createSafeAppStoreTypedError(
|
||||
`Could not load App Store details for ${dataArg.appId}@${dataArg.version || 'latest'}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -113,8 +160,15 @@ export class CloudlyAppStoreManager {
|
||||
'installAppStoreApp',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
const service = await this.installApp(dataArg.install);
|
||||
return { service: await service.createSavableObject() };
|
||||
try {
|
||||
const service = await this.installApp(dataArg.install);
|
||||
return { service: await service.createSavableObject() };
|
||||
} catch (error) {
|
||||
throw this.createSafeAppStoreTypedError(
|
||||
`Could not install App Store app ${dataArg.install?.appId || 'unknown'}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -240,6 +294,14 @@ export class CloudlyAppStoreManager {
|
||||
.slice(0, 25);
|
||||
}
|
||||
|
||||
public clearOperationsForService(serviceIdArg: string): void {
|
||||
for (const [operationId, operation] of this.upgradeOperations.entries()) {
|
||||
if (operation.serviceId === serviceIdArg) {
|
||||
this.upgradeOperations.delete(operationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async startHostedAppUpgrade(serviceIdArg: string, targetVersionArg: string): Promise<IAppStoreUpgradeOperation> {
|
||||
const operation = await this.createUpgradeOperation(serviceIdArg, targetVersionArg);
|
||||
void this.performUpgrade(operation.id).catch(() => {});
|
||||
|
||||
@@ -198,6 +198,18 @@ export class CloudlyBackupManager {
|
||||
return await backup.createSavableObject();
|
||||
}
|
||||
|
||||
public async deleteBackupsForService(serviceIdArg: string): Promise<void> {
|
||||
const backups = await this.CBackupRecord.getInstances({
|
||||
serviceId: serviceIdArg,
|
||||
});
|
||||
for (const backup of backups) {
|
||||
if (backup.replication?.targetPath) {
|
||||
await this.getBackupTargetWriter().deletePrefix(backup.replication.targetPath);
|
||||
}
|
||||
await backup.delete();
|
||||
}
|
||||
}
|
||||
|
||||
public async backupAllServices() {
|
||||
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const results: Array<{ serviceId: string; backupId?: string; errorText?: string }> = [];
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface IBackupTargetWriter {
|
||||
hasObject(pathArg: string, objectArg: TArchiveObject): Promise<boolean>;
|
||||
putObject(pathArg: string, objectArg: TArchiveObject, contentsArg: Buffer): Promise<void>;
|
||||
readObject(pathArg: string): Promise<Buffer>;
|
||||
deletePrefix(pathPrefixArg: string): Promise<void>;
|
||||
}
|
||||
|
||||
const requiredEnv = (nameArg: string) => {
|
||||
@@ -103,6 +104,14 @@ class S3BackupTargetWriter implements IBackupTargetWriter {
|
||||
const bucket = await this.getBucket();
|
||||
return await bucket.fastGet({ path: normalizeRemotePath(pathArg) });
|
||||
}
|
||||
|
||||
public async deletePrefix(pathPrefixArg: string): Promise<void> {
|
||||
const bucket = await this.getBucket();
|
||||
const prefix = normalizeRemotePath(pathPrefixArg).replace(/\/+$/, '');
|
||||
for await (const objectPath of bucket.listAllObjects(prefix)) {
|
||||
await bucket.fastRemove({ path: objectPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SmbBackupTargetWriter implements IBackupTargetWriter {
|
||||
@@ -183,6 +192,24 @@ class SmbBackupTargetWriter implements IBackupTargetWriter {
|
||||
public async readObject(pathArg: string) {
|
||||
return await (await this.getClient()).readFile(this.getShare(), normalizeRemotePath(pathArg));
|
||||
}
|
||||
|
||||
public async deletePrefix(pathPrefixArg: string): Promise<void> {
|
||||
const client = await this.getClient();
|
||||
const share = this.getShare();
|
||||
const rootPath = normalizeRemotePath(pathPrefixArg).replace(/\/+$/, '');
|
||||
const deleteDirectoryFiles = async (pathArg: string): Promise<void> => {
|
||||
const entries = await client.listDirectory(share, pathArg).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
const childPath = `${pathArg}/${entry.name}`.replace(/^\/+/, '');
|
||||
if (entry.isDirectory) {
|
||||
await deleteDirectoryFiles(childPath);
|
||||
} else {
|
||||
await client.deleteFile(share, childPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
await deleteDirectoryFiles(rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
export const createBackupTargetWriterFromEnv = (): IBackupTargetWriter => {
|
||||
|
||||
@@ -6,6 +6,12 @@ type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifec
|
||||
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
|
||||
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
|
||||
|
||||
interface IHostedAppParentUpgradeResponse {
|
||||
isHosted: boolean;
|
||||
unavailableReason?: string;
|
||||
upgradeState: IHostedAppUpgradeState;
|
||||
}
|
||||
|
||||
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
hostedAppLifecycle?: IHostedAppLifecycleState;
|
||||
};
|
||||
@@ -45,6 +51,89 @@ export class CloudlyHostedAppManager {
|
||||
);
|
||||
}
|
||||
|
||||
private getParentRuntimeUnavailableReason(): string | undefined {
|
||||
if (!process.env.SERVEZONE_RUNTIME_URL) {
|
||||
return 'SERVEZONE_RUNTIME_URL is not configured.';
|
||||
}
|
||||
if (!process.env.SERVEZONE_APP_INSTANCE_ID || !process.env.SERVEZONE_APP_CONTROL_TOKEN) {
|
||||
return 'Hosted app runtime identity is not configured.';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getErrorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
|
||||
public async getParentUpgradeStatus(): Promise<IHostedAppParentUpgradeResponse> {
|
||||
const unavailableReason = this.getParentRuntimeUnavailableReason();
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
|
||||
'hostedAppGetManagedUpgradeStatus',
|
||||
);
|
||||
if (unavailableReason || !identity || !request) {
|
||||
return {
|
||||
isHosted: false,
|
||||
unavailableReason,
|
||||
upgradeState: { status: 'unknown' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.fire({ identity });
|
||||
return {
|
||||
isHosted: true,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
return {
|
||||
isHosted: true,
|
||||
unavailableReason: message,
|
||||
upgradeState: {
|
||||
status: 'unknown',
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async startParentUpgrade(targetVersionArg?: string): Promise<IHostedAppParentUpgradeResponse> {
|
||||
const unavailableReason = this.getParentRuntimeUnavailableReason();
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
|
||||
'hostedAppStartManagedUpgrade',
|
||||
);
|
||||
if (unavailableReason || !identity || !request) {
|
||||
return {
|
||||
isHosted: false,
|
||||
unavailableReason,
|
||||
upgradeState: { status: 'unknown' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.fire({
|
||||
identity,
|
||||
targetVersion: targetVersionArg,
|
||||
});
|
||||
return {
|
||||
isHosted: true,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
return {
|
||||
isHosted: true,
|
||||
unavailableReason: message,
|
||||
upgradeState: {
|
||||
status: 'failed',
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async requestParentInitialAdminBootstrap(): Promise<{
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -332,5 +421,31 @@ export class CloudlyHostedAppManager {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_GetHostedAppParentUpgradeStatus>(
|
||||
'getHostedAppParentUpgradeStatus',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return await this.getParentUpgradeStatus();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_StartHostedAppParentUpgrade>(
|
||||
'startHostedAppParentUpgrade',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return await this.startParentUpgrade(dataArg.targetVersion);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,4 +212,35 @@ export class ImageManager {
|
||||
bucketDir: this.imageDir,
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteImageIfUnreferenced(imageIdArg: string, ownerServiceIdArg?: string): Promise<boolean> {
|
||||
if (!imageIdArg) return false;
|
||||
|
||||
const referencingServices = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const referencedByOtherService = referencingServices.some((serviceArg) => {
|
||||
return serviceArg.id !== ownerServiceIdArg && serviceArg.data?.imageId === imageIdArg;
|
||||
});
|
||||
if (referencedByOtherService) return false;
|
||||
|
||||
const image = await this.CImage.getInstance({
|
||||
id: imageIdArg,
|
||||
}).catch(() => null);
|
||||
if (!image) return false;
|
||||
|
||||
for (const version of image.data.versions || []) {
|
||||
const storagePath = version.storagePath || await image.getStoragePath(version.versionString);
|
||||
if (!storagePath || !this.imageDir) continue;
|
||||
await this.imageDir.fastRemove({
|
||||
path: `${storagePath}.tar`,
|
||||
}).catch((errorArg) => {
|
||||
const message = (errorArg as Error).message.toLowerCase();
|
||||
if (!message.includes('not found') && !message.includes('no such')) {
|
||||
throw errorArg;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await image.delete();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,18 @@ type TAuthenticatedRegistryUser = {
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
type TOciTags = Record<string, string>;
|
||||
|
||||
interface IOciDescriptor {
|
||||
digest?: unknown;
|
||||
}
|
||||
|
||||
interface IOciManifestDocument {
|
||||
config?: IOciDescriptor;
|
||||
layers?: IOciDescriptor[];
|
||||
manifests?: IOciDescriptor[];
|
||||
}
|
||||
|
||||
export class CloudlyRegistryManager {
|
||||
private cloudlyRef: Cloudly;
|
||||
private smartRegistry!: plugins.smartregistry.SmartRegistry;
|
||||
@@ -123,6 +135,214 @@ export class CloudlyRegistryManager {
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteServiceRepository(serviceArg: Service): Promise<void> {
|
||||
const repository = serviceArg.data.registryTarget?.repository;
|
||||
if (!repository) return;
|
||||
|
||||
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const referencedByOtherService = services.some((candidateArg) => {
|
||||
return candidateArg.id !== serviceArg.id && candidateArg.data.registryTarget?.repository === repository;
|
||||
});
|
||||
if (referencedByOtherService) return;
|
||||
|
||||
await this.deleteOciRepository(repository);
|
||||
for (const tagKey of Array.from(this.recordedTagDigests.keys())) {
|
||||
if (tagKey.startsWith(`${repository}:`)) {
|
||||
this.recordedTagDigests.delete(tagKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getRegistryStorage(): any {
|
||||
if (!this.started || !this.smartRegistry) {
|
||||
throw new Error('Cloudly registry is not started');
|
||||
}
|
||||
return this.smartRegistry.getStorage();
|
||||
}
|
||||
|
||||
private getOciTagsPath(repositoryArg: string): string {
|
||||
return `oci/tags/${repositoryArg}/tags.json`;
|
||||
}
|
||||
|
||||
private normalizeOciDigest(digestArg: string | null | undefined): string | null {
|
||||
if (typeof digestArg !== 'string') return null;
|
||||
const normalizedDigest = digestArg.trim().toLowerCase();
|
||||
return /^sha256:[a-f0-9]{64}$/.test(normalizedDigest) ? normalizedDigest : null;
|
||||
}
|
||||
|
||||
private getSha256HashFromDigest(digestArg: string): string {
|
||||
const normalizedDigest = this.normalizeOciDigest(digestArg);
|
||||
if (!normalizedDigest) {
|
||||
throw new Error(`Invalid OCI digest: ${digestArg}`);
|
||||
}
|
||||
return normalizedDigest.slice('sha256:'.length);
|
||||
}
|
||||
|
||||
private getOciManifestPath(repositoryArg: string, digestArg: string): string {
|
||||
return `oci/manifests/${repositoryArg}/${this.getSha256HashFromDigest(digestArg)}`;
|
||||
}
|
||||
|
||||
private getOciBlobPath(digestArg: string): string {
|
||||
return `oci/blobs/sha256/${this.getSha256HashFromDigest(digestArg)}`;
|
||||
}
|
||||
|
||||
private async readOciTags(repositoryArg: string, storageArg = this.getRegistryStorage()): Promise<TOciTags> {
|
||||
const tagsBuffer = await storageArg.getObject(this.getOciTagsPath(repositoryArg));
|
||||
if (!tagsBuffer) return {};
|
||||
|
||||
const parsedTags = JSON.parse(tagsBuffer.toString('utf8'));
|
||||
if (!parsedTags || typeof parsedTags !== 'object' || Array.isArray(parsedTags)) {
|
||||
throw new Error(`Invalid OCI tags document for ${repositoryArg}`);
|
||||
}
|
||||
|
||||
const tags: TOciTags = {};
|
||||
for (const [tagName, digestValue] of Object.entries(parsedTags)) {
|
||||
const digest = typeof digestValue === 'string' ? this.normalizeOciDigest(digestValue) : null;
|
||||
if (!digest) {
|
||||
throw new Error(`Invalid OCI digest for ${repositoryArg}:${tagName}`);
|
||||
}
|
||||
tags[tagName] = digest;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async readOciManifest(
|
||||
storageArg: any,
|
||||
repositoryArg: string,
|
||||
digestArg: string,
|
||||
): Promise<IOciManifestDocument | null> {
|
||||
const manifestBuffer = await storageArg.getOciManifest(repositoryArg, digestArg);
|
||||
if (!manifestBuffer) return null;
|
||||
try {
|
||||
const manifest = JSON.parse(manifestBuffer.toString('utf8'));
|
||||
return manifest && typeof manifest === 'object' ? manifest : null;
|
||||
} catch (error) {
|
||||
logger.log('warn', `failed to parse OCI manifest ${repositoryArg}@${digestArg}: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getDescriptorDigest(descriptorArg: IOciDescriptor | undefined): string | null {
|
||||
return typeof descriptorArg?.digest === 'string' ? this.normalizeOciDigest(descriptorArg.digest) : null;
|
||||
}
|
||||
|
||||
private collectOciManifestReferences(manifestArg: IOciManifestDocument): {
|
||||
blobDigests: string[];
|
||||
manifestDigests: string[];
|
||||
} {
|
||||
const blobDigests = [
|
||||
this.getDescriptorDigest(manifestArg.config),
|
||||
...(manifestArg.layers || []).map((descriptorArg) => this.getDescriptorDigest(descriptorArg)),
|
||||
].filter((digestArg): digestArg is string => Boolean(digestArg));
|
||||
|
||||
const manifestDigests = (manifestArg.manifests || [])
|
||||
.map((descriptorArg) => this.getDescriptorDigest(descriptorArg))
|
||||
.filter((digestArg): digestArg is string => Boolean(digestArg));
|
||||
|
||||
return { blobDigests, manifestDigests };
|
||||
}
|
||||
|
||||
private async collectReferencedOciObjects(
|
||||
storageArg: any,
|
||||
repositoryArg: string,
|
||||
rootDigestsArg: string[],
|
||||
): Promise<{ manifestDigests: Set<string>; blobDigests: Set<string> }> {
|
||||
const manifestDigests = new Set<string>();
|
||||
const blobDigests = new Set<string>();
|
||||
const pendingManifestDigests = rootDigestsArg
|
||||
.map((digestArg) => this.normalizeOciDigest(digestArg))
|
||||
.filter((digestArg): digestArg is string => Boolean(digestArg));
|
||||
|
||||
while (pendingManifestDigests.length > 0) {
|
||||
const manifestDigest = pendingManifestDigests.shift()!;
|
||||
if (manifestDigests.has(manifestDigest)) continue;
|
||||
manifestDigests.add(manifestDigest);
|
||||
|
||||
const manifest = await this.readOciManifest(storageArg, repositoryArg, manifestDigest);
|
||||
if (!manifest) continue;
|
||||
const references = this.collectOciManifestReferences(manifest);
|
||||
for (const blobDigest of references.blobDigests) {
|
||||
blobDigests.add(blobDigest);
|
||||
}
|
||||
for (const childManifestDigest of references.manifestDigests) {
|
||||
if (!manifestDigests.has(childManifestDigest)) {
|
||||
pendingManifestDigests.push(childManifestDigest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { manifestDigests, blobDigests };
|
||||
}
|
||||
|
||||
private async listRepositoryManifestDigests(storageArg: any, repositoryArg: string): Promise<string[]> {
|
||||
const manifestPrefix = `oci/manifests/${repositoryArg}/`;
|
||||
const paths = await storageArg.listObjects(manifestPrefix);
|
||||
return paths
|
||||
.filter((pathArg: string) => !pathArg.endsWith('.type'))
|
||||
.map((pathArg: string) => pathArg.slice(manifestPrefix.length))
|
||||
.filter((hashArg: string) => /^[a-f0-9]{64}$/.test(hashArg))
|
||||
.map((hashArg: string) => `sha256:${hashArg}`);
|
||||
}
|
||||
|
||||
private async collectAllTaggedOciObjectsExceptRepository(storageArg: any, repositoryArg: string) {
|
||||
const protectedObjects = {
|
||||
manifestDigests: new Set<string>(),
|
||||
blobDigests: new Set<string>(),
|
||||
};
|
||||
const tagPaths = await storageArg.listObjects('oci/tags/');
|
||||
for (const tagPath of tagPaths) {
|
||||
const match = tagPath.match(/^oci\/tags\/(.+)\/tags\.json$/);
|
||||
if (!match || match[1] === repositoryArg) continue;
|
||||
const tags = await this.readOciTags(match[1], storageArg);
|
||||
const references = await this.collectReferencedOciObjects(storageArg, match[1], Object.values(tags));
|
||||
for (const digest of references.manifestDigests) {
|
||||
protectedObjects.manifestDigests.add(digest);
|
||||
}
|
||||
for (const digest of references.blobDigests) {
|
||||
protectedObjects.blobDigests.add(digest);
|
||||
}
|
||||
}
|
||||
return protectedObjects;
|
||||
}
|
||||
|
||||
private async deleteObjectIfExists(storageArg: any, pathArg: string): Promise<void> {
|
||||
if (typeof storageArg.objectExists === 'function' && !(await storageArg.objectExists(pathArg))) {
|
||||
return;
|
||||
}
|
||||
await storageArg.deleteObject(pathArg);
|
||||
}
|
||||
|
||||
private async deleteOciRepository(repositoryArg: string): Promise<void> {
|
||||
const storage = this.getRegistryStorage();
|
||||
const tags = await this.readOciTags(repositoryArg, storage);
|
||||
const repositoryManifestDigests = await this.listRepositoryManifestDigests(storage, repositoryArg);
|
||||
const rootDigests = Array.from(new Set([...Object.values(tags), ...repositoryManifestDigests]));
|
||||
if (rootDigests.length === 0) {
|
||||
await this.deleteObjectIfExists(storage, this.getOciTagsPath(repositoryArg));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetObjects = await this.collectReferencedOciObjects(storage, repositoryArg, rootDigests);
|
||||
const protectedObjects = await this.collectAllTaggedOciObjectsExceptRepository(storage, repositoryArg);
|
||||
|
||||
for (const blobDigest of targetObjects.blobDigests) {
|
||||
if (!protectedObjects.blobDigests.has(blobDigest)) {
|
||||
await this.deleteObjectIfExists(storage, this.getOciBlobPath(blobDigest));
|
||||
}
|
||||
}
|
||||
|
||||
for (const manifestDigest of targetObjects.manifestDigests) {
|
||||
if (!protectedObjects.manifestDigests.has(manifestDigest)) {
|
||||
const manifestPath = this.getOciManifestPath(repositoryArg, manifestDigest);
|
||||
await this.deleteObjectIfExists(storage, manifestPath);
|
||||
await this.deleteObjectIfExists(storage, `${manifestPath}.type`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.deleteObjectIfExists(storage, this.getOciTagsPath(repositoryArg));
|
||||
logger.log('info', `deleted Cloudly registry repository ${repositoryArg}`);
|
||||
}
|
||||
|
||||
private async handleRegistryStorageAfterPut(
|
||||
contextArg: plugins.smartregistry.IStorageHookContext,
|
||||
) {
|
||||
|
||||
@@ -2,6 +2,20 @@ import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Service } from './classes.service.js';
|
||||
|
||||
type TServiceWithDomains = Service & {
|
||||
data: Service['data'] & {
|
||||
appTemplateId?: string;
|
||||
domains?: Array<{ name?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
interface IWorkAppRouteSyncResult {
|
||||
success: boolean;
|
||||
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
||||
routeId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class ServiceManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
public cloudlyRef: Cloudly;
|
||||
@@ -145,14 +159,7 @@ export class ServiceManager {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
const service = await Service.getInstance({
|
||||
id: dataArg.serviceId,
|
||||
});
|
||||
|
||||
// Remove DNS entries before deleting the service
|
||||
await service.removeDnsEntries();
|
||||
|
||||
await service.delete();
|
||||
await this.deleteServiceById(dataArg.serviceId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
@@ -170,4 +177,149 @@ export class ServiceManager {
|
||||
// Cleanup if needed
|
||||
console.log('ServiceManager stopped');
|
||||
}
|
||||
|
||||
public async deleteServiceById(serviceIdArg: string): Promise<void> {
|
||||
const service = await this.CService.getInstance({
|
||||
id: serviceIdArg,
|
||||
});
|
||||
if (!service) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Service not found: ${serviceIdArg}`);
|
||||
}
|
||||
|
||||
await this.deleteExternalGatewayRoutes(service as TServiceWithDomains);
|
||||
this.cloudlyRef.appStoreManager?.clearOperationsForService?.(service.id);
|
||||
await this.deleteDeploymentsForService(service.id);
|
||||
await service.removeDnsEntries();
|
||||
await this.deletePlatformBindingsForService(service.id, service.data.name);
|
||||
await this.cloudlyRef.backupManager?.deleteBackupsForService?.(service.id);
|
||||
await this.cloudlyRef.registryManager?.deleteServiceRepository?.(service);
|
||||
await this.deleteServiceOwnedSecretBundles(service);
|
||||
await this.deleteServiceOwnedImage(service as TServiceWithDomains);
|
||||
await service.delete();
|
||||
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
||||
}
|
||||
|
||||
private async deleteDeploymentsForService(serviceIdArg: string): Promise<void> {
|
||||
const deployments = await this.cloudlyRef.deploymentManager.CDeployment.getInstances({
|
||||
serviceId: serviceIdArg,
|
||||
});
|
||||
for (const deployment of deployments) {
|
||||
await deployment.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private async deletePlatformBindingsForService(
|
||||
serviceIdArg: string,
|
||||
serviceNameArg: string,
|
||||
): Promise<void> {
|
||||
const bindingsById = await this.cloudlyRef.platformManager.CPlatformBinding.getInstances({
|
||||
serviceId: serviceIdArg,
|
||||
});
|
||||
const bindingsByName = serviceNameArg
|
||||
? await this.cloudlyRef.platformManager.CPlatformBinding.getInstances({ serviceId: serviceNameArg })
|
||||
: [];
|
||||
const bindings = new Map<string, typeof bindingsById[number]>();
|
||||
for (const binding of [...bindingsById, ...bindingsByName]) {
|
||||
bindings.set(binding.id, binding);
|
||||
}
|
||||
for (const binding of bindings.values()) {
|
||||
await binding.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteServiceOwnedSecretBundles(serviceArg: Service): Promise<void> {
|
||||
const secretBundleIds = [serviceArg.data.secretBundleId]
|
||||
.filter((secretBundleIdArg): secretBundleIdArg is string => Boolean(secretBundleIdArg));
|
||||
if (secretBundleIds.length === 0) return;
|
||||
|
||||
for (const secretBundleId of secretBundleIds) {
|
||||
const secretBundle = await this.cloudlyRef.secretManager.CSecretBundle.getInstance({
|
||||
id: secretBundleId,
|
||||
}).catch(() => null);
|
||||
if (!secretBundle || (secretBundle.data as { serviceId?: string }).serviceId !== serviceArg.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const secretGroupIds = [...(secretBundle.data.includedSecretGroupIds || [])];
|
||||
await secretBundle.delete();
|
||||
await this.deleteUnreferencedSecretGroups(secretGroupIds);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteUnreferencedSecretGroups(secretGroupIdsArg: string[]): Promise<void> {
|
||||
if (secretGroupIdsArg.length === 0) return;
|
||||
const remainingBundles = await this.cloudlyRef.secretManager.CSecretBundle.getInstances({});
|
||||
for (const secretGroupId of secretGroupIdsArg) {
|
||||
const stillReferenced = remainingBundles.some((bundleArg) => {
|
||||
return (bundleArg.data.includedSecretGroupIds || []).includes(secretGroupId);
|
||||
});
|
||||
if (stillReferenced) continue;
|
||||
const secretGroup = await this.cloudlyRef.secretManager.CSecretGroup.getInstance({
|
||||
id: secretGroupId,
|
||||
}).catch(() => null);
|
||||
if (secretGroup) {
|
||||
await secretGroup.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteServiceOwnedImage(serviceArg: TServiceWithDomains): Promise<void> {
|
||||
if (!serviceArg.data.appTemplateId || !serviceArg.data.imageId) return;
|
||||
await this.cloudlyRef.imageManager.deleteImageIfUnreferenced(serviceArg.data.imageId, serviceArg.id);
|
||||
}
|
||||
|
||||
private async deleteExternalGatewayRoutes(serviceArg: TServiceWithDomains): Promise<void> {
|
||||
const domains = (serviceArg.data.domains || [])
|
||||
.map((domainArg) => domainArg.name?.trim().toLowerCase())
|
||||
.filter((domainArg): domainArg is string => Boolean(domainArg));
|
||||
if (domains.length === 0) return;
|
||||
|
||||
const settings = await this.cloudlyRef.settingsManager.getSettings().catch(() => undefined);
|
||||
if (!settings?.dcrouterGatewayUrl || !settings.dcrouterGatewayApiToken) return;
|
||||
|
||||
const clusters = await this.cloudlyRef.clusterManager.getAllClusters().catch(() => []);
|
||||
const workHosterIds = new Set<string>();
|
||||
if (settings.dcrouterWorkHosterId) {
|
||||
workHosterIds.add(settings.dcrouterWorkHosterId);
|
||||
} else {
|
||||
for (const cluster of clusters) {
|
||||
workHosterIds.add(cluster.id);
|
||||
}
|
||||
}
|
||||
if (workHosterIds.size === 0) return;
|
||||
|
||||
for (const domain of domains) {
|
||||
for (const workHosterId of workHosterIds) {
|
||||
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
|
||||
settings.dcrouterGatewayUrl,
|
||||
'syncWorkAppRoute',
|
||||
{
|
||||
apiToken: settings.dcrouterGatewayApiToken,
|
||||
ownership: {
|
||||
workHosterType: 'cloudly',
|
||||
workHosterId,
|
||||
workAppId: serviceArg.id || serviceArg.data.name,
|
||||
hostname: domain,
|
||||
},
|
||||
delete: true,
|
||||
},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || `dcrouter route delete failed for ${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fireDcRouterRequest<TResponse>(
|
||||
gatewayUrlArg: string,
|
||||
methodArg: string,
|
||||
requestDataArg: Record<string, unknown>,
|
||||
): Promise<TResponse> {
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
|
||||
`${gatewayUrlArg.replace(/\/+$/, '')}/typedrequest`,
|
||||
methodArg,
|
||||
);
|
||||
return await typedRequest.fire(requestDataArg) as TResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '6.3.1',
|
||||
version: '6.4.3',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
+131
-4
@@ -92,6 +92,13 @@ export interface IAppStoreState {
|
||||
upgradeOperations: IAppStoreUpgradeOperation[];
|
||||
}
|
||||
|
||||
export interface IHostedRuntimeState {
|
||||
isHosted: boolean;
|
||||
loading: boolean;
|
||||
unavailableReason?: string;
|
||||
upgradeState: plugins.interfaces.data.IHostedAppUpgradeState | null;
|
||||
}
|
||||
|
||||
const emptyDataState: IDataState = {
|
||||
secretGroups: [],
|
||||
secretBundles: [],
|
||||
@@ -117,6 +124,12 @@ const emptyAppStoreState: IAppStoreState = {
|
||||
upgradeOperations: [],
|
||||
};
|
||||
|
||||
const emptyHostedRuntimeState: IHostedRuntimeState = {
|
||||
isHosted: false,
|
||||
loading: false,
|
||||
upgradeState: null,
|
||||
};
|
||||
|
||||
interface IReq_AdminValidateIdentity {
|
||||
method: 'adminValidateIdentity';
|
||||
request: {
|
||||
@@ -183,6 +196,8 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
|
||||
apiClient.identity = null;
|
||||
dataState.setState({ ...emptyDataState });
|
||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
} catch {}
|
||||
return {
|
||||
...currentState,
|
||||
@@ -202,6 +217,12 @@ export const appStoreStatePart = await appstate.getStatePart<IAppStoreState>(
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const hostedRuntimeStatePart = await appstate.getStatePart<IHostedRuntimeState>(
|
||||
'hostedRuntime',
|
||||
{ ...emptyHostedRuntimeState },
|
||||
'soft',
|
||||
);
|
||||
|
||||
// Shared API client instance (used by UI actions)
|
||||
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
|
||||
identity: plugins.interfaces.data.IIdentity | null;
|
||||
@@ -303,6 +324,8 @@ export const invalidateIdentity = async (reasonArg = 'identity is not valid'): P
|
||||
});
|
||||
dataState.setState({ ...emptyDataState });
|
||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
} finally {
|
||||
identityInvalidationRunning = false;
|
||||
}
|
||||
@@ -865,13 +888,101 @@ const getIdentityForRequest = () => {
|
||||
return identity;
|
||||
};
|
||||
|
||||
let hostedRuntimePollTimer: number | undefined;
|
||||
|
||||
function clearHostedRuntimeUpgradePoll() {
|
||||
if (hostedRuntimePollTimer) {
|
||||
window.clearTimeout(hostedRuntimePollTimer);
|
||||
hostedRuntimePollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleHostedRuntimeUpgradePoll = (stateArg: IHostedRuntimeState) => {
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
if (stateArg.upgradeState?.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
hostedRuntimePollTimer = window.setTimeout(() => {
|
||||
void hostedRuntimeStatePart.dispatchAction(fetchHostedRuntimeUpgradeStatusAction, null);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
export const fetchHostedRuntimeUpgradeStatusAction = hostedRuntimeStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState };
|
||||
statePartArg.setState({ ...currentState, loading: true });
|
||||
try {
|
||||
const request = new plugins.typedrequest.TypedRequest<plugins.interfaces.requests.hostedapp.IReq_Admin_GetHostedAppParentUpgradeStatus>(
|
||||
'/typedrequest',
|
||||
'getHostedAppParentUpgradeStatus',
|
||||
);
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
const nextState: IHostedRuntimeState = {
|
||||
isHosted: response.isHosted,
|
||||
loading: false,
|
||||
unavailableReason: response.unavailableReason,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
} catch (error) {
|
||||
const nextState: IHostedRuntimeState = {
|
||||
...currentState,
|
||||
loading: false,
|
||||
unavailableReason: getErrorText(error) || 'Could not load hosted runtime status.',
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const startHostedRuntimeParentUpgradeAction = hostedRuntimeStatePart.createAction<{
|
||||
targetVersion?: string;
|
||||
} | null>(
|
||||
async (statePartArg, payloadArg) => {
|
||||
const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState };
|
||||
statePartArg.setState({ ...currentState, loading: true });
|
||||
try {
|
||||
const request = new plugins.typedrequest.TypedRequest<plugins.interfaces.requests.hostedapp.IReq_Admin_StartHostedAppParentUpgrade>(
|
||||
'/typedrequest',
|
||||
'startHostedAppParentUpgrade',
|
||||
);
|
||||
const response = await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
targetVersion: payloadArg?.targetVersion,
|
||||
});
|
||||
const nextState: IHostedRuntimeState = {
|
||||
isHosted: response.isHosted,
|
||||
loading: false,
|
||||
unavailableReason: response.unavailableReason,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
} catch (error) {
|
||||
const nextState: IHostedRuntimeState = {
|
||||
...currentState,
|
||||
loading: false,
|
||||
unavailableReason: getErrorText(error) || 'Could not start hosted runtime upgrade.',
|
||||
};
|
||||
statePartArg.setState(nextState);
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
if (!response?.apps) {
|
||||
throw new Error('The App Store returned an empty template response. Please retry.');
|
||||
}
|
||||
return {
|
||||
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||
apps: response.apps || [],
|
||||
apps: response.apps,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -880,9 +991,12 @@ export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAc
|
||||
async (statePartArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getUpgradeableAppStoreServices');
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
if (!response?.services) {
|
||||
throw new Error('The App Store returned an empty upgradeable-services response. Please retry.');
|
||||
}
|
||||
return {
|
||||
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||
upgradeableServices: response.services || [],
|
||||
upgradeableServices: response.services,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -891,9 +1005,12 @@ export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createActi
|
||||
async (statePartArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradeOperations');
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
if (!response?.operations) {
|
||||
throw new Error('The App Store returned an empty upgrade-operations response. Please retry.');
|
||||
}
|
||||
return {
|
||||
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||
upgradeOperations: response.operations || [],
|
||||
upgradeOperations: response.operations,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -919,7 +1036,7 @@ export const startAppStoreServiceUpgradeAction = appStoreStatePart.createAction<
|
||||
|
||||
export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreConfig');
|
||||
return await request.fire({
|
||||
const response = await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
appId: appIdArg,
|
||||
version: versionArg,
|
||||
@@ -927,6 +1044,10 @@ export const getAppStoreConfig = async (appIdArg: string, versionArg: string) =>
|
||||
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
|
||||
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
|
||||
};
|
||||
if (!response?.config || !response?.appMeta) {
|
||||
throw new Error('The App Store returned an empty config response. Please retry.');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => {
|
||||
@@ -936,6 +1057,9 @@ export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVers
|
||||
serviceId: serviceIdArg,
|
||||
targetVersion: targetVersionArg,
|
||||
});
|
||||
if (!response?.preview) {
|
||||
throw new Error('The App Store returned an empty upgrade preview response. Please retry.');
|
||||
}
|
||||
return response.preview as IAppStoreUpgradePreview;
|
||||
};
|
||||
|
||||
@@ -945,5 +1069,8 @@ export const installAppStoreApp = async (installArg: plugins.interfaces.appstore
|
||||
identity: getIdentityForRequest(),
|
||||
install: installArg,
|
||||
});
|
||||
if (!response?.service) {
|
||||
throw new Error('The App Store returned an empty install response. Please retry.');
|
||||
}
|
||||
return response.service as plugins.interfaces.data.IService;
|
||||
};
|
||||
|
||||
@@ -211,6 +211,13 @@ export class CloudlyViewAppStore extends DeesElement {
|
||||
}
|
||||
const platformRequirements = config.platformRequirements || {};
|
||||
const enabledRequirements = Object.entries(platformRequirements).filter(([, enabled]) => enabled);
|
||||
const platformRequirementLabels: Record<string, string> = {
|
||||
mongodb: 'MongoDB',
|
||||
s3: 'S3',
|
||||
clickhouse: 'ClickHouse',
|
||||
valkey: 'Valkey',
|
||||
mariadb: 'MariaDB',
|
||||
};
|
||||
const volumes = this.getConfigVolumes(config);
|
||||
const publishedPorts = config.publishedPorts || [];
|
||||
return html`
|
||||
@@ -243,7 +250,7 @@ export class CloudlyViewAppStore extends DeesElement {
|
||||
${enabledRequirements.length ? html`
|
||||
<div class="card">
|
||||
<div class="section-title">Platform Requirements</div>
|
||||
${enabledRequirements.map(([key]) => html`<span class="badge">${key}</span>`)}
|
||||
${enabledRequirements.map(([key]) => html`<span class="badge">${platformRequirementLabels[key] || key}</span>`)}
|
||||
<div class="muted">Cloudly currently provisions MongoDB and S3 requirements through platform bindings.</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
@@ -23,8 +23,29 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
@state()
|
||||
private accessor testResults: {[key: string]: {success: boolean; message: string}} = {};
|
||||
|
||||
@state()
|
||||
private accessor hostedRuntime: appstate.IHostedRuntimeState = {
|
||||
isHosted: false,
|
||||
loading: false,
|
||||
upgradeState: null,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hostedRuntimeSubscription = appstate.hostedRuntimeStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((stateArg) => {
|
||||
this.hostedRuntime = stateArg;
|
||||
});
|
||||
this.rxSubscriptions.push(hostedRuntimeSubscription);
|
||||
const loginSubscription = appstate.loginStatePart
|
||||
.select((stateArg) => stateArg.identity)
|
||||
.subscribe((identityArg) => {
|
||||
if (identityArg) {
|
||||
void this.refreshHostedRuntimeStatus();
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
@@ -41,10 +62,24 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
dees-panel { margin-bottom: 16px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-grid.single { grid-template-columns: 1fr; }
|
||||
.runtime-panel { display: grid; gap: 16px; }
|
||||
.runtime-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.runtime-card { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 8px; padding: 12px; background: var(--ci-shade-1, #09090b); }
|
||||
.runtime-label { color: var(--ci-shade-4, #71717a); font-size: 12px; margin-bottom: 6px; }
|
||||
.runtime-value { color: var(--ci-shade-7, #e4e4e7); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.runtime-message { color: var(--ci-shade-5, #a1a1aa); font-size: 13px; line-height: 1.5; }
|
||||
.runtime-message.error { color: #ef4444; }
|
||||
.runtime-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
@media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 768px) { .runtime-grid { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
];
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.refreshHostedRuntimeStatus();
|
||||
}
|
||||
|
||||
private async loadSettings() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
@@ -102,6 +137,124 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
return html`<dees-badge .type=${result.success ? 'success' : 'error'} .text=${result.success ? 'Connected' : 'Failed'}></dees-badge>`;
|
||||
}
|
||||
|
||||
private async refreshHostedRuntimeStatus() {
|
||||
if (!appstate.loginStatePart.getState()?.identity) {
|
||||
return;
|
||||
}
|
||||
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.fetchHostedRuntimeUpgradeStatusAction, null);
|
||||
}
|
||||
|
||||
private getHostedRuntimeBadgeType() {
|
||||
const status = this.hostedRuntime.upgradeState?.status;
|
||||
if (!this.hostedRuntime.isHosted) return 'info';
|
||||
if (status === 'failed') return 'error';
|
||||
if (status === 'upToDate' || status === 'success') return 'success';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
private getHostedRuntimeStatusText() {
|
||||
if (!this.hostedRuntime.isHosted) return 'Not hosted';
|
||||
const status = this.hostedRuntime.upgradeState?.status || 'unknown';
|
||||
switch (status) {
|
||||
case 'upToDate': return 'Up to date';
|
||||
case 'available': return 'Update available';
|
||||
case 'running': return 'Upgrade running';
|
||||
case 'success': return 'Upgrade complete';
|
||||
case 'failed': return 'Upgrade failed';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private getHostedRuntimeMessage() {
|
||||
if (!this.hostedRuntime.isHosted) {
|
||||
return this.hostedRuntime.unavailableReason || 'This Cloudly instance is not running as a managed hosted app.';
|
||||
}
|
||||
if (this.hostedRuntime.unavailableReason) {
|
||||
return this.hostedRuntime.unavailableReason;
|
||||
}
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
if (upgradeState?.status === 'available') {
|
||||
return `Parent host can upgrade Cloudly from ${upgradeState.currentVersion || 'current'} to ${upgradeState.targetVersion || upgradeState.latestVersion}.`;
|
||||
}
|
||||
if (upgradeState?.status === 'running') {
|
||||
return 'The parent host is upgrading this Cloudly service. Status refreshes automatically.';
|
||||
}
|
||||
if (upgradeState?.status === 'failed') {
|
||||
return upgradeState.error || 'The last parent-hosted upgrade failed.';
|
||||
}
|
||||
return 'Parent hosted runtime status is available. No upgrade action is currently required.';
|
||||
}
|
||||
|
||||
private async startHostedRuntimeUpgrade() {
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
const targetVersion = upgradeState?.targetVersion || upgradeState?.latestVersion;
|
||||
if (!targetVersion) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'No hosted runtime upgrade target is available.', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
let upgradeStarting = false;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Upgrade Hosted Cloudly',
|
||||
content: html`
|
||||
<div style="width: min(560px, calc(100vw - 48px)); max-width: 100%; color: var(--ci-shade-5, #a1a1aa); line-height: 1.5;">
|
||||
The parent host will upgrade this Cloudly app from ${upgradeState?.currentVersion || 'current'} to ${targetVersion} using its hosted app lifecycle controls.
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Start Upgrade',
|
||||
action: async (modalArg: any) => {
|
||||
if (upgradeStarting) return;
|
||||
upgradeStarting = true;
|
||||
try {
|
||||
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.startHostedRuntimeParentUpgradeAction, { targetVersion });
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Hosted runtime upgrade started.', type: 'success' });
|
||||
await modalArg.destroy();
|
||||
} catch (error) {
|
||||
upgradeStarting = false;
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private renderHostedRuntimePanel() {
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
const canStartUpgrade = this.hostedRuntime.isHosted && upgradeState?.status === 'available' && !this.hostedRuntime.loading;
|
||||
return html`
|
||||
<dees-panel .title=${'Hosted Runtime'} .subtitle=${'Manage this Cloudly instance through its parent serve.zone host'} .variant=${'outline'}>
|
||||
<div class="runtime-panel">
|
||||
<div class="runtime-grid">
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Runtime</div>
|
||||
<div class="runtime-value">${this.hostedRuntime.isHosted ? 'Managed hosted app' : 'Standalone'}</div>
|
||||
</div>
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Upgrade Status</div>
|
||||
<div class="runtime-value"><dees-badge .type=${this.getHostedRuntimeBadgeType()} .text=${this.getHostedRuntimeStatusText()}></dees-badge></div>
|
||||
</div>
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Version</div>
|
||||
<div class="runtime-value">${upgradeState?.currentVersion || '-'}${upgradeState?.latestVersion ? ` / ${upgradeState.latestVersion}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=${`runtime-message ${this.hostedRuntime.unavailableReason || upgradeState?.status === 'failed' ? 'error' : ''}`}>
|
||||
${this.getHostedRuntimeMessage()}
|
||||
</div>
|
||||
${upgradeState?.warnings?.length ? html`<div class="runtime-message">${upgradeState.warnings.join(' | ')}</div>` : ''}
|
||||
<div class="runtime-actions">
|
||||
<dees-button .text=${this.hostedRuntime.loading ? 'Refreshing...' : 'Refresh Status'} .type=${'secondary'} .disabled=${this.hostedRuntime.loading} @click=${() => this.refreshHostedRuntimeStatus()}></dees-button>
|
||||
<dees-button .text=${upgradeState?.status === 'running' ? 'Upgrade Running' : 'Start Parent Upgrade'} .type=${'primary'} .disabled=${!canStartUpgrade} @click=${() => this.startHostedRuntimeUpgrade()}></dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.isLoading && Object.keys(this.settings).length === 0) {
|
||||
return html`<div class="loading-container"><dees-spinner></dees-spinner></div>`;
|
||||
@@ -109,6 +262,7 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
return html`
|
||||
<cloudly-sectionheading>Settings</cloudly-sectionheading>
|
||||
<div class="settings-container">
|
||||
${this.renderHostedRuntimePanel()}
|
||||
<dees-form @formData=${(e: CustomEvent) => { this.saveSettings((e.detail as any).data); }}>
|
||||
<dees-panel .title=${'Hetzner Cloud'} .subtitle=${'Configure Hetzner Cloud API access'} .variant=${'outline'}>
|
||||
<div class="test-status">
|
||||
|
||||
Reference in New Issue
Block a user