From 28ea8ed15fa9d5cb3ac9876d642d2e62dde31cd7 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 17 Feb 2026 14:05:43 +0000 Subject: [PATCH 1/2] Support CopyUIDGID when copying archives to containers --- docs/features/containers.md | 21 ++++++++- .../clients/container/container-client.ts | 8 +++- .../container/docker-container-client.ts | 18 +++++++- .../abstract-started-container.ts | 15 +++++-- .../generic-container.test.ts | 45 +++++++++++++++++++ .../generic-container/generic-container.ts | 14 +++++- .../started-generic-container.ts | 15 +++++-- packages/testcontainers/src/index.ts | 2 +- packages/testcontainers/src/test-container.ts | 4 +- packages/testcontainers/src/types.ts | 15 +++++++ 10 files changed, 143 insertions(+), 14 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index 55331e8c9..69008d159 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -137,6 +137,9 @@ const container = await new GenericContainer("alpine") tar: nodeReadable, target: "/some/nested/remotedir" }]) + .withCopyToContainerOptions({ + copyUIDGID: true + }) .start(); ``` @@ -157,7 +160,9 @@ container.copyContentToContainer([{ content: "hello world", target: "/remote/file2.txt" }]) -container.copyArchiveToContainer(nodeReadable, "/some/nested/remotedir"); +container.copyArchiveToContainer(nodeReadable, "/some/nested/remotedir", { + copyUIDGID: true +}); ``` An optional `mode` can be specified in octal for setting file permissions: @@ -182,6 +187,20 @@ const container = await new GenericContainer("alpine") .start(); ``` +Archive copy options can also be specified: + +```js +const container = await new GenericContainer("alpine") + .withCopyToContainerOptions({ + copyUIDGID: true, + noOverwriteDirNonDir: true + }) + .start(); +``` + +- `copyUIDGID`: preserve UID/GID from tar archive entries. +- `noOverwriteDirNonDir`: fail if extraction would replace a file with a directory (or vice versa). + ### Copy archive from container Files and directories can be fetched from a started or stopped container as a tar archive. The archive is returned as a readable stream: diff --git a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts index 3f1460fe9..c1fc03cee 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts @@ -7,6 +7,7 @@ import Dockerode, { Network, } from "dockerode"; import { Readable } from "stream"; +import { CopyToContainerOptions } from "../../../types"; import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; export interface ContainerClient { @@ -21,7 +22,12 @@ export interface ContainerClient { ): Promise; fetchArchive(container: Container, path: string): Promise; - putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise; + putArchive( + container: Dockerode.Container, + stream: Readable, + path: string, + options?: CopyToContainerOptions + ): Promise; list(): Promise; create(opts: ContainerCreateOptions): Promise; start(container: Container): Promise; diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index ce22b023a..4ddef30e1 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -10,6 +10,7 @@ import Dockerode, { import { IncomingMessage } from "http"; import { PassThrough, Readable } from "stream"; import { execLog, log, streamToString, toSeconds } from "../../../common"; +import { CopyToContainerOptions } from "../../../types"; import { ContainerClient } from "./container-client"; import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; @@ -72,10 +73,23 @@ export class DockerContainerClient implements ContainerClient { } } - async putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise { + async putArchive( + container: Dockerode.Container, + stream: Readable, + path: string, + options?: CopyToContainerOptions + ): Promise { try { log.debug(`Putting archive to container...`, { containerId: container.id }); - await streamToString(Readable.from(await container.putArchive(stream, { path }))); + await streamToString( + Readable.from( + await container.putArchive(stream, { + path, + noOverwriteDirNonDir: options?.noOverwriteDirNonDir, + copyUIDGID: options?.copyUIDGID, + }) + ) + ); log.debug(`Put archive to container`, { containerId: container.id }); } catch (err) { log.error(`Failed to put archive to container: ${err}`, { containerId: container.id }); diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index caa067419..a90e7cc0d 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -1,6 +1,15 @@ import { Readable } from "stream"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; -import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; +import { + CommitOptions, + ContentToCopy, + CopyToContainerOptions, + DirectoryToCopy, + ExecOptions, + ExecResult, + FileToCopy, + Labels, +} from "../types"; export class AbstractStartedContainer implements StartedTestContainer { constructor(protected readonly startedTestContainer: StartedTestContainer) {} @@ -88,8 +97,8 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.copyContentToContainer(contentsToCopy); } - public copyArchiveToContainer(tar: Readable, target = "/"): Promise { - return this.startedTestContainer.copyArchiveToContainer(tar, target); + public copyArchiveToContainer(tar: Readable, target = "/", options?: CopyToContainerOptions): Promise { + return this.startedTestContainer.copyArchiveToContainer(tar, target, options); } public copyArchiveFromContainer(path: string): Promise { diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 7b7346015..d914eebe0 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -1,3 +1,4 @@ +import archiver from "archiver"; import getPort from "get-port"; import path from "path"; import { RandomUuid } from "../common"; @@ -463,6 +464,50 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content)); }); + it("should copy archive to started container with ownership when copyUIDGID is enabled", async () => { + const uid = 4242; + const gid = 4343; + const targetWithCopyOwnership = "/tmp/copy-archive-copyuidgid.txt"; + + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .start(); + + const tar = archiver("tar"); + tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData); + tar.finalize(); + + await container.copyArchiveToContainer(tar, "/", { copyUIDGID: true }); + + expect((await container.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim()).toEqual( + `${uid}:${gid}` + ); + }); + + it("should copy archives before start with ownership when copyUIDGID is enabled", async () => { + const uid = 4242; + const gid = 4343; + const targetWithCopyOwnership = "/tmp/with-copy-archives-copyuidgid.txt"; + const tar = archiver("tar"); + tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData); + tar.finalize(); + + await using containerWithCopyOwnership = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withCopyArchivesToContainer([ + { + tar, + target: "/", + }, + ]) + .withCopyToContainerOptions({ copyUIDGID: true }) + .withExposedPorts(8080) + .start(); + + expect( + (await containerWithCopyOwnership.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim() + ).toEqual(`${uid}:${gid}`); + }); + it("should honour .dockerignore file", async () => { const context = path.resolve(fixtures, "docker-with-dockerignore"); const container = await GenericContainer.fromDockerfile(context).build(); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index bada4e422..8973b1341 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -13,6 +13,7 @@ import { ArchiveToCopy, BindMount, ContentToCopy, + CopyToContainerOptions, DirectoryToCopy, Environment, ExtraHost, @@ -61,6 +62,7 @@ export class GenericContainer implements TestContainer { protected directoriesToCopy: DirectoryToCopy[] = []; protected contentsToCopy: ContentToCopy[] = []; protected archivesToCopy: ArchiveToCopy[] = []; + protected copyToContainerOptions: CopyToContainerOptions = {}; protected healthCheck?: HealthCheck; constructor(image: string) { @@ -181,11 +183,11 @@ export class GenericContainer implements TestContainer { if (this.filesToCopy.length > 0 || this.directoriesToCopy.length > 0 || this.contentsToCopy.length > 0) { const archive = this.createArchiveToCopyToContainer(); archive.finalize(); - await client.container.putArchive(container, archive, "/"); + await client.container.putArchive(container, archive, "/", this.copyToContainerOptions); } for (const archive of this.archivesToCopy) { - await client.container.putArchive(container, archive.tar, archive.target); + await client.container.putArchive(container, archive.tar, archive.target, this.copyToContainerOptions); } log.info(`Starting container for image "${this.createOpts.Image}"...`, { containerId: container.id }); @@ -482,6 +484,14 @@ export class GenericContainer implements TestContainer { return this; } + public withCopyToContainerOptions(copyToContainerOptions: CopyToContainerOptions): this { + this.copyToContainerOptions = { + ...this.copyToContainerOptions, + ...copyToContainerOptions, + }; + return this; + } + public withWorkingDir(workingDir: string): this { this.createOpts.WorkingDir = workingDir; return this; diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 32f7d3925..f7c49c966 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -6,7 +6,16 @@ import { containerLog, log } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime"; import { getReaper } from "../reaper/reaper"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; -import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; +import { + CommitOptions, + ContentToCopy, + CopyToContainerOptions, + DirectoryToCopy, + ExecOptions, + ExecResult, + FileToCopy, + Labels, +} from "../types"; import { BoundPorts } from "../utils/bound-ports"; import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { mapInspectResult } from "../utils/map-inspect-result"; @@ -209,10 +218,10 @@ export class StartedGenericContainer implements StartedTestContainer { log.debug(`Copied content to container`, { containerId: this.container.id }); } - public async copyArchiveToContainer(tar: Readable, target = "/"): Promise { + public async copyArchiveToContainer(tar: Readable, target = "/", options?: CopyToContainerOptions): Promise { log.debug(`Copying archive to container...`, { containerId: this.container.id }); const client = await getContainerRuntimeClient(); - await client.container.putArchive(this.container, tar, target); + await client.container.putArchive(this.container, tar, target, options); log.debug(`Copied archive to container`, { containerId: this.container.id }); } diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index 71e65489f..8997e8153 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -19,7 +19,7 @@ export { TestContainer, } from "./test-container"; export { TestContainers } from "./test-containers"; -export { CommitOptions, Content, ExecOptions, ExecResult, InspectResult } from "./types"; +export { CommitOptions, Content, CopyToContainerOptions, ExecOptions, ExecResult, InspectResult } from "./types"; export { BoundPorts } from "./utils/bound-ports"; export { LABEL_TESTCONTAINERS_SESSION_ID } from "./utils/labels"; export { PortWithBinding, PortWithOptionalBinding, getContainerPort, hasHostBinding } from "./utils/port"; diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 3711b5eeb..e286caf98 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -5,6 +5,7 @@ import { BindMount, CommitOptions, ContentToCopy, + CopyToContainerOptions, DirectoryToCopy, Environment, ExecOptions, @@ -47,6 +48,7 @@ export interface TestContainer { withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this; withCopyContentToContainer(contentsToCopy: ContentToCopy[]): this; withCopyArchivesToContainer(archivesToCopy: ArchiveToCopy[]): this; + withCopyToContainerOptions(copyToContainerOptions: CopyToContainerOptions): this; withWorkingDir(workingDir: string): this; withResourcesQuota(resourcesQuota: ResourcesQuota): this; withSharedMemorySize(bytes: number): this; @@ -80,7 +82,7 @@ export interface StartedTestContainer extends AsyncDisposable { getNetworkId(networkName: string): string; getIpAddress(networkName: string): string; copyArchiveFromContainer(path: string): Promise; - copyArchiveToContainer(tar: Readable, target?: string): Promise; + copyArchiveToContainer(tar: Readable, target?: string, options?: CopyToContainerOptions): Promise; copyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): Promise; copyFilesToContainer(filesToCopy: FileToCopy[]): Promise; copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise; diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index 0ab163683..7dbef7d9f 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -40,6 +40,21 @@ export type ContentToCopy = { mode?: number; }; +/** + * Options passed to Docker's put archive endpoint. + */ +export type CopyToContainerOptions = { + /** + * Fail extraction if it would replace an existing directory with a non-directory, + * or an existing non-directory with a directory. + */ + noOverwriteDirNonDir?: boolean; + /** + * Preserve UID/GID from archive entries when copying into the container. + */ + copyUIDGID?: boolean; +}; + export type ArchiveToCopy = { tar: Readable; target: string; From 33d0695ffaf1cc2c92a9076863ec2848f59361c9 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 17 Feb 2026 15:31:02 +0000 Subject: [PATCH 2/2] Document and gate copyUIDGID behavior on Podman --- docs/features/containers.md | 1 + .../generic-container.test.ts | 77 ++++++++++--------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index 69008d159..6bbdbb076 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -199,6 +199,7 @@ const container = await new GenericContainer("alpine") ``` - `copyUIDGID`: preserve UID/GID from tar archive entries. + Note: Podman may ignore this for archive copy in some cases, see [containers/podman#27538](https://github.com/containers/podman/issues/27538). - `noOverwriteDirNonDir`: fail if extraction would replace a file with a directory (or vice versa). ### Copy archive from container diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index d914eebe0..24d2b4b18 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -464,49 +464,52 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content)); }); - it("should copy archive to started container with ownership when copyUIDGID is enabled", async () => { - const uid = 4242; - const gid = 4343; - const targetWithCopyOwnership = "/tmp/copy-archive-copyuidgid.txt"; + // https://github.com/containers/podman/issues/27538 + if (!process.env.CI_PODMAN) { + it("should copy archive to started container with ownership when copyUIDGID is enabled", async () => { + const uid = 4242; + const gid = 4343; + const targetWithCopyOwnership = "/tmp/copy-archive-copyuidgid.txt"; - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .start(); + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .start(); - const tar = archiver("tar"); - tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData); - tar.finalize(); + const tar = archiver("tar"); + tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData); + tar.finalize(); - await container.copyArchiveToContainer(tar, "/", { copyUIDGID: true }); + await container.copyArchiveToContainer(tar, "/", { copyUIDGID: true }); - expect((await container.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim()).toEqual( - `${uid}:${gid}` - ); - }); + expect((await container.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim()).toEqual( + `${uid}:${gid}` + ); + }); - it("should copy archives before start with ownership when copyUIDGID is enabled", async () => { - const uid = 4242; - const gid = 4343; - const targetWithCopyOwnership = "/tmp/with-copy-archives-copyuidgid.txt"; - const tar = archiver("tar"); - tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData); - tar.finalize(); - - await using containerWithCopyOwnership = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withCopyArchivesToContainer([ - { - tar, - target: "/", - }, - ]) - .withCopyToContainerOptions({ copyUIDGID: true }) - .withExposedPorts(8080) - .start(); + it("should copy archives before start with ownership when copyUIDGID is enabled", async () => { + const uid = 4242; + const gid = 4343; + const targetWithCopyOwnership = "/tmp/with-copy-archives-copyuidgid.txt"; + const tar = archiver("tar"); + tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData); + tar.finalize(); + + await using containerWithCopyOwnership = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withCopyArchivesToContainer([ + { + tar, + target: "/", + }, + ]) + .withCopyToContainerOptions({ copyUIDGID: true }) + .withExposedPorts(8080) + .start(); - expect( - (await containerWithCopyOwnership.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim() - ).toEqual(`${uid}:${gid}`); - }); + expect( + (await containerWithCopyOwnership.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim() + ).toEqual(`${uid}:${gid}`); + }); + } it("should honour .dockerignore file", async () => { const context = path.resolve(fixtures, "docker-with-dockerignore");