Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ const container = await new GenericContainer("alpine")
tar: nodeReadable,
target: "/some/nested/remotedir"
}])
.withCopyToContainerOptions({
copyUIDGID: true
})
.start();
```

Expand All @@ -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:
Expand All @@ -182,6 +187,21 @@ 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.
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

Files and directories can be fetched from a started or stopped container as a tar archive. The archive is returned as a readable stream:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,7 +22,12 @@ export interface ContainerClient {
): Promise<Container | undefined>;

fetchArchive(container: Container, path: string): Promise<NodeJS.ReadableStream>;
putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void>;
putArchive(
container: Dockerode.Container,
stream: Readable,
path: string,
options?: CopyToContainerOptions
): Promise<void>;
list(): Promise<ContainerInfo[]>;
create(opts: ContainerCreateOptions): Promise<Container>;
start(container: Container): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -72,10 +73,23 @@ export class DockerContainerClient implements ContainerClient {
}
}

async putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void> {
async putArchive(
container: Dockerode.Container,
stream: Readable,
path: string,
options?: CopyToContainerOptions
): Promise<void> {
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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down Expand Up @@ -88,8 +97,8 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.copyContentToContainer(contentsToCopy);
}

public copyArchiveToContainer(tar: Readable, target = "/"): Promise<void> {
return this.startedTestContainer.copyArchiveToContainer(tar, target);
public copyArchiveToContainer(tar: Readable, target = "/", options?: CopyToContainerOptions): Promise<void> {
return this.startedTestContainer.copyArchiveToContainer(tar, target, options);
}

public copyArchiveFromContainer(path: string): Promise<NodeJS.ReadableStream> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import archiver from "archiver";
import getPort from "get-port";
import path from "path";
import { RandomUuid } from "../common";
Expand Down Expand Up @@ -463,6 +464,53 @@ describe("GenericContainer", { timeout: 180_000 }, () => {
expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content));
});

// 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();

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();
Expand Down
14 changes: 12 additions & 2 deletions packages/testcontainers/src/generic-container/generic-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ArchiveToCopy,
BindMount,
ContentToCopy,
CopyToContainerOptions,
DirectoryToCopy,
Environment,
ExtraHost,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
public async copyArchiveToContainer(tar: Readable, target = "/", options?: CopyToContainerOptions): Promise<void> {
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 });
}

Expand Down
2 changes: 1 addition & 1 deletion packages/testcontainers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 3 additions & 1 deletion packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BindMount,
CommitOptions,
ContentToCopy,
CopyToContainerOptions,
DirectoryToCopy,
Environment,
ExecOptions,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -80,7 +82,7 @@ export interface StartedTestContainer extends AsyncDisposable {
getNetworkId(networkName: string): string;
getIpAddress(networkName: string): string;
copyArchiveFromContainer(path: string): Promise<NodeJS.ReadableStream>;
copyArchiveToContainer(tar: Readable, target?: string): Promise<void>;
copyArchiveToContainer(tar: Readable, target?: string, options?: CopyToContainerOptions): Promise<void>;
copyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): Promise<void>;
copyFilesToContainer(filesToCopy: FileToCopy[]): Promise<void>;
copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise<void>;
Expand Down
15 changes: 15 additions & 0 deletions packages/testcontainers/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading