import * as plugins from './plugins.js'; export interface IDockerImageStoreConstructorOptions { /** Disposable archive-processing directory; persistent archives use SmartBucket. */ localDirPath: string; bucketDir: plugins.smartbucket.Directory; } export interface IDockerImageStoreOperationOptions { signal?: AbortSignal; } export interface IDockerImageArchiveReceipt { readonly schemaVersion: 1; readonly imageName: string; /** Path relative to the configured Directory, without the .tar suffix. */ readonly storagePath: string; /** Complete SmartBucket object key, including the .tar suffix. */ readonly objectPath: string; /** Digest of the repacked bytes consumed by storage, not the input archive. */ readonly archiveDigest: `sha256:${string}`; readonly byteLength: number; } export interface IDockerImageArchiveFailureState { readonly storagePath: string; readonly objectPath: string | null; readonly publication: 'not-published' | 'published' | 'ambiguous'; readonly storedArchive: IDockerImageArchiveReceipt | null; } /** Preserves the exact destination and any known stored receipt for reconciliation. */ export class DockerImageArchiveStoreError extends Error { public readonly state: IDockerImageArchiveFailureState; constructor(state: IDockerImageArchiveFailureState, cause: unknown) { super('Docker image archive storage failed', { cause }); this.name = 'DockerImageArchiveStoreError'; this.state = Object.freeze({ ...state }); } } const assertStoragePath = (path: string): void => { if (typeof path !== 'string' || !path || Buffer.byteLength(path) > 1024 || /[\s\0\\]/u.test(path) || path.split('/').some((part) => !part || part === '.' || part === '..')) { throw new TypeError('Image storage path must be a nonempty relative path without dot components'); } }; const objectPathFor = (directory: plugins.smartbucket.Directory, storagePath: string): string => { assertStoragePath(storagePath); return plugins.path.posix.join(directory.getBasePath(), `${storagePath}.tar`); }; export class DockerImageStore { public options: IDockerImageStoreConstructorOptions; private stopped = false; private stopPromise?: Promise; private readonly active = new Map, AbortController>(); private readonly processingDirectories = new Set(); constructor(optionsArg: IDockerImageStoreConstructorOptions) { this.options = { ...optionsArg }; } /** Atomically replaces the conventional name.tar object, preserving the legacy API. */ public async storeImage(imageName: string, tarStream: plugins.stream.Readable): Promise { await this.beginStore(imageName, tarStream, {}, false); } /** Creates a unique immutable archive and returns only after joined storage and cleanup. */ public storeImageArchive( imageName: string, tarStream: plugins.stream.Readable, options: IDockerImageStoreOperationOptions = {}, ): Promise { return this.beginStore(imageName, tarStream, options, true); } private track(controller: AbortController, operation: Promise): Promise { this.active.set(operation, controller); operation.then(() => this.active.delete(operation), () => this.active.delete(operation)); return operation; } private assertRunning(): void { if (this.stopped) throw new Error('Docker image store has stopped'); } private requireDirectory(): plugins.smartbucket.Directory { if (!this.options.bucketDir) throw new Error('Docker image-store S3 storage is not configured'); return this.options.bucketDir; } private beginStore( imageName: string, tarStream: plugins.stream.Readable, options: IDockerImageStoreOperationOptions, immutable: boolean, ): Promise { const controller = new AbortController(); const externalSignal = options.signal; const onExternalAbort = () => controller.abort(externalSignal?.reason); const onAbort = () => tarStream.destroy(controller.signal.reason instanceof Error ? controller.signal.reason : new Error('Docker image archive operation aborted')); const sourceSettlement = plugins.streamPromises.finished(tarStream, { cleanup: true }).catch((error) => { controller.abort(error); }); controller.signal.addEventListener('abort', onAbort, { once: true }); externalSignal?.addEventListener('abort', onExternalAbort, { once: true }); if (externalSignal?.aborted) onExternalAbort(); if (tarStream.errored) controller.abort(tarStream.errored); const storagePath = immutable ? `archives/${plugins.crypto.randomUUID()}` : imageName; const operation = (async (): Promise => { let objectPath: string | null = null; let processingDirectory: string | undefined; let publication: IDockerImageArchiveFailureState['publication'] = 'not-published'; let receipt: IDockerImageArchiveReceipt | null = null; let failure: unknown; let failed = false; try { this.assertRunning(); controller.signal.throwIfAborted(); assertStoragePath(imageName); const directory = this.requireDirectory(); const bucket = directory.bucketRef; const localDirPath = this.options.localDirPath; objectPath = objectPathFor(directory, storagePath); await plugins.fs.promises.mkdir(localDirPath, { recursive: true }); controller.signal.throwIfAborted(); processingDirectory = await plugins.fs.promises.mkdtemp(plugins.path.join(localDirPath, 'image-')); this.processingDirectories.add(processingDirectory); controller.signal.throwIfAborted(); const extractedPath = plugins.path.join(processingDirectory, 'extracted'); const archivePath = plugins.path.join(processingDirectory, 'image.tar'); const tarTools = new plugins.smartarchive.TarTools(); await tarTools.extractToNewDirectory(tarStream, extractedPath, { signal: controller.signal }); await this.rewriteImageMetadata(imageName, extractedPath, controller.signal); controller.signal.throwIfAborted(); await tarTools.packDirectoryToStream(extractedPath, plugins.fs.createWriteStream(archivePath, { flags: 'wx', mode: 0o600 }), { signal: controller.signal, }); controller.signal.throwIfAborted(); const byteLength = (await plugins.fs.promises.stat(archivePath)).size; if (!Number.isSafeInteger(byteLength) || byteLength <= 0) throw new Error('Repacked image archive has an invalid size'); let capability: plugins.smartbucket.IExactUploadCapability | undefined; if (immutable) { const support = await bucket.probeExactUploadCapability({ signal: controller.signal }); if (!support.supported) throw new Error(`Immutable archive uploads unavailable: ${support.reason}`, { cause: support.cause }); capability = support.capability; } controller.signal.throwIfAborted(); const hash = plugins.crypto.createHash('sha256'); let consumedBytes = 0; let archiveDigest: IDockerImageArchiveReceipt['archiveDigest'] | undefined; const digestStream = new plugins.stream.Transform({ transform(chunk: Buffer, _encoding, callback) { hash.update(chunk); consumedBytes += chunk.byteLength; callback(null, chunk); }, flush(callback) { if (consumedBytes !== byteLength) return callback(new Error('Repacked archive changed size while uploading')); archiveDigest = `sha256:${hash.digest('hex')}`; callback(); }, }); const source = plugins.fs.createReadStream(archivePath); const pump = plugins.streamPromises.pipeline(source, digestStream, { signal: controller.signal }); pump.catch((error) => controller.abort(error)); try { publication = 'ambiguous'; if (capability) { const stored = await bucket.fastPutStreamExact({ capability, path: objectPath, readableStream: digestStream, contentLength: byteLength, nativeObjectProperties: { contentType: 'application/x-tar' }, signal: controller.signal, }); publication = 'published'; if (stored.destinationPath !== objectPath || stored.contentLength !== byteLength) { throw new Error('SmartBucket archive receipt does not match the requested object'); } } else { // SmartBucket owns the atomic PutObject. Never delete the old key first. await bucket.fastPutStream({ path: objectPath, readableStream: digestStream, contentLength: byteLength, overwrite: true }); publication = 'published'; } // Keep confirmed storage evidence even if a concurrent stop interrupts // the local file's final close callback after every byte was consumed. if (archiveDigest) { receipt = Object.freeze({ schemaVersion: 1, imageName, storagePath, objectPath, archiveDigest, byteLength }); } await pump; if (!receipt) throw new Error('Archive digest was not finalized'); controller.signal.throwIfAborted(); } catch (error) { if (error instanceof plugins.smartbucket.ExactUploadError) publication = error.state.destinationPublication; controller.abort(error); await pump.catch(() => {}); throw error; } } catch (error) { failed = true; failure = error; controller.abort(error); } finally { await sourceSettlement; if (processingDirectory) { try { await plugins.fs.promises.rm(processingDirectory, { recursive: true, force: true }); this.processingDirectories.delete(processingDirectory); } catch (error) { failure = failed ? new AggregateError([failure, error], 'Image processing and cleanup failed') : error; failed = true; } } if (!failed && controller.signal.aborted) { failure = controller.signal.reason; failed = true; } externalSignal?.removeEventListener('abort', onExternalAbort); controller.signal.removeEventListener('abort', onAbort); } if (failed) throw new DockerImageArchiveStoreError({ storagePath, objectPath, publication, storedArchive: receipt }, failure); return receipt!; })(); return this.track(controller, operation); } private async rewriteImageMetadata(imageName: string, directory: string, signal: AbortSignal): Promise { const readJson = async (name: string): Promise => { signal.throwIfAborted(); return JSON.parse(await plugins.fs.promises.readFile(plugins.path.join(directory, name), 'utf8')); }; const index = await readJson('index.json') as { manifests?: { annotations?: Record }[] }; const manifest = await readJson('manifest.json') as { RepoTags?: string[] }[]; const layout = await readJson('oci-layout'); if (index.manifests?.[0]?.annotations) index.manifests[0].annotations['io.containerd.image.name'] = imageName; if (manifest?.[0]?.RepoTags) manifest[0].RepoTags[0] = imageName; const metadata: [string, unknown][] = [['index.json', index], ['manifest.json', manifest], ['oci-layout', layout]]; let repositories: Record | undefined; try { repositories = await readJson('repositories') as Record; } catch (error) { if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error; } if (repositories) { const firstKey = Object.keys(repositories)[0]; if (firstKey !== undefined && firstKey !== imageName) { Object.defineProperty(repositories, imageName, { value: repositories[firstKey], writable: true, configurable: true, enumerable: true }); delete repositories[firstKey]; } metadata.push(['repositories', repositories]); } for (const [name, contents] of metadata) { signal.throwIfAborted(); await plugins.fs.promises.writeFile(plugins.path.join(directory, name), JSON.stringify(contents, null, 2)); } signal.throwIfAborted(); } /** Ensures the shared scratch root exists without removing another operation's files. */ public start(): Promise { const controller = new AbortController(); return this.track(controller, (async () => { this.assertRunning(); await plugins.fs.promises.mkdir(this.options.localDirPath, { recursive: true }); controller.signal.throwIfAborted(); })()); } /** Permanently stops new work, cancels active work, and joins owned processing cleanup. */ public stop(): Promise { this.stopped = true; for (const controller of this.active.values()) controller.abort(new Error('Docker image store stopped')); if (!this.stopPromise) { this.stopPromise = (async () => { await Promise.allSettled([...this.active.keys()]); const errors: unknown[] = []; for (const directory of this.processingDirectories) { try { await plugins.fs.promises.rm(directory, { recursive: true, force: true }); this.processingDirectories.delete(directory); } catch (error) { errors.push(error); } } if (errors.length) throw new AggregateError(errors, 'Docker image-store temporary cleanup failed'); })().catch((error) => { this.stopPromise = undefined; throw error; }); } return this.stopPromise; } /** Retrieves from SmartBucket; ownership of a successful returned stream passes to the caller. */ public getImage(storagePath: string, options: IDockerImageStoreOperationOptions = {}): Promise { const controller = new AbortController(); const externalSignal = options.signal; const onAbort = () => controller.abort(externalSignal?.reason); externalSignal?.addEventListener('abort', onAbort, { once: true }); if (externalSignal?.aborted) onAbort(); return this.track(controller, (async () => { let transferred = false; try { this.assertRunning(); controller.signal.throwIfAborted(); const directory = this.requireDirectory(); const source = await directory.bucketRef.fastGetStream({ path: objectPathFor(directory, storagePath), signal: controller.signal }); if (controller.signal.aborted || this.stopped) { const settlement = plugins.streamPromises.finished(source, { cleanup: true }).catch(() => {}); source.destroy(); await settlement; throw new Error('Docker image retrieval was stopped'); } transferred = true; plugins.streamPromises.finished(source, { cleanup: true }).then( () => externalSignal?.removeEventListener('abort', onAbort), () => externalSignal?.removeEventListener('abort', onAbort), ); return source; } finally { if (!transferred) externalSignal?.removeEventListener('abort', onAbort); } })()); } }