Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,33 @@
# Generated by golang tooling
debug.test
vendor
# Generated docs
site/
.direnv/
src/mkdocs-codeinclude-plugin
src/pip-delete-this-directory.txt
.idea/
.build/
.DS_Store
TEST-*.xml
**/go.work
# VS Code settings
.vscode
# Environment variables
.env
# Coverage files
coverage.out
# Usage metrics script binary
usage-metrics/scripts/collect-metrics
# Gas Town / Claude Code agent artifacts
.beads/
.claude/
.runtime/
+120
View File
@@ -0,0 +1,120 @@
formatters:
enable:
- gci
- gofumpt
settings:
gci:
sections:
- standard
- default
- prefix(github.com/testcontainers)
linters:
enable:
- errorlint
- gocritic
- govet
- misspell
- nakedret
- nolintlint
- perfsprint
- prealloc
- revive
- testifylint
- thelper
- usestdlibvars
exclusions:
rules:
- linters:
- revive
path: "^exec/"
text: "var-naming: avoid package names that conflict with Go standard library package names"
- linters:
- revive
path: "^log/"
text: "var-naming: avoid package names that conflict with Go standard library package names"
- linters:
- revive
path: "modulegen/internal/(context|template)/"
text: "var-naming: avoid package names that conflict with Go standard library package names"
- linters:
- revive
path: "internal/sdk/types/"
text: "var-naming: avoid meaningless package names"
- linters:
- revive
path: "gcloud/internal/shared/"
text: "var-naming: avoid meaningless package names"
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
settings:
errorlint:
asserts: true
comparison: true
errorf: true
errorf-multi: true
govet:
disable:
- fieldalignment
- shadow
enable-all: true
revive:
rules:
- name: blank-imports
- name: context-as-argument
arguments:
- allowTypesBefore: '*testing.T'
- name: context-keys-type
- name: dot-imports
- name: early-return
arguments:
- preserveScope
- name: empty-block
- name: error-naming
disabled: true
- name: error-return
- name: error-strings
disabled: true
- name: errorf
- name: increment-decrement
- name: indent-error-flow
arguments:
- preserveScope
- name: range
- name: receiver-naming
- name: redefines-builtin-id
disabled: true
- name: superfluous-else
arguments:
- preserveScope
- name: time-naming
- name: unexported-return
disabled: true
- name: unreachable-code
- name: unused-parameter
- name: use-any
- name: var-declaration
- name: var-naming
arguments:
- - ID
- - VM
- - upperCaseConst: true
staticcheck:
checks:
- all
testifylint:
disable:
- float-compare
- go-require
enable-all: true
output:
formats:
text:
path: stdout
run:
relative-path-mode: gitroot
version: "2"
@@ -0,0 +1,11 @@
quiet: True
disable-version-string: True
with-expecter: True
mockname: "mock{{.InterfaceName}}"
filename: "{{ .InterfaceName | lower }}_mock_test.go"
outpkg: "{{.PackageName}}_test"
dir: "{{.InterfaceDir}}"
packages:
github.com/testcontainers/testcontainers-go/wait:
interfaces:
StrategyTarget:
+229
View File
@@ -0,0 +1,229 @@
# AI Coding Agent Guidelines
This document provides guidelines for AI coding agents working on the Testcontainers for Go repository.
## Repository Overview
This is a **Go monorepo** containing:
- **Core library**: Root directory contains the main testcontainers-go library
- **Modules**: `./modules/` directory with 50+ technology-specific modules (postgres, redis, kafka, etc.)
- **Examples**: `./examples/` directory with example implementations
- **Module generator**: `./modulegen/` directory with tools to generate new modules
- **Documentation**: `./docs/` directory with MkDocs-based documentation
## Environment Setup
### Go Version
- **Required**: Go 1.25.9
- **Tool**: Use [gvm](https://github.com/andrewkroh/gvm) for version management
- **CRITICAL**: Always run this before ANY Go command:
```bash
# For Apple Silicon (M1/M2/M3)
eval "$(gvm 1.25.9 --arch=arm64)"
# For Intel/AMD (x86_64)
eval "$(gvm 1.25.9 --arch=amd64)"
```
### Project Structure
Each module in `./modules/` is a separate Go module with:
- `go.mod` / `go.sum` - Module dependencies
- `{module}.go` - Main module implementation
- `{module}_test.go` - Unit tests
- `examples_test.go` - Testable examples for documentation
- `Makefile` - Standard targets: `pre-commit`, `test-unit`
## Development Workflow
### Before Making Changes
1. **Read existing code** in similar modules for patterns
2. **Check documentation** in `docs/modules/index.md` for best practices
3. **Run tests** to ensure baseline passes
### Working with Modules
1. **Change to module directory**: `cd modules/{module-name}`
2. **Run pre-commit checks**: `make pre-commit` (linting, formatting, tidy)
3. **Run tests**: `make test-unit`
4. **Both together**: `make pre-commit test-unit`
### Git Workflow
- **Branch naming**: Use descriptive names like `chore-module-use-run`, `feat-add-xyz`, `fix-module-issue`
- **NEVER** use `main` branch for PRs (they will be auto-closed)
- **Commit format**: Conventional commits (enforced by CI)
```text
type(scope): description
Longer explanation if needed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
```
- **Commit types** (enforced): `security`, `fix`, `feat`, `docs`, `chore`, `deps`
- **Scope rules**:
- Optional (can be omitted for repo-level changes)
- Must be lowercase (uppercase scopes are rejected)
- Examples: `feat(redis)`, `chore(kafka)`, `docs`, `fix(postgres)`
- **Subject rules**:
- Must NOT start with uppercase letter
- ✅ Good: `feat(redis): add support for clustering`
- ❌ Bad: `feat(redis): Add support for clustering`
- **Breaking changes**: Add `!` after type: `feat(redis)!: remove deprecated API`
- **Always include co-author footer** when AI assists with changes
### Pull Requests
- **Title format**: Same as commit format (validated by CI)
- `type(scope): description`
- Examples: `feat(redis): add clustering support`, `docs: improve module guide`, `chore(kafka): update tests`
- **Title validation** enforced by `.github/workflows/conventions.yml`
- **Labels**: Use appropriate labels (`chore`, `breaking change`, `documentation`, etc.)
- **Body template**:
```markdown
## What does this PR do?
Brief description of changes.
## Why is it important?
Context and rationale.
## Related issues
- Relates to #issue-number
```
## Module Development Best Practices
**📖 Detailed guide**: See [`docs/modules/index.md`](docs/modules/index.md) for comprehensive module development documentation.
### Quick Reference
#### Container Struct
- **Name**: Use `Container`, not module-specific names like `PostgresContainer`
- **Fields**: Use private fields for state management
- **Embedding**: Always embed `testcontainers.Container`
```go
type Container struct {
testcontainers.Container
dbName string // private
user string // private
}
```
#### Run Function Pattern
Five-step implementation:
1. Process custom options (if using intermediate settings)
2. Build `moduleOpts` with defaults
3. Add conditional options based on settings
4. Append user options (allows overrides)
5. Call `testcontainers.Run` and return with proper error wrapping
```go
func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) {
// See docs/modules/index.md for complete implementation
moduleOpts := []testcontainers.ContainerCustomizer{
testcontainers.WithExposedPorts("5432/tcp"),
// ... defaults
}
moduleOpts = append(moduleOpts, opts...)
ctr, err := testcontainers.Run(ctx, img, moduleOpts...)
if err != nil {
return nil, fmt.Errorf("run modulename: %w", err)
}
return &Container{Container: ctr}, nil
}
```
#### Container Options
- **Simple config**: Use built-in `testcontainers.With*` options
- **Complex logic**: Use `testcontainers.CustomizeRequestOption`
- **State transfer**: Create custom `Option` type
**Critical rules:**
- ✅ Return struct types (not interfaces)
- ✅ Call built-in options directly: `testcontainers.WithFiles(f)(req)`
- ❌ Don't use `.Customize()` method
- ❌ Don't pass slices to variadic functions
#### Common Patterns
- **Env inspection**: Use `strings.CutPrefix` with early exit
- **Variadic args**: Pass directly, not as slices
- **Option order**: defaults → user options → post-processing
- **Error format**: `fmt.Errorf("run modulename: %w", err)`
**For complete examples and detailed explanations**, see [`docs/modules/index.md`](docs/modules/index.md).
## Testing Guidelines
### Running Tests
- **From module directory**: `cd modules/{module} && make test-unit`
- **Pre-commit checks**: `make pre-commit` (run this first to catch lint issues)
- **Full check**: `make pre-commit test-unit`
### Test Patterns
- Use testable examples in `examples_test.go`
- Follow existing test patterns in similar modules
- Test both success and error cases
- Use `t.Parallel()` when tests are independent
### When Tests Fail
1. **Read the error message carefully** - it usually tells you exactly what's wrong
2. **Check if it's a lint issue** - run `make pre-commit` first
3. **Verify Go version** - ensure using Go 1.25.9
4. **Check Docker** - some tests require Docker daemon running
## Common Pitfalls to Avoid
### Code Issues
- ❌ Using interface types as return values
- ❌ Forgetting to run `eval "$(gvm 1.25.9 --arch=arm64)"`
- ❌ Not handling errors from built-in options
- ❌ Using module-specific container names (`PostgresContainer`)
- ❌ Calling `.Customize()` method instead of direct function call
### Git Issues
- ❌ Forgetting co-author footer in commits
- ❌ Not running tests before committing
- ❌ Committing files outside module scope (use `git add modules/{module}/`)
- ❌ Using uppercase in scope: `feat(Redis)` → use `feat(redis)`
- ❌ Starting subject with uppercase: `fix: Add feature` → use `fix: add feature`
- ❌ Using wrong commit type (only: `security`, `fix`, `feat`, `docs`, `chore`, `deps`)
- ❌ Creating PR from `main` branch (will be auto-closed)
### Testing Issues
- ❌ Running tests without pre-commit checks first
- ❌ Not changing to module directory before running make
- ❌ Forgetting to set Go version before testing
## Reference Documentation
For detailed information, see:
- **Module development**: `docs/modules/index.md` - Comprehensive best practices
- **Contributing**: `docs/contributing.md` - General contribution guidelines
- **Modules catalog**: [testcontainers.com/modules](https://testcontainers.com/modules/?language=go)
- **API docs**: [pkg.go.dev/github.com/testcontainers/testcontainers-go](https://pkg.go.dev/github.com/testcontainers/testcontainers-go)
## Module Generator
To create a new module:
```bash
cd modulegen
go run . new module --name mymodule --image "docker.io/myimage:tag"
```
This generates:
- Module scaffolding with proper structure
- Documentation template
- Test files with examples
- Makefile with standard targets
The generator uses templates in `modulegen/_template/` that follow current best practices.
## Need Help?
- **Slack**: [testcontainers.slack.com](https://slack.testcontainers.org/)
- **GitHub Discussions**: [github.com/testcontainers/testcontainers-go/discussions](https://github.com/testcontainers/testcontainers-go/discussions)
- **Issues**: Check existing issues or create a new one with detailed context
@@ -0,0 +1,13 @@
# Contributing
Please see the [main contributing guidelines](./docs/contributing.md).
There are additional docs describing [contributing documentation changes](./docs/contributing.md).
### GitHub Sponsorship
Testcontainers is [in the GitHub Sponsors program](https://github.com/sponsors/testcontainers)!
This repository is supported by our sponsors, meaning that issues are eligible to have a 'bounty' attached to them by sponsors.
Please see [the bounty policy page](https://golang.testcontainers.org/bounty) if you are interested, either as a sponsor or as a contributor.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017-2019 Gianluca Arbezzano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
include ./commons-test.mk
.PHONY: lint-all
lint-all:
$(MAKE) lint
$(MAKE) -C modulegen lint
$(MAKE) -C examples lint-examples
$(MAKE) -C modules lint-modules
.PHONY: test-all
test-all: tools test-tools test-unit
.PHONY: test-examples
test-examples:
@echo "Running example tests..."
$(MAKE) -C examples test
.PHONY: tidy-all
tidy-all:
$(MAKE) tidy
$(MAKE) -C examples tidy-examples
$(MAKE) -C modules tidy-modules
## --------------------------------------
DOCS_CONTAINER=mkdocs-container
DOCS_IMAGE=python:3.13
.PHONY: clean-docs
clean-docs:
@echo "Destroying docs"
docker rm -f $(DOCS_CONTAINER) || true
.PHONY: serve-docs
serve-docs:
docker run --rm --name $(DOCS_CONTAINER) -it -p 8000:8000 \
-v $(PWD):/testcontainers-go \
-w /testcontainers-go \
$(DOCS_IMAGE) bash -c "pip install -Ur requirements.txt && mkdocs serve -f mkdocs.yml -a 0.0.0.0:8000"
## --------------------------------------
# Compose tests: Make goals to test the compose module against the latest versions of the compose and compose-go repositories.
#
# The following goals are available:
#
# - compose-clean: Clean the .build directory, and clean the go.mod and go.sum files in the testcontainers-go compose module.
# - compose-clone: Clone the compose and compose-go repositories into the .build directory.
# - compose-replace: Replace the docker/compose/v5 dependency in the testcontainers-go compose module with the local copy.
# - compose-spec-replace: Replace the compose-spec/compose-go/v2 dependency in the testcontainers-go compose module with the local copy.
# - compose-tidy: Run "go mod tidy" in the testcontainers-go compose module.
# - compose-test-all-latest: Test the testcontainers-go compose module against the latest versions of the compose and compose-go repositories.
# - compose-test-latest: Test the testcontainers-go compose module against the latest version of the compose repository, using current version of the compose-spec repository.
# - compose-test-spec-latest: Test the testcontainers-go compose module against the latest version of the compose-spec repository, using current version of the compose repository.
.PHONY: compose-clean
compose-clean:
rm -rf .build
cd modules/compose && git checkout -- go.mod go.sum
.PHONY: compose-clone
compose-clone: compose-clean
mkdir .build
git clone https://github.com/compose-spec/compose-go.git .build/compose-go & \
git clone https://github.com/docker/compose.git .build/compose
wait
.PHONY: compose-replace
compose-replace:
cd modules/compose && echo "replace github.com/docker/compose/v5 => ../../.build/compose" >> go.mod
.PHONY: compose-spec-replace
compose-spec-replace:
cd modules/compose && echo "replace github.com/compose-spec/compose-go/v2 => ../../.build/compose-go" >> go.mod
.PHONY: compose-tidy
compose-tidy:
cd modules/compose && go mod tidy
# The following three goals are used in the GitHub Actions workflow to test the compose module against the latest versions of the compose and compose-spec repositories.
# Please update the 'docker-projects-latest' workflow if you are making any changes to these goals.
.PHONY: compose-test-all-latest
compose-test-all-latest: compose-clone compose-replace compose-spec-replace compose-tidy
make -C modules/compose test-compose
.PHONY: compose-test-latest
compose-test-latest: compose-clone compose-replace compose-tidy
make -C modules/compose test-compose
.PHONY: compose-test-spec-latest
compose-test-spec-latest: compose-clone compose-spec-replace compose-tidy
make -C modules/compose test-compose
+16
View File
@@ -0,0 +1,16 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
mkdocs = "==1.5.3"
mkdocs-codeinclude-plugin = "==0.3.1"
mkdocs-include-markdown-plugin = "==7.2.2"
mkdocs-material = "==9.5.18"
mkdocs-markdownextradata-plugin = "==0.2.6"
[requires]
python_version = "3.13"
+704
View File
@@ -0,0 +1,704 @@
{
"_meta": {
"hash": {
"sha256": "d2dc50d3b1c6818dd8a8fb4fa7a60013292b7f173db53d3e996bf43b4191ad70"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.8"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"babel": {
"hashes": [
"sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363",
"sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"
],
"markers": "python_version >= '3.7'",
"version": "==2.14.0"
},
"bracex": {
"hashes": [
"sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952",
"sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7"
],
"markers": "python_version >= '3.9'",
"version": "==2.6"
},
"certifi": {
"hashes": [
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
],
"markers": "python_version >= '3.7'",
"version": "==2026.2.25"
},
"charset-normalizer": {
"hashes": [
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.6"
},
"click": {
"hashes": [
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
"sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
],
"markers": "python_version >= '3.10'",
"version": "==8.3.1"
},
"colorama": {
"hashes": [
"sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44",
"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'",
"version": "==0.4.6"
},
"ghp-import": {
"hashes": [
"sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619",
"sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"
],
"version": "==2.1.0"
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"importlib-metadata": {
"hashes": [
"sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1",
"sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"
],
"markers": "python_version < '3.10'",
"version": "==8.4.0"
},
"jinja2": {
"hashes": [
"sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d",
"sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.6"
},
"markdown": {
"hashes": [
"sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950",
"sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"
],
"markers": "python_version >= '3.10'",
"version": "==3.10.2"
},
"markupsafe": {
"hashes": [
"sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f",
"sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a",
"sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf",
"sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19",
"sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf",
"sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c",
"sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175",
"sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219",
"sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb",
"sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6",
"sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab",
"sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26",
"sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1",
"sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce",
"sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218",
"sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634",
"sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695",
"sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad",
"sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73",
"sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c",
"sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe",
"sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa",
"sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559",
"sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa",
"sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37",
"sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758",
"sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f",
"sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8",
"sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d",
"sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c",
"sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97",
"sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a",
"sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19",
"sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9",
"sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9",
"sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc",
"sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2",
"sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4",
"sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354",
"sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50",
"sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698",
"sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9",
"sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b",
"sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc",
"sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115",
"sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e",
"sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485",
"sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f",
"sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12",
"sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025",
"sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009",
"sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d",
"sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b",
"sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a",
"sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5",
"sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f",
"sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d",
"sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1",
"sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287",
"sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6",
"sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f",
"sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581",
"sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed",
"sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b",
"sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c",
"sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026",
"sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8",
"sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676",
"sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6",
"sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e",
"sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d",
"sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d",
"sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01",
"sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7",
"sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419",
"sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795",
"sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1",
"sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5",
"sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d",
"sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42",
"sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe",
"sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda",
"sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e",
"sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737",
"sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523",
"sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591",
"sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc",
"sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a",
"sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"
],
"markers": "python_version >= '3.9'",
"version": "==3.0.3"
},
"mergedeep": {
"hashes": [
"sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8",
"sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"
],
"markers": "python_version >= '3.6'",
"version": "==1.3.4"
},
"mkdocs": {
"hashes": [
"sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1",
"sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==1.5.3"
},
"mkdocs-codeinclude-plugin": {
"hashes": [
"sha256:06bbbf0d4ac7eccaec6e0d89ce76d644a197cfed34880f541516e722ded6512a",
"sha256:443f32c9e4412b84ec084bd2b454020c5bf06cb9a958682e08a528e62b45da4d"
],
"index": "pypi",
"markers": "python_version >= '3.11'",
"version": "==0.3.1"
},
"mkdocs-include-markdown-plugin": {
"hashes": [
"sha256:f052ccb741eccf498116b826c1d78a2d761c56747372594709441cee0963fbc9",
"sha256:f2ec4487cf32d3e33ca528f9366f20fb9280ded9c8d1630eb2bbda244962dcd1"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==7.2.2"
},
"mkdocs-markdownextradata-plugin": {
"hashes": [
"sha256:34dd40870781784c75809596b2d8d879da783815b075336d541de1f150c94242",
"sha256:4aed9b43b8bec65b02598387426ca4809099ea5f5aa78bf114f3296fd46686b5"
],
"index": "pypi",
"markers": "python_version >= '3.6'",
"version": "==0.2.6"
},
"mkdocs-material": {
"hashes": [
"sha256:1e0e27fc9fe239f9064318acf548771a4629d5fd5dfd45444fd80a953fe21eb4",
"sha256:a43f470947053fa2405c33995f282d24992c752a50114f23f30da9d8d0c57e62"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==9.5.18"
},
"mkdocs-material-extensions": {
"hashes": [
"sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443",
"sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"
],
"markers": "python_version >= '3.8'",
"version": "==1.3.1"
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"paginate": {
"hashes": [
"sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"
],
"version": "==0.5.6"
},
"pathspec": {
"hashes": [
"sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645",
"sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"
],
"markers": "python_version >= '3.9'",
"version": "==1.0.4"
},
"platformdirs": {
"hashes": [
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
],
"markers": "python_version >= '3.10'",
"version": "==4.9.4"
},
"pygments": {
"hashes": [
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.20.0"
},
"pymdown-extensions": {
"hashes": [
"sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91",
"sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==10.16.1"
},
"python-dateutil": {
"hashes": [
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==2.9.0.post0"
},
"pytz": {
"hashes": [
"sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812",
"sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"
],
"markers": "python_version < '3.9'",
"version": "==2024.1"
},
"pyyaml": {
"hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
},
"pyyaml-env-tag": {
"hashes": [
"sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04",
"sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"
],
"markers": "python_version >= '3.9'",
"version": "==1.1"
},
"regex": {
"hashes": [
"sha256:05d9b6578a22db7dedb4df81451f360395828b04f4513980b6bd7a1412c679cc",
"sha256:08a1749f04fee2811c7617fdd46d2e46d09106fa8f475c884b65c01326eb15c5",
"sha256:0940038bec2fe9e26b203d636c44d31dd8766abc1fe66262da6484bd82461ccf",
"sha256:0a2a512d623f1f2d01d881513af9fc6a7c46e5cfffb7dc50c38ce959f9246c94",
"sha256:0a54a047b607fd2d2d52a05e6ad294602f1e0dec2291152b745870afc47c1397",
"sha256:0dd3f69098511e71880fb00f5815db9ed0ef62c05775395968299cb400aeab82",
"sha256:1031a5e7b048ee371ab3653aad3030ecfad6ee9ecdc85f0242c57751a05b0ac4",
"sha256:108e2dcf0b53a7c4ab8986842a8edcb8ab2e59919a74ff51c296772e8e74d0ae",
"sha256:144a1fc54765f5c5c36d6d4b073299832aa1ec6a746a6452c3ee7b46b3d3b11d",
"sha256:19d6c11bf35a6ad077eb23852827f91c804eeb71ecb85db4ee1386825b9dc4db",
"sha256:1f687a28640f763f23f8a9801fe9e1b37338bb1ca5d564ddd41619458f1f22d1",
"sha256:224803b74aab56aa7be313f92a8d9911dcade37e5f167db62a738d0c85fdac4b",
"sha256:23a412b7b1a7063f81a742463f38821097b6a37ce1e5b89dd8e871d14dbfd86b",
"sha256:25f87ae6b96374db20f180eab083aafe419b194e96e4f282c40191e71980c666",
"sha256:2630ca4e152c221072fd4a56d4622b5ada876f668ecd24d5ab62544ae6793ed6",
"sha256:28e1f28d07220c0f3da0e8fcd5a115bbb53f8b55cecf9bec0c946eb9a059a94c",
"sha256:2b51739ddfd013c6f657b55a508de8b9ea78b56d22b236052c3a85a675102dc6",
"sha256:2cc1b87bba1dd1a898e664a31012725e48af826bf3971e786c53e32e02adae6c",
"sha256:2fef0b38c34ae675fcbb1b5db760d40c3fc3612cfa186e9e50df5782cac02bcd",
"sha256:36f392dc7763fe7924575475736bddf9ab9f7a66b920932d0ea50c2ded2f5636",
"sha256:374f690e1dd0dbdcddea4a5c9bdd97632cf656c69113f7cd6a361f2a67221cb6",
"sha256:3986217ec830c2109875be740531feb8ddafe0dfa49767cdcd072ed7e8927962",
"sha256:39fb166d2196413bead229cd64a2ffd6ec78ebab83fff7d2701103cf9f4dfd26",
"sha256:4290035b169578ffbbfa50d904d26bec16a94526071ebec3dadbebf67a26b25e",
"sha256:43548ad74ea50456e1c68d3c67fff3de64c6edb85bcd511d1136f9b5376fc9d1",
"sha256:44a22ae1cfd82e4ffa2066eb3390777dc79468f866f0625261a93e44cdf6482b",
"sha256:457c2cd5a646dd4ed536c92b535d73548fb8e216ebee602aa9f48e068fc393f3",
"sha256:459226445c7d7454981c4c0ce0ad1a72e1e751c3e417f305722bbcee6697e06a",
"sha256:47af45b6153522733aa6e92543938e97a70ce0900649ba626cf5aad290b737b6",
"sha256:499334ad139557de97cbc4347ee921c0e2b5e9c0f009859e74f3f77918339257",
"sha256:57ba112e5530530fd175ed550373eb263db4ca98b5f00694d73b18b9a02e7185",
"sha256:5ce479ecc068bc2a74cb98dd8dba99e070d1b2f4a8371a7dfe631f85db70fe6e",
"sha256:5dbc1bcc7413eebe5f18196e22804a3be1bfdfc7e2afd415e12c068624d48247",
"sha256:6277d426e2f31bdbacb377d17a7475e32b2d7d1f02faaecc48d8e370c6a3ff31",
"sha256:66372c2a01782c5fe8e04bff4a2a0121a9897e19223d9eab30c54c50b2ebeb7f",
"sha256:670fa596984b08a4a769491cbdf22350431970d0112e03d7e4eeaecaafcd0fec",
"sha256:6f435946b7bf7a1b438b4e6b149b947c837cb23c704e780c19ba3e6855dbbdd3",
"sha256:7413167c507a768eafb5424413c5b2f515c606be5bb4ef8c5dee43925aa5718b",
"sha256:7c3d389e8d76a49923683123730c33e9553063d9041658f23897f0b396b2386f",
"sha256:7d77b6f63f806578c604dca209280e4c54f0fa9a8128bb8d2cc5fb6f99da4150",
"sha256:7e76b9cfbf5ced1aca15a0e5b6f229344d9b3123439ffce552b11faab0114a02",
"sha256:7f3502f03b4da52bbe8ba962621daa846f38489cae5c4a7b5d738f15f6443d17",
"sha256:7fe9739a686dc44733d52d6e4f7b9c77b285e49edf8570754b322bca6b85b4cc",
"sha256:83ab366777ea45d58f72593adf35d36ca911ea8bd838483c1823b883a121b0e4",
"sha256:84077821c85f222362b72fdc44f7a3a13587a013a45cf14534df1cbbdc9a6796",
"sha256:8bb381f777351bd534462f63e1c6afb10a7caa9fa2a421ae22c26e796fe31b1f",
"sha256:92da587eee39a52c91aebea8b850e4e4f095fe5928d415cb7ed656b3460ae79a",
"sha256:9301cc6db4d83d2c0719f7fcda37229691745168bf6ae849bea2e85fc769175d",
"sha256:965fd0cf4694d76f6564896b422724ec7b959ef927a7cb187fc6b3f4e4f59833",
"sha256:99d6a550425cc51c656331af0e2b1651e90eaaa23fb4acde577cf15068e2e20f",
"sha256:99ef6289b62042500d581170d06e17f5353b111a15aa6b25b05b91c6886df8fc",
"sha256:a1409c4eccb6981c7baabc8888d3550df518add6e06fe74fa1d9312c1838652d",
"sha256:a74fcf77d979364f9b69fcf8200849ca29a374973dc193a7317698aa37d8b01c",
"sha256:aaa179975a64790c1f2701ac562b5eeb733946eeb036b5bcca05c8d928a62f10",
"sha256:ac69b394764bb857429b031d29d9604842bc4cbfd964d764b1af1868eeebc4f0",
"sha256:b45d4503de8f4f3dc02f1d28a9b039e5504a02cc18906cfe744c11def942e9eb",
"sha256:b7d893c8cf0e2429b823ef1a1d360a25950ed11f0e2a9df2b5198821832e1947",
"sha256:b8eb28995771c087a73338f695a08c9abfdf723d185e57b97f6175c5051ff1ae",
"sha256:b91d529b47798c016d4b4c1d06cc826ac40d196da54f0de3c519f5a297c5076a",
"sha256:bc365ce25f6c7c5ed70e4bc674f9137f52b7dd6a125037f9132a7be52b8a252f",
"sha256:bf29304a8011feb58913c382902fde3395957a47645bf848eea695839aa101b7",
"sha256:c06bf3f38f0707592898428636cbb75d0a846651b053a1cf748763e3063a6925",
"sha256:c77d10ec3c1cf328b2f501ca32583625987ea0f23a0c2a49b37a39ee5c4c4630",
"sha256:cd196d056b40af073d95a2879678585f0b74ad35190fac04ca67954c582c6b61",
"sha256:d7a353ebfa7154c871a35caca7bfd8f9e18666829a1dc187115b80e35a29393e",
"sha256:d84308f097d7a513359757c69707ad339da799e53b7393819ec2ea36bc4beb58",
"sha256:dd7ef715ccb8040954d44cfeff17e6b8e9f79c8019daae2fd30a8806ef5435c0",
"sha256:e672cf9caaf669053121f1766d659a8813bd547edef6e009205378faf45c67b8",
"sha256:ecc6148228c9ae25ce403eade13a0961de1cb016bdb35c6eafd8e7b87ad028b1",
"sha256:f1c5742c31ba7d72f2dedf7968998730664b45e38827637e0f04a2ac7de2f5f1",
"sha256:f1d6e4b7b2ae3a6a9df53efbf199e4bfcff0959dbdb5fd9ced34d4407348e39a",
"sha256:f2fc053228a6bd3a17a9b0a3f15c3ab3cf95727b00557e92e1cfe094b88cc662",
"sha256:f57515750d07e14743db55d59759893fdb21d2668f39e549a7d6cad5d70f9fea",
"sha256:f85151ec5a232335f1be022b09fbbe459042ea1951d8a48fef251223fc67eee1",
"sha256:fb0315a2b26fde4005a7c401707c5352df274460f2f85b209cf6024271373013",
"sha256:fc0916c4295c64d6890a46e02d4482bb5ccf33bf1a824c0eaa9e83b148291f90",
"sha256:fd24fd140b69f0b0bcc9165c397e9b2e89ecbeda83303abf2a072609f60239e2",
"sha256:fdae0120cddc839eb8e3c15faa8ad541cc6d906d3eb24d82fb041cfe2807bc1e",
"sha256:fe00f4fe11c8a521b173e6324d862ee7ee3412bf7107570c9b564fe1119b56fb"
],
"markers": "python_version >= '3.8'",
"version": "==2024.4.28"
},
"requests": {
"hashes": [
"sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b",
"sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==2.33.0"
},
"six": {
"hashes": [
"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274",
"sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.17.0"
},
"urllib3": {
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
},
"watchdog": {
"hashes": [
"sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a",
"sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2",
"sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f",
"sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c",
"sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c",
"sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c",
"sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0",
"sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13",
"sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134",
"sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa",
"sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e",
"sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379",
"sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a",
"sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11",
"sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282",
"sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b",
"sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f",
"sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c",
"sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112",
"sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948",
"sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881",
"sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860",
"sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3",
"sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680",
"sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26",
"sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26",
"sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e",
"sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8",
"sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c",
"sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"
],
"markers": "python_version >= '3.9'",
"version": "==6.0.0"
},
"wcmatch": {
"hashes": [
"sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a",
"sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af"
],
"markers": "python_version >= '3.9'",
"version": "==10.1"
},
"zipp": {
"hashes": [
"sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064",
"sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b"
],
"markers": "python_version >= '3.8'",
"version": "==3.20.1"
}
},
"develop": {}
}
+21
View File
@@ -0,0 +1,21 @@
# Testcontainers
[![Main pipeline](https://github.com/testcontainers/testcontainers-go/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/testcontainers/testcontainers-go/actions/workflows/ci.yml)
[![GoDoc Reference](https://pkg.go.dev/badge/github.com/testcontainers/testcontainers-go.svg)](https://pkg.go.dev/github.com/testcontainers/testcontainers-go)
[![Go Report Card](https://goreportcard.com/badge/github.com/testcontainers/testcontainers-go)](https://goreportcard.com/report/github.com/testcontainers/testcontainers-go)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=testcontainers_testcontainers-go&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=testcontainers_testcontainers-go)
[![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/testcontainers/testcontainers-go/blob/main/LICENSE)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=141451032&machine=standardLinux32gb&devcontainer_path=.devcontainer%2Fdevcontainer.json&location=EastUs)
[![Join our Slack](https://img.shields.io/badge/Slack-4A154B?logo=slack)](https://testcontainers.slack.com/)
_Testcontainers for Go_ is a Go package that makes it simple to create and clean up container-based dependencies for
automated integration/smoke tests. The clean, easy-to-use API enables developers to programmatically define containers
that should be run as part of a test and clean up those resources when the test is done.
You can find more information about _Testcontainers for Go_ at [golang.testcontainers.org](https://golang.testcontainers.org), which is rendered from the [./docs](./docs) directory.
## Using _Testcontainers for Go_
Please visit [the quickstart guide](https://golang.testcontainers.org/quickstart) to understand how to add the dependency to your Go project.
+201
View File
@@ -0,0 +1,201 @@
# Releasing Testcontainers for Go
In order to create a release, we have added a shell script that performs all the tasks for you, allowing a dry-run mode for checking it before creating the release. We are going to explain how to use it in this document.
## Prerequisites
First, it's really important that you first check that the [version.go](./internal/version.go) file is up-to-date, containing the right version you want to create. That file will be used by the automation to perform the release.
Once the version file is correct in the repository:
Second, check that the git remote for the `origin` is pointing to `github.com/testcontainers/testcontainers-go`. You can check it by running:
```shell
git remote -v
```
## Prepare the release
Once the remote is properly set, please follow these steps:
- Run the [pre-release.sh](./scripts/pre-release.sh) shell script to run it in dry-run mode.
- You can use the `DRY_RUN` variable to enable or disable the dry-run mode. By default, it's enabled.
- To prepare for a release, updating the _Testcontainers for Go_ dependency for all the modules and examples, without performing any Git operation:
DRY_RUN="false" ./scripts/pre-release.sh
- The script will update the [mkdocs.yml](./mkdocks.yml) file, updating the `latest_version` field to the current version.
- The script will update the `go.mod` files for each Go modules and example modules under the examples and modules directories, updating the version of the testcontainers-go dependency to the recently created tag.
- The script will modify the docs for the each Go module **that was not released yet**, updating the version of _Testcontainers for Go_ where it was added to the recently created tag.
An example execution, with dry-run mode enabled:
```shell
sed "s/latest_version: .*/latest_version: v0.20.1/g" /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/mkdocs.yml > /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/mkdocs.yml.tmp
mv /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/mkdocs.yml.tmp /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/mkdocs.yml
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" bigtable/go.mod > bigtable/go.mod.tmp
mv bigtable/go.mod.tmp bigtable/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" cockroachdb/go.mod > cockroachdb/go.mod.tmp
mv cockroachdb/go.mod.tmp cockroachdb/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" consul/go.mod > consul/go.mod.tmp
mv consul/go.mod.tmp consul/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" datastore/go.mod > datastore/go.mod.tmp
mv datastore/go.mod.tmp datastore/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" firestore/go.mod > firestore/go.mod.tmp
mv firestore/go.mod.tmp firestore/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" mongodb/go.mod > mongodb/go.mod.tmp
mv mongodb/go.mod.tmp mongodb/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" nginx/go.mod > nginx/go.mod.tmp
mv nginx/go.mod.tmp nginx/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" pubsub/go.mod > pubsub/go.mod.tmp
mv pubsub/go.mod.tmp pubsub/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" spanner/go.mod > spanner/go.mod.tmp
mv spanner/go.mod.tmp spanner/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" toxiproxy/go.mod > toxiproxy/go.mod.tmp
mv toxiproxy/go.mod.tmp toxiproxy/go.mod
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" compose/go.mod > compose/go.mod.tmp
mv compose/go.mod.tmp compose/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" couchbase/go.mod > couchbase/go.mod.tmp
mv couchbase/go.mod.tmp couchbase/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" localstack/go.mod > localstack/go.mod.tmp
mv localstack/go.mod.tmp localstack/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" mysql/go.mod > mysql/go.mod.tmp
mv mysql/go.mod.tmp mysql/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" neo4j/go.mod > neo4j/go.mod.tmp
mv neo4j/go.mod.tmp neo4j/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" postgres/go.mod > postgres/go.mod.tmp
mv postgres/go.mod.tmp postgres/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" pulsar/go.mod > pulsar/go.mod.tmp
mv pulsar/go.mod.tmp pulsar/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" redis/go.mod > redis/go.mod.tmp
mv redis/go.mod.tmp redis/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" redpanda/go.mod > redpanda/go.mod.tmp
mv redpanda/go.mod.tmp redpanda/go.mod
sed "s/testcontainers-go v.*/testcontainers-go v0.20.1/g" vault/go.mod > vault/go.mod.tmp
mv vault/go.mod.tmp vault/go.mod
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
go mod tidy
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" couchbase.md > couchbase.md.tmp
mv couchbase.md.tmp couchbase.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" localstack.md > localstack.md.tmp
mv localstack.md.tmp localstack.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" mysql.md > mysql.md.tmp
mv mysql.md.tmp mysql.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" neo4j.md > neo4j.md.tmp
mv neo4j.md.tmp neo4j.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" postgres.md > postgres.md.tmp
mv postgres.md.tmp postgres.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" pulsar.md > pulsar.md.tmp
mv pulsar.md.tmp pulsar.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" redis.md > redis.md.tmp
mv redis.md.tmp redis.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" redpanda.md > redpanda.md.tmp
mv redpanda.md.tmp redpanda.md
sed "s/Not available until the next release <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\"><span class=\"tc-version\">:material-tag: main<\/span><\/a>/Since <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\/releases\/tag\/v0.20.1\"><span class=\"tc-version\">:material-tag: v0.20.1<\/span><\/a>/g" vault.md > vault.md.tmp
mv vault.md.tmp vault.md
```
## Performing a release
Once you are satisfied with the modified files in the git state:
- Run the [release.sh](./scripts/release.sh) shell script to create the release in dry-run mode.
- You can use the `DRY_RUN` variable to enable or disable the dry-run mode. By default, it's enabled.
DRY_RUN="false" ./scripts/release.sh
- You can define the bump type, using the `BUMP_TYPE` environment variable. The default value is `minor`, but you can also use `major` or `patch` (the script will fail if the value is not one of these three):
BUMP_TYPE="major" ./scripts/release.sh
- The script will commit the current state of the git repository, if the `DRY_RUN` variable is set to `false`. The modified files are the ones modified by the `pre-release.sh` script.
- The script will create a git tag with the current value of the [version.go](./internal/version.go) file, starting with `v`: e.g. `v0.18.0`, for the following Go modules:
- the root module, representing the Testcontainers for Go library.
- all the Go modules living in both the `examples` and `modules` directory. The git tag value for these Go modules will be created using this name convention:
"${directory}/${module_name}/${version}", e.g. "examples/mysql/v0.18.0", "modules/compose/v0.18.0"
- The script will update the [version.go](./internal/version.go) file, setting the next development version to the value defined in the `BUMP_TYPE` environment variable. For example, if the current version is `v0.18.0`, the script will update the [version.go](./internal/version.go) file with the next development version `v0.19.0`.
- The script will create a commit in the **main** branch if the `DRY_RUN` variable is set to `false`.
- The script will push the main branch including the tags to the upstream repository, https://github.com/testcontainers/testcontainers-go, if the `DRY_RUN` variable is set to `false`.
- Finally, the script will trigger the Golang proxy to update the modules in https://proxy.golang.org/, if the `DRY_RUN` variable is set to `false`.
An example execution, with dry-run mode enabled:
```
$ ./scripts/release.sh
Current version: v0.20.1
git add /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/internal/version.go
git add /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/mkdocs.yml
git add examples/**/go.*
git add modules/**/go.*
git commit -m chore: use new version (v0.20.1) in modules and examples
git tag v0.20.1
git tag examples/bigtable/v0.20.1
git tag examples/datastore/v0.20.1
git tag examples/firestore/v0.20.1
git tag examples/mongodb/v0.20.1
git tag examples/nginx/v0.20.1
git tag examples/pubsub/v0.20.1
git tag examples/spanner/v0.20.1
git tag examples/toxiproxy/v0.20.1
git tag modules/cockroachdb/v0.20.1
git tag modules/compose/v0.20.1
git tag modules/couchbase/v0.20.1
git tag modules/localstack/v0.20.1
git tag modules/mysql/v0.20.1
git tag modules/neo4j/v0.20.1
git tag modules/postgres/v0.20.1
git tag modules/pulsar/v0.20.1
git tag modules/redis/v0.20.1
git tag modules/redpanda/v0.20.1
git tag modules/vault/v0.20.1
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
Producing a minor bump of the version, from 0.20.1 to 0.21.0
sed "s/const Version = ".*"/const Version = "0.21.0"/g" /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/internal/version.go > /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/internal/version.go.tmp
mv /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/internal/version.go.tmp /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/internal/version.go
git add /Users/mdelapenya/sourcecode/src/github.com/testcontainers/testcontainers-go/internal/version.go
git commit -m chore: prepare for next minor development cycle (0.21.0)
git push origin main --tags
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/bigtable/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/datastore/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/firestore/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/mongodb/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/nginx/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/pubsub/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/spanner/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/examples/toxiproxy/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/cockroachdb/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/compose/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/couchbase/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/localstack/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/mysql/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/neo4j/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/postgres/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/pulsar/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/redis/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/redpanda/@v/v0.20.1.info
curl https://proxy.golang.org/github.com/testcontainers/testcontainers-go/modules/vault/@v/v0.20.1.info
```
Right after that, you have to:
- Verify that the commits are in the upstream repository, otherwise, update it with the current state of the main branch.
+125
View File
@@ -0,0 +1,125 @@
package testcontainers
import (
"context"
"errors"
"fmt"
"reflect"
"time"
"github.com/moby/moby/client"
)
// TerminateOptions is a type that holds the options for terminating a container.
type TerminateOptions struct {
ctx context.Context
stopTimeout *time.Duration
volumes []string
}
// TerminateOption is a type that represents an option for terminating a container.
type TerminateOption func(*TerminateOptions)
// NewTerminateOptions returns a fully initialised TerminateOptions.
// Defaults: StopTimeout: 10 seconds.
func NewTerminateOptions(ctx context.Context, opts ...TerminateOption) *TerminateOptions {
timeout := time.Second * 10
options := &TerminateOptions{
stopTimeout: &timeout,
ctx: ctx,
}
for _, opt := range opts {
opt(options)
}
return options
}
// Context returns the context to use during a Terminate.
func (o *TerminateOptions) Context() context.Context {
return o.ctx
}
// StopTimeout returns the stop timeout to use during a Terminate.
func (o *TerminateOptions) StopTimeout() *time.Duration {
return o.stopTimeout
}
// Cleanup performs any clean up needed
func (o *TerminateOptions) Cleanup() error {
// TODO: simplify this when when perform the client refactor.
if len(o.volumes) == 0 {
return nil
}
apiClient, err := NewDockerClientWithOpts(o.ctx)
if err != nil {
return fmt.Errorf("docker client: %w", err)
}
defer apiClient.Close()
// Best effort to remove all volumes.
var errs []error
for _, volume := range o.volumes {
if _, errRemove := apiClient.VolumeRemove(o.ctx, volume, client.VolumeRemoveOptions{Force: true}); errRemove != nil {
errs = append(errs, fmt.Errorf("volume remove %q: %w", volume, errRemove))
}
}
return errors.Join(errs...)
}
// StopContext returns a TerminateOption that sets the context.
// Default: context.Background().
func StopContext(ctx context.Context) TerminateOption {
return func(c *TerminateOptions) {
c.ctx = ctx
}
}
// StopTimeout returns a TerminateOption that sets the timeout.
// Default: See [Container.Stop].
func StopTimeout(timeout time.Duration) TerminateOption {
return func(c *TerminateOptions) {
c.stopTimeout = &timeout
}
}
// RemoveVolumes returns a TerminateOption that sets additional volumes to remove.
// This is useful when the container creates named volumes that should be removed
// which are not removed by default.
// Default: nil.
func RemoveVolumes(volumes ...string) TerminateOption {
return func(c *TerminateOptions) {
c.volumes = volumes
}
}
// TerminateContainer calls [Container.Terminate] on the container if it is not nil.
//
// This should be called as a defer directly after [GenericContainer](...)
// or a modules Run(...) to ensure the container is terminated when the
// function ends.
func TerminateContainer(container Container, options ...TerminateOption) error {
if isNil(container) {
return nil
}
err := container.Terminate(context.Background(), options...)
if !isCleanupSafe(err) {
return fmt.Errorf("terminate: %w", err)
}
return nil
}
// isNil returns true if val is nil or a nil instance false otherwise.
func isNil(val any) bool {
if val == nil {
return true
}
valueOf := reflect.ValueOf(val)
switch valueOf.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return valueOf.IsNil()
default:
return false
}
}
@@ -0,0 +1,69 @@
ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
GOBIN= $(GOPATH)/bin
define go_install
go install $(1)
endef
$(GOBIN)/golangci-lint:
$(call go_install,github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9.0)
$(GOBIN)/gotestsum:
$(call go_install,gotest.tools/gotestsum@latest)
$(GOBIN)/mockery:
$(call go_install,github.com/vektra/mockery/v2@v2.53.4)
$(GOBIN)/gci:
$(call go_install,github.com/daixiang0/gci@v0.13.5)
.PHONY: install
install: $(GOBIN)/golangci-lint $(GOBIN)/gotestsum $(GOBIN)/mockery
.PHONY: clean
clean:
rm $(GOBIN)/golangci-lint
rm $(GOBIN)/gotestsum
rm $(GOBIN)/mockery
.PHONY: dependencies-scan
dependencies-scan:
@echo ">> Scanning dependencies in $(CURDIR)..."
go list -json -m all | docker run --rm -i sonatypecommunity/nancy:latest sleuth --skip-update-check
.PHONY: lint
lint: $(GOBIN)/golangci-lint
golangci-lint run -c $(ROOT_DIR)/.golangci.yml --fix
.PHONY: generate
generate: $(GOBIN)/gci
generate: $(GOBIN)/mockery
go generate ./...
.PHONY: test-%
test-%: $(GOBIN)/gotestsum
@echo "Running $* tests..."
gotestsum \
--format short-verbose \
--rerun-fails=5 \
--packages="./..." \
--junitfile TEST-unit.xml \
-- \
-v \
-coverprofile=coverage.out \
-timeout=30m \
-race
.PHONY: tools
tools:
go mod download
.PHONY: test-tools
test-tools: $(GOBIN)/gotestsum
.PHONY: tidy
tidy:
go mod tidy
.PHONY: pre-commit
pre-commit: generate tidy lint
+29
View File
@@ -0,0 +1,29 @@
package testcontainers
import (
"github.com/testcontainers/testcontainers-go/internal/config"
)
// TestcontainersConfig represents the configuration for Testcontainers
type TestcontainersConfig struct {
Host string `properties:"docker.host,default="` // Deprecated: use Config.Host instead
TLSVerify int `properties:"docker.tls.verify,default=0"` // Deprecated: use Config.TLSVerify instead
CertPath string `properties:"docker.cert.path,default="` // Deprecated: use Config.CertPath instead
RyukDisabled bool `properties:"ryuk.disabled,default=false"` // Deprecated: use Config.RyukDisabled instead
RyukPrivileged bool `properties:"ryuk.container.privileged,default=false"` // Deprecated: use Config.RyukPrivileged instead
Config config.Config
}
// ReadConfig reads from testcontainers properties file, storing the result in a singleton instance
// of the TestcontainersConfig struct
func ReadConfig() TestcontainersConfig {
cfg := config.Read()
return TestcontainersConfig{
Host: cfg.Host,
TLSVerify: cfg.TLSVerify,
CertPath: cfg.CertPath,
RyukDisabled: cfg.RyukDisabled,
RyukPrivileged: cfg.RyukPrivileged,
Config: cfg,
}
}
+561
View File
@@ -0,0 +1,561 @@
package testcontainers
import (
"archive/tar"
"context"
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"strings"
"time"
"github.com/cpuguy83/dockercfg"
"github.com/google/uuid"
"github.com/moby/go-archive"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
"github.com/moby/patternmatcher/ignorefile"
tcexec "github.com/testcontainers/testcontainers-go/exec"
"github.com/testcontainers/testcontainers-go/internal/core"
"github.com/testcontainers/testcontainers-go/log"
"github.com/testcontainers/testcontainers-go/wait"
)
// Deprecated: Use [Container]
//
// DeprecatedContainer shows methods that were supported before, but are now deprecated
type DeprecatedContainer interface {
GetHostEndpoint(ctx context.Context, port string) (string, string, error)
GetIPAddress(ctx context.Context) (string, error)
LivenessCheckPorts(ctx context.Context) (network.PortSet, error)
Terminate(ctx context.Context) error
}
// Container allows getting info about and controlling a single container instance
type Container interface {
GetContainerID() string // get the container id from the provider
Endpoint(context.Context, string) (string, error) // get proto://ip:port string for the lowest exposed port
PortEndpoint(ctx context.Context, port string, proto string) (string, error) // get proto://ip:port string for the given exposed port
Host(context.Context) (string, error) // get host where the container port is exposed
Inspect(context.Context) (*container.InspectResponse, error) // get container info
MappedPort(context.Context, string) (network.Port, error) // get externally mapped port for a container port
Ports(context.Context) (network.PortMap, error) // Deprecated: Use c.Inspect(ctx).NetworkSettings.Ports instead
SessionID() string // get session id
IsRunning() bool // IsRunning returns true if the container is running, false otherwise.
Start(context.Context) error // start the container
Stop(context.Context, *time.Duration) error // stop the container
// Terminate stops and removes the container and its image if it was built and not flagged as kept.
Terminate(ctx context.Context, opts ...TerminateOption) error
Logs(context.Context) (io.ReadCloser, error) // Get logs of the container
FollowOutput(LogConsumer) // Deprecated: it will be removed in the next major release
StartLogProducer(context.Context, ...LogProductionOption) error // Deprecated: Use the ContainerRequest instead
StopLogProducer() error // Deprecated: it will be removed in the next major release
Name(context.Context) (string, error) // Deprecated: Use c.Inspect(ctx).Name instead
State(context.Context) (*container.State, error) // returns container's running state
Networks(context.Context) ([]string, error) // get container networks
NetworkAliases(context.Context) (map[string][]string, error) // get container network aliases for a network
Exec(ctx context.Context, cmd []string, options ...tcexec.ProcessOption) (int, io.Reader, error)
ContainerIP(context.Context) (string, error) // get container ip
ContainerIPs(context.Context) ([]string, error) // get all container IPs
CopyToContainer(ctx context.Context, fileContent []byte, containerFilePath string, fileMode int64) error
CopyDirToContainer(ctx context.Context, hostDirPath string, containerParentPath string, fileMode int64) error
CopyFileToContainer(ctx context.Context, hostFilePath string, containerFilePath string, fileMode int64) error
CopyFileFromContainer(ctx context.Context, filePath string) (io.ReadCloser, error)
GetLogProductionErrorChannel() <-chan error
}
// ImageBuildInfo defines what is needed to build an image
type ImageBuildInfo interface {
BuildOptions() (client.ImageBuildOptions, error) // converts the ImageBuildInfo to a build.ImageBuildOptions
GetContext() (io.Reader, error) // the path to the build context
GetDockerfile() string // the relative path to the Dockerfile, including the file itself
GetRepo() string // get repo label for image
GetTag() string // get tag label for image
BuildLogWriter() io.Writer // for output of build log, use io.Discard to disable the output
ShouldBuildImage() bool // return true if the image needs to be built
GetBuildArgs() map[string]*string // return the environment args used to build the Dockerfile
GetAuthConfigs() map[string]registry.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Return the auth configs to be able to pull from an authenticated docker registry
}
// FromDockerfile represents the parameters needed to build an image from a Dockerfile
// rather than using a pre-built one
type FromDockerfile struct {
Context string // the path to the context of the docker build
ContextArchive io.ReadSeeker // the tar archive file to send to docker that contains the build context
Dockerfile string // the path from the context to the Dockerfile for the image, defaults to "Dockerfile"
Repo string // the repo label for image, defaults to UUID
Tag string // the tag label for image, defaults to UUID
BuildArgs map[string]*string // enable user to pass build args to docker daemon
PrintBuildLog bool // Deprecated: Use BuildLogWriter instead
BuildLogWriter io.Writer // for output of build log, defaults to io.Discard
AuthConfigs map[string]registry.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Enable auth configs to be able to pull from an authenticated docker registry
// KeepImage describes whether DockerContainer.Terminate should not delete the
// container image. Useful for images that are built from a Dockerfile and take a
// long time to build. Keeping the image also Docker to reuse it.
KeepImage bool
// BuildOptionsModifier Modifier for the build options before image build. Use it for
// advanced configurations while building the image. Please consider that the modifier
// is called after the default build options are set.
BuildOptionsModifier func(*client.ImageBuildOptions)
}
type ContainerFile struct {
HostFilePath string // If Reader is present, HostFilePath is ignored
Reader io.Reader // If Reader is present, HostFilePath is ignored
ContainerFilePath string
FileMode int64
}
// validate validates the ContainerFile
func (c *ContainerFile) validate() error {
if c.HostFilePath == "" && c.Reader == nil {
return errors.New("either HostFilePath or Reader must be specified")
}
if c.ContainerFilePath == "" {
return errors.New("ContainerFilePath must be specified")
}
return nil
}
// ContainerRequest represents the parameters used to get a running container
type ContainerRequest struct {
FromDockerfile
HostAccessPorts []int
Image string
ImageSubstitutors []ImageSubstitutor
Entrypoint []string
Env map[string]string
ExposedPorts []string // allow specifying protocol info
Cmd []string
Labels map[string]string
Mounts ContainerMounts
Tmpfs map[string]string
RegistryCred string // Deprecated: Testcontainers will detect registry credentials automatically
WaitingFor wait.Strategy
Name string // for specifying container name
Hostname string // Deprecated: Use [ConfigModifier] instead. S
WorkingDir string // Deprecated: Use [ConfigModifier] instead. Specify the working directory of the container
ExtraHosts []string // Deprecated: Use HostConfigModifier instead
Privileged bool // Deprecated: Use [HostConfigModifier] instead. For starting privileged container
Networks []string // for specifying network names
NetworkAliases map[string][]string // for specifying network aliases
NetworkMode container.NetworkMode // Deprecated: Use HostConfigModifier instead
Resources container.Resources // Deprecated: Use HostConfigModifier instead
Files []ContainerFile // files which will be copied when container starts
User string // Deprecated: Use [ConfigModifier] instead. For specifying uid:gid
SkipReaper bool // Deprecated: The reaper is globally controlled by the .testcontainers.properties file or the TESTCONTAINERS_RYUK_DISABLED environment variable
ReaperImage string // Deprecated: use WithImageName ContainerOption instead. Alternative reaper image
ReaperOptions []ContainerOption // Deprecated: the reaper is configured at the properties level, for an entire test session
AutoRemove bool // Deprecated: Use HostConfigModifier instead. If set to true, the container will be removed from the host when stopped
AlwaysPullImage bool // Always pull image
ImagePlatform string // ImagePlatform describes the platform which the image runs on.
Binds []string // Deprecated: Use HostConfigModifier instead
ShmSize int64 // Deprecated: Use [HostConfigModifier] instead. Amount of memory shared with the host (in bytes)
CapAdd []string // Deprecated: Use HostConfigModifier instead. Add Linux capabilities
CapDrop []string // Deprecated: Use HostConfigModifier instead. Drop Linux capabilities
ConfigModifier func(*container.Config) // Modifier for the config before container creation
HostConfigModifier func(*container.HostConfig) // Modifier for the host config before container creation
EndpointSettingsModifier func(map[string]*network.EndpointSettings) // Modifier for the network settings before container creation
LifecycleHooks []ContainerLifecycleHooks // define hooks to be executed during container lifecycle
LogConsumerCfg *LogConsumerConfig // define the configuration for the log producer and its log consumers to follow the logs
}
// sessionID returns the session ID for the container request.
func (c *ContainerRequest) sessionID() string {
if sessionID := c.Labels[core.LabelSessionID]; sessionID != "" {
return sessionID
}
return core.SessionID()
}
// containerOptions functional options for a container
type containerOptions struct {
ImageName string
RegistryCredentials string // Deprecated: Testcontainers will detect registry credentials automatically
}
// Deprecated: it will be removed in the next major release
// functional option for setting the reaper image
type ContainerOption func(*containerOptions)
// Deprecated: it will be removed in the next major release
// WithImageName sets the reaper image name
func WithImageName(imageName string) ContainerOption {
return func(o *containerOptions) {
o.ImageName = imageName
}
}
// Deprecated: Testcontainers will detect registry credentials automatically, and it will be removed in the next major release
// WithRegistryCredentials sets the reaper registry credentials
func WithRegistryCredentials(registryCredentials string) ContainerOption {
return func(o *containerOptions) {
o.RegistryCredentials = registryCredentials
}
}
// Validate ensures that the ContainerRequest does not have invalid parameters configured to it
// ex. make sure you are not specifying both an image as well as a context
func (c *ContainerRequest) Validate() error {
validationMethods := []func() error{
c.validateContextAndImage,
c.validateContextOrImageIsSpecified,
c.validateMounts,
}
var err error
for _, validationMethod := range validationMethods {
err = validationMethod()
if err != nil {
return err
}
}
return nil
}
// GetContext retrieve the build context for the request
// Must be closed when no longer needed.
func (c *ContainerRequest) GetContext() (io.Reader, error) {
includes := []string{"."}
if c.ContextArchive != nil {
return c.ContextArchive, nil
}
// always pass context as absolute path
abs, err := filepath.Abs(c.Context)
if err != nil {
return nil, fmt.Errorf("error getting absolute path: %w", err)
}
c.Context = abs
dockerIgnoreExists, excluded, err := parseDockerIgnore(abs)
if err != nil {
return nil, err
}
if dockerIgnoreExists {
// only add .dockerignore if it exists
includes = append(includes, ".dockerignore")
}
includes = append(includes, c.GetDockerfile())
buildContext, err := archive.TarWithOptions(
c.Context,
&archive.TarOptions{ExcludePatterns: excluded, IncludeFiles: includes},
)
if err != nil {
return nil, err
}
return buildContext, nil
}
// parseDockerIgnore returns if the file exists, the excluded files and an error if any
func parseDockerIgnore(targetDir string) (bool, []string, error) {
// based on https://github.com/docker/cli/blob/master/cli/command/image/build/dockerignore.go#L14
fileLocation := filepath.Join(targetDir, ".dockerignore")
var excluded []string
exists := false
if f, openErr := os.Open(fileLocation); openErr == nil {
defer f.Close()
exists = true
var err error
excluded, err = ignorefile.ReadAll(f)
if err != nil {
return true, excluded, fmt.Errorf("error reading .dockerignore: %w", err)
}
}
return exists, excluded, nil
}
// GetBuildArgs returns the env args to be used when creating from Dockerfile
func (c *ContainerRequest) GetBuildArgs() map[string]*string {
return c.BuildArgs
}
// GetDockerfile returns the Dockerfile from the ContainerRequest, defaults to "Dockerfile".
// Sets FromDockerfile.Dockerfile to the default if blank.
func (c *ContainerRequest) GetDockerfile() string {
if c.Dockerfile == "" {
c.Dockerfile = "Dockerfile"
}
return c.Dockerfile
}
// GetRepo returns the Repo label for image from the ContainerRequest, defaults to UUID.
// Sets FromDockerfile.Repo to the default value if blank.
func (c *ContainerRequest) GetRepo() string {
if c.Repo == "" {
c.Repo = uuid.NewString()
}
return strings.ToLower(c.Repo)
}
// GetTag returns the Tag label for image from the ContainerRequest, defaults to UUID.
// Sets FromDockerfile.Tag to the default value if blank.
func (c *ContainerRequest) GetTag() string {
if c.Tag == "" {
c.Tag = uuid.NewString()
}
return strings.ToLower(c.Tag)
}
// Deprecated: Testcontainers will detect registry credentials automatically, and it will be removed in the next major release.
// GetAuthConfigs returns the auth configs to be able to pull from an authenticated docker registry.
// Panics if an error occurs.
func (c *ContainerRequest) GetAuthConfigs() map[string]registry.AuthConfig {
auth, err := getAuthConfigsFromDockerfile(c)
if err != nil {
panic(fmt.Sprintf("failed to get auth configs from Dockerfile: %v", err))
}
return auth
}
// dockerFileImages returns the images from the request Dockerfile.
func (c *ContainerRequest) dockerFileImages() ([]string, error) {
if c.ContextArchive == nil {
// Source is a directory, we can read the Dockerfile directly.
images, err := core.ExtractImagesFromDockerfile(filepath.Join(c.Context, c.GetDockerfile()), c.GetBuildArgs())
if err != nil {
return nil, fmt.Errorf("extract images from Dockerfile: %w", err)
}
return images, nil
}
// Source is an archive, we need to read it to get the Dockerfile.
dockerFile := c.GetDockerfile()
tr := tar.NewReader(c.ContextArchive)
for {
hdr, err := tr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("dockerfile %q not found in context archive", dockerFile)
}
return nil, fmt.Errorf("reading tar archive: %w", err)
}
if hdr.Name != dockerFile {
continue
}
images, err := core.ExtractImagesFromReader(tr, c.GetBuildArgs())
if err != nil {
return nil, fmt.Errorf("extract images from Dockerfile: %w", err)
}
// Reset the archive to the beginning.
if _, err := c.ContextArchive.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("seek context archive to start: %w", err)
}
return images, nil
}
}
// getAuthConfigsFromDockerfile returns the auth configs to be able to pull from an authenticated docker registry
func getAuthConfigsFromDockerfile(c *ContainerRequest) (map[string]registry.AuthConfig, error) {
images, err := c.dockerFileImages()
if err != nil {
return nil, fmt.Errorf("docker file images: %w", err)
}
// Get the auth configs once for all images as it can be a time-consuming operation.
configs, err := getDockerAuthConfigs()
if err != nil {
return nil, err
}
authConfigs := map[string]registry.AuthConfig{}
for _, image := range images {
registry, authConfig, err := dockerImageAuth(context.Background(), image, configs)
if err != nil {
if !errors.Is(err, dockercfg.ErrCredentialsNotFound) {
return nil, fmt.Errorf("docker image auth %q: %w", image, err)
}
// Credentials not found no config to add.
continue
}
authConfigs[registry] = authConfig
}
return authConfigs, nil
}
func (c *ContainerRequest) ShouldBuildImage() bool {
return c.Context != "" || c.ContextArchive != nil
}
func (c *ContainerRequest) ShouldKeepBuiltImage() bool {
return c.KeepImage
}
// BuildLogWriter returns the io.Writer for output of log when building a Docker image from
// a Dockerfile. It returns the BuildLogWriter from the ContainerRequest, defaults to io.Discard.
// For backward compatibility, if BuildLogWriter is default and PrintBuildLog is true,
// the function returns os.Stderr.
//
//nolint:staticcheck //FIXME
func (c *ContainerRequest) BuildLogWriter() io.Writer {
if c.FromDockerfile.BuildLogWriter != nil {
return c.FromDockerfile.BuildLogWriter
}
if c.PrintBuildLog {
c.FromDockerfile.BuildLogWriter = os.Stderr
} else {
c.FromDockerfile.BuildLogWriter = io.Discard
}
return c.FromDockerfile.BuildLogWriter
}
// BuildOptions returns the image build options when building a Docker image from a Dockerfile.
// It will apply some defaults and finally call the BuildOptionsModifier from the FromDockerfile struct,
// if set.
func (c *ContainerRequest) BuildOptions() (client.ImageBuildOptions, error) {
buildOptions := client.ImageBuildOptions{
Remove: true,
ForceRemove: true,
}
if c.BuildOptionsModifier != nil {
c.BuildOptionsModifier(&buildOptions)
}
// apply mandatory values after the modifier
buildOptions.BuildArgs = c.GetBuildArgs()
buildOptions.Dockerfile = c.GetDockerfile()
// Make sure the auth configs from the Dockerfile are set right after the user-defined build options.
authsFromDockerfile, err := getAuthConfigsFromDockerfile(c)
if err != nil {
return client.ImageBuildOptions{}, fmt.Errorf("auth configs from Dockerfile: %w", err)
}
if buildOptions.AuthConfigs == nil {
buildOptions.AuthConfigs = map[string]registry.AuthConfig{}
}
maps.Copy(buildOptions.AuthConfigs, authsFromDockerfile)
// make sure the first tag is the one defined in the ContainerRequest
tag := fmt.Sprintf("%s:%s", c.GetRepo(), c.GetTag())
// apply substitutors to the built image
for _, is := range c.ImageSubstitutors {
modifiedTag, err := is.Substitute(tag)
if err != nil {
return client.ImageBuildOptions{}, fmt.Errorf("failed to substitute image %s with %s: %w", tag, is.Description(), err)
}
if modifiedTag != tag {
log.Printf("✍🏼 Replacing image with %s. From: %s to %s\n", is.Description(), tag, modifiedTag)
tag = modifiedTag
}
}
if len(buildOptions.Tags) > 0 {
// prepend the tag
buildOptions.Tags = append([]string{tag}, buildOptions.Tags...)
} else {
buildOptions.Tags = []string{tag}
}
if !c.ShouldKeepBuiltImage() {
dst := GenericLabels()
if err = core.MergeCustomLabels(dst, c.Labels); err != nil {
return client.ImageBuildOptions{}, err
}
if err = core.MergeCustomLabels(dst, buildOptions.Labels); err != nil {
return client.ImageBuildOptions{}, err
}
buildOptions.Labels = dst
}
// Do this as late as possible to ensure we don't leak the context on error/panic.
buildContext, err := c.GetContext()
if err != nil {
return client.ImageBuildOptions{}, err
}
buildOptions.Context = buildContext
return buildOptions, nil
}
func (c *ContainerRequest) validateContextAndImage() error {
if c.Context != "" && c.Image != "" {
return errors.New("you cannot specify both an Image and Context in a ContainerRequest")
}
return nil
}
func (c *ContainerRequest) validateContextOrImageIsSpecified() error {
if c.Context == "" && c.ContextArchive == nil && c.Image == "" {
return errors.New("you must specify either a build context or an image")
}
return nil
}
// validateMounts ensures that the mounts do not have duplicate targets.
// It will check the Mounts and HostConfigModifier.Binds fields.
func (c *ContainerRequest) validateMounts() error {
targets := make(map[string]bool, len(c.Mounts))
for idx := range c.Mounts {
m := c.Mounts[idx]
targetPath := m.Target.Target()
if targets[targetPath] {
return fmt.Errorf("%w: %s", ErrDuplicateMountTarget, targetPath)
}
targets[targetPath] = true
}
if c.HostConfigModifier == nil {
return nil
}
hostConfig := container.HostConfig{}
c.HostConfigModifier(&hostConfig)
if len(hostConfig.Binds) > 0 {
for _, bind := range hostConfig.Binds {
parts := strings.Split(bind, ":")
if len(parts) != 2 && len(parts) != 3 {
return fmt.Errorf("%w: %s", ErrInvalidBindMount, bind)
}
targetPath := parts[1]
if targets[targetPath] {
return fmt.Errorf("%w: %s", ErrDuplicateMountTarget, targetPath)
}
targets[targetPath] = true
}
}
return nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,290 @@
package testcontainers
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strings"
"sync"
"github.com/cpuguy83/dockercfg"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
"github.com/testcontainers/testcontainers-go/internal/core"
)
// defaultRegistryFn is variable overwritten in tests to check for behaviour with different default values.
var defaultRegistryFn = defaultRegistry
// getRegistryCredentials is a variable overwritten in tests to mock the dockercfg.GetRegistryCredentials function.
var getRegistryCredentials = dockercfg.GetRegistryCredentials
// DockerImageAuth returns the auth config for the given Docker image, extracting first its Docker registry.
// Finally, it will use the credential helpers to extract the information from the docker config file
// for that registry, if it exists.
func DockerImageAuth(ctx context.Context, image string) (string, registry.AuthConfig, error) {
configs, err := getDockerAuthConfigs()
if err != nil {
reg := core.ExtractRegistry(image, defaultRegistryFn(ctx))
return reg, registry.AuthConfig{}, err
}
return dockerImageAuth(ctx, image, configs)
}
// dockerImageAuth returns the auth config for the given Docker image.
func dockerImageAuth(ctx context.Context, image string, configs map[string]registry.AuthConfig) (string, registry.AuthConfig, error) {
defaultRegistry := defaultRegistryFn(ctx)
reg := core.ExtractRegistry(image, defaultRegistry)
// Normalize Docker Hub aliases for credential lookup
if strings.EqualFold(reg, "docker.io") ||
strings.EqualFold(reg, "registry.hub.docker.com") ||
strings.EqualFold(reg, "registry-1.docker.io") {
reg = defaultRegistry // This is https://index.docker.io/v1/
}
if cfg, ok := getRegistryAuth(reg, configs); ok {
return reg, cfg, nil
}
return reg, registry.AuthConfig{}, dockercfg.ErrCredentialsNotFound
}
func getRegistryAuth(reg string, cfgs map[string]registry.AuthConfig) (registry.AuthConfig, bool) {
if cfg, ok := cfgs[reg]; ok {
return cfg, true
}
// fallback match using authentication key host
for k, cfg := range cfgs {
keyURL, err := url.Parse(k)
if err != nil {
continue
}
host := keyURL.Host
if keyURL.Scheme == "" {
// url.Parse: The url may be relative (a path, without a host) [...]
host = keyURL.Path
}
if host == reg {
return cfg, true
}
}
return registry.AuthConfig{}, false
}
// defaultRegistry returns the default registry to use when pulling images
// It will use the docker daemon to get the default registry, returning "https://index.docker.io/v1/" if
// it fails to get the information from the daemon
func defaultRegistry(ctx context.Context) string {
apiClient, err := NewDockerClientWithOpts(ctx)
if err != nil {
return core.IndexDockerIO
}
defer apiClient.Close()
info, err := apiClient.Info(ctx, client.InfoOptions{})
if err != nil {
return core.IndexDockerIO
}
return info.Info.IndexServerAddress
}
// authConfigResult is a result looking up auth details for key.
type authConfigResult struct {
key string
cfg registry.AuthConfig
err error
}
// credentialsCache is a cache for registry credentials.
type credentialsCache struct {
entries map[string]credentials
mtx sync.RWMutex
}
// credentials represents the username and password for a registry.
type credentials struct {
username string
password string
}
var creds = &credentialsCache{entries: map[string]credentials{}}
// AuthConfig updates the details in authConfig for the given hostname
// as determined by the details in configKey.
func (c *credentialsCache) AuthConfig(hostname, configKey string, authConfig *registry.AuthConfig) error {
u, p, err := creds.get(hostname, configKey)
if err != nil {
return err
}
if u != "" {
authConfig.Username = u
authConfig.Password = p
} else {
authConfig.IdentityToken = p
}
return nil
}
// get returns the username and password for the given hostname
// as determined by the details in configPath.
// If the username is empty, the password is an identity token.
func (c *credentialsCache) get(hostname, configKey string) (string, string, error) {
key := configKey + ":" + hostname
c.mtx.RLock()
entry, ok := c.entries[key]
c.mtx.RUnlock()
if ok {
return entry.username, entry.password, nil
}
// No entry found, request and cache.
user, password, err := getRegistryCredentials(hostname)
if err != nil {
return "", "", fmt.Errorf("getting credentials for %s: %w", hostname, err)
}
c.mtx.Lock()
c.entries[key] = credentials{username: user, password: password}
c.mtx.Unlock()
return user, password, nil
}
// configKey returns a key to use for caching credentials based on
// the contents of the currently active config.
func configKey(cfg *dockercfg.Config) (string, error) {
h := md5.New()
if err := json.NewEncoder(h).Encode(cfg); err != nil {
return "", fmt.Errorf("encode config: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// getDockerAuthConfigs returns a map with the auth configs from the docker config file
// using the registry as the key
func getDockerAuthConfigs() (map[string]registry.AuthConfig, error) {
cfg, err := getDockerConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return map[string]registry.AuthConfig{}, nil
}
return nil, err
}
key, err := configKey(cfg)
if err != nil {
return nil, err
}
size := len(cfg.AuthConfigs) + len(cfg.CredentialHelpers)
cfgs := make(map[string]registry.AuthConfig, size)
results := make(chan authConfigResult, size)
var wg sync.WaitGroup
wg.Add(size)
for k, v := range cfg.AuthConfigs {
go func(k string, v dockercfg.AuthConfig) {
defer wg.Done()
ac := registry.AuthConfig{
Auth: v.Auth,
IdentityToken: v.IdentityToken,
Password: v.Password,
RegistryToken: v.RegistryToken,
ServerAddress: v.ServerAddress,
Username: v.Username,
}
switch {
case ac.Username == "" && ac.Password == "":
// Look up credentials from the credential store.
if err := creds.AuthConfig(k, key, &ac); err != nil {
results <- authConfigResult{err: err}
return
}
case ac.Auth == "":
// Create auth from the username and password encoding.
ac.Auth = base64.StdEncoding.EncodeToString([]byte(ac.Username + ":" + ac.Password))
}
results <- authConfigResult{key: k, cfg: ac}
}(k, v)
}
// In the case where the auth field in the .docker/conf.json is empty, and the user has
// credential helpers registered the auth comes from there.
for k := range cfg.CredentialHelpers {
go func(k string) {
defer wg.Done()
var ac registry.AuthConfig
if err := creds.AuthConfig(k, key, &ac); err != nil {
results <- authConfigResult{err: err}
return
}
results <- authConfigResult{key: k, cfg: ac}
}(k)
}
go func() {
wg.Wait()
close(results)
}()
var errs []error
for result := range results {
if result.err != nil {
errs = append(errs, result.err)
continue
}
cfgs[result.key] = result.cfg
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
return cfgs, nil
}
// getDockerConfig returns the docker config file. It will internally check, in this particular order:
// 1. the DOCKER_AUTH_CONFIG environment variable, unmarshalling it into a dockercfg.Config
// 2. the DOCKER_CONFIG environment variable, as the path to the config file
// 3. else it will load the default config file, which is ~/.docker/config.json
func getDockerConfig() (*dockercfg.Config, error) {
if env := os.Getenv("DOCKER_AUTH_CONFIG"); env != "" {
var cfg dockercfg.Config
if err := json.Unmarshal([]byte(env), &cfg); err != nil {
return nil, fmt.Errorf("unmarshal DOCKER_AUTH_CONFIG: %w", err)
}
return &cfg, nil
}
cfg, err := dockercfg.LoadDefaultConfig()
if err != nil {
return nil, fmt.Errorf("load default config: %w", err)
}
return &cfg, nil
}
@@ -0,0 +1,146 @@
package testcontainers
import (
"context"
"fmt"
"strings"
"sync"
"github.com/moby/moby/client"
"github.com/testcontainers/testcontainers-go/internal"
"github.com/testcontainers/testcontainers-go/internal/core"
"github.com/testcontainers/testcontainers-go/log"
)
// DockerClient is a wrapper around the docker client that is used by testcontainers-go.
// It implements the SystemAPIClient interface in order to cache the docker info and reuse it.
type DockerClient struct {
*client.Client // client is embedded into our own client
}
var (
// dockerInfo stores the docker info to be reused in the Info method
dockerInfo client.SystemInfoResult
dockerInfoSet bool
dockerInfoLock sync.Mutex
)
// implements SystemAPIClient interface
var _ client.SystemAPIClient = &DockerClient{}
// Events returns a channel to listen to events that happen to the docker daemon.
func (c *DockerClient) Events(ctx context.Context, options client.EventsListOptions) client.EventsResult {
return c.Client.Events(ctx, options)
}
// Info returns information about the docker server. The result of Info is cached
// and reused every time Info is called.
// It will also print out the docker server info, and the resolved Docker paths, to the default logger.
func (c *DockerClient) Info(ctx context.Context, options client.InfoOptions) (client.SystemInfoResult, error) {
dockerInfoLock.Lock()
defer dockerInfoLock.Unlock()
if dockerInfoSet {
return dockerInfo, nil
}
res, err := c.Client.Info(ctx, options)
if err != nil {
return res, fmt.Errorf("failed to retrieve docker info: %w", err)
}
dockerInfo = res
dockerInfoSet = true
infoMessage := `%v - Connected to docker:
Server Version: %v
API Version: %v
Operating System: %v
Total Memory: %v MB%s
Testcontainers for Go Version: v%s
Resolved Docker Host: %s
Resolved Docker Socket Path: %s
Test SessionID: %s
Test ProcessID: %s
`
infoLabels := ""
if len(dockerInfo.Info.Labels) > 0 {
infoLabels = `
Labels:`
var infoLabelsSb72 strings.Builder
for _, lb := range dockerInfo.Info.Labels {
infoLabelsSb72.WriteString("\n " + lb)
}
infoLabels += infoLabelsSb72.String()
}
host, err := core.ExtractDockerHost(ctx)
if err != nil {
return dockerInfo, err
}
log.Printf(infoMessage, packagePath,
dockerInfo.Info.ServerVersion,
c.ClientVersion(),
dockerInfo.Info.OperatingSystem, dockerInfo.Info.MemTotal/1024/1024,
infoLabels,
internal.Version,
host,
core.MustExtractDockerSocket(ctx),
core.SessionID(),
core.ProcessID(),
)
return dockerInfo, nil
}
// RegistryLogin logs into a Docker registry.
func (c *DockerClient) RegistryLogin(ctx context.Context, options client.RegistryLoginOptions) (client.RegistryLoginResult, error) {
return c.Client.RegistryLogin(ctx, options)
}
// DiskUsage returns the disk usage of all images.
func (c *DockerClient) DiskUsage(ctx context.Context, options client.DiskUsageOptions) (client.DiskUsageResult, error) {
return c.Client.DiskUsage(ctx, options)
}
// Ping pings the docker server.
func (c *DockerClient) Ping(ctx context.Context, options client.PingOptions) (client.PingResult, error) {
return c.Client.Ping(ctx, options)
}
// Deprecated: Use NewDockerClientWithOpts instead.
func NewDockerClient() (*client.Client, error) {
cli, err := NewDockerClientWithOpts(context.Background())
if err != nil {
return nil, err
}
return cli.Client, nil
}
func NewDockerClientWithOpts(ctx context.Context, opt ...client.Opt) (*DockerClient, error) {
dockerClient, err := core.NewClient(ctx, opt...)
if err != nil {
return nil, err
}
tcClient := DockerClient{
Client: dockerClient,
}
if _, err = tcClient.Info(ctx, client.InfoOptions{}); err != nil {
// Fallback to environment, including the original options
if len(opt) == 0 {
opt = []client.Opt{client.FromEnv}
}
apiClient, err := client.New(opt...)
if err != nil {
return nil, err
}
tcClient.Client = apiClient
}
defer tcClient.Close()
return &tcClient, nil
}
@@ -0,0 +1,194 @@
package testcontainers
import (
"errors"
"path/filepath"
"github.com/moby/moby/api/types/mount"
"github.com/testcontainers/testcontainers-go/log"
)
var mountTypeMapping = map[MountType]mount.Type{
MountTypeBind: mount.TypeBind, // Deprecated, it will be removed in a future release
MountTypeVolume: mount.TypeVolume,
MountTypeTmpfs: mount.TypeTmpfs,
MountTypePipe: mount.TypeNamedPipe,
MountTypeImage: mount.TypeImage,
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
// BindMounter can optionally be implemented by mount sources
// to support advanced scenarios based on mount.BindOptions
type BindMounter interface {
GetBindOptions() *mount.BindOptions
}
// VolumeMounter can optionally be implemented by mount sources
// to support advanced scenarios based on mount.VolumeOptions
type VolumeMounter interface {
GetVolumeOptions() *mount.VolumeOptions
}
// TmpfsMounter can optionally be implemented by mount sources
// to support advanced scenarios based on mount.TmpfsOptions
type TmpfsMounter interface {
GetTmpfsOptions() *mount.TmpfsOptions
}
// ImageMounter can optionally be implemented by mount sources
// to support advanced scenarios based on mount.ImageOptions
type ImageMounter interface {
ImageOptions() *mount.ImageOptions
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
type DockerBindMountSource struct {
*mount.BindOptions
// HostPath is the path mounted into the container
// the same host path might be mounted to multiple locations within a single container
HostPath string
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
func (s DockerBindMountSource) Source() string {
return s.HostPath
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
func (DockerBindMountSource) Type() MountType {
return MountTypeBind
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
func (s DockerBindMountSource) GetBindOptions() *mount.BindOptions {
return s.BindOptions
}
type DockerVolumeMountSource struct {
*mount.VolumeOptions
// Name refers to the name of the volume to be mounted
// the same volume might be mounted to multiple locations within a single container
Name string
}
func (s DockerVolumeMountSource) Source() string {
return s.Name
}
func (DockerVolumeMountSource) Type() MountType {
return MountTypeVolume
}
func (s DockerVolumeMountSource) GetVolumeOptions() *mount.VolumeOptions {
return s.VolumeOptions
}
type DockerTmpfsMountSource struct {
GenericTmpfsMountSource
*mount.TmpfsOptions
}
func (s DockerTmpfsMountSource) GetTmpfsOptions() *mount.TmpfsOptions {
return s.TmpfsOptions
}
// DockerImageMountSource is a mount source for an image
type DockerImageMountSource struct {
// imageName is the image name
imageName string
// subpath is the subpath to mount the image into
subpath string
}
// NewDockerImageMountSource creates a new DockerImageMountSource
func NewDockerImageMountSource(imageName string, subpath string) DockerImageMountSource {
return DockerImageMountSource{
imageName: imageName,
subpath: subpath,
}
}
// Validate validates the source of the mount, ensuring that the subpath is a relative path
func (s DockerImageMountSource) Validate() error {
if !filepath.IsLocal(s.subpath) {
return errors.New("image mount source must be a local path")
}
return nil
}
// ImageOptions returns the image options for the image mount
func (s DockerImageMountSource) ImageOptions() *mount.ImageOptions {
return &mount.ImageOptions{
Subpath: s.subpath,
}
}
// Source returns the image name for the image mount
func (s DockerImageMountSource) Source() string {
return s.imageName
}
// Type returns the mount type for the image mount
func (s DockerImageMountSource) Type() MountType {
return MountTypeImage
}
// PrepareMounts maps the given []ContainerMount to the corresponding
// []mount.Mount for further processing
func (m ContainerMounts) PrepareMounts() []mount.Mount {
return mapToDockerMounts(m)
}
// mapToDockerMounts maps the given []ContainerMount to the corresponding
// []mount.Mount for further processing
func mapToDockerMounts(containerMounts ContainerMounts) []mount.Mount {
mounts := make([]mount.Mount, 0, len(containerMounts))
for idx := range containerMounts {
m := containerMounts[idx]
var mountType mount.Type
if mt, ok := mountTypeMapping[m.Source.Type()]; ok {
mountType = mt
} else {
continue
}
containerMount := mount.Mount{
Type: mountType,
Source: m.Source.Source(),
ReadOnly: m.ReadOnly,
Target: m.Target.Target(),
}
switch typedMounter := m.Source.(type) {
case VolumeMounter:
containerMount.VolumeOptions = typedMounter.GetVolumeOptions()
case TmpfsMounter:
containerMount.TmpfsOptions = typedMounter.GetTmpfsOptions()
case ImageMounter:
containerMount.ImageOptions = typedMounter.ImageOptions()
case BindMounter:
log.Printf("Mount type %s is not supported by Testcontainers for Go", m.Source.Type())
default:
// The provided source type has no custom options
}
if mountType == mount.TypeVolume {
if containerMount.VolumeOptions == nil {
containerMount.VolumeOptions = &mount.VolumeOptions{
Labels: make(map[string]string),
}
}
AddGenericLabels(containerMount.VolumeOptions.Labels)
}
mounts = append(mounts, containerMount)
}
return mounts
}
@@ -0,0 +1,128 @@
package exec
import (
"bytes"
"fmt"
"io"
"sync"
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/client"
)
// ProcessOptions defines options applicable to the reader processor
type ProcessOptions struct {
ExecConfig client.ExecCreateOptions
Reader io.Reader
}
// NewProcessOptions returns a new ProcessOptions instance
// with the given command and default options:
// - detach: false
// - attach stdout: true
// - attach stderr: true
func NewProcessOptions(cmd []string) *ProcessOptions {
return &ProcessOptions{
ExecConfig: client.ExecCreateOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
},
}
}
// ProcessOption defines a common interface to modify the reader processor
// These options can be passed to the Exec function in a variadic way to customize the returned Reader instance
type ProcessOption interface {
Apply(opts *ProcessOptions)
}
type ProcessOptionFunc func(opts *ProcessOptions)
func (fn ProcessOptionFunc) Apply(opts *ProcessOptions) {
fn(opts)
}
func WithUser(user string) ProcessOption {
return ProcessOptionFunc(func(opts *ProcessOptions) {
opts.ExecConfig.User = user
})
}
func WithWorkingDir(workingDir string) ProcessOption {
return ProcessOptionFunc(func(opts *ProcessOptions) {
opts.ExecConfig.WorkingDir = workingDir
})
}
func WithEnv(env []string) ProcessOption {
return ProcessOptionFunc(func(opts *ProcessOptions) {
opts.ExecConfig.Env = env
})
}
// safeBuffer is a goroutine safe buffer.
type safeBuffer struct {
mtx sync.Mutex
buf bytes.Buffer
err error
}
// Error sets an error for the next read.
func (sb *safeBuffer) Error(err error) {
sb.mtx.Lock()
defer sb.mtx.Unlock()
sb.err = err
}
// Write writes p to the buffer.
// It is safe for concurrent use by multiple goroutines.
func (sb *safeBuffer) Write(p []byte) (n int, err error) {
sb.mtx.Lock()
defer sb.mtx.Unlock()
return sb.buf.Write(p)
}
// Read reads up to len(p) bytes into p from the buffer.
// It is safe for concurrent use by multiple goroutines.
func (sb *safeBuffer) Read(p []byte) (n int, err error) {
sb.mtx.Lock()
defer sb.mtx.Unlock()
if sb.err != nil {
return 0, sb.err
}
return sb.buf.Read(p)
}
// Multiplexed returns a [ProcessOption] that configures the command execution
// to combine stdout and stderr into a single stream without Docker's multiplexing headers.
func Multiplexed() ProcessOption {
return ProcessOptionFunc(func(opts *ProcessOptions) {
// returning fast to bypass those options with a nil reader,
// which could be the case when other options are used
// to configure the exec creation.
if opts.Reader == nil {
return
}
done := make(chan struct{})
var outBuff safeBuffer
var errBuff safeBuffer
go func() {
defer close(done)
if _, err := stdcopy.StdCopy(&outBuff, &errBuff, opts.Reader); err != nil {
outBuff.Error(fmt.Errorf("copying output: %w", err))
return
}
}()
<-done
opts.Reader = io.MultiReader(&outBuff, &errBuff)
})
}
+143
View File
@@ -0,0 +1,143 @@
package testcontainers
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/testcontainers/testcontainers-go/log"
)
func isDir(path string) (bool, error) {
file, err := os.Open(path)
if err != nil {
return false, err
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return false, err
}
if fileInfo.IsDir() {
return true, nil
}
return false, nil
}
// tarDir compress a directory using tar + gzip algorithms
func tarDir(src string, fileMode int64) (*bytes.Buffer, error) {
// always pass src as absolute path
abs, err := filepath.Abs(src)
if err != nil {
return &bytes.Buffer{}, fmt.Errorf("error getting absolute path: %w", err)
}
src = abs
buffer := &bytes.Buffer{}
log.Printf(">> creating TAR file from directory: %s\n", src)
// tar > gzip > buffer
zr := gzip.NewWriter(buffer)
tw := tar.NewWriter(zr)
_, baseDir := filepath.Split(src)
// keep the path relative to the parent directory
index := strings.LastIndex(src, baseDir)
// walk through every file in the folder
err = filepath.Walk(src, func(file string, fi os.FileInfo, errFn error) error {
if errFn != nil {
return fmt.Errorf("error traversing the file system: %w", errFn)
}
// if a symlink, skip file
if fi.Mode().Type() == os.ModeSymlink {
log.Printf(">> skipping symlink: %s\n", file)
return nil
}
// generate tar header
header, err := tar.FileInfoHeader(fi, file)
if err != nil {
return fmt.Errorf("error getting file info header: %w", err)
}
// see https://pkg.go.dev/archive/tar#FileInfoHeader:
// Since fs.FileInfo's Name method only returns the base name of the file it describes,
// it may be necessary to modify Header.Name to provide the full path name of the file.
header.Name = filepath.ToSlash(file[index:])
header.Mode = fileMode
// write header
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("error writing header: %w", err)
}
// if not a dir, write file content
if !fi.IsDir() {
data, err := os.Open(file)
if err != nil {
return fmt.Errorf("error opening file: %w", err)
}
defer data.Close()
if _, err := io.Copy(tw, data); err != nil {
return fmt.Errorf("error compressing file: %w", err)
}
}
return nil
})
if err != nil {
return buffer, err
}
// produce tar
if err := tw.Close(); err != nil {
return buffer, fmt.Errorf("error closing tar file: %w", err)
}
// produce gzip
if err := zr.Close(); err != nil {
return buffer, fmt.Errorf("error closing gzip file: %w", err)
}
return buffer, nil
}
// tarFile compress a single file using tar + gzip algorithms
func tarFile(basePath string, fileContent func(tw io.Writer) error, fileContentSize int64, fileMode int64) (*bytes.Buffer, error) {
buffer := &bytes.Buffer{}
zr := gzip.NewWriter(buffer)
tw := tar.NewWriter(zr)
hdr := &tar.Header{
Name: basePath,
Mode: fileMode,
Size: fileContentSize,
}
if err := tw.WriteHeader(hdr); err != nil {
return buffer, err
}
if err := fileContent(tw); err != nil {
return buffer, err
}
// produce tar
if err := tw.Close(); err != nil {
return buffer, fmt.Errorf("error closing tar file: %w", err)
}
// produce gzip
if err := zr.Close(); err != nil {
return buffer, fmt.Errorf("error closing gzip file: %w", err)
}
return buffer, nil
}
@@ -0,0 +1,4 @@
package testcontainers
//go:generate mockery
//go:generate gci write -s standard -s default -s prefix(github.com/testcontainers) .
+149
View File
@@ -0,0 +1,149 @@
package testcontainers
import (
"context"
"errors"
"fmt"
"maps"
"strings"
"sync"
"github.com/testcontainers/testcontainers-go/internal/core"
"github.com/testcontainers/testcontainers-go/log"
)
var (
reuseContainerMx sync.Mutex
ErrReuseEmptyName = errors.New("with reuse option a container name mustn't be empty")
)
// GenericContainerRequest represents parameters to a generic container
type GenericContainerRequest struct {
ContainerRequest // embedded request for provider
Started bool // whether to auto-start the container
ProviderType ProviderType // which provider to use, Docker if empty
Logger log.Logger // provide a container specific Logging - use default global logger if empty
Reuse bool // reuse an existing container if it exists or create a new one. a container name mustn't be empty
}
// Deprecated: will be removed in the future.
// GenericNetworkRequest represents parameters to a generic network
type GenericNetworkRequest struct {
NetworkRequest // embedded request for provider
ProviderType ProviderType // which provider to use, Docker if empty
}
// Deprecated: use network.New instead
// GenericNetwork creates a generic network with parameters
func GenericNetwork(ctx context.Context, req GenericNetworkRequest) (Network, error) {
provider, err := req.ProviderType.GetProvider()
if err != nil {
return nil, err
}
network, err := provider.CreateNetwork(ctx, req.NetworkRequest)
if err != nil {
return nil, fmt.Errorf("%w: failed to create network", err)
}
return network, nil
}
// GenericContainer creates a generic container with parameters
func GenericContainer(ctx context.Context, req GenericContainerRequest) (Container, error) {
if req.Reuse && req.Name == "" {
return nil, ErrReuseEmptyName
}
logger := req.Logger
if logger == nil {
// Ensure there is always a non-nil logger by default
logger = log.Default()
}
provider, err := req.ProviderType.GetProvider(WithLogger(logger))
if err != nil {
return nil, fmt.Errorf("get provider: %w", err)
}
defer provider.Close()
var c Container
if req.Reuse {
// we must protect the reusability of the container in the case it's invoked
// in a parallel execution, via ParallelContainers or t.Parallel()
reuseContainerMx.Lock()
defer reuseContainerMx.Unlock()
c, err = provider.ReuseOrCreateContainer(ctx, req.ContainerRequest)
} else {
c, err = provider.CreateContainer(ctx, req.ContainerRequest)
}
if err != nil {
// At this point `c` might not be nil. Give the caller an opportunity to call Destroy on the container.
// TODO: Remove this debugging.
if strings.Contains(err.Error(), "toomanyrequests") {
// Debugging information for rate limiting.
cfg, err := getDockerConfig()
if err == nil {
fmt.Printf("XXX: too many requests: %+v", cfg)
}
}
return c, fmt.Errorf("create container: %w", err)
}
if req.Started && !c.IsRunning() {
if err := c.Start(ctx); err != nil {
return c, fmt.Errorf("start container: %w", err)
}
}
return c, nil
}
// GenericProvider represents an abstraction for container and network providers
type GenericProvider interface {
ContainerProvider
NetworkProvider
ImageProvider
}
// GenericLabels returns a map of labels that can be used to identify resources
// created by this library. This includes the standard LabelSessionID if the
// reaper is enabled, otherwise this is excluded to prevent resources being
// incorrectly reaped.
func GenericLabels() map[string]string {
return core.DefaultLabels(core.SessionID())
}
// AddGenericLabels adds the generic labels to target.
func AddGenericLabels(target map[string]string) {
maps.Copy(target, GenericLabels())
}
// Run is a convenience function that creates a new container and starts it.
// It calls the GenericContainer function and returns a concrete DockerContainer type.
func Run(ctx context.Context, img string, opts ...ContainerCustomizer) (*DockerContainer, error) {
req := ContainerRequest{
Image: img,
}
genericContainerReq := GenericContainerRequest{
ContainerRequest: req,
Started: true,
}
for _, opt := range opts {
if err := opt.Customize(&genericContainerReq); err != nil {
return nil, fmt.Errorf("customize: %w", err)
}
}
ctr, err := GenericContainer(ctx, genericContainerReq)
var c *DockerContainer
if ctr != nil {
c = ctr.(*DockerContainer)
}
if err != nil {
return c, fmt.Errorf("generic container: %w", err)
}
return c, nil
}
+27
View File
@@ -0,0 +1,27 @@
package testcontainers
import (
"context"
"github.com/moby/moby/client"
)
// ImageInfo represents summary information of an image
type ImageInfo struct {
ID string
Name string
}
type saveImageOptions struct {
dockerSaveOpts []client.ImageSaveOption
}
type SaveImageOption func(*saveImageOptions) error
// ImageProvider allows manipulating images
type ImageProvider interface {
ListImages(context.Context) ([]ImageInfo, error)
SaveImages(context.Context, string, ...string) error
SaveImagesWithOpts(context.Context, string, []string, ...SaveImageOption) error
PullImage(context.Context, string) error
}
@@ -0,0 +1,185 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"github.com/magiconair/properties"
)
const ReaperDefaultImage = "testcontainers/ryuk:0.13.0"
var (
tcConfig Config
tcConfigOnce = new(sync.Once)
)
// testcontainersConfig {
// Config represents the configuration for Testcontainers.
// User values are read from ~/.testcontainers.properties file which can be overridden
// using the specified environment variables. For more information, see [Custom Configuration].
//
// The Ryuk prefixed fields controls the [Garbage Collector] feature, which ensures that
// resources are cleaned up after the test execution.
//
// [Garbage Collector]: https://golang.testcontainers.org/features/garbage_collector/
// [Custom Configuration]: https://golang.testcontainers.org/features/configuration/
type Config struct {
// Host is the address of the Docker daemon.
//
// Environment variable: DOCKER_HOST
Host string `properties:"docker.host,default="`
// TLSVerify is a flag to enable or disable TLS verification when connecting to a Docker daemon.
//
// Environment variable: DOCKER_TLS_VERIFY
TLSVerify int `properties:"docker.tls.verify,default=0"`
// CertPath is the path to the directory containing the Docker certificates.
// This is used when connecting to a Docker daemon over TLS.
//
// Environment variable: DOCKER_CERT_PATH
CertPath string `properties:"docker.cert.path,default="`
// HubImageNamePrefix is the prefix used for the images pulled from the Docker Hub.
// This is useful when running tests in environments with restricted internet access.
//
// Environment variable: TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX
HubImageNamePrefix string `properties:"hub.image.name.prefix,default="`
// RyukDisabled is a flag to enable or disable the Garbage Collector.
// Setting this to true will prevent testcontainers from automatically cleaning up
// resources, which is particularly important in tests which timeout as they
// don't run test clean up.
//
// Environment variable: TESTCONTAINERS_RYUK_DISABLED
RyukDisabled bool `properties:"ryuk.disabled,default=false"`
// RyukPrivileged is a flag to enable or disable the privileged mode for the Garbage Collector container.
// Setting this to true will run the Garbage Collector container in privileged mode.
//
// Environment variable: TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED
RyukPrivileged bool `properties:"ryuk.container.privileged,default=false"`
// RyukReconnectionTimeout is the time to wait before attempting to reconnect to the Garbage Collector container.
//
// Environment variable: RYUK_RECONNECTION_TIMEOUT
RyukReconnectionTimeout time.Duration `properties:"ryuk.reconnection.timeout,default=10s"`
// RyukConnectionTimeout is the time to wait before timing out when connecting to the Garbage Collector container.
//
// Environment variable: RYUK_CONNECTION_TIMEOUT
RyukConnectionTimeout time.Duration `properties:"ryuk.connection.timeout,default=1m"`
// RyukVerbose is a flag to enable or disable verbose logging for the Garbage Collector.
//
// Environment variable: RYUK_VERBOSE
RyukVerbose bool `properties:"ryuk.verbose,default=false"`
// TestcontainersHost is the address of the Testcontainers host.
//
// Environment variable: TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE
TestcontainersHost string `properties:"tc.host,default="`
}
// }
// Read reads from testcontainers properties file, if it exists
// it is possible that certain values get overridden when set as environment variables
func Read() Config {
tcConfigOnce.Do(func() {
tcConfig = read()
})
return tcConfig
}
// Reset resets the singleton instance of the Config struct,
// allowing to read the configuration again.
// Handy for testing, so do not use it in production code
// This function is not thread-safe
func Reset() {
tcConfigOnce = new(sync.Once)
}
func read() Config {
config := Config{}
applyEnvironmentConfiguration := func(config Config) Config {
ryukDisabledEnv := os.Getenv("TESTCONTAINERS_RYUK_DISABLED")
if parseBool(ryukDisabledEnv) {
config.RyukDisabled = ryukDisabledEnv == "true"
}
hubImageNamePrefix := os.Getenv("TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX")
if hubImageNamePrefix != "" {
config.HubImageNamePrefix = hubImageNamePrefix
}
ryukPrivilegedEnv := os.Getenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED")
if parseBool(ryukPrivilegedEnv) {
config.RyukPrivileged = ryukPrivilegedEnv == "true"
}
ryukVerboseEnv := readTestcontainersEnv("RYUK_VERBOSE")
if parseBool(ryukVerboseEnv) {
config.RyukVerbose = ryukVerboseEnv == "true"
}
ryukReconnectionTimeoutEnv := readTestcontainersEnv("RYUK_RECONNECTION_TIMEOUT")
if timeout, err := time.ParseDuration(ryukReconnectionTimeoutEnv); err == nil {
config.RyukReconnectionTimeout = timeout
}
ryukConnectionTimeoutEnv := readTestcontainersEnv("RYUK_CONNECTION_TIMEOUT")
if timeout, err := time.ParseDuration(ryukConnectionTimeoutEnv); err == nil {
config.RyukConnectionTimeout = timeout
}
return config
}
home, err := os.UserHomeDir()
if err != nil {
return applyEnvironmentConfiguration(config)
}
tcProp := filepath.Join(home, ".testcontainers.properties")
// init from a file
properties, err := properties.LoadFile(tcProp, properties.UTF8)
if err != nil {
return applyEnvironmentConfiguration(config)
}
if err := properties.Decode(&config); err != nil {
fmt.Printf("invalid testcontainers properties file, returning an empty Testcontainers configuration: %v\n", err)
return applyEnvironmentConfiguration(config)
}
return applyEnvironmentConfiguration(config)
}
func parseBool(input string) bool {
_, err := strconv.ParseBool(input)
return err == nil
}
// readTestcontainersEnv reads the environment variable with the given name.
// It checks for the environment variable with the given name first, and then
// checks for the environment variable with the given name prefixed with "TESTCONTAINERS_".
func readTestcontainersEnv(envVar string) string {
value := os.Getenv(envVar)
if value != "" {
return value
}
// TODO: remove this prefix after the next major release
const prefix string = "TESTCONTAINERS_"
return os.Getenv(prefix + envVar)
}
@@ -0,0 +1,106 @@
package core
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"github.com/google/uuid"
"github.com/shirou/gopsutil/v4/process"
)
// sessionID returns a unique session ID for the current test session. Because each Go package
// will be run in a separate process, we need a way to identify the current test session.
// By test session, we mean:
// - a single "go test" invocation (including flags)
// - a single "go test ./..." invocation (including flags)
// - the execution of a single test or a set of tests using the IDE
//
// As a consequence, with the sole goal of aggregating test execution across multiple
// packages, this function will use the parent process ID (pid) of the current process
// and its creation date, to use it to generate a unique session ID. We are using the parent pid because
// the current process will be a child process of:
// - the process that is running the tests, e.g.: "go test";
// - the process that is running the application in development mode, e.g. "go run main.go -tags dev";
// - the process that is running the tests in the IDE, e.g.: "go test ./...".
//
// Finally, we will hash the combination of the "testcontainers-go:" string with the parent pid
// and the creation date of that parent process to generate a unique session ID.
//
// This sessionID will be used to:
// - identify the test session, aggregating the test execution of multiple packages in the same test session.
// - tag the containers created by testcontainers-go, adding a label to the container with the session ID.
var sessionID string
// projectPath returns the current working directory of the parent test process running Testcontainers for Go.
// If it's not possible to get that directory, the library will use the current working directory. If again
// it's not possible to get the current working directory, the library will use a temporary directory.
var projectPath string
// processID returns a unique ID for the current test process. Because each Go package will be run in a separate process,
// we need a way to identify the current test process, in the form of a UUID
var processID string
const sessionIDPlaceholder = "testcontainers-go:%d:%d"
func init() {
processID = uuid.New().String()
parentPid := os.Getppid()
var createTime int64
fallbackCwd, err := os.Getwd()
if err != nil {
// very unlikely to fail, but if it does, we will use a temp dir
fallbackCwd = os.TempDir()
}
processes, err := process.Processes()
if err != nil {
sessionID = uuid.New().String()
projectPath = fallbackCwd
return
}
for _, p := range processes {
if int(p.Pid) != parentPid {
continue
}
cwd, err := p.Cwd()
if err != nil {
cwd = fallbackCwd
}
projectPath = cwd
t, err := p.CreateTime()
if err != nil {
sessionID = uuid.New().String()
return
}
createTime = t
break
}
hasher := sha256.New()
_, err = fmt.Fprintf(hasher, sessionIDPlaceholder, parentPid, createTime)
if err != nil {
sessionID = uuid.New().String()
return
}
sessionID = hex.EncodeToString(hasher.Sum(nil))
}
func ProcessID() string {
return processID
}
func ProjectPath() string {
return projectPath
}
func SessionID() string {
return sessionID
}
@@ -0,0 +1,53 @@
package core
import (
"context"
"path/filepath"
"github.com/moby/moby/client"
"github.com/testcontainers/testcontainers-go/internal"
"github.com/testcontainers/testcontainers-go/internal/config"
)
// NewClient returns a new docker client extracting the docker host from the different alternatives
func NewClient(ctx context.Context, ops ...client.Opt) (*client.Client, error) {
dockerHost, err := ExtractDockerHost(ctx)
if err != nil {
return nil, err
}
tcConfig := config.Read()
opts := []client.Opt{client.FromEnv}
if dockerHost != "" {
opts = append(opts, client.WithHost(dockerHost))
// for further information, read https://docs.docker.com/engine/security/protect-access/
if tcConfig.TLSVerify == 1 {
cacertPath := filepath.Join(tcConfig.CertPath, "ca.pem")
certPath := filepath.Join(tcConfig.CertPath, "cert.pem")
keyPath := filepath.Join(tcConfig.CertPath, "key.pem")
opts = append(opts, client.WithTLSClientConfig(cacertPath, certPath, keyPath))
}
}
opts = append(opts, client.WithHTTPHeaders(
map[string]string{
"x-tc-pp": ProjectPath(),
"x-tc-sid": SessionID(),
"User-Agent": "tc-go/" + internal.Version,
}),
)
// passed options have priority over the default ones
opts = append(opts, ops...)
cli, err := client.New(opts...)
if err != nil {
return nil, err
}
return cli, nil
}
@@ -0,0 +1,334 @@
package core
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"github.com/moby/moby/client"
"github.com/testcontainers/testcontainers-go/internal/config"
)
type dockerHostContext string
var DockerHostContextKey = dockerHostContext("docker_host")
var (
ErrDockerHostNotSet = errors.New("DOCKER_HOST is not set")
ErrDockerSocketOverrideNotSet = errors.New("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is not set")
ErrDockerSocketNotSetInContext = errors.New("socket not set in context")
ErrDockerSocketNotSetInProperties = errors.New("socket not set in ~/.testcontainers.properties")
ErrNoUnixSchema = errors.New("URL schema is not unix")
ErrSocketNotFound = errors.New("socket not found")
ErrSocketNotFoundInPath = errors.New("docker socket not found in " + DockerSocketPath)
// ErrTestcontainersHostNotSetInProperties this error is specific to Testcontainers
ErrTestcontainersHostNotSetInProperties = errors.New("tc.host not set in ~/.testcontainers.properties")
)
var (
dockerHostCache string
dockerHostErrCache error
dockerHostOnce sync.Once
)
var (
dockerSocketPathCache string
dockerSocketPathOnce sync.Once
)
// deprecated
// see https://github.com/testcontainers/testcontainers-java/blob/main/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java#L46
func DefaultGatewayIP() (string, error) {
// see https://github.com/testcontainers/testcontainers-java/blob/3ad8d80e2484864e554744a4800a81f6b7982168/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java#L27
cmd := exec.Command("sh", "-c", "ip route|awk '/default/ { print $3 }'")
stdout, err := cmd.Output()
if err != nil {
return "", errors.New("failed to detect docker host")
}
ip := strings.TrimSpace(string(stdout))
if len(ip) == 0 {
return "", errors.New("failed to parse default gateway IP")
}
return ip, nil
}
// dockerHostCheck Use a vanilla Docker client to check if the Docker host is reachable.
// It will avoid recursive calls to this function.
var dockerHostCheck = func(ctx context.Context, host string) error {
cli, err := client.New(client.FromEnv, client.WithHost(host))
if err != nil {
return fmt.Errorf("new client: %w", err)
}
defer cli.Close()
_, err = cli.Info(ctx, client.InfoOptions{})
if err != nil {
return fmt.Errorf("docker info: %w", err)
}
return nil
}
// MustExtractDockerHost Extracts the docker host from the different alternatives, caching the result to avoid unnecessary
// calculations. Use this function to get the actual Docker host. This function does not consider Windows containers at the moment.
// The possible alternatives are:
//
// 1. Docker host from the "tc.host" property in the ~/.testcontainers.properties file.
// 2. DOCKER_HOST environment variable.
// 3. Docker host from context.
// 4. Docker host from the default docker socket path, without the unix schema.
// 5. Docker host from the "docker.host" property in the ~/.testcontainers.properties file.
// 6. Rootless docker socket path.
// 7. Else, because the Docker host is not set, it panics.
func MustExtractDockerHost(ctx context.Context) string {
host, err := ExtractDockerHost(ctx)
if err != nil {
panic(err)
}
return host
}
func ExtractDockerHost(ctx context.Context) (string, error) {
dockerHostOnce.Do(func() {
dockerHostCache, dockerHostErrCache = extractDockerHost(ctx)
})
return dockerHostCache, dockerHostErrCache
}
// MustExtractDockerSocket Extracts the docker socket from the different alternatives, removing the socket schema and
// caching the result to avoid unnecessary calculations. Use this function to get the docker socket path,
// not the host (e.g. mounting the socket in a container). This function does not consider Windows containers at the moment.
// The possible alternatives are:
//
// 1. Docker host from the "tc.host" property in the ~/.testcontainers.properties file.
// 2. The TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE environment variable.
// 3. Using a Docker client, check if the Info().OperatingSystem is "Docker Desktop" and return the default docker socket path for rootless docker.
// 4. Else, Get the current Docker Host from the existing strategies: see MustExtractDockerHost.
// 5. If the socket contains the unix schema, the schema is removed (e.g. unix:///var/run/docker.sock -> /var/run/docker.sock)
// 6. Else, the default location of the docker socket is used (/var/run/docker.sock)
//
// It panics if a Docker client cannot be created, or the Docker host cannot be discovered.
func MustExtractDockerSocket(ctx context.Context) string {
dockerSocketPathOnce.Do(func() {
dockerSocketPathCache = extractDockerSocket(ctx)
})
return dockerSocketPathCache
}
// extractDockerHost Extracts the docker host from the different alternatives, without caching the result.
// This internal method is handy for testing purposes.
func extractDockerHost(ctx context.Context) (string, error) {
dockerHostFns := []func(context.Context) (string, error){
testcontainersHostFromProperties,
dockerHostFromEnv,
dockerHostFromContext,
dockerSocketPath,
dockerHostFromProperties,
rootlessDockerSocketPath,
}
var errs []error
for _, dockerHostFn := range dockerHostFns {
dockerHost, err := dockerHostFn(ctx)
if err != nil {
if !isHostNotSet(err) {
errs = append(errs, err)
}
continue
}
if err = dockerHostCheck(ctx, dockerHost); err != nil {
errs = append(errs, fmt.Errorf("check host %q: %w", dockerHost, err))
continue
}
return dockerHost, nil
}
if len(errs) > 0 {
return "", errors.Join(errs...)
}
return "", ErrSocketNotFound
}
// extractDockerSocket Extracts the docker socket from the different alternatives, without caching the result.
// It will internally use the default Docker client, calling the internal method extractDockerSocketFromClient with it.
// This internal method is handy for testing purposes.
// It panics if a Docker client cannot be created, or the Docker host is not discovered.
func extractDockerSocket(ctx context.Context) string {
cli, err := NewClient(ctx)
if err != nil {
panic(err) // a Docker client is required to get the Docker info
}
defer cli.Close()
return extractDockerSocketFromClient(ctx, cli)
}
// extractDockerSocketFromClient Extracts the docker socket from the different alternatives, without caching the result,
// and receiving an instance of the Docker API client interface.
// This internal method is handy for testing purposes, passing a mock type simulating the desired behaviour.
// It panics if the Docker Info call errors, or the Docker host is not discovered.
func extractDockerSocketFromClient(ctx context.Context, cli client.APIClient) string {
// check that the socket is not a tcp or unix socket
checkDockerSocketFn := func(socket string) string {
// this use case will cover the case when the docker host is a tcp socket
if strings.HasPrefix(socket, TCPSchema) {
return DockerSocketPath
}
if strings.HasPrefix(socket, DockerSocketSchema) {
return strings.Replace(socket, DockerSocketSchema, "", 1)
}
return socket
}
tcHost, err := testcontainersHostFromProperties(ctx)
if err == nil {
return checkDockerSocketFn(tcHost)
}
testcontainersDockerSocket, err := dockerSocketOverridePath()
if err == nil {
return checkDockerSocketFn(testcontainersDockerSocket)
}
info, err := cli.Info(ctx, client.InfoOptions{})
if err != nil {
panic(err) // Docker Info is required to get the Operating System
}
// Because Docker Desktop runs in a VM, we need to use the default docker path for rootless docker
if info.Info.OperatingSystem == "Docker Desktop" {
if IsWindows() {
return WindowsDockerSocketPath
}
return DockerSocketPath
}
dockerHost, err := extractDockerHost(ctx)
if err != nil {
panic(err) // Docker host is required to get the Docker socket
}
return checkDockerSocketFn(dockerHost)
}
// isHostNotSet returns true if the error is related to the Docker host
// not being set, false otherwise.
func isHostNotSet(err error) bool {
switch {
case errors.Is(err, ErrTestcontainersHostNotSetInProperties),
errors.Is(err, ErrDockerHostNotSet),
errors.Is(err, ErrDockerSocketNotSetInContext),
errors.Is(err, ErrDockerSocketNotSetInProperties),
errors.Is(err, ErrSocketNotFoundInPath),
errors.Is(err, ErrXDGRuntimeDirNotSet),
errors.Is(err, ErrRootlessDockerNotFoundHomeRunDir),
errors.Is(err, ErrRootlessDockerNotFoundHomeDesktopDir),
errors.Is(err, ErrRootlessDockerNotFoundRunDir):
return true
default:
return false
}
}
// dockerHostFromEnv returns the docker host from the DOCKER_HOST environment variable, if it's not empty
func dockerHostFromEnv(_ context.Context) (string, error) {
if dockerHostPath := os.Getenv("DOCKER_HOST"); dockerHostPath != "" {
return dockerHostPath, nil
}
return "", ErrDockerHostNotSet
}
// dockerHostFromContext returns the docker host from the Go context, if it's not empty
func dockerHostFromContext(ctx context.Context) (string, error) {
if socketPath, ok := ctx.Value(DockerHostContextKey).(string); ok && socketPath != "" {
parsed, err := parseURL(socketPath)
if err != nil {
return "", err
}
return parsed, nil
}
return "", ErrDockerSocketNotSetInContext
}
// dockerHostFromProperties returns the docker host from the ~/.testcontainers.properties file, if it's not empty
func dockerHostFromProperties(_ context.Context) (string, error) {
cfg := config.Read()
socketPath := cfg.Host
if socketPath != "" {
return socketPath, nil
}
return "", ErrDockerSocketNotSetInProperties
}
// dockerSocketOverridePath returns the docker socket from the TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE environment variable,
// if it's not empty
func dockerSocketOverridePath() (string, error) {
if dockerHostPath, exists := os.LookupEnv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"); exists {
return dockerHostPath, nil
}
return "", ErrDockerSocketOverrideNotSet
}
// dockerSocketPath returns the docker socket from the default docker socket path, if it's not empty
// and the socket exists
func dockerSocketPath(_ context.Context) (string, error) {
if fileExists(DockerSocketPath) {
return DockerSocketPathWithSchema, nil
}
return "", ErrSocketNotFoundInPath
}
// testcontainersHostFromProperties returns the testcontainers host from the ~/.testcontainers.properties file, if it's not empty
func testcontainersHostFromProperties(_ context.Context) (string, error) {
cfg := config.Read()
testcontainersHost := cfg.TestcontainersHost
if testcontainersHost != "" {
// Validate the URL format
_, err := parseURL(testcontainersHost)
if err != nil {
return "", err
}
// Return the original URL to preserve schema for Docker client
return testcontainersHost, nil
}
return "", ErrTestcontainersHostNotSetInProperties
}
// DockerEnvFile is the file that is created when running inside a container.
// It's a variable to allow testing.
// TODO: Remove this once context rework is done, which eliminates need for the default network creation.
var DockerEnvFile = "/.dockerenv"
// InAContainer returns true if the code is running inside a container
// See https://github.com/docker/docker/blob/a9fa38b1edf30b23cae3eade0be48b3d4b1de14b/daemon/initlayer/setup_unix.go#L25
func InAContainer() bool {
return inAContainer(DockerEnvFile)
}
func inAContainer(path string) bool {
// see https://github.com/testcontainers/testcontainers-java/blob/3ad8d80e2484864e554744a4800a81f6b7982168/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java#L15
if _, err := os.Stat(path); err == nil {
return true
}
return false
}
@@ -0,0 +1,150 @@
package core
import (
"context"
"errors"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
)
var (
ErrRootlessDockerNotFound = errors.New("rootless Docker not found")
ErrRootlessDockerNotFoundHomeDesktopDir = errors.New("checked path: ~/.docker/desktop/docker.sock")
ErrRootlessDockerNotFoundHomeRunDir = errors.New("checked path: ~/.docker/run/docker.sock")
ErrRootlessDockerNotFoundRunDir = errors.New("checked path: /run/user/${uid}/docker.sock")
ErrRootlessDockerNotFoundXDGRuntimeDir = errors.New("checked path: $XDG_RUNTIME_DIR")
ErrRootlessDockerNotSupportedWindows = errors.New("rootless Docker is not supported on Windows")
ErrXDGRuntimeDirNotSet = errors.New("XDG_RUNTIME_DIR is not set")
)
// baseRunDir is the base directory for the "/run/user/${uid}" directory.
// It is a variable so it can be modified for testing.
var baseRunDir = "/run"
// IsWindows returns if the current OS is Windows. For that it checks the GOOS environment variable or the runtime.GOOS constant.
func IsWindows() bool {
return os.Getenv("GOOS") == "windows" || runtime.GOOS == "windows"
}
// rootlessDockerSocketPath returns if the path to the rootless Docker socket exists.
// The rootless socket path is determined by the following order:
//
// 1. XDG_RUNTIME_DIR environment variable.
// 2. ~/.docker/run/docker.sock file.
// 3. ~/.docker/desktop/docker.sock file.
// 4. /run/user/${uid}/docker.sock file.
// 5. Else, return ErrRootlessDockerNotFound, wrapping specific errors for each of the above paths.
//
// It should include the Docker socket schema (unix://) in the returned path.
func rootlessDockerSocketPath(_ context.Context) (string, error) {
// adding a manner to test it on non-windows machines, setting the GOOS env var to windows
// This is needed because runtime.GOOS is a constant that returns the OS of the machine running the test
if IsWindows() {
return "", ErrRootlessDockerNotSupportedWindows
}
socketPathFns := []func() (string, error){
rootlessSocketPathFromEnv,
rootlessSocketPathFromHomeRunDir,
rootlessSocketPathFromHomeDesktopDir,
rootlessSocketPathFromRunDir,
}
var errs []error
for _, socketPathFn := range socketPathFns {
s, err := socketPathFn()
if err != nil {
if !isHostNotSet(err) {
errs = append(errs, err)
}
continue
}
return DockerSocketSchema + s, nil
}
if len(errs) > 0 {
return "", errors.Join(errs...)
}
return "", ErrRootlessDockerNotFound
}
func fileExists(f string) bool {
_, err := os.Stat(f)
return err == nil
}
func parseURL(s string) (string, error) {
hostURL, err := url.Parse(s)
if err != nil {
return "", err
}
switch hostURL.Scheme {
case "unix", "npipe":
return hostURL.Path, nil
case "tcp":
// return the original URL, as it is a valid TCP URL
return s, nil
default:
return "", ErrNoUnixSchema
}
}
// rootlessSocketPathFromEnv returns the path to the rootless Docker socket from the XDG_RUNTIME_DIR environment variable.
// It should include the Docker socket schema (unix://) in the returned path.
func rootlessSocketPathFromEnv() (string, error) {
xdgRuntimeDir, exists := os.LookupEnv("XDG_RUNTIME_DIR")
if exists {
f := filepath.Join(xdgRuntimeDir, "docker.sock")
if fileExists(f) {
return f, nil
}
return "", ErrRootlessDockerNotFoundXDGRuntimeDir
}
return "", ErrXDGRuntimeDirNotSet
}
// rootlessSocketPathFromHomeRunDir returns the path to the rootless Docker socket from the ~/.docker/run/docker.sock file.
func rootlessSocketPathFromHomeRunDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
f := filepath.Join(home, ".docker", "run", "docker.sock")
if fileExists(f) {
return f, nil
}
return "", ErrRootlessDockerNotFoundHomeRunDir
}
// rootlessSocketPathFromHomeDesktopDir returns the path to the rootless Docker socket from the ~/.docker/desktop/docker.sock file.
func rootlessSocketPathFromHomeDesktopDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
f := filepath.Join(home, ".docker", "desktop", "docker.sock")
if fileExists(f) {
return f, nil
}
return "", ErrRootlessDockerNotFoundHomeDesktopDir
}
// rootlessSocketPathFromRunDir returns the path to the rootless Docker socket from the /run/user/<uid>/docker.sock file.
func rootlessSocketPathFromRunDir() (string, error) {
uid := os.Getuid()
f := filepath.Join(baseRunDir, "user", strconv.Itoa(uid), "docker.sock")
if fileExists(f) {
return f, nil
}
return "", ErrRootlessDockerNotFoundRunDir
}
@@ -0,0 +1,49 @@
package core
import (
"net/url"
"strings"
"github.com/moby/moby/client"
)
// DockerSocketSchema is the unix schema.
var DockerSocketSchema = "unix://"
// DockerSocketPath is the path to the docker socket under unix systems.
var DockerSocketPath = "/var/run/docker.sock"
// DockerSocketPathWithSchema is the path to the docker socket under unix systems with the unix schema.
var DockerSocketPathWithSchema = DockerSocketSchema + DockerSocketPath
// TCPSchema is the tcp schema.
var TCPSchema = "tcp://"
// WindowsDockerSocketPath is the path to the docker socket under windows systems.
var WindowsDockerSocketPath = "//var/run/docker.sock"
func init() {
const DefaultDockerHost = client.DefaultDockerHost
u, err := url.Parse(DefaultDockerHost)
if err != nil {
// unsupported default host specified by the docker client package,
// so revert to the default unix docker socket path
return
}
switch u.Scheme {
case "unix", "npipe":
DockerSocketSchema = u.Scheme + "://"
DockerSocketPath = u.Path
if !strings.HasPrefix(DockerSocketPath, "/") {
// seeing as the code in this module depends on DockerSocketPath having
// a slash (`/`) prefix, we add it here if it is missing.
// for the known environments, we do not foresee how the socket-path
// should miss the slash, however this extra if-condition is worth to
// save future pain from innocent users.
DockerSocketPath = "/" + DockerSocketPath
}
DockerSocketPathWithSchema = DockerSocketSchema + DockerSocketPath
}
}
@@ -0,0 +1,143 @@
package core
import (
"bufio"
"io"
"net/url"
"os"
"regexp"
"strings"
"unicode/utf8"
)
const (
IndexDockerIO = "https://index.docker.io/v1/"
maxURLRuneCount = 2083
minURLRuneCount = 3
URLSchema = `((ftp|tcp|udp|wss?|https?):\/\/)`
URLUsername = `(\S+(:\S*)?@)`
URLIP = `([1-9]\d?|1\d\d|2[01]\d|22[0-3]|24\d|25[0-5])(\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-5]))`
IP = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`
URLSubdomain = `((www\.)|([a-zA-Z0-9]+([-_\.]?[a-zA-Z0-9])*[a-zA-Z0-9]\.[a-zA-Z0-9]+))`
URLPath = `((\/|\?|#)[^\s]*)`
URLPort = `(:(\d{1,5}))`
URL = `^` + URLSchema + `?` + URLUsername + `?` + `((` + URLIP + `|(\[` + IP + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + URLSubdomain + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + URLPort + `?` + URLPath + `?$`
)
var rxURL = regexp.MustCompile(URL)
// ExtractImagesFromDockerfile extracts images from the Dockerfile sourced from dockerfile.
func ExtractImagesFromDockerfile(dockerfile string, buildArgs map[string]*string) ([]string, error) {
file, err := os.Open(dockerfile)
if err != nil {
return nil, err
}
defer file.Close()
return ExtractImagesFromReader(file, buildArgs)
}
// ExtractImagesFromReader extracts images from the Dockerfile sourced from r.
func ExtractImagesFromReader(r io.Reader, buildArgs map[string]*string) ([]string, error) {
var lines []string
scanner := bufio.NewScanner(r)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if scanner.Err() != nil {
return nil, scanner.Err()
}
images := make([]string, 0, len(lines))
// extract images from dockerfile
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToUpper(line), "FROM") {
continue
}
// remove FROM
line = strings.TrimPrefix(line, "FROM")
parts := strings.Split(strings.TrimSpace(line), " ")
if len(parts) == 0 {
continue
}
// interpolate build args
for k, v := range buildArgs {
if v != nil {
parts[0] = strings.ReplaceAll(parts[0], "${"+k+"}", *v)
}
}
images = append(images, parts[0])
}
return images, nil
}
// ExtractRegistry extracts the registry from the image name, using a regular expression to extract the registry from the image name.
// regular expression to extract the registry from the image name
// the regular expression is based on the grammar defined in
// - image:tag
// - image
// - repository/image:tag
// - repository/image
// - registry/image:tag
// - registry/image
// - registry/repository/image:tag
// - registry/repository/image
// - registry:port/repository/image:tag
// - registry:port/repository/image
// - registry:port/image:tag
// - registry:port/image
// Once extracted the registry, it is validated to check if it is a valid URL or an IP address.
func ExtractRegistry(image string, fallback string) string {
exp := regexp.MustCompile(`^(?:(?P<registry>(https?://)?[^/]+)(?::(?P<port>\d+))?/)?(?:(?P<repository>[^/]+)/)?(?P<image>[^:]+)(?::(?P<tag>.+))?$`).FindStringSubmatch(image)
if len(exp) == 0 {
return ""
}
registry := exp[1]
// docker.io is an implicit reference, return fallback for normalization
if strings.EqualFold(registry, "docker.io") {
return fallback
}
// registry.hub.docker.com is an explicit registry reference, preserve it
if strings.EqualFold(registry, "registry.hub.docker.com") {
return "registry.hub.docker.com"
}
if IsURL(registry) {
return registry
}
return fallback
}
// IsURL checks if the string is a URL.
// Extracted from https://github.com/asaskevich/govalidator/blob/f21760c49a8d/validator.go#L104
func IsURL(str string) bool {
if str == "" || utf8.RuneCountInString(str) >= maxURLRuneCount || len(str) <= minURLRuneCount || strings.HasPrefix(str, ".") {
return false
}
strTemp := str
if strings.Contains(str, ":") && !strings.Contains(str, "://") {
// support no indicated urlscheme but with colon for port number
// http:// is appended so url.Parse will succeed, strTemp used so it does not impact rxURL.MatchString
strTemp = "http://" + str
}
u, err := url.Parse(strTemp)
if err != nil {
return false
}
if strings.HasPrefix(u.Host, ".") {
return false
}
if u.Host == "" && (u.Path != "" && !strings.Contains(u.Path, ".")) {
return false
}
return rxURL.MatchString(str)
}
@@ -0,0 +1,72 @@
package core
import (
"errors"
"fmt"
"maps"
"strings"
"github.com/testcontainers/testcontainers-go/internal"
"github.com/testcontainers/testcontainers-go/internal/config"
)
const (
// LabelBase is the base label for all testcontainers labels.
LabelBase = "org.testcontainers"
// LabelLang specifies the language which created the test container.
LabelLang = LabelBase + ".lang"
// LabelReaper identifies the container as a reaper.
LabelReaper = LabelBase + ".reaper"
// LabelRyuk identifies the container as a ryuk.
LabelRyuk = LabelBase + ".ryuk"
// LabelSessionID specifies the session ID of the container.
LabelSessionID = LabelBase + ".sessionId"
// LabelVersion specifies the version of testcontainers which created the container.
LabelVersion = LabelBase + ".version"
// LabelReap specifies the container should be reaped by the reaper.
LabelReap = LabelBase + ".reap"
)
// DefaultLabels returns the standard set of labels which
// includes LabelSessionID if the reaper is enabled.
func DefaultLabels(sessionID string) map[string]string {
labels := map[string]string{
LabelBase: "true",
LabelLang: "go",
LabelVersion: internal.Version,
LabelSessionID: sessionID,
}
if !config.Read().RyukDisabled {
labels[LabelReap] = "true"
}
return labels
}
// AddDefaultLabels adds the default labels for sessionID to target.
func AddDefaultLabels(sessionID string, target map[string]string) {
maps.Copy(target, DefaultLabels(sessionID))
}
// MergeCustomLabels sets labels from src to dst.
// If a key in src has [LabelBase] prefix returns an error.
// If dst is nil returns an error.
func MergeCustomLabels(dst, src map[string]string) error {
if dst == nil {
return errors.New("destination map is nil")
}
for key := range src {
if strings.HasPrefix(key, LabelBase) {
return fmt.Errorf("key %q has %q prefix", key, LabelBase)
}
}
maps.Copy(dst, src)
return nil
}
@@ -0,0 +1,52 @@
package network
import (
"context"
"fmt"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"github.com/testcontainers/testcontainers-go/internal/core"
)
const (
// FilterByID uses to filter network by identifier.
FilterByID = "id"
// FilterByName uses to filter network by name.
FilterByName = "name"
)
// Get returns a network by its ID.
func Get(ctx context.Context, id string) (network.Summary, error) {
return get(ctx, FilterByID, id)
}
// GetByName returns a network by its name.
func GetByName(ctx context.Context, name string) (network.Summary, error) {
return get(ctx, FilterByName, name)
}
func get(ctx context.Context, filter string, value string) (network.Summary, error) {
var nw network.Summary // initialize to the zero value
cli, err := core.NewClient(ctx)
if err != nil {
return nw, err
}
defer cli.Close()
list, err := cli.NetworkList(ctx, client.NetworkListOptions{
Filters: make(client.Filters).Add(filter, value),
})
if err != nil {
return nw, fmt.Errorf("failed to list networks: %w", err)
}
if len(list.Items) == 0 {
return nw, fmt.Errorf("network %s not found (filtering by %s)", value, filter)
}
return list.Items[0], nil
}
@@ -0,0 +1,4 @@
package internal
// Version is the next development version of the application
const Version = "0.42.0"
+674
View File
@@ -0,0 +1,674 @@
package testcontainers
import (
"context"
"errors"
"fmt"
"io"
"reflect"
"strings"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/log"
)
// ContainerRequestHook is a hook that will be called before a container is created.
// It can be used to modify container configuration before it is created,
// using the different lifecycle hooks that are available:
// - Creating
// For that, it will receive a ContainerRequest, modify it and return an error if needed.
type ContainerRequestHook func(ctx context.Context, req ContainerRequest) error
// ContainerHook is a hook that will be called after a container is created
// It can be used to modify the state of the container after it is created,
// using the different lifecycle hooks that are available:
// - Created
// - Starting
// - Started
// - Readied
// - Stopping
// - Stopped
// - Terminating
// - Terminated
// For that, it will receive a Container, modify it and return an error if needed.
type ContainerHook func(ctx context.Context, ctr Container) error
// ContainerLifecycleHooks is a struct that contains all the hooks that can be used
// to modify the container lifecycle. All the container lifecycle hooks except the PreCreates hooks
// will be passed to the container once it's created
type ContainerLifecycleHooks struct {
PreBuilds []ContainerRequestHook
PostBuilds []ContainerRequestHook
PreCreates []ContainerRequestHook
PostCreates []ContainerHook
PreStarts []ContainerHook
PostStarts []ContainerHook
PostReadies []ContainerHook
PreStops []ContainerHook
PostStops []ContainerHook
PreTerminates []ContainerHook
PostTerminates []ContainerHook
}
// DefaultLoggingHook is a hook that will log the container lifecycle events
var DefaultLoggingHook = func(logger log.Logger) ContainerLifecycleHooks {
shortContainerID := func(c Container) string {
return c.GetContainerID()[:12]
}
return ContainerLifecycleHooks{
PreBuilds: []ContainerRequestHook{
func(_ context.Context, req ContainerRequest) error {
logger.Printf("🐳 Building image %s:%s", req.GetRepo(), req.GetTag())
return nil
},
},
PostBuilds: []ContainerRequestHook{
func(_ context.Context, req ContainerRequest) error {
logger.Printf("✅ Built image %s", req.Image)
return nil
},
},
PreCreates: []ContainerRequestHook{
func(_ context.Context, req ContainerRequest) error {
logger.Printf("🐳 Creating container for image %s", req.Image)
return nil
},
},
PostCreates: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("✅ Container created: %s", shortContainerID(c))
return nil
},
},
PreStarts: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("🐳 Starting container: %s", shortContainerID(c))
return nil
},
},
PostStarts: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("✅ Container started: %s", shortContainerID(c))
return nil
},
},
PostReadies: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("🔔 Container is ready: %s", shortContainerID(c))
return nil
},
},
PreStops: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("🐳 Stopping container: %s", shortContainerID(c))
return nil
},
},
PostStops: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("✅ Container stopped: %s", shortContainerID(c))
return nil
},
},
PreTerminates: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("🐳 Terminating container: %s", shortContainerID(c))
return nil
},
},
PostTerminates: []ContainerHook{
func(_ context.Context, c Container) error {
logger.Printf("🚫 Container terminated: %s", shortContainerID(c))
return nil
},
},
}
}
// defaultPreCreateHook is a hook that will apply the default configuration to the container
var defaultPreCreateHook = func(p *DockerProvider, dockerInput *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) ContainerLifecycleHooks {
return ContainerLifecycleHooks{
PreCreates: []ContainerRequestHook{
func(ctx context.Context, req ContainerRequest) error {
return p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig)
},
},
}
}
// defaultCopyFileToContainerHook is a hook that will copy files to the container after it's created
// but before it's started
var defaultCopyFileToContainerHook = func(files []ContainerFile) ContainerLifecycleHooks {
return ContainerLifecycleHooks{
PostCreates: []ContainerHook{
// copy files to container after it's created
func(ctx context.Context, c Container) error {
for _, f := range files {
if err := f.validate(); err != nil {
return fmt.Errorf("invalid file: %w", err)
}
var err error
// Bytes takes precedence over HostFilePath
if f.Reader != nil {
bs, ioerr := io.ReadAll(f.Reader)
if ioerr != nil {
return fmt.Errorf("can't read from reader: %w", ioerr)
}
err = c.CopyToContainer(ctx, bs, f.ContainerFilePath, f.FileMode)
} else {
err = c.CopyFileToContainer(ctx, f.HostFilePath, f.ContainerFilePath, f.FileMode)
}
if err != nil {
return fmt.Errorf("can't copy %s to container: %w", f.HostFilePath, err)
}
}
return nil
},
},
}
}
// defaultLogConsumersHook is a hook that will start log consumers after the container is started
var defaultLogConsumersHook = func(cfg *LogConsumerConfig) ContainerLifecycleHooks {
return ContainerLifecycleHooks{
PostStarts: []ContainerHook{
// Produce logs sending details to the log consumers.
// See combineContainerHooks for the order of execution.
func(ctx context.Context, c Container) error {
if cfg == nil || len(cfg.Consumers) == 0 {
return nil
}
dockerContainer := c.(*DockerContainer)
dockerContainer.resetConsumers(cfg.Consumers)
return dockerContainer.startLogProduction(ctx, cfg.Opts...)
},
},
PostStops: []ContainerHook{
// Stop the log production.
// See combineContainerHooks for the order of execution.
func(_ context.Context, c Container) error {
if cfg == nil || len(cfg.Consumers) == 0 {
return nil
}
dockerContainer := c.(*DockerContainer)
return dockerContainer.stopLogProduction()
},
},
}
}
// defaultReadinessHook is a hook that will wait for the container to be ready
var defaultReadinessHook = func() ContainerLifecycleHooks {
return ContainerLifecycleHooks{
PostStarts: []ContainerHook{
// wait for the container to be ready
func(ctx context.Context, c Container) error {
dockerContainer := c.(*DockerContainer)
// if a Wait Strategy has been specified, wait before returning
if dockerContainer.WaitingFor != nil {
strategy := dockerContainer.WaitingFor
strategyDesc := "unknown strategy"
if s, ok := strategy.(fmt.Stringer); ok {
strategyDesc = s.String()
}
dockerContainer.logger.Printf(
"⏳ Waiting for container id %s image: %s. Waiting for: %+v",
dockerContainer.ID[:12], dockerContainer.Image, strategyDesc,
)
if err := strategy.WaitUntilReady(ctx, dockerContainer); err != nil {
return fmt.Errorf("wait until ready: %w", err)
}
}
dockerContainer.isRunning.Store(true)
return nil
},
},
}
}
// buildingHook is a hook that will be called before a container image is built.
func (req ContainerRequest) buildingHook(ctx context.Context) error {
return req.applyLifecycleHooks(func(lifecycleHooks ContainerLifecycleHooks) error {
return lifecycleHooks.Building(ctx)(req)
})
}
// builtHook is a hook that will be called after a container image is built.
func (req ContainerRequest) builtHook(ctx context.Context) error {
return req.applyLifecycleHooks(func(lifecycleHooks ContainerLifecycleHooks) error {
return lifecycleHooks.Built(ctx)(req)
})
}
// creatingHook is a hook that will be called before a container is created.
func (req ContainerRequest) creatingHook(ctx context.Context) error {
return req.applyLifecycleHooks(func(lifecycleHooks ContainerLifecycleHooks) error {
return lifecycleHooks.Creating(ctx)(req)
})
}
// applyLifecycleHooks calls hook on all LifecycleHooks.
func (req ContainerRequest) applyLifecycleHooks(hook func(lifecycleHooks ContainerLifecycleHooks) error) error {
var errs []error
for _, lifecycleHooks := range req.LifecycleHooks {
if err := hook(lifecycleHooks); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// createdHook is a hook that will be called after a container is created.
func (c *DockerContainer) createdHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, false, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PostCreates
})
}
// startingHook is a hook that will be called before a container is started.
func (c *DockerContainer) startingHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, true, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PreStarts
})
}
// startedHook is a hook that will be called after a container is started.
func (c *DockerContainer) startedHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, true, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PostStarts
})
}
// readiedHook is a hook that will be called after a container is ready.
func (c *DockerContainer) readiedHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, true, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PostReadies
})
}
// printLogs is a helper function that will print the logs of a Docker container
// We are going to use this helper function to inform the user of the logs when an error occurs
func (c *DockerContainer) printLogs(ctx context.Context, cause error) {
reader, err := c.Logs(ctx)
if err != nil {
c.logger.Printf("failed accessing container logs: %v\n", err)
return
}
b, err := io.ReadAll(reader)
if err != nil {
if len(b) > 0 {
c.logger.Printf("failed reading container logs: %v\npartial container logs (%s):\n%s", err, cause, b)
} else {
c.logger.Printf("failed reading container logs: %v\n", err)
}
return
}
c.logger.Printf("container logs (%s):\n%s", cause, b)
}
// stoppingHook is a hook that will be called before a container is stopped.
func (c *DockerContainer) stoppingHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, false, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PreStops
})
}
// stoppedHook is a hook that will be called after a container is stopped.
func (c *DockerContainer) stoppedHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, false, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PostStops
})
}
// terminatingHook is a hook that will be called before a container is terminated.
func (c *DockerContainer) terminatingHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, false, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PreTerminates
})
}
// terminatedHook is a hook that will be called after a container is terminated.
func (c *DockerContainer) terminatedHook(ctx context.Context) error {
return c.applyLifecycleHooks(ctx, false, func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook {
return lifecycleHooks.PostTerminates
})
}
// applyLifecycleHooks applies all lifecycle hooks reporting the container logs on error if logError is true.
func (c *DockerContainer) applyLifecycleHooks(ctx context.Context, logError bool, hooks func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook) error {
var errs []error
for _, lifecycleHooks := range c.lifecycleHooks {
if err := containerHookFn(ctx, hooks(lifecycleHooks))(c); err != nil {
errs = append(errs, err)
}
}
if err := errors.Join(errs...); err != nil {
if logError {
select {
case <-ctx.Done():
// Context has timed out so need a new context to get logs.
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
c.printLogs(ctx, err)
default:
c.printLogs(ctx, err)
}
}
return err
}
return nil
}
// Building is a hook that will be called before a container image is built.
func (c ContainerLifecycleHooks) Building(ctx context.Context) func(req ContainerRequest) error {
return containerRequestHook(ctx, c.PreBuilds)
}
// Building is a hook that will be called before a container image is built.
func (c ContainerLifecycleHooks) Built(ctx context.Context) func(req ContainerRequest) error {
return containerRequestHook(ctx, c.PostBuilds)
}
// Creating is a hook that will be called before a container is created.
func (c ContainerLifecycleHooks) Creating(ctx context.Context) func(req ContainerRequest) error {
return containerRequestHook(ctx, c.PreCreates)
}
// containerRequestHook returns a function that will iterate over all
// the hooks and call them one by one until there is an error.
func containerRequestHook(ctx context.Context, hooks []ContainerRequestHook) func(req ContainerRequest) error {
return func(req ContainerRequest) error {
for _, hook := range hooks {
if err := hook(ctx, req); err != nil {
return err
}
}
return nil
}
}
// containerHookFn is a helper function that will create a function to be returned by all the different
// container lifecycle hooks. The created function will iterate over all the hooks and call them one by one.
func containerHookFn(ctx context.Context, containerHook []ContainerHook) func(container Container) error {
return func(ctr Container) error {
var errs []error
for _, hook := range containerHook {
if err := hook(ctx, ctr); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
}
// Created is a hook that will be called after a container is created
func (c ContainerLifecycleHooks) Created(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PostCreates)
}
// Starting is a hook that will be called before a container is started
func (c ContainerLifecycleHooks) Starting(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PreStarts)
}
// Started is a hook that will be called after a container is started
func (c ContainerLifecycleHooks) Started(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PostStarts)
}
// Readied is a hook that will be called after a container is ready
func (c ContainerLifecycleHooks) Readied(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PostReadies)
}
// Stopping is a hook that will be called before a container is stopped
func (c ContainerLifecycleHooks) Stopping(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PreStops)
}
// Stopped is a hook that will be called after a container is stopped
func (c ContainerLifecycleHooks) Stopped(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PostStops)
}
// Terminating is a hook that will be called before a container is terminated
func (c ContainerLifecycleHooks) Terminating(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PreTerminates)
}
// Terminated is a hook that will be called after a container is terminated
func (c ContainerLifecycleHooks) Terminated(ctx context.Context) func(container Container) error {
return containerHookFn(ctx, c.PostTerminates)
}
func (p *DockerProvider) preCreateContainerHook(ctx context.Context, req ContainerRequest, dockerInput *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) error {
var mountErrors []error
for _, m := range req.Mounts {
// validate only the mount sources that implement the Validator interface
if v, ok := m.Source.(Validator); ok {
if err := v.Validate(); err != nil {
mountErrors = append(mountErrors, err)
}
}
}
if len(mountErrors) > 0 {
return errors.Join(mountErrors...)
}
// prepare mounts
hostConfig.Mounts = mapToDockerMounts(req.Mounts)
endpointSettings := map[string]*network.EndpointSettings{}
// #248: Docker allows only one network to be specified during container creation
// If there is more than one network specified in the request container should be attached to them
// once it is created. We will take a first network if any specified in the request and use it to create container
if len(req.Networks) > 0 {
attachContainerTo := req.Networks[0]
nw, err := p.GetNetwork(ctx, NetworkRequest{
Name: attachContainerTo,
})
if err == nil {
aliases := []string{}
if _, ok := req.NetworkAliases[attachContainerTo]; ok {
aliases = req.NetworkAliases[attachContainerTo]
}
endpointSetting := network.EndpointSettings{
Aliases: aliases,
NetworkID: nw.ID,
}
endpointSettings[attachContainerTo] = &endpointSetting
}
}
if req.ConfigModifier == nil {
req.ConfigModifier = defaultConfigModifier(req)
}
req.ConfigModifier(dockerInput)
if req.HostConfigModifier == nil {
req.HostConfigModifier = defaultHostConfigModifier(req)
}
req.HostConfigModifier(hostConfig)
if req.EndpointSettingsModifier != nil {
req.EndpointSettingsModifier(endpointSettings)
}
networkingConfig.EndpointsConfig = endpointSettings
// Expose ports automatically if the container request exposes zero ports and the container
// does not run in a container network. The NetworkMode check must be done after the pre-creation
// Modifiers are called, so the network mode is already set.
exposedPorts := req.ExposedPorts
if len(exposedPorts) == 0 && !hostConfig.NetworkMode.IsContainer() {
image, err := p.client.ImageInspect(ctx, dockerInput.Image)
if err != nil {
return err
}
exposedPorts = exposedPorts[:0]
for port := range image.Config.ExposedPorts {
exposedPorts = append(exposedPorts, port)
}
}
exposedPortSet, err := parseExposedPorts(exposedPorts)
if err != nil {
return err
}
dockerInput.ExposedPorts = exposedPortSet
hostConfig.PortBindings = mergePortBindings(hostConfig.PortBindings, exposedPortSet)
return nil
}
// combineContainerHooks returns a ContainerLifecycle hook as the result
// of combining the default hooks with the user-defined hooks.
//
// The order of hooks is the following:
// - Pre-hooks run the default hooks first then the user-defined hooks
// - Post-hooks run the user-defined hooks first then the default hooks
func combineContainerHooks(defaultHooks, userDefinedHooks []ContainerLifecycleHooks) ContainerLifecycleHooks {
// We use reflection here to ensure that any new hooks are handled.
var hooks ContainerLifecycleHooks
hooksVal := reflect.ValueOf(&hooks).Elem()
hooksType := reflect.TypeOf(hooks)
for _, defaultHook := range defaultHooks {
defaultVal := reflect.ValueOf(defaultHook)
for i := range hooksType.NumField() {
if strings.HasPrefix(hooksType.Field(i).Name, "Pre") {
field := hooksVal.Field(i)
field.Set(reflect.AppendSlice(field, defaultVal.Field(i)))
}
}
}
// Append the user-defined hooks after the default pre-hooks
// and because the post hooks are still empty, the user-defined
// post-hooks will be the first ones to be executed.
for _, userDefinedHook := range userDefinedHooks {
userVal := reflect.ValueOf(userDefinedHook)
for i := range hooksType.NumField() {
field := hooksVal.Field(i)
field.Set(reflect.AppendSlice(field, userVal.Field(i)))
}
}
// Finally, append the default post-hooks.
for _, defaultHook := range defaultHooks {
defaultVal := reflect.ValueOf(defaultHook)
for i := range hooksType.NumField() {
if strings.HasPrefix(hooksType.Field(i).Name, "Post") {
field := hooksVal.Field(i)
field.Set(reflect.AppendSlice(field, defaultVal.Field(i)))
}
}
}
return hooks
}
func parseExposedPorts(specs []string) (network.PortSet, error) {
exposed := make(network.PortSet, len(specs))
for _, s := range specs {
pr, err := network.ParsePortRange(s)
if err != nil {
return nil, fmt.Errorf("invalid exposed port %q: %w", s, err)
}
for p := range pr.All() {
exposed[p] = struct{}{}
}
}
return exposed, nil
}
// mergePortBindings returns a PortMap for the given exposedPortSet.
//
// For each port in exposedPortSet, a binding is ensured:
// - If configPortMap contains bindings for that port, those bindings are used.
// - Otherwise, a default binding with HostPort "0" (ephemeral allocation)
// is assigned.
//
// Bindings for ports not present in exposedPortSet are not preserved.
// Any binding with an empty HostPort is normalized to "0".
//
// TODO(thaJeztah): this logic seems the reverse of the docker CLI, which
// exposes ports if the user requests a port-mapping (i.e., if a port-mapping
// is requested, but not exposed, we map the port *and* add an entry to
// ExposedPorts). The logic here is the reverse; any port "mapped" in
// HostConfig.PortBindings is dropped if is not exposed.
func mergePortBindings(configPortMap network.PortMap, exposedPortSet network.PortSet) network.PortMap {
if len(exposedPortSet) == 0 {
return network.PortMap{}
}
exposedPortMap := make(network.PortMap, len(exposedPortSet))
for p := range exposedPortSet {
bindings := configPortMap[p]
if len(bindings) == 0 {
exposedPortMap[p] = []network.PortBinding{{HostPort: "0"}}
continue
}
// Fix: Ensure that ports with empty HostPort get "0" for automatic allocation
// This fixes the UDP port binding issue where ports were getting HostPort:0 instead of being allocated
for i := range bindings {
if bindings[i].HostPort == "" {
bindings[i].HostPort = "0" // Tell Docker to allocate a random port
}
}
exposedPortMap[p] = bindings
}
return exposedPortMap
}
// defaultHostConfigModifier provides a default modifier including the deprecated fields
func defaultConfigModifier(req ContainerRequest) func(config *container.Config) {
return func(config *container.Config) {
config.Hostname = req.Hostname
config.WorkingDir = req.WorkingDir
config.User = req.User
}
}
// defaultHostConfigModifier provides a default modifier including the deprecated fields
func defaultHostConfigModifier(req ContainerRequest) func(hostConfig *container.HostConfig) {
return func(hostConfig *container.HostConfig) {
hostConfig.AutoRemove = req.AutoRemove
hostConfig.CapAdd = req.CapAdd
hostConfig.CapDrop = req.CapDrop
hostConfig.Binds = req.Binds
hostConfig.ExtraHosts = req.ExtraHosts
hostConfig.NetworkMode = req.NetworkMode
hostConfig.Resources = req.Resources
hostConfig.Privileged = req.Privileged
hostConfig.ShmSize = req.ShmSize
}
}
@@ -0,0 +1,73 @@
package log
import (
"log"
"os"
"strings"
"testing"
)
// Validate our types implement the required interfaces.
var (
_ Logger = (*log.Logger)(nil)
_ Logger = (*noopLogger)(nil)
_ Logger = (*testLogger)(nil)
)
// Logger defines the Logger interface.
type Logger interface {
Printf(format string, v ...any)
}
// defaultLogger is the default Logger instance.
var defaultLogger Logger = &noopLogger{}
func init() {
// Enable default logger in the testing with a verbose flag.
if testing.Testing() {
// Parse manually because testing.Verbose() panics unless flag.Parse() has done.
for _, arg := range os.Args {
if strings.EqualFold(arg, "-test.v=true") || strings.EqualFold(arg, "-v") {
defaultLogger = log.New(os.Stderr, "", log.LstdFlags)
}
}
}
}
// Default returns the default Logger instance.
func Default() Logger {
return defaultLogger
}
// SetDefault sets the default Logger instance.
func SetDefault(logger Logger) {
defaultLogger = logger
}
func Printf(format string, v ...any) {
defaultLogger.Printf(format, v...)
}
type noopLogger struct{}
// Printf implements Logging.
func (n noopLogger) Printf(_ string, _ ...any) {
// NOOP
}
// TestLogger returns a Logging implementation for testing.TB
// This way logs from testcontainers are part of the test output of a test suite or test case.
func TestLogger(tb testing.TB) Logger {
tb.Helper()
return testLogger{TB: tb}
}
type testLogger struct {
testing.TB
}
// Printf implements Logging.
func (t testLogger) Printf(format string, v ...any) {
t.Helper()
t.Logf(format, v...)
}
@@ -0,0 +1,36 @@
package testcontainers
// StdoutLog is the log type for STDOUT
const StdoutLog = "STDOUT"
// StderrLog is the log type for STDERR
const StderrLog = "STDERR"
// logStruct {
// Log represents a message that was created by a process,
// LogType is either "STDOUT" or "STDERR",
// Content is the byte contents of the message itself
type Log struct {
LogType string
Content []byte
}
// }
// logConsumerInterface {
// LogConsumer represents any object that can
// handle a Log, it is up to the LogConsumer instance
// what to do with the log
type LogConsumer interface {
Accept(Log)
}
// }
// LogConsumerConfig is a configuration object for the producer/consumer pattern
type LogConsumerConfig struct {
Opts []LogProductionOption // options for the production of logs
Consumers []LogConsumer // consumers for the logs
}
@@ -0,0 +1,45 @@
package testcontainers
import "github.com/testcontainers/testcontainers-go/log"
// Validate our types implement the required interfaces.
var (
_ ContainerCustomizer = LoggerOption{}
_ GenericProviderOption = LoggerOption{}
_ DockerProviderOption = LoggerOption{}
)
// WithLogger returns a generic option that sets the logger to be used.
//
// Consider calling this before other "With functions" as these may generate logs.
//
// This can be given a TestLogger to collect the logs from testcontainers into a
// test case.
func WithLogger(logger log.Logger) LoggerOption {
return LoggerOption{
logger: logger,
}
}
// LoggerOption is a generic option that sets the logger to be used.
//
// It can be used to set the logger for providers and containers.
type LoggerOption struct {
logger log.Logger
}
// ApplyGenericTo implements GenericProviderOption.
func (o LoggerOption) ApplyGenericTo(opts *GenericProviderOptions) {
opts.Logger = o.logger
}
// ApplyDockerTo implements DockerProviderOption.
func (o LoggerOption) ApplyDockerTo(opts *DockerProviderOptions) {
opts.Logger = o.logger
}
// Customize implements ContainerCustomizer.
func (o LoggerOption) Customize(req *GenericContainerRequest) error {
req.Logger = o.logger
return nil
}
+165
View File
@@ -0,0 +1,165 @@
# This file is autogenerated by the 'modulegen' tool.
site_name: Testcontainers for Go
site_url: https://golang.testcontainers.org
plugins:
- search
- codeinclude
- include-markdown
- markdownextradata
theme:
name: material
custom_dir: docs/theme
palette:
scheme: testcontainers
font:
text: Roboto
code: Roboto Mono
logo: logo.svg
favicon: favicon.ico
extra_css:
- css/extra.css
- css/tc-header.css
- css/usage-metrics.css
extra_javascript:
- https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js
- https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js
- https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js
- js/usage-metrics.js
repo_name: testcontainers-go
repo_url: https://github.com/testcontainers/testcontainers-go
markdown_extensions:
- admonition
- codehilite:
linenums: false
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.snippets
- toc:
permalink: true
- attr_list
- pymdownx.emoji:
emoji_generator: !!python/name:material.extensions.emoji.to_svg
emoji_index: !!python/name:material.extensions.emoji.twemoji
nav:
- Home: index.md
- Quickstart: quickstart.md
- Features:
- features/creating_container.md
- Wait Strategies:
- Introduction: features/wait/introduction.md
- Exec: features/wait/exec.md
- Exit: features/wait/exit.md
- File: features/wait/file.md
- Health: features/wait/health.md
- HostPort: features/wait/host_port.md
- HTTP: features/wait/http.md
- Log: features/wait/log.md
- Multi: features/wait/multi.md
- SQL: features/wait/sql.md
- TLS: features/wait/tls.md
- Walk: features/wait/walk.md
- features/files_and_mounts.md
- features/follow_logs.md
- features/garbage_collector.md
- features/build_from_dockerfile.md
- features/override_container_command.md
- features/networking.md
- features/configuration.md
- features/image_name_substitution.md
- features/test_session_semantics.md
- features/docker_auth.md
- features/docker_compose.md
- features/tls.md
- Modules:
- modules/index.md
- modules/aerospike.md
- modules/arangodb.md
- modules/artemis.md
- modules/azure.md
- modules/azurite.md
- modules/cassandra.md
- modules/chroma.md
- modules/clickhouse.md
- modules/cockroachdb.md
- modules/consul.md
- modules/couchbase.md
- modules/databend.md
- modules/dind.md
- modules/dockermcpgateway.md
- modules/dockermodelrunner.md
- modules/dolt.md
- modules/dynamodb.md
- modules/elasticsearch.md
- modules/etcd.md
- modules/forgejo.md
- modules/gcloud.md
- modules/grafana-lgtm.md
- modules/inbucket.md
- modules/influxdb.md
- modules/k3s.md
- modules/k6.md
- modules/kafka.md
- modules/localstack.md
- modules/mariadb.md
- modules/meilisearch.md
- modules/memcached.md
- modules/milvus.md
- modules/minio.md
- modules/mockserver.md
- modules/mongodb-atlaslocal.md
- modules/mongodb.md
- modules/mssql.md
- modules/mysql.md
- modules/nats.md
- modules/nebulagraph.md
- modules/neo4j.md
- modules/ollama.md
- modules/openfga.md
- modules/openldap.md
- modules/opensearch.md
- modules/pinecone.md
- modules/postgres.md
- modules/pulsar.md
- modules/qdrant.md
- modules/rabbitmq.md
- modules/redis.md
- modules/redpanda.md
- modules/registry.md
- modules/scylladb.md
- modules/socat.md
- modules/solace.md
- modules/surrealdb.md
- modules/tidb.md
- modules/toxiproxy.md
- modules/valkey.md
- modules/vault.md
- modules/vearch.md
- modules/weaviate.md
- modules/yugabytedb.md
- Examples:
- examples/index.md
- examples/nginx.md
- System Requirements:
- system_requirements/index.md
- system_requirements/docker.md
- Continuous Integration:
- system_requirements/ci/aws_codebuild.md
- system_requirements/ci/bitbucket_pipelines.md
- system_requirements/ci/circle_ci.md
- system_requirements/ci/concourse_ci.md
- system_requirements/ci/dind_patterns.md
- system_requirements/ci/drone.md
- system_requirements/ci/gitlab_ci.md
- system_requirements/ci/tekton.md
- system_requirements/ci/travis.md
- system_requirements/using_colima.md
- system_requirements/using_podman.md
- system_requirements/rancher.md
- Usage Metrics: usage-metrics.md
- Dependabot: dependabot.md
- Contributing: contributing.md
- Getting help: getting_help.md
edit_uri: edit/main/docs/
extra:
latest_version: v0.42.0
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017-2019 Gianluca Arbezzano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,5 @@
include ../../commons-test.mk
.PHONY: test
test:
$(MAKE) test-opensearch
@@ -0,0 +1,124 @@
package opensearch
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
"github.com/docker/go-units"
"github.com/moby/moby/api/types/container"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
defaultPassword = "admin"
defaultUsername = "admin"
defaultHTTPPort = "9200/tcp"
)
// OpenSearchContainer represents the OpenSearch container type used in the module
type OpenSearchContainer struct {
testcontainers.Container
User string
Password string
}
// Deprecated: use Run instead
// RunContainer creates an instance of the OpenSearch container type
func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*OpenSearchContainer, error) {
return Run(ctx, "opensearchproject/opensearch:2.11.1", opts...)
}
// Run creates an instance of the OpenSearch container type
func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*OpenSearchContainer, error) {
// Gather all config options (defaults and then apply provided options)
settings := defaultOptions()
for _, opt := range opts {
if apply, ok := opt.(Option); ok {
if err := apply(settings); err != nil {
return nil, fmt.Errorf("apply option: %w", err)
}
}
}
username := settings.Username
password := settings.Password
moduleOpts := make([]testcontainers.ContainerCustomizer, 0, 4+len(opts))
moduleOpts = append(moduleOpts,
testcontainers.WithEnv(map[string]string{
"discovery.type": "single-node",
"DISABLE_INSTALL_DEMO_CONFIG": "true",
"DISABLE_SECURITY_PLUGIN": "true",
"OPENSEARCH_USERNAME": username,
"OPENSEARCH_PASSWORD": password,
}),
testcontainers.WithExposedPorts(defaultHTTPPort, "9600/tcp"),
testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) {
hc.Ulimits = []*units.Ulimit{
{
Name: "memlock",
Soft: -1, // Set memlock to unlimited (no soft or hard limit)
Hard: -1,
},
{
Name: "nofile",
Soft: 65536, // Maximum number of open files for the opensearch user - set to at least 65536
Hard: 65536,
},
}
}),
// the wait strategy does not support TLS at the moment,
// so we need to disable it in the strategy for now.
testcontainers.WithWaitStrategy(wait.ForHTTP("/").
WithPort("9200").
WithTLS(false).
WithStartupTimeout(120*time.Second).
WithStatusCodeMatcher(func(status int) bool {
return status == 200
}).
WithBasicAuth(username, password).
WithResponseMatcher(func(body io.Reader) bool {
bs, err := io.ReadAll(body)
if err != nil {
return false
}
type response struct {
Tagline string `json:"tagline"`
}
var r response
err = json.Unmarshal(bs, &r)
if err != nil {
return false
}
return r.Tagline == "The OpenSearch Project: https://opensearch.org/"
})),
)
moduleOpts = append(moduleOpts, opts...)
ctr, err := testcontainers.Run(ctx, img, moduleOpts...)
var c *OpenSearchContainer
if ctr != nil {
c = &OpenSearchContainer{Container: ctr, User: username, Password: password}
}
if err != nil {
return c, fmt.Errorf("run opensearch: %w", err)
}
return c, nil
}
// Address retrieves the address of the OpenSearch container.
// It will use http as protocol, as TLS is not supported at the moment.
func (c *OpenSearchContainer) Address(ctx context.Context) (string, error) {
return c.PortEndpoint(ctx, defaultHTTPPort, "http")
}
@@ -0,0 +1,44 @@
package opensearch
import "github.com/testcontainers/testcontainers-go"
// Options is a struct for specifying options for the OpenSearch container.
type Options struct {
Password string
Username string
}
func defaultOptions() *Options {
return &Options{
Username: defaultUsername,
Password: defaultPassword,
}
}
// Compiler check to ensure that Option implements the testcontainers.ContainerCustomizer interface.
var _ testcontainers.ContainerCustomizer = (*Option)(nil)
// Option is an option for the OpenSearch container.
type Option func(*Options) error
// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface.
func (o Option) Customize(*testcontainers.GenericContainerRequest) error {
// NOOP to satisfy interface.
return nil
}
// WithPassword sets the password for the OpenSearch container.
func WithPassword(password string) Option {
return func(o *Options) error {
o.Password = password
return nil
}
}
// WithUsername sets the username for the OpenSearch container.
func WithUsername(username string) Option {
return func(o *Options) error {
o.Username = username
return nil
}
}
+175
View File
@@ -0,0 +1,175 @@
package testcontainers
import (
"errors"
"path/filepath"
)
const (
MountTypeBind MountType = iota // Deprecated: Use MountTypeVolume instead
MountTypeVolume
MountTypeTmpfs
MountTypePipe
MountTypeImage
)
var (
ErrDuplicateMountTarget = errors.New("duplicate mount target detected")
ErrInvalidBindMount = errors.New("invalid bind mount")
)
var (
_ ContainerMountSource = (*GenericBindMountSource)(nil) // Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
_ ContainerMountSource = (*GenericVolumeMountSource)(nil)
_ ContainerMountSource = (*GenericTmpfsMountSource)(nil)
_ ContainerMountSource = (*GenericImageMountSource)(nil)
)
type (
// ContainerMounts represents a collection of mounts for a container
ContainerMounts []ContainerMount
MountType uint
)
// ContainerMountSource is the base for all mount sources
type ContainerMountSource interface {
// Source will be used as Source field in the final mount
// this might either be a volume name, a host path or might be empty e.g. for Tmpfs
Source() string
// Type determines the final mount type
// possible options are limited by the Docker API
Type() MountType
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
// GenericBindMountSource implements ContainerMountSource and represents a bind mount
// Optionally mount.BindOptions might be added for advanced scenarios
type GenericBindMountSource struct {
// HostPath is the path mounted into the container
// the same host path might be mounted to multiple locations within a single container
HostPath string
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
func (s GenericBindMountSource) Source() string {
return s.HostPath
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
func (GenericBindMountSource) Type() MountType {
return MountTypeBind
}
// GenericVolumeMountSource implements ContainerMountSource and represents a volume mount
type GenericVolumeMountSource struct {
// Name refers to the name of the volume to be mounted
// the same volume might be mounted to multiple locations within a single container
Name string
}
func (s GenericVolumeMountSource) Source() string {
return s.Name
}
func (GenericVolumeMountSource) Type() MountType {
return MountTypeVolume
}
// GenericTmpfsMountSource implements ContainerMountSource and represents a TmpFS mount
// Optionally mount.TmpfsOptions might be added for advanced scenarios
type GenericTmpfsMountSource struct{}
func (s GenericTmpfsMountSource) Source() string {
return ""
}
func (GenericTmpfsMountSource) Type() MountType {
return MountTypeTmpfs
}
// ContainerMountTarget represents the target path within a container where the mount will be available
// Note that mount targets must be unique. It's not supported to mount different sources to the same target.
type ContainerMountTarget string
func (t ContainerMountTarget) Target() string {
return string(t)
}
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
// BindMount returns a new ContainerMount with a GenericBindMountSource as source
// This is a convenience method to cover typical use cases.
func BindMount(hostPath string, mountTarget ContainerMountTarget) ContainerMount {
return ContainerMount{
Source: GenericBindMountSource{HostPath: hostPath},
Target: mountTarget,
}
}
// VolumeMount returns a new ContainerMount with a GenericVolumeMountSource as source
// This is a convenience method to cover typical use cases.
func VolumeMount(volumeName string, mountTarget ContainerMountTarget) ContainerMount {
return ContainerMount{
Source: GenericVolumeMountSource{Name: volumeName},
Target: mountTarget,
}
}
// ImageMount returns a new ContainerMount with a GenericImageMountSource as source
// This is a convenience method to cover typical use cases.
func ImageMount(imageName string, subpath string, mountTarget ContainerMountTarget) ContainerMount {
return ContainerMount{
Source: NewGenericImageMountSource(imageName, subpath),
Target: mountTarget,
}
}
// Mounts returns a ContainerMounts to support a more fluent API
func Mounts(mounts ...ContainerMount) ContainerMounts {
return mounts
}
// ContainerMount models a mount into a container
type ContainerMount struct {
// Source is typically either a GenericVolumeMountSource, as BindMount is not supported by all Docker environments
Source ContainerMountSource
// Target is the path where the mount should be mounted within the container
Target ContainerMountTarget
// ReadOnly determines if the mount should be read-only
ReadOnly bool
}
// GenericImageMountSource implements ContainerMountSource and represents an image mount
type GenericImageMountSource struct {
// imageName refers to the name of the image to be mounted
// the same image might be mounted to multiple locations within a single container
imageName string
// subpath is the path within the image to be mounted
subpath string
}
// NewGenericImageMountSource creates a new GenericImageMountSource
func NewGenericImageMountSource(imageName string, subpath string) GenericImageMountSource {
return GenericImageMountSource{
imageName: imageName,
subpath: subpath,
}
}
// Source returns the name of the image to be mounted
func (s GenericImageMountSource) Source() string {
return s.imageName
}
// Type returns the type of the mount
func (GenericImageMountSource) Type() MountType {
return MountTypeImage
}
// Validate validates the source of the mount
func (s GenericImageMountSource) Validate() error {
if !filepath.IsLocal(s.subpath) {
return errors.New("image mount source must be a local path")
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package testcontainers
import (
"context"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/internal/core"
)
// NetworkProvider allows the creation of networks on an arbitrary system
type NetworkProvider interface {
CreateNetwork(context.Context, NetworkRequest) (Network, error) // create a network
GetNetwork(context.Context, NetworkRequest) (network.Inspect, error) // get a network
}
// Deprecated: will be removed in the future
// Network allows getting info about a single network instance
type Network interface {
Remove(context.Context) error // removes the network
}
// Deprecated: will be removed in the future.
type DefaultNetwork string
// Deprecated: will be removed in the future.
func (n DefaultNetwork) ApplyGenericTo(opts *GenericProviderOptions) {
opts.defaultNetwork = string(n)
}
// Deprecated: will be removed in the future.
func (n DefaultNetwork) ApplyDockerTo(opts *DockerProviderOptions) {
opts.defaultNetwork = string(n)
}
// Deprecated: will be removed in the future
// NetworkRequest represents the parameters used to get a network
type NetworkRequest struct {
Driver string
CheckDuplicate bool // Deprecated: CheckDuplicate is deprecated since API v1.44, but it defaults to true when sent by the client package to older daemons.
Internal bool
EnableIPv6 *bool
Name string
Labels map[string]string
Attachable bool
IPAM *network.IPAM
SkipReaper bool // Deprecated: The reaper is globally controlled by the .testcontainers.properties file or the TESTCONTAINERS_RYUK_DISABLED environment variable
ReaperImage string // Deprecated: use WithImageName ContainerOption instead. Alternative reaper registry
ReaperOptions []ContainerOption // Deprecated: the reaper is configured at the properties level, for an entire test session
}
// sessionID returns the session ID for the network request.
func (r NetworkRequest) sessionID() string {
if sessionID := r.Labels[core.LabelSessionID]; sessionID != "" {
return sessionID
}
return core.SessionID()
}
+538
View File
@@ -0,0 +1,538 @@
package testcontainers
import (
"context"
"errors"
"fmt"
"maps"
"path"
"time"
"dario.cat/mergo"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
tcexec "github.com/testcontainers/testcontainers-go/exec"
"github.com/testcontainers/testcontainers-go/internal/core"
"github.com/testcontainers/testcontainers-go/wait"
)
// ContainerCustomizer is an interface that can be used to configure the Testcontainers container
// request. The passed request will be merged with the default one.
type ContainerCustomizer interface {
Customize(req *GenericContainerRequest) error
}
// CustomizeRequestOption is a type that can be used to configure the Testcontainers container request.
// The passed request will be merged with the default one.
type CustomizeRequestOption func(req *GenericContainerRequest) error
func (opt CustomizeRequestOption) Customize(req *GenericContainerRequest) error {
return opt(req)
}
// CustomizeRequest returns a function that can be used to merge the passed container request with the one that is used by the container.
// Slices and Maps will be appended.
func CustomizeRequest(src GenericContainerRequest) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if err := mergo.Merge(req, &src, mergo.WithOverride, mergo.WithAppendSlice); err != nil {
return fmt.Errorf("error merging container request, keeping the original one: %w", err)
}
return nil
}
}
// WithDockerfile allows to build a container from a Dockerfile
func WithDockerfile(df FromDockerfile) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.FromDockerfile = df
return nil
}
}
// WithConfigModifier allows to override the default container config
func WithConfigModifier(modifier func(config *container.Config)) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.ConfigModifier = modifier
return nil
}
}
// WithEndpointSettingsModifier allows to override the default endpoint settings
func WithEndpointSettingsModifier(modifier func(settings map[string]*network.EndpointSettings)) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.EndpointSettingsModifier = modifier
return nil
}
}
// WithEnv sets the environment variables for a container.
// If the environment variable already exists, it will be overridden.
func WithEnv(envs map[string]string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if req.Env == nil {
req.Env = map[string]string{}
}
maps.Copy(req.Env, envs)
return nil
}
}
// WithHostConfigModifier allows to override the default host config
func WithHostConfigModifier(modifier func(hostConfig *container.HostConfig)) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.HostConfigModifier = modifier
return nil
}
}
// WithHostPortAccess allows to expose the host ports to the container
func WithHostPortAccess(ports ...int) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if req.HostAccessPorts == nil {
req.HostAccessPorts = []int{}
}
req.HostAccessPorts = append(req.HostAccessPorts, ports...)
return nil
}
}
// WithName will set the name of the container.
func WithName(containerName string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if containerName == "" {
return errors.New("container name must be provided")
}
req.Name = containerName
return nil
}
}
// WithNoStart will prevent the container from being started after creation.
func WithNoStart() CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Started = false
return nil
}
}
// WithReuseByName will mark a container to be reused if it exists or create a new one if it doesn't.
// A container name must be provided to identify the container to be reused.
func WithReuseByName(containerName string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if err := WithName(containerName)(req); err != nil {
return err
}
req.Reuse = true
return nil
}
}
// WithImage sets the image for a container
func WithImage(image string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Image = image
return nil
}
}
// imageSubstitutor {
// ImageSubstitutor represents a way to substitute container image names
type ImageSubstitutor interface {
// Description returns the name of the type and a short description of how it modifies the image.
// Useful to be printed in logs
Description() string
Substitute(image string) (string, error)
}
// }
// CustomHubSubstitutor represents a way to substitute the hub of an image with a custom one,
// using provided value with respect to the HubImageNamePrefix configuration value.
type CustomHubSubstitutor struct {
hub string
}
// NewCustomHubSubstitutor creates a new CustomHubSubstitutor
func NewCustomHubSubstitutor(hub string) CustomHubSubstitutor {
return CustomHubSubstitutor{
hub: hub,
}
}
// Description returns the name of the type and a short description of how it modifies the image.
func (c CustomHubSubstitutor) Description() string {
return fmt.Sprintf("CustomHubSubstitutor (replaces hub with %s)", c.hub)
}
// Substitute replaces the hub of the image with the provided one, with certain conditions:
// - if the hub is empty, the image is returned as is.
// - if the image already contains a registry, the image is returned as is.
// - if the HubImageNamePrefix configuration value is set, the image is returned as is.
func (c CustomHubSubstitutor) Substitute(image string) (string, error) {
registry := core.ExtractRegistry(image, "")
cfg := ReadConfig()
exclusions := []func() bool{
func() bool { return c.hub == "" },
func() bool { return registry != "" },
func() bool { return cfg.Config.HubImageNamePrefix != "" },
}
for _, exclusion := range exclusions {
if exclusion() {
return image, nil
}
}
return path.Join(c.hub, image), nil
}
// prependHubRegistry represents a way to prepend a custom Hub registry to the image name,
// using the HubImageNamePrefix configuration value
type prependHubRegistry struct {
prefix string
}
// newPrependHubRegistry creates a new prependHubRegistry
func newPrependHubRegistry(hubPrefix string) prependHubRegistry {
return prependHubRegistry{
prefix: hubPrefix,
}
}
// Description returns the name of the type and a short description of how it modifies the image.
func (p prependHubRegistry) Description() string {
return fmt.Sprintf("HubImageSubstitutor (prepends %s)", p.prefix)
}
// Substitute prepends the Hub prefix to the image name, with certain conditions:
// - if the prefix is empty, the image is returned as is.
// - if the image is a non-hub image (e.g. where another registry is set), the image is returned as is.
// - if the image is a Docker Hub image where the hub registry is explicitly part of the name
// (i.e. anything with a registry.hub.docker.com host part), the image is returned as is.
func (p prependHubRegistry) Substitute(image string) (string, error) {
registry := core.ExtractRegistry(image, "")
// add the exclusions in the right order
exclusions := []func() bool{
func() bool { return p.prefix == "" }, // no prefix set at the configuration level
func() bool { return registry != "" }, // non-hub image
func() bool { return registry == "docker.io" }, // explicitly including docker.io
func() bool { return registry == "registry.hub.docker.com" }, // explicitly including registry.hub.docker.com
}
for _, exclusion := range exclusions {
if exclusion() {
return image, nil
}
}
return path.Join(p.prefix, image), nil
}
// WithImageSubstitutors sets the image substitutors for a container
func WithImageSubstitutors(fn ...ImageSubstitutor) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.ImageSubstitutors = fn
return nil
}
}
// WithLogConsumers sets the log consumers for a container
func WithLogConsumers(consumer ...LogConsumer) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if req.LogConsumerCfg == nil {
req.LogConsumerCfg = &LogConsumerConfig{}
}
req.LogConsumerCfg.Consumers = consumer
return nil
}
}
// WithLogConsumerConfig sets the log consumer config for a container.
// Beware that this option completely replaces the existing log consumer config,
// including the log consumers and the log production options,
// so it should be used with care.
func WithLogConsumerConfig(config *LogConsumerConfig) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.LogConsumerCfg = config
return nil
}
}
// Executable represents an executable command to be sent to a container, including options,
// as part of the different lifecycle hooks.
type Executable interface {
AsCommand() []string
// Options can container two different types of options:
// - Docker's ExecConfigs (WithUser, WithWorkingDir, WithEnv, etc.)
// - testcontainers' ProcessOptions (i.e. Multiplexed response)
Options() []tcexec.ProcessOption
}
// ExecOptions is a struct that provides a default implementation for the Options method
// of the Executable interface.
type ExecOptions struct {
opts []tcexec.ProcessOption
}
func (ce ExecOptions) Options() []tcexec.ProcessOption {
return ce.opts
}
// RawCommand is a type that implements Executable and represents a command to be sent to a container
type RawCommand struct {
ExecOptions
cmds []string
}
func NewRawCommand(cmds []string, opts ...tcexec.ProcessOption) RawCommand {
return RawCommand{
cmds: cmds,
ExecOptions: ExecOptions{
opts: opts,
},
}
}
// AsCommand returns the command as a slice of strings
func (r RawCommand) AsCommand() []string {
return r.cmds
}
// WithStartupCommand will execute the command representation of each Executable into the container.
// It will leverage the container lifecycle hooks to call the command right after the container
// is started.
func WithStartupCommand(execs ...Executable) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
startupCommandsHook := ContainerLifecycleHooks{
PostStarts: []ContainerHook{},
}
for _, exec := range execs {
execFn := func(ctx context.Context, c Container) error {
_, _, err := c.Exec(ctx, exec.AsCommand(), exec.Options()...)
return err
}
startupCommandsHook.PostStarts = append(startupCommandsHook.PostStarts, execFn)
}
req.LifecycleHooks = append(req.LifecycleHooks, startupCommandsHook)
return nil
}
}
// WithAfterReadyCommand will execute the command representation of each Executable into the container.
// It will leverage the container lifecycle hooks to call the command right after the container
// is ready.
func WithAfterReadyCommand(execs ...Executable) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
postReadiesHook := make([]ContainerHook, 0, len(execs))
for _, exec := range execs {
execFn := func(ctx context.Context, c Container) error {
_, _, err := c.Exec(ctx, exec.AsCommand(), exec.Options()...)
return err
}
postReadiesHook = append(postReadiesHook, execFn)
}
req.LifecycleHooks = append(req.LifecycleHooks, ContainerLifecycleHooks{
PostReadies: postReadiesHook,
})
return nil
}
}
// WithWaitStrategy replaces the wait strategy for a container, using 60 seconds as deadline
func WithWaitStrategy(strategies ...wait.Strategy) CustomizeRequestOption {
return WithWaitStrategyAndDeadline(60*time.Second, strategies...)
}
// WithAdditionalWaitStrategy appends the wait strategy for a container, using 60 seconds as deadline
func WithAdditionalWaitStrategy(strategies ...wait.Strategy) CustomizeRequestOption {
return WithAdditionalWaitStrategyAndDeadline(60*time.Second, strategies...)
}
// WithWaitStrategyAndDeadline replaces the wait strategy for a container, including deadline
func WithWaitStrategyAndDeadline(deadline time.Duration, strategies ...wait.Strategy) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.WaitingFor = wait.ForAll(strategies...).WithDeadline(deadline)
return nil
}
}
// WithAdditionalWaitStrategyAndDeadline appends the wait strategy for a container, including deadline
func WithAdditionalWaitStrategyAndDeadline(deadline time.Duration, strategies ...wait.Strategy) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if req.WaitingFor == nil {
req.WaitingFor = wait.ForAll(strategies...).WithDeadline(deadline)
return nil
}
wss := make([]wait.Strategy, 0, len(strategies)+1)
wss = append(wss, req.WaitingFor)
wss = append(wss, strategies...)
req.WaitingFor = wait.ForAll(wss...).WithDeadline(deadline)
return nil
}
}
// WithImageMount mounts an image to a container, passing the source image name,
// the relative subpath to mount in that image, and the mount point in the target container.
// This option validates that the subpath is a relative path, raising an error otherwise.
func WithImageMount(source string, subpath string, target ContainerMountTarget) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
src := NewDockerImageMountSource(source, subpath)
if err := src.Validate(); err != nil {
return fmt.Errorf("validate image mount source: %w", err)
}
req.Mounts = append(req.Mounts, ContainerMount{
Source: src,
Target: target,
})
return nil
}
}
// WithAlwaysPull will pull the image before starting the container
func WithAlwaysPull() CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.AlwaysPullImage = true
return nil
}
}
// WithImagePlatform sets the platform for a container
func WithImagePlatform(platform string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.ImagePlatform = platform
return nil
}
}
// WithEntrypoint completely replaces the entrypoint of a container
func WithEntrypoint(entrypoint ...string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Entrypoint = entrypoint
return nil
}
}
// WithEntrypointArgs appends the entrypoint arguments to the entrypoint of a container
func WithEntrypointArgs(entrypointArgs ...string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Entrypoint = append(req.Entrypoint, entrypointArgs...)
return nil
}
}
// WithExposedPorts appends the ports to the exposed ports for a container
func WithExposedPorts(ports ...string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.ExposedPorts = append(req.ExposedPorts, ports...)
return nil
}
}
// WithCmd completely replaces the command for a container
func WithCmd(cmd ...string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Cmd = cmd
return nil
}
}
// WithCmdArgs appends the command arguments to the command for a container
func WithCmdArgs(cmdArgs ...string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Cmd = append(req.Cmd, cmdArgs...)
return nil
}
}
// WithLabels appends the labels to the labels for a container
func WithLabels(labels map[string]string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if req.Labels == nil {
req.Labels = make(map[string]string)
}
maps.Copy(req.Labels, labels)
return nil
}
}
// WithLifecycleHooks completely replaces the lifecycle hooks for a container
func WithLifecycleHooks(hooks ...ContainerLifecycleHooks) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.LifecycleHooks = hooks
return nil
}
}
// WithAdditionalLifecycleHooks appends lifecycle hooks to the existing ones for a container
func WithAdditionalLifecycleHooks(hooks ...ContainerLifecycleHooks) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.LifecycleHooks = append(req.LifecycleHooks, hooks...)
return nil
}
}
// WithMounts appends the mounts to the mounts for a container
func WithMounts(mounts ...ContainerMount) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Mounts = append(req.Mounts, mounts...)
return nil
}
}
// WithTmpfs appends the tmpfs mounts to the tmpfs mounts for a container
func WithTmpfs(tmpfs map[string]string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if req.Tmpfs == nil {
req.Tmpfs = make(map[string]string)
}
maps.Copy(req.Tmpfs, tmpfs)
return nil
}
}
// WithFiles appends the files to the files for a container
func WithFiles(files ...ContainerFile) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.Files = append(req.Files, files...)
return nil
}
}
// WithProvider sets the provider type for a container
func WithProvider(provider ProviderType) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
req.ProviderType = provider
return nil
}
}
+107
View File
@@ -0,0 +1,107 @@
package testcontainers
import (
"context"
"fmt"
"sync"
)
const (
defaultWorkersCount = 8
)
type ParallelContainerRequest []GenericContainerRequest
// ParallelContainersOptions represents additional options for parallel running
type ParallelContainersOptions struct {
WorkersCount int // count of parallel workers. If field empty(zero), default value will be 'defaultWorkersCount'
}
// ParallelContainersRequestError represents error from parallel request
type ParallelContainersRequestError struct {
Request GenericContainerRequest
Error error
}
type ParallelContainersError struct {
Errors []ParallelContainersRequestError
}
func (gpe ParallelContainersError) Error() string {
return fmt.Sprintf("%v", gpe.Errors)
}
// parallelContainersResult represents result.
type parallelContainersResult struct {
ParallelContainersRequestError
Container Container
}
func parallelContainersRunner(
ctx context.Context,
requests <-chan GenericContainerRequest,
results chan<- parallelContainersResult,
wg *sync.WaitGroup,
) {
defer wg.Done()
for req := range requests {
c, err := GenericContainer(ctx, req)
res := parallelContainersResult{Container: c}
if err != nil {
res.Request = req
res.Error = err
}
results <- res
}
}
// ParallelContainers creates a generic containers with parameters and run it in parallel mode
func ParallelContainers(ctx context.Context, reqs ParallelContainerRequest, opt ParallelContainersOptions) ([]Container, error) {
if opt.WorkersCount == 0 {
opt.WorkersCount = defaultWorkersCount
}
tasksChanSize := min(opt.WorkersCount, len(reqs))
tasksChan := make(chan GenericContainerRequest, tasksChanSize)
resultsChan := make(chan parallelContainersResult, tasksChanSize)
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(tasksChanSize)
// run workers
for range tasksChanSize {
go parallelContainersRunner(ctx, tasksChan, resultsChan, &wg)
}
var errs []ParallelContainersRequestError
containers := make([]Container, 0, len(reqs))
go func() {
defer close(done)
for res := range resultsChan {
if res.Error != nil {
errs = append(errs, res.ParallelContainersRequestError)
} else {
containers = append(containers, res.Container)
}
}
}()
for _, req := range reqs {
tasksChan <- req
}
close(tasksChan)
wg.Wait()
close(resultsChan)
<-done
if len(errs) != 0 {
return containers, ParallelContainersError{Errors: errs}
}
return containers, nil
}
@@ -0,0 +1,413 @@
package testcontainers
import (
"context"
"errors"
"fmt"
"io"
"net"
"slices"
"sync"
"time"
"github.com/google/uuid"
"github.com/moby/moby/api/types/container"
"golang.org/x/crypto/ssh"
"github.com/testcontainers/testcontainers-go/internal/core/network"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
// hubSshdImage {
sshdImage string = "testcontainers/sshd:1.3.0"
// }
// HostInternal is the internal hostname used to reach the host from the container,
// using the SSHD container as a bridge.
HostInternal string = "host.testcontainers.internal"
user string = "root"
sshPort = "22/tcp"
)
// sshPassword is a random password generated for the SSHD container.
var sshPassword = uuid.NewString()
// exposeHostPorts performs all the necessary steps to expose the host ports to the container, leveraging
// the SSHD container to create the tunnel, and the container lifecycle hooks to manage the tunnel lifecycle.
// At least one port must be provided to expose.
// The steps are:
// 1. Create a new SSHD container.
// 2. Expose the host ports to the container after the container is ready.
// 3. Close the SSH sessions before killing the container.
func exposeHostPorts(ctx context.Context, req *ContainerRequest, ports ...int) (sshdConnectHook ContainerLifecycleHooks, err error) {
if len(ports) == 0 {
return sshdConnectHook, errors.New("no ports to expose")
}
// Use the first network of the container to connect to the SSHD container.
var sshdFirstNetwork string
if len(req.Networks) > 0 {
sshdFirstNetwork = req.Networks[0]
}
if sshdFirstNetwork == "bridge" && len(req.Networks) > 1 {
sshdFirstNetwork = req.Networks[1]
}
opts := []ContainerCustomizer{}
if len(req.Networks) > 0 {
// get the first network of the container to connect the SSHD container to it.
nw, err := network.GetByName(ctx, sshdFirstNetwork)
if err != nil {
return sshdConnectHook, fmt.Errorf("get network %q: %w", sshdFirstNetwork, err)
}
dockerNw := DockerNetwork{
ID: nw.ID,
Name: nw.Name,
}
// WithNetwork reuses an already existing network, attaching the container to it.
// Finally it sets the network alias on that network to the given alias.
// TODO: Using an anonymous function to avoid cyclic dependencies with the network package.
withNetwork := func(aliases []string, nw *DockerNetwork) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
networkName := nw.Name
// attaching to the network because it was created with success or it already existed.
req.Networks = append(req.Networks, networkName)
if req.NetworkAliases == nil {
req.NetworkAliases = make(map[string][]string)
}
req.NetworkAliases[networkName] = aliases
return nil
}
}
opts = append(opts, withNetwork([]string{HostInternal}, &dockerNw))
}
// start the SSHD container with the provided options
sshdContainer, err := newSshdContainer(ctx, opts...)
// Ensure the SSHD container is stopped and removed in case of error.
defer func() {
if err != nil {
err = errors.Join(err, TerminateContainer(sshdContainer))
}
}()
if err != nil {
return sshdConnectHook, fmt.Errorf("new sshd container: %w", err)
}
// IP in the first network of the container.
inspect, err := sshdContainer.Inspect(ctx)
if err != nil {
return sshdConnectHook, fmt.Errorf("inspect sshd container: %w", err)
}
var sshdIP string
single := len(inspect.NetworkSettings.Networks) == 1
for name, nw := range inspect.NetworkSettings.Networks {
if name == sshdFirstNetwork || single {
if nw.IPAddress.IsValid() {
sshdIP = nw.IPAddress.String()
break
}
}
}
if sshdIP == "" {
return sshdConnectHook, errors.New("sshd container IP not found")
}
if req.HostConfigModifier == nil {
req.HostConfigModifier = func(_ *container.HostConfig) {}
}
// do not override the original HostConfigModifier
originalHCM := req.HostConfigModifier
req.HostConfigModifier = func(hostConfig *container.HostConfig) {
// adding the host internal alias to the container as an extra host
// to allow the container to reach the SSHD container.
hostConfig.ExtraHosts = append(hostConfig.ExtraHosts, fmt.Sprintf("%s:%s", HostInternal, sshdIP))
modes := []container.NetworkMode{container.NetworkMode(sshdFirstNetwork), "none", "host"}
// if the container is not in one of the modes, attach it to the first network of the SSHD container
found := slices.Contains(modes, hostConfig.NetworkMode)
if !found {
req.Networks = append(req.Networks, sshdFirstNetwork)
}
// invoke the original HostConfigModifier with the updated hostConfig
originalHCM(hostConfig)
}
stopHooks := []ContainerHook{
func(ctx context.Context, _ Container) error {
if ctx.Err() != nil {
// Context already canceled, need to create a new one to ensure
// the SSH session is closed.
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
}
return TerminateContainer(sshdContainer, StopContext(ctx))
},
}
// after the container is ready, create the SSH tunnel
// for each exposed port from the host.
sshdConnectHook = ContainerLifecycleHooks{
PostReadies: []ContainerHook{
func(ctx context.Context, _ Container) error {
return sshdContainer.exposeHostPort(ctx, req.HostAccessPorts...)
},
},
PostStops: stopHooks,
PreTerminates: stopHooks,
}
return sshdConnectHook, nil
}
// newSshdContainer creates a new SSHD container with the provided options.
func newSshdContainer(ctx context.Context, opts ...ContainerCustomizer) (*sshdContainer, error) {
moduleOpts := make([]ContainerCustomizer, 0, 3+len(opts))
moduleOpts = append(moduleOpts,
WithExposedPorts(sshPort),
WithEnv(map[string]string{"PASSWORD": sshPassword}),
WithWaitStrategy(wait.ForListeningPort(sshPort)),
)
moduleOpts = append(moduleOpts, opts...)
c, err := Run(ctx, sshdImage, moduleOpts...)
var sshd *sshdContainer
if c != nil {
sshd = &sshdContainer{Container: c}
}
if err != nil {
return sshd, fmt.Errorf("run sshd container: %w", err)
}
if err = sshd.clientConfig(ctx); err != nil {
// Return the container and the error to the caller to handle it.
return sshd, err
}
return sshd, nil
}
// sshdContainer represents the SSHD container type used for the port forwarding container.
// It's an internal type that extends the DockerContainer type, to add the SSH tunnelling capabilities.
type sshdContainer struct {
Container
port string
sshConfig *ssh.ClientConfig
portForwarders []*portForwarder
}
// Terminate stops the container and closes the SSH session
func (sshdC *sshdContainer) Terminate(ctx context.Context, opts ...TerminateOption) error {
return errors.Join(
sshdC.closePorts(),
sshdC.Container.Terminate(ctx, opts...),
)
}
// Stop stops the container and closes the SSH session
func (sshdC *sshdContainer) Stop(ctx context.Context, timeout *time.Duration) error {
return errors.Join(
sshdC.closePorts(),
sshdC.Container.Stop(ctx, timeout),
)
}
// closePorts closes all port forwarders.
func (sshdC *sshdContainer) closePorts() error {
var errs []error
for _, pfw := range sshdC.portForwarders {
if err := pfw.Close(); err != nil {
errs = append(errs, err)
}
}
sshdC.portForwarders = nil // Ensure the port forwarders are not used after closing.
return errors.Join(errs...)
}
// clientConfig sets up the SSHD client configuration.
func (sshdC *sshdContainer) clientConfig(ctx context.Context) error {
mappedPort, err := sshdC.MappedPort(ctx, sshPort)
if err != nil {
return fmt.Errorf("mapped port: %w", err)
}
sshdC.port = mappedPort.Port()
sshdC.sshConfig = &ssh.ClientConfig{
User: user,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Auth: []ssh.AuthMethod{ssh.Password(sshPassword)},
}
return nil
}
// exposeHostPort exposes the host ports to the container.
func (sshdC *sshdContainer) exposeHostPort(ctx context.Context, ports ...int) (err error) {
defer func() {
if err != nil {
err = errors.Join(err, sshdC.closePorts())
}
}()
for _, port := range ports {
pf, err := newPortForwarder(ctx, "localhost:"+sshdC.port, sshdC.sshConfig, port)
if err != nil {
return fmt.Errorf("new port forwarder: %w", err)
}
sshdC.portForwarders = append(sshdC.portForwarders, pf)
}
return nil
}
// portForwarder forwards a port from the container to the host.
type portForwarder struct {
client *ssh.Client
listener net.Listener
dialTimeout time.Duration
localAddr string
ctx context.Context
cancel context.CancelFunc
// closeMtx protects the close operation
closeMtx sync.Mutex
closeErr error
}
// newPortForwarder creates a new running portForwarder for the given port.
// The context is only used for the initial SSH connection.
func newPortForwarder(ctx context.Context, sshDAddr string, sshConfig *ssh.ClientConfig, port int) (pf *portForwarder, err error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", sshDAddr)
if err != nil {
return nil, fmt.Errorf("ssh dial: %w", err)
}
// Ensure the connection is closed in case of error.
defer func() {
if err != nil {
err = errors.Join(err, conn.Close())
}
}()
c, chans, reqs, err := ssh.NewClientConn(conn, sshDAddr, sshConfig)
if err != nil {
return nil, fmt.Errorf("ssh new client conn: %w", err)
}
client := ssh.NewClient(c, chans, reqs)
listener, err := client.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return nil, fmt.Errorf("listening on remote port %d: %w", port, err)
}
ctx, cancel := context.WithCancel(context.Background())
pf = &portForwarder{
client: client,
listener: listener,
localAddr: fmt.Sprintf("localhost:%d", port),
ctx: ctx,
cancel: cancel,
dialTimeout: time.Second * 2,
}
go pf.run()
return pf, nil
}
// Close closes the port forwarder.
func (pf *portForwarder) Close() error {
pf.closeMtx.Lock()
defer pf.closeMtx.Unlock()
select {
case <-pf.ctx.Done():
// Already closed.
return pf.closeErr
default:
}
var errs []error
if err := pf.listener.Close(); err != nil {
errs = append(errs, fmt.Errorf("close listener: %w", err))
}
if err := pf.client.Close(); err != nil {
errs = append(errs, fmt.Errorf("close client: %w", err))
}
pf.closeErr = errors.Join(errs...)
pf.cancel()
return pf.closeErr
}
// run forwards the port from the remote connection to the local connection.
func (pf *portForwarder) run() {
for {
remote, err := pf.listener.Accept()
if err != nil {
if errors.Is(err, io.EOF) {
// The listener has been closed.
return
}
// Ignore errors as they are transient and we want requests to
// continue to be accepted.
continue
}
go pf.tunnel(remote)
}
}
// tunnel runs a tunnel between two connections; as soon as the forwarder
// context is cancelled or one connection copies returns, irrespective of
// the error, both connections are closed.
func (pf *portForwarder) tunnel(remote net.Conn) {
defer remote.Close()
ctx, cancel := context.WithTimeout(pf.ctx, pf.dialTimeout)
defer cancel()
var dialer net.Dialer
local, err := dialer.DialContext(ctx, "tcp", pf.localAddr)
if err != nil {
// Nothing we can do with the error.
return
}
defer local.Close()
ctx, cancel = context.WithCancel(pf.ctx)
go func() {
defer cancel()
io.Copy(local, remote) //nolint:errcheck // Nothing useful we can do with the error.
}()
go func() {
defer cancel()
io.Copy(remote, local) //nolint:errcheck // Nothing useful we can do with the error.
}()
// Wait for the context to be done before returning which triggers
// both connections to close. This is done to prevent the copies
// blocking forever on unused connections.
<-ctx.Done()
}
+158
View File
@@ -0,0 +1,158 @@
package testcontainers
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/testcontainers/testcontainers-go/internal/config"
"github.com/testcontainers/testcontainers-go/internal/core"
"github.com/testcontainers/testcontainers-go/log"
)
// possible provider types
const (
ProviderDefault ProviderType = iota // default will auto-detect provider from DOCKER_HOST environment variable
ProviderDocker
ProviderPodman
)
type (
// ProviderType is an enum for the possible providers
ProviderType int
// GenericProviderOptions defines options applicable to all providers
GenericProviderOptions struct {
Logger log.Logger
defaultNetwork string
}
// GenericProviderOption defines a common interface to modify GenericProviderOptions
// These options can be passed to GetProvider in a variadic way to customize the returned GenericProvider instance
GenericProviderOption interface {
ApplyGenericTo(opts *GenericProviderOptions)
}
// GenericProviderOptionFunc is a shorthand to implement the GenericProviderOption interface
GenericProviderOptionFunc func(opts *GenericProviderOptions)
// DockerProviderOptions defines options applicable to DockerProvider
DockerProviderOptions struct {
defaultBridgeNetworkName string
*GenericProviderOptions
}
// DockerProviderOption defines a common interface to modify DockerProviderOptions
// These can be passed to NewDockerProvider in a variadic way to customize the returned DockerProvider instance
DockerProviderOption interface {
ApplyDockerTo(opts *DockerProviderOptions)
}
// DockerProviderOptionFunc is a shorthand to implement the DockerProviderOption interface
DockerProviderOptionFunc func(opts *DockerProviderOptions)
)
func (f DockerProviderOptionFunc) ApplyDockerTo(opts *DockerProviderOptions) {
f(opts)
}
func Generic2DockerOptions(opts ...GenericProviderOption) []DockerProviderOption {
converted := make([]DockerProviderOption, 0, len(opts))
for _, o := range opts {
switch c := o.(type) {
case DockerProviderOption:
converted = append(converted, c)
default:
converted = append(converted, DockerProviderOptionFunc(func(opts *DockerProviderOptions) {
o.ApplyGenericTo(opts.GenericProviderOptions)
}))
}
}
return converted
}
func WithDefaultBridgeNetwork(bridgeNetworkName string) DockerProviderOption {
return DockerProviderOptionFunc(func(opts *DockerProviderOptions) {
opts.defaultBridgeNetworkName = bridgeNetworkName
})
}
func (f GenericProviderOptionFunc) ApplyGenericTo(opts *GenericProviderOptions) {
f(opts)
}
// ContainerProvider allows the creation of containers on an arbitrary system
type ContainerProvider interface {
Close() error // close the provider
CreateContainer(context.Context, ContainerRequest) (Container, error) // create a container without starting it
ReuseOrCreateContainer(context.Context, ContainerRequest) (Container, error) // reuses a container if it exists or creates a container without starting
RunContainer(context.Context, ContainerRequest) (Container, error) // create a container and start it
Health(context.Context) error
Config() TestcontainersConfig
}
// GetProvider provides the provider implementation for a certain type
func (t ProviderType) GetProvider(opts ...GenericProviderOption) (GenericProvider, error) {
opt := &GenericProviderOptions{
Logger: log.Default(),
}
for _, o := range opts {
o.ApplyGenericTo(opt)
}
pt := t
if pt == ProviderDefault && strings.Contains(os.Getenv("DOCKER_HOST"), "podman.sock") {
pt = ProviderPodman
}
switch pt {
case ProviderDefault, ProviderDocker:
providerOptions := append(Generic2DockerOptions(opts...), WithDefaultBridgeNetwork(Bridge))
provider, err := NewDockerProvider(providerOptions...)
if err != nil {
return nil, fmt.Errorf("%w, failed to create Docker provider", err)
}
return provider, nil
case ProviderPodman:
providerOptions := append(Generic2DockerOptions(opts...), WithDefaultBridgeNetwork(Podman))
provider, err := NewDockerProvider(providerOptions...)
if err != nil {
return nil, fmt.Errorf("%w, failed to create Docker provider", err)
}
return provider, nil
}
return nil, errors.New("unknown provider")
}
// NewDockerProvider creates a Docker provider with the EnvClient
func NewDockerProvider(provOpts ...DockerProviderOption) (*DockerProvider, error) {
o := &DockerProviderOptions{
GenericProviderOptions: &GenericProviderOptions{
Logger: log.Default(),
},
}
for idx := range provOpts {
provOpts[idx].ApplyDockerTo(o)
}
ctx := context.Background()
host, err := core.ExtractDockerHost(ctx)
if err != nil {
return nil, err
}
c, err := NewDockerClientWithOpts(ctx)
if err != nil {
return nil, err
}
return &DockerProvider{
DockerProviderOptions: o,
client: c,
host: host,
config: config.Read(),
}, nil
}
+577
View File
@@ -0,0 +1,577 @@
package testcontainers
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strings"
"sync"
"syscall"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"github.com/testcontainers/testcontainers-go/internal/config"
"github.com/testcontainers/testcontainers-go/internal/core"
"github.com/testcontainers/testcontainers-go/log"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
// Deprecated: it has been replaced by the internal core.LabelLang
TestcontainerLabel = "org.testcontainers.golang"
// Deprecated: it has been replaced by the internal core.LabelSessionID
TestcontainerLabelSessionID = TestcontainerLabel + ".sessionId"
// Deprecated: it has been replaced by the internal core.LabelReaper
TestcontainerLabelIsReaper = TestcontainerLabel + ".reaper"
)
var (
// Deprecated: it has been replaced by an internal value
ReaperDefaultImage = config.ReaperDefaultImage
// defaultReaperPort is the default port that the reaper listens on if not
// overridden by the RYUK_PORT environment variable.
defaultReaperPort = network.MustParsePort("8080/tcp")
// errReaperNotFound is returned when no reaper container is found.
errReaperNotFound = errors.New("reaper not found")
// errReaperDisabled is returned if a reaper is requested but the
// config has it disabled.
errReaperDisabled = errors.New("reaper disabled")
// spawner is the singleton instance of reaperSpawner.
spawner = &reaperSpawner{}
// reaperAck is the expected response from the reaper container.
reaperAck = []byte("ACK\n")
)
// ReaperProvider represents a provider for the reaper to run itself with
// The ContainerProvider interface should usually satisfy this as well, so it is pluggable
type ReaperProvider interface {
RunContainer(ctx context.Context, req ContainerRequest) (Container, error)
Config() TestcontainersConfig
}
// Deprecated: it's not possible to create a reaper any more. Compose module uses this method
// to create a reaper for the compose stack.
//
// # NewReaper creates a Reaper with a sessionID to identify containers and a provider to use
//
// The caller must call Connect at least once on the returned Reaper and use the returned
// result otherwise the reaper will be kept open until the process exits.
func NewReaper(ctx context.Context, sessionID string, provider ReaperProvider, _ string) (*Reaper, error) {
reaper, err := spawner.reaper(ctx, sessionID, provider)
if err != nil {
return nil, fmt.Errorf("reaper: %w", err)
}
return reaper, nil
}
// reaperContainerNameFromSessionID returns the container name that uniquely
// identifies the container based on the session id.
func reaperContainerNameFromSessionID(sessionID string) string {
// The session id is 64 characters, so we will not hit the limit of 128
// characters for container names.
return "reaper_" + sessionID
}
// reaperSpawner is a singleton that manages the reaper container.
type reaperSpawner struct {
instance *Reaper
mtx sync.Mutex
}
// port returns the port that a new reaper should listen on.
func (r *reaperSpawner) port() network.Port {
if port := os.Getenv("RYUK_PORT"); port != "" {
natPort, err := network.ParsePort(port + "/tcp")
if err != nil {
panic(fmt.Sprintf("invalid RYUK_PORT value %q: %s", port, err))
}
return natPort
}
return defaultReaperPort
}
// backoff returns a backoff policy for the reaper spawner.
// It will take at most 20 seconds, doing each attempt every 100ms - 250ms.
func (r *reaperSpawner) backoff() *backoff.ExponentialBackOff {
// We want random intervals between 100ms and 250ms for concurrent executions
// to not be synchronized: it could be the case that multiple executions of this
// function happen at the same time (specifically when called from a different test
// process execution), and we want to avoid that they all try to find the reaper
// container at the same time.
b := &backoff.ExponentialBackOff{
InitialInterval: time.Millisecond * 100,
RandomizationFactor: backoff.DefaultRandomizationFactor,
Multiplier: backoff.DefaultMultiplier,
// Adjust MaxInterval to compensate for randomization factor which can be added to
// returned interval so we have a maximum of 250ms.
MaxInterval: time.Duration(float64(time.Millisecond*250) * backoff.DefaultRandomizationFactor),
MaxElapsedTime: time.Second * 20,
Stop: backoff.Stop,
Clock: backoff.SystemClock,
}
b.Reset()
return b
}
// cleanup terminates the reaper container if set.
func (r *reaperSpawner) cleanup() error {
r.mtx.Lock()
defer r.mtx.Unlock()
return r.cleanupLocked()
}
// cleanupLocked terminates the reaper container if set.
// It must be called with the lock held.
func (r *reaperSpawner) cleanupLocked() error {
if r.instance == nil {
return nil
}
err := TerminateContainer(r.instance.container)
r.instance = nil
return err
}
// lookupContainer returns a DockerContainer type with the reaper container in the case
// it's found in the running state, and including the labels for sessionID, reaper, and ryuk.
// It will perform a retry with exponential backoff to allow for the container to be started and
// avoid potential false negatives.
func (r *reaperSpawner) lookupContainer(ctx context.Context, sessionID string) (*DockerContainer, error) {
dockerClient, err := NewDockerClientWithOpts(ctx)
if err != nil {
return nil, fmt.Errorf("new client: %w", err)
}
defer dockerClient.Close()
provider, err := NewDockerProvider()
if err != nil {
return nil, fmt.Errorf("new provider: %w", err)
}
provider.SetClient(dockerClient)
opts := client.ContainerListOptions{
All: true,
Filters: make(client.Filters).
Add("label", fmt.Sprintf("%s=%s", core.LabelSessionID, sessionID)).
Add("label", fmt.Sprintf("%s=%t", core.LabelReaper, true)).
Add("label", fmt.Sprintf("%s=%t", core.LabelRyuk, true)).
Add("name", reaperContainerNameFromSessionID(sessionID)),
}
return backoff.RetryWithData(
func() (*DockerContainer, error) {
resp, err := dockerClient.ContainerList(ctx, opts)
if err != nil {
return nil, fmt.Errorf("container list: %w", err)
}
if len(resp.Items) == 0 {
// No reaper container not found.
return nil, backoff.Permanent(errReaperNotFound)
}
if len(resp.Items) > 1 {
return nil, fmt.Errorf("found %d reaper containers for session ID %q", len(resp.Items), sessionID)
}
r, err := provider.ContainerFromType(ctx, resp.Items[0])
if err != nil {
return nil, fmt.Errorf("from docker: %w", err)
}
switch r.healthStatus {
case "", container.Healthy, container.NoHealthcheck:
return r, nil
default:
return nil, fmt.Errorf("container not healthy: %s", r.healthStatus)
}
},
backoff.WithContext(r.backoff(), ctx),
)
}
// isRunning returns an error if the container is not running.
func (r *reaperSpawner) isRunning(ctx context.Context, ctr Container) error {
state, err := ctr.State(ctx)
if err != nil {
return fmt.Errorf("container state: %w", err)
}
if !state.Running {
// Use NotFound error to indicate the container is not running
// and should be recreated.
return errdefs.ErrNotFound.WithMessage("container state: " + string(state.Status))
}
return nil
}
// retryError returns a permanent error if the error is not considered retryable.
func (r *reaperSpawner) retryError(err error) error {
var timeout interface {
Timeout() bool
}
switch {
case isCleanupSafe(err),
createContainerFailDueToNameConflictRegex.MatchString(err.Error()),
errors.Is(err, syscall.ECONNREFUSED),
errors.Is(err, syscall.ECONNRESET),
errors.Is(err, syscall.ECONNABORTED),
errors.Is(err, syscall.ETIMEDOUT),
errors.Is(err, os.ErrDeadlineExceeded),
errors.As(err, &timeout) && timeout.Timeout(),
errors.Is(err, context.DeadlineExceeded),
errors.Is(err, context.Canceled):
// Retryable error.
return err
default:
return backoff.Permanent(err)
}
}
// reaper returns an existing Reaper instance if it exists and is running, otherwise
// a new Reaper instance will be created with a sessionID to identify containers in
// the same test session/program. If connect is true, the reaper will be connected
// to the reaper container.
// Returns an error if config.RyukDisabled is true.
//
// Safe for concurrent calls.
func (r *reaperSpawner) reaper(ctx context.Context, sessionID string, provider ReaperProvider) (*Reaper, error) {
if config.Read().RyukDisabled {
return nil, errReaperDisabled
}
r.mtx.Lock()
defer r.mtx.Unlock()
return backoff.RetryWithData(
r.retryLocked(ctx, sessionID, provider),
backoff.WithContext(r.backoff(), ctx),
)
}
// retryLocked returns a function that can be used to create or reuse a reaper container.
// If connect is true, the reaper will be connected to the reaper container.
// It must be called with the lock held.
func (r *reaperSpawner) retryLocked(ctx context.Context, sessionID string, provider ReaperProvider) func() (*Reaper, error) {
return func() (reaper *Reaper, err error) {
reaper, err = r.reuseOrCreate(ctx, sessionID, provider)
// Ensure that the reaper is terminated if an error occurred.
defer func() {
if err != nil {
if reaper != nil {
err = errors.Join(err, TerminateContainer(reaper.container))
}
err = r.retryError(errors.Join(err, r.cleanupLocked()))
}
}()
if err != nil {
return nil, err
}
if err = r.isRunning(ctx, reaper.container); err != nil {
return nil, err
}
// Check we can still connect.
termSignal, err := reaper.connect(ctx)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
reaper.setOrSignal(termSignal)
r.instance = reaper
return reaper, nil
}
}
// reuseOrCreate returns an existing Reaper instance if it exists, otherwise a new Reaper instance.
func (r *reaperSpawner) reuseOrCreate(ctx context.Context, sessionID string, provider ReaperProvider) (*Reaper, error) {
if r.instance != nil {
// We already have an associated reaper.
return r.instance, nil
}
// Look for an existing reaper created in the same test session but in a
// different test process execution e.g. when running tests in parallel.
container, err := r.lookupContainer(context.Background(), sessionID)
if err != nil {
if !errors.Is(err, errReaperNotFound) {
return nil, fmt.Errorf("look up container: %w", err)
}
// The reaper container was not found, continue to create a new one.
reaper, err := r.newReaper(ctx, sessionID, provider)
if err != nil {
return nil, fmt.Errorf("new reaper: %w", err)
}
return reaper, nil
}
// A reaper container exists re-use it.
reaper, err := r.fromContainer(ctx, sessionID, provider, container)
if err != nil {
return nil, fmt.Errorf("from container %q: %w", container.ID[:8], err)
}
return reaper, nil
}
// fromContainer constructs a Reaper from an already running reaper DockerContainer.
func (r *reaperSpawner) fromContainer(ctx context.Context, sessionID string, provider ReaperProvider, dockerContainer *DockerContainer) (*Reaper, error) {
log.Printf("⏳ Waiting for Reaper %q to be ready", dockerContainer.ID[:8])
// Reusing an existing container so we determine the port from the container's exposed ports.
if err := wait.ForExposedPort().
WithPollInterval(100*time.Millisecond).
SkipInternalCheck().
WaitUntilReady(ctx, dockerContainer); err != nil {
return nil, fmt.Errorf("wait for reaper %s: %w", dockerContainer.ID[:8], err)
}
endpoint, err := dockerContainer.Endpoint(ctx, "")
if err != nil {
return nil, fmt.Errorf("port endpoint: %w", err)
}
log.Printf("🔥 Reaper obtained from Docker for this test session %s", dockerContainer.ID[:8])
return &Reaper{
Provider: provider,
SessionID: sessionID,
Endpoint: endpoint,
container: dockerContainer,
}, nil
}
// newReaper creates a connected Reaper with a sessionID to identify containers
// and a provider to use.
func (r *reaperSpawner) newReaper(ctx context.Context, sessionID string, provider ReaperProvider) (reaper *Reaper, err error) {
dockerHostMount := core.MustExtractDockerSocket(ctx)
port := r.port()
tcConfig := provider.Config().Config
req := ContainerRequest{
Image: config.ReaperDefaultImage,
ExposedPorts: []string{port.String()},
Labels: core.DefaultLabels(sessionID),
WaitingFor: wait.ForListeningPort(port.String()),
Name: reaperContainerNameFromSessionID(sessionID),
HostConfigModifier: func(hc *container.HostConfig) {
hc.AutoRemove = true
hc.Binds = []string{dockerHostMount + ":/var/run/docker.sock"}
hc.NetworkMode = Bridge
hc.Privileged = tcConfig.RyukPrivileged
},
Env: map[string]string{},
}
if to := tcConfig.RyukConnectionTimeout; to > time.Duration(0) {
req.Env["RYUK_CONNECTION_TIMEOUT"] = to.String()
}
if to := tcConfig.RyukReconnectionTimeout; to > time.Duration(0) {
req.Env["RYUK_RECONNECTION_TIMEOUT"] = to.String()
}
if tcConfig.RyukVerbose {
req.Env["RYUK_VERBOSE"] = "true"
}
// Setup reaper-specific labels for the reaper container.
req.Labels[core.LabelReaper] = "true"
req.Labels[core.LabelRyuk] = "true"
delete(req.Labels, core.LabelReap)
// Attach reaper container to a requested network if it is specified
if p, ok := provider.(*DockerProvider); ok {
defaultNetwork, err := p.ensureDefaultNetwork(ctx)
if err != nil {
return nil, fmt.Errorf("ensure default network: %w", err)
}
req.Networks = append(req.Networks, defaultNetwork)
}
c, err := provider.RunContainer(ctx, req)
defer func() {
if err != nil {
err = errors.Join(err, TerminateContainer(c))
}
}()
if err != nil {
return nil, fmt.Errorf("run container: %w", err)
}
endpoint, err := c.PortEndpoint(ctx, port.String(), "")
if err != nil {
return nil, fmt.Errorf("port endpoint: %w", err)
}
return &Reaper{
Provider: provider,
SessionID: sessionID,
Endpoint: endpoint,
container: c,
}, nil
}
// Reaper is used to start a sidecar container that cleans up resources
type Reaper struct {
Provider ReaperProvider
SessionID string
Endpoint string
container Container
mtx sync.Mutex // Protects termSignal.
termSignal chan bool
}
// Connect connects to the reaper container and sends the labels to it
// so that it can clean up the containers with the same labels.
//
// It returns a channel that can be closed to terminate the connection.
// Returns an error if config.RyukDisabled is true.
func (r *Reaper) Connect() (chan bool, error) {
if config.Read().RyukDisabled {
return nil, errReaperDisabled
}
if termSignal := r.useTermSignal(); termSignal != nil {
return termSignal, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
return r.connect(ctx)
}
// close signals the connection to close if needed.
// Safe for concurrent calls.
func (r *Reaper) close() {
r.mtx.Lock()
defer r.mtx.Unlock()
if r.termSignal != nil {
r.termSignal <- true
r.termSignal = nil
}
}
// setOrSignal sets the reapers termSignal field if nil
// otherwise consumes by sending true to it.
// Safe for concurrent calls.
func (r *Reaper) setOrSignal(termSignal chan bool) {
r.mtx.Lock()
defer r.mtx.Unlock()
if r.termSignal != nil {
// Already have an existing connection, close the new one.
termSignal <- true
return
}
// First or new unused termSignal, assign for caller to reuse.
r.termSignal = termSignal
}
// useTermSignal if termSignal is not nil returns it
// and sets it to nil, otherwise returns nil.
//
// Safe for concurrent calls.
func (r *Reaper) useTermSignal() chan bool {
r.mtx.Lock()
defer r.mtx.Unlock()
if r.termSignal == nil {
return nil
}
// Use existing connection.
term := r.termSignal
r.termSignal = nil
return term
}
// connect connects to the reaper container and sends the labels to it
// so that it can clean up the containers with the same labels.
//
// It returns a channel that can be sent true to terminate the connection.
// Returns an error if config.RyukDisabled is true.
func (r *Reaper) connect(ctx context.Context) (chan bool, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", r.Endpoint)
if err != nil {
return nil, fmt.Errorf("dial reaper %s: %w", r.Endpoint, err)
}
terminationSignal := make(chan bool)
go func() {
defer conn.Close()
if err := r.handshake(conn); err != nil {
log.Printf("Reaper handshake failed: %s", err)
}
<-terminationSignal
}()
return terminationSignal, nil
}
// handshake sends the labels to the reaper container and reads the ACK.
func (r *Reaper) handshake(conn net.Conn) error {
labels := core.DefaultLabels(r.SessionID)
labelFilters := make([]string, 0, len(labels))
for l, v := range labels {
labelFilters = append(labelFilters, fmt.Sprintf("label=%s=%s", l, v))
}
filters := []byte(strings.Join(labelFilters, "&") + "\n")
buf := make([]byte, 4)
if _, err := conn.Write(filters); err != nil {
return fmt.Errorf("writing filters: %w", err)
}
n, err := io.ReadFull(conn, buf)
if err != nil {
return fmt.Errorf("read ack: %w", err)
}
if !bytes.Equal(reaperAck, buf[:n]) {
// We have received the ACK so all done.
return fmt.Errorf("unexpected reaper response: %s", buf[:n])
}
return nil
}
// Deprecated: internally replaced by core.DefaultLabels(sessionID)
//
// Labels returns the container labels to use so that this Reaper cleans them up
func (r *Reaper) Labels() map[string]string {
return GenericLabels()
}
// isReaperImage returns true if the image name is the reaper image.
func isReaperImage(name string) bool {
return strings.HasSuffix(name, config.ReaperDefaultImage)
}
@@ -0,0 +1,5 @@
mkdocs==1.5.3
mkdocs-codeinclude-plugin==0.3.1
mkdocs-include-markdown-plugin==7.2.1
mkdocs-material==9.5.18
mkdocs-markdownextradata-plugin==0.2.6
@@ -0,0 +1 @@
3.13
@@ -0,0 +1,54 @@
package testcontainers
import (
"context"
"github.com/testcontainers/testcontainers-go/internal/core"
)
// Deprecated: use MustExtractDockerHost instead.
func ExtractDockerSocket() string {
return MustExtractDockerSocket(context.Background())
}
// MustExtractDockerSocket Extracts the docker socket from the different alternatives, removing the socket schema.
// Use this function to get the docker socket path, not the host (e.g. mounting the socket in a container).
// This function does not consider Windows containers at the moment.
// The possible alternatives are:
//
// 1. Docker host from the "tc.host" property in the ~/.testcontainers.properties file.
// 2. The TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE environment variable.
// 3. Using a Docker client, check if the Info().OperatingSystem is "Docker Desktop" and return the default docker socket path for rootless docker.
// 4. Else, Get the current Docker Host from the existing strategies: see MustExtractDockerHost.
// 5. If the socket contains the unix schema, the schema is removed (e.g. unix:///var/run/docker.sock -> /var/run/docker.sock)
// 6. Else, the default location of the docker socket is used (/var/run/docker.sock)
//
// It panics if a Docker client cannot be created, or the Docker host cannot be discovered.
func MustExtractDockerSocket(ctx context.Context) string {
return core.MustExtractDockerSocket(ctx)
}
// SessionID returns a unique session ID for the current test session. Because each Go package
// will be run in a separate process, we need a way to identify the current test session.
// By test session, we mean:
// - a single "go test" invocation (including flags)
// - a single "go test ./..." invocation (including flags)
// - the execution of a single test or a set of tests using the IDE
//
// As a consequence, with the sole goal of aggregating test execution across multiple
// packages, this variable will contain the value of the parent process ID (pid) of the current process
// and its creation date, to use it to generate a unique session ID. We are using the parent pid because
// the current process will be a child process of:
// - the process that is running the tests, e.g.: "go test";
// - the process that is running the application in development mode, e.g. "go run main.go -tags dev";
// - the process that is running the tests in the IDE, e.g.: "go test ./...".
//
// Finally, we will hash the combination of the "testcontainers-go:" string with the parent pid
// and the creation date of that parent process to generate a unique session ID.
//
// This sessionID will be used to:
// - identify the test session, aggregating the test execution of multiple packages in the same test session.
// - tag the containers created by testcontainers-go, adding a label to the container with the session ID.
func SessionID() string {
return core.SessionID()
}
+204
View File
@@ -0,0 +1,204 @@
package testcontainers
import (
"context"
"fmt"
"io"
"os"
"regexp"
"strings"
"testing"
"github.com/containerd/errdefs"
"github.com/moby/moby/client"
"github.com/stretchr/testify/require"
)
// errAlreadyInProgress is a regular expression that matches the error for a container
// removal that is already in progress.
var errAlreadyInProgress = regexp.MustCompile(`removal of container .* is already in progress`)
// SkipIfProviderIsNotHealthy is a utility function capable of skipping tests
// if the provider is not healthy, or running at all.
// This is a function designed to be used in your test, when Docker is not mandatory for CI/CD.
// In this way tests that depend on Testcontainers won't run if the provider is provisioned correctly.
func SkipIfProviderIsNotHealthy(t *testing.T) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Skipf("Recovered from panic: %v. Docker is not running. Testcontainers can't perform is work without it", r)
}
}()
ctx := context.Background()
provider, err := ProviderDocker.GetProvider()
if err != nil {
t.Skipf("Docker is not running. Testcontainers can't perform is work without it: %s", err)
}
err = provider.Health(ctx)
if err != nil {
t.Skipf("Docker is not running. Testcontainers can't perform is work without it: %s", err)
}
}
// SkipIfDockerDesktop is a utility function capable of skipping tests
// if tests are run using Docker Desktop or another VM-based Docker
// environment (e.g. colima) where host network access is not available.
func SkipIfDockerDesktop(t *testing.T, ctx context.Context) {
t.Helper()
// Colima runs Docker inside a Linux VM, so host networking doesn't work
// the same way as native Docker on Linux. Detect it via DOCKER_HOST which
// typically contains the colima socket path.
if strings.Contains(os.Getenv("DOCKER_HOST"), "colima") {
t.Skip("Skipping test that requires host network access when running in colima")
}
cli, err := NewDockerClientWithOpts(ctx)
require.NoErrorf(t, err, "failed to create docker client: %s", err)
res, err := cli.Info(ctx, client.InfoOptions{})
require.NoErrorf(t, err, "failed to get docker info: %s", err)
if res.Info.OperatingSystem == "Docker Desktop" {
t.Skip("Skipping test that requires host network access when running in Docker Desktop")
}
}
// SkipIfNotDockerDesktop is a utility function capable of skipping tests
// if tests are not run using Docker Desktop.
func SkipIfNotDockerDesktop(t *testing.T, ctx context.Context) {
t.Helper()
cli, err := NewDockerClientWithOpts(ctx)
require.NoErrorf(t, err, "failed to create docker client: %s", err)
res, err := cli.Info(ctx, client.InfoOptions{})
require.NoErrorf(t, err, "failed to get docker info: %s", err)
if res.Info.OperatingSystem != "Docker Desktop" {
t.Skip("Skipping test that needs Docker Desktop")
}
}
// exampleLogConsumer {
// StdoutLogConsumer is a LogConsumer that prints the log to stdout
type StdoutLogConsumer struct{}
// Accept prints the log to stdout
func (lc *StdoutLogConsumer) Accept(l Log) {
fmt.Print(string(l.Content))
}
// }
// CleanupContainer is a helper function that schedules the container
// to be stopped / terminated when the test ends.
//
// This should be called as a defer directly after (before any error check)
// of [GenericContainer](...) or a modules Run(...) in a test to ensure the
// container is stopped when the function ends.
//
// before any error check. If container is nil, it's a no-op.
func CleanupContainer(tb testing.TB, ctr Container, options ...TerminateOption) {
tb.Helper()
tb.Cleanup(func() {
noErrorOrIgnored(tb, TerminateContainer(ctr, options...))
})
}
// CleanupNetwork is a helper function that schedules the network to be
// removed when the test ends.
// This should be the first call after NewNetwork(...) in a test before
// any error check. If network is nil, it's a no-op.
func CleanupNetwork(tb testing.TB, network Network) {
tb.Helper()
tb.Cleanup(func() {
if !isNil(network) {
noErrorOrIgnored(tb, network.Remove(context.Background()))
}
})
}
// noErrorOrIgnored is a helper function that checks if the error is nil or an error
// we can ignore.
func noErrorOrIgnored(tb testing.TB, err error) {
tb.Helper()
if isCleanupSafe(err) {
return
}
require.NoError(tb, err)
}
// causer is an interface that allows to get the cause of an error.
type causer interface {
Cause() error
}
// wrapErr is an interface that allows to unwrap an error.
type wrapErr interface {
Unwrap() error
}
// unwrapErrs is an interface that allows to unwrap multiple errors.
type unwrapErrs interface {
Unwrap() []error
}
// isCleanupSafe reports whether all errors in err's tree are one of the
// following, so can safely be ignored:
// - nil
// - not found
// - already in progress
func isCleanupSafe(err error) bool {
if err == nil {
return true
}
// First try with containerd's errdefs
switch {
case errdefs.IsNotFound(err):
return true
case errdefs.IsConflict(err):
// Terminating a container that is already terminating.
if errAlreadyInProgress.MatchString(err.Error()) {
return true
}
return false
}
switch x := err.(type) { //nolint:errorlint // We need to check for interfaces.
case causer:
return isCleanupSafe(x.Cause())
case wrapErr:
return isCleanupSafe(x.Unwrap())
case unwrapErrs:
for _, e := range x.Unwrap() {
if !isCleanupSafe(e) {
return false
}
}
return true
default:
return false
}
}
// RequireContainerExec is a helper function that executes a command in a container
// It insures that there is no error during the execution
// Finally returns the output of its execution
func RequireContainerExec(ctx context.Context, t *testing.T, container Container, cmd []string) string {
t.Helper()
code, out, err := container.Exec(ctx, cmd)
require.NoError(t, err)
require.Zero(t, code)
checkBytes, err := io.ReadAll(out)
require.NoError(t, err)
return string(checkBytes)
}
@@ -0,0 +1,7 @@
package testcontainers
// Validator is an interface that can be implemented by types that need to validate their state.
type Validator interface {
// Validate validates the state of the type.
Validate() error
}
+115
View File
@@ -0,0 +1,115 @@
package wait
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"time"
)
// Implement interface
var (
_ Strategy = (*MultiStrategy)(nil)
_ StrategyTimeout = (*MultiStrategy)(nil)
)
type MultiStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
deadline *time.Duration
// additional properties
Strategies []Strategy
}
// WithStartupTimeoutDefault sets the default timeout for all inner wait strategies
func (ms *MultiStrategy) WithStartupTimeoutDefault(timeout time.Duration) *MultiStrategy {
ms.timeout = &timeout
return ms
}
// WithStartupTimeout sets a time.Duration which limits all wait strategies
//
// Deprecated: use WithDeadline
func (ms *MultiStrategy) WithStartupTimeout(timeout time.Duration) Strategy {
return ms.WithDeadline(timeout)
}
// WithDeadline sets a time.Duration which limits all wait strategies
func (ms *MultiStrategy) WithDeadline(deadline time.Duration) *MultiStrategy {
ms.deadline = &deadline
return ms
}
func ForAll(strategies ...Strategy) *MultiStrategy {
return &MultiStrategy{
Strategies: strategies,
}
}
func (ms *MultiStrategy) Timeout() *time.Duration {
return ms.timeout
}
// String returns a human-readable description of the wait strategy.
func (ms *MultiStrategy) String() string {
if len(ms.Strategies) == 0 {
return "all of: (none)"
}
var strategies []string
for _, strategy := range ms.Strategies {
if strategy == nil || reflect.ValueOf(strategy).IsNil() {
continue
}
if s, ok := strategy.(fmt.Stringer); ok {
strategies = append(strategies, s.String())
} else {
strategies = append(strategies, fmt.Sprintf("%T", strategy))
}
}
// Always include "all of:" prefix to make it clear this is a MultiStrategy
// even when there's only one strategy after filtering out nils
return "all of: [" + strings.Join(strategies, ", ") + "]"
}
func (ms *MultiStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
var cancel context.CancelFunc
if ms.deadline != nil {
ctx, cancel = context.WithTimeout(ctx, *ms.deadline)
defer cancel()
}
if len(ms.Strategies) == 0 {
return errors.New("no wait strategy supplied")
}
for _, strategy := range ms.Strategies {
if strategy == nil || reflect.ValueOf(strategy).IsNil() {
// A module could be appending strategies after part of the container initialization,
// and use wait.ForAll on a not initialized strategy.
// In this case, we just skip the nil strategy.
continue
}
strategyCtx := ctx
// Set default Timeout when strategy implements StrategyTimeout
if st, ok := strategy.(StrategyTimeout); ok {
if ms.Timeout() != nil && st.Timeout() == nil {
strategyCtx, cancel = context.WithTimeout(ctx, *ms.Timeout())
defer cancel()
}
}
err := strategy.WaitUntilReady(strategyCtx, target)
if err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,12 @@
//go:build !windows
package wait
import (
"errors"
"syscall"
)
func isConnRefusedErr(err error) bool {
return errors.Is(err, syscall.ECONNREFUSED)
}
@@ -0,0 +1,9 @@
package wait
import (
"golang.org/x/sys/windows"
)
func isConnRefusedErr(err error) bool {
return err == windows.WSAECONNREFUSED
}
+124
View File
@@ -0,0 +1,124 @@
package wait
import (
"context"
"fmt"
"io"
"time"
tcexec "github.com/testcontainers/testcontainers-go/exec"
)
// Implement interface
var (
_ Strategy = (*ExecStrategy)(nil)
_ StrategyTimeout = (*ExecStrategy)(nil)
)
type ExecStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
cmd []string
// additional properties
ExitCodeMatcher func(exitCode int) bool
ResponseMatcher func(body io.Reader) bool
PollInterval time.Duration
}
// NewExecStrategy constructs an Exec strategy ...
func NewExecStrategy(cmd []string) *ExecStrategy {
return &ExecStrategy{
cmd: cmd,
ExitCodeMatcher: defaultExitCodeMatcher,
ResponseMatcher: func(_ io.Reader) bool { return true },
PollInterval: defaultPollInterval(),
}
}
func defaultExitCodeMatcher(exitCode int) bool {
return exitCode == 0
}
// WithStartupTimeout can be used to change the default startup timeout
func (ws *ExecStrategy) WithStartupTimeout(startupTimeout time.Duration) *ExecStrategy {
ws.timeout = &startupTimeout
return ws
}
func (ws *ExecStrategy) WithExitCode(exitCode int) *ExecStrategy {
return ws.WithExitCodeMatcher(func(actualCode int) bool {
return actualCode == exitCode
})
}
func (ws *ExecStrategy) WithExitCodeMatcher(exitCodeMatcher func(exitCode int) bool) *ExecStrategy {
ws.ExitCodeMatcher = exitCodeMatcher
return ws
}
func (ws *ExecStrategy) WithResponseMatcher(matcher func(body io.Reader) bool) *ExecStrategy {
ws.ResponseMatcher = matcher
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *ExecStrategy) WithPollInterval(pollInterval time.Duration) *ExecStrategy {
ws.PollInterval = pollInterval
return ws
}
// ForExec is a convenience method to assign ExecStrategy
func ForExec(cmd []string) *ExecStrategy {
return NewExecStrategy(cmd)
}
func (ws *ExecStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *ExecStrategy) String() string {
if len(ws.cmd) == 0 {
return "exec command"
}
// Only show the command name and argument count to avoid exposing sensitive data
argCount := len(ws.cmd) - 1
if argCount == 0 {
return fmt.Sprintf("exec command %q", ws.cmd[0])
}
if argCount == 1 {
return fmt.Sprintf("exec command %q with 1 argument", ws.cmd[0])
}
return fmt.Sprintf("exec command %q with %d arguments", ws.cmd[0], argCount)
}
func (ws *ExecStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(ws.PollInterval):
exitCode, resp, err := target.Exec(ctx, ws.cmd, tcexec.Multiplexed())
if err != nil {
return err
}
if !ws.ExitCodeMatcher(exitCode) {
continue
}
if ws.ResponseMatcher != nil && !ws.ResponseMatcher(resp) {
continue
}
return nil
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package wait
import (
"context"
"strings"
"time"
)
// Implement interface
var (
_ Strategy = (*ExitStrategy)(nil)
_ StrategyTimeout = (*ExitStrategy)(nil)
)
// ExitStrategy will wait until container exit
type ExitStrategy struct {
// all Strategies should have a timeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
PollInterval time.Duration
}
// NewExitStrategy constructs with polling interval of 100 milliseconds without timeout by default
func NewExitStrategy() *ExitStrategy {
return &ExitStrategy{
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones
// WithExitTimeout can be used to change the default exit timeout
func (ws *ExitStrategy) WithExitTimeout(exitTimeout time.Duration) *ExitStrategy {
ws.timeout = &exitTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *ExitStrategy) WithPollInterval(pollInterval time.Duration) *ExitStrategy {
ws.PollInterval = pollInterval
return ws
}
// ForExit is the default construction for the fluid interface.
//
// For Example:
//
// wait.
// ForExit().
// WithPollInterval(1 * time.Second)
func ForExit() *ExitStrategy {
return NewExitStrategy()
}
func (ws *ExitStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *ExitStrategy) String() string {
return "container to exit"
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *ExitStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
if ws.timeout != nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, *ws.timeout)
defer cancel()
}
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
state, err := target.State(ctx)
if err != nil {
if !strings.Contains(err.Error(), "No such container") {
return err
}
return nil
}
if state.Running {
time.Sleep(ws.PollInterval)
continue
}
return nil
}
}
}
+120
View File
@@ -0,0 +1,120 @@
package wait
import (
"context"
"fmt"
"io"
"time"
"github.com/containerd/errdefs"
)
var (
_ Strategy = (*FileStrategy)(nil)
_ StrategyTimeout = (*FileStrategy)(nil)
)
// FileStrategy waits for a file to exist in the container.
type FileStrategy struct {
timeout *time.Duration
file string
pollInterval time.Duration
matcher func(io.Reader) error
}
// NewFileStrategy constructs an FileStrategy strategy.
func NewFileStrategy(file string) *FileStrategy {
return &FileStrategy{
file: file,
pollInterval: defaultPollInterval(),
}
}
// WithStartupTimeout can be used to change the default startup timeout
func (ws *FileStrategy) WithStartupTimeout(startupTimeout time.Duration) *FileStrategy {
ws.timeout = &startupTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *FileStrategy) WithPollInterval(pollInterval time.Duration) *FileStrategy {
ws.pollInterval = pollInterval
return ws
}
// WithMatcher can be used to consume the file content.
// The matcher can return an errdefs.ErrNotFound to indicate that the file is not ready.
// Any other error will be considered a failure.
// Default: nil, will only wait for the file to exist.
func (ws *FileStrategy) WithMatcher(matcher func(io.Reader) error) *FileStrategy {
ws.matcher = matcher
return ws
}
// ForFile is a convenience method to assign FileStrategy
func ForFile(file string) *FileStrategy {
return NewFileStrategy(file)
}
// Timeout returns the timeout for the strategy
func (ws *FileStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *FileStrategy) String() string {
if ws.matcher != nil {
return fmt.Sprintf("file %q to exist and match condition", ws.file)
}
return fmt.Sprintf("file %q to exist", ws.file)
}
// WaitUntilReady waits until the file exists in the container and copies it to the target.
func (ws *FileStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
timer := time.NewTicker(ws.pollInterval)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
if err := ws.matchFile(ctx, target); err != nil {
if errdefs.IsNotFound(err) {
// Not found, continue polling.
continue
}
return fmt.Errorf("copy from container: %w", err)
}
return nil
}
}
}
// matchFile tries to copy the file from the container and match it.
func (ws *FileStrategy) matchFile(ctx context.Context, target StrategyTarget) error {
rc, err := target.CopyFileFromContainer(ctx, ws.file)
if err != nil {
return fmt.Errorf("copy from container: %w", err)
}
defer rc.Close()
if ws.matcher == nil {
// No matcher, just check if the file exists.
return nil
}
if err = ws.matcher(rc); err != nil {
return fmt.Errorf("matcher: %w", err)
}
return nil
}
@@ -0,0 +1,97 @@
package wait
import (
"context"
"time"
"github.com/moby/moby/api/types/container"
)
// Implement interface
var (
_ Strategy = (*HealthStrategy)(nil)
_ StrategyTimeout = (*HealthStrategy)(nil)
)
// HealthStrategy will wait until the container becomes healthy
type HealthStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
PollInterval time.Duration
}
// NewHealthStrategy constructs with polling interval of 100 milliseconds and startup timeout of 60 seconds by default
func NewHealthStrategy() *HealthStrategy {
return &HealthStrategy{
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// WithStartupTimeout can be used to change the default startup timeout
func (ws *HealthStrategy) WithStartupTimeout(startupTimeout time.Duration) *HealthStrategy {
ws.timeout = &startupTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *HealthStrategy) WithPollInterval(pollInterval time.Duration) *HealthStrategy {
ws.PollInterval = pollInterval
return ws
}
// ForHealthCheck is the default construction for the fluid interface.
//
// For Example:
//
// wait.
// ForHealthCheck().
// WithPollInterval(1 * time.Second)
func ForHealthCheck() *HealthStrategy {
return NewHealthStrategy()
}
func (ws *HealthStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *HealthStrategy) String() string {
return "container to become healthy"
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *HealthStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
state, err := target.State(ctx)
if err != nil {
return err
}
if err := checkState(state); err != nil {
return err
}
if state.Health == nil || state.Health.Status != container.Healthy {
time.Sleep(ws.PollInterval)
continue
}
return nil
}
}
}
@@ -0,0 +1,321 @@
package wait
import (
"context"
"errors"
"fmt"
"net"
"os"
"time"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/log"
)
const (
exitEaccess = 126 // container cmd can't be invoked (permission denied)
exitCmdNotFound = 127 // container cmd not found/does not exist or invalid bind-mount
)
// Implement interface
var (
_ Strategy = (*HostPortStrategy)(nil)
_ StrategyTimeout = (*HostPortStrategy)(nil)
)
var (
errShellNotExecutable = errors.New("/bin/sh command not executable")
errShellNotFound = errors.New("/bin/sh command not found")
)
type HostPortStrategy struct {
// Port is a string containing port number and protocol in the format "80/tcp"
// which
Port string
// all WaitStrategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
PollInterval time.Duration
// skipInternalCheck is a flag to skip the internal check, which is useful when
// a shell is not available in the container or when the container doesn't bind
// the port internally until additional conditions are met.
skipInternalCheck bool
// skipExternalCheck is a flag to skip the external check, which, if used with
// skipInternalCheck, makes strategy waiting only for port mapping completion
// without accessing port.
skipExternalCheck bool
}
// NewHostPortStrategy constructs a default host port strategy that waits for the given
// port to be exposed. The default startup timeout is 60 seconds.
func NewHostPortStrategy(port string) *HostPortStrategy {
return &HostPortStrategy{
Port: port,
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// ForListeningPort returns a host port strategy that waits for the given port
// to be exposed and bound internally the container.
// Alias for `NewHostPortStrategy(port)`.
func ForListeningPort(port string) *HostPortStrategy {
return NewHostPortStrategy(port)
}
// ForExposedPort returns a host port strategy that waits for the first port
// to be exposed and bound internally the container.
func ForExposedPort() *HostPortStrategy {
return NewHostPortStrategy("")
}
// ForMappedPort returns a host port strategy that waits for the given port
// to be mapped without accessing the port itself.
func ForMappedPort(port string) *HostPortStrategy {
return NewHostPortStrategy(port).SkipInternalCheck().SkipExternalCheck()
}
// SkipInternalCheck changes the host port strategy to skip the internal check,
// which is useful when a shell is not available in the container or when the
// container doesn't bind the port internally until additional conditions are met.
func (hp *HostPortStrategy) SkipInternalCheck() *HostPortStrategy {
hp.skipInternalCheck = true
return hp
}
// SkipExternalCheck changes the host port strategy to skip the external check,
// which, if used with SkipInternalCheck, makes strategy waiting only for port
// mapping completion without accessing port.
func (hp *HostPortStrategy) SkipExternalCheck() *HostPortStrategy {
hp.skipExternalCheck = true
return hp
}
// WithStartupTimeout can be used to change the default startup timeout
func (hp *HostPortStrategy) WithStartupTimeout(startupTimeout time.Duration) *HostPortStrategy {
hp.timeout = &startupTimeout
return hp
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (hp *HostPortStrategy) WithPollInterval(pollInterval time.Duration) *HostPortStrategy {
hp.PollInterval = pollInterval
return hp
}
func (hp *HostPortStrategy) Timeout() *time.Duration {
return hp.timeout
}
// String returns a human-readable description of the wait strategy.
func (hp *HostPortStrategy) String() string {
port := "first exposed port"
if hp.Port != "" {
port = "port " + hp.Port
}
var checks string
switch {
case hp.skipInternalCheck && hp.skipExternalCheck:
checks = " to be mapped"
case hp.skipInternalCheck:
checks = " to be accessible externally"
case hp.skipExternalCheck:
checks = " to be listening internally"
default:
checks = " to be listening"
}
return fmt.Sprintf("%s%s", port, checks)
}
// detectInternalPort returns the lowest internal port that is currently bound.
// If no internal port is found, it returns the zero nat.Port value which
// can be checked against an empty string.
func (hp *HostPortStrategy) detectInternalPort(ctx context.Context, target StrategyTarget) (network.Port, error) {
var internalPort network.Port
inspect, err := target.Inspect(ctx)
if err != nil {
return internalPort, fmt.Errorf("inspect: %w", err)
}
for port := range inspect.NetworkSettings.Ports {
if internalPort.IsZero() || port.Num() < internalPort.Num() {
internalPort = port
}
}
return internalPort, nil
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (hp *HostPortStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if hp.timeout != nil {
timeout = *hp.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
waitInterval := hp.PollInterval
var internalPort network.Port
if hp.Port != "" {
p, err := network.ParsePort(hp.Port)
if err != nil {
return err
}
internalPort = p
}
i := 0
if internalPort.IsZero() {
var err error
// Port is not specified, so we need to detect it.
internalPort, err = hp.detectInternalPort(ctx, target)
if err != nil {
return fmt.Errorf("detect internal port: %w", err)
}
for internalPort.IsZero() {
select {
case <-ctx.Done():
return fmt.Errorf("detect internal port: retries: %d, last err: %w, ctx err: %w", i, err, ctx.Err())
case <-time.After(waitInterval):
if err := checkTarget(ctx, target); err != nil {
return fmt.Errorf("detect internal port: check target: retries: %d, last err: %w", i, err)
}
internalPort, err = hp.detectInternalPort(ctx, target)
if err != nil {
return fmt.Errorf("detect internal port: %w", err)
}
}
}
}
port, err := target.MappedPort(ctx, internalPort.String())
i = 0
for port.IsZero() {
i++
select {
case <-ctx.Done():
return fmt.Errorf("mapped port: retries: %d, port: %q, last err: %w, ctx err: %w", i, port, err, ctx.Err())
case <-time.After(waitInterval):
if err := checkTarget(ctx, target); err != nil {
return fmt.Errorf("mapped port: check target: retries: %d, port: %q, last err: %w", i, port, err)
}
port, err = target.MappedPort(ctx, internalPort.String())
if err != nil {
log.Printf("mapped port: retries: %d, port: %q, err: %s\n", i, port, err)
}
}
}
if !hp.skipExternalCheck {
ipAddress, err := target.Host(ctx)
if err != nil {
return fmt.Errorf("host: %w", err)
}
if err := externalCheck(ctx, ipAddress, port, target, waitInterval); err != nil {
return fmt.Errorf("external check: %w", err)
}
}
if hp.skipInternalCheck {
return nil
}
if err = internalCheck(ctx, internalPort, target); err != nil {
switch {
case errors.Is(err, errShellNotExecutable):
log.Printf("Shell not executable in container, only external port validated")
return nil
case errors.Is(err, errShellNotFound):
log.Printf("Shell not found in container")
return nil
default:
return fmt.Errorf("internal check: %w", err)
}
}
return nil
}
func externalCheck(ctx context.Context, ipAddress string, port network.Port, target StrategyTarget, waitInterval time.Duration) error {
proto := port.Proto()
dialer := net.Dialer{}
address := net.JoinHostPort(ipAddress, port.Port())
for i := 0; ; i++ {
if err := checkTarget(ctx, target); err != nil {
return fmt.Errorf("check target: retries: %d address: %s: %w", i, address, err)
}
conn, err := dialer.DialContext(ctx, string(proto), address)
if err != nil {
var v *net.OpError
if errors.As(err, &v) {
var v2 *os.SyscallError
if errors.As(v.Err, &v2) {
if isConnRefusedErr(v2.Err) {
time.Sleep(waitInterval)
continue
}
}
}
return fmt.Errorf("dial: %w", err)
}
_ = conn.Close()
return nil
}
}
func internalCheck(ctx context.Context, internalPort network.Port, target StrategyTarget) error {
command := buildInternalCheckCommand(internalPort.Num())
for {
if ctx.Err() != nil {
return ctx.Err()
}
if err := checkTarget(ctx, target); err != nil {
return err
}
exitCode, _, err := target.Exec(ctx, []string{"/bin/sh", "-c", command})
if err != nil {
return fmt.Errorf("%w, host port waiting failed", err)
}
// Docker has an issue which override exit code 127 to 126 due to:
// https://github.com/moby/moby/issues/45795
// Handle both to ensure compatibility with Docker and Podman for now.
switch exitCode {
case 0:
return nil
case exitEaccess:
return errShellNotExecutable
case exitCmdNotFound:
return errShellNotFound
}
}
}
func buildInternalCheckCommand(internalPort uint16) string {
command := `(
cat /proc/net/tcp* | awk '{print $2}' | grep -i :%04x ||
nc -vz -w 1 localhost %d ||
/bin/sh -c '</dev/tcp/localhost/%d'
)
`
return "true && " + fmt.Sprintf(command, internalPort, internalPort, internalPort)
}
+357
View File
@@ -0,0 +1,357 @@
package wait
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/moby/moby/api/types/network"
)
// Implement interface
var (
_ Strategy = (*HTTPStrategy)(nil)
_ StrategyTimeout = (*HTTPStrategy)(nil)
)
type HTTPStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
Port network.Port
Path string
StatusCodeMatcher func(status int) bool
ResponseMatcher func(body io.Reader) bool
UseTLS bool
AllowInsecure bool
TLSConfig *tls.Config // TLS config for HTTPS
Method string // http method
Body io.Reader // http request body
Headers map[string]string
ResponseHeadersMatcher func(headers http.Header) bool
PollInterval time.Duration
UserInfo *url.Userinfo
ForceIPv4LocalHost bool
}
// NewHTTPStrategy constructs an HTTP strategy waiting on port 80 and status code 200
func NewHTTPStrategy(path string) *HTTPStrategy {
return &HTTPStrategy{
Port: network.Port{},
Path: path,
StatusCodeMatcher: defaultStatusCodeMatcher,
ResponseMatcher: func(_ io.Reader) bool { return true },
UseTLS: false,
TLSConfig: nil,
Method: http.MethodGet,
Body: nil,
Headers: map[string]string{},
ResponseHeadersMatcher: func(_ http.Header) bool { return true },
PollInterval: defaultPollInterval(),
UserInfo: nil,
}
}
func defaultStatusCodeMatcher(status int) bool {
return status == http.StatusOK
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// WithStartupTimeout can be used to change the default startup timeout
func (ws *HTTPStrategy) WithStartupTimeout(timeout time.Duration) *HTTPStrategy {
ws.timeout = &timeout
return ws
}
// WithPort set the port to wait for.
// Default is the lowest numbered port.
func (ws *HTTPStrategy) WithPort(port string) *HTTPStrategy {
if p, err := network.ParsePort(port); err == nil {
ws.Port = p
}
return ws
}
func (ws *HTTPStrategy) WithStatusCodeMatcher(statusCodeMatcher func(status int) bool) *HTTPStrategy {
ws.StatusCodeMatcher = statusCodeMatcher
return ws
}
func (ws *HTTPStrategy) WithResponseMatcher(matcher func(body io.Reader) bool) *HTTPStrategy {
ws.ResponseMatcher = matcher
return ws
}
func (ws *HTTPStrategy) WithTLS(useTLS bool, tlsconf ...*tls.Config) *HTTPStrategy {
ws.UseTLS = useTLS
if useTLS && len(tlsconf) > 0 {
ws.TLSConfig = tlsconf[0]
}
return ws
}
func (ws *HTTPStrategy) WithAllowInsecure(allowInsecure bool) *HTTPStrategy {
ws.AllowInsecure = allowInsecure
return ws
}
func (ws *HTTPStrategy) WithMethod(method string) *HTTPStrategy {
ws.Method = method
return ws
}
func (ws *HTTPStrategy) WithBody(reqdata io.Reader) *HTTPStrategy {
ws.Body = reqdata
return ws
}
func (ws *HTTPStrategy) WithHeaders(headers map[string]string) *HTTPStrategy {
ws.Headers = headers
return ws
}
func (ws *HTTPStrategy) WithResponseHeadersMatcher(matcher func(http.Header) bool) *HTTPStrategy {
ws.ResponseHeadersMatcher = matcher
return ws
}
func (ws *HTTPStrategy) WithBasicAuth(username, password string) *HTTPStrategy {
ws.UserInfo = url.UserPassword(username, password)
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *HTTPStrategy) WithPollInterval(pollInterval time.Duration) *HTTPStrategy {
ws.PollInterval = pollInterval
return ws
}
// WithForcedIPv4LocalHost forces usage of localhost to be ipv4 127.0.0.1
// to avoid ipv6 docker bugs https://github.com/moby/moby/issues/42442 https://github.com/moby/moby/issues/42375
func (ws *HTTPStrategy) WithForcedIPv4LocalHost() *HTTPStrategy {
ws.ForceIPv4LocalHost = true
return ws
}
// ForHTTP is a convenience method similar to Wait.java
// https://github.com/testcontainers/testcontainers-java/blob/1d85a3834bd937f80aad3a4cec249c027f31aeb4/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java
func ForHTTP(path string) *HTTPStrategy {
return NewHTTPStrategy(path)
}
func (ws *HTTPStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *HTTPStrategy) String() string {
proto := "HTTP"
if ws.UseTLS {
proto = "HTTPS"
}
port := "default"
if !ws.Port.IsZero() {
port = ws.Port.Port()
}
return fmt.Sprintf("%s %s request on port %s path %q", proto, ws.Method, port, ws.Path)
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *HTTPStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ipAddress, err := target.Host(ctx)
if err != nil {
return err
}
// to avoid ipv6 docker bugs https://github.com/moby/moby/issues/42442 https://github.com/moby/moby/issues/42375
if ws.ForceIPv4LocalHost {
ipAddress = strings.Replace(ipAddress, "localhost", "127.0.0.1", 1)
}
var mappedPort network.Port
if ws.Port.IsZero() {
// No specific port requested; inspect container to find lowest exposed TCP port.
// We wait one polling interval before we grab the ports
// otherwise they might not be bound yet on startup.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(ws.PollInterval):
// Port should now be bound so just continue.
}
if err := checkTarget(ctx, target); err != nil {
return err
}
inspect, err := target.Inspect(ctx)
if err != nil {
return err
}
// Find the lowest numbered exposed tcp port.
var lowestPort network.Port
var hostPort string
for port, bindings := range inspect.NetworkSettings.Ports {
if len(bindings) == 0 || port.Proto() != "tcp" {
continue
}
if lowestPort.IsZero() || port.Num() < lowestPort.Num() {
lowestPort = port
hostPort = bindings[0].HostPort
}
}
if lowestPort.IsZero() {
return errors.New("no exposed tcp ports or mapped ports - cannot wait for status")
}
hPort, _ := strconv.ParseUint(hostPort, 10, 16)
mappedPort, _ = network.PortFrom(uint16(hPort), lowestPort.Proto())
} else {
// Specific port requested; use MappedPort to resolve it.
mappedPort, err = target.MappedPort(ctx, ws.Port.String())
for mappedPort.IsZero() {
select {
case <-ctx.Done():
return fmt.Errorf("%w: %w", ctx.Err(), err)
case <-time.After(ws.PollInterval):
if err := checkTarget(ctx, target); err != nil {
return err
}
mappedPort, err = target.MappedPort(ctx, ws.Port.String())
}
}
if mappedPort.Proto() != "tcp" {
return errors.New("cannot use HTTP client on non-TCP ports")
}
}
switch ws.Method {
case http.MethodGet, http.MethodHead, http.MethodPost,
http.MethodPut, http.MethodPatch, http.MethodDelete,
http.MethodConnect, http.MethodOptions, http.MethodTrace:
default:
if ws.Method != "" {
return fmt.Errorf("invalid http method %q", ws.Method)
}
ws.Method = http.MethodGet
}
tripper := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: ws.TLSConfig,
}
var proto string
if ws.UseTLS {
proto = "https"
if ws.AllowInsecure {
if ws.TLSConfig == nil {
tripper.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
} else {
ws.TLSConfig.InsecureSkipVerify = true
}
}
} else {
proto = "http"
}
client := http.Client{Transport: tripper, Timeout: time.Second}
address := net.JoinHostPort(ipAddress, mappedPort.Port())
endpoint, err := url.Parse(ws.Path)
if err != nil {
return err
}
endpoint.Scheme = proto
endpoint.Host = address
if ws.UserInfo != nil {
endpoint.User = ws.UserInfo
}
// cache the body into a byte-slice so that it can be iterated over multiple times
var body []byte
if ws.Body != nil {
body, err = io.ReadAll(ws.Body)
if err != nil {
return err
}
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(ws.PollInterval):
if err := checkTarget(ctx, target); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, ws.Method, endpoint.String(), bytes.NewReader(body))
if err != nil {
return err
}
for k, v := range ws.Headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
continue
}
if ws.StatusCodeMatcher != nil && !ws.StatusCodeMatcher(resp.StatusCode) {
_ = resp.Body.Close()
continue
}
if ws.ResponseMatcher != nil && !ws.ResponseMatcher(resp.Body) {
_ = resp.Body.Close()
continue
}
if ws.ResponseHeadersMatcher != nil && !ws.ResponseHeadersMatcher(resp.Header) {
_ = resp.Body.Close()
continue
}
if err := resp.Body.Close(); err != nil {
continue
}
return nil
}
}
}
+229
View File
@@ -0,0 +1,229 @@
package wait
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"regexp"
"time"
)
// Implement interface
var (
_ Strategy = (*LogStrategy)(nil)
_ StrategyTimeout = (*LogStrategy)(nil)
)
// PermanentError is a special error that will stop the wait and return an error.
type PermanentError struct {
err error
}
// Error implements the error interface.
func (e *PermanentError) Error() string {
return e.err.Error()
}
// NewPermanentError creates a new PermanentError.
func NewPermanentError(err error) *PermanentError {
return &PermanentError{err: err}
}
// LogStrategy will wait until a given log entry shows up in the docker logs
type LogStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
Log string
IsRegexp bool
Occurrence int
PollInterval time.Duration
// check is the function that will be called to check if the log entry is present.
check func([]byte) error
// submatchCallback is a callback that will be called with the sub matches of the regexp.
submatchCallback func(pattern string, matches [][][]byte) error
// re is the optional compiled regexp.
re *regexp.Regexp
// log byte slice version of [LogStrategy.Log] used for count checks.
log []byte
}
// NewLogStrategy constructs with polling interval of 100 milliseconds and startup timeout of 60 seconds by default
func NewLogStrategy(log string) *LogStrategy {
return &LogStrategy{
Log: log,
IsRegexp: false,
Occurrence: 1,
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// AsRegexp can be used to change the default behavior of the log strategy to use regexp instead of plain text
func (ws *LogStrategy) AsRegexp() *LogStrategy {
ws.IsRegexp = true
return ws
}
// Submatch configures a function that will be called with the result of
// [regexp.Regexp.FindAllSubmatch], allowing the caller to process the results.
// If the callback returns nil, the strategy will be considered successful.
// Returning a [PermanentError] will stop the wait and return an error, otherwise
// it will retry until the timeout is reached.
// [LogStrategy.Occurrence] is ignored if this option is set.
func (ws *LogStrategy) Submatch(callback func(pattern string, matches [][][]byte) error) *LogStrategy {
ws.submatchCallback = callback
return ws
}
// WithStartupTimeout can be used to change the default startup timeout
func (ws *LogStrategy) WithStartupTimeout(timeout time.Duration) *LogStrategy {
ws.timeout = &timeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *LogStrategy) WithPollInterval(pollInterval time.Duration) *LogStrategy {
ws.PollInterval = pollInterval
return ws
}
func (ws *LogStrategy) WithOccurrence(o int) *LogStrategy {
// the number of occurrence needs to be positive
if o <= 0 {
o = 1
}
ws.Occurrence = o
return ws
}
// ForLog is the default construction for the fluid interface.
//
// For Example:
//
// wait.
// ForLog("some text").
// WithPollInterval(1 * time.Second)
func ForLog(log string) *LogStrategy {
return NewLogStrategy(log)
}
func (ws *LogStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *LogStrategy) String() string {
logType := "log message"
if ws.IsRegexp {
logType = "log pattern"
}
occurrence := ""
if ws.Occurrence > 1 {
occurrence = fmt.Sprintf(" (occurrence: %d)", ws.Occurrence)
}
return fmt.Sprintf("%s %q%s", logType, ws.Log, occurrence)
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *LogStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
switch {
case ws.submatchCallback != nil:
ws.re = regexp.MustCompile(ws.Log)
ws.check = ws.checkSubmatch
case ws.IsRegexp:
ws.re = regexp.MustCompile(ws.Log)
ws.check = ws.checkRegexp
default:
ws.log = []byte(ws.Log)
ws.check = ws.checkCount
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var lastLen int
var lastError error
for {
select {
case <-ctx.Done():
return errors.Join(lastError, ctx.Err())
default:
checkErr := checkTarget(ctx, target)
reader, err := target.Logs(ctx)
if err != nil {
// TODO: fix as this will wait for timeout if the logs are not available.
time.Sleep(ws.PollInterval)
continue
}
b, err := io.ReadAll(reader)
if err != nil {
// TODO: fix as this will wait for timeout if the logs are not readable.
time.Sleep(ws.PollInterval)
continue
}
if lastLen == len(b) && checkErr != nil {
// Log length hasn't changed so we're not making progress.
return checkErr
}
if err := ws.check(b); err != nil {
var errPermanent *PermanentError
if errors.As(err, &errPermanent) {
return err
}
lastError = err
lastLen = len(b)
time.Sleep(ws.PollInterval)
continue
}
return nil
}
}
}
// checkCount checks if the log entry is present in the logs using a string count.
func (ws *LogStrategy) checkCount(b []byte) error {
if count := bytes.Count(b, ws.log); count < ws.Occurrence {
return fmt.Errorf("%q matched %d times, expected %d", ws.Log, count, ws.Occurrence)
}
return nil
}
// checkRegexp checks if the log entry is present in the logs using a regexp count.
func (ws *LogStrategy) checkRegexp(b []byte) error {
if matches := ws.re.FindAll(b, -1); len(matches) < ws.Occurrence {
return fmt.Errorf("`%s` matched %d times, expected %d", ws.Log, len(matches), ws.Occurrence)
}
return nil
}
// checkSubmatch checks if the log entry is present in the logs using a regexp sub match callback.
func (ws *LogStrategy) checkSubmatch(b []byte) error {
return ws.submatchCallback(ws.Log, ws.re.FindAllSubmatch(b, -1))
}
+89
View File
@@ -0,0 +1,89 @@
package wait
import (
"context"
"io"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/exec"
)
var (
_ Strategy = (*NopStrategy)(nil)
_ StrategyTimeout = (*NopStrategy)(nil)
)
type NopStrategy struct {
timeout *time.Duration
waitUntilReady func(context.Context, StrategyTarget) error
}
func ForNop(
waitUntilReady func(context.Context, StrategyTarget) error,
) *NopStrategy {
return &NopStrategy{
waitUntilReady: waitUntilReady,
}
}
func (ws *NopStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *NopStrategy) String() string {
return "custom wait condition"
}
func (ws *NopStrategy) WithStartupTimeout(timeout time.Duration) *NopStrategy {
ws.timeout = &timeout
return ws
}
func (ws *NopStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
return ws.waitUntilReady(ctx, target)
}
type NopStrategyTarget struct {
ReaderCloser io.ReadCloser
ContainerState container.State
}
func (st NopStrategyTarget) Host(_ context.Context) (string, error) {
return "", nil
}
func (st NopStrategyTarget) Inspect(_ context.Context) (*container.InspectResponse, error) {
return nil, nil
}
// Deprecated: use Inspect instead
func (st NopStrategyTarget) Ports(_ context.Context) (network.PortMap, error) {
return nil, nil
}
func (st NopStrategyTarget) MappedPort(_ context.Context, n string) (network.Port, error) {
if n == "" {
return network.Port{}, nil
}
return network.ParsePort(n)
}
func (st NopStrategyTarget) Logs(_ context.Context) (io.ReadCloser, error) {
return st.ReaderCloser, nil
}
func (st NopStrategyTarget) Exec(_ context.Context, _ []string, _ ...exec.ProcessOption) (int, io.Reader, error) {
return 0, nil, nil
}
func (st NopStrategyTarget) State(_ context.Context) (*container.State, error) {
return &st.ContainerState, nil
}
func (st NopStrategyTarget) CopyFileFromContainer(context.Context, string) (io.ReadCloser, error) {
return st.ReaderCloser, nil
}
+136
View File
@@ -0,0 +1,136 @@
package wait
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/moby/moby/api/types/network"
)
var (
_ Strategy = (*waitForSQL)(nil)
_ StrategyTimeout = (*waitForSQL)(nil)
)
const defaultForSQLQuery = "SELECT 1"
// ForSQL constructs a new waitForSql strategy for the given driver
func ForSQL(port string, driver string, url func(host string, port string) string) *waitForSQL {
return &waitForSQL{
Port: port,
URL: url,
Driver: driver,
startupTimeout: defaultStartupTimeout(),
PollInterval: defaultPollInterval(),
query: defaultForSQLQuery,
}
}
type waitForSQL struct {
timeout *time.Duration
URL func(host string, port string) string
Driver string
Port string
startupTimeout time.Duration
PollInterval time.Duration
query string
}
// WithStartupTimeout can be used to change the default startup timeout
func (w *waitForSQL) WithStartupTimeout(timeout time.Duration) *waitForSQL {
w.timeout = &timeout
return w
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (w *waitForSQL) WithPollInterval(pollInterval time.Duration) *waitForSQL {
w.PollInterval = pollInterval
return w
}
// WithQuery can be used to override the default query used in the strategy.
func (w *waitForSQL) WithQuery(query string) *waitForSQL {
w.query = query
return w
}
func (w *waitForSQL) Timeout() *time.Duration {
return w.timeout
}
// String returns a human-readable description of the wait strategy.
func (w *waitForSQL) String() string {
port := "default"
if w.Port != "" {
p, err := network.ParsePort(w.Port)
if err == nil {
port = p.Port()
}
}
query := ""
if w.query != defaultForSQLQuery {
query = fmt.Sprintf(" with query %q", w.query)
}
return fmt.Sprintf("SQL database on port %s using driver %q%s", port, w.Driver, query)
}
// WaitUntilReady repeatedly tries to run "SELECT 1" or user defined query on the given port using sql and driver.
//
// If it doesn't succeed until the timeout value which defaults to 60 seconds, it will return an error.
func (w *waitForSQL) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if w.timeout != nil {
timeout = *w.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
host, err := target.Host(ctx)
if err != nil {
return err
}
ticker := time.NewTicker(w.PollInterval)
defer ticker.Stop()
var port network.Port
port, err = target.MappedPort(ctx, w.Port)
for port.IsZero() {
select {
case <-ctx.Done():
return fmt.Errorf("%w: %w", ctx.Err(), err)
case <-ticker.C:
if err := checkTarget(ctx, target); err != nil {
return err
}
port, err = target.MappedPort(ctx, w.Port)
}
}
db, err := sql.Open(w.Driver, w.URL(host, port.String()))
if err != nil {
return fmt.Errorf("sql.Open: %w", err)
}
defer db.Close()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := checkTarget(ctx, target); err != nil {
return err
}
if _, err := db.ExecContext(ctx, w.query); err != nil {
continue
}
return nil
}
}
}
+187
View File
@@ -0,0 +1,187 @@
package wait
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"strings"
"time"
)
// Validate we implement interface.
var _ Strategy = (*TLSStrategy)(nil)
// TLSStrategy is a strategy for handling TLS.
type TLSStrategy struct {
// General Settings.
timeout *time.Duration
pollInterval time.Duration
// Custom Settings.
certFiles *x509KeyPair
rootFiles []string
// State.
tlsConfig *tls.Config
}
// x509KeyPair is a pair of certificate and key files.
type x509KeyPair struct {
certPEMFile string
keyPEMFile string
}
// ForTLSCert returns a CertStrategy that will add a Certificate to the [tls.Config]
// constructed from PEM formatted certificate key file pair in the container.
func ForTLSCert(certPEMFile, keyPEMFile string) *TLSStrategy {
return &TLSStrategy{
certFiles: &x509KeyPair{
certPEMFile: certPEMFile,
keyPEMFile: keyPEMFile,
},
tlsConfig: &tls.Config{},
pollInterval: defaultPollInterval(),
}
}
// ForTLSRootCAs returns a CertStrategy that sets the root CAs for the [tls.Config]
// using the given PEM formatted files from the container.
func ForTLSRootCAs(pemFiles ...string) *TLSStrategy {
return &TLSStrategy{
rootFiles: pemFiles,
tlsConfig: &tls.Config{},
pollInterval: defaultPollInterval(),
}
}
// WithRootCAs sets the root CAs for the [tls.Config] using the given files from
// the container.
func (ws *TLSStrategy) WithRootCAs(files ...string) *TLSStrategy {
ws.rootFiles = files
return ws
}
// WithCert sets the [tls.Config] Certificates using the given files from the container.
func (ws *TLSStrategy) WithCert(certPEMFile, keyPEMFile string) *TLSStrategy {
ws.certFiles = &x509KeyPair{
certPEMFile: certPEMFile,
keyPEMFile: keyPEMFile,
}
return ws
}
// WithServerName sets the server for the [tls.Config].
func (ws *TLSStrategy) WithServerName(serverName string) *TLSStrategy {
ws.tlsConfig.ServerName = serverName
return ws
}
// WithStartupTimeout can be used to change the default startup timeout.
func (ws *TLSStrategy) WithStartupTimeout(startupTimeout time.Duration) *TLSStrategy {
ws.timeout = &startupTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds.
func (ws *TLSStrategy) WithPollInterval(pollInterval time.Duration) *TLSStrategy {
ws.pollInterval = pollInterval
return ws
}
// TLSConfig returns the TLS config once the strategy is ready.
// If the strategy is nil, it returns nil.
func (ws *TLSStrategy) TLSConfig() *tls.Config {
if ws == nil {
return nil
}
return ws.tlsConfig
}
// String returns a human-readable description of the wait strategy.
func (ws *TLSStrategy) String() string {
var parts []string
if len(ws.rootFiles) > 0 {
parts = append(parts, fmt.Sprintf("root CAs %v", ws.rootFiles))
}
if ws.certFiles != nil {
parts = append(parts, fmt.Sprintf("cert %q and key %q", ws.certFiles.certPEMFile, ws.certFiles.keyPEMFile))
}
if len(parts) == 0 {
return "TLS certificates"
}
return strings.Join(parts, " and ")
}
// WaitUntilReady implements the [Strategy] interface.
// It waits for the CA, client cert and key files to be available in the container and
// uses them to setup the TLS config.
func (ws *TLSStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
size := len(ws.rootFiles)
if ws.certFiles != nil {
size += 2
}
strategies := make([]Strategy, 0, size)
for _, file := range ws.rootFiles {
strategies = append(strategies,
ForFile(file).WithMatcher(func(r io.Reader) error {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read CA cert file %q: %w", file, err)
}
if ws.tlsConfig.RootCAs == nil {
ws.tlsConfig.RootCAs = x509.NewCertPool()
}
if !ws.tlsConfig.RootCAs.AppendCertsFromPEM(buf) {
return fmt.Errorf("invalid CA cert file %q", file)
}
return nil
}).WithPollInterval(ws.pollInterval),
)
}
if ws.certFiles != nil {
var certPEMBlock []byte
strategies = append(strategies,
ForFile(ws.certFiles.certPEMFile).WithMatcher(func(r io.Reader) error {
var err error
if certPEMBlock, err = io.ReadAll(r); err != nil {
return fmt.Errorf("read certificate cert %q: %w", ws.certFiles.certPEMFile, err)
}
return nil
}).WithPollInterval(ws.pollInterval),
ForFile(ws.certFiles.keyPEMFile).WithMatcher(func(r io.Reader) error {
keyPEMBlock, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read certificate key %q: %w", ws.certFiles.keyPEMFile, err)
}
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
if err != nil {
return fmt.Errorf("x509 key pair %q %q: %w", ws.certFiles.certPEMFile, ws.certFiles.keyPEMFile, err)
}
ws.tlsConfig.Certificates = []tls.Certificate{cert}
return nil
}).WithPollInterval(ws.pollInterval),
)
}
strategy := ForAll(strategies...)
if ws.timeout != nil {
strategy.WithStartupTimeout(*ws.timeout)
}
return strategy.WaitUntilReady(ctx, target)
}
+65
View File
@@ -0,0 +1,65 @@
package wait
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/exec"
)
// Strategy defines the basic interface for a Wait Strategy
type Strategy interface {
WaitUntilReady(context.Context, StrategyTarget) error
}
// StrategyTimeout allows MultiStrategy to configure a Strategy's Timeout
type StrategyTimeout interface {
Timeout() *time.Duration
}
type StrategyTarget interface {
Host(context.Context) (string, error)
Inspect(context.Context) (*container.InspectResponse, error)
Ports(ctx context.Context) (network.PortMap, error) // Deprecated: use Inspect instead
MappedPort(context.Context, string) (network.Port, error)
Logs(context.Context) (io.ReadCloser, error)
Exec(context.Context, []string, ...exec.ProcessOption) (int, io.Reader, error)
State(context.Context) (*container.State, error)
CopyFileFromContainer(ctx context.Context, filePath string) (io.ReadCloser, error)
}
func checkTarget(ctx context.Context, target StrategyTarget) error {
state, err := target.State(ctx)
if err != nil {
return fmt.Errorf("get state: %w", err)
}
return checkState(state)
}
func checkState(state *container.State) error {
switch {
case state.Running:
return nil
case state.OOMKilled:
return errors.New("container crashed with out-of-memory (OOMKilled)")
case state.Status == container.StateExited:
return fmt.Errorf("container exited with code %d", state.ExitCode)
default:
return fmt.Errorf("unexpected container status %q", state.Status)
}
}
func defaultStartupTimeout() time.Duration {
return 60 * time.Second
}
func defaultPollInterval() time.Duration {
return 100 * time.Millisecond
}
+81
View File
@@ -0,0 +1,81 @@
package wait
import (
"errors"
"slices"
)
var (
// ErrVisitStop is used as a return value from [VisitFunc] to stop the walk.
// It is not returned as an error by any function.
ErrVisitStop = errors.New("stop the walk")
// Deprecated: use [ErrVisitStop] instead.
VisitStop = ErrVisitStop
// ErrVisitRemove is used as a return value from [VisitFunc] to have the current node removed.
// It is not returned as an error by any function.
ErrVisitRemove = errors.New("remove this strategy")
// Deprecated: use [ErrVisitRemove] instead.
VisitRemove = ErrVisitRemove
)
// VisitFunc is a function that visits a strategy node.
// If it returns [ErrVisitStop], the walk stops.
// If it returns [ErrVisitRemove], the current node is removed.
type VisitFunc func(root Strategy) error
// Walk walks the strategies tree and calls the visit function for each node.
func Walk(root *Strategy, visit VisitFunc) error {
if root == nil {
return errors.New("root strategy is nil")
}
if err := walk(root, visit); err != nil {
if errors.Is(err, ErrVisitRemove) || errors.Is(err, ErrVisitStop) {
return nil
}
return err
}
return nil
}
// walk walks the strategies tree and calls the visit function for each node.
// It returns an error if the visit function returns an error.
func walk(root *Strategy, visit VisitFunc) error {
if *root == nil {
// No strategy.
return nil
}
// Allow the visit function to customize the behaviour of the walk before visiting the children.
if err := visit(*root); err != nil {
if errors.Is(err, ErrVisitRemove) {
*root = nil
}
return err
}
if s, ok := (*root).(*MultiStrategy); ok {
var i int
for range s.Strategies {
if err := walk(&s.Strategies[i], visit); err != nil {
if errors.Is(err, ErrVisitRemove) {
s.Strategies = slices.Delete(s.Strategies, i, i+1)
if errors.Is(err, VisitStop) {
return VisitStop
}
continue
}
return err
}
i++
}
}
return nil
}