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