Compare commits

..

10 Commits

Author SHA1 Message Date
jkunz 05560c9db9 v6.0.0
Docker (tags) / release (push) Failing after 1s
2026-05-25 03:03:25 +00:00
jkunz 50e69b095c feat(appstore): use shared resolver 2026-05-25 03:03:03 +00:00
jkunz d5445609a0 v5.9.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 13:15:16 +00:00
jkunz fd7c7b4313 fix(cloudly): invalidate expired dashboard sessions 2026-05-24 13:14:49 +00:00
jkunz 057af996aa chore(cloudly): consume released Spark interfaces 2026-05-24 12:54:09 +00:00
jkunz 6565c44c29 feat(cloudly): accept Spark node heartbeats 2026-05-24 12:47:15 +00:00
jkunz ebb4f36c67 v5.8.2
Docker (tags) / release (push) Failing after 13m53s
2026-05-24 01:30:46 +00:00
jkunz e7d3140f7a chore(cloudly): update api dependency and stabilize tests 2026-05-24 01:30:32 +00:00
jkunz 304767a75c v5.8.1
Docker (tags) / release (push) Failing after 0s
2026-05-23 10:59:24 +00:00
jkunz 0fa95d6c99 fix(cloudly): allow Docker install of fresh interfaces 2026-05-23 10:59:06 +00:00
23 changed files with 1451 additions and 256 deletions
+24 -9
View File
@@ -1,10 +1,15 @@
{
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registries": [
"code.foss.global"
],
"registryRepoMap": {
"code.foss.global": "serve.zone/cloudly"
},
"platforms": ["linux/amd64", "linux/arm64"]
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@git.zone/tsbundle": {
"bundles": [
@@ -14,7 +19,9 @@
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": ["./html/**/*"]
"includeFiles": [
"./html/**/*"
]
}
]
},
@@ -29,7 +36,10 @@
"watchers": [
{
"name": "backend",
"watch": ["./ts/**/*", "./ts_cliclient/**/*"],
"watch": [
"./ts/**/*",
"./ts_cliclient/**/*"
],
"command": "pnpm run startTs",
"restart": true,
"debounce": 300,
@@ -41,11 +51,16 @@
"name": "website",
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
"watchPatterns": [
"./ts_web/**/*",
"./html/**/*"
],
"triggerReload": true,
"bundler": "esbuild",
"production": false,
"includeFiles": ["./html/**/*"]
"includeFiles": [
"./html/**/*"
]
}
]
},
@@ -58,9 +73,6 @@
},
"dockerBuildargEnvMap": {}
},
"tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service",
@@ -113,5 +125,8 @@
}
}
}
},
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ FROM code.foss.global/host.today/ht-docker-node:lts AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . ./
+43
View File
@@ -3,6 +3,49 @@
## Pending
## 2026-05-25 - 6.0.0
### Breaking Changes
- switch App Store APIs to the shared appstore client
- Replaces Cloudly App Store manager naming and request methods with App Store naming
- Uses `@serve.zone/appstore` for app metadata parsing and source resolution
- Adds `servezone.appstore.json` as Cloudly's source-owned App Store manifest
## 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
- bumps `@serve.zone/api` to `^5.3.8`
- keeps Docker release installs working with fresh serve.zone package releases
- refreshes mature build, bundle, test, docs, and watch tooling
- stabilizes API client integration test setup and cleanup
## 2026-05-23 - 5.8.1
### Fixes
- allow Docker builds to install freshly released serve.zone interfaces
- Copies pnpm-workspace.yaml into the Docker dependency install layer
- Excludes @serve.zone/interfaces from pnpm 11 minimum release age checks during release builds
## 2026-05-23 - 5.8.0
### Features
+13 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/cloudly",
"version": "5.8.0",
"version": "6.0.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/tsbundle": "^2.10.1",
"@git.zone/tsdoc": "^2.0.3",
"@git.zone/tsdocker": "^2.2.6",
"@git.zone/tspublish": "^1.11.6",
"@git.zone/tstest": "^3.6.5",
"@git.zone/tswatch": "^3.3.3",
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdoc": "^2.0.5",
"@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",
@@ -45,7 +45,7 @@
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-domtools": "^2.5.6",
"@design.estate/dees-element": "^2.2.4",
"@git.zone/tsrun": "^2.0.3",
"@git.zone/tsrun": "^2.0.4",
"@push.rocks/early": "^4.0.4",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
@@ -78,8 +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.7",
"@serve.zone/interfaces": "^5.9.0",
"@serve.zone/api": "^5.3.8",
"@serve.zone/appstore": "^0.2.0",
"@serve.zone/interfaces": "^6.0.0",
"@tsclass/tsclass": "^9.5.1"
},
"files": [
+729 -56
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1,3 +1,8 @@
minimumReleaseAgeExclude:
- '@serve.zone/api'
- '@serve.zone/appstore'
- '@serve.zone/interfaces'
allowBuilds:
'@design.estate/dees-catalog': false
cpu-features: true
+129
View File
@@ -0,0 +1,129 @@
{
"schemaVersion": 1,
"app": {
"id": "cloudly",
"name": "Cloudly",
"description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
"category": "Dev Tools",
"iconName": "server",
"tags": ["serve.zone", "control-plane", "clusters", "deployments"],
"maintainer": "serve.zone",
"links": {
"Source": "https://code.foss.global/serve.zone/cloudly",
"Docs": "https://serve.zone"
}
},
"latestVersion": "latest",
"source": {
"type": "dockerImage",
"image": "code.foss.global/serve.zone/cloudly:latest",
"tracking": "digest"
},
"runtime": {
"image": "code.foss.global/serve.zone/cloudly:latest",
"port": 80,
"envVars": [
{
"key": "SERVEZONE_ENVIRONMENT",
"value": "production",
"description": "Cloudly runtime environment.",
"required": true
},
{
"key": "SERVEZONE_URL",
"value": "${SERVICE_DOMAIN}",
"description": "Public Cloudly hostname without protocol.",
"required": true
},
{
"key": "SERVEZONE_PORT",
"value": "80",
"description": "Internal Cloudly HTTP port inside the container.",
"required": true
},
{
"key": "SERVEZONE_SSLMODE",
"value": "external",
"description": "Use external TLS termination through Onebox or dcrouter.",
"required": true
},
{
"key": "SERVEZONE_ADMINACCOUNT",
"value": "",
"description": "Initial admin account in username:password format. Only used when Cloudly has no human users yet.",
"required": true
},
{
"key": "MONGODB_URL",
"value": "${MONGODB_URI}",
"description": "Authenticated MongoDB connection URL provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_NAME",
"value": "${MONGODB_DATABASE}",
"description": "MongoDB database name provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_USER",
"value": "${MONGODB_USERNAME}",
"description": "MongoDB username provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_PASS",
"value": "${MONGODB_PASSWORD}",
"description": "MongoDB password provisioned by Onebox.",
"required": true
},
{
"key": "S3_ENDPOINT",
"value": "onebox-minio",
"description": "S3 endpoint host for the MinIO service provisioned by Onebox.",
"required": true
},
{
"key": "S3_PORT",
"value": "9000",
"description": "S3 endpoint port for the MinIO service provisioned by Onebox.",
"required": true
},
{
"key": "S3_USESSL",
"value": "false",
"description": "Use plain HTTP for internal MinIO traffic on the Onebox network.",
"required": true
},
{
"key": "S3_BUCKET",
"value": "${S3_BUCKET}",
"description": "S3 bucket provisioned by Onebox for Cloudly's registry.",
"required": true
},
{
"key": "S3_ACCESSKEY",
"value": "${S3_ACCESS_KEY}",
"description": "S3 access key provisioned by Onebox.",
"required": true
},
{
"key": "S3_SECRETKEY",
"value": "${S3_SECRET_KEY}",
"description": "S3 secret key provisioned by Onebox.",
"required": true
}
],
"platformRequirements": {
"mongodb": true,
"s3": true
},
"minOneboxVersion": "1.24.2",
"backupBeforeUpgrade": true,
"healthCheck": {
"path": "/status",
"port": 80,
"expectedStatus": 200
}
}
}
+8 -1
View File
@@ -1,3 +1,6 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Qenv } from '@push.rocks/qenv';
import { SmartNetwork } from '@push.rocks/smartnetwork';
import { tap } from '@git.zone/tstest/tapbundle';
@@ -58,7 +61,11 @@ export const testCloudlyConfig: cloudly.ICloudlyConfig = {
})(),
};
await tapNodeTools.testFileProvider.getDockerAlpineImageAsLocalTarball();
const alpineImageTarballPath = path.join(process.cwd(), '.nogit/testfiles/alpine.tar');
const existingAlpineImageTarball = await fs.stat(alpineImageTarballPath).catch(() => undefined);
if (!existingAlpineImageTarball?.isFile() || existingAlpineImageTarball.size === 0) {
await tapNodeTools.testFileProvider.getDockerAlpineImageAsLocalTarball();
}
export const createCloudly = async () => {
const cloudlyInstance = new cloudly.Cloudly(testCloudlyConfig);
+2 -2
View File
@@ -463,10 +463,10 @@ tap.test('should upload an image version', async () => {
});
tap.test('should stop the apiclient', async (toolsArg) => {
await toolsArg.delayFor(10000);
await helpers.stopCloudly();
await testClient.stop();
await testCloudly.stop();
await helpers.stopCloudly();
toolsArg.delayFor(1000).then(() => process.exit());
})
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.8.0',
version: '6.0.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+5 -5
View File
@@ -34,7 +34,7 @@ import { CloudlySettingsManager } from './manager.settings/classes.settingsmanag
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
import { CloudlyAppCatalogManager } from './manager.appcatalog/classes.appcatalogmanager.js';
import { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
/**
@@ -81,7 +81,7 @@ export class Cloudly {
public nodeManager: CloudlyNodeManager;
public baremetalManager: CloudlyBaremetalManager;
public baseOsManager: CloudlyBaseOsManager;
public appCatalogManager: CloudlyAppCatalogManager;
public appStoreManager: CloudlyAppStoreManager;
public jumpManager: CloudlyJumpManager;
private readyDeferred = new plugins.smartpromise.Deferred();
@@ -119,7 +119,7 @@ export class Cloudly {
this.backupManager = new CloudlyBackupManager(this);
this.baseOsManager = new CloudlyBaseOsManager(this);
this.secretManager = new CloudlySecretManager(this);
this.appCatalogManager = new CloudlyAppCatalogManager(this);
this.appStoreManager = new CloudlyAppStoreManager(this);
this.nodeManager = new CloudlyNodeManager(this);
this.baremetalManager = new CloudlyBaremetalManager(this);
this.jumpManager = new CloudlyJumpManager(this);
@@ -151,7 +151,7 @@ export class Cloudly {
await this.taskManager.init();
await this.backupManager.start();
await this.baseOsManager.start();
await this.appCatalogManager.start();
await this.appStoreManager.start();
await this.registryManager.start();
await this.domainManager.init();
@@ -186,7 +186,7 @@ export class Cloudly {
await this.backupManager.stop();
await this.baseOsManager.stop();
await this.registryManager.stop();
await this.appCatalogManager.stop();
await this.appStoreManager.stop();
await this.externalRegistryManager.stop();
}
}
+5
View File
@@ -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',
@@ -5,19 +5,18 @@ import { Service } from '../manager.service/classes.service.js';
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
import { PlatformBinding } from '../manager.platform/classes.platformbinding.js';
type ICatalogApp = plugins.servezoneInterfaces.appcatalog.ICatalogApp;
type ICatalog = plugins.servezoneInterfaces.appcatalog.ICatalog;
type IAppMeta = plugins.servezoneInterfaces.appcatalog.IAppMeta;
type IAppVersionConfig = plugins.servezoneInterfaces.appcatalog.IAppVersionConfig;
type IInstallOptions = plugins.servezoneInterfaces.appcatalog.IAppInstallRequest;
type IUpgradeableCatalogService = plugins.servezoneInterfaces.appcatalog.IUpgradeableAppService;
type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex;
type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig;
type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest;
type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;
export class CloudlyAppCatalogManager {
export class CloudlyAppStoreManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
private catalogCache: ICatalog | null = null;
private lastFetchTime = 0;
private readonly repoBaseUrl = process.env.APPCATALOG_URL || 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
private readonly cacheTtlMs = 5 * 60 * 1000;
private readonly appStoreResolver = new plugins.servezoneAppstore.AppStoreResolver({
baseUrl: process.env.APPSTORE_URL || 'https://code.foss.global/serve.zone/appstore/raw/branch/main',
});
constructor(private cloudlyRef: Cloudly) {
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
@@ -28,88 +27,74 @@ export class CloudlyAppCatalogManager {
public async stop() {}
private registerHandlers() {
const addCatalogListHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
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() };
}),
);
};
addCatalogListHandler('getAppCatalogTemplates');
addCatalogListHandler('getAppTemplates');
},
),
);
const addConfigHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreConfig>(
'getAppStoreConfig',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return {
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
appMeta: await this.getAppMeta(dataArg.appId),
};
}),
);
};
addConfigHandler('getAppCatalogConfig');
addConfigHandler('getAppConfig');
},
),
);
const addInstallHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_InstallAppStoreApp>(
'installAppStoreApp',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
const service = await this.installApp(dataArg.install || dataArg);
const service = await this.installApp(dataArg.install);
return { service: await service.createSavableObject() };
}),
);
};
addInstallHandler('installAppCatalogApp');
addInstallHandler('installAppTemplate');
},
),
);
const addUpgradeableHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetUpgradeableAppStoreServices>(
'getUpgradeableAppStoreServices',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { services: await this.getUpgradeableServices() };
}),
);
};
addUpgradeableHandler('getUpgradeableAppCatalogServices');
addUpgradeableHandler('getUpgradeableServices');
return { services: await this.getUpgradeableAppStoreServices() };
},
),
);
}
public async getCatalog(): Promise<ICatalog> {
const now = Date.now();
if (this.catalogCache && now - this.lastFetchTime < this.cacheTtlMs) {
return this.catalogCache;
}
const catalog = await this.fetchJson('catalog.json') as ICatalog;
if (!catalog || !Array.isArray(catalog.apps)) {
throw new Error('Invalid app catalog format');
}
this.catalogCache = catalog;
this.lastFetchTime = now;
return catalog;
public async getAppStore(): Promise<IAppStoreIndex> {
return await this.appStoreResolver.getAppStoreIndex();
}
public async getApps(): Promise<ICatalogApp[]> {
return (await this.getCatalog()).apps;
public async getApps(): Promise<IAppStoreApp[]> {
return await this.appStoreResolver.getApps();
}
public async getAppMeta(appIdArg: string): Promise<IAppMeta> {
return await this.fetchJson(`apps/${appIdArg}/app.json`) as IAppMeta;
public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
return await this.appStoreResolver.getAppMeta(appIdArg);
}
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppVersionConfig> {
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppStoreVersionConfig> {
if (!versionArg) {
versionArg = (await this.getAppMeta(appIdArg)).latestVersion;
}
return await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as IAppVersionConfig;
return await this.appStoreResolver.getAppVersionConfig(appIdArg, versionArg);
}
public async getUpgradeableServices(): Promise<IUpgradeableCatalogService[]> {
const catalog = await this.getCatalog();
public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
const appStore = await this.getAppStore();
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const upgradeableServices: IUpgradeableCatalogService[] = [];
const upgradeableServices: IUpgradeableAppStoreService[] = [];
for (const service of services) {
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
@@ -119,15 +104,15 @@ export class CloudlyAppCatalogManager {
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
continue;
}
const catalogApp = catalog.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
if (!catalogApp || catalogApp.latestVersion === serviceData.appTemplateVersion) {
const appStoreApp = appStore.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) {
continue;
}
upgradeableServices.push({
serviceName: serviceData.name,
appTemplateId: serviceData.appTemplateId,
currentVersion: serviceData.appTemplateVersion,
latestVersion: catalogApp.latestVersion,
latestVersion: appStoreApp.latestVersion,
hasMigration: false,
});
}
@@ -135,18 +120,19 @@ export class CloudlyAppCatalogManager {
return upgradeableServices;
}
public async installApp(optionsArg: IInstallOptions): Promise<Service> {
public async installApp(optionsArg: IAppStoreInstallOptions): Promise<Service> {
const appMeta = await this.getAppMeta(optionsArg.appId);
const version = optionsArg.version || appMeta.latestVersion;
const config = await this.getAppVersionConfig(optionsArg.appId, version);
const appStoreVersion = config.appStoreVersion || version;
const webPort = optionsArg.port || config.port;
this.assertSupportedPlatformRequirements(config);
const envVars = this.getCatalogEnvVars(config, optionsArg.envVars || {});
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
}
const image = await this.createCatalogImage(optionsArg.serviceName, config.image, appMeta.description);
const image = await this.createAppStoreImage(optionsArg.serviceName, config.image, appMeta.description);
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
const serviceData = {
name: optionsArg.serviceName,
@@ -155,7 +141,7 @@ export class CloudlyAppCatalogManager {
imageVersion: this.getImageTag(config.image),
deployOnPush: false,
appTemplateId: optionsArg.appId,
appTemplateVersion: version,
appTemplateVersion: appStoreVersion,
environment: envVars,
secretBundleId: secretBundle.id,
additionalSecretBundleIds: [],
@@ -181,11 +167,11 @@ export class CloudlyAppCatalogManager {
return service;
}
private async createCatalogImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
private async createAppStoreImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
const image = new Image();
image.id = await Image.getNewId();
image.data = {
name: `${serviceNameArg}-catalog-image`,
name: `${serviceNameArg}-appstore-image`,
description: descriptionArg,
location: {
internal: false,
@@ -210,8 +196,8 @@ export class CloudlyAppCatalogManager {
const secretBundle = new SecretBundle();
secretBundle.id = plugins.smartunique.shortId(8);
secretBundle.data = {
name: `${serviceNameArg} catalog secrets`,
description: `Generated catalog secret bundle for ${serviceNameArg}`,
name: `${serviceNameArg} appstore secrets`,
description: `Generated appstore secret bundle for ${serviceNameArg}`,
type: 'service',
includedSecretGroupIds: [],
includedTags: [],
@@ -222,7 +208,7 @@ export class CloudlyAppCatalogManager {
return secretBundle;
}
private async createPlatformBindings(serviceArg: Service, configArg: IAppVersionConfig) {
private async createPlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
const requirements = configArg.platformRequirements || {};
if (requirements.mongodb) {
await PlatformBinding.upsertBinding({
@@ -244,7 +230,7 @@ export class CloudlyAppCatalogManager {
}
}
private normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []) {
private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) {
return volumesArg.map((volumeArg) => {
if (typeof volumeArg === 'string') {
return { mountPath: volumeArg };
@@ -253,7 +239,7 @@ export class CloudlyAppCatalogManager {
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
}
private getCatalogEnvVars(configArg: IAppVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
const envVars: Record<string, string> = {};
const missingRequiredEnvVars: string[] = [];
for (const envVar of configArg.envVars || []) {
@@ -274,12 +260,12 @@ export class CloudlyAppCatalogManager {
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
}
private assertSupportedPlatformRequirements(configArg: IAppVersionConfig) {
private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) {
const unsupported = Object.entries(configArg.platformRequirements || {})
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
.map(([key]) => key);
if (unsupported.length > 0) {
throw new Error(`Cloudly catalog install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
throw new Error(`Cloudly App Store install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
}
}
@@ -294,13 +280,4 @@ export class CloudlyAppCatalogManager {
this.cloudlyRef.authManager.adminIdentityGuard,
]);
}
private async fetchJson(pathArg: string): Promise<unknown> {
const url = `${this.repoBaseUrl.replace(/\/+$/, '')}/${pathArg}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
}
+48 -29
View File
@@ -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',
},
);
+7 -1
View File
@@ -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."
`;
+17
View File
@@ -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();
+123
View File
@@ -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',
},
});
}
}
+2 -1
View File
@@ -86,5 +86,6 @@ export {
// @servezone scope
import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as servezoneAppstore from '@serve.zone/appstore';
export { servezoneInterfaces };
export { servezoneAppstore, servezoneInterfaces };
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.8.0',
version: '6.0.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+164 -37
View File
@@ -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() || {};
+50 -3
View File
@@ -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();
}
}
+1 -1
View File
@@ -472,7 +472,7 @@ export class CloudlyViewServices extends DeesElement {
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
try {
const response = await this.fireTypedRequest('getUpgradeableServices', {}) as { services: any[] };
const response = await this.fireTypedRequest('getUpgradeableAppStoreServices', {}) as { services: any[] };
this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null;
} catch {
this.upgradeInfo = null;
+5 -5
View File
@@ -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';