Add GitOps stack for abrban.com with Gitea Actions CI/CD.
Build and Deploy Platform / build-push-deploy (push) Has been cancelled
Build and Deploy Platform / build-push-deploy (push) Has been cancelled
Harbor in-cluster builds via Kaniko, ArgoCD auto-sync, and production Helm values for abrban.com domains. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { LifecycleModule } from './lifecycle/lifecycle.module';
|
||||
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
@@ -66,6 +67,7 @@ import configuration from './config/configuration';
|
||||
]),
|
||||
|
||||
// Feature modules
|
||||
StorageModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
ApplicationsModule,
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
assertRuntimeMatch,
|
||||
detectRuntimeFromArchive,
|
||||
} from '../build/runtime-detector';
|
||||
import { SourceStorageService } from '../storage/source-storage.service';
|
||||
import * as os from 'os';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationsService {
|
||||
@@ -31,6 +33,7 @@ export class ApplicationsService {
|
||||
private appsRepository: Repository<Application>,
|
||||
private clustersService: ClustersService,
|
||||
private configService: ConfigService,
|
||||
private sourceStorage: SourceStorageService,
|
||||
) {}
|
||||
|
||||
private toDnsLabel(value: string): string {
|
||||
@@ -208,17 +211,12 @@ export class ApplicationsService {
|
||||
async delete(id: string, userId: string): Promise<Application> {
|
||||
const app = await this.findOne(id, userId);
|
||||
|
||||
// Delete uploaded files
|
||||
// Delete uploaded source files
|
||||
if (app.codePath) {
|
||||
try {
|
||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||
if (fs.existsSync(appDir)) {
|
||||
fs.rmSync(appDir, { recursive: true, force: true });
|
||||
this.logger.log(`Deleted upload directory: ${appDir}`);
|
||||
}
|
||||
await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
|
||||
this.logger.warn(`Failed to delete source for ${app.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,21 +263,15 @@ export class ApplicationsService {
|
||||
}
|
||||
|
||||
const app = await this.findOne(id, userId);
|
||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||
|
||||
// Ensure directory exists
|
||||
fs.mkdirSync(appDir, { recursive: true });
|
||||
|
||||
// Save the zip file
|
||||
const zipPath = path.join(appDir, 'source.zip');
|
||||
fs.writeFileSync(zipPath, file.buffer);
|
||||
const tempPath = path.join(os.tmpdir(), `upload-${app.id}-${Date.now()}.zip`);
|
||||
fs.writeFileSync(tempPath, file.buffer);
|
||||
|
||||
try {
|
||||
const detected = await detectRuntimeFromArchive(zipPath);
|
||||
const detected = await detectRuntimeFromArchive(tempPath);
|
||||
assertRuntimeMatch(app.runtime, detected);
|
||||
|
||||
app.codePath = zipPath;
|
||||
const storedPath = await this.sourceStorage.putSource(app.userId, app.id, file.buffer);
|
||||
app.codePath = storedPath;
|
||||
const saved = await this.appsRepository.save(app);
|
||||
|
||||
if (detected.confidence === 'low') {
|
||||
@@ -289,13 +281,19 @@ export class ApplicationsService {
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
this.logger.log(`Uploaded code for ${app.name} → ${storedPath} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
return saved;
|
||||
} catch (err) {
|
||||
if (fs.existsSync(zipPath)) {
|
||||
fs.unlinkSync(zipPath);
|
||||
try {
|
||||
await this.sourceStorage.deleteSource(app.userId, app.id);
|
||||
} catch {
|
||||
// ignore rollback errors
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppRuntime } from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
import { BuildProgressStore } from './build-progress.store';
|
||||
import { SourceStorageService } from '../storage/source-storage.service';
|
||||
|
||||
describe('BuildService', () => {
|
||||
let service: BuildService;
|
||||
@@ -34,6 +35,14 @@ describe('BuildService', () => {
|
||||
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
|
||||
},
|
||||
{ provide: RegistryService, useValue: {} },
|
||||
{
|
||||
provide: SourceStorageService,
|
||||
useValue: {
|
||||
isObjectStorage: () => false,
|
||||
materializeToTempFile: jest.fn(),
|
||||
getSize: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppRuntime } from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
import { BuildProgressStore } from './build-progress.store';
|
||||
import { SourceStorageService } from '../storage/source-storage.service';
|
||||
import {
|
||||
detectDjangoSettingsModule,
|
||||
detectGoBuildTarget,
|
||||
@@ -64,6 +65,7 @@ export class BuildService {
|
||||
private clustersService: ClustersService,
|
||||
private registryService: RegistryService,
|
||||
private progressStore: BuildProgressStore,
|
||||
private sourceStorage: SourceStorageService,
|
||||
) {}
|
||||
|
||||
private beginBuildSession(deploymentId: string): void {
|
||||
@@ -321,13 +323,23 @@ export class BuildService {
|
||||
this.beginBuildSession(deploymentId);
|
||||
}
|
||||
|
||||
const codePath = app.codePath ? path.resolve(app.codePath) : null;
|
||||
const hasUploadedCode = codePath && fs.existsSync(codePath);
|
||||
const hasUploadedCode = !!app.codePath;
|
||||
let localZipPath: string | null = null;
|
||||
let cleanupSource: (() => void) | null = null;
|
||||
|
||||
if (hasUploadedCode) {
|
||||
await validateRuntimeFromArchive(app.runtime, codePath);
|
||||
try {
|
||||
const materialized = await this.sourceStorage.materializeToTempFile(app.codePath!);
|
||||
localZipPath = materialized.path;
|
||||
cleanupSource = materialized.cleanup;
|
||||
await validateRuntimeFromArchive(app.runtime, localZipPath);
|
||||
} catch (e) {
|
||||
cleanupSource?.();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const archiveEntries = hasUploadedCode ? await listArchiveEntries(codePath!) : [];
|
||||
const archiveEntries = hasUploadedCode && localZipPath ? await listArchiveEntries(localZipPath) : [];
|
||||
|
||||
// Determine Dockerfile based on runtime
|
||||
const dockerfileContent = this.generateDockerfile(app, archiveEntries);
|
||||
@@ -377,16 +389,18 @@ export class BuildService {
|
||||
|
||||
// If we have uploaded code, create a PVC and upload via kubectl cp
|
||||
let sourcePvcName: string | undefined;
|
||||
if (hasUploadedCode) {
|
||||
if (hasUploadedCode && localZipPath) {
|
||||
sourcePvcName = `${buildPodName}-source`;
|
||||
if (deploymentId) {
|
||||
this.updateBuildSession(deploymentId, { sourcePvcName });
|
||||
}
|
||||
const zipSize = fs.statSync(codePath!).size;
|
||||
const zipSize = await this.sourceStorage.getSize(app.codePath!);
|
||||
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
|
||||
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
|
||||
|
||||
await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId);
|
||||
await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, localZipPath, pvcSizeGi, deploymentId);
|
||||
cleanupSource?.();
|
||||
cleanupSource = null;
|
||||
}
|
||||
|
||||
// Build the Kaniko Job spec
|
||||
@@ -653,6 +667,7 @@ export class BuildService {
|
||||
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
|
||||
}
|
||||
this.endBuildSession(deploymentId);
|
||||
cleanupSource?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,14 @@ export default () => ({
|
||||
},
|
||||
},
|
||||
|
||||
sourceStorage: {
|
||||
endpoint: process.env.SOURCE_STORAGE_ENDPOINT,
|
||||
region: process.env.SOURCE_STORAGE_REGION || 'us-east-1',
|
||||
bucket: process.env.SOURCE_STORAGE_BUCKET,
|
||||
accessKey: process.env.SOURCE_STORAGE_ACCESS_KEY,
|
||||
secretKey: process.env.SOURCE_STORAGE_SECRET_KEY,
|
||||
},
|
||||
|
||||
platform: {
|
||||
domain: resolvePlatformDomainFromEnv(),
|
||||
previewRootDomain: resolvePreviewRootDomainFromEnv(),
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as path from 'path';
|
||||
import { AppSnapshot, SnapshotType, SnapshotStatus } from './entities/snapshot.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { SourceStorageService } from '../storage/source-storage.service';
|
||||
import { AppRuntime, DatabaseType, ProductType } from '../common/enums';
|
||||
|
||||
const MAX_SNAPSHOTS = 10;
|
||||
@@ -30,6 +31,7 @@ export class SnapshotsService implements OnModuleInit {
|
||||
private applicationsService: ApplicationsService,
|
||||
private kubernetesService: KubernetesService,
|
||||
private configService: ConfigService,
|
||||
private sourceStorage: SourceStorageService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -122,13 +124,18 @@ export class SnapshotsService implements OnModuleInit {
|
||||
|
||||
if (!managedDbOnly) {
|
||||
// 1. Copy current source code zip
|
||||
if (app.codePath && fs.existsSync(app.codePath)) {
|
||||
if (app.codePath && (await this.sourceStorage.exists(app.codePath))) {
|
||||
await this.setSnapshotProgress(snapshotId, 12);
|
||||
const destPath = path.join(snapshotDir, 'source.zip');
|
||||
fs.copyFileSync(app.codePath, destPath);
|
||||
updates.appArchivePath = destPath;
|
||||
updates.appArchiveSize = fs.statSync(destPath).size;
|
||||
this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`);
|
||||
const { path: tempPath, cleanup } = await this.sourceStorage.materializeToTempFile(app.codePath);
|
||||
try {
|
||||
const destPath = path.join(snapshotDir, 'source.zip');
|
||||
fs.copyFileSync(tempPath, destPath);
|
||||
updates.appArchivePath = destPath;
|
||||
updates.appArchiveSize = fs.statSync(destPath).size;
|
||||
this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Archive wp-content for WordPress apps
|
||||
@@ -315,8 +322,9 @@ export class SnapshotsService implements OnModuleInit {
|
||||
async downloadCurrentSource(applicationId: string, userId: string): Promise<{ filePath: string; fileName: string } | null> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
|
||||
if (app.codePath && fs.existsSync(app.codePath)) {
|
||||
return { filePath: app.codePath, fileName: `${app.name}-current-source.zip` };
|
||||
if (app.codePath && (await this.sourceStorage.exists(app.codePath))) {
|
||||
const { path } = await this.sourceStorage.materializeToTempFile(app.codePath);
|
||||
return { filePath: path, fileName: `${app.name}-current-source.zip` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -382,12 +390,9 @@ export class SnapshotsService implements OnModuleInit {
|
||||
} else {
|
||||
// Revision not found — fall back to restoring source code and redeploying
|
||||
if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) {
|
||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||
const destPath = path.join(appDir, 'source.zip');
|
||||
fs.mkdirSync(appDir, { recursive: true });
|
||||
fs.copyFileSync(snapshot.appArchivePath, destPath);
|
||||
await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any);
|
||||
const buffer = fs.readFileSync(snapshot.appArchivePath);
|
||||
const storedPath = await this.sourceStorage.putSource(app.userId, app.id, buffer);
|
||||
await this.applicationsService.update(app.id, app.userId, { codePath: storedPath } as any);
|
||||
details.push('✅ Source code restored (K8s revision expired — will need redeploy)');
|
||||
this.logger.log(`Rollback ${snapshotId}: source code restored (revision not found)`);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { SourceStorageService } from './source-storage.service';
|
||||
|
||||
jest.mock('@aws-sdk/client-s3', () => {
|
||||
const send = jest.fn();
|
||||
return {
|
||||
S3Client: jest.fn().mockImplementation(() => ({ send })),
|
||||
PutObjectCommand: jest.fn().mockImplementation((input) => ({ input })),
|
||||
GetObjectCommand: jest.fn(),
|
||||
HeadObjectCommand: jest.fn(),
|
||||
DeleteObjectCommand: jest.fn(),
|
||||
__mockSend: send,
|
||||
};
|
||||
});
|
||||
|
||||
const s3Module = jest.requireMock('@aws-sdk/client-s3');
|
||||
const mockSend = s3Module.__mockSend as jest.Mock;
|
||||
|
||||
describe('SourceStorageService', () => {
|
||||
let service: SourceStorageService;
|
||||
let uploadDir: string;
|
||||
|
||||
const createModule = (config: Record<string, string | undefined>) => {
|
||||
return Test.createTestingModule({
|
||||
providers: [
|
||||
SourceStorageService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string) => {
|
||||
const map: Record<string, string | undefined> = {
|
||||
'platform.uploadDir': uploadDir,
|
||||
'sourceStorage.endpoint': config.endpoint,
|
||||
'sourceStorage.region': config.region,
|
||||
'sourceStorage.bucket': config.bucket,
|
||||
'sourceStorage.accessKey': config.accessKey,
|
||||
'sourceStorage.secretKey': config.secretKey,
|
||||
};
|
||||
return map[key];
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudhost-upload-'));
|
||||
mockSend.mockReset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('local mode', () => {
|
||||
beforeEach(async () => {
|
||||
const module = await createModule({});
|
||||
service = module.get(SourceStorageService);
|
||||
});
|
||||
|
||||
it('stores source zip on local disk', async () => {
|
||||
const buffer = Buffer.from('zip-content');
|
||||
const storedPath = await service.putSource('user-1', 'app-1', buffer);
|
||||
|
||||
expect(service.isObjectStorage()).toBe(false);
|
||||
expect(storedPath).toBe(path.join(uploadDir, 'user-1', 'app-1', 'source.zip'));
|
||||
expect(fs.readFileSync(storedPath, 'utf8')).toBe('zip-content');
|
||||
});
|
||||
|
||||
it('checks existence and size for local files', async () => {
|
||||
const storedPath = await service.putSource('user-1', 'app-1', Buffer.from('abc'));
|
||||
|
||||
expect(await service.exists(storedPath)).toBe(true);
|
||||
expect(await service.getSize(storedPath)).toBe(3);
|
||||
});
|
||||
|
||||
it('deletes local upload directory', async () => {
|
||||
const storedPath = await service.putSource('user-1', 'app-1', Buffer.from('abc'));
|
||||
expect(fs.existsSync(storedPath)).toBe(true);
|
||||
|
||||
await service.deleteSource('user-1', 'app-1', storedPath);
|
||||
expect(fs.existsSync(path.dirname(storedPath))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('object storage mode', () => {
|
||||
beforeEach(async () => {
|
||||
const module = await createModule({
|
||||
endpoint: 'http://rgw.local:80',
|
||||
region: 'us-east-1',
|
||||
bucket: 'app-sources',
|
||||
accessKey: 'access',
|
||||
secretKey: 'secret',
|
||||
});
|
||||
service = module.get(SourceStorageService);
|
||||
});
|
||||
|
||||
it('uploads source zip to S3', async () => {
|
||||
mockSend.mockResolvedValue({});
|
||||
const buffer = Buffer.from('zip-content');
|
||||
|
||||
const key = await service.putSource('user-1', 'app-1', buffer);
|
||||
|
||||
expect(service.isObjectStorage()).toBe(true);
|
||||
expect(key).toBe('user-1/app-1/source.zip');
|
||||
expect(PutObjectCommand).toHaveBeenCalledWith({
|
||||
Bucket: 'app-sources',
|
||||
Key: 'user-1/app-1/source.zip',
|
||||
Body: buffer,
|
||||
ContentType: 'application/zip',
|
||||
});
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
expect(S3Client).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
endpoint: 'http://rgw.local:80',
|
||||
forcePathStyle: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats object keys as non-local paths', () => {
|
||||
expect(service.isLocalPath('user-1/app-1/source.zip')).toBe(false);
|
||||
expect(service.isLocalPath(path.join(uploadDir, 'user-1/app-1/source.zip'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
@Injectable()
|
||||
export class SourceStorageService {
|
||||
private readonly logger = new Logger(SourceStorageService.name);
|
||||
private readonly s3Client: S3Client | null;
|
||||
private readonly bucket: string | undefined;
|
||||
private readonly uploadDir: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const endpoint = this.configService.get<string>('sourceStorage.endpoint');
|
||||
const region = this.configService.get<string>('sourceStorage.region') || 'us-east-1';
|
||||
const accessKey = this.configService.get<string>('sourceStorage.accessKey');
|
||||
const secretKey = this.configService.get<string>('sourceStorage.secretKey');
|
||||
this.bucket = this.configService.get<string>('sourceStorage.bucket');
|
||||
this.uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||
|
||||
if (endpoint && this.bucket && accessKey && secretKey) {
|
||||
this.s3Client = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
this.logger.log(`Object storage enabled (bucket=${this.bucket})`);
|
||||
} else {
|
||||
this.s3Client = null;
|
||||
}
|
||||
}
|
||||
|
||||
isObjectStorage(): boolean {
|
||||
return this.s3Client !== null;
|
||||
}
|
||||
|
||||
sourceKey(userId: string, appId: string, filename = 'source.zip'): string {
|
||||
return `${userId}/${appId}/${filename}`;
|
||||
}
|
||||
|
||||
isLocalPath(codePath: string): boolean {
|
||||
if (!codePath) return false;
|
||||
if (path.isAbsolute(codePath)) return true;
|
||||
if (codePath.startsWith('./') || codePath.startsWith('../')) return true;
|
||||
const normalizedUploadDir = path.resolve(this.uploadDir);
|
||||
return path.resolve(codePath).startsWith(normalizedUploadDir);
|
||||
}
|
||||
|
||||
localPath(userId: string, appId: string, filename = 'source.zip'): string {
|
||||
return path.join(this.uploadDir, userId, appId, filename);
|
||||
}
|
||||
|
||||
async putSource(userId: string, appId: string, buffer: Buffer): Promise<string> {
|
||||
if (this.isObjectStorage()) {
|
||||
const key = this.sourceKey(userId, appId);
|
||||
await this.s3Client!.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: 'application/zip',
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Uploaded source to S3 → s3://${this.bucket}/${key} (${(buffer.length / 1024).toFixed(1)} KB)`);
|
||||
return key;
|
||||
}
|
||||
|
||||
const zipPath = this.localPath(userId, appId);
|
||||
fs.mkdirSync(path.dirname(zipPath), { recursive: true });
|
||||
fs.writeFileSync(zipPath, buffer);
|
||||
this.logger.log(`Uploaded source to local → ${zipPath} (${(buffer.length / 1024).toFixed(1)} KB)`);
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
async exists(codePath: string): Promise<boolean> {
|
||||
if (!codePath) return false;
|
||||
if (this.isLocalPath(codePath)) {
|
||||
return fs.existsSync(codePath);
|
||||
}
|
||||
if (!this.s3Client) return false;
|
||||
try {
|
||||
await this.s3Client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: codePath }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getSize(codePath: string): Promise<number> {
|
||||
if (this.isLocalPath(codePath)) {
|
||||
return fs.statSync(codePath).size;
|
||||
}
|
||||
const head = await this.s3Client!.send(new HeadObjectCommand({ Bucket: this.bucket, Key: codePath }));
|
||||
return head.ContentLength ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a local filesystem path for reading the archive.
|
||||
* For S3 keys, downloads to a temp file and returns { path, cleanup }.
|
||||
*/
|
||||
async materializeToTempFile(codePath: string): Promise<{ path: string; cleanup: () => void }> {
|
||||
if (this.isLocalPath(codePath)) {
|
||||
if (!fs.existsSync(codePath)) {
|
||||
throw new Error(`Source file not found: ${codePath}`);
|
||||
}
|
||||
return { path: codePath, cleanup: () => {} };
|
||||
}
|
||||
|
||||
const response = await this.s3Client!.send(
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: codePath }),
|
||||
);
|
||||
const tempPath = path.join(
|
||||
os.tmpdir(),
|
||||
`cloudhost-source-${Date.now()}-${path.basename(codePath)}`,
|
||||
);
|
||||
const body = response.Body;
|
||||
if (!body || typeof (body as Readable).pipe !== 'function') {
|
||||
throw new Error(`Empty S3 response for key ${codePath}`);
|
||||
}
|
||||
await pipeline(body as Readable, fs.createWriteStream(tempPath));
|
||||
return {
|
||||
path: tempPath,
|
||||
cleanup: () => {
|
||||
try {
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSource(userId: string, appId: string, codePath?: string | null): Promise<void> {
|
||||
if (this.isObjectStorage()) {
|
||||
const key = codePath && !this.isLocalPath(codePath) ? codePath : this.sourceKey(userId, appId);
|
||||
try {
|
||||
await this.s3Client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
this.logger.log(`Deleted S3 source → s3://${this.bucket}/${key}`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to delete S3 source ${key}: ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const appDir = path.join(this.uploadDir, userId, appId);
|
||||
if (fs.existsSync(appDir)) {
|
||||
fs.rmSync(appDir, { recursive: true, force: true });
|
||||
this.logger.log(`Deleted local upload directory: ${appDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SourceStorageService } from './source-storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SourceStorageService],
|
||||
exports: [SourceStorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
Reference in New Issue
Block a user