feat(settings): Add runtime settings management, node & baremetal managers, and settings UI

This commit is contained in:
2025-09-07 17:21:30 +00:00
parent 83abe37d8c
commit 54ef62e7af
36 changed files with 1914 additions and 301 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.1.0',
version: '5.2.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}

View File

@@ -18,12 +18,14 @@ import { CloudlyCoreflowManager } from './manager.coreflow/coreflowmanager.js';
import { ClusterManager } from './manager.cluster/classes.clustermanager.js';
import { CloudlyTaskmanager } from './manager.task/taskmanager.js';
import { CloudlySecretManager } from './manager.secret/classes.secretmanager.js';
import { CloudlyServerManager } from './manager.server/classes.servermanager.js';
import { CloudlyNodeManager } from './manager.node/classes.nodemanager.js';
import { CloudlyBaremetalManager } from './manager.baremetal/classes.baremetalmanager.js';
import { ExternalApiManager } from './manager.status/statusmanager.js';
import { ExternalRegistryManager } from './manager.externalregistry/index.js';
import { ImageManager } from './manager.image/classes.imagemanager.js';
import { logger } from './logger.js';
import { CloudlyAuthManager } from './manager.auth/classes.authmanager.js';
import { CloudlySettingsManager } from './manager.settings/classes.settingsmanager.js';
/**
* Cloudly class can be used to instantiate a cloudly server.
@@ -52,13 +54,15 @@ export class Cloudly {
// managers
public authManager: CloudlyAuthManager;
public secretManager: CloudlySecretManager;
public settingsManager: CloudlySettingsManager;
public clusterManager: ClusterManager;
public coreflowManager: CloudlyCoreflowManager;
public externalApiManager: ExternalApiManager;
public externalRegistryManager: ExternalRegistryManager;
public imageManager: ImageManager;
public taskManager: CloudlyTaskmanager;
public serverManager: CloudlyServerManager;
public nodeManager: CloudlyNodeManager;
public baremetalManager: CloudlyBaremetalManager;
private readyDeferred = new plugins.smartpromise.Deferred();
@@ -79,6 +83,7 @@ export class Cloudly {
// managers
this.authManager = new CloudlyAuthManager(this);
this.settingsManager = new CloudlySettingsManager(this);
this.clusterManager = new ClusterManager(this);
this.coreflowManager = new CloudlyCoreflowManager(this);
this.externalApiManager = new ExternalApiManager(this);
@@ -86,7 +91,8 @@ export class Cloudly {
this.imageManager = new ImageManager(this);
this.taskManager = new CloudlyTaskmanager(this);
this.secretManager = new CloudlySecretManager(this);
this.serverManager = new CloudlyServerManager(this);
this.nodeManager = new CloudlyNodeManager(this);
this.baremetalManager = new CloudlyBaremetalManager(this);
}
/**
@@ -97,13 +103,18 @@ export class Cloudly {
// config
await this.config.init(this.configOptions);
// database (data comes from config)
await this.mongodbConnector.init();
// settings (are stored in db)
await this.settingsManager.init();
// manageers
await this.authManager.start();
await this.secretManager.start();
await this.serverManager.start();
// connectors
await this.mongodbConnector.init();
await this.nodeManager.start();
await this.baremetalManager.start();
await this.cloudflareConnector.init();
await this.letsencryptConnector.init();
await this.clusterManager.init();

View File

@@ -20,10 +20,8 @@ export class CloudlyConfig {
await plugins.npmextra.AppData.createAndInit<plugins.servezoneInterfaces.data.ICloudlyConfig>(
{
envMapping: {
cfToken: 'CF_TOKEN',
environment: 'SERVEZONE_ENVIRONMENT' as 'production' | 'integration',
letsEncryptEmail: 'hard:domains@lossless.org',
hetznerToken: 'HETZNER_API_TOKEN',
letsEncryptPrivateKey: null,
publicUrl: 'SERVEZONE_URL',
publicPort: 'SERVEZONE_PORT',
@@ -46,8 +44,6 @@ export class CloudlyConfig {
servezoneAdminaccount: 'SERVEZONE_ADMINACCOUNT',
},
requiredKeys: [
'cfToken',
'hetznerToken',
'letsEncryptEmail',
'publicUrl',
'publicPort',

View File

@@ -95,7 +95,7 @@ export class CloudlyServer {
this.typedServer.typedrouter.addTypedRouter(this.typedrouter);
this.typedServer.server.addRoute(
'/curlfresh/:scriptname',
this.cloudlyRef.serverManager.curlfreshInstance.handler,
this.cloudlyRef.nodeManager.curlfreshInstance.handler,
);
await this.typedServer.start();
}

View File

@@ -14,6 +14,13 @@ export class CloudflareConnector {
// init the instance
public async init() {
this.cloudflare = new plugins.cloudflare.CloudflareAccount(this.cloudlyRef.config.data.cfToken);
const cloudflareToken = await this.cloudlyRef.settingsManager.getSetting('cloudflareToken');
if (!cloudflareToken) {
console.log('warn', 'No Cloudflare token configured in settings. Cloudflare features will be disabled.');
return;
}
this.cloudflare = new plugins.cloudflare.CloudflareAccount(cloudflareToken);
}
}

View File

@@ -0,0 +1,104 @@
import * as plugins from '../plugins.js';
/**
* BareMetal represents an actual physical server
*/
@plugins.smartdata.Manager()
export class BareMetal extends plugins.smartdata.SmartDataDbDoc<
BareMetal,
plugins.servezoneInterfaces.data.IBareMetal
> {
// STATIC
public static async createFromHetznerServer(
hetznerServerArg: plugins.hetznercloud.HetznerServer,
) {
const newBareMetal = new BareMetal();
newBareMetal.id = plugins.smartunique.shortId(8);
const data: plugins.servezoneInterfaces.data.IBareMetal['data'] = {
hostname: hetznerServerArg.data.name,
primaryIp: hetznerServerArg.data.public_net.ipv4.ip,
provider: 'hetzner',
location: hetznerServerArg.data.datacenter.name,
specs: {
cpuModel: hetznerServerArg.data.server_type.cpu_type,
cpuCores: hetznerServerArg.data.server_type.cores,
memoryGB: hetznerServerArg.data.server_type.memory,
storageGB: hetznerServerArg.data.server_type.disk,
storageType: 'nvme',
},
powerState: hetznerServerArg.data.status === 'running' ? 'on' : 'off',
osInfo: {
name: 'Debian',
version: '12',
},
assignedNodeIds: [],
providerMetadata: {
hetznerServerId: hetznerServerArg.data.id,
hetznerServerName: hetznerServerArg.data.name,
},
};
Object.assign(newBareMetal, { data });
await newBareMetal.save();
return newBareMetal;
}
// INSTANCE
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.servezoneInterfaces.data.IBareMetal['data'];
constructor() {
super();
}
public async assignNode(nodeId: string) {
if (!this.data.assignedNodeIds.includes(nodeId)) {
this.data.assignedNodeIds.push(nodeId);
await this.save();
}
}
public async removeNode(nodeId: string) {
this.data.assignedNodeIds = this.data.assignedNodeIds.filter(id => id !== nodeId);
await this.save();
}
public async updatePowerState(state: 'on' | 'off' | 'unknown') {
this.data.powerState = state;
await this.save();
}
public async powerOn(): Promise<boolean> {
// TODO: Implement IPMI power on
if (this.data.ipmiAddress && this.data.ipmiCredentials) {
// Implement IPMI power on command
console.log(`Powering on BareMetal ${this.id} via IPMI`);
await this.updatePowerState('on');
return true;
}
return false;
}
public async powerOff(): Promise<boolean> {
// TODO: Implement IPMI power off
if (this.data.ipmiAddress && this.data.ipmiCredentials) {
// Implement IPMI power off command
console.log(`Powering off BareMetal ${this.id} via IPMI`);
await this.updatePowerState('off');
return true;
}
return false;
}
public async reset(): Promise<boolean> {
// TODO: Implement IPMI reset
if (this.data.ipmiAddress && this.data.ipmiCredentials) {
// Implement IPMI reset command
console.log(`Resetting BareMetal ${this.id} via IPMI`);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,176 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { BareMetal } from './classes.baremetal.js';
import { logger } from '../logger.js';
export class CloudlyBaremetalManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public hetznerAccount: plugins.hetznercloud.HetznerAccount;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CBareMetal = plugins.smartdata.setDefaultManagerForDoc(this, BareMetal);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
// API endpoint to get baremetal servers
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.baremetal.IRequest_Any_Cloudly_GetBaremetalServers>(
'getBaremetalServers',
async (requestData) => {
const baremetals = await this.getAllBaremetals();
return {
baremetals: await Promise.all(
baremetals.map((baremetal) => baremetal.createSavableObject())
),
};
},
),
);
// API endpoint to control baremetal via IPMI
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.baremetal.IRequest_Any_Cloudly_ControlBaremetal>(
'controlBaremetal',
async (requestData) => {
const baremetal = await this.CBareMetal.getInstance({
id: requestData.baremetalId,
});
if (!baremetal) {
return {
success: false,
message: 'BareMetal not found',
};
}
let success = false;
switch (requestData.action) {
case 'powerOn':
success = await baremetal.powerOn();
break;
case 'powerOff':
success = await baremetal.powerOff();
break;
case 'reset':
success = await baremetal.reset();
break;
}
return {
success,
message: success ? `Action ${requestData.action} completed` : `Action ${requestData.action} failed`,
};
},
),
);
}
public async start() {
const hetznerToken = await this.cloudlyRef.settingsManager.getSetting('hetznerToken');
if (hetznerToken) {
this.hetznerAccount = new plugins.hetznercloud.HetznerAccount(hetznerToken);
}
logger.log('info', 'BareMetal manager started');
}
public async stop() {
logger.log('info', 'BareMetal manager stopped');
}
/**
* Get all baremetal servers
*/
public async getAllBaremetals(): Promise<BareMetal[]> {
const baremetals = await this.CBareMetal.getInstances({});
return baremetals;
}
/**
* Get baremetal by ID
*/
public async getBaremetalById(id: string): Promise<BareMetal | null> {
const baremetal = await this.CBareMetal.getInstance({
id,
});
return baremetal;
}
/**
* Get baremetals by provider
*/
public async getBaremetalsByProvider(provider: 'hetzner' | 'aws' | 'digitalocean' | 'onpremise'): Promise<BareMetal[]> {
const baremetals = await this.CBareMetal.getInstances({
data: {
provider,
},
});
return baremetals;
}
/**
* Create baremetal from Hetzner server
*/
public async createBaremetalFromHetznerServer(hetznerServer: plugins.hetznercloud.HetznerServer): Promise<BareMetal> {
// Check if baremetal already exists for this Hetzner server
const existingBaremetals = await this.CBareMetal.getInstances({});
for (const baremetal of existingBaremetals) {
if (baremetal.data.providerMetadata?.hetznerServerId === hetznerServer.data.id) {
logger.log('info', `BareMetal already exists for Hetzner server ${hetznerServer.data.id}`);
return baremetal;
}
}
// Create new baremetal
const newBaremetal = await BareMetal.createFromHetznerServer(hetznerServer);
logger.log('success', `Created new BareMetal ${newBaremetal.id} from Hetzner server ${hetznerServer.data.id}`);
return newBaremetal;
}
/**
* Sync baremetals with Hetzner
*/
public async syncWithHetzner() {
if (!this.hetznerAccount) {
logger.log('warn', 'Cannot sync with Hetzner - no account configured');
return;
}
const hetznerServers = await this.hetznerAccount.getServers();
for (const hetznerServer of hetznerServers) {
await this.createBaremetalFromHetznerServer(hetznerServer);
}
logger.log('success', `Synced ${hetznerServers.length} servers from Hetzner`);
}
/**
* Provision a new baremetal server
*/
public async provisionBaremetal(options: {
provider: 'hetzner' | 'aws' | 'digitalocean';
location: any; // TODO: Import proper type from hetznercloud when available
type: any; // TODO: Import proper type from hetznercloud when available
}): Promise<BareMetal> {
if (options.provider === 'hetzner' && this.hetznerAccount) {
const hetznerServer = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('baremetal'),
location: options.location,
type: options.type,
});
const baremetal = await this.createBaremetalFromHetznerServer(hetznerServer);
return baremetal;
}
throw new Error(`Provider ${options.provider} not supported or not configured`);
}
}

View File

@@ -33,7 +33,7 @@ export class ClusterManager {
setupMode: setupMode,
acmeInfo: null,
cloudlyUrl: `https://${this.cloudlyRef.config.data.publicUrl}:${this.cloudlyRef.config.data.publicPort}/`,
servers: [],
nodes: [],
sshKeys: [],
},
});
@@ -41,7 +41,7 @@ export class ClusterManager {
// Only auto-provision servers if setupMode is 'hetzner'
if (setupMode === 'hetzner') {
this.cloudlyRef.serverManager.ensureServerInfrastructure();
this.cloudlyRef.nodeManager.ensureNodeInfrastructure();
}
return {

View File

@@ -0,0 +1,61 @@
import * as plugins from '../plugins.js';
/**
* ClusterNode represents a logical node participating in a cluster
*/
@plugins.smartdata.Manager()
export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
ClusterNode,
plugins.servezoneInterfaces.data.IClusterNode
> {
// STATIC
public static async createFromHetznerServer(
hetznerServerArg: plugins.hetznercloud.HetznerServer,
clusterId: string,
baremetalId: string,
) {
const newNode = new ClusterNode();
newNode.id = plugins.smartunique.shortId(8);
const data: plugins.servezoneInterfaces.data.IClusterNode['data'] = {
clusterId: clusterId,
baremetalId: baremetalId,
nodeType: 'baremetal',
status: 'initializing',
role: 'worker',
joinedAt: Date.now(),
lastHealthCheck: Date.now(),
sshKeys: [],
requiredDebianPackages: [],
};
Object.assign(newNode, { data });
await newNode.save();
return newNode;
}
// INSTANCE
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.servezoneInterfaces.data.IClusterNode['data'];
constructor() {
super();
}
public async getDeployments(): Promise<plugins.servezoneInterfaces.data.IDeployment[]> {
// TODO: Implement getting deployments for this node
return [];
}
public async updateMetrics(metrics: plugins.servezoneInterfaces.data.IClusterNodeMetrics) {
this.data.metrics = metrics;
this.data.lastHealthCheck = Date.now();
await this.save();
}
public async updateStatus(status: plugins.servezoneInterfaces.data.IClusterNode['data']['status']) {
this.data.status = status;
await this.save();
}
}

View File

@@ -1,6 +1,6 @@
import { logger } from '../logger.js';
import * as plugins from '../plugins.js';
import type { CloudlyServerManager } from './classes.servermanager.js';
import type { CloudlyNodeManager } from './classes.nodemanager.js';
export class CurlFresh {
public optionsArg = {
@@ -45,7 +45,7 @@ bash -c "spark installdaemon"
`,
};
public serverManagerRef: CloudlyServerManager;
public nodeManagerRef: CloudlyNodeManager;
public curlFreshRoute: plugins.typedserver.servertools.Route;
public handler = new plugins.typedserver.servertools.Handler('ALL', async (req, res) => {
logger.log('info', 'curlfresh handler called. a server might be coming online soon :)');
@@ -62,12 +62,12 @@ bash -c "spark installdaemon"
}
});
constructor(serverManagerRefArg: CloudlyServerManager) {
this.serverManagerRef = serverManagerRefArg;
constructor(nodeManagerRefArg: CloudlyNodeManager) {
this.nodeManagerRef = nodeManagerRefArg;
}
public async getServerUserData(): Promise<string> {
const sslMode =
await this.serverManagerRef.cloudlyRef.config.appData.waitForAndGetKey('sslMode');
await this.nodeManagerRef.cloudlyRef.config.appData.waitForAndGetKey('sslMode');
let protocol: 'http' | 'https';
if (sslMode === 'none') {
protocol = 'http';
@@ -76,9 +76,9 @@ bash -c "spark installdaemon"
}
const domain =
await this.serverManagerRef.cloudlyRef.config.appData.waitForAndGetKey('publicUrl');
await this.nodeManagerRef.cloudlyRef.config.appData.waitForAndGetKey('publicUrl');
const port =
await this.serverManagerRef.cloudlyRef.config.appData.waitForAndGetKey('publicPort');
await this.nodeManagerRef.cloudlyRef.config.appData.waitForAndGetKey('publicPort');
const serverUserData = `#cloud-config
runcmd:

View File

@@ -0,0 +1,131 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { Cluster } from '../manager.cluster/classes.cluster.js';
import { ClusterNode } from './classes.clusternode.js';
import { CurlFresh } from './classes.curlfresh.js';
export class CloudlyNodeManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public curlfreshInstance = new CurlFresh(this);
public hetznerAccount: plugins.hetznercloud.HetznerAccount;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CClusterNode = plugins.smartdata.setDefaultManagerForDoc(this, ClusterNode);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
/**
* is used be serverconfig module on the node to get the actual node config
*/
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.config.IRequest_Any_Cloudly_GetNodeConfig>(
'getNodeConfig',
async (requestData) => {
const nodeId = requestData.nodeId;
const node = await this.CClusterNode.getInstance({
id: nodeId,
});
return {
configData: await node.createSavableObject(),
};
},
),
);
}
public async start() {
const hetznerToken = await this.cloudlyRef.settingsManager.getSetting('hetznerToken');
if (!hetznerToken) {
console.log('warn', 'No Hetzner token configured in settings. Hetzner features will be disabled.');
return;
}
this.hetznerAccount = new plugins.hetznercloud.HetznerAccount(hetznerToken);
}
public async stop() {}
/**
* creates the node infrastructure on hetzner
* ensures that there are exactly the resources that are needed
* no more, no less
*/
public async ensureNodeInfrastructure() {
// get all clusters
const allClusters = await this.cloudlyRef.clusterManager.getAllClusters();
for (const cluster of allClusters) {
// Skip clusters that are not set up for Hetzner auto-provisioning
if (cluster.data.setupMode !== 'hetzner') {
console.log(`Skipping node provisioning for cluster ${cluster.id} - setupMode is ${cluster.data.setupMode || 'manual'}`);
continue;
}
// get existing nodes
const nodes = await this.getNodesByCluster(cluster);
// if there is no node, create one
if (nodes.length === 0) {
const hetznerServer = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('node'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
},
userData: await this.curlfreshInstance.getServerUserData(),
});
// First create BareMetal record
const baremetal = await this.cloudlyRef.baremetalManager.createBaremetalFromHetznerServer(hetznerServer);
const newNode = await ClusterNode.createFromHetznerServer(hetznerServer, cluster.id, baremetal.id);
await baremetal.assignNode(newNode.id);
console.log(`cluster created new node for cluster ${cluster.id}`);
} else {
console.log(
`cluster ${cluster.id} already has nodes. Making sure that they actually exist in the real world...`,
);
// if there is a node, make sure that it exists
for (const node of nodes) {
const hetznerServers = await this.hetznerAccount.getServersByLabel({
clusterId: cluster.id,
});
if (!hetznerServers || hetznerServers.length === 0) {
console.log(`node ${node.id} does not exist in the real world. Creating it now...`);
const hetznerServer = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('node'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
},
});
// First create BareMetal record
const baremetal = await this.cloudlyRef.baremetalManager.createBaremetalFromHetznerServer(hetznerServer);
const newNode = await ClusterNode.createFromHetznerServer(hetznerServer, cluster.id, baremetal.id);
await baremetal.assignNode(newNode.id);
}
}
}
}
}
public async getNodesByCluster(clusterArg: Cluster) {
const results = await this.CClusterNode.getInstances({
data: {
clusterId: clusterArg.id,
},
});
return results;
}
}

View File

@@ -1,42 +0,0 @@
import * as plugins from '../plugins.js';
/*
* cluster defines a swarmkit cluster
*/
@plugins.smartdata.Manager()
export class Server extends plugins.smartdata.SmartDataDbDoc<
Server,
plugins.servezoneInterfaces.data.IServer
> {
// STATIC
public static async createFromHetznerServer(
hetznerServerArg: plugins.hetznercloud.HetznerServer,
) {
const newServer = new Server();
newServer.id = plugins.smartunique.shortId(8);
const data: plugins.servezoneInterfaces.data.IServer['data'] = {
assignedClusterId: hetznerServerArg.data.labels.clusterId,
requiredDebianPackages: [],
sshKeys: [],
type: 'hetzner',
};
Object.assign(newServer, { data });
await newServer.save();
return newServer;
}
// INSTANCE
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.servezoneInterfaces.data.IServer['data'];
constructor() {
super();
}
public async getServices(): Promise<plugins.servezoneInterfaces.data.IService[]> {
return [];
}
}

View File

@@ -1,116 +0,0 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { Cluster } from '../manager.cluster/classes.cluster.js';
import { Server } from './classes.server.js';
import { CurlFresh } from './classes.curlfresh.js';
export class CloudlyServerManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public curlfreshInstance = new CurlFresh(this);
public hetznerAccount: plugins.hetznercloud.HetznerAccount;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CServer = plugins.smartdata.setDefaultManagerForDoc(this, Server);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
/**
* is used be serverconfig module on the server to get the actual server config
*/
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.config.IRequest_Any_Cloudly_GetServerConfig>(
'getServerConfig',
async (requestData) => {
const serverId = requestData.serverId;
const server = await this.CServer.getInstance({
id: serverId,
});
return {
configData: await server.createSavableObject(),
};
},
),
);
}
public async start() {
this.hetznerAccount = new plugins.hetznercloud.HetznerAccount(
this.cloudlyRef.config.data.hetznerToken,
);
}
public async stop() {}
/**
* creates the server infrastructure on hetzner
* ensures that there are exactly the reources that are needed
* no more, no less
*/
public async ensureServerInfrastructure() {
// get all clusters
const allClusters = await this.cloudlyRef.clusterManager.getAllClusters();
for (const cluster of allClusters) {
// Skip clusters that are not set up for Hetzner auto-provisioning
if (cluster.data.setupMode !== 'hetzner') {
console.log(`Skipping server provisioning for cluster ${cluster.id} - setupMode is ${cluster.data.setupMode || 'manual'}`);
continue;
}
// get existing servers
const servers = await this.getServersByCluster(cluster);
// if there is no server, create one
if (servers.length === 0) {
const server = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('server'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
},
userData: await this.curlfreshInstance.getServerUserData(),
});
const newServer = await Server.createFromHetznerServer(server);
console.log(`cluster created new server for cluster ${cluster.id}`);
} else {
console.log(
`cluster ${cluster.id} already has servers. Making sure that they actually exist in the real world...`,
);
// if there is a server, make sure that it exists
for (const server of servers) {
const hetznerServer = await this.hetznerAccount.getServersByLabel({
clusterId: cluster.id,
});
if (!hetznerServer) {
console.log(`server ${server.id} does not exist in the real world. Creating it now...`);
const hetznerServer = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('server'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
},
});
const newServer = await Server.createFromHetznerServer(hetznerServer);
}
}
}
}
}
public async getServersByCluster(clusterArg: Cluster) {
const results = await this.CServer.getInstances({
data: {
assignedClusterId: clusterArg.id,
},
});
return results;
}
}

View File

@@ -0,0 +1,255 @@
import * as plugins from '../plugins.js';
import type { Cloudly } from '../classes.cloudly.js';
import * as servezoneInterfaces from '@serve.zone/interfaces';
export class CloudlySettingsManager {
public cloudlyRef: Cloudly;
public readyDeferred = plugins.smartpromise.defer();
public settingsStore: plugins.smartdata.EasyStore<servezoneInterfaces.data.ICloudlySettings>;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
}
/**
* Initialize the settings manager and create the EasyStore
*/
public async init() {
this.settingsStore = await this.cloudlyRef.mongodbConnector.smartdataDb
.createEasyStore('cloudly-settings') as plugins.smartdata.EasyStore<servezoneInterfaces.data.ICloudlySettings>;
// Setup API route handlers
await this.setupRoutes();
this.readyDeferred.resolve();
}
/**
* Get all settings
*/
public async getSettings(): Promise<servezoneInterfaces.data.ICloudlySettings> {
await this.readyDeferred.promise;
return await this.settingsStore.readAll();
}
/**
* Get all settings with masked sensitive values (for API responses)
*/
public async getSettingsMasked(): Promise<servezoneInterfaces.data.ICloudlySettingsMasked> {
await this.readyDeferred.promise;
const settings = await this.getSettings();
const masked: servezoneInterfaces.data.ICloudlySettingsMasked = {};
for (const [key, value] of Object.entries(settings)) {
if (typeof value === 'string' && value.length > 4) {
// Mask the token, showing only last 4 characters
masked[key] = '****' + value.slice(-4);
} else {
masked[key] = value;
}
}
return masked;
}
/**
* Update multiple settings at once
*/
public async updateSettings(updates: Partial<servezoneInterfaces.data.ICloudlySettings>): Promise<void> {
await this.readyDeferred.promise;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined && value !== '') {
await this.settingsStore.writeKey(key as keyof servezoneInterfaces.data.ICloudlySettings, value);
} else if (value === '') {
// Empty string means clear the setting
await this.settingsStore.deleteKey(key as keyof servezoneInterfaces.data.ICloudlySettings);
}
}
}
/**
* Get a specific setting value
*/
public async getSetting<K extends keyof servezoneInterfaces.data.ICloudlySettings>(key: K): Promise<servezoneInterfaces.data.ICloudlySettings[K]> {
await this.readyDeferred.promise;
return await this.settingsStore.readKey(key);
}
/**
* Set a specific setting value
*/
public async setSetting<K extends keyof servezoneInterfaces.data.ICloudlySettings>(key: K, value: servezoneInterfaces.data.ICloudlySettings[K]): Promise<void> {
await this.readyDeferred.promise;
if (value !== undefined && value !== '') {
await this.settingsStore.writeKey(key, value);
}
}
/**
* Clear a specific setting
*/
public async clearSetting(key: keyof servezoneInterfaces.data.ICloudlySettings): Promise<void> {
await this.readyDeferred.promise;
await this.settingsStore.deleteKey(key);
}
/**
* Clear all settings
*/
public async clearAllSettings(): Promise<void> {
await this.readyDeferred.promise;
await this.settingsStore.wipe();
}
/**
* Test connection for a specific provider
*/
public async testProviderConnection(provider: string): Promise<{success: boolean; message: string}> {
await this.readyDeferred.promise;
try {
switch (provider) {
case 'hetzner':
const hetznerToken = await this.getSetting('hetznerToken');
if (!hetznerToken) {
return { success: false, message: 'No Hetzner token configured' };
}
// TODO: Implement actual Hetzner API test
return { success: true, message: 'Hetzner connection test successful' };
case 'cloudflare':
const cloudflareToken = await this.getSetting('cloudflareToken');
if (!cloudflareToken) {
return { success: false, message: 'No Cloudflare token configured' };
}
// TODO: Implement actual Cloudflare API test
return { success: true, message: 'Cloudflare connection test successful' };
case 'aws':
const awsKey = await this.getSetting('awsAccessKey');
const awsSecret = await this.getSetting('awsSecretKey');
if (!awsKey || !awsSecret) {
return { success: false, message: 'AWS credentials not configured' };
}
// TODO: Implement actual AWS API test
return { success: true, message: 'AWS connection test successful' };
case 'digitalocean':
const doToken = await this.getSetting('digitalOceanToken');
if (!doToken) {
return { success: false, message: 'No DigitalOcean token configured' };
}
// TODO: Implement actual DigitalOcean API test
return { success: true, message: 'DigitalOcean connection test successful' };
case 'azure':
const azureClientId = await this.getSetting('azureClientId');
const azureClientSecret = await this.getSetting('azureClientSecret');
const azureTenantId = await this.getSetting('azureTenantId');
if (!azureClientId || !azureClientSecret || !azureTenantId) {
return { success: false, message: 'Azure credentials not configured' };
}
// TODO: Implement actual Azure API test
return { success: true, message: 'Azure connection test successful' };
default:
return { success: false, message: `Unknown provider: ${provider}` };
}
} catch (error) {
return { success: false, message: `Connection test failed: ${error.message}` };
}
}
/**
* Setup API route handlers for settings management
*/
private async setupRoutes() {
// Get Settings Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSettings>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSettings>(
'getSettings',
async (requestData) => {
// TODO: Add authentication check for admin users
const maskedSettings = await this.getSettingsMasked();
return {
settings: maskedSettings
};
}
)
);
// Update Settings Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_UpdateSettings>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_UpdateSettings>(
'updateSettings',
async (requestData) => {
// TODO: Add authentication check for admin users
try {
await this.updateSettings(requestData.updates);
return {
success: true,
message: 'Settings updated successfully'
};
} catch (error) {
return {
success: false,
message: `Failed to update settings: ${error.message}`
};
}
}
)
);
// Clear Setting Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_ClearSetting>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_ClearSetting>(
'clearSetting',
async (requestData) => {
// TODO: Add authentication check for admin users
try {
await this.clearSetting(requestData.key);
return {
success: true,
message: `Setting ${requestData.key} cleared successfully`
};
} catch (error) {
return {
success: false,
message: `Failed to clear setting: ${error.message}`
};
}
}
)
);
// Test Provider Connection Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_TestProviderConnection>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_TestProviderConnection>(
'testProviderConnection',
async (requestData) => {
// TODO: Add authentication check for admin users
const testResult = await this.testProviderConnection(requestData.provider);
return {
success: testResult.success,
message: testResult.message,
connectionValid: testResult.success
};
}
)
);
// Get Single Setting Handler (for internal use)
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSetting>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSetting>(
'getSetting',
async (requestData) => {
// TODO: Add authentication check for admin users
const value = await this.getSetting(requestData.key);
return {
value
};
}
)
);
}
}

View File

@@ -0,0 +1 @@
export * from './classes.settingsmanager.js';