|
|
|
@@ -0,0 +1,586 @@
|
|
|
|
|
import * as plugins from './corestore.plugins.js';
|
|
|
|
|
import type * as interfaces from './corestore.interfaces.js';
|
|
|
|
|
|
|
|
|
|
export interface ICoreStoreOptions {
|
|
|
|
|
dataDir?: string;
|
|
|
|
|
bindAddress?: string;
|
|
|
|
|
publicHost?: string;
|
|
|
|
|
controlPort?: number;
|
|
|
|
|
s3Port?: number;
|
|
|
|
|
dbPort?: number;
|
|
|
|
|
region?: string;
|
|
|
|
|
apiToken?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TResolvedCoreStoreOptions = Required<Omit<ICoreStoreOptions, 'apiToken'>> & {
|
|
|
|
|
apiToken?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class CoreStore {
|
|
|
|
|
private options: TResolvedCoreStoreOptions;
|
|
|
|
|
private smartStorage: plugins.smartstorage.SmartStorage | null = null;
|
|
|
|
|
private smartDb: plugins.smartdb.SmartdbServer | null = null;
|
|
|
|
|
private controlServer: plugins.http.Server | null = null;
|
|
|
|
|
private manifest: interfaces.ICoreStoreManifest = { version: 1, services: {} };
|
|
|
|
|
private secretFile: interfaces.ICoreStoreSecretFile | null = null;
|
|
|
|
|
|
|
|
|
|
constructor(optionsArg: ICoreStoreOptions = {}) {
|
|
|
|
|
this.options = {
|
|
|
|
|
dataDir: optionsArg.dataDir || process.env.CORESTORE_DATA_DIR || '/data/corestore',
|
|
|
|
|
bindAddress: optionsArg.bindAddress || process.env.CORESTORE_BIND_ADDRESS || '0.0.0.0',
|
|
|
|
|
publicHost: optionsArg.publicHost || process.env.CORESTORE_PUBLIC_HOST || 'corestore',
|
|
|
|
|
controlPort: optionsArg.controlPort || this.getNumberEnv('CORESTORE_CONTROL_PORT', 3000),
|
|
|
|
|
s3Port: optionsArg.s3Port || this.getNumberEnv('CORESTORE_S3_PORT', 9000),
|
|
|
|
|
dbPort: optionsArg.dbPort || this.getNumberEnv('CORESTORE_DB_PORT', 27017),
|
|
|
|
|
region: optionsArg.region || process.env.CORESTORE_REGION || 'us-east-1',
|
|
|
|
|
...(optionsArg.apiToken || process.env.CORESTORE_API_TOKEN
|
|
|
|
|
? { apiToken: optionsArg.apiToken || process.env.CORESTORE_API_TOKEN }
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async start() {
|
|
|
|
|
await this.ensureDirectories();
|
|
|
|
|
this.secretFile = await this.loadOrCreateSecretFile();
|
|
|
|
|
this.manifest = await this.loadManifest();
|
|
|
|
|
await this.startSmartStorage();
|
|
|
|
|
await this.startSmartDb();
|
|
|
|
|
await this.startControlApi();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async stop() {
|
|
|
|
|
if (this.controlServer) {
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
this.controlServer!.close((errorArg) => (errorArg ? reject(errorArg) : resolve()));
|
|
|
|
|
});
|
|
|
|
|
this.controlServer = null;
|
|
|
|
|
}
|
|
|
|
|
if (this.smartStorage) {
|
|
|
|
|
await this.smartStorage.stop();
|
|
|
|
|
this.smartStorage = null;
|
|
|
|
|
}
|
|
|
|
|
if (this.smartDb) {
|
|
|
|
|
await this.smartDb.stop();
|
|
|
|
|
this.smartDb = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getNumberEnv(envNameArg: string, defaultArg: number) {
|
|
|
|
|
const value = process.env[envNameArg];
|
|
|
|
|
const parsedValue = value ? Number(value) : NaN;
|
|
|
|
|
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : defaultArg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getStorageDir() {
|
|
|
|
|
return plugins.path.join(this.options.dataDir, 'smartstorage');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getDbDir() {
|
|
|
|
|
return plugins.path.join(this.options.dataDir, 'smartdb');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getManifestPath() {
|
|
|
|
|
return plugins.path.join(this.options.dataDir, 'corestore-manifest.json');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getSecretPath() {
|
|
|
|
|
return plugins.path.join(this.options.dataDir, 'corestore-secret.json');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async ensureDirectories() {
|
|
|
|
|
await plugins.fs.mkdir(this.options.dataDir, { recursive: true });
|
|
|
|
|
await plugins.fs.mkdir(this.getStorageDir(), { recursive: true });
|
|
|
|
|
await plugins.fs.mkdir(this.getDbDir(), { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async loadOrCreateSecretFile(): Promise<interfaces.ICoreStoreSecretFile> {
|
|
|
|
|
const envMasterSecret = process.env.CORESTORE_MASTER_SECRET;
|
|
|
|
|
const existingSecretFile = await this.readJsonFile<interfaces.ICoreStoreSecretFile>(this.getSecretPath());
|
|
|
|
|
const masterSecret = envMasterSecret || existingSecretFile?.masterSecret || plugins.crypto.randomBytes(32).toString('hex');
|
|
|
|
|
const secretFile: interfaces.ICoreStoreSecretFile = {
|
|
|
|
|
masterSecret,
|
|
|
|
|
dbRootPassword:
|
|
|
|
|
process.env.CORESTORE_DB_ROOT_PASSWORD ||
|
|
|
|
|
existingSecretFile?.dbRootPassword ||
|
|
|
|
|
this.deriveHex(masterSecret, 'db-root-password', 48),
|
|
|
|
|
s3AdminAccessKeyId:
|
|
|
|
|
process.env.CORESTORE_S3_ADMIN_ACCESS_KEY_ID ||
|
|
|
|
|
existingSecretFile?.s3AdminAccessKeyId ||
|
|
|
|
|
`CSADMIN${this.deriveHex(masterSecret, 's3-admin-access-key', 20).toUpperCase()}`,
|
|
|
|
|
s3AdminSecretAccessKey:
|
|
|
|
|
process.env.CORESTORE_S3_ADMIN_SECRET_ACCESS_KEY ||
|
|
|
|
|
existingSecretFile?.s3AdminSecretAccessKey ||
|
|
|
|
|
this.deriveHex(masterSecret, 's3-admin-secret-key', 64),
|
|
|
|
|
};
|
|
|
|
|
await this.writeJsonFile(this.getSecretPath(), secretFile);
|
|
|
|
|
return secretFile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async loadManifest(): Promise<interfaces.ICoreStoreManifest> {
|
|
|
|
|
return (await this.readJsonFile<interfaces.ICoreStoreManifest>(this.getManifestPath())) || {
|
|
|
|
|
version: 1,
|
|
|
|
|
services: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async saveManifest() {
|
|
|
|
|
await this.writeJsonFile(this.getManifestPath(), this.manifest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async readJsonFile<T>(filePathArg: string): Promise<T | null> {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(await plugins.fs.readFile(filePathArg, 'utf8')) as T;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async writeJsonFile(filePathArg: string, dataArg: unknown) {
|
|
|
|
|
await plugins.fs.writeFile(`${filePathArg}.tmp`, `${JSON.stringify(dataArg, null, 2)}\n`);
|
|
|
|
|
await plugins.fs.rename(`${filePathArg}.tmp`, filePathArg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private deriveHex(secretArg: string, labelArg: string, lengthArg: number) {
|
|
|
|
|
return plugins.crypto.createHmac('sha256', secretArg).update(labelArg).digest('hex').slice(0, lengthArg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async startSmartStorage() {
|
|
|
|
|
if (!this.secretFile) {
|
|
|
|
|
throw new Error('corestore secret file must be loaded before smartstorage starts');
|
|
|
|
|
}
|
|
|
|
|
this.smartStorage = await plugins.smartstorage.SmartStorage.createAndStart({
|
|
|
|
|
server: {
|
|
|
|
|
address: this.options.bindAddress,
|
|
|
|
|
port: this.options.s3Port,
|
|
|
|
|
region: this.options.region,
|
|
|
|
|
silent: true,
|
|
|
|
|
},
|
|
|
|
|
storage: {
|
|
|
|
|
directory: this.getStorageDir(),
|
|
|
|
|
cleanSlate: false,
|
|
|
|
|
},
|
|
|
|
|
auth: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
credentials: [
|
|
|
|
|
{
|
|
|
|
|
accessKeyId: this.secretFile.s3AdminAccessKeyId,
|
|
|
|
|
secretAccessKey: this.secretFile.s3AdminSecretAccessKey,
|
|
|
|
|
region: this.options.region,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
logging: {
|
|
|
|
|
enabled: process.env.CORESTORE_VERBOSE === 'true',
|
|
|
|
|
level: process.env.CORESTORE_VERBOSE === 'true' ? 'info' : 'error',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async startSmartDb() {
|
|
|
|
|
if (!this.secretFile) {
|
|
|
|
|
throw new Error('corestore secret file must be loaded before smartdb starts');
|
|
|
|
|
}
|
|
|
|
|
this.smartDb = new plugins.smartdb.SmartdbServer({
|
|
|
|
|
host: this.options.bindAddress,
|
|
|
|
|
port: this.options.dbPort,
|
|
|
|
|
storage: 'file',
|
|
|
|
|
storagePath: this.getDbDir(),
|
|
|
|
|
auth: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
usersPath: plugins.path.join(this.getDbDir(), 'smartdb-users.json'),
|
|
|
|
|
users: [
|
|
|
|
|
{
|
|
|
|
|
username: process.env.CORESTORE_DB_ROOT_USER || 'corestore_root',
|
|
|
|
|
password: this.secretFile.dbRootPassword,
|
|
|
|
|
database: 'admin',
|
|
|
|
|
roles: ['root'],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await this.smartDb.start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async startControlApi() {
|
|
|
|
|
this.controlServer = plugins.http.createServer(async (reqArg, resArg) => {
|
|
|
|
|
try {
|
|
|
|
|
await this.handleControlRequest(reqArg, resArg);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.sendJson(resArg, 500, {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: (error as Error).message,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
|
this.controlServer!.listen(this.options.controlPort, this.options.bindAddress, resolve);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleControlRequest(
|
|
|
|
|
reqArg: plugins.http.IncomingMessage,
|
|
|
|
|
resArg: plugins.http.ServerResponse,
|
|
|
|
|
) {
|
|
|
|
|
const url = new URL(reqArg.url || '/', `http://${reqArg.headers.host || 'localhost'}`);
|
|
|
|
|
const method = reqArg.method || 'GET';
|
|
|
|
|
|
|
|
|
|
if (method === 'GET' && url.pathname === '/health') {
|
|
|
|
|
this.sendJson(resArg, 200, await this.getHealth());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.isAuthorized(reqArg)) {
|
|
|
|
|
this.sendJson(resArg, 401, { ok: false, error: 'unauthorized' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (method === 'GET' && url.pathname === '/metrics') {
|
|
|
|
|
this.sendJson(resArg, 200, await this.getMetrics());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (method === 'GET' && url.pathname === '/resources') {
|
|
|
|
|
this.sendJson(resArg, 200, {
|
|
|
|
|
services: Object.values(this.manifest.services).map((serviceArg) => ({
|
|
|
|
|
serviceId: serviceArg.serviceId,
|
|
|
|
|
serviceName: serviceArg.serviceName,
|
|
|
|
|
resources: Object.values(serviceArg.resources).map((resourceArg) => ({
|
|
|
|
|
capability: resourceArg!.capability,
|
|
|
|
|
provider: resourceArg!.provider,
|
|
|
|
|
resourceName: resourceArg!.resourceName,
|
|
|
|
|
createdAt: resourceArg!.createdAt,
|
|
|
|
|
})),
|
|
|
|
|
updatedAt: serviceArg.updatedAt,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (method === 'POST' && url.pathname === '/resources/provision') {
|
|
|
|
|
const body = await this.readRequestBody<interfaces.ICoreStoreProvisionRequest>(reqArg);
|
|
|
|
|
this.sendJson(resArg, 200, await this.provisionForService(body));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (method === 'POST' && url.pathname === '/resources/deprovision') {
|
|
|
|
|
const body = await this.readRequestBody<interfaces.ICoreStoreDeprovisionRequest>(reqArg);
|
|
|
|
|
this.sendJson(resArg, 200, await this.deprovisionForService(body));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.sendJson(resArg, 404, { ok: false, error: 'not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isAuthorized(reqArg: plugins.http.IncomingMessage) {
|
|
|
|
|
if (!this.options.apiToken) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const authHeader = reqArg.headers.authorization;
|
|
|
|
|
const tokenHeader = reqArg.headers['x-corestore-token'];
|
|
|
|
|
return (
|
|
|
|
|
authHeader === `Bearer ${this.options.apiToken}` ||
|
|
|
|
|
tokenHeader === this.options.apiToken ||
|
|
|
|
|
(Array.isArray(tokenHeader) && tokenHeader.includes(this.options.apiToken))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async readRequestBody<T>(reqArg: plugins.http.IncomingMessage): Promise<T> {
|
|
|
|
|
const chunks: Buffer[] = [];
|
|
|
|
|
for await (const chunk of reqArg) {
|
|
|
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
|
|
|
}
|
|
|
|
|
const rawBody = Buffer.concat(chunks).toString('utf8').trim();
|
|
|
|
|
if (!rawBody) {
|
|
|
|
|
return {} as T;
|
|
|
|
|
}
|
|
|
|
|
return JSON.parse(rawBody) as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sendJson(resArg: plugins.http.ServerResponse, statusCodeArg: number, dataArg: unknown) {
|
|
|
|
|
const body = JSON.stringify(dataArg, null, 2);
|
|
|
|
|
resArg.writeHead(statusCodeArg, {
|
|
|
|
|
'content-type': 'application/json; charset=utf-8',
|
|
|
|
|
'content-length': Buffer.byteLength(body),
|
|
|
|
|
});
|
|
|
|
|
resArg.end(body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getHealth() {
|
|
|
|
|
const [dbHealth, storageHealth] = await Promise.all([
|
|
|
|
|
this.smartDb?.getHealth(),
|
|
|
|
|
this.smartStorage?.getHealth(),
|
|
|
|
|
]);
|
|
|
|
|
return {
|
|
|
|
|
ok: Boolean(dbHealth?.running && storageHealth?.ok),
|
|
|
|
|
control: {
|
|
|
|
|
port: this.options.controlPort,
|
|
|
|
|
},
|
|
|
|
|
database: {
|
|
|
|
|
host: this.options.publicHost,
|
|
|
|
|
port: this.options.dbPort,
|
|
|
|
|
health: dbHealth,
|
|
|
|
|
},
|
|
|
|
|
objectstorage: {
|
|
|
|
|
host: this.options.publicHost,
|
|
|
|
|
port: this.options.s3Port,
|
|
|
|
|
health: storageHealth,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getMetrics() {
|
|
|
|
|
const [dbMetrics, storageMetrics] = await Promise.all([
|
|
|
|
|
this.smartDb?.getMetrics(),
|
|
|
|
|
this.smartStorage?.getMetrics(),
|
|
|
|
|
]);
|
|
|
|
|
return {
|
|
|
|
|
database: dbMetrics,
|
|
|
|
|
objectstorage: storageMetrics,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private normalizeCapabilities(
|
|
|
|
|
requestArg: interfaces.ICoreStoreProvisionRequest,
|
|
|
|
|
): interfaces.TCoreStoreCapability[] {
|
|
|
|
|
const capabilities = new Set<interfaces.TCoreStoreCapability>();
|
|
|
|
|
for (const capability of requestArg.capabilities || []) {
|
|
|
|
|
capabilities.add(capability);
|
|
|
|
|
}
|
|
|
|
|
if (requestArg.database || requestArg.db) {
|
|
|
|
|
capabilities.add('database');
|
|
|
|
|
}
|
|
|
|
|
if (requestArg.objectstorage || requestArg.s3) {
|
|
|
|
|
capabilities.add('objectstorage');
|
|
|
|
|
}
|
|
|
|
|
if (capabilities.size === 0) {
|
|
|
|
|
throw new Error('No corestore capabilities requested');
|
|
|
|
|
}
|
|
|
|
|
return Array.from(capabilities);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async provisionForService(
|
|
|
|
|
requestArg: interfaces.ICoreStoreProvisionRequest,
|
|
|
|
|
): Promise<interfaces.ICoreStoreProvisionResponse> {
|
|
|
|
|
if (!requestArg.serviceId) {
|
|
|
|
|
throw new Error('serviceId is required');
|
|
|
|
|
}
|
|
|
|
|
const capabilities = this.normalizeCapabilities(requestArg);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const serviceEntry = this.manifest.services[requestArg.serviceId] || {
|
|
|
|
|
serviceId: requestArg.serviceId,
|
|
|
|
|
serviceName: requestArg.serviceName,
|
|
|
|
|
resources: {},
|
|
|
|
|
env: {},
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
};
|
|
|
|
|
serviceEntry.serviceName = requestArg.serviceName || serviceEntry.serviceName;
|
|
|
|
|
|
|
|
|
|
for (const capability of capabilities) {
|
|
|
|
|
if (serviceEntry.resources[capability]) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const resource = capability === 'database'
|
|
|
|
|
? await this.provisionDatabase(requestArg)
|
|
|
|
|
: await this.provisionObjectStorage(requestArg);
|
|
|
|
|
serviceEntry.resources[capability] = resource;
|
|
|
|
|
serviceEntry.env = {
|
|
|
|
|
...serviceEntry.env,
|
|
|
|
|
...resource.env,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
serviceEntry.updatedAt = Date.now();
|
|
|
|
|
this.manifest.services[requestArg.serviceId] = serviceEntry;
|
|
|
|
|
await this.saveManifest();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
serviceId: serviceEntry.serviceId,
|
|
|
|
|
serviceName: serviceEntry.serviceName,
|
|
|
|
|
resources: Object.values(serviceEntry.resources) as interfaces.TCoreStoreResource[],
|
|
|
|
|
env: serviceEntry.env,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async deprovisionForService(requestArg: interfaces.ICoreStoreDeprovisionRequest) {
|
|
|
|
|
if (!requestArg.serviceId) {
|
|
|
|
|
throw new Error('serviceId is required');
|
|
|
|
|
}
|
|
|
|
|
const serviceEntry = this.manifest.services[requestArg.serviceId];
|
|
|
|
|
if (!serviceEntry) {
|
|
|
|
|
return { ok: true, deleted: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deleted: Array<{ capability: string; resourceName: string }> = [];
|
|
|
|
|
const databaseResource = serviceEntry.resources.database as interfaces.ICoreStoreDatabaseResource | undefined;
|
|
|
|
|
if (databaseResource && this.smartDb) {
|
|
|
|
|
await this.smartDb.deleteDatabaseTenant({
|
|
|
|
|
databaseName: databaseResource.databaseName,
|
|
|
|
|
username: databaseResource.username,
|
|
|
|
|
}).catch((errorArg) => {
|
|
|
|
|
console.warn(`Failed to delete database tenant ${databaseResource.databaseName}: ${(errorArg as Error).message}`);
|
|
|
|
|
});
|
|
|
|
|
deleted.push({ capability: 'database', resourceName: databaseResource.resourceName });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const storageResource = serviceEntry.resources.objectstorage as interfaces.ICoreStoreObjectStorageResource | undefined;
|
|
|
|
|
if (storageResource && this.smartStorage) {
|
|
|
|
|
await this.smartStorage.deleteBucketTenant({
|
|
|
|
|
bucketName: storageResource.bucketName,
|
|
|
|
|
}).catch((errorArg) => {
|
|
|
|
|
console.warn(`Failed to delete bucket tenant ${storageResource.bucketName}: ${(errorArg as Error).message}`);
|
|
|
|
|
});
|
|
|
|
|
deleted.push({ capability: 'objectstorage', resourceName: storageResource.resourceName });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
delete this.manifest.services[requestArg.serviceId];
|
|
|
|
|
await this.saveManifest();
|
|
|
|
|
return { ok: true, deleted };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async provisionDatabase(
|
|
|
|
|
requestArg: interfaces.ICoreStoreProvisionRequest,
|
|
|
|
|
): Promise<interfaces.ICoreStoreDatabaseResource> {
|
|
|
|
|
if (!this.smartDb || !this.secretFile) {
|
|
|
|
|
throw new Error('smartdb is not running');
|
|
|
|
|
}
|
|
|
|
|
const names = this.getResourceNames(requestArg.serviceId, requestArg.serviceName);
|
|
|
|
|
const password = this.deriveHex(this.secretFile.masterSecret, `db-password:${requestArg.serviceId}`, 48);
|
|
|
|
|
const existingTenants = await this.smartDb.listDatabaseTenants().catch(() => []);
|
|
|
|
|
const existingTenant = existingTenants.find((tenantArg) => {
|
|
|
|
|
return tenantArg.databaseName === names.databaseName && tenantArg.username === names.databaseUsername;
|
|
|
|
|
});
|
|
|
|
|
if (existingTenant) {
|
|
|
|
|
await this.smartDb.rotateDatabaseTenantPassword({
|
|
|
|
|
username: names.databaseUsername,
|
|
|
|
|
password,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await this.smartDb.createDatabaseTenant({
|
|
|
|
|
databaseName: names.databaseName,
|
|
|
|
|
username: names.databaseUsername,
|
|
|
|
|
password,
|
|
|
|
|
roles: ['readWrite'],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const mongodbUri = this.buildMongoDbUri(names.databaseName, names.databaseUsername, password);
|
|
|
|
|
const env = {
|
|
|
|
|
MONGODB_URI: mongodbUri,
|
|
|
|
|
MONGODB_HOST: this.options.publicHost,
|
|
|
|
|
MONGODB_PORT: String(this.options.dbPort),
|
|
|
|
|
MONGODB_DATABASE: names.databaseName,
|
|
|
|
|
MONGODB_USERNAME: names.databaseUsername,
|
|
|
|
|
MONGODB_PASSWORD: password,
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
capability: 'database',
|
|
|
|
|
provider: 'smartdb',
|
|
|
|
|
resourceName: names.databaseName,
|
|
|
|
|
databaseName: names.databaseName,
|
|
|
|
|
username: names.databaseUsername,
|
|
|
|
|
env,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async provisionObjectStorage(
|
|
|
|
|
requestArg: interfaces.ICoreStoreProvisionRequest,
|
|
|
|
|
): Promise<interfaces.ICoreStoreObjectStorageResource> {
|
|
|
|
|
if (!this.smartStorage || !this.secretFile) {
|
|
|
|
|
throw new Error('smartstorage is not running');
|
|
|
|
|
}
|
|
|
|
|
const names = this.getResourceNames(requestArg.serviceId, requestArg.serviceName);
|
|
|
|
|
const accessKeyId = `CS${this.deriveHex(this.secretFile.masterSecret, `s3-access:${requestArg.serviceId}`, 22).toUpperCase()}`;
|
|
|
|
|
const secretAccessKey = this.deriveHex(this.secretFile.masterSecret, `s3-secret:${requestArg.serviceId}`, 64);
|
|
|
|
|
const existingTenants = await this.smartStorage.listBucketTenants().catch(() => []);
|
|
|
|
|
const existingTenant = existingTenants.find((tenantArg) => tenantArg.bucketName === names.bucketName);
|
|
|
|
|
if (existingTenant) {
|
|
|
|
|
await this.smartStorage.rotateBucketTenantCredentials({
|
|
|
|
|
bucketName: names.bucketName,
|
|
|
|
|
accessKeyId,
|
|
|
|
|
secretAccessKey,
|
|
|
|
|
region: this.options.region,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await this.smartStorage.createBucketTenant({
|
|
|
|
|
bucketName: names.bucketName,
|
|
|
|
|
accessKeyId,
|
|
|
|
|
secretAccessKey,
|
|
|
|
|
region: this.options.region,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const endpointUrl = `http://${this.options.publicHost}:${this.options.s3Port}`;
|
|
|
|
|
const env = {
|
|
|
|
|
S3_ENDPOINT: endpointUrl,
|
|
|
|
|
S3_ENDPOINT_HOST: this.options.publicHost,
|
|
|
|
|
S3_PORT: String(this.options.s3Port),
|
|
|
|
|
S3_REGION: this.options.region,
|
|
|
|
|
S3_BUCKET: names.bucketName,
|
|
|
|
|
S3_ACCESS_KEY: accessKeyId,
|
|
|
|
|
S3_ACCESS_KEY_ID: accessKeyId,
|
|
|
|
|
S3_SECRET_KEY: secretAccessKey,
|
|
|
|
|
S3_SECRET_ACCESS_KEY: secretAccessKey,
|
|
|
|
|
S3_USE_SSL: 'false',
|
|
|
|
|
AWS_ACCESS_KEY_ID: accessKeyId,
|
|
|
|
|
AWS_SECRET_ACCESS_KEY: secretAccessKey,
|
|
|
|
|
AWS_REGION: this.options.region,
|
|
|
|
|
AWS_ENDPOINT_URL: endpointUrl,
|
|
|
|
|
AWS_S3_FORCE_PATH_STYLE: 'true',
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
capability: 'objectstorage',
|
|
|
|
|
provider: 'smartstorage',
|
|
|
|
|
resourceName: names.bucketName,
|
|
|
|
|
bucketName: names.bucketName,
|
|
|
|
|
accessKeyId,
|
|
|
|
|
env,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getResourceNames(serviceIdArg: string, serviceNameArg?: string) {
|
|
|
|
|
const hash = plugins.crypto.createHash('sha1').update(serviceIdArg).digest('hex').slice(0, 12);
|
|
|
|
|
const base = serviceNameArg || serviceIdArg;
|
|
|
|
|
const bucketBase = this.sanitizeBucketPart(base).slice(0, 42) || 'service';
|
|
|
|
|
const databaseBase = this.sanitizeIdentifierPart(base).slice(0, 36) || 'service';
|
|
|
|
|
return {
|
|
|
|
|
bucketName: this.trimBucketName(`sz-${bucketBase}-${hash}`),
|
|
|
|
|
databaseName: `sz_${databaseBase}_${hash}`,
|
|
|
|
|
databaseUsername: `u_${databaseBase}_${hash}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sanitizeBucketPart(valueArg: string) {
|
|
|
|
|
return valueArg
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9-]+/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '')
|
|
|
|
|
.replace(/--+/g, '-');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sanitizeIdentifierPart(valueArg: string) {
|
|
|
|
|
return valueArg
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9_]+/g, '_')
|
|
|
|
|
.replace(/^_+|_+$/g, '')
|
|
|
|
|
.replace(/_+/g, '_');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private trimBucketName(bucketNameArg: string) {
|
|
|
|
|
const trimmed = bucketNameArg.slice(0, 63).replace(/^-+|-+$/g, '');
|
|
|
|
|
return trimmed.length >= 3 ? trimmed : `${trimmed}000`.slice(0, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildMongoDbUri(databaseNameArg: string, usernameArg: string, passwordArg: string) {
|
|
|
|
|
const auth = `${encodeURIComponent(usernameArg)}:${encodeURIComponent(passwordArg)}@`;
|
|
|
|
|
const host = `${this.options.publicHost}:${this.options.dbPort}`;
|
|
|
|
|
const query = new URLSearchParams({
|
|
|
|
|
authSource: databaseNameArg,
|
|
|
|
|
directConnection: 'true',
|
|
|
|
|
});
|
|
|
|
|
return `mongodb://${auth}${host}/${encodeURIComponent(databaseNameArg)}?${query.toString()}`;
|
|
|
|
|
}
|
|
|
|
|
}
|