diff --git a/docs/banner.png b/docs/banner.png new file mode 100644 index 0000000..88961b3 Binary files /dev/null and b/docs/banner.png differ diff --git a/docs/community-logos/github.svg b/docs/community-logos/github.svg new file mode 100644 index 0000000..d563c83 --- /dev/null +++ b/docs/community-logos/github.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/docs/community-logos/slack.svg b/docs/community-logos/slack.svg new file mode 100644 index 0000000..942265e --- /dev/null +++ b/docs/community-logos/slack.svg @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/docs/community-logos/stackoverflow.svg b/docs/community-logos/stackoverflow.svg new file mode 100644 index 0000000..c330762 --- /dev/null +++ b/docs/community-logos/stackoverflow.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/docs/community-logos/twitter.svg b/docs/community-logos/twitter.svg new file mode 100644 index 0000000..ef3fbc6 --- /dev/null +++ b/docs/community-logos/twitter.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..5c3e5b0 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,11 @@ +# Contributing + +* Star the project on [GitHub](https://github.com/dragosv/testcontainers-zig) and help spread the word :) +* Join our [Slack Workspace](https://slack.testcontainers.org/) +* Post an issue if you find any bugs +* Contribute improvements or fixes using a Pull Request. If you're going to contribute, thank you! Please just be sure to: + * Discuss with the authors on an issue ticket prior to doing anything big. + * Follow the style, naming, and structure conventions of the rest of the project. + * Make commits atomic and easy to merge. + * Verify all tests are passing with `zig build test --summary all` (requires Docker running for integration tests). + * Let `zig fmt` handle formatting. diff --git a/docs/contributing_docs.md b/docs/contributing_docs.md new file mode 100644 index 0000000..02d900d --- /dev/null +++ b/docs/contributing_docs.md @@ -0,0 +1,69 @@ +# Contributing to Documentation + +The Testcontainers for Zig documentation lives in the `docs/` directory of the repository. + +## Structure + +``` +docs/ +├── index.md # Landing page +├── contributing.md # Contributing guide +├── contributing_docs.md # This file +├── quickstart/ +│ └── index.md # Getting started guide +├── features/ +│ ├── creating_container.md # ContainerRequest API +│ ├── creating_image.md # Image pulling and management +│ ├── networking.md # Ports and networks +│ ├── configuration.md # Docker host and configuration +│ ├── garbage_collector.md # Container cleanup patterns +│ ├── best_practices.md # Recommendations +│ ├── connection_strings.md # Connection string reference +│ ├── low_level_api.md # DockerClient access +│ └── wait/ +│ └── introduction.md # Wait strategies +├── modules/ +│ ├── index.md # Module overview +│ ├── postgres.md # PostgreSQL module +│ ├── mysql.md # MySQL module +│ ├── redis.md # Redis module +│ └── mongodb.md # MongoDB module +├── system_requirements/ +│ ├── index.md # Zig, Docker, and OS requirements +│ └── ci/ +│ ├── github_actions.md # GitHub Actions setup +│ ├── gitlab_ci.md # GitLab CI/CD setup +│ └── dind_patterns.md # Docker-in-Docker patterns +├── examples/ +│ └── index.md # Usage examples +└── test_frameworks/ + └── zig_test.md # Zig test integration patterns +``` + +## Guidelines + +- Use Zig code examples — not Swift, Go, Java, or other languages. +- All code examples must use the actual testcontainers-zig API (`ContainerRequest` structs, `DockerProvider`, etc.). +- Ensure examples are consistent with the actual API in `src/`. +- Use fenced code blocks with the `zig` language identifier. +- Follow the [testcontainers-go documentation](https://golang.testcontainers.org/) style: + - Each page should follow: Introduction → Usage example → Reference tables → Examples. + - Module pages use: Introduction → Adding dependency → Usage example → Module Reference → Examples. + - Use tables for API reference (parameters, methods, options). + - Use admonitions (`!!! tip`, `!!! warning`, `!!! note`) for callouts. + +## Adding a new page + +1. Create the Markdown file in the appropriate directory. +2. Follow the structure of existing pages in the same section. +3. Cross-link to related pages where appropriate. +4. Verify all code samples are consistent with the current API. + +## Adding a new module page + +When a new container module is added to `src/modules/`, create a corresponding documentation page in `docs/modules/`: + +1. Copy the structure from an existing module page (e.g., `postgres.md`). +2. Fill in: Introduction, Adding the dependency, Usage example, Module Reference (Options table, Container Methods table, Wait Strategy), and Examples. +3. Add the module to the table in `docs/modules/index.md`. +4. Cross-link from `docs/features/connection_strings.md` if it provides a connection string method. diff --git a/docs/css/extra.css b/docs/css/extra.css new file mode 100644 index 0000000..bf4db2a --- /dev/null +++ b/docs/css/extra.css @@ -0,0 +1,128 @@ +h1, h2, h3, h4, h5, h6 { + font-family: 'Rubik', sans-serif; +} + +[data-md-color-scheme="testcontainers"] { + --md-primary-fg-color: #00bac2; + --md-accent-fg-color: #361E5B; + --md-typeset-a-color: #0C94AA; + --md-primary-fg-color--dark: #291A3F; + --md-default-fg-color--lightest: #F2F4FE; + --md-footer-fg-color: #361E5B; + --md-footer-fg-color--light: #746C8F; + --md-footer-fg-color--lighter: #C3BEDE; + --md-footer-bg-color: #F7F9FD; + --md-footer-bg-color--dark: #F7F9FD; +} + +.card-grid { + display: grid; + gap: 10px; +} + +.tc-version { + font-size: 1.1em; + text-align: center; + margin: 0; +} + +@media (min-width: 680px) { + .card-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +body .card-grid-item { + display: flex; + align-items: center; + gap: 20px; + border: 1px solid #C3BEDE; + border-radius: 6px; + padding: 16px; + font-weight: 600; + color: #9991B5; + background: #F2F4FE; +} + +body .card-grid-item:hover, +body .card-grid-item:focus { + color: #9991B5; +} + +.card-grid-item[href] { + color: var(--md-primary-fg-color--dark); + background: transparent; +} + +.card-grid-item[href]:hover, +.card-grid-item[href]:focus { + background: #F2F4FE; + color: var(--md-primary-fg-color--dark); +} + +.community-callout-wrapper { + padding: 30px 10px 0 10px; +} + +.community-callout { + color: #F2F4FE; + background: linear-gradient(10.88deg, rgba(102, 56, 242, 0.4) 9.56%, #6638F2 100%), #291A3F; + box-shadow: 0px 20px 45px rgba(#9991B5, 0.75); + border-radius: 10px; + padding: 20px; +} + +.community-callout h2 { + font-size: 1.15em; + margin: 0 0 20px 0; + color: #F2F4FE; + text-align: center; +} + +.community-callout ul { + list-style: none; + padding: 0; + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 20px; + margin-bottom: 0; +} + +.community-callout a { + transition: opacity 0.2s ease; +} + +.community-callout a:hover { + opacity: 0.5; +} + +.community-callout a img { + height: 1.75em; + width: auto; + aspect-ratio: 1; +} + +@media (min-width: 1220px) { + .community-callout-wrapper { + padding: 40px 0 0; + } + + .community-callout h2 { + font-size: 1.25em; + } + + .community-callout a img { + height: 2em; + } +} + +@media (min-width: 1600px) { + .community-callout h2 { + font-size: 1.15em; + } + + .community-callout a img { + height: 1.75em; + } +} \ No newline at end of file diff --git a/docs/css/tc-header.css b/docs/css/tc-header.css new file mode 100644 index 0000000..122102b --- /dev/null +++ b/docs/css/tc-header.css @@ -0,0 +1,375 @@ + +:root { + --color-catskill: #F2F4FE; + --color-catskill-45: rgba(242, 244, 254, 0.45); + --color-mist: #E7EAFB; + --color-fog: #C3C7E6; + --color-smoke: #9991B5; + --color-smoke-75: rgba(153, 145, 181, 0.75); + --color-storm: #746C8F; + --color-topaz: #00BAC2; + --color-pacific: #17A6B2; + --color-teal: #027F9E; + --color-eggplant: #291A3F; + --color-plum: #361E5B; + +} + +#site-header { + color: var(--color-storm); + background: #fff; + font-family: 'Rubik', Arial, Helvetica, sans-serif; + font-size: 12px; + line-height: 1.5; + position: relative; + width: 100%; + z-index: 4; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px; +} + +body.tc-header-active #site-header { + z-index: 5; +} + +#site-header .brand { + display: flex; + justify-content: space-between; + gap: 20px; + width: 100%; +} + +#site-header .logo { + display: flex; +} + +#site-header .logo img, +#site-header .logo svg { + height: 30px; + width: auto; + max-width: 100%; +} + +#site-header #mobile-menu-toggle { + background: none; + border: none; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + color: var(--color-eggplant); + padding: 0; + margin: 0; + font-weight: 500; +} + +body.mobile-menu #site-header #mobile-menu-toggle { + color: var(--color-topaz); +} + +#site-header ul { + list-style: none; + padding: 0; + margin: 0; +} + +#site-header nav { + display: none; +} + +#site-header .menu-item { + display: flex; +} + +#site-header .menu-item button, +#site-header .menu-item a { + min-height: 30px; + display: flex; + gap: 6px; + align-items: center; + border: none; + background: none; + cursor: pointer; + padding: 0; + font-weight: 500; + color: var(--color-eggplant); + text-decoration: none; + font-size: 14px; + transition: color 0.2s ease; + white-space: nowrap; +} + +#site-header .menu-item button:hover, +#site-header .menu-item a:hover { + color: var(--color-topaz); +} + +#site-header .menu-item button .icon-external, +#site-header .menu-item a .icon-externa { + margin-left: auto; + opacity: .3; + flex-shrink: 0; +} + +#site-header .menu-item button .icon-caret, +#site-header .menu-item a .icon-caret { + opacity: .3; + height: 8px; +} + +#site-header .menu-item button .icon-slack, +#site-header .menu-item a .icon-slack, +#site-header .menu-item button .icon-github, +#site-header .menu-item a .icon-github { + height: 18px; +} + +#site-header .menu-item .menu-dropdown { + flex-direction: column; +} + +body #site-header .menu-item .menu-dropdown { + display: none; +} + +#site-header .menu-item.has-children.active .menu-dropdown { + display: flex; + z-index: 10; +} + +#site-header .menu-dropdown-item + .menu-dropdown-item { + border-top: 1px solid var(--color-mist); +} + +#site-header .menu-dropdown-item a { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 20px; + font-weight: 500; + color: var(--color-eggplant); + text-decoration: none; + transition: + color 0.2s ease, + background 0.2s ease; +} + +#site-header .menu-dropdown-item a .icon-external { + margin-left: auto; + color: var(--color-fog); + flex-shrink: 0; + opacity: 1; +} + +#site-header .menu-dropdown-item a:hover { + background-color: var(--color-catskill-45); +} + +#site-header .menu-dropdown-item a:hover .icon-external { + color: var(--color-topaz); +} + +#site-header .menu-dropdown-item a img { + height: 24px; +} + +.md-header { + background-color: var(--color-catskill); + color: var(--color-eggplant); +} + +.md-header.md-header--shadow { + box-shadow: none; +} + +.md-header__inner.md-grid { + max-width: 100%; + padding: 1.5px 20px; +} + +[dir=ltr] .md-header__title { + margin: 0; +} + +.md-header__topic:first-child { + font-size: 16px; + font-weight: 500; + font-family: 'Rubik', Arial, Helvetica, sans-serif; +} + +.md-header__title.md-header__title--active .md-header__topic, +.md-header__title[data-md-state=active] .md-header__topic { + opacity: 1; + pointer-events: all; + transform: translateX(0); + transition: none; + z-index: 0; +} + +.md-header__topic a { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + transition: color .2s ease; +} + +.md-header__topic a:hover { + color: var(--color-topaz); +} + +div.md-header__source { + width: auto; +} + +div.md-source__repository { + max-width: 100%; +} + +.md-main { + padding: 0 12px; +} + +@media screen and (min-width: 60em) { + form.md-search__form { + background-color: #FBFBFF; + color: var(--color-storm); + } + + form.md-search__form:hover { + background-color: #fff; + } + + .md-search__input + .md-search__icon { + color: var(--color-plum); + } + + .md-search__input::placeholder { + color: var(--color-smoke); + } +} + +@media (min-width: 500px) { + #site-header { + font-size: 16px; + padding: 20px 40px; + } + #site-header .logo img, + #site-header .logo svg { + height: 48px; + } + + #site-header .menu-item button .icon-caret, + #site-header .menu-item a .icon-caret { + height: 10px; + } + + #site-header .menu-item button .icon-slack, + #site-header .menu-item a .icon-slack, + #site-header .menu-item button .icon-github, + #site-header .menu-item a .icon-github { + height: 24px; + } + + .md-header__inner.md-grid { + padding: 5px 40px; + } + + .md-main { + padding: 0 32px; + } +} + +@media (min-width: 1024px) { + #site-header #mobile-menu-toggle { + display: none; + } + + #site-header nav { + display: block; + } + + #site-header .menu { + display: flex; + justify-content: center; + gap: 30px; + } + + #site-header .menu-item { + align-items: center; + position: relative; + } + + #site-header .menu-item button, + #site-header .menu-item a { + min-height: 48px; + gap: 8px; + font-size: 16px; + } + + #site-header .menu-item .menu-dropdown { + position: absolute; + top: 100%; + right: -8px; + border: 1px solid var(--color-mist); + border-radius: 6px; + background: #fff; + box-shadow: 0px 30px 35px var(--color-smoke-75); + min-width: 200px; + } +} + + +@media (max-width: 1023px) { + #site-header { + flex-direction: column; + } + + body.mobile-tc-header-active #site-header { + z-index: 5; + } + + body.mobile-menu #site-header nav { + display: flex; + } + + #site-header nav { + position: absolute; + top: calc(100% - 5px); + width: calc(100% - 80px); + flex-direction: column; + border: 1px solid var(--color-mist); + border-radius: 6px; + background: #fff; + box-shadow: 0px 30px 35px var(--color-smoke-75); + min-width: 200px; + } + + #site-header .menu-item { + flex-direction: column; + } + #site-header .menu-item + .menu-item { + border-top: 1px solid var(--color-mist); + } + + #site-header .menu-item button, + #site-header .menu-item a { + padding: 10px 20px; + } + + #site-header .menu-item.has-children.active .menu-dropdown { + border-top: 1px solid var(--color-mist); + } + + #site-header .menu-dropdown-item a { + padding: 10px 20px 10px 30px; + } +} + +@media (max-width: 499px) { + #site-header nav { + width: calc(100% - 40px); + } +} \ No newline at end of file diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..e22804f --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,245 @@ +# Examples + +This page demonstrates common usage patterns for Testcontainers for Zig, from basic container management through to multi-container setups. + +## Basic HTTP container + +Start an NGINX container, wait for it to be ready, and make an HTTP request: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "nginx container" { + const allocator = std.testing.allocator; + + const ctr = try tc.run(allocator, "nginx:1.26-alpine", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), + }); + defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); + } + + const port = try ctr.mappedPort("80/tcp", allocator); + const host = try ctr.daemonHost(allocator); + defer allocator.free(host); + + const url = try std.fmt.allocPrint(allocator, "http://{s}:{d}/", .{ host, port }); + defer allocator.free(url); + + var http_client: std.http.Client = .{ .allocator = allocator }; + defer http_client.deinit(); + + const fetch_result = try http_client.fetch(.{ + .location = .{ .url = url }, + }); + std.debug.print("Status: {d}\n", .{@intFromEnum(fetch_result.status)}); +} +``` + +## Database module + +Use the pre-configured [PostgreSQL module](../modules/postgres.md) for zero-config database testing: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "postgres module" { + const allocator = std.testing.allocator; + + var provider = tc.DockerProvider.init(allocator); + defer provider.deinit(); + + const pg = try tc.modules.postgres.run(&provider, tc.modules.postgres.default_image, .{ + .database = "myapp_test", + .username = "admin", + .password = "secret", + }); + defer pg.terminate() catch {}; + defer pg.deinit(); + + const conn = try pg.connectionString(allocator); + defer allocator.free(conn); + // "postgres://admin:secret@localhost:PORT/myapp_test" +} +``` + +## Combined wait strategies + +Wait for multiple conditions before considering a container ready: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "combined wait strategies" { + const allocator = std.testing.allocator; + + const strategies = [_]tc.wait.Strategy{ + tc.wait.forPort("5432/tcp"), + tc.wait.forLog("database system is ready to accept connections"), + }; + + const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forAll(&strategies), + }); + defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); + } + + // Container is guaranteed to have port 5432 listening + // AND the log message present +} +``` + +## Multi-container network + +Connect multiple containers through a custom Docker network: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "multi-container network" { + const allocator = std.testing.allocator; + + var provider = tc.DockerProvider.init(allocator); + defer provider.deinit(); + + // Create a shared network + const net = try tc.network.newNetwork(allocator, &provider.client, .{ + .name = "app-network", + }); + defer { + net.remove() catch {}; + net.deinit(); + } + + // Start PostgreSQL + const pg = try tc.modules.postgres.run(&provider, tc.modules.postgres.default_image, .{ + .database = "mydb", + .username = "admin", + .password = "secret", + }); + defer pg.terminate() catch {}; + defer pg.deinit(); + + // Start Redis + const redis = try tc.modules.redis.runDefault(&provider); + defer redis.terminate() catch {}; + defer redis.deinit(); + + const pg_conn = try pg.connectionString(allocator); + defer allocator.free(pg_conn); + const redis_conn = try redis.connectionString(allocator); + defer allocator.free(redis_conn); + + std.debug.print("PostgreSQL: {s}\n", .{pg_conn}); + std.debug.print("Redis: {s}\n", .{redis_conn}); +} +``` + +## Executing commands in a container + +Run commands inside a running container: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "exec in container" { + const allocator = std.testing.allocator; + + const ctr = try tc.run(allocator, "alpine:latest", .{ + .cmd = &.{ "sleep", "30" }, + }); + defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); + } + + const result = try ctr.exec(&.{ "echo", "Hello from Alpine" }); + defer allocator.free(result.output); + std.debug.print("{s}\n", .{std.mem.trim(u8, result.output, "\n\r ")}); +} +``` + +## Reading container logs + +Access stdout/stderr from a running container: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "container logs" { + const allocator = std.testing.allocator; + + const ctr = try tc.run(allocator, "alpine:latest", .{ + .cmd = &.{ "sh", "-c", "echo 'Application started' && sleep 30" }, + .wait_strategy = tc.wait.forLog("Application started"), + }); + defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); + } + + const logs = try ctr.logs(allocator); + defer allocator.free(logs); + std.debug.print("{s}\n", .{logs}); +} +``` + +## Zig test integration + +Testcontainers integrates naturally with Zig's built-in test framework. Use `test` blocks and `defer` for automatic cleanup: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "database reachable" { + const allocator = std.testing.allocator; + + var provider = tc.DockerProvider.init(allocator); + defer provider.deinit(); + + const pg = try tc.modules.postgres.run(&provider, tc.modules.postgres.default_image, .{ + .database = "testdb", + .username = "admin", + .password = "secret", + }); + defer pg.terminate() catch {}; + defer pg.deinit(); + + const conn = try pg.connectionString(allocator); + defer allocator.free(conn); + + // Verify the connection string contains our database name + try std.testing.expect(std.mem.indexOf(u8, conn, "testdb") != null); +} + +test "redis reachable" { + const allocator = std.testing.allocator; + + var provider = tc.DockerProvider.init(allocator); + defer provider.deinit(); + + const redis = try tc.modules.redis.runDefault(&provider); + defer redis.terminate() catch {}; + defer redis.deinit(); + + const port = try redis.port(allocator); + try std.testing.expect(port > 0); +} +``` diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..311a0ac Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/features/best_practices.md b/docs/features/best_practices.md new file mode 100644 index 0000000..12fd386 --- /dev/null +++ b/docs/features/best_practices.md @@ -0,0 +1,139 @@ +# Best practices + +This page provides guidelines for writing reliable, maintainable tests with Testcontainers for Zig. + +## Use random host ports + +Avoid binding fixed host ports. Testcontainers maps container ports to random available host ports by default, preventing port conflicts — especially in CI environments where tests may run in parallel. + +```zig +// ✅ Good — random host port (default behaviour) +const ctr = try tc.run(allocator, "postgres:16", .{ + .exposed_ports = &.{"5432/tcp"}, + .env = &.{"POSTGRES_PASSWORD=password"}, + .wait_strategy = tc.wait.forLog("database system is ready to accept connections"), +}); + +const port = try ctr.mappedPort("5432/tcp", allocator); +``` + +## Pin image versions + +Always use a specific image tag. Never rely on `latest`, which can change unexpectedly and break your tests. + +```zig +// ✅ Good +const ctr = try tc.run(allocator, "postgres:16.4", .{ ... }); + +// ❌ Avoid +const ctr = try tc.run(allocator, "postgres:latest", .{ ... }); +``` + +## Use wait strategies + +Configure a wait strategy so your test only proceeds after the service is fully ready. Without one, tests may fail intermittently due to race conditions. + +```zig +const strategies = [_]tc.wait.Strategy{ + tc.wait.forPort("5432/tcp"), + tc.wait.forLog("database system is ready to accept connections"), +}; + +const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forAll(&strategies), +}); +``` + +See [Wait Strategies](wait/introduction.md) for all available strategies. + +## Use pre-configured modules + +When a pre-configured module exists, prefer it over raw `ContainerRequest`. Modules provide sensible defaults, correct wait strategies, and convenience methods like `connectionString()`. + +```zig +// ✅ Good — uses the pre-configured module +var provider = tc.DockerProvider.init(allocator); +defer provider.deinit(); + +const pg = try tc.modules.postgres.run(&provider, tc.modules.postgres.default_image, .{ + .database = "testdb", + .username = "admin", + .password = "secret", +}); +defer pg.terminate() catch {}; +defer pg.deinit(); + +const conn = try pg.connectionString(allocator); +defer allocator.free(conn); +``` + +See [Modules](../modules/index.md) for all available modules. + +## Clean up containers + +Always clean up containers when tests complete. Use `defer` for deterministic cleanup: + +```zig +test "something" { + const allocator = std.testing.allocator; + + const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forLog("database system is ready to accept connections"), + }); + defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); + } + + // test logic... +} +``` + +See [Garbage Collector](garbage_collector.md) for detailed cleanup patterns. + +## Use `defer` for deterministic cleanup + +Zig's `defer` guarantees cleanup runs when the scope exits, whether the test passes or fails. Always pair `terminate()` and `deinit()` with `defer`: + +```zig +// ✅ Good — deterministic cleanup with defer +const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), +}); +defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); +} +``` + +## Use network aliases for inter-container communication + +When containers need to communicate, use custom networks with aliases instead of `localhost`: + +```zig +const ctr = try tc.run(allocator, "postgres:16", .{ + .exposed_ports = &.{"5432/tcp"}, + .env = &.{"POSTGRES_PASSWORD=password"}, + .networks = &.{"test-net"}, + .network_aliases = &.{.{ .network = "test-net", .aliases = &.{"db"} }}, + .wait_strategy = tc.wait.forLog("database system is ready to accept connections"), +}); +``` + +See [Networking](networking.md) for detailed networking patterns. + +## Use `std.log` for debugging + +Use Zig's `std.log` to diagnose container issues: + +```zig +const std = @import("std"); +std.log.info("Container started on port {d}", .{port}); +``` diff --git a/docs/features/configuration.md b/docs/features/configuration.md new file mode 100644 index 0000000..3c1502e --- /dev/null +++ b/docs/features/configuration.md @@ -0,0 +1,79 @@ +# Custom configuration + +You can override some default properties if your environment requires it. + +## Docker host detection + +Testcontainers for Zig will attempt to detect the Docker environment and configure everything to work automatically. + +However, sometimes customization is required. Testcontainers for Zig will respect the following order: + +1. **`DOCKER_HOST` environment variable** — If set, this takes priority. The value must use the `unix://` scheme (e.g., `unix:///var/run/docker.sock`). For `unix://` URIs, the path after the scheme is extracted and used as the socket path. +2. **`/var/run/docker.sock`** — Standard Docker socket on macOS and Linux (default fallback). + +## Environment variables + +| Variable | Description | Example | +|-----------------------------------|-------------------------------------------------------------------|--------------------------------------| +| `DOCKER_HOST` | Override the Docker daemon socket path. | `unix:///var/run/docker.sock` | +| `TESTCONTAINERS_HOST_OVERRIDE` | Override the host used to reach containers (e.g. in Docker Desktop). | `host.docker.internal` | + +Set it before running tests: + +```bash +export DOCKER_HOST=unix:///var/run/docker.sock +zig build test --summary all +``` + +Or set it in your CI configuration: + +```yaml +env: + DOCKER_HOST: unix:///var/run/docker.sock +``` + +## Docker socket path detection + +Testcontainers for Zig will attempt to detect the Docker socket path and configure everything to work automatically. + +The following locations are checked in order: + +| Priority | Location | Notes | +|:--------:|--------------------------------------|---------------------------------------------| +| 1 | `DOCKER_HOST` env var | Parsed from `unix://` prefix | +| 2 | `/var/run/docker.sock` | Default path on macOS and Linux | + +## Programmatic configuration + +You can initialize the Docker provider with a custom socket path: + +```zig +const tc = @import("testcontainers"); + +// Use default auto-detection (checks DOCKER_HOST, then /var/run/docker.sock) +var provider = tc.DockerProvider.init(allocator); +defer provider.deinit(); + +// Or specify a custom socket path +var provider2 = tc.DockerProvider.init_with_socket(allocator, "/custom/docker.sock"); +defer provider2.deinit(); +``` + +## Logging + +Testcontainers for Zig uses Zig's built-in `std.log` for diagnostic output. You can configure the log scope and level at compile time: + +```zig +pub const std_options: std.Options = .{ + .log_level = .debug, +}; +``` + +## Platform requirements + +| Requirement | Minimum version | +|-----------------|----------------------| +| Zig | 0.15.2 | +| macOS | 13.0 (Ventura) | +| Linux | Ubuntu 22.04+ | +| Docker | 20.10+ | diff --git a/docs/features/connection_strings.md b/docs/features/connection_strings.md new file mode 100644 index 0000000..4590762 --- /dev/null +++ b/docs/features/connection_strings.md @@ -0,0 +1,143 @@ +# Connection strings + +Pre-configured modules provide convenience methods that return connection strings or URLs. These methods query the container's mapped port and return a caller-owned string that must be freed with the allocator. + +## PostgreSQL + +```zig +const pg = try tc.modules.postgres.run(&provider, tc.modules.postgres.default_image, .{ + .username = "admin", + .password = "secret", + .database = "testdb", +}); +defer pg.terminate() catch {}; +defer pg.deinit(); + +const conn = try pg.connectionString(allocator); +defer allocator.free(conn); +// "postgres://admin:secret@localhost:PORT/testdb" +``` + +## MySQL + +```zig +const mysql = try tc.modules.mysql.run(&provider, tc.modules.mysql.default_image, .{ + .username = "admin", + .password = "secret", + .database = "testdb", +}); +defer mysql.terminate() catch {}; +defer mysql.deinit(); + +const conn = try mysql.connectionString(allocator); +defer allocator.free(conn); +// "mysql://admin:secret@localhost:PORT/testdb" +``` + +## MariaDB + +```zig +const maria = try tc.modules.mariadb.run(&provider, tc.modules.mariadb.default_image, .{ + .username = "admin", + .password = "secret", + .database = "testdb", +}); +defer maria.terminate() catch {}; +defer maria.deinit(); + +const conn = try maria.connectionString(allocator); +defer allocator.free(conn); +// "mariadb://admin:secret@localhost:PORT/testdb" +``` + +## Redis + +```zig +const redis = try tc.modules.redis.runDefault(&provider); +defer redis.terminate() catch {}; +defer redis.deinit(); + +const conn = try redis.connectionString(allocator); +defer allocator.free(conn); +// "redis://localhost:PORT" +``` + +## MongoDB + +```zig +const mongo = try tc.modules.mongodb.runDefault(&provider); +defer mongo.terminate() catch {}; +defer mongo.deinit(); + +const conn = try mongo.connectionString(allocator); +defer allocator.free(conn); +// "mongodb://localhost:PORT" +``` + +## RabbitMQ + +```zig +const rmq = try tc.modules.rabbitmq.runDefault(&provider); +defer rmq.terminate() catch {}; +defer rmq.deinit(); + +const amqp = try rmq.amqpURL(allocator); +defer allocator.free(amqp); +// "amqp://guest:guest@localhost:PORT" + +const http = try rmq.httpURL(allocator); +defer allocator.free(http); +// "http://localhost:MGMT_PORT" +``` + +## MinIO + +```zig +const minio = try tc.modules.minio.runDefault(&provider); +defer minio.terminate() catch {}; +defer minio.deinit(); + +const conn = try minio.connectionString(allocator); +defer allocator.free(conn); +// "http://localhost:PORT" +``` + +## Elasticsearch + +```zig +const es = try tc.modules.elasticsearch.runDefault(&provider); +defer es.terminate() catch {}; +defer es.deinit(); + +const url = try es.httpURL(allocator); +defer allocator.free(url); +// "http://localhost:PORT" +``` + +## Kafka + +```zig +const kafka = try tc.modules.kafka.runDefault(&provider); +defer kafka.terminate() catch {}; +defer kafka.deinit(); + +const brokers = try kafka.brokers(allocator); +defer allocator.free(brokers); +// "localhost:PORT" +``` + +## LocalStack + +```zig +const ls = try tc.modules.localstack.runDefault(&provider); +defer ls.terminate() catch {}; +defer ls.deinit(); + +const endpoint = try ls.endpointURL(allocator); +defer allocator.free(endpoint); +// "http://localhost:PORT" +``` + +!!! note + + All connection string methods return caller-owned slices. Always `defer allocator.free(...)` immediately after receiving the result. diff --git a/docs/features/creating_container.md b/docs/features/creating_container.md new file mode 100644 index 0000000..6c2ca17 --- /dev/null +++ b/docs/features/creating_container.md @@ -0,0 +1,118 @@ +# Creating a container + +The core API for creating containers in Testcontainers for Zig is the `ContainerRequest` struct, configured using Zig's struct-literal syntax with named fields and sensible defaults. + +## Using `ContainerRequest` + +```zig +const tc = @import("testcontainers"); + +const ctr = try tc.run(allocator, "nginx:1.26-alpine", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), +}); +defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); +} +``` + +## ContainerRequest fields + +| Field | Type | Default | Description | +|---------------------|------------------------------|-------------|-------------------------------------------------------| +| `image` | `[]const u8` | `""` | Docker image reference (e.g. `"nginx:latest"`). | +| `cmd` | `[]const []const u8` | `&.{}` | Command override (replaces image default CMD). | +| `entrypoint` | `[]const []const u8` | `&.{}` | Entrypoint override. | +| `env` | `[]const []const u8` | `&.{}` | Environment variables as `KEY=VALUE` strings. | +| `exposed_ports` | `[]const []const u8` | `&.{}` | Ports to expose (e.g. `"5432/tcp"`, `"80"`). | +| `labels` | `[]const KV` | `&.{}` | Arbitrary labels for the container. | +| `name` | `?[]const u8` | `null` | Optional container name. | +| `wait_strategy` | `wait.Strategy` | `.none` | Wait strategy executed after the container starts. | +| `networks` | `[]const []const u8` | `&.{}` | Networks to attach the container to. | +| `network_aliases` | `[]const NetworkAlias` | `&.{}` | Network aliases per network. | +| `mounts` | `[]const Mount` | `&.{}` | Bind-mounts and named volumes. | +| `files` | `[]const ContainerFile` | `&.{}` | Files to copy into the container. | +| `always_pull_image` | `bool` | `false` | Always pull the image even if present locally. | +| `image_platform` | `[]const u8` | `""` | Image platform (e.g. `"linux/amd64"`). | +| `startup_timeout_ns`| `u64` | `0` | Startup timeout in nanoseconds (0 = use default 60s). | + +## Using `DockerProvider` directly + +For more control, use a `DockerProvider` to create and manage containers: + +```zig +const tc = @import("testcontainers"); + +var provider = tc.DockerProvider.init(allocator); +defer provider.deinit(); + +const req = tc.ContainerRequest{ + .image = "nginx:1.26-alpine", + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), +}; + +const ctr = try provider.runContainer(&req); +defer { + ctr.terminate() catch {}; + ctr.deinit(); +} +``` + +## Setting environment variables + +```zig +const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{ + "POSTGRES_USER=admin", + "POSTGRES_PASSWORD=secret", + "POSTGRES_DB=testdb", + }, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forLog("database system is ready to accept connections"), +}); +``` + +## Setting the command + +```zig +const ctr = try tc.run(allocator, "alpine:latest", .{ + .cmd = &.{ "sh", "-c", "echo hello && sleep 30" }, + .wait_strategy = tc.wait.forLog("hello"), +}); +``` + +## Mounting volumes + +```zig +const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .mounts = &.{.{ + .mount_type = .bind, + .source = "/host/path/html", + .target = "/usr/share/nginx/html", + .read_only = true, + }}, + .wait_strategy = tc.wait.forHttp("/"), +}); +``` + +## Using `GenericContainerRequest` + +For advanced lifecycle control (e.g. creating a container without starting it, or reusing an existing container by name): + +```zig +const tc = @import("testcontainers"); + +const ctr = try tc.genericContainer(allocator, .{ + .container_request = .{ + .image = "nginx:latest", + .exposed_ports = &.{"80/tcp"}, + .name = "my-nginx", + }, + .started = false, // create but don't start + .reuse = true, // reuse existing container with same name +}); +``` diff --git a/docs/features/creating_image.md b/docs/features/creating_image.md new file mode 100644 index 0000000..de51111 --- /dev/null +++ b/docs/features/creating_image.md @@ -0,0 +1,53 @@ +# Image management + +Testcontainers for Zig handles image pulling automatically. When you create a container, the library checks whether the image exists locally and pulls it if needed. + +## Automatic pulling + +By default, Testcontainers pulls the image only if it is not already present on the Docker host: + +```zig +const ctr = try tc.run(allocator, "nginx:1.26-alpine", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), +}); +``` + +## Force pulling + +Set `always_pull_image` to `true` to always pull the image, even if it exists locally. This is useful to ensure you're testing against the latest version of a tag: + +```zig +const ctr = try tc.run(allocator, "nginx:1.26-alpine", .{ + .exposed_ports = &.{"80/tcp"}, + .always_pull_image = true, + .wait_strategy = tc.wait.forHttp("/"), +}); +``` + +## Image platform + +You can specify the image platform for multi-architecture images: + +```zig +const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .image_platform = "linux/amd64", + .wait_strategy = tc.wait.forHttp("/"), +}); +``` + +## Low-level image operations + +For direct image management, use the `DockerClient` API: + +```zig +var provider = tc.DockerProvider.init(allocator); +defer provider.deinit(); + +// Check if an image exists locally +const exists = try provider.client.imageExists("nginx:latest"); + +// Pull an image explicitly +try provider.client.imagePull("nginx:latest"); +``` diff --git a/docs/features/garbage_collector.md b/docs/features/garbage_collector.md new file mode 100644 index 0000000..1c6407c --- /dev/null +++ b/docs/features/garbage_collector.md @@ -0,0 +1,100 @@ +# Garbage Collector / Container Cleanup + +Testcontainers for Zig relies on Zig's `defer` mechanism for deterministic container cleanup. Every container must be terminated and freed when it is no longer needed. + +## Basic cleanup with `defer` + +The recommended pattern is to use `defer` immediately after creating a container: + +```zig +test "with cleanup" { + const allocator = std.testing.allocator; + + const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), + }); + defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); + } + + // test logic... +} +``` + +`defer` ensures that `terminate()` and `deinit()` are called when the scope exits, regardless of whether the test passes or fails. + +## Module containers + +Module containers follow the same pattern: + +```zig +test "postgres cleanup" { + const allocator = std.testing.allocator; + + var provider = tc.DockerProvider.init(allocator); + defer provider.deinit(); + + const pg = try tc.modules.postgres.runDefault(&provider); + defer pg.terminate() catch {}; + defer pg.deinit(); + + // test logic... +} +``` + +!!! warning + + Always call `terminate()` before `deinit()`. `terminate()` stops and removes the container from Docker. `deinit()` frees the Zig memory. If you only call `deinit()`, the container will keep running in Docker. + +## `terminate()` vs `deinit()` + +| Method | What it does | +|---------------|-----------------------------------------------------------| +| `terminate()` | Stops and removes the Docker container (and anonymous volumes). | +| `deinit()` | Frees the Zig-side memory. Does NOT stop the container. | + +Always call both. Use `defer` to ensure they run: + +```zig +defer ctr.terminate() catch {}; +defer ctr.deinit(); +``` + +Note: `defer` statements execute in reverse order, so `deinit()` will be called first, then `terminate()`. To ensure `terminate()` runs first, use a single `defer` block: + +```zig +defer { + ctr.terminate() catch {}; + ctr.deinit(); +} +``` + +## Network cleanup + +Networks also need cleanup: + +```zig +const net = try tc.network.newNetwork(allocator, &provider.client, .{ + .name = "test-network", +}); +defer { + net.remove() catch {}; + net.deinit(); +} +``` + +## Error path cleanup with `errdefer` + +When creating resources that may fail during setup, use `errdefer` to clean up partially-created state: + +```zig +const ctr = try provider.createContainer(&req); +errdefer { + ctr.terminate() catch {}; + ctr.deinit(); +} +try ctr.start(); // if this fails, errdefer runs cleanup +``` diff --git a/docs/features/low_level_api.md b/docs/features/low_level_api.md new file mode 100644 index 0000000..fd253a6 --- /dev/null +++ b/docs/features/low_level_api.md @@ -0,0 +1,76 @@ +# Low-level API + +For advanced use cases, you can access the `DockerClient` directly. The client communicates with the Docker daemon over its Unix domain socket using a built-in HTTP/1.1 client. + +## Accessing the DockerClient + +```zig +const tc = @import("testcontainers"); + +var provider = tc.DockerProvider.init(allocator); +defer provider.deinit(); + +// Access the client through the provider +const client = &provider.client; +``` + +## Image operations + +```zig +// Check if an image exists locally +const exists = try client.imageExists("nginx:latest"); + +// Pull an image +try client.imagePull("nginx:latest"); +``` + +## Container operations + +```zig +// Create a container (returns container ID) +const req = tc.ContainerRequest{ + .image = "nginx:latest", + .exposed_ports = &.{"80/tcp"}, +}; +const id = try client.containerCreate(&req, null); +defer allocator.free(id); + +// Start a container +try client.containerStart(id); + +// Stop a container (with timeout in seconds) +try client.containerStop(id, 10); + +// Remove a container +try client.containerRemove(id, true, true); + +// Inspect a container +var parsed = try client.containerInspect(id); +defer parsed.deinit(); + +// Execute a command in a container +const result = try client.containerExec(id, &.{ "echo", "hello" }); +defer allocator.free(result.output); + +// Get container logs +const logs = try client.containerLogs(id); +defer allocator.free(logs); +``` + +## Network operations + +```zig +// Create a network +const net_id = try client.networkCreate("test-net", "bridge"); +defer allocator.free(net_id); + +// Connect a container to a network +try client.networkConnect("test-net", container_id, &.{"alias1"}); + +// Remove a network +try client.networkRemove(net_id); +``` + +!!! warning + + The low-level API gives you direct access to Docker operations without lifecycle management. You are responsible for cleaning up all resources you create. diff --git a/docs/features/networking.md b/docs/features/networking.md new file mode 100644 index 0000000..be390cd --- /dev/null +++ b/docs/features/networking.md @@ -0,0 +1,110 @@ +# Networking + +Testcontainers for Zig provides support for Docker networking features including port mapping, custom networks, and inter-container communication. + +## Port mapping + +When you specify `exposed_ports` in the `ContainerRequest`, Docker maps each container port to a random available host port. Use `mappedPort()` to discover the actual host port: + +```zig +const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), +}); +defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); +} + +const port = try ctr.mappedPort("80/tcp", allocator); +const host = try ctr.daemonHost(allocator); +defer allocator.free(host); + +std.debug.print("Service available at {s}:{d}\n", .{ host, port }); +``` + +## Docker host + +The `daemonHost()` method returns the hostname to use when connecting to the container. It checks: + +1. `TESTCONTAINERS_HOST_OVERRIDE` environment variable +2. `DOCKER_HOST` environment variable (extracts host from `tcp://` URI) +3. Falls back to `"localhost"` + +```zig +const host = try ctr.daemonHost(allocator); +defer allocator.free(host); +``` + +## Custom networks + +Create custom Docker networks for inter-container communication: + +```zig +const tc = @import("testcontainers"); + +var provider = tc.DockerProvider.init(allocator); +defer provider.deinit(); + +const net = try tc.network.newNetwork(allocator, &provider.client, .{ + .name = "test-network", + .driver = "bridge", +}); +defer { + net.remove() catch {}; + net.deinit(); +} +``` + +### NetworkRequest fields + +| Field | Type | Default | Description | +|--------------|--------------------|-------------|-----------------------------------------------| +| `name` | `[]const u8` | (required) | Network name. | +| `driver` | `[]const u8` | `"bridge"` | Network driver. | +| `labels` | `[]const KV` | `&.{}` | Arbitrary labels. | +| `internal` | `bool` | `false` | Internal network (no external connectivity). | +| `attachable` | `bool` | `true` | Allow manual container attachment. | + +## Attaching containers to networks + +Attach containers to a network using the `networks` field in `ContainerRequest`: + +```zig +const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .networks = &.{"test-network"}, + .network_aliases = &.{.{ + .network = "test-network", + .aliases = &.{ "web", "frontend" }, + }}, + .wait_strategy = tc.wait.forHttp("/"), +}); +``` + +## Inspecting container networking + +```zig +// Get container IP on primary network +const ip = try ctr.containerIP(allocator); +defer allocator.free(ip); + +// Get all networks the container is attached to +const nets = try ctr.networks(allocator); +defer { + for (nets) |n| allocator.free(n); + allocator.free(nets); +} + +// Get aliases for a specific network +const aliases = try ctr.networkAliases("test-network", allocator); +defer { + for (aliases) |a| allocator.free(a); + allocator.free(aliases); +} + +// Get IP on a specific network +const net_ip = try ctr.networkIP("test-network", allocator); +defer allocator.free(net_ip); +``` diff --git a/docs/features/wait/introduction.md b/docs/features/wait/introduction.md new file mode 100644 index 0000000..99637e0 --- /dev/null +++ b/docs/features/wait/introduction.md @@ -0,0 +1,161 @@ +# Wait strategies + +Wait strategies control when a container is considered "ready" after it starts. Testcontainers for Zig uses a tagged union (`wait.Strategy`) to represent different strategies. You set the strategy on the `wait_strategy` field of `ContainerRequest`. + +## Available strategies + +| Strategy | Constructor | Description | +|-------------|---------------------------|-------------------------------------------------------------------------| +| Log | `wait.forLog(message)` | Wait for a specific substring in container logs. | +| HTTP | `wait.forHttp(path)` | Wait for an HTTP endpoint to return the expected status code. | +| Port | `wait.forPort(port)` | Wait for a TCP port to be reachable on the container. | +| Health Check| `wait.forHealthCheck()` | Wait for Docker's built-in health check to report "healthy". | +| Exec | `wait.forExec(cmd)` | Wait for a command executed inside the container to return exit code 0. | +| All | `wait.forAll(strategies)` | Combine multiple strategies — all must succeed (run serially). | +| None | `.none` | No waiting (default). | + +## Log strategy + +Wait for a specific log message to appear in the container output: + +```zig +const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forLog("database system is ready to accept connections"), +}); +``` + +### LogStrategy options + +| Field | Type | Default | Description | +|----------------------|-------------|---------|-----------------------------------------------------| +| `log` | `[]const u8`| — | The log substring to search for. | +| `is_regexp` | `bool` | `false` | Treat `log` as a regular expression. | +| `occurrence` | `u32` | `1` | Number of times the pattern must appear. | +| `startup_timeout_ns` | `u64` | `0` | Timeout in nanoseconds (0 = default 60s). | +| `poll_interval_ns` | `u64` | `0` | Polling interval in nanoseconds (0 = default 100ms).| + +For finer control, set the strategy field directly: + +```zig +const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = .{ .log = .{ + .log = "database system is ready to accept connections", + .occurrence = 2, + .startup_timeout_ns = 30 * std.time.ns_per_s, + }}, +}); +``` + +## HTTP strategy + +Wait for an HTTP endpoint to return a specific status code: + +```zig +const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), +}); +``` + +### HttpStrategy options + +| Field | Type | Default | Description | +|----------------------|-------------|---------|--------------------------------------------------| +| `path` | `[]const u8`| `"/"` | URL path to poll. | +| `port` | `[]const u8`| `""` | Container port spec. Empty = first exposed port. | +| `status_code` | `u16` | `200` | Expected HTTP status code. 0 = any 2xx. | +| `use_tls` | `bool` | `false` | Use HTTPS instead of HTTP. | +| `method` | `[]const u8`| `"GET"` | HTTP method (uppercase). | +| `startup_timeout_ns` | `u64` | `0` | Timeout in nanoseconds (0 = default 60s). | +| `poll_interval_ns` | `u64` | `0` | Polling interval in nanoseconds. | + +## Port strategy + +Wait for a TCP port to be reachable: + +```zig +const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forPort("5432/tcp"), +}); +``` + +## Health check strategy + +Wait for Docker's built-in health check to report "healthy": + +```zig +const ctr = try tc.run(allocator, "my-image:latest", .{ + .wait_strategy = tc.wait.forHealthCheck(), +}); +``` + +!!! note + + The image must define a `HEALTHCHECK` instruction in its Dockerfile. + +## Exec strategy + +Wait for a command to succeed (exit code 0) inside the container: + +```zig +const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forExec(&.{ "pg_isready", "-U", "postgres" }), +}); +``` + +### ExecStrategy options + +| Field | Type | Default | Description | +|----------------------|------------------------|---------|-----------------------------------------------| +| `cmd` | `[]const []const u8` | — | Command to run inside the container. | +| `expected_exit_code` | `i64` | `0` | Expected exit code for success. | +| `startup_timeout_ns` | `u64` | `0` | Timeout in nanoseconds (0 = default 60s). | +| `poll_interval_ns` | `u64` | `0` | Polling interval in nanoseconds. | + +## Combining strategies + +Use `wait.forAll()` to require multiple strategies to succeed: + +```zig +const strategies = [_]tc.wait.Strategy{ + tc.wait.forPort("5432/tcp"), + tc.wait.forLog("database system is ready to accept connections"), +}; + +const ctr = try tc.run(allocator, "postgres:16", .{ + .env = &.{"POSTGRES_PASSWORD=password"}, + .exposed_ports = &.{"5432/tcp"}, + .wait_strategy = tc.wait.forAll(&strategies), +}); +``` + +Strategies run serially — each must succeed before the next is attempted. + +## Timeouts + +All strategies default to a 60-second startup timeout and a 100ms polling interval. Override per-strategy: + +```zig +.wait_strategy = .{ .log = .{ + .log = "ready", + .startup_timeout_ns = 120 * std.time.ns_per_s, + .poll_interval_ns = 500 * std.time.ns_per_ms, +}}, +``` + +Or set a global timeout on the `ContainerRequest`: + +```zig +const ctr = try tc.run(allocator, "slow-image:latest", .{ + .startup_timeout_ns = 120 * std.time.ns_per_s, + .wait_strategy = tc.wait.forLog("ready"), +}); +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c0f6fdc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,91 @@ +# Testcontainers for Zig + +Testcontainers for Zig is a Zig library that makes it simple to create and clean up container-based dependencies for automated integration and end-to-end tests. The library uses Zig's built-in `test` blocks and integrates with `std.testing`. + +Typical use cases include spinning up throwaway instances of databases, message brokers, or any Docker image as part of your test suite — containers start in seconds and are cleaned up automatically when the test finishes. + +```zig title="Quickstart example" +const std = @import("std"); +const tc = @import("testcontainers"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const ctr = try tc.run(allocator, "nginx:latest", .{ + .exposed_ports = &.{"80/tcp"}, + .wait_strategy = tc.wait.forHttp("/"), + }); + defer { + ctr.terminate() catch {}; + ctr.deinit(); + tc.deinitProvider(); + } + + const port = try ctr.mappedPort("80/tcp", allocator); + std.debug.print("nginx at localhost:{d}\n", .{port}); +} +``` + +
+ Not using Zig? Here are other supported languages! +
+ + +## About + +Testcontainers for Zig is a library to support tests with throwaway instances of Docker containers. Built with Zig 0.15.2, it communicates with Docker via the Docker Remote API over Unix domain sockets using a built-in HTTP/1.1 client — no external dependencies required. + +Choose from existing pre-configured [modules](modules/index.md) — PostgreSQL, MySQL, Redis, MongoDB, RabbitMQ, MariaDB, MinIO, Elasticsearch, Kafka, and LocalStack — and start containers within seconds. Or use the generic `ContainerRequest` struct to run any Docker image with full control over configuration. + +Read the [Quickstart](quickstart/index.md) to get up and running in minutes. + +## System requirements + +Please read the [System Requirements](system_requirements/index.md) page before you start. + +| Requirement | Minimum version | +|-----------------|----------------------| +| Zig | 0.15.2 | +| macOS | 13.0 (Ventura) | +| Linux | Ubuntu 22.04+ | +| Docker | 20.10+ | + +Testcontainers automatically detects the Docker socket. It checks the `DOCKER_HOST` environment variable first, then falls back to `/var/run/docker.sock`. + +## License + +See [LICENSE](https://github.com/dragosv/testcontainers-zig/blob/main/LICENSE). + +## Copyright + +Copyright (c) 2024 - 2026 The Testcontainers for Zig Authors. + +---- + +Join our [Slack workspace](https://slack.testcontainers.org/) | [Testcontainers OSS](https://www.testcontainers.org/) | [Testcontainers Cloud](https://testcontainers.com/cloud/) +[testcontainers-cloud]: https://www.testcontainers.cloud/ diff --git a/docs/js/tc-header.js b/docs/js/tc-header.js new file mode 100644 index 0000000..4186b6c --- /dev/null +++ b/docs/js/tc-header.js @@ -0,0 +1,45 @@ +const mobileToggle = document.getElementById("mobile-menu-toggle"); +const mobileSubToggle = document.getElementById("mobile-submenu-toggle"); +function toggleMobileMenu() { + document.body.classList.toggle('mobile-menu'); + document.body.classList.toggle("mobile-tc-header-active"); +} +function toggleMobileSubmenu() { + document.body.classList.toggle('mobile-submenu'); +} +if (mobileToggle) + mobileToggle.addEventListener("click", toggleMobileMenu); +if (mobileSubToggle) + mobileSubToggle.addEventListener("click", toggleMobileSubmenu); + +const allParentMenuItems = document.querySelectorAll("#site-header .menu-item.has-children"); +function clearActiveMenuItem() { + document.body.classList.remove("tc-header-active"); + allParentMenuItems.forEach((item) => { + item.classList.remove("active"); + }); +} +function setActiveMenuItem(e) { + clearActiveMenuItem(); + e.currentTarget.closest(".menu-item").classList.add("active"); + document.body.classList.add("tc-header-active"); +} +allParentMenuItems.forEach((item) => { + const trigger = item.querySelector(":scope > a, :scope > button"); + + trigger.addEventListener("click", (e) => { + if (e.currentTarget.closest(".menu-item").classList.contains("active")) { + clearActiveMenuItem(); + } else { + setActiveMenuItem(e); + } + }); + + trigger.addEventListener("mouseenter", (e) => { + setActiveMenuItem(e); + }); + + item.addEventListener("mouseleave", (e) => { + clearActiveMenuItem(); + }); +}); \ No newline at end of file diff --git a/docs/language-logos/dotnet.svg b/docs/language-logos/dotnet.svg new file mode 100644 index 0000000..2fb163d --- /dev/null +++ b/docs/language-logos/dotnet.svg @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/docs/language-logos/go.svg b/docs/language-logos/go.svg new file mode 100644 index 0000000..bfcca48 --- /dev/null +++ b/docs/language-logos/go.svg @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/docs/language-logos/haskell.svg b/docs/language-logos/haskell.svg new file mode 100644 index 0000000..eb6de37 --- /dev/null +++ b/docs/language-logos/haskell.svg @@ -0,0 +1,6 @@ + diff --git a/docs/language-logos/java.svg b/docs/language-logos/java.svg new file mode 100644 index 0000000..590da12 --- /dev/null +++ b/docs/language-logos/java.svg @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/docs/language-logos/nodejs.svg b/docs/language-logos/nodejs.svg new file mode 100644 index 0000000..08c6ea7 --- /dev/null +++ b/docs/language-logos/nodejs.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/docs/language-logos/python.svg b/docs/language-logos/python.svg new file mode 100644 index 0000000..d06a313 --- /dev/null +++ b/docs/language-logos/python.svg @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/docs/language-logos/ruby.svg b/docs/language-logos/ruby.svg new file mode 100644 index 0000000..05537ce --- /dev/null +++ b/docs/language-logos/ruby.svg @@ -0,0 +1,125 @@ + diff --git a/docs/language-logos/rust.svg b/docs/language-logos/rust.svg new file mode 100644 index 0000000..8903933 --- /dev/null +++ b/docs/language-logos/rust.svg @@ -0,0 +1,57 @@ + \ No newline at end of file diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..9001e9e Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/logo.svg b/docs/logo.svg new file mode 100644 index 0000000..bac0c39 --- /dev/null +++ b/docs/logo.svg @@ -0,0 +1,92 @@ + + + diff --git a/docs/modules/index.md b/docs/modules/index.md new file mode 100644 index 0000000..04fd1d6 --- /dev/null +++ b/docs/modules/index.md @@ -0,0 +1,79 @@ +# Modules + +Testcontainers for Zig modules are pre-configured container wrappers that provide sensible defaults, correct wait strategies, and convenience methods for the most popular Docker images. + +Each module exposes: + +- `default_image` — the recommended image tag +- `Options` — configuration struct with sensible defaults +- `run(provider, image, opts)` — start and return a typed container +- `runDefault(provider)` — shorthand with default image and options + +## Available modules + +| Module | Default Image | Connection Helper | +|-----------------|---------------------------------------------------------------|-------------------------| +| PostgreSQL | `postgres:16-alpine` | `connectionString()` | +| MySQL | `mysql:8.0` | `connectionString()` | +| MariaDB | `mariadb:11` | `connectionString()` | +| Redis | `redis:7-alpine` | `connectionString()` | +| MongoDB | `mongo:7` | `connectionString()` | +| RabbitMQ | `rabbitmq:3-management-alpine` | `amqpURL()`, `httpURL()`| +| MinIO | `minio/minio:RELEASE.2024-01-16T16-07-38Z` | `connectionString()` | +| Elasticsearch | `docker.elastic.co/elasticsearch/elasticsearch:8.12.0` | `httpURL()` | +| Kafka | `bitnami/kafka:3.7` | `brokers()` | +| LocalStack | `localstack/localstack:3` | `endpointURL()` | + +## Usage pattern + +All modules follow the same pattern: + +```zig +const std = @import("std"); +const tc = @import("testcontainers"); + +test "module usage" { + const allocator = std.testing.allocator; + + var provider = tc.DockerProvider.init(allocator); + defer provider.deinit(); + + // Using runDefault (default image + default options) + const pg = try tc.modules.postgres.runDefault(&provider); + defer pg.terminate() catch {}; + defer pg.deinit(); + + const conn = try pg.connectionString(allocator); + defer allocator.free(conn); +} +``` + +### With custom options + +```zig +const pg = try tc.modules.postgres.run(&provider, tc.modules.postgres.default_image, .{ + .username = "admin", + .password = "secret", + .database = "mydb", +}); +defer pg.terminate() catch {}; +defer pg.deinit(); +``` + +### With a custom image + +```zig +const pg = try tc.modules.postgres.run(&provider, "postgres:15-alpine", .{}); +defer pg.terminate() catch {}; +defer pg.deinit(); +``` + +## Creating a new module + +See [AGENTS.md](https://github.com/dragosv/testcontainers-zig/blob/main/AGENTS.md) for the step-by-step guide on adding a new module. Each module should: + +1. Create `src/modules/