feat: harden baseos image builds

This commit is contained in:
2026-05-07 19:04:12 +00:00
parent 1792ea89e1
commit c55eb5b832
+124 -6
View File
@@ -115,9 +115,16 @@ interface ICoreBuildBaseOsImageResponse {
errorText?: string; errorText?: string;
} }
interface ICoreBuildCapabilitiesResponse {
workerId: string;
supportedBuildTypes: string[];
supportedArchitectures: TBaseOsImageArchitecture[];
}
export class CloudlyBaseOsManager { export class CloudlyBaseOsManager {
public cloudlyRef: Cloudly; public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter(); public typedRouter = new plugins.typedrequest.TypedRouter();
private cleanupInterval?: ReturnType<typeof setInterval>;
public get db() { public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb; return this.cloudlyRef.mongodbConnector.smartdataDb;
@@ -205,10 +212,27 @@ export class CloudlyBaseOsManager {
} }
public async start() { 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'); logger.log('info', 'BaseOS manager started');
} }
public async stop() { public async stop() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
logger.log('info', 'BaseOS manager stopped'); logger.log('info', 'BaseOS manager stopped');
} }
@@ -267,11 +291,7 @@ export class CloudlyBaseOsManager {
} }
const artifact = build.data.artifact; const artifact = build.data.artifact;
const smartbucket = new plugins.smartbucket.SmartBucket({ const bucket = await this.getArtifactBucket(artifact.bucketName);
...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 artifactStream = await bucket.fastGetStream({ path: artifact.key }, 'nodestream'); const artifactStream = await bucket.fastGetStream({ path: artifact.key }, 'nodestream');
resArg.status(200); resArg.status(200);
resArg.setHeader('Content-Type', artifact.contentType || 'application/octet-stream'); resArg.setHeader('Content-Type', artifact.contentType || 'application/octet-stream');
@@ -388,6 +408,7 @@ export class CloudlyBaseOsManager {
if (!workerUrl) { if (!workerUrl) {
throw new plugins.typedrequest.TypedResponseError('corebuildWorkerUrl is not configured in Cloudly settings'); throw new plugins.typedrequest.TypedResponseError('corebuildWorkerUrl is not configured in Cloudly settings');
} }
await this.assertWorkerSupportsImageBuild(workerUrl, workerToken, buildRequestArg.architecture);
const now = Date.now(); const now = Date.now();
const buildId = await this.CBaseOsImageBuild.getNewId(); 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) { private async upsertNode(statusArg: IBaseOsRuntimeInfo, nodeTokenArg: string) {
const now = Date.now(); const now = Date.now();
let node = await this.CBaseOsNode.getInstance({ let node = await this.CBaseOsNode.getInstance({
@@ -498,7 +569,7 @@ export class CloudlyBaseOsManager {
: 'baseos-rpi.img'; : 'baseos-rpi.img';
const artifactKey = `corebuild/baseos/${buildArg.id}/${artifactFilename}`; const artifactKey = `corebuild/baseos/${buildArg.id}/${artifactFilename}`;
const response = await fetch( 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', method: 'POST',
headers: { headers: {
@@ -546,6 +617,53 @@ export class CloudlyBaseOsManager {
await buildArg.save(); 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) { private async getImageBuildById(buildIdArg: string) {
const build = await this.CBaseOsImageBuild.getInstance({ id: buildIdArg }); const build = await this.CBaseOsImageBuild.getInstance({ id: buildIdArg });
if (!build) { if (!build) {