feat: harden baseos image builds
This commit is contained in:
@@ -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<typeof setInterval>;
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user