Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
11becd3
Using unix sockets
dragosv Feb 23, 2026
3f8d6a3
Docker setup on MacOS
dragosv Feb 23, 2026
ee25bb3
Initial plan
Copilot Feb 24, 2026
ee5865c
Add unit tests for HTTP parsing functions in docker_client.zig
Copilot Feb 24, 2026
04f599e
Fix crashing tests
dragosv Mar 4, 2026
cf42c82
Ignore MacOs
dragosv Mar 4, 2026
c3caf1b
Update src/wait.zig
dragosv Mar 5, 2026
fcd7e5e
Update examples/basic.zig
dragosv Mar 5, 2026
578fa41
Initial plan
Copilot Mar 5, 2026
09d80b7
Update src/docker_client.zig
dragosv Mar 5, 2026
c3da803
Initial plan
Copilot Mar 5, 2026
923e3eb
Initial plan
Copilot Mar 5, 2026
e4876ac
Update src/docker_client.zig
dragosv Mar 5, 2026
06e4068
Update src/docker_client.zig
dragosv Mar 5, 2026
1fa712f
Update src/docker_client.zig
dragosv Mar 5, 2026
01bf92c
Update src/wait.zig
dragosv Mar 5, 2026
3e5c286
Fix HTTP spec violation: check chunked before content_length per RFC …
Copilot Mar 5, 2026
7d5cc99
Replace fixed 4096-byte buffer with ArrayList in sendHttpRequest
Copilot Mar 5, 2026
9eb74ac
Parse Docker pull stream for embedded errors in imagePull
Copilot Mar 5, 2026
aaf7cbb
Merge pull request #6 from dragosv/copilot/sub-pr-1-yet-again
dragosv Mar 5, 2026
aba10d6
Merge pull request #4 from dragosv/copilot/sub-pr-1-again
dragosv Mar 5, 2026
511d5d7
Merge branch 'unixsocket' into copilot/sub-pr-1-another-one
dragosv Mar 5, 2026
fb72191
Merge pull request #5 from dragosv/copilot/sub-pr-1-another-one
dragosv Mar 5, 2026
4e22390
Fix build
dragosv Mar 5, 2026
edad26c
Fix build
dragosv Mar 5, 2026
43e27d3
Fix build
dragosv Mar 5, 2026
4fe26c8
fixes
dragosv Mar 5, 2026
9674e44
Update src/root.zig
dragosv Mar 5, 2026
0988231
Fixes for imagePull logic
dragosv Mar 5, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
build-and-test:
strategy:
matrix:
platform: [ubuntu-latest, macos-latest]
platform: [ubuntu-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v6
Expand All @@ -26,7 +26,7 @@ jobs:
- name: Check zig version
run: zig version

- name: Build all examples
- name: Build all
run: zig build

- name: Run tests
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ There is no separate formatter or linter. Follow the style rules below and let

## Architecture Rules

- **Single library**: all code lives under `src/`; `dusty` is the only external dependency.
- **Single library**: all code lives under `src/`; no external dependencies (uses built-in HTTP/1.1 client over Unix domain socket).
- **Tagged unions over protocols**: `wait.Strategy` is a tagged union, not an interface.
- **Struct-literal configuration**: `ContainerRequest` uses default field values;
no builder methods or method chaining.
Expand Down
27 changes: 10 additions & 17 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ For usage examples and getting-started instructions see [QUICKSTART.md](QUICKSTA
| Zig-first | Tagged unions, comptime, `errdefer`, manual allocation — no hidden allocations |
| Type safety | Comptime-checked configuration; all errors are explicit in return types |
| Developer experience | Simple struct-literal configuration, namespace-based wait strategy DSL |
| Minimal coupling | Single library; HTTP transport is a separate `dusty` dependency |
| Minimal coupling | Single library; no external dependencies — built-in HTTP/1.1 client over Unix domain socket |
| Testability | `DockerClient` is injected via value; containers clean up deterministically |

## Component Overview
Expand All @@ -31,7 +31,7 @@ testcontainers (src/root.zig)
mariadb, minio, elasticsearch, kafka, localstack
```

The HTTP transport layer is provided by **dusty** (a dependency declared in `build.zig.zon`). The async I/O runtime is **zio**. Neither is re-exported by `testcontainers`; callers only need to initialise `zio.Runtime` once before making any calls.
The HTTP transport layer is a built-in HTTP/1.1 client that communicates directly with the Docker Unix socket via `std.net.connectUnixSocket`. There are no external dependencies. For the HTTP wait strategy, `std.http.Client` from the standard library is used.

## Component Diagram

Expand Down Expand Up @@ -61,8 +61,8 @@ The HTTP transport layer is provided by **dusty** (a dependency declared in `bui
└────────────────────────────────────────────┼────────────────────┘
┌──────────────────────────────┐
dusty HTTP client
│ (unix socket transport)
Built-in HTTP/1.1 client │
│ (std.net.connectUnixSocket)
└──────────────────────────────┘
Expand Down Expand Up @@ -164,14 +164,8 @@ There are no finalizers, no reference counting, and no garbage collector.

## Concurrency Model

The library uses **dusty** for HTTP over a Unix socket. dusty is internally powered by **zio**,
a structured async I/O runtime. Callers must initialise a `zio.Runtime` before making any
network calls:

```zig
var rt = try zio.Runtime.init(alloc, .{});
defer rt.deinit();
```
The library uses a built-in HTTP/1.1 client that communicates directly with the Docker Unix
socket via `std.net.connectUnixSocket`. No external runtime or async framework is needed.

All public API functions block the calling thread until completion. There is no callback or
Future-based API surface.
Expand Down Expand Up @@ -204,15 +198,14 @@ aliases from `ContainerRequest.network_aliases`.

| Dependency | Role | Source |
|-----------|------|--------|
| `dusty` | HTTP client (unix socket + TCP) | `build.zig.zon` |
| Zig stdlib | JSON, I/O, testing | built-in |
| Zig stdlib | JSON, I/O, HTTP, networking, testing | built-in |

No other runtime dependencies. zio is a transitive dependency of dusty and is not referenced
directly by application code.
No external dependencies. The library communicates with the Docker Engine using a built-in
HTTP/1.1 client over `std.net.connectUnixSocket`.

## References

- [Docker Engine API v1.44](https://docs.docker.com/engine/api/v1.44/)
- [Zig Language Reference](https://ziglang.org/documentation/0.15.2/)
- [testcontainers-go](https://github.com/testcontainers/testcontainers-go) — reference architecture
- [dusty HTTP client](https://github.com/dragosv/dusty)transport layer
- [Zig Standard Library](https://ziglang.org/documentation/0.15.2/std/)HTTP, networking, JSON
21 changes: 10 additions & 11 deletions IMPLEMENTATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ Public API entry point. Exports:

### `src/docker_client.zig`

`DockerClient` communicates with the Docker Engine via a Unix socket using the **dusty**
HTTP library. Endpoints used:
`DockerClient` communicates with the Docker Engine via a Unix socket using a built-in
HTTP/1.1 client (`std.net.connectUnixSocket`). Endpoints used:

| Method | Endpoint | Purpose |
|--------|----------|---------|
Expand Down Expand Up @@ -264,12 +264,11 @@ pub const Network = struct {
### `examples/basic.zig`

Full nginx example:
1. Initialise `zio.Runtime`
2. `tc.run(alloc, "nginx:latest", .{ .wait_strategy = tc.wait.forHttp("/") })`
3. `ctr.mappedPort("80/tcp", alloc)`
4. Fetch `/` using a dusty HTTP client
5. `ctr.exec(&.{"echo", "hello"})`
6. Print output
1. `tc.run(alloc, "nginx:latest", .{ .wait_strategy = tc.wait.forHttp("/") })`
2. `ctr.mappedPort("80/tcp", alloc)`
3. Fetch `/` using `std.http.Client`
4. `ctr.exec(&.{"echo", "hello"})`
5. Print output

## Adding a New Module

Expand Down Expand Up @@ -299,9 +298,9 @@ zig build example # run examples/basic.zig

| Package | Version | Role |
|---------|---------|------|
| `dusty` | main @ 69f47e2b | HTTP over Unix socket |
| Zig stdlib | built-in | JSON, I/O, HTTP, networking, testing |

zio is a transitive dependency of dusty; it does not need to be declared separately.
No external dependencies. The library uses a built-in HTTP/1.1 client over Unix domain socket.

## Project Statistics

Expand All @@ -311,7 +310,7 @@ zio is a transitive dependency of dusty; it does not need to be declared separat
| Modules | 10 |
| Wait strategies | 7 |
| Integration tests | 24 |
| External dependencies | 1 |
| External dependencies | 0 |
| Zig version | 0.15.2 |

## Getting Help
Expand Down
19 changes: 7 additions & 12 deletions PROJECT_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This document provides a comprehensive overview of the testcontainers-zig implem
- Zig Build System configuration
- Minimum Zig version: 0.15.2
- Build steps: `build`, `test`, `integration-test`, `example`
- Single external dependency: `dusty` (HTTP client, unix socket transport)
- No external dependencies — built-in HTTP/1.1 client over Unix domain socket (`std.net.connectUnixSocket`)

2. **Docker API Client** (`src/docker_client.zig`)
- Value-type `DockerClient` communicating over a Unix socket
Expand Down Expand Up @@ -149,23 +149,18 @@ errdefer alloc.free(some_resource);
Every function that allocates takes an `std.mem.Allocator` parameter and documents ownership
of returned slices. No global allocator, no GC.

### 5. zio Runtime Requirement
### 5. No External Runtime Required

All network I/O is backed by zio (async runtime). Callers must keep a `zio.Runtime` alive:

```zig
var rt = try zio.Runtime.init(alloc, .{});
defer rt.deinit();
```
All network I/O uses the built-in HTTP/1.1 client over `std.net.connectUnixSocket`.
No external runtime initialisation is needed before using the library.

## Dependencies

| Package | Role |
|---------|------|
| `dusty` | HTTP over Unix socket (Docker API transport) |
| Zig stdlib | JSON, I/O, testing, memory |
| Zig stdlib | JSON, I/O, HTTP, networking, testing, memory |

zio is a transitive dependency of dusty; application code does not import it directly.
No external dependencies. The library uses a built-in HTTP/1.1 client over Unix domain socket.

## Feature Comparison with testcontainers-go

Expand Down Expand Up @@ -234,7 +229,7 @@ testcontainers-zig/
- **Supported Modules**: 10 (Postgres, MySQL, Redis, MongoDB, RabbitMQ, MariaDB, MinIO, Elasticsearch, Kafka, LocalStack)
- **Wait Strategies**: 7 built-in
- **Integration Tests**: 24
- **External Dependencies**: 1 (`dusty`)
- **External Dependencies**: 0
- **Zig Version**: 0.15.2

## Testing Coverage
Expand Down
12 changes: 0 additions & 12 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,19 @@ Wire it up in `build.zig`:
```zig
const tc_dep = b.dependency("testcontainers", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("testcontainers", tc_dep.module("testcontainers"));
// also expose zio so the runtime is accessible
const zio_dep = tc_dep.builder.dependency("dusty", .{}).builder
.dependency("zio", .{});
exe.root_module.addImport("zio", zio_dep.module("zio"));
```

## Basic Example

Every program that uses testcontainers must initialise the **zio** async runtime before
making any network calls:

```zig
const std = @import("std");
const zio = @import("zio");
const tc = @import("testcontainers");

pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();

// REQUIRED: zio runtime must be alive for the duration of all I/O.
var rt = try zio.Runtime.init(alloc, .{});
defer rt.deinit();

// Start a PostgreSQL container using the built-in module.
var provider = try tc.DockerProvider.init(alloc);
defer provider.deinit();
Expand Down
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,13 @@ See [QUICKSTART.md](QUICKSTART.md) for a step-by-step guide.

```zig
const std = @import("std");
const zio = @import("zio");
const tc = @import("testcontainers");

pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();

// zio runtime must live for the duration of all network I/O
var rt = try zio.Runtime.init(alloc, .{});
defer rt.deinit();

const ctr = try tc.run(alloc, "nginx:latest", .{
.exposed_ports = &.{"80/tcp"},
.wait_strategy = tc.wait.forHttp("/"),
Expand Down
18 changes: 0 additions & 18 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,12 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});

const dusty_dep = b.dependency("dusty", .{
.target = target,
.optimize = optimize,
});

const zio_dep = dusty_dep.builder.dependency("zio", .{
.target = target,
.optimize = optimize,
});

// Main library module
const mod = b.addModule("testcontainers", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
mod.addImport("dusty", dusty_dep.module("dusty"));
mod.addImport("zio", zio_dep.module("zio"));

// Unit tests
const lib_tests = b.addTest(.{
Expand All @@ -31,8 +19,6 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
}),
});
lib_tests.root_module.addImport("dusty", dusty_dep.module("dusty"));
lib_tests.root_module.addImport("zio", zio_dep.module("zio"));

const run_lib_tests = b.addRunArtifact(lib_tests);
const test_step = b.step("test", "Run library tests");
Expand All @@ -46,8 +32,6 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
}),
});
integration_tests.root_module.addImport("dusty", dusty_dep.module("dusty"));
integration_tests.root_module.addImport("zio", zio_dep.module("zio"));
integration_tests.root_module.addImport("testcontainers", mod);

const run_integration_tests = b.addRunArtifact(integration_tests);
Expand All @@ -64,8 +48,6 @@ pub fn build(b: *std.Build) void {
}),
});
example.root_module.addImport("testcontainers", mod);
example.root_module.addImport("zio", zio_dep.module("zio"));
example.root_module.addImport("dusty", dusty_dep.module("dusty"));
b.installArtifact(example);

const run_example = b.addRunArtifact(example);
Expand Down
7 changes: 1 addition & 6 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@
.version = "0.1.0",
.minimum_zig_version = "0.15.2",
.fingerprint = 0x5ead92dc45f2b31e,
.dependencies = .{
.dusty = .{
.url = "git+https://github.com/lalinsky/dusty?ref=main#771fd97cd9899cda6f0b4c357ecce713fd080892",
.hash = "dusty-0.0.0-Qdw7Rnf0CQA_GZZjli5STfzOrVGJO-aIx3Bpg84VWTMd",
},
},
.dependencies = .{},
.paths = .{
"build.zig",
"build.zig.zon",
Expand Down
29 changes: 7 additions & 22 deletions examples/basic.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,13 @@
/// Run with:
/// zig build example
const std = @import("std");
const zio = @import("zio");
const tc = @import("testcontainers");

pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();

// IMPORTANT: initialise the zio runtime before making any network calls.
// dusty (the HTTP library) is async-behind-the-scenes via zio.
var rt = try zio.Runtime.init(allocator, .{});
defer rt.deinit();

std.log.info("Starting nginx container...", .{});

const ctr = try tc.run(allocator, "nginx:latest", .{
Expand All @@ -36,27 +30,18 @@ pub fn main() !void {
const port = try ctr.mappedPort("80/tcp", allocator);
std.log.info("nginx is ready on localhost:{d}", .{port});

// Fetch the nginx welcome page using a dusty client
var client = tc.DockerClient.init(allocator, tc.docker_socket);
defer client.deinit();

// Use a plain dusty client to hit the mapped port over TCP
const dusty = @import("dusty");
var http_client = dusty.Client.init(allocator, .{});
defer http_client.deinit();

// Verify the nginx page is reachable using std.http.Client
const url = try std.fmt.allocPrint(allocator, "http://localhost:{d}/", .{port});
defer allocator.free(url);

var resp = try http_client.fetch(url, .{});
defer resp.deinit();
var http_client: std.http.Client = .{ .allocator = allocator };
defer http_client.deinit();

std.log.info("HTTP status: {d}", .{@intFromEnum(resp.status())});
const fetch_result = try http_client.fetch(.{
.location = .{ .url = url },
});

if (try resp.body()) |body| {
const preview_len = @min(body.len, 200);
std.log.info("Body preview:\n{s}", .{body[0..preview_len]});
}
std.log.info("HTTP status: {d}", .{@intFromEnum(fetch_result.status)});

// Demonstrate exec
const result = try ctr.exec(&.{ "echo", "hello from container" });
Expand Down
Loading