Initial QSfera import
This commit is contained in:
@@ -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
@@ -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"
|
||||
+11
@@ -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
@@ -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
|
||||
+13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,21 @@
|
||||
# Testcontainers
|
||||
|
||||
[](https://github.com/testcontainers/testcontainers-go/actions/workflows/ci.yml)
|
||||
[](https://pkg.go.dev/github.com/testcontainers/testcontainers-go)
|
||||
[](https://goreportcard.com/report/github.com/testcontainers/testcontainers-go)
|
||||
[](https://sonarcloud.io/summary/new_code?id=testcontainers_testcontainers-go)
|
||||
[](https://github.com/testcontainers/testcontainers-go/blob/main/LICENSE)
|
||||
|
||||
[](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=141451032&machine=standardLinux32gb&devcontainer_path=.devcontainer%2Fdevcontainer.json&location=EastUs)
|
||||
|
||||
[](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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
+69
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+1897
File diff suppressed because it is too large
Load Diff
+290
@@ -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
|
||||
}
|
||||
+146
@@ -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
|
||||
}
|
||||
+194
@@ -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
|
||||
}
|
||||
+128
@@ -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
@@ -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
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package testcontainers
|
||||
|
||||
//go:generate mockery
|
||||
//go:generate gci write -s standard -s default -s prefix(github.com/testcontainers) .
|
||||
+149
@@ -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
@@ -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
|
||||
}
|
||||
Generated
Vendored
+185
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+106
@@ -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
|
||||
}
|
||||
Generated
Vendored
+53
@@ -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
|
||||
}
|
||||
Generated
Vendored
+334
@@ -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
|
||||
}
|
||||
Generated
Vendored
+150
@@ -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
|
||||
}
|
||||
Generated
Vendored
+49
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+143
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+72
@@ -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
|
||||
}
|
||||
Generated
Vendored
+52
@@ -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
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package internal
|
||||
|
||||
// Version is the next development version of the application
|
||||
const Version = "0.42.0"
|
||||
+674
@@ -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
|
||||
}
|
||||
}
|
||||
+73
@@ -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...)
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
+45
@@ -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
@@ -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
|
||||
Generated
Vendored
+21
@@ -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.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
include ../../commons-test.mk
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
$(MAKE) test-opensearch
|
||||
Generated
Vendored
+124
@@ -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")
|
||||
}
|
||||
Generated
Vendored
+44
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+413
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+5
@@ -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
|
||||
+1
@@ -0,0 +1 @@
|
||||
3.13
|
||||
+54
@@ -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
@@ -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)
|
||||
}
|
||||
+7
@@ -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
@@ -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
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package wait
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func isConnRefusedErr(err error) bool {
|
||||
return errors.Is(err, syscall.ECONNREFUSED)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package wait
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func isConnRefusedErr(err error) bool {
|
||||
return err == windows.WSAECONNREFUSED
|
||||
}
|
||||
+124
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+97
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user