Skip to content
Open
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
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,27 @@ jobs:
build-and-test:
strategy:
matrix:
platform: [ubuntu-latest, macos-latest]
platform: [ubuntu-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v6

- name: Install and start Docker (macOS)
if: runner.os == 'macOS'
run: |
brew install docker docker-compose colima
colima start --memory 4 --cpu 2
# Wait for Docker to be ready
for i in $(seq 1 30); do
if docker info > /dev/null 2>&1; then
echo "Docker is ready!"
break
fi
echo "Waiting for Docker to start... ($i/30)"
sleep 2
done
docker info

- uses: mlugg/setup-zig@v2
with:
version: 0.15.2
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 },
});
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leak: The FetchResult returned by client.fetch() must have deinit() called to free allocated resources. Add defer fetch_result.deinit(allocator); after line 42.

Suggested change
});
});
defer fetch_result.deinit(allocator);

Copilot uses AI. Check for mistakes.

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