Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5445609a0 | |||
| fd7c7b4313 | |||
| 057af996aa | |||
| 6565c44c29 |
@@ -3,6 +3,24 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-24 - 5.9.0
|
||||
|
||||
### Features
|
||||
|
||||
- accept Spark node heartbeats
|
||||
- Adds a Spark heartbeat HTTP endpoint for cluster nodes
|
||||
- Stores Spark metrics and runtime info on cluster node records
|
||||
- Extends jump onboarding with per-node Spark telemetry credentials
|
||||
|
||||
### Fixes
|
||||
|
||||
- invalidate expired dashboard sessions and return admins to login
|
||||
|
||||
### Maintenance
|
||||
|
||||
- refresh release tooling dependencies
|
||||
- update `@serve.zone/interfaces` to the Spark telemetry contract release
|
||||
|
||||
## 2026-05-24 - 5.8.2
|
||||
|
||||
- update Cloudly to consume the released Jump Code API client
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/cloudly",
|
||||
"version": "5.8.2",
|
||||
"version": "5.9.0",
|
||||
"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,15 +23,15 @@
|
||||
"docs": "tsdoc aidoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbuild": "^4.4.1",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdoc": "^2.0.5",
|
||||
"@git.zone/tsdocker": "^2.2.6",
|
||||
"@git.zone/tsdocker": "^2.3.0",
|
||||
"@git.zone/tspublish": "^1.11.7",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@push.rocks/smartnetwork": "^4.7.1",
|
||||
"@types/node": "^25.6.2"
|
||||
"@types/node": "^25.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "3.3.1",
|
||||
@@ -79,7 +79,7 @@
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@serve.zone/api": "^5.3.8",
|
||||
"@serve.zone/interfaces": "^5.9.0",
|
||||
"@serve.zone/interfaces": "^5.10.0",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
Generated
+36
-15
@@ -144,15 +144,15 @@ importers:
|
||||
specifier: ^5.3.8
|
||||
version: 5.3.8(@push.rocks/smartserve@2.0.4)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.9.0
|
||||
version: 5.9.0
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
'@git.zone/tsbundle':
|
||||
specifier: ^2.10.4
|
||||
version: 2.10.4
|
||||
@@ -160,8 +160,8 @@ importers:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5(ws@8.20.0)(zod@4.4.3)
|
||||
'@git.zone/tsdocker':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
'@git.zone/tspublish':
|
||||
specifier: ^1.11.7
|
||||
version: 1.11.7
|
||||
@@ -175,8 +175,8 @@ importers:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1
|
||||
'@types/node':
|
||||
specifier: ^25.6.2
|
||||
version: 25.6.2
|
||||
specifier: ^25.8.0
|
||||
version: 25.8.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -831,8 +831,8 @@ packages:
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||
|
||||
'@git.zone/tsbuild@4.4.0':
|
||||
resolution: {integrity: sha512-98igHfppi6blFYDyzNukNkj4FUO5ZlyXEaSyJh8vCkkZM8SyAgfZj+NUWA1D1iaPXE58UvK1Pt/o8p8iI9UHHw==}
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsbundle@2.10.1':
|
||||
@@ -847,8 +847,8 @@ packages:
|
||||
resolution: {integrity: sha512-s0Jbq9q1lvPppaIsLRr0VJR5lJn9bBzSr4POssXHKFJlVXRU5UeefR7sRERXNYz45FUCXLn+PLAB786PKEAKXg==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsdocker@2.2.6':
|
||||
resolution: {integrity: sha512-vF0QT5od+t7UyWT8dQt6grybAiVx8EhpH6OZoMsleOrAyLMHEcfAKaPfTELXFnF5A+GPhAree+3KpRGyp5cGCg==}
|
||||
'@git.zone/tsdocker@2.3.0':
|
||||
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tspublish@1.11.6':
|
||||
@@ -2030,6 +2030,9 @@ packages:
|
||||
'@serve.zone/api@5.3.8':
|
||||
resolution: {integrity: sha512-k3IU4mcHuk5pKB+X7rhYWGK+j5hyyDzFoqR3ytzG1iidvgDEIIToQJq+mB3E1v6X1+tI3WyYUaMN/TaZRz0l0w==}
|
||||
|
||||
'@serve.zone/interfaces@5.10.0':
|
||||
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
|
||||
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
resolution: {integrity: sha512-XMXyTXTMcB8AX6zYOMO+Jt5bOv9ujyXj5miE6lrgyT8g+eJ/I6sUFqVNUKJ3LiMk/yFWsPln7HtZeZKDEhaCwQ==}
|
||||
|
||||
@@ -2509,6 +2512,9 @@ packages:
|
||||
'@types/node@25.6.2':
|
||||
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
|
||||
|
||||
'@types/node@25.8.0':
|
||||
resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==}
|
||||
|
||||
'@types/randomatic@3.1.5':
|
||||
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
|
||||
|
||||
@@ -4589,6 +4595,9 @@ packages:
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -5729,9 +5738,9 @@ snapshots:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@git.zone/tsbuild@4.4.0':
|
||||
'@git.zone/tsbuild@4.4.1':
|
||||
dependencies:
|
||||
'@git.zone/tspublish': 1.11.6
|
||||
'@git.zone/tspublish': 1.11.7
|
||||
'@push.rocks/early': 4.0.4
|
||||
'@push.rocks/smartcli': 4.0.21
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
@@ -5841,7 +5850,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@git.zone/tsdocker@2.2.6':
|
||||
'@git.zone/tsdocker@2.3.0':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/projectinfo': 5.1.0
|
||||
@@ -7770,6 +7779,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@push.rocks/smartserve'
|
||||
|
||||
'@serve.zone/interfaces@5.10.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
@@ -8398,6 +8413,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@types/node@25.8.0':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/randomatic@3.1.5': {}
|
||||
|
||||
'@types/relateurl@0.2.33': {}
|
||||
@@ -10883,6 +10902,8 @@ snapshots:
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.8.2',
|
||||
version: '5.9.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
@@ -120,6 +120,11 @@ export class CloudlyServer {
|
||||
'POST',
|
||||
async (ctx) => this.cloudlyRef.baseOsManager.handleHeartbeatHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/spark/v1/nodes/heartbeat',
|
||||
'POST',
|
||||
async (ctx) => this.cloudlyRef.nodeManager.handleSparkHeartbeatHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/baseos/v1/images/:buildId/download',
|
||||
'GET',
|
||||
|
||||
@@ -11,6 +11,17 @@ export interface IJwtData {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface IReq_AdminValidateIdentity {
|
||||
method: 'adminValidateIdentity';
|
||||
request: {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class CloudlyAuthManager {
|
||||
cloudlyRef: Cloudly;
|
||||
public get db() {
|
||||
@@ -82,6 +93,16 @@ export class CloudlyAuthManager {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<IReq_AdminValidateIdentity>('adminValidateIdentity', async (dataArg) => {
|
||||
const valid = await this.adminIdentityGuard.exec(dataArg).catch(() => false);
|
||||
return {
|
||||
valid,
|
||||
reason: valid ? undefined : 'identity is not valid',
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async bootstrapInitialAdmin() {
|
||||
@@ -126,28 +147,22 @@ export class CloudlyAuthManager {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
const jwt = dataArg.identity.jwt;
|
||||
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
|
||||
const expired = jwtData.expiresAt < Date.now();
|
||||
plugins.smartexpect
|
||||
.expect(jwtData.status)
|
||||
.setFailMessage('user not logged in')
|
||||
.toEqual('loggedIn');
|
||||
plugins.smartexpect.expect(expired).setFailMessage(`jwt expired`).toBeFalse();
|
||||
plugins.smartexpect
|
||||
.expect(dataArg.identity.expiresAt)
|
||||
.setFailMessage(
|
||||
`expiresAt >>identity valid until:${dataArg.identity.expiresAt}, but jwt says: ${jwtData.expiresAt}<< has been tampered with`,
|
||||
)
|
||||
.toEqual(jwtData.expiresAt);
|
||||
plugins.smartexpect
|
||||
.expect(dataArg.identity.userId)
|
||||
.setFailMessage('userId has been tampered with')
|
||||
.toEqual(jwtData.userId);
|
||||
if (expired) {
|
||||
throw new Error('identity is expired');
|
||||
try {
|
||||
const jwt = dataArg.identity?.jwt;
|
||||
if (!jwt) {
|
||||
return false;
|
||||
}
|
||||
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
|
||||
const expired = jwtData.expiresAt < Date.now();
|
||||
return (
|
||||
jwtData.status === 'loggedIn' &&
|
||||
!expired &&
|
||||
dataArg.identity.expiresAt === jwtData.expiresAt &&
|
||||
dataArg.identity.userId === jwtData.userId
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
failedHint: 'identity is not valid.',
|
||||
@@ -159,16 +174,17 @@ export class CloudlyAuthManager {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]);
|
||||
const validIdentity = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!validIdentity) {
|
||||
return false;
|
||||
}
|
||||
const jwt = dataArg.identity.jwt;
|
||||
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
|
||||
const user = await this.CUser.getInstance({ id: jwtData.userId });
|
||||
const isAdminBool = user.data.role === 'admin';
|
||||
console.log(`user is admin: ${isAdminBool}`);
|
||||
return isAdminBool;
|
||||
return user?.data.role === 'admin';
|
||||
},
|
||||
{
|
||||
failedHint: 'user is not admin.',
|
||||
failedHint: 'identity is not valid or user is not admin.',
|
||||
name: 'adminIdentityGuard',
|
||||
},
|
||||
);
|
||||
@@ -177,14 +193,17 @@ export class CloudlyAuthManager {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]);
|
||||
const validIdentity = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!validIdentity) {
|
||||
return false;
|
||||
}
|
||||
const jwt = dataArg.identity.jwt;
|
||||
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
|
||||
const user = await this.CUser.getInstance({ id: jwtData.userId });
|
||||
return user.data.role === 'admin' || user.data.role === 'cluster';
|
||||
return user?.data.role === 'admin' || user?.data.role === 'cluster';
|
||||
},
|
||||
{
|
||||
failedHint: 'user is not admin or cluster.',
|
||||
failedHint: 'identity is not valid or user is not admin or cluster.',
|
||||
name: 'adminOrClusterIdentityGuard',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface IClaimJumpCodeResponse {
|
||||
accepted: boolean;
|
||||
message?: string;
|
||||
nodeId?: string;
|
||||
sparkNodeToken?: string;
|
||||
cloudlyUrl?: string;
|
||||
coreflowJumpCode?: string;
|
||||
}
|
||||
@@ -148,8 +149,10 @@ export class CloudlyJumpManager {
|
||||
|
||||
const nodeId = plugins.smartunique.shortId(8);
|
||||
const now = Date.now();
|
||||
const sparkNodeToken = await this.cloudlyRef.authManager.createNewSecureToken();
|
||||
const node = new this.cloudlyRef.nodeManager.CClusterNode();
|
||||
node.id = nodeId;
|
||||
node.sparkNodeTokenHash = this.hashSecret(sparkNodeToken);
|
||||
node.data = {
|
||||
clusterId: cluster.id,
|
||||
nodeType: jumpCodeDoc.data.nodeType,
|
||||
@@ -178,6 +181,7 @@ export class CloudlyJumpManager {
|
||||
return {
|
||||
accepted: true,
|
||||
nodeId: node.id,
|
||||
sparkNodeToken,
|
||||
cloudlyUrl: cluster.data.cloudlyUrl || `${this.getPublicCloudlyUrl()}/`,
|
||||
coreflowJumpCode,
|
||||
};
|
||||
@@ -292,8 +296,10 @@ CLAIM_RESPONSE="$(curl -fsSL -X POST "\${CLAIM_URL}" -H 'content-type: applicati
|
||||
export CLAIM_RESPONSE
|
||||
CLOUDLY_URL="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.accepted) { throw new Error(data.message || "Cloudly rejected jump code"); } process.stdout.write(data.cloudlyUrl);')"
|
||||
COREFLOW_JUMPCODE="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.coreflowJumpCode) { throw new Error("Cloudly did not return a Coreflow jump code"); } process.stdout.write(data.coreflowJumpCode);')"
|
||||
SPARK_NODE_ID="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.nodeId) { throw new Error("Cloudly did not return a Spark node id"); } process.stdout.write(data.nodeId);')"
|
||||
SPARK_NODE_TOKEN="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.sparkNodeToken) { throw new Error("Cloudly did not return a Spark node token"); } process.stdout.write(data.sparkNodeToken);')"
|
||||
|
||||
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}"
|
||||
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}" --nodeId="\${SPARK_NODE_ID}" --nodeToken="\${SPARK_NODE_TOKEN}"
|
||||
|
||||
echo "Cloudly jump completed. This system is now connected."
|
||||
`;
|
||||
|
||||
@@ -39,6 +39,9 @@ export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
|
||||
@plugins.smartdata.svDb()
|
||||
public data!: plugins.servezoneInterfaces.data.IClusterNode['data'];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public sparkNodeTokenHash?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
@@ -54,6 +57,20 @@ export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
|
||||
await this.save();
|
||||
}
|
||||
|
||||
public async updateSparkHeartbeat(
|
||||
metricsArg: plugins.servezoneInterfaces.data.IClusterNodeMetrics,
|
||||
runtimeInfoArg: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo,
|
||||
) {
|
||||
this.data.metrics = metricsArg;
|
||||
this.data.sparkRuntimeInfo = runtimeInfoArg;
|
||||
this.data.status = 'online';
|
||||
this.data.lastHealthCheck = Date.now();
|
||||
if (typeof runtimeInfoArg.swarmNodeId === 'string' && runtimeInfoArg.swarmNodeId) {
|
||||
this.data.swarmNodeId = runtimeInfoArg.swarmNodeId;
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
public async updateStatus(status: plugins.servezoneInterfaces.data.IClusterNode['data']['status']) {
|
||||
this.data.status = status;
|
||||
await this.save();
|
||||
|
||||
@@ -4,6 +4,18 @@ import { Cluster } from '../manager.cluster/classes.cluster.js';
|
||||
import { ClusterNode } from './classes.clusternode.js';
|
||||
import { CurlFresh } from './classes.curlfresh.js';
|
||||
|
||||
interface ISparkHeartbeatRequest {
|
||||
nodeId?: string;
|
||||
nodeToken?: string;
|
||||
metrics?: plugins.servezoneInterfaces.data.IClusterNodeMetrics;
|
||||
runtimeInfo?: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo;
|
||||
}
|
||||
|
||||
interface ISparkHeartbeatResponse {
|
||||
accepted: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class CloudlyNodeManager {
|
||||
public cloudlyRef: Cloudly;
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -51,6 +63,69 @@ export class CloudlyNodeManager {
|
||||
|
||||
public async stop() {}
|
||||
|
||||
public async handleSparkHeartbeatHttpRequest(
|
||||
ctxArg: plugins.typedserver.IRequestContext,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const requestData = await this.readJsonBody<ISparkHeartbeatRequest>(ctxArg);
|
||||
const response = await this.acceptSparkHeartbeat(requestData);
|
||||
return this.createJsonResponse(200, response);
|
||||
} catch (error) {
|
||||
return this.createJsonResponse(400, {
|
||||
accepted: false,
|
||||
message: `Spark heartbeat failed: ${(error as Error).message}`,
|
||||
} satisfies ISparkHeartbeatResponse);
|
||||
}
|
||||
}
|
||||
|
||||
public async acceptSparkHeartbeat(
|
||||
requestDataArg: ISparkHeartbeatRequest,
|
||||
): Promise<ISparkHeartbeatResponse> {
|
||||
if (!requestDataArg.nodeId) {
|
||||
return {
|
||||
accepted: false,
|
||||
message: 'Spark node id is missing',
|
||||
};
|
||||
}
|
||||
if (!requestDataArg.nodeToken) {
|
||||
return {
|
||||
accepted: false,
|
||||
message: 'Spark node token is missing',
|
||||
};
|
||||
}
|
||||
if (!this.isSparkMetrics(requestDataArg.metrics)) {
|
||||
return {
|
||||
accepted: false,
|
||||
message: 'Spark metrics are missing or invalid',
|
||||
};
|
||||
}
|
||||
if (!this.isSparkRuntimeInfo(requestDataArg.runtimeInfo, requestDataArg.nodeId)) {
|
||||
return {
|
||||
accepted: false,
|
||||
message: 'Spark runtime info is missing or invalid',
|
||||
};
|
||||
}
|
||||
|
||||
const node = await this.CClusterNode.getInstance({ id: requestDataArg.nodeId });
|
||||
if (!node) {
|
||||
return {
|
||||
accepted: false,
|
||||
message: 'Spark node is unknown',
|
||||
};
|
||||
}
|
||||
if (node.sparkNodeTokenHash !== this.hashSecret(requestDataArg.nodeToken)) {
|
||||
return {
|
||||
accepted: false,
|
||||
message: 'Spark node token is invalid',
|
||||
};
|
||||
}
|
||||
|
||||
await node.updateSparkHeartbeat(requestDataArg.metrics, requestDataArg.runtimeInfo);
|
||||
return {
|
||||
accepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* creates the node infrastructure on hetzner
|
||||
* ensures that there are exactly the resources that are needed
|
||||
@@ -133,4 +208,52 @@ export class CloudlyNodeManager {
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
private isSparkMetrics(valueArg: unknown): valueArg is plugins.servezoneInterfaces.data.IClusterNodeMetrics {
|
||||
if (!valueArg || typeof valueArg !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const metrics = valueArg as Partial<plugins.servezoneInterfaces.data.IClusterNodeMetrics>;
|
||||
return typeof metrics.cpuUsagePercent === 'number'
|
||||
&& typeof metrics.memoryUsedMB === 'number'
|
||||
&& typeof metrics.memoryAvailableMB === 'number'
|
||||
&& typeof metrics.diskUsedGB === 'number'
|
||||
&& typeof metrics.diskAvailableGB === 'number'
|
||||
&& typeof metrics.containerCount === 'number'
|
||||
&& typeof metrics.timestamp === 'number';
|
||||
}
|
||||
|
||||
private isSparkRuntimeInfo(
|
||||
valueArg: unknown,
|
||||
nodeIdArg: string,
|
||||
): valueArg is plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo {
|
||||
if (!valueArg || typeof valueArg !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const runtimeInfo = valueArg as Record<string, unknown>;
|
||||
return runtimeInfo.runtime === 'spark'
|
||||
&& runtimeInfo.nodeId === nodeIdArg
|
||||
&& typeof runtimeInfo.checkedAt === 'number';
|
||||
}
|
||||
|
||||
private hashSecret(secretArg: string) {
|
||||
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
|
||||
}
|
||||
|
||||
private async readJsonBody<T>(ctxArg: plugins.typedserver.IRequestContext): Promise<T> {
|
||||
const bodyString = (await ctxArg.text()).trim();
|
||||
return bodyString ? JSON.parse(bodyString) as T : {} as T;
|
||||
}
|
||||
|
||||
private createJsonResponse(
|
||||
statusCodeArg: number,
|
||||
bodyArg: object,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(bodyArg), {
|
||||
status: statusCodeArg,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.8.2',
|
||||
version: '5.9.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
+164
-37
@@ -16,6 +16,55 @@ export interface IUiState {
|
||||
activeSubview: string | null;
|
||||
}
|
||||
|
||||
export interface IDataState {
|
||||
secretGroups?: plugins.interfaces.data.ISecretGroup[];
|
||||
secretBundles?: plugins.interfaces.data.ISecretBundle[];
|
||||
clusters?: plugins.interfaces.data.ICluster[];
|
||||
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
|
||||
images?: any[];
|
||||
services?: plugins.interfaces.data.IService[];
|
||||
deployments?: plugins.interfaces.data.IDeployment[];
|
||||
domains?: plugins.interfaces.data.IDomain[];
|
||||
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
|
||||
tasks?: any[];
|
||||
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
|
||||
mails?: any[];
|
||||
logs?: any[];
|
||||
s3?: any[];
|
||||
dbs?: any[];
|
||||
backups?: any[];
|
||||
}
|
||||
|
||||
const emptyDataState: IDataState = {
|
||||
secretGroups: [],
|
||||
secretBundles: [],
|
||||
clusters: [],
|
||||
externalRegistries: [],
|
||||
images: [],
|
||||
services: [],
|
||||
deployments: [],
|
||||
domains: [],
|
||||
dnsEntries: [],
|
||||
tasks: [],
|
||||
taskExecutions: [],
|
||||
mails: [],
|
||||
logs: [],
|
||||
s3: [],
|
||||
dbs: [],
|
||||
backups: [],
|
||||
};
|
||||
|
||||
interface IReq_AdminValidateIdentity {
|
||||
method: 'adminValidateIdentity';
|
||||
request: {
|
||||
identity: plugins.interfaces.data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const getInitialView = (): string => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const validViews = ['overview', 'platform', 'runtime', 'registry', 'secrets', 'domains', 'storage', 'logs'];
|
||||
@@ -49,7 +98,7 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
|
||||
}
|
||||
const newState = {
|
||||
...currentState,
|
||||
...(identity ? { identity } : {}),
|
||||
identity,
|
||||
};
|
||||
try {
|
||||
// Keep shared API client in sync and establish WS for modules using sockets
|
||||
@@ -67,50 +116,19 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
|
||||
|
||||
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||
const currentState = statePartArg.getState() || { identity: null };
|
||||
try {
|
||||
apiClient.identity = null;
|
||||
dataState.setState({ ...emptyDataState });
|
||||
} catch {}
|
||||
return {
|
||||
...currentState,
|
||||
identity: null,
|
||||
};
|
||||
});
|
||||
|
||||
export interface IDataState {
|
||||
secretGroups?: plugins.interfaces.data.ISecretGroup[];
|
||||
secretBundles?: plugins.interfaces.data.ISecretBundle[];
|
||||
clusters?: plugins.interfaces.data.ICluster[];
|
||||
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
|
||||
images?: any[];
|
||||
services?: plugins.interfaces.data.IService[];
|
||||
deployments?: plugins.interfaces.data.IDeployment[];
|
||||
domains?: plugins.interfaces.data.IDomain[];
|
||||
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
|
||||
tasks?: any[];
|
||||
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
|
||||
mails?: any[];
|
||||
logs?: any[];
|
||||
s3?: any[];
|
||||
dbs?: any[];
|
||||
backups?: any[];
|
||||
}
|
||||
export const dataState = await appstate.getStatePart<IDataState>(
|
||||
'data',
|
||||
{
|
||||
secretGroups: [],
|
||||
secretBundles: [],
|
||||
clusters: [],
|
||||
externalRegistries: [],
|
||||
images: [],
|
||||
services: [],
|
||||
deployments: [],
|
||||
domains: [],
|
||||
dnsEntries: [],
|
||||
tasks: [],
|
||||
taskExecutions: [],
|
||||
mails: [],
|
||||
logs: [],
|
||||
s3: [],
|
||||
dbs: [],
|
||||
backups: [],
|
||||
},
|
||||
{ ...emptyDataState },
|
||||
'soft'
|
||||
);
|
||||
|
||||
@@ -124,6 +142,115 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({
|
||||
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
|
||||
}) as TCloudlyApiClientWithNullableIdentity;
|
||||
|
||||
let identityExpiryTimer: number | undefined;
|
||||
let identityInvalidationRunning = false;
|
||||
|
||||
const getErrorText = (errorArg: unknown): string => {
|
||||
if (!errorArg) return '';
|
||||
if (typeof errorArg === 'string') return errorArg;
|
||||
const errorLike = errorArg as { errorText?: string; message?: string; text?: string };
|
||||
return errorLike.errorText || errorLike.message || errorLike.text || '';
|
||||
};
|
||||
|
||||
const isAuthRejectionText = (errorTextArg: string): boolean => {
|
||||
const errorText = errorTextArg.toLowerCase();
|
||||
return [
|
||||
'identity is not valid',
|
||||
'jwt expired',
|
||||
'identity is expired',
|
||||
'user not logged in',
|
||||
'has been tampered with',
|
||||
'invalid jwt',
|
||||
'invalid signature',
|
||||
].some((textPart) => errorText.includes(textPart));
|
||||
};
|
||||
|
||||
export const isIdentityExpired = (identityArg: plugins.interfaces.data.IIdentity | null | undefined): boolean => {
|
||||
return typeof identityArg?.expiresAt === 'number' && identityArg.expiresAt <= Date.now();
|
||||
};
|
||||
|
||||
export const invalidateIdentity = async (reasonArg = 'identity is not valid'): Promise<void> => {
|
||||
if (identityInvalidationRunning) return;
|
||||
identityInvalidationRunning = true;
|
||||
try {
|
||||
const currentLoginState = loginStatePart.getState() || { identity: null };
|
||||
if (currentLoginState.identity) {
|
||||
console.warn(`Cloudly session invalidated: ${reasonArg}`);
|
||||
}
|
||||
apiClient.identity = null;
|
||||
try { await apiClient.typedsocketClient?.setTag('identity', null); } catch {}
|
||||
loginStatePart.setState({
|
||||
...currentLoginState,
|
||||
identity: null,
|
||||
});
|
||||
dataState.setState({ ...emptyDataState });
|
||||
} finally {
|
||||
identityInvalidationRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateStoredIdentity = async (): Promise<boolean> => {
|
||||
const identity = loginStatePart.getState()?.identity ?? null;
|
||||
if (!identity) return false;
|
||||
|
||||
if (isIdentityExpired(identity)) {
|
||||
await invalidateIdentity('identity expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
const validateIdentityRequest = new plugins.typedrequest.TypedRequest<IReq_AdminValidateIdentity>(
|
||||
'/typedrequest',
|
||||
'adminValidateIdentity',
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validateIdentityRequest.fire({ identity });
|
||||
if (!response?.valid) {
|
||||
await invalidateIdentity(response?.reason || 'identity rejected by server');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorText = getErrorText(error);
|
||||
if (isAuthRejectionText(errorText)) {
|
||||
await invalidateIdentity(errorText);
|
||||
return false;
|
||||
}
|
||||
console.warn('Could not validate stored identity:', error);
|
||||
}
|
||||
|
||||
return !!loginStatePart.getState()?.identity;
|
||||
};
|
||||
|
||||
const scheduleIdentityExpiryTimer = () => {
|
||||
if (identityExpiryTimer) {
|
||||
window.clearTimeout(identityExpiryTimer);
|
||||
identityExpiryTimer = undefined;
|
||||
}
|
||||
const identity = loginStatePart.getState()?.identity ?? null;
|
||||
if (!identity?.expiresAt) return;
|
||||
const msUntilExpiry = identity.expiresAt - Date.now();
|
||||
if (msUntilExpiry <= 0) {
|
||||
void invalidateIdentity('identity expired');
|
||||
return;
|
||||
}
|
||||
identityExpiryTimer = window.setTimeout(() => {
|
||||
void invalidateIdentity('identity expired');
|
||||
}, Math.min(msUntilExpiry, 2147483647));
|
||||
};
|
||||
|
||||
plugins.typedrequest.TypedRouter.setGlobalHooks({
|
||||
onIncomingResponse: (entryArg) => {
|
||||
if (entryArg.error && isAuthRejectionText(entryArg.error)) {
|
||||
void invalidateIdentity(entryArg.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
loginStatePart.select((stateArg) => stateArg?.identity ?? null).subscribe(() => {
|
||||
scheduleIdentityExpiryTimer();
|
||||
});
|
||||
scheduleIdentityExpiryTimer();
|
||||
|
||||
// Getting data
|
||||
export const getAllDataAction = dataState.createAction(async (statePartArg) => {
|
||||
let currentState = statePartArg.getState() || {};
|
||||
|
||||
@@ -169,6 +169,17 @@ export class CloudlyDashboard extends DeesElement {
|
||||
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
|
||||
});
|
||||
this.rxSubscriptions.push(uiSubscription);
|
||||
|
||||
const loginSubscription = appstate.loginStatePart
|
||||
.select((stateArg) => stateArg?.identity ?? null)
|
||||
.subscribe((identityArg) => {
|
||||
const hadIdentity = !!this.identity;
|
||||
this.identity = identityArg ?? null;
|
||||
if (!identityArg && hadIdentity) {
|
||||
void this.switchToLoginContent('Session expired. Please sign in again.');
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
}
|
||||
|
||||
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
|
||||
@@ -267,9 +278,16 @@ export class CloudlyDashboard extends DeesElement {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
if (loginState?.identity) {
|
||||
this.identity = loginState.identity;
|
||||
const identityValid = await appstate.validateStoredIdentity();
|
||||
const currentIdentity = appstate.loginStatePart.getState()?.identity ?? null;
|
||||
if (!identityValid || !currentIdentity) {
|
||||
await this.switchToLoginContent('Session expired. Please sign in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.identity = currentIdentity;
|
||||
try {
|
||||
appstate.apiClient.identity = loginState.identity;
|
||||
appstate.apiClient.identity = currentIdentity;
|
||||
if (!appstate.apiClient['typedsocketClient']) {
|
||||
await appstate.apiClient.start();
|
||||
}
|
||||
@@ -301,5 +319,34 @@ export class CloudlyDashboard extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async logout() {}
|
||||
private async switchToLoginContent(statusMessageArg?: string) {
|
||||
const simpleLogin = this.shadowRoot?.querySelector('dees-simple-login') as any;
|
||||
if (!simpleLogin?.shadowRoot) return;
|
||||
|
||||
const loginDiv = simpleLogin.shadowRoot.querySelector('.login') as HTMLDivElement | null;
|
||||
const loginContainerDiv = simpleLogin.shadowRoot.querySelector('.loginContainer') as HTMLDivElement | null;
|
||||
const slotContainerDiv = simpleLogin.shadowRoot.querySelector('.slotContainer') as HTMLDivElement | null;
|
||||
const form = simpleLogin.shadowRoot.querySelector('dees-form') as any;
|
||||
|
||||
if (loginDiv) {
|
||||
loginDiv.style.opacity = '1';
|
||||
loginDiv.style.transform = 'translateY(0px)';
|
||||
}
|
||||
if (loginContainerDiv) {
|
||||
loginContainerDiv.style.pointerEvents = 'all';
|
||||
}
|
||||
if (slotContainerDiv) {
|
||||
slotContainerDiv.style.opacity = '0';
|
||||
slotContainerDiv.style.transform = 'translateY(20px)';
|
||||
slotContainerDiv.style.pointerEvents = 'none';
|
||||
}
|
||||
if (form && statusMessageArg) {
|
||||
form.setStatus('error', statusMessageArg);
|
||||
}
|
||||
}
|
||||
|
||||
private async logout() {
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
await this.switchToLoginContent();
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -4,6 +4,11 @@ export {
|
||||
interfaces
|
||||
}
|
||||
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
export { typedrequest };
|
||||
|
||||
// @design.estate scope
|
||||
import * as deesDomtools from '@design.estate/dees-domtools';
|
||||
import * as deesElement from '@design.estate/dees-element';
|
||||
@@ -11,11 +16,6 @@ import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
export { deesDomtools, deesElement, deesCatalog };
|
||||
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
export { typedrequest };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as webjwt from '@push.rocks/webjwt';
|
||||
import * as smartstate from '@push.rocks/smartstate';
|
||||
|
||||
Reference in New Issue
Block a user