feat(bucket-tenants): add persisted bucket-scoped tenant credentials with bucket export and import APIs
This commit is contained in:
+295
@@ -7,10 +7,14 @@ import * as paths from './paths.js';
|
||||
export interface IStorageCredential {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface IStorageCredentialMetadata {
|
||||
accessKeyId: string;
|
||||
bucketName?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +159,88 @@ export interface IStorageStats {
|
||||
storageLocations?: IStorageLocationSummary[];
|
||||
}
|
||||
|
||||
export interface IBucketTenantInput {
|
||||
bucketName: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface IDeleteBucketTenantInput {
|
||||
bucketName: string;
|
||||
accessKeyId?: string;
|
||||
}
|
||||
|
||||
export interface IBucketTenantMetadata {
|
||||
bucketName: string;
|
||||
accessKeyId: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface IBucketTenantDescriptor extends plugins.tsclass.storage.IS3Descriptor {
|
||||
endpoint: string;
|
||||
port: number;
|
||||
region: string;
|
||||
bucket: string;
|
||||
bucketName: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
accessKey: string;
|
||||
accessSecret: string;
|
||||
useSsl: boolean;
|
||||
ssl: boolean;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IBucketExportObject {
|
||||
key: string;
|
||||
size: number;
|
||||
md5: string;
|
||||
metadata: Record<string, string>;
|
||||
dataHex: string;
|
||||
}
|
||||
|
||||
export interface IBucketExport {
|
||||
format: 'smartstorage.bucket.v1';
|
||||
bucketName: string;
|
||||
exportedAt: number;
|
||||
objects: IBucketExportObject[];
|
||||
}
|
||||
|
||||
export interface IExportBucketInput {
|
||||
bucketName: string;
|
||||
}
|
||||
|
||||
export interface IImportBucketInput {
|
||||
bucketName: string;
|
||||
source: IBucketExport;
|
||||
}
|
||||
|
||||
export interface ISmartStorageHealth {
|
||||
ok: boolean;
|
||||
running: boolean;
|
||||
storageDirectory: string;
|
||||
auth: {
|
||||
enabled: boolean;
|
||||
credentialCount: number;
|
||||
tenantCredentialCount: number;
|
||||
};
|
||||
bucketCount: number;
|
||||
objectCount: number;
|
||||
totalBytes: number;
|
||||
cluster: IClusterHealth;
|
||||
}
|
||||
|
||||
export interface ISmartStorageMetrics {
|
||||
bucketCount: number;
|
||||
objectCount: number;
|
||||
totalBytes: number;
|
||||
authCredentialCount: number;
|
||||
tenantCredentialCount: number;
|
||||
clusterEnabled: boolean;
|
||||
prometheusText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known peer status from the local node's current cluster view.
|
||||
*/
|
||||
@@ -306,6 +392,14 @@ function mergeConfig(userConfig: ISmartStorageConfig): Required<ISmartStorageCon
|
||||
} as Required<ISmartStorageConfig>;
|
||||
}
|
||||
|
||||
function createAccessKeyId(): string {
|
||||
return `SS${plugins.crypto.randomBytes(10).toString('hex').toUpperCase()}`;
|
||||
}
|
||||
|
||||
function createSecretAccessKey(): string {
|
||||
return plugins.crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC command type map for RustBridge
|
||||
*/
|
||||
@@ -313,6 +407,35 @@ type TRustStorageCommands = {
|
||||
start: { params: { config: Required<ISmartStorageConfig> }; result: {} };
|
||||
stop: { params: {}; result: {} };
|
||||
createBucket: { params: { name: string }; result: {} };
|
||||
createBucketTenant: {
|
||||
params: {
|
||||
bucketName: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
region?: string;
|
||||
};
|
||||
result: IStorageCredential;
|
||||
};
|
||||
deleteBucketTenant: {
|
||||
params: { bucketName: string; accessKeyId?: string };
|
||||
result: {};
|
||||
};
|
||||
rotateBucketTenantCredentials: {
|
||||
params: {
|
||||
bucketName: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
region?: string;
|
||||
};
|
||||
result: IStorageCredential;
|
||||
};
|
||||
listBucketTenants: { params: {}; result: IBucketTenantMetadata[] };
|
||||
getBucketTenantCredential: {
|
||||
params: { bucketName: string };
|
||||
result: IStorageCredential;
|
||||
};
|
||||
exportBucket: { params: { bucketName: string }; result: IBucketExport };
|
||||
importBucket: { params: { bucketName: string; source: IBucketExport }; result: {} };
|
||||
getStorageStats: { params: {}; result: IStorageStats };
|
||||
listBucketSummaries: { params: {}; result: IBucketSummary[] };
|
||||
listCredentials: { params: {}; result: IStorageCredentialMetadata[] };
|
||||
@@ -334,6 +457,7 @@ export class SmartStorage {
|
||||
// INSTANCE
|
||||
public config: Required<ISmartStorageConfig>;
|
||||
private bridge: InstanceType<typeof plugins.RustBridge<TRustStorageCommands>>;
|
||||
private running = false;
|
||||
|
||||
constructor(configArg: ISmartStorageConfig = {}) {
|
||||
this.config = mergeConfig(configArg);
|
||||
@@ -353,6 +477,7 @@ export class SmartStorage {
|
||||
throw new Error('Failed to spawn ruststorage binary. Make sure it is compiled (pnpm build).');
|
||||
}
|
||||
await this.bridge.sendCommand('start', { config: this.config });
|
||||
this.running = true;
|
||||
|
||||
if (!this.config.server.silent) {
|
||||
console.log('storage server is running');
|
||||
@@ -382,11 +507,110 @@ export class SmartStorage {
|
||||
};
|
||||
}
|
||||
|
||||
private getEndpoint(): string {
|
||||
return this.config.server.address === '0.0.0.0' ? 'localhost' : this.config.server.address!;
|
||||
}
|
||||
|
||||
private buildBucketTenantDescriptor(
|
||||
credential: IStorageCredential,
|
||||
bucketNameArg: string,
|
||||
): IBucketTenantDescriptor {
|
||||
const bucketName = credential.bucketName || bucketNameArg;
|
||||
const region = credential.region || this.config.server.region || 'us-east-1';
|
||||
const endpoint = this.getEndpoint();
|
||||
const port = this.config.server.port!;
|
||||
const useSsl = false;
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
port,
|
||||
region,
|
||||
bucket: bucketName,
|
||||
bucketName,
|
||||
accessKeyId: credential.accessKeyId,
|
||||
secretAccessKey: credential.secretAccessKey,
|
||||
accessKey: credential.accessKeyId,
|
||||
accessSecret: credential.secretAccessKey,
|
||||
useSsl,
|
||||
ssl: useSsl,
|
||||
env: {
|
||||
S3_ENDPOINT: endpoint,
|
||||
S3_PORT: String(port),
|
||||
S3_REGION: region,
|
||||
S3_BUCKET: bucketName,
|
||||
S3_ACCESS_KEY_ID: credential.accessKeyId,
|
||||
S3_SECRET_ACCESS_KEY: credential.secretAccessKey,
|
||||
S3_USE_SSL: String(useSsl),
|
||||
AWS_ACCESS_KEY_ID: credential.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: credential.secretAccessKey,
|
||||
AWS_REGION: region,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private assertTenantAuthEnabled(): void {
|
||||
if (!this.config.auth.enabled) {
|
||||
throw new Error('Bucket tenant APIs require auth.enabled=true.');
|
||||
}
|
||||
}
|
||||
|
||||
public async createBucket(bucketNameArg: string) {
|
||||
await this.bridge.sendCommand('createBucket', { name: bucketNameArg });
|
||||
return { name: bucketNameArg };
|
||||
}
|
||||
|
||||
public async createBucketTenant(
|
||||
tenantArg: IBucketTenantInput,
|
||||
): Promise<IBucketTenantDescriptor> {
|
||||
this.assertTenantAuthEnabled();
|
||||
const credential = await this.bridge.sendCommand('createBucketTenant', {
|
||||
bucketName: tenantArg.bucketName,
|
||||
accessKeyId: tenantArg.accessKeyId || createAccessKeyId(),
|
||||
secretAccessKey: tenantArg.secretAccessKey || createSecretAccessKey(),
|
||||
region: tenantArg.region || this.config.server.region,
|
||||
});
|
||||
return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
|
||||
}
|
||||
|
||||
public async deleteBucketTenant(tenantArg: IDeleteBucketTenantInput): Promise<void> {
|
||||
this.assertTenantAuthEnabled();
|
||||
await this.bridge.sendCommand('deleteBucketTenant', tenantArg);
|
||||
}
|
||||
|
||||
public async rotateBucketTenantCredentials(
|
||||
tenantArg: IBucketTenantInput,
|
||||
): Promise<IBucketTenantDescriptor> {
|
||||
this.assertTenantAuthEnabled();
|
||||
const credential = await this.bridge.sendCommand('rotateBucketTenantCredentials', {
|
||||
bucketName: tenantArg.bucketName,
|
||||
accessKeyId: tenantArg.accessKeyId || createAccessKeyId(),
|
||||
secretAccessKey: tenantArg.secretAccessKey || createSecretAccessKey(),
|
||||
region: tenantArg.region || this.config.server.region,
|
||||
});
|
||||
return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
|
||||
}
|
||||
|
||||
public async listBucketTenants(): Promise<IBucketTenantMetadata[]> {
|
||||
return this.bridge.sendCommand('listBucketTenants', {});
|
||||
}
|
||||
|
||||
public async getBucketTenantDescriptor(optionsArg: {
|
||||
bucketName: string;
|
||||
}): Promise<IBucketTenantDescriptor> {
|
||||
const credential = await this.bridge.sendCommand('getBucketTenantCredential', {
|
||||
bucketName: optionsArg.bucketName,
|
||||
});
|
||||
return this.buildBucketTenantDescriptor(credential, optionsArg.bucketName);
|
||||
}
|
||||
|
||||
public async exportBucket(optionsArg: IExportBucketInput): Promise<IBucketExport> {
|
||||
return this.bridge.sendCommand('exportBucket', { bucketName: optionsArg.bucketName });
|
||||
}
|
||||
|
||||
public async importBucket(optionsArg: IImportBucketInput): Promise<void> {
|
||||
await this.bridge.sendCommand('importBucket', optionsArg);
|
||||
}
|
||||
|
||||
public async getStorageStats(): Promise<IStorageStats> {
|
||||
return this.bridge.sendCommand('getStorageStats', {});
|
||||
}
|
||||
@@ -408,8 +632,79 @@ export class SmartStorage {
|
||||
return this.bridge.sendCommand('getClusterHealth', {});
|
||||
}
|
||||
|
||||
public async getHealth(): Promise<ISmartStorageHealth> {
|
||||
if (!this.running) {
|
||||
return {
|
||||
ok: false,
|
||||
running: false,
|
||||
storageDirectory: this.config.storage.directory || paths.bucketsDir,
|
||||
auth: {
|
||||
enabled: this.config.auth.enabled,
|
||||
credentialCount: this.config.auth.credentials.length,
|
||||
tenantCredentialCount: 0,
|
||||
},
|
||||
bucketCount: 0,
|
||||
objectCount: 0,
|
||||
totalBytes: 0,
|
||||
cluster: { enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
const [stats, credentials, tenants, cluster] = await Promise.all([
|
||||
this.getStorageStats(),
|
||||
this.listCredentials(),
|
||||
this.listBucketTenants(),
|
||||
this.getClusterHealth(),
|
||||
]);
|
||||
return {
|
||||
ok: true,
|
||||
running: true,
|
||||
storageDirectory: stats.storageDirectory,
|
||||
auth: {
|
||||
enabled: this.config.auth.enabled,
|
||||
credentialCount: credentials.length,
|
||||
tenantCredentialCount: tenants.length,
|
||||
},
|
||||
bucketCount: stats.bucketCount,
|
||||
objectCount: stats.totalObjectCount,
|
||||
totalBytes: stats.totalStorageBytes,
|
||||
cluster,
|
||||
};
|
||||
}
|
||||
|
||||
public async getMetrics(): Promise<ISmartStorageMetrics> {
|
||||
const health = await this.getHealth();
|
||||
const clusterEnabled = health.cluster.enabled;
|
||||
return {
|
||||
bucketCount: health.bucketCount,
|
||||
objectCount: health.objectCount,
|
||||
totalBytes: health.totalBytes,
|
||||
authCredentialCount: health.auth.credentialCount,
|
||||
tenantCredentialCount: health.auth.tenantCredentialCount,
|
||||
clusterEnabled,
|
||||
prometheusText: [
|
||||
'# HELP smartstorage_buckets_total Runtime bucket count.',
|
||||
'# TYPE smartstorage_buckets_total gauge',
|
||||
`smartstorage_buckets_total ${health.bucketCount}`,
|
||||
'# HELP smartstorage_objects_total Runtime object count.',
|
||||
'# TYPE smartstorage_objects_total gauge',
|
||||
`smartstorage_objects_total ${health.objectCount}`,
|
||||
'# HELP smartstorage_storage_bytes_total Runtime storage bytes.',
|
||||
'# TYPE smartstorage_storage_bytes_total gauge',
|
||||
`smartstorage_storage_bytes_total ${health.totalBytes}`,
|
||||
'# HELP smartstorage_tenant_credentials_total Scoped bucket tenant credential count.',
|
||||
'# TYPE smartstorage_tenant_credentials_total gauge',
|
||||
`smartstorage_tenant_credentials_total ${health.auth.tenantCredentialCount}`,
|
||||
'# HELP smartstorage_cluster_enabled Cluster mode enabled.',
|
||||
'# TYPE smartstorage_cluster_enabled gauge',
|
||||
`smartstorage_cluster_enabled ${clusterEnabled ? 1 : 0}`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.bridge.sendCommand('stop', {});
|
||||
this.bridge.kill();
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user