Skip to content

Commit f1a9a0b

Browse files
Support preserving UID/GID when copying archives to containers (#1234)
1 parent 6274827 commit f1a9a0b

File tree

10 files changed

+147
-14
lines changed

10 files changed

+147
-14
lines changed

docs/features/containers.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ const container = await new GenericContainer("alpine")
137137
tar: nodeReadable,
138138
target: "/some/nested/remotedir"
139139
}])
140+
.withCopyToContainerOptions({
141+
copyUIDGID: true
142+
})
140143
.start();
141144
```
142145

@@ -157,7 +160,9 @@ container.copyContentToContainer([{
157160
content: "hello world",
158161
target: "/remote/file2.txt"
159162
}])
160-
container.copyArchiveToContainer(nodeReadable, "/some/nested/remotedir");
163+
container.copyArchiveToContainer(nodeReadable, "/some/nested/remotedir", {
164+
copyUIDGID: true
165+
});
161166
```
162167

163168
When copying files, symbolic links in `source` are followed and the linked file content is copied into the container.
@@ -184,6 +189,21 @@ const container = await new GenericContainer("alpine")
184189
.start();
185190
```
186191

192+
Archive copy options can also be specified:
193+
194+
```js
195+
const container = await new GenericContainer("alpine")
196+
.withCopyToContainerOptions({
197+
copyUIDGID: true,
198+
noOverwriteDirNonDir: true
199+
})
200+
.start();
201+
```
202+
203+
- `copyUIDGID`: preserve UID/GID from tar archive entries.
204+
Note: Podman may ignore this for archive copy in some cases, see [containers/podman#27538](https://github.com/containers/podman/issues/27538).
205+
- `noOverwriteDirNonDir`: fail if extraction would replace a file with a directory (or vice versa).
206+
187207
### Copy archive from container
188208

189209
Files and directories can be fetched from a started or stopped container as a tar archive. The archive is returned as a readable stream:

packages/testcontainers/src/container-runtime/clients/container/container-client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Dockerode, {
77
Network,
88
} from "dockerode";
99
import { Readable } from "stream";
10+
import { CopyToContainerOptions } from "../../../types";
1011
import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";
1112

1213
export interface ContainerClient {
@@ -21,7 +22,12 @@ export interface ContainerClient {
2122
): Promise<Container | undefined>;
2223

2324
fetchArchive(container: Container, path: string): Promise<NodeJS.ReadableStream>;
24-
putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void>;
25+
putArchive(
26+
container: Dockerode.Container,
27+
stream: Readable,
28+
path: string,
29+
options?: CopyToContainerOptions
30+
): Promise<void>;
2531
list(): Promise<ContainerInfo[]>;
2632
create(opts: ContainerCreateOptions): Promise<Container>;
2733
start(container: Container): Promise<void>;

packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Dockerode, {
1010
import { IncomingMessage } from "http";
1111
import { PassThrough, Readable } from "stream";
1212
import { execLog, log, streamToString, toSeconds } from "../../../common";
13+
import { CopyToContainerOptions } from "../../../types";
1314
import { ContainerClient } from "./container-client";
1415
import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";
1516

@@ -72,10 +73,23 @@ export class DockerContainerClient implements ContainerClient {
7273
}
7374
}
7475

75-
async putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void> {
76+
async putArchive(
77+
container: Dockerode.Container,
78+
stream: Readable,
79+
path: string,
80+
options?: CopyToContainerOptions
81+
): Promise<void> {
7682
try {
7783
log.debug(`Putting archive to container...`, { containerId: container.id });
78-
await streamToString(Readable.from(await container.putArchive(stream, { path })));
84+
await streamToString(
85+
Readable.from(
86+
await container.putArchive(stream, {
87+
path,
88+
noOverwriteDirNonDir: options?.noOverwriteDirNonDir,
89+
copyUIDGID: options?.copyUIDGID,
90+
})
91+
)
92+
);
7993
log.debug(`Put archive to container`, { containerId: container.id });
8094
} catch (err) {
8195
log.error(`Failed to put archive to container: ${err}`, { containerId: container.id });

packages/testcontainers/src/generic-container/abstract-started-container.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { Readable } from "stream";
22
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
3-
import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
3+
import {
4+
CommitOptions,
5+
ContentToCopy,
6+
CopyToContainerOptions,
7+
DirectoryToCopy,
8+
ExecOptions,
9+
ExecResult,
10+
FileToCopy,
11+
Labels,
12+
} from "../types";
413

514
export class AbstractStartedContainer implements StartedTestContainer {
615
constructor(protected readonly startedTestContainer: StartedTestContainer) {}
@@ -88,8 +97,8 @@ export class AbstractStartedContainer implements StartedTestContainer {
8897
return this.startedTestContainer.copyContentToContainer(contentsToCopy);
8998
}
9099

91-
public copyArchiveToContainer(tar: Readable, target = "/"): Promise<void> {
92-
return this.startedTestContainer.copyArchiveToContainer(tar, target);
100+
public copyArchiveToContainer(tar: Readable, target = "/", options?: CopyToContainerOptions): Promise<void> {
101+
return this.startedTestContainer.copyArchiveToContainer(tar, target, options);
93102
}
94103

95104
public copyArchiveFromContainer(path: string): Promise<NodeJS.ReadableStream> {

packages/testcontainers/src/generic-container/generic-container.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import archiver from "archiver";
12
import getPort from "get-port";
23
import path from "path";
34
import { RandomUuid } from "../common";
@@ -509,6 +510,53 @@ describe("GenericContainer", { timeout: 180_000 }, () => {
509510
expect((await container.exec(["cat", target])).output).toEqual(expect.stringContaining(content));
510511
});
511512

513+
// https://github.com/containers/podman/issues/27538
514+
if (!process.env.CI_PODMAN) {
515+
it("should copy archive to started container with ownership when copyUIDGID is enabled", async () => {
516+
const uid = 4242;
517+
const gid = 4343;
518+
const targetWithCopyOwnership = "/tmp/copy-archive-copyuidgid.txt";
519+
520+
await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
521+
.withExposedPorts(8080)
522+
.start();
523+
524+
const tar = archiver("tar");
525+
tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData);
526+
tar.finalize();
527+
528+
await container.copyArchiveToContainer(tar, "/", { copyUIDGID: true });
529+
530+
expect((await container.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim()).toEqual(
531+
`${uid}:${gid}`
532+
);
533+
});
534+
535+
it("should copy archives before start with ownership when copyUIDGID is enabled", async () => {
536+
const uid = 4242;
537+
const gid = 4343;
538+
const targetWithCopyOwnership = "/tmp/with-copy-archives-copyuidgid.txt";
539+
const tar = archiver("tar");
540+
tar.append("hello world", { name: targetWithCopyOwnership.slice(1), uid, gid } as archiver.EntryData);
541+
tar.finalize();
542+
543+
await using containerWithCopyOwnership = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
544+
.withCopyArchivesToContainer([
545+
{
546+
tar,
547+
target: "/",
548+
},
549+
])
550+
.withCopyToContainerOptions({ copyUIDGID: true })
551+
.withExposedPorts(8080)
552+
.start();
553+
554+
expect(
555+
(await containerWithCopyOwnership.exec(["stat", "-c", "%u:%g", targetWithCopyOwnership])).output.trim()
556+
).toEqual(`${uid}:${gid}`);
557+
});
558+
}
559+
512560
it("should honour .dockerignore file", async () => {
513561
const context = path.resolve(fixtures, "docker-with-dockerignore");
514562
const container = await GenericContainer.fromDockerfile(context).build();

packages/testcontainers/src/generic-container/generic-container.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ArchiveToCopy,
1515
BindMount,
1616
ContentToCopy,
17+
CopyToContainerOptions,
1718
DirectoryToCopy,
1819
Environment,
1920
ExtraHost,
@@ -62,6 +63,7 @@ export class GenericContainer implements TestContainer {
6263
protected directoriesToCopy: DirectoryToCopy[] = [];
6364
protected contentsToCopy: ContentToCopy[] = [];
6465
protected archivesToCopy: ArchiveToCopy[] = [];
66+
protected copyToContainerOptions: CopyToContainerOptions = {};
6567
protected healthCheck?: HealthCheck;
6668

6769
constructor(image: string) {
@@ -182,11 +184,11 @@ export class GenericContainer implements TestContainer {
182184
if (this.filesToCopy.length > 0 || this.directoriesToCopy.length > 0 || this.contentsToCopy.length > 0) {
183185
const archive = await this.createArchiveToCopyToContainer();
184186
archive.finalize();
185-
await client.container.putArchive(container, archive, "/");
187+
await client.container.putArchive(container, archive, "/", this.copyToContainerOptions);
186188
}
187189

188190
for (const archive of this.archivesToCopy) {
189-
await client.container.putArchive(container, archive.tar, archive.target);
191+
await client.container.putArchive(container, archive.tar, archive.target, this.copyToContainerOptions);
190192
}
191193

192194
log.info(`Starting container for image "${this.createOpts.Image}"...`, { containerId: container.id });
@@ -494,6 +496,14 @@ export class GenericContainer implements TestContainer {
494496
return this;
495497
}
496498

499+
public withCopyToContainerOptions(copyToContainerOptions: CopyToContainerOptions): this {
500+
this.copyToContainerOptions = {
501+
...this.copyToContainerOptions,
502+
...copyToContainerOptions,
503+
};
504+
return this;
505+
}
506+
497507
public withWorkingDir(workingDir: string): this {
498508
this.createOpts.WorkingDir = workingDir;
499509
return this;

packages/testcontainers/src/generic-container/started-generic-container.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@ import { containerLog, log } from "../common";
77
import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime";
88
import { getReaper } from "../reaper/reaper";
99
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
10-
import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
10+
import {
11+
CommitOptions,
12+
ContentToCopy,
13+
CopyToContainerOptions,
14+
DirectoryToCopy,
15+
ExecOptions,
16+
ExecResult,
17+
FileToCopy,
18+
Labels,
19+
} from "../types";
1120
import { BoundPorts } from "../utils/bound-ports";
1221
import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
1322
import { mapInspectResult } from "../utils/map-inspect-result";
@@ -216,10 +225,10 @@ export class StartedGenericContainer implements StartedTestContainer {
216225
log.debug(`Copied content to container`, { containerId: this.container.id });
217226
}
218227

219-
public async copyArchiveToContainer(tar: Readable, target = "/"): Promise<void> {
228+
public async copyArchiveToContainer(tar: Readable, target = "/", options?: CopyToContainerOptions): Promise<void> {
220229
log.debug(`Copying archive to container...`, { containerId: this.container.id });
221230
const client = await getContainerRuntimeClient();
222-
await client.container.putArchive(this.container, tar, target);
231+
await client.container.putArchive(this.container, tar, target, options);
223232
log.debug(`Copied archive to container`, { containerId: this.container.id });
224233
}
225234

packages/testcontainers/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export {
1919
TestContainer,
2020
} from "./test-container";
2121
export { TestContainers } from "./test-containers";
22-
export { CommitOptions, Content, ExecOptions, ExecResult, InspectResult } from "./types";
22+
export { CommitOptions, Content, CopyToContainerOptions, ExecOptions, ExecResult, InspectResult } from "./types";
2323
export { BoundPorts } from "./utils/bound-ports";
2424
export { LABEL_TESTCONTAINERS_SESSION_ID } from "./utils/labels";
2525
export { PortWithBinding, PortWithOptionalBinding, getContainerPort, hasHostBinding } from "./utils/port";

packages/testcontainers/src/test-container.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
BindMount,
66
CommitOptions,
77
ContentToCopy,
8+
CopyToContainerOptions,
89
DirectoryToCopy,
910
Environment,
1011
ExecOptions,
@@ -48,6 +49,7 @@ export interface TestContainer {
4849
withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this;
4950
withCopyContentToContainer(contentsToCopy: ContentToCopy[]): this;
5051
withCopyArchivesToContainer(archivesToCopy: ArchiveToCopy[]): this;
52+
withCopyToContainerOptions(copyToContainerOptions: CopyToContainerOptions): this;
5153
withWorkingDir(workingDir: string): this;
5254
withResourcesQuota(resourcesQuota: ResourcesQuota): this;
5355
withSharedMemorySize(bytes: number): this;
@@ -81,7 +83,7 @@ export interface StartedTestContainer extends AsyncDisposable {
8183
getNetworkId(networkName: string): string;
8284
getIpAddress(networkName: string): string;
8385
copyArchiveFromContainer(path: string): Promise<NodeJS.ReadableStream>;
84-
copyArchiveToContainer(tar: Readable, target?: string): Promise<void>;
86+
copyArchiveToContainer(tar: Readable, target?: string, options?: CopyToContainerOptions): Promise<void>;
8587
copyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): Promise<void>;
8688
copyFilesToContainer(filesToCopy: FileToCopy[]): Promise<void>;
8789
copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise<void>;

packages/testcontainers/src/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ export type ContentToCopy = {
4040
mode?: number;
4141
};
4242

43+
/**
44+
* Options passed to Docker's put archive endpoint.
45+
*/
46+
export type CopyToContainerOptions = {
47+
/**
48+
* Fail extraction if it would replace an existing directory with a non-directory,
49+
* or an existing non-directory with a directory.
50+
*/
51+
noOverwriteDirNonDir?: boolean;
52+
/**
53+
* Preserve UID/GID from archive entries when copying into the container.
54+
*/
55+
copyUIDGID?: boolean;
56+
};
57+
4358
export type ArchiveToCopy = {
4459
tar: Readable;
4560
target: string;

0 commit comments

Comments
 (0)