import * as plugins from './plugins.js'; import * as interfaces from './interfaces/index.js'; import { DockerHost } from './classes.host.js'; import { DockerResource } from './classes.base.js'; import { assertDockerImageId, assertDockerMutableImageReference, assertDockerResponseStatus, assertNonemptyDockerString, buildDockerFilterRoute, formatDockerResponseBody, } from './helpers.docker.js'; import { logger } from './logger.js'; /** * represents a docker image on the remote docker host */ export class DockerImage extends DockerResource { // STATIC (Internal - prefixed with _ to indicate internal use) /** * Internal: Get all images * Public API: Use dockerHost.listImages() instead */ public static async _list( dockerHost: DockerHost, optionsArg: interfaces.IImageListOptions = {}, ): Promise { const images: DockerImage[] = []; let route = `/images/json?all=${optionsArg.all ? 'true' : 'false'}`; route = buildDockerFilterRoute(route, optionsArg.filters); const response = await dockerHost.request('GET', route); assertDockerResponseStatus('Docker image list', 200, response); if (!Array.isArray(response.body)) { throw new Error( `Docker image list failed: expected response body to be an array; response body: ${formatDockerResponseBody(response.body)}`, ); } for (const imageObject of response.body) { images.push(new DockerImage(dockerHost, imageObject)); } return images; } /** * Internal: Inspect an image by a complete local reference. */ public static async _fromReference( dockerHostArg: DockerHost, referenceArg: string, signalArg?: AbortSignal, ): Promise { assertNonemptyDockerString(referenceArg, 'Docker image reference'); signalArg?.throwIfAborted(); const response = await dockerHostArg.request( 'GET', `/images/${encodeURIComponent(referenceArg)}/json`, {}, signalArg ? { timeoutMs: 0, signal: signalArg } : undefined, ); if (response.statusCode === 404) { return undefined; } assertDockerResponseStatus( `Docker image inspect reference "${referenceArg}"`, 200, response, ); try { assertDockerImageId( response.body?.Id, `Docker image inspect reference "${referenceArg}" response Id`, ); } catch { throw new Error( `Docker image inspect reference "${referenceArg}" failed: inspected Id is not a complete immutable sha256 local image ID; response Id: ${response.body?.Id}`, ); } const image = new DockerImage(dockerHostArg, response.body); image.Reference = referenceArg; return image; } /** * Internal: Inspect an image through its immutable local ID. */ public static async _fromId( dockerHostArg: DockerHost, idArg: string, ): Promise { assertDockerImageId(idArg, 'Docker image local ID'); const response = await dockerHostArg.request( 'GET', `/images/${encodeURIComponent(idArg)}/json`, ); if (response.statusCode === 404) { return undefined; } assertDockerResponseStatus(`Docker image inspect ID "${idArg}"`, 200, response); const image = new DockerImage(dockerHostArg, response.body); if (image.Id !== idArg) { throw new Error( `Docker image inspect ID "${idArg}" failed: inspected Id "${image.Id}" does not equal the requested immutable ID`, ); } return image; } /** * Pulls a complete registry reference and verifies the expected repository * digest before exposing the resolved local image ID. */ public static async _pull( dockerHostArg: DockerHost, descriptorArg: interfaces.IImagePullDescriptor, ): Promise { descriptorArg.signal?.throwIfAborted(); assertNonemptyDockerString(descriptorArg.reference, 'Docker image reference'); assertNonemptyDockerString( descriptorArg.expectedRepoDigest, 'Docker expected repository digest', ); const expectedDigestMatch = /^([^\s@]+)@sha256:[0-9a-f]{64}$/.exec( descriptorArg.expectedRepoDigest, ); const expectedRepository = expectedDigestMatch?.[1]; const repositoryLastComponent = expectedRepository?.slice( expectedRepository.lastIndexOf('/') + 1, ); if (!expectedRepository || repositoryLastComponent?.includes(':')) { throw new TypeError( 'Docker expected repository digest must be a complete repository@sha256 reference', ); } if ( descriptorArg.reference !== descriptorArg.reference.trim() || /\s/.test(descriptorArg.reference) ) { throw new TypeError('Docker image reference must not contain whitespace'); } if ( descriptorArg.reference.includes('@') && descriptorArg.reference !== descriptorArg.expectedRepoDigest ) { throw new Error( `Docker image digest reference "${descriptorArg.reference}" does not equal expected repository digest "${descriptorArg.expectedRepoDigest}"`, ); } const response = await dockerHostArg.request( 'POST', `/images/create?fromImage=${encodeURIComponent(descriptorArg.reference)}`, {}, { timeoutMs: 0, ...(descriptorArg.signal === undefined ? {} : { signal: descriptorArg.signal }), ...(descriptorArg.registryAuth === undefined ? {} : { registryAuth: descriptorArg.registryAuth }), }, ); assertDockerResponseStatus( `Docker image pull "${descriptorArg.reference}"`, 200, response, ); DockerImage.assertPullResponseHasNoError( descriptorArg.reference, response.body, ); descriptorArg.signal?.throwIfAborted(); const image = await DockerImage._fromReference( dockerHostArg, descriptorArg.reference, descriptorArg.signal, ); if (!image) { throw new Error( `Docker image pull "${descriptorArg.reference}" failed: direct inspect did not find the pulled reference`, ); } assertNonemptyDockerString(image.Id, 'Docker pulled image local ID'); if (!/^sha256:[0-9a-f]{64}$/.test(image.Id)) { throw new Error( `Docker image pull "${descriptorArg.reference}" failed: inspected Id is not a complete immutable sha256 local image ID; response Id: ${image.Id}`, ); } if (!Array.isArray(image.RepoDigests) || !image.RepoDigests.includes(descriptorArg.expectedRepoDigest)) { throw new Error( `Docker image pull "${descriptorArg.reference}" failed: expected RepoDigest "${descriptorArg.expectedRepoDigest}"; actual RepoDigests: ${formatDockerResponseBody(image.RepoDigests)}`, ); } image.Reference = descriptorArg.reference; image.#verifiedRepoDigest = descriptorArg.expectedRepoDigest; image.VerifiedRepoDigest = descriptorArg.expectedRepoDigest; logger.log('info', `Pulled and verified Docker image ${descriptorArg.expectedRepoDigest}`); return image; } /** * Pulls an explicitly tagged mutable application image and returns its * directly inspected DockerImage with an immutable local Id. */ public static async _pullMutable( dockerHostArg: DockerHost, descriptorArg: interfaces.IImageMutablePullDescriptor, ): Promise { const { reference, registryAuth, signal } = descriptorArg; signal?.throwIfAborted(); assertDockerMutableImageReference(reference); const response = await dockerHostArg.request( 'POST', `/images/create?fromImage=${encodeURIComponent(reference)}`, {}, { timeoutMs: 0, ...(signal === undefined ? {} : { signal }), ...(registryAuth === undefined ? {} : { registryAuth }), }, ); assertDockerResponseStatus( `Docker mutable image pull "${reference}"`, 200, response, ); DockerImage.assertPullResponseHasNoError(reference, response.body); signal?.throwIfAborted(); const image = await DockerImage._fromReference( dockerHostArg, reference, signal, ); if (!image) { throw new Error( `Docker mutable image pull "${reference}" failed: direct inspect did not find the pulled reference`, ); } image.Reference = reference; image.#pulledMutableReference = reference; logger.log( 'info', `Pulled mutable Docker image ${reference} as immutable local image ${image.Id}`, ); return image; } private static assertPullResponseHasNoError( referenceArg: string, bodyArg: unknown, ): void { if (typeof bodyArg !== 'string' || bodyArg.trim().length === 0) { return; } for (const line of bodyArg.split('\n')) { const trimmedLine = line.trim(); if (!trimmedLine) { continue; } let event: unknown; try { event = JSON.parse(trimmedLine); } catch { throw new Error( `Docker image pull "${referenceArg}" failed: malformed JSON progress response; response body: ${bodyArg}`, ); } if (typeof event !== 'object' || event === null) { continue; } const errorDetail = Reflect.get(event, 'errorDetail'); const errorDetailMessage = typeof errorDetail === 'object' && errorDetail !== null ? Reflect.get(errorDetail, 'message') : undefined; const eventError = Reflect.get(event, 'error'); const errorMessage = typeof errorDetailMessage === 'string' ? errorDetailMessage : typeof eventError === 'string' ? eventError : undefined; if (errorMessage) { throw new Error( `Docker image pull "${referenceArg}" failed: ${errorMessage}; response body: ${bodyArg}`, ); } } } /** * Internal: Create image from tar stream * Public API: Use dockerHost.createImageFromTarStream(stream, descriptor) instead */ public static async _createFromTarStream( dockerHostArg: DockerHost, optionsArg: { creationObject: interfaces.IImageCreationDescriptor; tarStream: plugins.smartstream.stream.Readable; }, ): Promise { // Start the request for importing an image const response = await dockerHostArg.requestStreaming( 'POST', '/images/load', optionsArg.tarStream, ); // requestStreaming now returns Node.js stream const nodeStream = response as plugins.smartstream.stream.Readable; /** * Docker typically returns lines like: * {"stream":"Loaded image: myrepo/myimage:latest"} * * So we will collect those lines and parse out the final image name. */ let rawOutput = ''; nodeStream.on('data', (chunk) => { rawOutput += chunk.toString(); }); // Wrap the end event in a Promise for easier async/await usage await new Promise((resolve, reject) => { nodeStream.on('end', () => { resolve(); }); nodeStream.on('error', (err) => { reject(err); }); }); // Attempt to parse each line to find something like "Loaded image: ..." let loadedImageTag: string | undefined; const lines = rawOutput.trim().split('\n').filter(Boolean); for (const line of lines) { try { const jsonLine = JSON.parse(line); if ( jsonLine.stream && (jsonLine.stream.startsWith('Loaded image:') || jsonLine.stream.startsWith('Loaded image ID:')) ) { // Examples: // "Loaded image: your-image:latest" // "Loaded image ID: sha256:...." loadedImageTag = jsonLine.stream .replace('Loaded image: ', '') .replace('Loaded image ID: ', '') .trim(); } } catch { // not valid JSON, ignore } } if (!loadedImageTag) { throw new Error( `Could not parse the loaded image info from Docker response.\nResponse was:\n${rawOutput}`, ); } // Now try to look up that image by the "loadedImageTag". // Depending on Docker's response, it might be something like: // "myrepo/myimage:latest" OR "sha256:someHash..." // If Docker gave you an ID (e.g. "sha256:..."), you may need a separate // DockerImage.getImageById method; or if you prefer, you can treat it as a name. const newlyImportedImage = await DockerImage._fromReference( dockerHostArg, loadedImageTag, ); if (!newlyImportedImage) { throw new Error( `Image load succeeded, but no local reference found for "${loadedImageTag}".`, ); } logger.log('info', `Successfully imported image "${loadedImageTag}".`); return newlyImportedImage; } public static async tagImageByIdOrName( dockerHost: DockerHost, idOrNameArg: string, newTagArg: string, ) { const response = await dockerHost.request( 'POST', `/images/${encodeURIComponent(idOrNameArg)}/${encodeURIComponent(newTagArg)}`, ); } /** * Internal: Build image from Dockerfile * Public API: Use dockerHost.buildImage(tag) instead */ public static async _build(dockerHostArg: DockerHost, dockerImageTag) { // TODO: implement building an image } // INSTANCE PROPERTIES /** * the tags for an image */ public Containers!: number; public Created!: number; public Id!: string; public Labels!: interfaces.TLabels; public ParentId!: string; public RepoDigests!: string[]; public RepoTags!: string[]; public Os?: string; public Architecture?: string; public SharedSize!: number; public Size!: number; public VirtualSize!: number; /** Complete reference used for direct inspection or verified pull. */ public Reference?: string; #verifiedRepoDigest?: string; #pulledMutableReference?: string; /** RepoDigest verified during this pull operation. */ public VerifiedRepoDigest?: string; /** Checks pull-time proof that is not represented by caller-writable public fields. */ public hasVerifiedRepoDigest(referenceArg: string): boolean { return this.#verifiedRepoDigest === referenceArg; } /** Checks exact mutable-reference pull proof that caller-writable fields cannot represent. */ public hasPulledMutableReference(referenceArg: string): boolean { return this.#pulledMutableReference === referenceArg; } private readonly immutableId: string; constructor( dockerHostArg: DockerHost, dockerImageObjectArg: Record, ) { super(dockerHostArg); assertDockerImageId( dockerImageObjectArg?.Id, 'Docker image response Id', ); this.immutableId = dockerImageObjectArg.Id; Object.assign(this, dockerImageObjectArg); } // INSTANCE METHODS /** * Refreshes this image's state from the Docker daemon */ public async refresh(): Promise { const updated = await DockerImage._fromId(this.dockerHost, this.immutableId); if (!updated) { throw new Error( `Docker image refresh failed: image "${this.immutableId}" was not found`, ); } Object.assign(this, updated); this.Id = this.immutableId; } /** * tag an image * @param newTag */ public async tagImage(newTag) { throw new Error('.tagImage is not yet implemented'); } /** * Removes this image from the Docker daemon */ public async remove(options?: { force?: boolean; noprune?: boolean }): Promise { const queryParams = new URLSearchParams(); if (options?.force) queryParams.append('force', '1'); if (options?.noprune) queryParams.append('noprune', '1'); const queryString = queryParams.toString(); const response = await this.dockerHost.request( 'DELETE', `/images/${encodeURIComponent(this.Id)}${queryString ? '?' + queryString : ''}`, ); if (response.statusCode >= 300) { throw new Error(`Failed to remove image: ${response.statusCode}`); } } // get stuff public async getVersion() { if (this.Labels && this.Labels.version) { return this.Labels.version; } else { return '0.0.0'; } } /** * exports an image to a tar ball */ public async exportToTarStream(): Promise { logger.log('info', `Exporting image ${this.RepoTags[0]} to tar stream.`); const response = await this.dockerHost.requestStreaming( 'GET', `/images/${encodeURIComponent(this.RepoTags[0])}/get`, ); // requestStreaming now returns Node.js stream const nodeStream = response as plugins.smartstream.stream.Readable; let counter = 0; const webduplexStream = new plugins.smartstream.SmartDuplex({ writeFunction: async (chunk, tools) => { if (counter % 1000 === 0) console.log(`Got chunk: ${counter}`); counter++; return chunk; }, }); nodeStream.on('data', (chunk) => { if (!webduplexStream.write(chunk)) { nodeStream.pause(); webduplexStream.once('drain', () => { nodeStream.resume(); }); } }); nodeStream.on('end', () => { webduplexStream.end(); }); nodeStream.on('error', (error) => { logger.log('error', `Error during image export: ${error.message}`); webduplexStream.destroy(error); }); return webduplexStream; } }