From c55eb5b832563d138d34194fa99e1c14bc2d08e6 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 7 May 2026 19:04:12 +0000 Subject: [PATCH] feat: harden baseos image builds --- ts/manager.baseos/classes.baseosmanager.ts | 130 ++++++++++++++++++++- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/ts/manager.baseos/classes.baseosmanager.ts b/ts/manager.baseos/classes.baseosmanager.ts index 3619370..48ef16e 100644 --- a/ts/manager.baseos/classes.baseosmanager.ts +++ b/ts/manager.baseos/classes.baseosmanager.ts @@ -115,9 +115,16 @@ interface ICoreBuildBaseOsImageResponse { errorText?: string; } +interface ICoreBuildCapabilitiesResponse { + workerId: string; + supportedBuildTypes: string[]; + supportedArchitectures: TBaseOsImageArchitecture[]; +} + export class CloudlyBaseOsManager { public cloudlyRef: Cloudly; public typedRouter = new plugins.typedrequest.TypedRouter(); + private cleanupInterval?: ReturnType; public get db() { return this.cloudlyRef.mongodbConnector.smartdataDb; @@ -205,10 +212,27 @@ export class CloudlyBaseOsManager { } public async start() { + await this.cleanupExpiredImageBuilds().catch((error) => { + logger.log('error', `BaseOS image cleanup failed: ${(error as Error).message}`); + }); + const cleanupIntervalMs = Number( + process.env.CLOUDLY_BASEOS_IMAGE_CLEANUP_INTERVAL_MS || 1000 * 60 * 60 * 12, + ); + if (Number.isFinite(cleanupIntervalMs) && cleanupIntervalMs > 0) { + this.cleanupInterval = setInterval(() => { + this.cleanupExpiredImageBuilds().catch((error) => { + logger.log('error', `BaseOS image cleanup failed: ${(error as Error).message}`); + }); + }, cleanupIntervalMs); + } logger.log('info', 'BaseOS manager started'); } public async stop() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = undefined; + } logger.log('info', 'BaseOS manager stopped'); } @@ -267,11 +291,7 @@ export class CloudlyBaseOsManager { } const artifact = build.data.artifact; - const smartbucket = new plugins.smartbucket.SmartBucket({ - ...this.cloudlyRef.config.data.s3Descriptor, - port: Number((this.cloudlyRef.config.data.s3Descriptor as any).port || 443), - } as any); - const bucket = await smartbucket.getBucketByName(artifact.bucketName); + const bucket = await this.getArtifactBucket(artifact.bucketName); const artifactStream = await bucket.fastGetStream({ path: artifact.key }, 'nodestream'); resArg.status(200); resArg.setHeader('Content-Type', artifact.contentType || 'application/octet-stream'); @@ -388,6 +408,7 @@ export class CloudlyBaseOsManager { if (!workerUrl) { throw new plugins.typedrequest.TypedResponseError('corebuildWorkerUrl is not configured in Cloudly settings'); } + await this.assertWorkerSupportsImageBuild(workerUrl, workerToken, buildRequestArg.architecture); const now = Date.now(); const buildId = await this.CBaseOsImageBuild.getNewId(); @@ -448,6 +469,56 @@ export class CloudlyBaseOsManager { }; } + public async cleanupExpiredImageBuilds() { + const now = Date.now(); + const builds = await this.CBaseOsImageBuild.getInstances({}); + let deletedBuilds = 0; + let deletedArtifacts = 0; + let clearedDownloadTokens = 0; + + for (const build of builds) { + let shouldSave = false; + if (build.downloadTokenExpiresAt && build.downloadTokenExpiresAt <= now) { + build.downloadTokenHash = undefined; + build.downloadTokenExpiresAt = undefined; + clearedDownloadTokens++; + shouldSave = true; + } + + const isExpired = Boolean(build.data.expiresAt && build.data.expiresAt <= now); + const canDeleteBuild = build.data.status === 'ready' + || build.data.status === 'failed' + || build.data.status === 'cancelled'; + if (isExpired && canDeleteBuild) { + if (build.data.artifact) { + await this.deleteImageArtifact(build.data.artifact); + deletedArtifacts++; + } + await build.delete(); + deletedBuilds++; + continue; + } + + if (shouldSave) { + build.data.updatedAt = now; + await build.save(); + } + } + + if (deletedBuilds > 0 || deletedArtifacts > 0 || clearedDownloadTokens > 0) { + logger.log( + 'info', + `BaseOS image cleanup completed: ${deletedBuilds} builds, ${deletedArtifacts} artifacts, ${clearedDownloadTokens} download tokens`, + ); + } + + return { + deletedBuilds, + deletedArtifacts, + clearedDownloadTokens, + }; + } + private async upsertNode(statusArg: IBaseOsRuntimeInfo, nodeTokenArg: string) { const now = Date.now(); let node = await this.CBaseOsNode.getInstance({ @@ -498,7 +569,7 @@ export class CloudlyBaseOsManager { : 'baseos-rpi.img'; const artifactKey = `corebuild/baseos/${buildArg.id}/${artifactFilename}`; const response = await fetch( - new URL('/corebuild/v1/jobs/baseos-image', workerUrlArg.endsWith('/') ? workerUrlArg : `${workerUrlArg}/`), + this.getCoreBuildUrl(workerUrlArg, '/corebuild/v1/jobs/baseos-image'), { method: 'POST', headers: { @@ -546,6 +617,53 @@ export class CloudlyBaseOsManager { await buildArg.save(); } + private async assertWorkerSupportsImageBuild( + workerUrlArg: string, + workerTokenArg: string | undefined, + architectureArg: TBaseOsImageArchitecture, + ) { + const response = await fetch( + this.getCoreBuildUrl(workerUrlArg, '/corebuild/v1/capabilities'), + { + method: 'GET', + headers: workerTokenArg ? { authorization: `Bearer ${workerTokenArg}` } : {}, + }, + ); + if (!response.ok) { + throw new plugins.typedrequest.TypedResponseError( + `CoreBuild capabilities request failed with HTTP ${response.status}`, + ); + } + const capabilities = await response.json() as ICoreBuildCapabilitiesResponse; + if (!capabilities.supportedBuildTypes?.includes('baseos-image')) { + throw new plugins.typedrequest.TypedResponseError( + `CoreBuild worker ${capabilities.workerId} does not support BaseOS image builds`, + ); + } + if (!capabilities.supportedArchitectures?.includes(architectureArg)) { + throw new plugins.typedrequest.TypedResponseError( + `CoreBuild worker ${capabilities.workerId} does not support BaseOS ${architectureArg} builds`, + ); + } + } + + private getCoreBuildUrl(workerUrlArg: string, pathArg: string) { + return new URL(pathArg, workerUrlArg.endsWith('/') ? workerUrlArg : `${workerUrlArg}/`); + } + + private async deleteImageArtifact(artifactArg: IBaseOsImageArtifact) { + const bucket = await this.getArtifactBucket(artifactArg.bucketName); + await bucket.fastRemove({ path: artifactArg.key }); + } + + private async getArtifactBucket(bucketNameArg: string) { + const smartbucket = new plugins.smartbucket.SmartBucket({ + ...this.cloudlyRef.config.data.s3Descriptor, + port: Number((this.cloudlyRef.config.data.s3Descriptor as any).port || 443), + } as any); + return await smartbucket.getBucketByName(bucketNameArg); + } + private async getImageBuildById(buildIdArg: string) { const build = await this.CBaseOsImageBuild.getInstance({ id: buildIdArg }); if (!build) {