diff --git a/lib/docker-client.ts b/lib/docker-client.ts index 1fd94bd..18a3726 100644 --- a/lib/docker-client.ts +++ b/lib/docker-client.ts @@ -17,6 +17,7 @@ import { isFileNotFoundError, parseDockerHost, } from './util.js'; +import type { Platform } from './models/index.js'; import type { SecureContextOptions } from 'tls'; export interface Credentials { @@ -309,10 +310,12 @@ export class DockerClient { ) { await this.api.sendHTTPRequest('GET', '/events', { params: options, - callback: (data: string) => { - data.split('\n').forEach((line) => { - callback(JSON.parse(line) as models.EventMessage); - }); + callback: (data: Buffer) => { + data.toString('utf-8') + .split('\n') + .forEach((line) => { + callback(JSON.parse(line) as models.EventMessage); + }); }, }); } @@ -969,7 +972,7 @@ export class DockerClient { }); } - // --- Images API + // --- Image API /** * Return image digest and platform information by contacting the registry. @@ -982,6 +985,59 @@ export class DockerClient { return this.api.get(`/distribution/${name}/json`); } + /** + * Delete builder cache + * @param reservedSpace Amount of disk space in bytes to keep for cache + * @param maxUsedSpace Maximum amount of disk space allowed to keep for cache + * @param minFreeSpace Target amount of free disk space after pruning + * @param all Remove all types of build cache + * @param filters A JSON encoded value of the filters (a `map[string][]string`) to process on the list of build cache objects. Available filters: - `until=<timestamp>` remove cache older than `<timestamp>`. The `<timestamp>` can be Unix timestamps, date formatted timestamps, or Go duration strings (e.g. `10m`, `1h30m`) computed relative to the daemon\'s local time. - `id=<id>` - `parent=<id>` - `type=<string>` - `description=<string>` - `inuse` - `shared` - `private` + */ + public async buildPrune(options?: { + reservedSpace?: number; + maxUsedSpace?: number; + minFreeSpace?: number; + all?: boolean; + filters?: Filter; + }): Promise { + return this.api.post('/build/prune', options); + } + + /** + * Create a new image from a container + * @param container The ID or name of the container to commit + * @param repo Repository name for the created image + * @param tag Tag name for the create image + * @param comment Commit message + * @param author Author of the image (e.g., `John Hannibal Smith <hannibal@a-team.com>`) + * @param pause Whether to pause the container before committing + * @param changes `Dockerfile` instructions to apply while committing + * @param containerConfig The container configuration + */ + public async imageCommit( + container: string, + options?: { + repo?: string; + tag?: string; + comment?: string; + author?: string; + pause?: boolean; + changes?: string; + containerConfig?: models.ContainerConfig; + }, + ): Promise { + return this.api.post(`/commit`, { + container: container, + repo: options?.repo, + tag: options?.tag, + comment: options?.comment, + author: options?.author, + pause: options?.pause, + changes: options?.changes, + containerConfig: options?.containerConfig, + }); + } + /** * Pull or import an image. * Create an image @@ -1033,12 +1089,14 @@ export class DockerClient { }, undefined, headers, - (data: string) => { - data.split('\n').forEach((line) => { - if (line) { - callback(JSON.parse(line)); - } - }); + (data: Buffer) => { + data.toString('utf-8') + .split('\n') + .forEach((line) => { + if (line) { + callback(JSON.parse(line)); + } + }); }, ); } @@ -1060,7 +1118,44 @@ export class DockerClient { platforms?: Array; }, ): Promise> { - return this.api.delete(`/image/${name}`, options); + return this.api.delete(`/images/${name}`, options); + } + + /** + * Get a tarball containing all images and metadata for a repository. If `name` is a specific name and tag (e.g. `ubuntu:latest`), then only that image (and its parents) are returned. If `name` is an image ID, similarly only that image (and its parents) are returned, but with the exclusion of the `repositories` file in the tarball, as there were no image names referenced. ### Image tarball format An image tarball contains [Content as defined in the OCI Image Layout Specification](https://github.com/opencontainers/image-spec/blob/v1.1.1/image-layout.md#content). Additionally, includes the manifest.json file associated with a backwards compatible docker save format. If the tarball defines a repository, the tarball should also include a `repositories` file at the root that contains a list of repository and tag names mapped to layer IDs. ```json { \"hello-world\": { \"latest\": \"565a9d68a73f6706862bfe8409a7f659776d4d60a8d096eb4a3cbce6999cc2a1\" } } ``` + * Export an image + * @param name Image name or ID + * @param platform JSON encoded OCI platform describing a platform which will be used to select a platform-specific image to be saved if the image is multi-platform. If not provided, the full multi-platform image will be saved. Example: `{\"os\": \"linux\", \"architecture\": \"arm\", \"variant\": \"v5\"}` + */ + public async imageGet( + name: string, + w: stream.Writable, + platform?: models.Platform, + ): Promise { + return this.api.get( + `/images/${name}/get`, + { + platform: platform, + }, + 'application/x-tar', + (data: any) => w.write(data), + ); + } + + /** + * Get a tarball containing all images and metadata for several image repositories. For each value of the `names` parameter: if it is a specific name and tag (e.g. `ubuntu:latest`), then only that image (and its parents) are returned; if it is an image ID, similarly only that image (and its parents) are returned and there would be no names referenced in the \'repositories\' file for this image ID. For details on the format, see the [export image endpoint](#operation/ImageGet). + * Export several images + * @param names Image names to filter by + * @param platform JSON encoded OCI platform(s) which will be used to select the platform-specific image(s) to be saved if the image is multi-platform. If not provided, the full multi-platform image will be saved. Example: `{\"os\": \"linux\", \"architecture\": \"arm\", \"variant\": \"v5\"}` + */ + public async imageGetAll( + names: Array, + platform?: models.Platform, + ): Promise { + return this.api.get(`/images/get`, { + names: names, + platform: platform, + }); } /** @@ -1115,6 +1210,99 @@ export class DockerClient { return this.api.get('/images/json', options); } + /** + * Load a set of images and tags into a repository. For details on the format, see the [export image endpoint](#operation/ImageGet). + * Import images + * @param quiet Suppress progress details during load. + * @param platform JSON encoded OCI platform(s) which will be used to select the platform-specific image(s) to load if the image is multi-platform. If not provided, the full multi-platform image will be loaded. Example: `{\"os\": \"linux\", \"architecture\": \"arm\", \"variant\": \"v5\"}` + * @param imagesTarball Tar archive containing images + */ + public async imageLoad( + imagesTarball: stream.Readable, + options?: { + quiet?: boolean; + platform?: Platform; + callback?: (event: any) => void; + }, + ): Promise { + return this.api.post( + `/images/load`, + options, + imagesTarball, + { + 'Content-Type': 'application/x-tar', + }, + options?.callback, + ); + } + + /** + * Delete unused images + * @param filters Filters to process on the prune list, encoded as JSON (a `map[string][]string`). Available filters: - `dangling=<boolean>` When set to `true` (or `1`), prune only unused *and* untagged images. When set to `false` (or `0`), all unused images are pruned. - `until=<string>` Prune images created before this timestamp. The `<timestamp>` can be Unix timestamps, date formatted timestamps, or Go duration strings (e.g. `10m`, `1h30m`) computed relative to the daemon machine’s time. - `label` (`label=<key>`, `label=<key>=<value>`, `label!=<key>`, or `label!=<key>=<value>`) Prune images with (or without, in case `label!=...` is used) the specified labels. + */ + public async imagePrune( + filters?: Filter, + ): Promise { + return this.api.post(`/images/prune`, { + filters: filters, + }); + } + + /** + * Push an image to a registry. If you wish to push an image on to a private registry, that image must already have a tag which references the registry. For example, `registry.example.com/myimage:latest`. The push is cancelled if the HTTP connection is closed. + * Push an image + * @param name Name of the image to push. For example, `registry.example.com/myimage`. The image must be present in the local image store with the same name. The name should be provided without tag; if a tag is provided, it is ignored. For example, `registry.example.com/myimage:latest` is considered equivalent to `registry.example.com/myimage`. Use the `tag` parameter to specify the tag to push. + * @param credentials A base64url-encoded auth configuration. Refer to the [authentication section](#section/Authentication) for details. + * @param tag Tag of the image to push. For example, `latest`. If no tag is provided, all tags of the given image that are present in the local image store are pushed. + * @param platform JSON-encoded OCI platform to select the platform-variant to push. If not provided, all available variants will attempt to be pushed. If the daemon provides a multi-platform image store, this selects the platform-variant to push to the registry. If the image is a single-platform image, or if the multi-platform image does not provide a variant matching the given platform, an error is returned. Example: `{\"os\": \"linux\", \"architecture\": \"arm\", \"variant\": \"v5\"}` + */ + public async imagePush( + name: string, + options: { + credentials: Credentials | IdentityToken; + tag?: string; + platform?: Platform; + callback: (event: any) => void; + }, + ): Promise { + const headers: Record = {}; + + if (options?.credentials) { + headers['X-Registry-Auth'] = this.authCredentials( + options.credentials, + ); + } + + return this.api.post( + `/images/${name}/push`, + { + tag: options?.tag, + platform: options?.platform, + }, + undefined, + headers, + options?.callback, + ); + } + + /** + * Tag an image so that it becomes part of a repository. + * Tag an image + * @param name Image name or ID to tag. + * @param repo The repository to tag in. For example, `someuser/someimage`. + * @param tag The name of the new tag. + */ + public async imageTag( + name: string, + repo: string, + tag: string, + ): Promise { + return this.api.post(`/images/${name}/tag`, { + repo: repo, + tag: tag, + }); + } + // -- Exec /** diff --git a/lib/http.ts b/lib/http.ts index 6577f5f..1d142f6 100644 --- a/lib/http.ts +++ b/lib/http.ts @@ -76,8 +76,8 @@ export class HTTPClient { uri: string, options?: { params?: Record; - data?: object; - callback?: (data: string) => void; + data?: any; + callback?: (data: Buffer) => void; accept?: string; headers?: Record; }, @@ -113,7 +113,7 @@ export class HTTPClient { typeof (data as any).read === 'function' ) { // Use chunked transfer encoding for streams - body = data as NodeJS.ReadableStream; + body = data as stream.Readable; requestHeaders['Transfer-Encoding'] = 'chunked'; } else { // Convert to JSON string for objects @@ -198,7 +198,7 @@ export class HTTPClient { if (isDockerStream && callback) { // For upgrade protocols, forward all data directly to callback res.on('data', (data: Buffer) => { - callback(data.toString('utf8')); + callback(data); }); // Resolve immediately with upgrade response @@ -212,7 +212,7 @@ export class HTTPClient { callback ) { res.on('data', (chunk: Buffer) => { - callback(chunk.toString('utf8')); + callback(chunk); }); res.on('end', () => handleResponseEnd()); @@ -242,19 +242,8 @@ export class HTTPClient { req.write(body); req.end(); } else { - // Handle stream body - body.on('data', (chunk: Buffer) => { - req.write(chunk); - }); - - body.on('end', () => { - req.end(); - }); - - body.on('error', (error) => { - req.destroy(error); - reject(error); - }); + const input = body as stream.Readable; + input.pipe(req); } } else { req.end(); @@ -300,7 +289,7 @@ export class HTTPClient { uri: string, params?: Record, accept?: string, - callback?: (data: any) => boolean, + callback?: (data: Buffer) => boolean, ): Promise { return this.sendHTTPRequest('GET', uri, { params: params, @@ -314,7 +303,7 @@ export class HTTPClient { params?: Record, data?: object, headers?: Record, - callback?: (data: any) => void, + callback?: (data: Buffer) => void, ): Promise { return this.sendHTTPRequest('POST', uri, { params: params, diff --git a/package-lock.json b/package-lock.json index 660db6c..ef42e0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@docker/node-sdk", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@docker/node-sdk", - "version": "0.0.1", + "version": "0.0.2", "license": "Apache-2.0", "dependencies": { "ssh2": "^1.16.0" diff --git a/test/image.test.ts b/test/image.test.ts new file mode 100644 index 0000000..098a756 --- /dev/null +++ b/test/image.test.ts @@ -0,0 +1,162 @@ +import { assert, expect, test } from 'vitest'; +import { DockerClient } from '../lib/docker-client.js'; +import { Filter } from '../lib/filter.js'; +import { Writable } from 'stream'; +import { Readable } from 'node:stream'; +import type { NotFoundError } from '../lib/http.js'; + +test('image lifecycle: create container, commit image, export/import, inspect, and prune', async () => { + const client = await DockerClient.fromDockerConfig(); + let containerId: string | undefined; + const testImageName = 'test'; + + try { + // Step 1: Pull alpine image and create container + console.log(' Pulling alpine image...'); + await client.imageCreate( + (event) => { + if (event.status) console.log(` ${event.status}`); + }, + { + fromImage: 'docker.io/library/alpine', + tag: 'latest', + }, + ); + + console.log(' Creating Alpine container...'); + const createResponse = await client.containerCreate({ + Image: 'docker.io/library/alpine:latest', + Cmd: ['echo', 'test container'], + Labels: { + 'test.type': 'image-test', + }, + }); + + containerId = createResponse.Id; + assert.isNotNull(containerId); + console.log(` Container created: ${containerId.substring(0, 12)}`); + + // Step 2: Commit container as new image with label + console.log(' Committing container as new image...'); + const commitResponse = await client.imageCommit(containerId, { + repo: testImageName, + tag: 'latest', + changes: 'LABEL test=true', + }); + + assert.isNotNull(commitResponse.Id); + console.log( + ` Image committed: ${commitResponse.Id.substring(0, 19)}`, + ); + + // Verify the committed image exists + console.log(' Verifying committed image exists...'); + const images = await client.imageList({ + filters: new Filter().add('label', 'test=true'), + }); + const testImage = images.find((img) => + img.RepoTags?.includes(`${testImageName}:latest`), + ); + assert.isNotNull(testImage, 'Test image should exist after commit'); + console.log( + ` Found committed image: ${testImage!.Id!.substring(0, 19)}`, + ); + + // Step 3: Get image as tar file + console.log(' Exporting image as tar file...'); + const tarData: Buffer[] = []; + + await client.imageGet( + testImageName, + new Writable({ + write(chunk, encoding, callback) { + const data = + typeof chunk === 'string' + ? Buffer.from(chunk, encoding) + : chunk; + tarData.push(data); + callback(); + }, + }), + ); + + // Write tar data to file + const tarBuffer = Buffer.concat(tarData); + console.log( + ` Image exported to tar file: (${tarBuffer.length} bytes)`, + ); + + // Step 4: Delete the test image + console.log(' Deleting test image...'); + await client.imageDelete(testImageName, { force: true }); + console.log(' Test image deleted'); + + // Verify image is deleted + const imagesAfterDelete = await client.imageList(); + const deletedImage = imagesAfterDelete.find((img) => + img.RepoTags?.includes(`${testImageName}:latest`), + ); + assert.isUndefined(deletedImage, 'Test image should be deleted'); + console.log(' Verified image deletion'); + + // Step 5: Load image from tar file + console.log(' Loading image from tar file...'); + await client.imageLoad(Readable.from(tarBuffer)); + console.log(' Image loaded from tar file'); + + // Step 6: Inspect the loaded image to confirm successful load + console.log(' Inspecting loaded image...'); + const inspectResponse = await client.imageInspect(testImageName); + assert.isNotNull( + inspectResponse, + 'Should be able to inspect loaded image', + ); + assert.isNotNull( + inspectResponse.Config?.Labels?.['test'], + 'Image should have test=true label', + ); + assert.equal( + inspectResponse.Config?.Labels?.['test'], + 'true', + 'Label should be "true"', + ); + console.log( + ` Image inspection successful: ${inspectResponse.Id!.substring(0, 19)}`, + ); + console.log( + ` Verified label test=${inspectResponse.Config?.Labels?.['test']}`, + ); + + // Verify the image exists in the list again + const imagesAfterLoad = await client.imageList(); + const loadedImage = imagesAfterLoad.find((img) => + img.RepoTags?.includes(`${testImageName}:latest`), + ); + assert.isNotNull(loadedImage, 'Test image should exist after load'); + console.log(' Verified loaded image in image list'); + } finally { + // Clean up: delete container + if (containerId) { + console.log(' Cleaning up container...'); + try { + await client.containerDelete(containerId, { force: true }); + console.log(' Container deleted successfully'); + } catch (err) { + if ((err as NotFoundError)?.name === 'NotFoundError') { + console.log(' Container already deleted or not found'); + } else { + console.log( + ` Warning: Failed to delete container: ${(err as any)?.message}`, + ); + } + } + } + + // Clean up: ensure test image is deleted + try { + await client.imageDelete(testImageName, { force: true }); + } catch (deleteError) { + // Ignore error - image might already be deleted + } + } +}, 60000); // 60 second timeout for this comprehensive test