detached 25c80a1

Merge pull request #7 from bnema/refactor/clean-architecture

refactor: clean architecture implementation
commit
25c80a1dfd01f29c276d3c18d4a0ef3f547948be
author
bnema · · 2026-01-14 06:55 UTC
committer
GitHub · · 2026-01-14 06:55 UTC
merge of
7efce35 + b1b134e · diff against the first parent
signature
unsigned
150 files · +22731 -17149
A .github/actions/deploy/README.md +244 -0
@@ -0,0 +1,244 @@
1+# Gordon Deploy Action
2+
3+Build and push container images to a Gordon registry, with automatic deployment on push.
4+
5+## Features
6+
7+- Automatic image building from Dockerfile
8+- Tag-based versioning (uses git tag as image tag)
9+- Optional `:latest` tag pushing
10+- Multi-platform builds support
11+- Build caching with GitHub Actions cache
12+- Works with Gordon's token and password authentication
13+
14+## Usage
15+
16+### Basic Usage (Tag Push)
17+
18+```yaml
19+name: Deploy to Gordon
20+
21+on:
22+ push:
23+ tags:
24+ - 'v*'
25+
26+jobs:
27+ deploy:
28+ runs-on: ubuntu-latest
29+ steps:
30+ - uses: actions/checkout@v6
31+
32+ - name: Deploy to Gordon
33+ uses: bnema/gordon/.github/actions/deploy@main
34+ with:
35+ registry: registry.mydomain.com
36+ username: ${{ secrets.GORDON_USERNAME }}
37+ password: ${{ secrets.GORDON_TOKEN }}
38+```
39+
40+### Full Example with All Options
41+
42+```yaml
43+name: Deploy to Gordon
44+
45+on:
46+ push:
47+ tags:
48+ - 'v*'
49+ - 'release-*'
50+
51+jobs:
52+ deploy:
53+ runs-on: ubuntu-latest
54+ steps:
55+ - uses: actions/checkout@v6
56+
57+ - name: Deploy to Gordon
58+ id: deploy
59+ uses: bnema/gordon/.github/actions/deploy@main
60+ with:
61+ registry: registry.mydomain.com
62+ username: ${{ secrets.GORDON_USERNAME }}
63+ password: ${{ secrets.GORDON_TOKEN }}
64+ image: myapp # Custom image name
65+ dockerfile: ./docker/Dockerfile # Custom Dockerfile path
66+ context: . # Build context
67+ build-args: | # Build arguments
68+ NODE_ENV=production
69+ API_URL=https://api.example.com
70+ platforms: linux/amd64,linux/arm64 # Multi-platform
71+ push-latest: 'true' # Also push :latest
72+
73+ - name: Print outputs
74+ run: |
75+ echo "Image: ${{ steps.deploy.outputs.image }}"
76+ echo "Tag: ${{ steps.deploy.outputs.tag }}"
77+ echo "Digest: ${{ steps.deploy.outputs.digest }}"
78+```
79+
80+## Inputs
81+
82+| Input | Description | Required | Default |
83+|-------|-------------|----------|---------|
84+| `registry` | Gordon registry URL (e.g., `registry.mydomain.com`) | Yes | - |
85+| `username` | Registry username (token subject for token auth) | Yes | - |
86+| `password` | Registry password or token | Yes | - |
87+| `image` | Image name | No | Repository name |
88+| `tag` | Override image tag | No | Git tag or short SHA |
89+| `dockerfile` | Path to Dockerfile | No | `./Dockerfile` |
90+| `context` | Build context path | No | `.` |
91+| `build-args` | Build arguments (one per line) | No | - |
92+| `platforms` | Target platforms | No | - |
93+| `push-latest` | Also push with `:latest` tag | No | `true` |
94+| `cache-from` | Cache source | No | `type=gha` |
95+| `cache-to` | Cache destination | No | `type=gha,mode=max` |
96+
97+## Outputs
98+
99+| Output | Description |
100+|--------|-------------|
101+| `image` | Full image reference that was pushed |
102+| `tag` | Tag that was pushed |
103+| `digest` | Image digest |
104+
105+## Authentication Setup
106+
107+### 1. Generate a Gordon Token
108+
109+On your Gordon server:
110+
111+```bash
112+# Generate a never-expiring token for CI
113+gordon auth token generate --subject github-actions --scopes push,pull --expiry 0
114+```
115+
116+### 2. Add GitHub Secrets
117+
118+In your repository settings, add:
119+
120+- `GORDON_USERNAME`: The token subject (e.g., `github-actions`)
121+- `GORDON_TOKEN`: The generated JWT token
122+
123+### 3. Configure Gordon
124+
125+Ensure your Gordon config uses token authentication:
126+
127+```toml
128+[secrets]
129+backend = "pass" # or "sops"
130+
131+[registry_auth]
132+enabled = true
133+type = "token"
134+token_secret = "gordon/registry/token_secret"
135+```
136+
137+## Examples
138+
139+### Deploy on Tag + Manual Trigger
140+
141+```yaml
142+name: Deploy
143+
144+on:
145+ push:
146+ tags:
147+ - 'v*'
148+ workflow_dispatch:
149+ inputs:
150+ tag:
151+ description: 'Tag to deploy'
152+ required: true
153+
154+jobs:
155+ deploy:
156+ runs-on: ubuntu-latest
157+ steps:
158+ - uses: actions/checkout@v6
159+ with:
160+ ref: ${{ github.event.inputs.tag || github.ref }}
161+
162+ - uses: bnema/gordon/.github/actions/deploy@main
163+ with:
164+ registry: registry.mydomain.com
165+ username: ${{ secrets.GORDON_USERNAME }}
166+ password: ${{ secrets.GORDON_TOKEN }}
167+ tag: ${{ github.event.inputs.tag }}
168+```
169+
170+### Monorepo with Multiple Services
171+
172+```yaml
173+name: Deploy Services
174+
175+on:
176+ push:
177+ tags:
178+ - 'v*'
179+
180+jobs:
181+ deploy-api:
182+ runs-on: ubuntu-latest
183+ steps:
184+ - uses: actions/checkout@v6
185+ - uses: bnema/gordon/.github/actions/deploy@main
186+ with:
187+ registry: registry.mydomain.com
188+ username: ${{ secrets.GORDON_USERNAME }}
189+ password: ${{ secrets.GORDON_TOKEN }}
190+ image: myapp-api
191+ dockerfile: ./services/api/Dockerfile
192+ context: ./services/api
193+
194+ deploy-web:
195+ runs-on: ubuntu-latest
196+ steps:
197+ - uses: actions/checkout@v6
198+ - uses: bnema/gordon/.github/actions/deploy@main
199+ with:
200+ registry: registry.mydomain.com
201+ username: ${{ secrets.GORDON_USERNAME }}
202+ password: ${{ secrets.GORDON_TOKEN }}
203+ image: myapp-web
204+ dockerfile: ./services/web/Dockerfile
205+ context: ./services/web
206+```
207+
208+### Build with Secrets (BuildKit)
209+
210+```yaml
211+- uses: bnema/gordon/.github/actions/deploy@main
212+ with:
213+ registry: registry.mydomain.com
214+ username: ${{ secrets.GORDON_USERNAME }}
215+ password: ${{ secrets.GORDON_TOKEN }}
216+ build-args: |
217+ NPM_TOKEN=${{ secrets.NPM_TOKEN }}
218+```
219+
220+## Troubleshooting
221+
222+### Authentication Failed
223+
224+- Verify your token hasn't been revoked: `gordon auth token list`
225+- Ensure the token has `push` scope
226+- Check that `token_secret` matches between token generation and validation
227+
228+### Dockerfile Not Found
229+
230+- Verify the `dockerfile` input path is correct
231+- Paths are relative to the repository root
232+
233+### Push Failed
234+
235+- Ensure your Gordon server is accessible from GitHub Actions
236+- Check firewall rules allow connections from GitHub's IP ranges
237+- Verify the registry domain resolves correctly
238+
239+## Security Notes
240+
241+- Never commit tokens or passwords to your repository
242+- Use GitHub Secrets for all sensitive values
243+- Consider using short-lived tokens for production
244+- Each Gordon instance has unique tokens (tokens from one instance won't work on another)
A .github/actions/deploy/action.yml +157 -0
@@ -0,0 +1,157 @@
1+name: 'Gordon Deploy'
2+description: 'Build and push container images to a Gordon registry on tag push'
3+author: 'Gordon Contributors'
4+
5+branding:
6+ icon: 'upload-cloud'
7+ color: 'blue'
8+
9+inputs:
10+ registry:
11+ description: 'Gordon registry URL (e.g., registry.mydomain.com)'
12+ required: true
13+ username:
14+ description: 'Registry username (token subject for token auth)'
15+ required: true
16+ password:
17+ description: 'Registry password or token'
18+ required: true
19+ image:
20+ description: 'Image name (defaults to repository name)'
21+ required: false
22+ default: ''
23+ tag:
24+ description: 'Override image tag (defaults to git tag or short SHA)'
25+ required: false
26+ default: ''
27+ dockerfile:
28+ description: 'Path to Dockerfile (defaults to ./Dockerfile)'
29+ required: false
30+ default: './Dockerfile'
31+ context:
32+ description: 'Build context path (defaults to .)'
33+ required: false
34+ default: '.'
35+ build-args:
36+ description: 'Build arguments (one per line, KEY=value format)'
37+ required: false
38+ default: ''
39+ platforms:
40+ description: 'Target platforms (e.g., linux/amd64,linux/arm64)'
41+ required: false
42+ default: ''
43+ push-latest:
44+ description: 'Also push with :latest tag'
45+ required: false
46+ default: 'true'
47+ cache-from:
48+ description: 'Cache source (e.g., type=gha)'
49+ required: false
50+ default: 'type=gha'
51+ cache-to:
52+ description: 'Cache destination (e.g., type=gha,mode=max)'
53+ required: false
54+ default: 'type=gha,mode=max'
55+
56+outputs:
57+ image:
58+ description: 'Full image reference that was pushed'
59+ value: ${{ steps.meta.outputs.image }}
60+ tag:
61+ description: 'Tag that was pushed'
62+ value: ${{ steps.meta.outputs.tag }}
63+ digest:
64+ description: 'Image digest'
65+ value: ${{ steps.push.outputs.digest }}
66+
67+runs:
68+ using: 'composite'
69+ steps:
70+ - name: Validate inputs
71+ shell: bash
72+ run: |
73+ if [[ ! -f "${{ inputs.dockerfile }}" ]]; then
74+ echo "::error::Dockerfile not found at ${{ inputs.dockerfile }}"
75+ exit 1
76+ fi
77+
78+ - name: Extract metadata
79+ id: meta
80+ shell: bash
81+ run: |
82+ # Determine image name
83+ IMAGE_NAME="${{ inputs.image }}"
84+ if [[ -z "$IMAGE_NAME" ]]; then
85+ IMAGE_NAME="${GITHUB_REPOSITORY#*/}"
86+ fi
87+
88+ # Extract tag from ref (or override)
89+ if [[ -n "${{ inputs.tag }}" ]]; then
90+ TAG="${{ inputs.tag }}"
91+ elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
92+ TAG="${GITHUB_REF#refs/tags/}"
93+ else
94+ TAG="$(echo "$GITHUB_SHA" | head -c 7)"
95+ fi
96+
97+ # Full image reference
98+ FULL_IMAGE="${{ inputs.registry }}/${IMAGE_NAME}:${TAG}"
99+
100+ echo "image=${FULL_IMAGE}" >> $GITHUB_OUTPUT
101+ echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
102+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
103+ echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT
104+
105+ echo "Building: ${FULL_IMAGE}"
106+
107+ - name: Set up QEMU
108+ uses: docker/setup-qemu-action@v3
109+
110+ - name: Set up Docker Buildx
111+ uses: docker/setup-buildx-action@v3
112+
113+ - name: Log in to Gordon registry
114+ uses: docker/login-action@v3
115+ with:
116+ registry: ${{ inputs.registry }}
117+ username: ${{ inputs.username }}
118+ password: ${{ inputs.password }}
119+
120+ - name: Build tags
121+ id: tags
122+ shell: bash
123+ run: |
124+ TAGS="${{ steps.meta.outputs.image }}"
125+ if [[ "${{ inputs.push-latest }}" == "true" ]]; then
126+ TAGS="${TAGS}"$'\n'"${{ inputs.registry }}/${{ steps.meta.outputs.image_name }}:latest"
127+ fi
128+ printf "tags<<EOF\n%s\nEOF\n" "$TAGS" >> "$GITHUB_OUTPUT"
129+
130+ - name: Build and push
131+ id: push
132+ uses: docker/build-push-action@v6
133+ env:
134+ DOCKER_BUILD_SUMMARY: false
135+ with:
136+ context: ${{ inputs.context }}
137+ file: ${{ inputs.dockerfile }}
138+ push: true
139+ tags: ${{ steps.tags.outputs.tags }}
140+ build-args: ${{ inputs.build-args }}
141+ platforms: ${{ inputs.platforms }}
142+ cache-from: ${{ inputs.cache-from }}
143+ cache-to: ${{ inputs.cache-to }}
144+ provenance: false
145+
146+ - name: Summary
147+ shell: bash
148+ run: |
149+ echo "## Gordon Deploy Summary" >> $GITHUB_STEP_SUMMARY
150+ echo "" >> $GITHUB_STEP_SUMMARY
151+ echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
152+ echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
153+ echo "| Image | \`${{ steps.meta.outputs.image }}\` |" >> $GITHUB_STEP_SUMMARY
154+ echo "| Tag | \`${{ steps.meta.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
155+ echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY
156+ echo "" >> $GITHUB_STEP_SUMMARY
157+ echo "Container deployed to Gordon registry and will be automatically deployed." >> $GITHUB_STEP_SUMMARY
M .github/dependabot.yml +1 -1
@@ -5,7 +5,7 @@
55
66 version: 2
77 updates:
8- - package-ecosystem: "" # See documentation for possible values
8+ - package-ecosystem: "gomod" # See documentation for possible values
99 directory: "/" # Location of package manifests
1010 schedule:
1111 interval: "weekly"
A .github/workflows/ci.yml +79 -0
@@ -0,0 +1,79 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches: [main]
6+ paths:
7+ - '**.go'
8+ - 'go.mod'
9+ - 'go.sum'
10+ - '.golangci.yml'
11+ - 'Makefile'
12+ - '.github/workflows/**'
13+ - '!**.md'
14+ - '!docs/**'
15+ - '!LICENSE'
16+ pull_request:
17+ paths:
18+ - '**.go'
19+ - 'go.mod'
20+ - 'go.sum'
21+ - '.golangci.yml'
22+ - 'Makefile'
23+ - '.github/workflows/**'
24+ - '!**.md'
25+ - '!docs/**'
26+ - '!LICENSE'
27+
28+jobs:
29+ lint:
30+ name: Lint
31+ runs-on: ubuntu-latest
32+ steps:
33+ - uses: actions/checkout@v4
34+
35+ - uses: actions/setup-go@v5
36+ with:
37+ go-version: stable
38+
39+ - name: Cache Go modules
40+ uses: actions/cache@v4
41+ with:
42+ path: ~/go/pkg/mod
43+ key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
44+ restore-keys: |
45+ ${{ runner.os }}-go-mod-
46+
47+ - name: Run golangci-lint
48+ uses: golangci/golangci-lint-action@v7
49+ with:
50+ version: latest
51+
52+ test:
53+ name: Test
54+ runs-on: ubuntu-latest
55+ steps:
56+ - uses: actions/checkout@v4
57+
58+ - uses: actions/setup-go@v5
59+ with:
60+ go-version: stable
61+
62+ - name: Cache Go modules
63+ uses: actions/cache@v4
64+ with:
65+ path: ~/go/pkg/mod
66+ key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
67+ restore-keys: |
68+ ${{ runner.os }}-go-mod-
69+
70+ - name: Cache Go build cache
71+ uses: actions/cache@v4
72+ with:
73+ path: ~/.cache/go-build
74+ key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
75+ restore-keys: |
76+ ${{ runner.os }}-go-build-
77+
78+ - name: Run tests
79+ run: go test -race -v ./...
A .github/workflows/release.yml +31 -0
@@ -0,0 +1,31 @@
1+name: Release
2+
3+on:
4+ push:
5+ tags:
6+ - 'v*'
7+
8+permissions:
9+ contents: write
10+
11+jobs:
12+ release:
13+ name: Release
14+ runs-on: ubuntu-latest
15+ steps:
16+ - uses: actions/checkout@v4
17+ with:
18+ fetch-depth: 0
19+
20+ - uses: actions/setup-go@v5
21+ with:
22+ go-version: stable
23+
24+ - name: Run GoReleaser
25+ uses: goreleaser/goreleaser-action@v6
26+ with:
27+ distribution: goreleaser
28+ version: "~> v2"
29+ args: release --clean
30+ env:
31+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
M .golangci.yml +49 -16
@@ -1,37 +1,70 @@
1+version: "2"
2+
13 run:
24 timeout: 5m
35 tests: true
46
57 linters:
68 enable:
7- - gofmt
89 - govet
910 - errcheck
1011 - staticcheck
1112 - unused
12- - gosimple
1313 - ineffassign
14- - typecheck
1514 - misspell
1615 - gosec
1716 - unconvert
1817 - gocyclo
18+ settings:
19+ gocyclo:
20+ min-complexity: 15
21+ exclusions:
22+ rules:
23+ - path: _test\.go
24+ linters:
25+ - gocyclo
26+ - errcheck
27+ - dupl
28+ - gosec
29+ - text: "Error return value of `.*Close` is not checked"
30+ linters:
31+ - errcheck
32+ - text: "Error return value of `.*Write` is not checked"
33+ linters:
34+ - errcheck
35+ - text: "Error return value of `os.Remove.*` is not checked"
36+ linters:
37+ - errcheck
38+ - text: "Error return value of `.*Publish` is not checked"
39+ linters:
40+ - errcheck
41+ - text: "Error return value of `.*Stop` is not checked"
42+ linters:
43+ - errcheck
44+ - text: "Error return value of `.*RemoveContainer` is not checked"
45+ linters:
46+ - errcheck
47+ - text: "Error return value of `log.WrapErr` is not checked"
48+ linters:
49+ - errcheck
50+ - text: "Error return value of `log.WrapErrWithFields` is not checked"
51+ linters:
52+ - errcheck
53+ # gosec: file operations with variables are intentional in storage layer
54+ - text: "G304:"
55+ linters:
56+ - gosec
57+ # gosec: G104 duplicates errcheck
58+ - text: "G104:"
59+ linters:
60+ - gosec
1961
20-linters-settings:
21- gocyclo:
22- min-complexity: 15
62+formatters:
63+ enable:
64+ - gofmt
2365
2466 issues:
25- exclude-rules:
26- - path: _test\.go
27- linters:
28- - gocyclo
29- - errcheck
30- - dupl
31- - gosec
32-
3367 max-same-issues: 50
3468
3569 severity:
36- default-severity: error
37- case-sensitive: false
70+ default: error
M .goreleaser.yaml +1 -1
@@ -26,7 +26,7 @@ builds:
2626 # docker_manifests:
2727
2828 archives:
29- - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
29+ - name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
3030
3131 changelog:
3232 sort: asc
A .mockery.yaml +29 -0
@@ -0,0 +1,29 @@
1+template: testify
2+formatter: goimports
3+force-file-write: true
4+dir: '{{.InterfaceDir}}/mocks'
5+filename: 'mock_{{.InterfaceName | snakecase}}.go'
6+structname: 'Mock{{.InterfaceName}}'
7+pkgname: 'mocks'
8+
9+packages:
10+ gordon/internal/boundaries/out:
11+ interfaces:
12+ ContainerRuntime:
13+ BlobStorage:
14+ ManifestStorage:
15+ EventBus:
16+ EventPublisher:
17+ EventSubscriber:
18+ EventHandler:
19+ SecretProvider:
20+ EnvLoader:
21+ ContainerLogWriter:
22+ TokenStore:
23+ gordon/internal/boundaries/in:
24+ interfaces:
25+ ContainerService:
26+ RegistryService:
27+ ProxyService:
28+ ConfigService:
29+ AuthService:
M Makefile +72 -27
@@ -21,13 +21,65 @@ LDFLAGS := -s -w \
2121 ARCHS := amd64 arm64
2222
2323 # Phony targets
24-.PHONY: all build build-push clean dev-release test test-unit test-coverage test-race clean-test
24+.PHONY: all build build-push clean dev-release \
25+ test test-short test-race test-coverage \
26+ lint fmt check mocks clean-test help
2527
2628 # Default target
2729 all: build
2830
29-# Build binaries
30-build:
31+##@ Development
32+
33+help: ## Show this help
34+ @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
35+
36+fmt: ## Format Go code
37+ @echo "Formatting Go code..."
38+ @go fmt ./...
39+
40+lint: ## Run golangci-lint
41+ @echo "Running linter..."
42+ @golangci-lint run ./...
43+
44+mocks: ## Generate mocks using mockery
45+ @echo "Generating mocks..."
46+ @mockery
47+ @echo "Mocks generated successfully"
48+
49+check: lint test ## Run lint and tests
50+
51+##@ Testing
52+
53+test: ## Run all tests
54+ @echo "Running tests..."
55+ @go test ./...
56+
57+test-v: ## Run all tests with verbose output
58+ @go test -v ./...
59+
60+test-short: ## Run tests (skip long-running tests)
61+ @go test -short ./...
62+
63+test-race: ## Run tests with race detector
64+ @echo "Running tests with race detector..."
65+ @go test -race ./...
66+
67+test-coverage: ## Run tests with coverage report
68+ @echo "Running tests with coverage..."
69+ @go test -coverprofile=coverage.out ./...
70+ @go tool cover -func=coverage.out
71+ @go tool cover -html=coverage.out -o coverage.html
72+ @echo "Coverage report generated: coverage.html"
73+
74+test-usecase: ## Run usecase layer tests only
75+ @go test -v ./internal/usecase/...
76+
77+test-adapter: ## Run adapter layer tests only
78+ @go test -v ./internal/adapters/...
79+
80+##@ Build
81+
82+build: ## Build binaries for linux (amd64 and arm64)
3183 @echo "Building Go binaries..."
3284 @mkdir -p $(DIST_DIR)
3385 @rm -f $(DIST_DIR)/*
@@ -36,8 +88,14 @@ build:
3688 @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
3789 @echo "Go binaries built successfully"
3890
39-# Build and push Docker images
40-build-push: build
91+build-local: ## Build binary for current platform
92+ @echo "Building for current platform..."
93+ @go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon ./main.go
94+ @echo "Binary built: $(DIST_DIR)/gordon"
95+
96+##@ Release
97+
98+build-push: build ## Build and push Docker images
4199 @echo "Cleaning up dangling images..."
42100 @$(ENGINE) image prune -f
43101
@@ -69,8 +127,7 @@ build-push: build
69127
70128 @echo "Script completed successfully."
71129
72-# Create dev GitHub release (separate from GoReleaser)
73-dev-release: build
130+dev-release: build ## Create dev GitHub release
74131 @echo "Creating dev GitHub release..."
75132 @if [ -z "$(shell which gh)" ]; then \
76133 echo "Error: GitHub CLI (gh) is not installed. Please install it first."; \
@@ -92,27 +149,15 @@ dev-release: build
92149 @echo ""
93150 @echo "🔗 Release page: https://github.com/bnema/gordon/releases/tag/$(DEV_TAG)"
94151
95-# Test targets
96-test: test-unit
97-
98-test-unit:
99- go test -v ./...
100-
101-test-race:
102- go test -race -v ./...
152+##@ Cleanup
103153
104-test-coverage:
105- go test -coverprofile=coverage.out ./...
106- go tool cover -html=coverage.out -o coverage.html
107- @echo "Coverage report generated: coverage.html"
108-
109-
110-clean-test:
111- rm -f coverage.out coverage.html
112-
113-# Clean up
114-clean:
154+clean: ## Clean build artifacts
115155 @echo "Cleaning up..."
116156 @rm -rf $(DIST_DIR)
157+ @echo "Cleanup completed."
158+
159+clean-all: clean clean-test ## Clean all artifacts including test files
117160 @$(ENGINE) system prune -f
118- @echo "Cleanup completed."⏎̸
161+
162+clean-test: ## Clean test artifacts
163+ @rm -f coverage.out coverage.html⏎̸
M README.md +195 -105
@@ -1,75 +1,58 @@
11 # Gordon
22
3-> Deploy containers to your VPS in seconds, not hours. One push, zero complexity.
3+[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
4+[![Go Report Card](https://goreportcard.com/badge/github.com/bnema/gordon)](https://goreportcard.com/report/github.com/bnema/gordon)
45
5-<div align="center">
6- <img src="assets/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" width="200">
7- <h3>The Smart Way to Deploy Containers on Your VPS</h3>
8- <p><em>Push image → Auto-deploy → Zero downtime</em></p>
9-</div>
6+Self-hosted web app deployments. Push to your registry, Gordon does the rest.
107
11-## What is Gordon?
8+- Website: https://github.com/bnema/gordon
9+- Documentation: [Configuration](examples/) | [Setup Guide](#detailed-setup-guide-podman-rootless-mode)
10+- Discuss: [GitHub Discussions](https://github.com/bnema/gordon/discussions)
1211
13-Gordon is a lightweight deployment system that turns any VPS into a container hosting platform with push-to-deploy capabilities. It combines a private container registry with an intelligent reverse proxy to create a self-hosted alternative to complex orchestration systems.
14-
15-### Key Features
16-
17-- **Built-in Container Registry**: Your VPS becomes a private registry
18-- **Push-to-Deploy**: Pushing an image triggers automatic deployment
19-- **Smart Routing**: Multi-domain support with automatic HTTPS
20-- **Zero-Downtime Updates**: Graceful container swaps on new pushes
21-- **Network Isolation**: Each app gets its own network with attached services
22-- **Auto-Volume Management**: Persistent storage from Dockerfile VOLUME directives
23-- **Environment Merging**: Dockerfile ENV + your custom variables
24-- **Minimal Footprint**: ~15MB RAM usage, single binary
25-
26-### Perfect For
27-
28-- **Solo developers** running multiple projects on one VPS
29-- **Small teams** wanting simple, reliable deployments
30-- **Agencies** managing client applications across servers
31-- **Anyone** tired of overengineered deployment solutions
12+---
3213
33-## How It Works
14+Gordon is a private container registry + HTTP reverse proxy for your VPS. Push a container image exposing a web port, it deploys automatically with zero downtime.
3415
3516 ```bash
36-# 1. Build & test locally
37-podman build -t myapp .
38-podman run -p 8080:8080 myapp # Works? Great!
39-
40-# 2. Push to deploy
41-podman tag myapp registry.mydomain.com/myapp:latest
42-podman push registry.mydomain.com/myapp:latest
43-
44-# 3. That's it. Gordon handles the rest.
17+docker build -t myapp .
18+docker push registry.your-server.com/myapp:latest
19+# → Live at https://app.your-server.com
4520 ```
4621
47-**Your machine is the build server. If it runs locally, it runs in production.**
22+Build on your machine, push to deploy. Works from your laptop or CI.
23+
24+**What it does:**
25+- Runs a private Docker registry on your VPS
26+- Routes domains to containers via HTTP reverse proxy
27+- Deploys automatically when you push a new image
28+- Zero downtime updates, persistent volumes, environment merging
29+- Single binary, ~15MB RAM
4830
49-## Quick Start (5 minutes)
31+## Quick Start
5032
5133 ### Prerequisites
5234 - Ubuntu/Debian VPS with root access
5335 - Domain pointing to your VPS
54-- Cloudflare account (free tier works)
36+- Cloudflare account (free tier works) - **Required for HTTPS**
37+
38+> **HTTPS Architecture:** Cloudflare terminates HTTPS and proxies to Gordon over HTTP internally. Gordon doesn't handle TLS certificates directly (no Let's Encrypt support yet).
5539
5640 ### Installation
5741
5842 ```bash
59-# Download Gordon from the releases page
60-# https://github.com/bnema/gordon/releases/latest
61-# Example: wget https://github.com/bnema/gordon/releases/download/v2.0.0/gordon_2.0.0_linux_amd64.tar.gz
62-# Then extract:
63-tar -xzf gordon_*.tar.gz
43+# Download and install
44+wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz
45+tar -xzf gordon_linux_amd64.tar.gz
6446 chmod +x gordon
6547 sudo mv gordon /usr/local/bin/
6648
67-# Create initial config
49+# Start Gordon (generates config on first run)
6850 gordon start
69-# Press Ctrl+C after config is created
7051 ```
7152
72-**Important:** This is just the binary installation. For a complete working setup including networking, firewall, and systemd service, follow the [detailed setup guide](#detailed-setup-guide) below.
53+Config is created at `~/.config/gordon/gordon.toml` on first start. Edit it to set your `registry_domain` and routes.
54+
55+**Important:** For a complete working setup including networking, firewall, and systemd service, follow the [detailed setup guide](#detailed-setup-guide-podman-rootless-mode) below.
7356
7457 ## Core Concepts
7558
@@ -79,13 +62,13 @@ Your dev machine likely has 8-16 cores and 16-32GB RAM. Your VPS has 1-2 cores a
7962 ### Push-to-Deploy Workflow
8063 ```bash
8164 # Initial deployment
82-podman build -t myapp .
83-podman push registry.mydomain.com/myapp:latest
65+docker build -t myapp .
66+docker push registry.mydomain.com/myapp:latest
8467 # Visit https://app.mydomain.com
8568
8669 # Update deployment
87-podman build -t myapp .
88-podman push registry.mydomain.com/myapp:latest
70+docker build -t myapp .
71+docker push registry.mydomain.com/myapp:latest
8972 # Zero-downtime update applied automatically
9073 ```
9174
@@ -99,10 +82,28 @@ FROM node:18
9982 VOLUME ["/app/uploads", "/app/data"] # Multiple volumes supported
10083 ```
10184
85+### Proxy Port Selection
86+When an image exposes multiple ports, Gordon needs to know which one to proxy HTTP traffic to. By default, Gordon uses the first exposed port, but you can specify the exact port with a label:
87+
88+```dockerfile
89+# Gitea exposes both SSH (22) and HTTP (3000)
90+FROM gitea/gitea:latest
91+LABEL gordon.proxy.port=3000
92+EXPOSE 22
93+EXPOSE 3000
94+```
95+
96+Without the label, Gordon would proxy to port 22 (SSH), which would fail. The `gordon.proxy.port` label tells Gordon to route HTTP traffic to port 3000.
97+
98+**When to use this label:**
99+- Images that expose multiple ports (web + SSH, web + metrics, etc.)
100+- When the first exposed port isn't the HTTP service
101+- To be explicit about which port serves your web application
102+
102103 ### Environment Configuration
103104 Gordon automatically creates and loads env files based on domain names:
104105 ```bash
105-# Auto-created on first run: ~/.local/share/gordon/env/app_mydomain_com.env
106+# Auto-created on first run: ~/.gordon/env/app_mydomain_com.env
106107 # Just add your variables:
107108 DATABASE_URL=postgresql://localhost:5432/myapp
108109 API_KEY=sk-1234567890
@@ -123,7 +124,7 @@ Gordon also supports network groups, see [examples/](examples/) for advanced usa
123124 - **Volumes**: Automatically created from Dockerfile VOLUME directives, persist across updates
124125
125126 ### Production ready logging
126-- **Logs**: All container output saved to `~/.local/share/gordon/logs/containers/`
127+- **Logs**: All container output saved to `~/.gordon/logs/containers/`
127128 - **Rotation**: Logs rotate at 100MB, keeping 3 backups for 28 days
128129
129130 ## Configuration
@@ -135,9 +136,19 @@ Gordon uses a single TOML file for all configuration:
135136 [server]
136137 registry_domain = "registry.mydomain.com" # Required
137138
139+# Secrets backend for sensitive data (tokens, passwords)
140+[secrets]
141+backend = "pass" # "pass", "sops", or "unsafe" (plain text in data_dir)
142+
143+# Registry authentication (choose password or token type)
138144 [registry_auth]
139-username = "admin" # Choose your credentials
140-password = "your-secure-password"
145+enabled = true
146+type = "password" # "password" or "token"
147+# Password auth: bcrypt hash stored in secrets backend
148+username = "deploy"
149+password_hash = "gordon/registry/password_hash" # path in secrets backend
150+# Token auth: JWT-based authentication
151+# token_secret = "gordon/registry/token_secret" # path in secrets backend
141152
142153 [routes]
143154 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
@@ -151,49 +162,122 @@ password = "your-secure-password"
151162
152163 See [examples/](examples/) for advanced configurations including network groups and more.
153164
154-## Detailed Setup Guide (Using podman in rootless mode)
155-Note: Refer to Quick Start for the binary installation.
165+### Authentication Types
166+
167+**Password Authentication** (simple):
168+```bash
169+# Generate a bcrypt hash for your password
170+gordon auth password hash
171+# Store the hash in your secrets backend, then reference it in config
172+```
173+
174+**Token Authentication** (recommended for CI/CD):
175+```bash
176+# Generate a never-expiring token for CI
177+gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
178+
179+# List all tokens
180+gordon auth token list
181+
182+# Revoke a compromised token
183+gordon auth token revoke <token-id>
184+```
185+
186+Tokens are stored in your configured secrets backend and are unique to each Gordon instance (different `token_secret` = incompatible tokens).
187+
188+### GitHub Actions Integration
189+
190+Gordon provides an official GitHub Action for automated deployments on tag push:
191+
192+```yaml
193+# .github/workflows/deploy.yml
194+name: Deploy to Gordon
195+
196+on:
197+ push:
198+ tags:
199+ - 'v*'
200+
201+jobs:
202+ deploy:
203+ runs-on: ubuntu-latest
204+ steps:
205+ - uses: actions/checkout@v6
206+
207+ - name: Deploy to Gordon
208+ uses: bnema/gordon/.github/actions/deploy@main
209+ with:
210+ registry: ${{ secrets.GORDON_REGISTRY }}
211+ username: ${{ secrets.GORDON_USERNAME }}
212+ password: ${{ secrets.GORDON_TOKEN }}
213+```
214+
215+**Setup:**
216+1. Generate a CI token: `gordon auth token generate --subject github-actions --scopes push,pull --expiry 0`
217+2. Add secrets to your GitHub repository: `GORDON_REGISTRY`, `GORDON_USERNAME`, `GORDON_TOKEN`
218+3. Push a tag to deploy: `git tag v1.0.0 && git push origin v1.0.0`
219+
220+See [examples/github-workflow.yml](examples/github-workflow.yml) and [.github/actions/deploy/README.md](.github/actions/deploy/README.md) for advanced options.
221+
222+## Detailed Setup Guide (Podman Rootless Mode)
156223
157-### 1. VPS Preparation
224+> **Note:** This guide uses Podman in rootless mode for enhanced security. Docker users can follow similar steps. Gordon works with any OCI-compatible runtime.
225+
226+### 1. System Preparation
158227 ```bash
159228 # Update system
160229 sudo apt update && sudo apt upgrade -y
161230
162-# Install podman and firewall
163-sudo apt install -y podman ufw
231+# Install podman and firewalld
232+sudo apt install -y podman firewalld
164233
165-# Configure firewall
166-sudo ufw allow 22/tcp 80/tcp 443/tcp
167-sudo default deny
168-sudo ufw --force enable
234+# Enable and start firewalld
235+sudo systemctl enable --now firewalld
169236 ```
170237
171-### 2. Enable Port Forwarding
238+### 2. Firewall & Port Forwarding (rootless requires this)
239+
240+Rootless containers can't bind to privileged ports (< 1024). Use firewalld to redirect 80/443 to unprivileged ports:
241+
172242 ```bash
173-# Forward 80/443 to unprivileged port 8080
174-sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080
175-sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080
243+# Allow HTTP and HTTPS traffic
244+sudo firewall-cmd --permanent --add-service=http
245+sudo firewall-cmd --permanent --add-service=https
246+
247+# Port forward 80/443 to rootless container ports
248+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080
249+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
176250
177-# Make persistent
178-sudo apt install -y iptables-persistent
179-sudo netfilter-persistent save
251+# Set default policy to drop incoming traffic
252+sudo firewall-cmd --permanent --zone=public --set-target=DROP
253+
254+# Reload to apply
255+sudo firewall-cmd --reload
256+
257+# Verify rules
258+sudo firewall-cmd --list-all
180259 ```
181260
182-### 3. Configure Rootless Containers
261+> **Note:** If using Tailscale for management, add its interface to the trusted zone:
262+> ```bash
263+> sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0
264+> sudo firewall-cmd --permanent --add-port=41641/udp # Tailscale WireGuard
265+> sudo firewall-cmd --reload
266+> ```
267+
268+### 3. Rootless Container Setup
183269 ```bash
184270 # Enable user namespaces
185271 echo 'user.max_user_namespaces=28633' | sudo tee -a /etc/sysctl.conf
186272 sudo sysctl -p
187273
188-# Setup subuid/subgid
274+# Setup subuid/subgid for your user
189275 sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
190276
191277 # Enable podman socket
192278 systemctl --user enable --now podman.socket
193-```
194279
195-### 4. Registry Configuration
196-```bash
280+# Allow localhost registry (podman config)
197281 mkdir -p ~/.config/containers
198282 cat > ~/.config/containers/registries.conf <<EOF
199283 [registries.insecure]
@@ -201,9 +285,28 @@ registries = ['localhost:5000']
201285 EOF
202286 ```
203287
204-### 5. Create Systemd Service
288+### 4. Install Gordon
289+```bash
290+# Download from releases
291+wget https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64.tar.gz
292+tar -xzf gordon_linux_amd64.tar.gz
293+chmod +x gordon
294+sudo mv gordon /usr/local/bin/
295+```
296+
297+### 5. Configure Gordon
298+```bash
299+# Run once to generate config, then stop with Ctrl+C
300+gordon start
301+
302+# Edit the generated config
303+nano ~/.config/gordon/gordon.toml
304+```
305+
306+Set your `registry_domain` and add routes. Data is stored in `~/.gordon/`.
307+
308+### 6. Create Systemd Service
205309 ```bash
206-# Create user service
207310 mkdir -p ~/.config/systemd/user
208311 cat > ~/.config/systemd/user/gordon.service <<EOF
209312 [Unit]
@@ -215,19 +318,19 @@ Type=simple
215318 Restart=always
216319 RestartSec=5
217320 ExecStart=/usr/local/bin/gordon start
218-WorkingDirectory=%h
219321
220322 [Install]
221323 WantedBy=default.target
222324 EOF
223325
224-# Enable and start service
326+# Enable and start
225327 systemctl --user daemon-reload
226328 systemctl --user enable --now gordon
227329 sudo loginctl enable-linger $USER
228330
229-# Verify it's running
331+# Verify
230332 systemctl --user status gordon
333+journalctl --user -u gordon -f # Watch logs
231334 ```
232335
233336 ## Deployment Strategies
@@ -235,14 +338,14 @@ systemctl --user status gordon
235338 ### Simple Deployment
236339 ```bash
237340 # Build locally
238-podman build -t myapp .
341+docker build -t myapp .
239342
240343 # Test locally
241-podman run -p 8080:8080 myapp
344+docker run -p 8080:8080 myapp
242345
243346 # Push to registry
244-podman tag myapp registry.mydomain.com/myapp:latest
245-podman push registry.mydomain.com/myapp:latest
347+docker tag myapp registry.mydomain.com/myapp:latest
348+docker push registry.mydomain.com/myapp:latest
246349
247350 # Profit!
248351 ```
@@ -250,8 +353,8 @@ podman push registry.mydomain.com/myapp:latest
250353 ### Versioned Deployment
251354 ```bash
252355 # Deploy specific version
253-podman tag myapp:v1.2.0 registry.mydomain.com/myapp:v1.2.0
254-podman push registry.mydomain.com/myapp:v1.2.0
356+docker tag myapp:v1.2.0 registry.mydomain.com/myapp:v1.2.0
357+docker push registry.mydomain.com/myapp:v1.2.0
255358
256359 # Update route in gordon.toml
257360 # "app.mydomain.com" = "myapp:v1.2.0"
@@ -261,10 +364,10 @@ podman push registry.mydomain.com/myapp:v1.2.0
261364 ```bash
262365 # Deploy with metadata
263366 export VERSION=v1.2.0
264-podman manifest create myapp:latest
265-podman manifest add myapp:latest registry.mydomain.com/myapp:$VERSION
266-podman manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION
267-podman manifest push myapp:latest registry.mydomain.com/myapp:latest
367+docker manifest create myapp:latest
368+docker manifest add myapp:latest registry.mydomain.com/myapp:$VERSION
369+docker manifest annotate myapp:latest --annotation version=$VERSION registry.mydomain.com/myapp:$VERSION
370+docker manifest push myapp:latest registry.mydomain.com/myapp:latest
268371 ```
269372
270373 ## Community
@@ -274,19 +377,6 @@ Gordon is open source and welcomes contributions:
274377 - [Suggest features](https://github.com/bnema/gordon/discussions)
275378 - [Submit PRs](https://github.com/bnema/gordon/pulls)
276379
277-## Philosophy
278-
279-Traditional deployment pipelines recreate your development environment in CI/CD systems, adding complexity and points of failure. Gordon takes a different approach: **your development machine is already a perfect build environment**.
280-
281-```bash
282-# Traditional: Code → CI/CD → Build → Test → Deploy → Hope
283-# Gordon: Code → Build → Test → Push → Done
284-```
285-
286380 ## License
287381
288-GPL-3.0 - Use freely, contribute back.
289-
290----
291-
292-**Built for developers who ship.** If Gordon helps you deploy faster, [give it a star](https://github.com/bnema/gordon).⏎̸
382+GPL-3.0 - Use freely, contribute back.⏎̸
D cmd/reload.go +0 -85
@@ -1,85 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6- "path/filepath"
7- "syscall"
8-
9- "github.com/spf13/cobra"
10-)
11-
12-var reloadCmd = &cobra.Command{
13- Use: "reload",
14- Short: "Reload configuration and environment variables into running containers",
15- Long: `Reload the Gordon configuration and environment variables from .env files into running containers.
16-This command reloads the config file and redeploys all containers with updated environment
17-variables from their respective .env files. Containers are recreated to pick up the new configuration and environment variables.`,
18- Run: runReload,
19-}
20-
21-func init() {
22- rootCmd.AddCommand(reloadCmd)
23-}
24-
25-func runReload(cmd *cobra.Command, args []string) {
26- // Find the Gordon PID file to get the process ID
27- pidFile := findGordonPidFile()
28- if pidFile == "" {
29- fmt.Println("Error: Gordon PID file not found. Is Gordon running?")
30- fmt.Println("Please start Gordon with 'gordon start' first.")
31- return
32- }
33-
34- // Read the PID from the file
35- pidBytes, err := os.ReadFile(pidFile)
36- if err != nil {
37- fmt.Printf("Error reading Gordon PID file: %v\n", err)
38- return
39- }
40-
41- var pid int
42- if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
43- fmt.Printf("Error parsing Gordon PID: %v\n", err)
44- return
45- }
46-
47- // Check if the process is still running
48- process, err := os.FindProcess(pid)
49- if err != nil {
50- fmt.Printf("Error finding Gordon process: %v\n", err)
51- return
52- }
53-
54- // Send SIGUSR1 signal to trigger reload
55- if err := process.Signal(syscall.SIGUSR1); err != nil {
56- fmt.Printf("Error sending reload signal to Gordon: %v\n", err)
57- fmt.Println("Gordon may not be running or may not have permission to send signals.")
58- return
59- }
60-
61- fmt.Println("Manual reload signal sent to Gordon successfully.")
62- fmt.Println("Check Gordon logs to see the reload progress.")
63-}
64-
65-func findGordonPidFile() string {
66- // Look for PID file in common locations
67- locations := []string{
68- "/tmp/gordon.pid",
69- "/var/run/gordon.pid",
70- filepath.Join(os.TempDir(), "gordon.pid"),
71- }
72-
73- // Also check in user's home directory
74- if homeDir, err := os.UserHomeDir(); err == nil {
75- locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
76- }
77-
78- for _, location := range locations {
79- if _, err := os.Stat(location); err == nil {
80- return location
81- }
82- }
83-
84- return ""
85-}
D cmd/reload_test.go +0 -434
@@ -1,434 +0,0 @@
1-package cmd
2-
3-import (
4- "bytes"
5- "fmt"
6- "os"
7- "path/filepath"
8- "syscall"
9- "testing"
10-
11- "github.com/spf13/cobra"
12- "github.com/stretchr/testify/assert"
13- "github.com/stretchr/testify/require"
14-)
15-
16-func TestReloadCommand(t *testing.T) {
17- // Test reload command structure
18- assert.Equal(t, "reload", reloadCmd.Use)
19- assert.Contains(t, reloadCmd.Short, "Reload")
20- assert.Contains(t, reloadCmd.Long, "configuration")
21- assert.Contains(t, reloadCmd.Long, "environment variables")
22-}
23-
24-func TestReloadCmdStructure(t *testing.T) {
25- // Verify command is properly registered
26- found := false
27- for _, cmd := range rootCmd.Commands() {
28- if cmd.Name() == "reload" {
29- found = true
30- break
31- }
32- }
33- assert.True(t, found, "reload command should be registered with root command")
34-}
35-
36-func TestReloadCmdHelp(t *testing.T) {
37- var output bytes.Buffer
38-
39- // Create a copy of the command to avoid affecting the global command
40- cmd := &cobra.Command{
41- Use: reloadCmd.Use,
42- Short: reloadCmd.Short,
43- Long: reloadCmd.Long,
44- Run: reloadCmd.Run,
45- }
46-
47- cmd.SetOut(&output)
48- cmd.SetErr(&output)
49- cmd.SetArgs([]string{"--help"})
50-
51- err := cmd.Execute()
52- assert.NoError(t, err)
53-
54- helpOutput := output.String()
55- assert.Contains(t, helpOutput, "Reload")
56- assert.Contains(t, helpOutput, "configuration")
57- assert.Contains(t, helpOutput, "environment variables")
58- assert.Contains(t, helpOutput, "containers")
59-}
60-
61-func TestFindGordonPidFile(t *testing.T) {
62- tests := []struct {
63- name string
64- setupFunc func(t *testing.T) (string, func()) // returns temp dir and cleanup func
65- expectedFound bool
66- }{
67- {
68- name: "PID file exists in temp directory",
69- setupFunc: func(t *testing.T) (string, func()) {
70- tempDir := t.TempDir()
71- pidFile := filepath.Join(tempDir, "gordon.pid")
72- err := os.WriteFile(pidFile, []byte("12345"), 0644)
73- require.NoError(t, err)
74-
75- cleanup := func() {
76- // Cleanup function for consistency
77- }
78- return pidFile, cleanup
79- },
80- expectedFound: false, // Won't find it because we can't easily mock os.TempDir
81- },
82- {
83- name: "PID file exists in home directory",
84- setupFunc: func(t *testing.T) (string, func()) {
85- // Create temp directory to simulate home
86- tempDir := t.TempDir()
87- pidFile := filepath.Join(tempDir, ".gordon.pid")
88- err := os.WriteFile(pidFile, []byte("12345"), 0644)
89- require.NoError(t, err)
90-
91- // Mock home directory
92- originalHome := os.Getenv("HOME")
93- os.Setenv("HOME", tempDir)
94-
95- cleanup := func() {
96- os.Setenv("HOME", originalHome)
97- }
98- return pidFile, cleanup
99- },
100- expectedFound: true,
101- },
102- {
103- name: "no PID file exists",
104- setupFunc: func(t *testing.T) (string, func()) {
105- // Set home to a temp directory with no PID file
106- tempDir := t.TempDir()
107- originalHome := os.Getenv("HOME")
108- os.Setenv("HOME", tempDir)
109-
110- cleanup := func() {
111- os.Setenv("HOME", originalHome)
112- }
113- return "", cleanup
114- },
115- expectedFound: false,
116- },
117- }
118-
119- for _, tt := range tests {
120- t.Run(tt.name, func(t *testing.T) {
121- expectedPidFile, cleanup := tt.setupFunc(t)
122- defer cleanup()
123-
124- result := findGordonPidFile()
125-
126- if tt.expectedFound {
127- assert.NotEmpty(t, result)
128- assert.Equal(t, expectedPidFile, result)
129- } else {
130- assert.Empty(t, result)
131- }
132- })
133- }
134-}
135-
136-func TestRunReload_NoPidFile(t *testing.T) {
137- // Set up environment with no PID file
138- tempDir := t.TempDir()
139- originalHome := os.Getenv("HOME")
140- os.Setenv("HOME", tempDir)
141- defer os.Setenv("HOME", originalHome)
142-
143- // Capture stdout by redirecting it
144- oldStdout := os.Stdout
145- r, w, _ := os.Pipe()
146- os.Stdout = w
147-
148- done := make(chan string)
149- go func() {
150- var buf bytes.Buffer
151- buf.ReadFrom(r)
152- done <- buf.String()
153- }()
154-
155- // Run reload command
156- runReload(reloadCmd, []string{})
157-
158- // Restore stdout and get output
159- w.Close()
160- os.Stdout = oldStdout
161- outputStr := <-done
162-
163- // Should indicate PID file not found
164- assert.Contains(t, outputStr, "PID file not found")
165- assert.Contains(t, outputStr, "Is Gordon running?")
166-}
167-
168-func TestRunReload_WithValidPidFile(t *testing.T) {
169- // Create a test PID file
170- tempDir := t.TempDir()
171- pidFile := filepath.Join(tempDir, ".gordon.pid")
172-
173- // Write current process PID to file (so it's valid)
174- pid := os.Getpid()
175- err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", pid)), 0644)
176- require.NoError(t, err)
177-
178- // Mock home directory
179- originalHome := os.Getenv("HOME")
180- os.Setenv("HOME", tempDir)
181- defer os.Setenv("HOME", originalHome)
182-
183- // Capture stdout
184- var output bytes.Buffer
185- reloadCmd.SetOut(&output)
186-
187- // We can't easily test the actual signal sending to another process,
188- // but we can test the PID file reading logic
189-
190- // Test PID file reading
191- foundPidFile := findGordonPidFile()
192- assert.Equal(t, pidFile, foundPidFile)
193-
194- // Test PID parsing
195- pidBytes, err := os.ReadFile(pidFile)
196- require.NoError(t, err)
197-
198- var parsedPid int
199- _, err = fmt.Sscanf(string(pidBytes), "%d", &parsedPid)
200- require.NoError(t, err)
201- assert.Equal(t, pid, parsedPid)
202-}
203-
204-func TestRunReload_InvalidPidFile(t *testing.T) {
205- // Create a PID file with invalid content
206- tempDir := t.TempDir()
207- pidFile := filepath.Join(tempDir, ".gordon.pid")
208-
209- // Write invalid PID content
210- err := os.WriteFile(pidFile, []byte("invalid_pid"), 0644)
211- require.NoError(t, err)
212-
213- // Mock home directory
214- originalHome := os.Getenv("HOME")
215- os.Setenv("HOME", tempDir)
216- defer os.Setenv("HOME", originalHome)
217-
218- // Capture stdout by redirecting it
219- oldStdout := os.Stdout
220- r, w, _ := os.Pipe()
221- os.Stdout = w
222-
223- done := make(chan string)
224- go func() {
225- var buf bytes.Buffer
226- buf.ReadFrom(r)
227- done <- buf.String()
228- }()
229-
230- // Run reload command
231- runReload(reloadCmd, []string{})
232-
233- // Restore stdout and get output
234- w.Close()
235- os.Stdout = oldStdout
236- outputStr := <-done
237-
238- // Should indicate parsing error
239- assert.Contains(t, outputStr, "Error parsing Gordon PID")
240-}
241-
242-func TestRunReload_UnreadablePidFile(t *testing.T) {
243- // Create a PID file that can't be read
244- tempDir := t.TempDir()
245- pidFile := filepath.Join(tempDir, ".gordon.pid")
246-
247- // Create file but make it unreadable (if possible)
248- err := os.WriteFile(pidFile, []byte("12345"), 0000)
249- require.NoError(t, err)
250-
251- // Mock home directory
252- originalHome := os.Getenv("HOME")
253- os.Setenv("HOME", tempDir)
254- defer os.Setenv("HOME", originalHome)
255-
256- // Capture stdout
257- var output bytes.Buffer
258- reloadCmd.SetOut(&output)
259-
260- // Run reload command
261- runReload(reloadCmd, []string{})
262-
263- // Should indicate read error (on systems that enforce permissions)
264- outputStr := output.String()
265- // Note: This test might not fail on all systems due to permission handling
266- if len(outputStr) > 0 {
267- // If we get output, it should contain an error message
268- assert.True(t,
269- contains(outputStr, "Error reading") ||
270- contains(outputStr, "Error parsing") ||
271- contains(outputStr, "Error finding") ||
272- contains(outputStr, "Error sending"),
273- "Should contain some error message, got: %s", outputStr)
274- }
275-}
276-
277-func TestRunReload_NonExistentProcess(t *testing.T) {
278- // Create a PID file with a PID that doesn't exist
279- tempDir := t.TempDir()
280- pidFile := filepath.Join(tempDir, ".gordon.pid")
281-
282- // Use a PID that's very unlikely to exist (high number)
283- nonExistentPid := 999999
284- err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", nonExistentPid)), 0644)
285- require.NoError(t, err)
286-
287- // Mock home directory
288- originalHome := os.Getenv("HOME")
289- os.Setenv("HOME", tempDir)
290- defer os.Setenv("HOME", originalHome)
291-
292- // Capture stdout by redirecting it
293- oldStdout := os.Stdout
294- r, w, _ := os.Pipe()
295- os.Stdout = w
296-
297- done := make(chan string)
298- go func() {
299- var buf bytes.Buffer
300- buf.ReadFrom(r)
301- done <- buf.String()
302- }()
303-
304- // Run reload command
305- runReload(reloadCmd, []string{})
306-
307- // Restore stdout and get output
308- w.Close()
309- os.Stdout = oldStdout
310- outputStr := <-done
311-
312- // Should indicate process not found or signal sending error
313- assert.True(t,
314- contains(outputStr, "Error finding") ||
315- contains(outputStr, "Error sending") ||
316- contains(outputStr, "not be running"),
317- "Should contain process-related error, got: %s", outputStr)
318-}
319-
320-func TestFindGordonPidFile_AllLocations(t *testing.T) {
321- // Test the locations that findGordonPidFile checks
322- locations := []string{
323- "/tmp/gordon.pid",
324- "/var/run/gordon.pid",
325- }
326-
327- // Add temp dir location
328- tempDirLocation := filepath.Join(os.TempDir(), "gordon.pid")
329- locations = append(locations, tempDirLocation)
330-
331- // Add home directory location
332- if homeDir, err := os.UserHomeDir(); err == nil {
333- homeLocation := filepath.Join(homeDir, ".gordon.pid")
334- locations = append(locations, homeLocation)
335- }
336-
337- // Verify that the function checks these locations
338- // We can't easily test all of them without root access,
339- // but we can verify the function doesn't panic
340- assert.NotPanics(t, func() {
341- result := findGordonPidFile()
342- // Result might be empty if no PID file exists, which is fine
343- _ = result
344- })
345-}
346-
347-func TestSignalSending_Concepts(t *testing.T) {
348- // Test the signal sending concepts used in runReload
349-
350- // Test that we can find our own process
351- process, err := os.FindProcess(os.Getpid())
352- assert.NoError(t, err)
353- assert.NotNil(t, process)
354-
355- // Test signal types
356- signals := []syscall.Signal{
357- syscall.SIGUSR1,
358- syscall.SIGTERM,
359- syscall.SIGINT,
360- }
361-
362- for _, sig := range signals {
363- assert.NotNil(t, sig)
364- }
365-}
366-
367-func TestPidFileValidation(t *testing.T) {
368- tests := []struct {
369- name string
370- content string
371- expectErr bool
372- }{
373- {
374- name: "valid PID",
375- content: "12345",
376- expectErr: false,
377- },
378- {
379- name: "invalid PID - letters",
380- content: "abc",
381- expectErr: true,
382- },
383- {
384- name: "invalid PID - empty",
385- content: "",
386- expectErr: true,
387- },
388- {
389- name: "invalid PID - negative",
390- content: "-123",
391- expectErr: false, // Parsing succeeds but PID is invalid
392- },
393- {
394- name: "valid PID with whitespace",
395- content: " 12345 \n",
396- expectErr: false,
397- },
398- }
399-
400- for _, tt := range tests {
401- t.Run(tt.name, func(t *testing.T) {
402- var pid int
403- _, err := fmt.Sscanf(tt.content, "%d", &pid)
404-
405- if tt.expectErr {
406- assert.Error(t, err)
407- } else {
408- assert.NoError(t, err)
409- if tt.content != "" && tt.content != " 12345 \n" && tt.content != "-123" {
410- assert.Greater(t, pid, 0)
411- }
412- }
413- })
414- }
415-}
416-
417-// Helper function to check if string contains substring (case-insensitive)
418-func contains(s, substr string) bool {
419- return len(s) >= len(substr) &&
420- (s == substr ||
421- len(s) > len(substr) &&
422- (s[:len(substr)] == substr ||
423- s[len(s)-len(substr):] == substr ||
424- searchSubstring(s, substr)))
425-}
426-
427-func searchSubstring(s, substr string) bool {
428- for i := 0; i <= len(s)-len(substr); i++ {
429- if s[i:i+len(substr)] == substr {
430- return true
431- }
432- }
433- return false
434-}⏎̸
D cmd/root.go +0 -81
@@ -1,81 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6-
7- "github.com/spf13/cobra"
8- "github.com/spf13/viper"
9-)
10-
11-var cfgFile string
12-
13-// Version information set by main.go from goreleaser
14-var (
15- BuildVersion = "dev"
16- BuildCommit = "unknown"
17- BuildDate = "unknown"
18-)
19-
20-var rootCmd = &cobra.Command{
21- Use: "gordon",
22- Short: "Gordon - Container Registry & Deployment System",
23- Long: `Gordon is a single-binary application that combines a container registry
24-with an intelligent reverse proxy and automated deployment system.`,
25-}
26-
27-func Execute() error {
28- return rootCmd.Execute()
29-}
30-
31-// SetVersionInfo sets the build information from main.go
32-func SetVersionInfo(version, commit, date string) {
33- BuildVersion = version
34- BuildCommit = commit
35- BuildDate = date
36-}
37-
38-func init() {
39- cobra.OnInitialize(initConfig)
40- rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./gordon.toml)")
41-}
42-
43-func initConfig() {
44- if cfgFile != "" {
45- viper.SetConfigFile(cfgFile)
46- } else {
47- // Search for config file in standard locations
48- viper.SetConfigName("gordon")
49- viper.SetConfigType("toml")
50-
51- // Current directory (highest priority)
52- viper.AddConfigPath(".")
53-
54- // User config directory
55- if userConfigDir, err := os.UserConfigDir(); err == nil {
56- viper.AddConfigPath(userConfigDir + "/gordon")
57- }
58-
59- // User home directory
60- if homeDir, err := os.UserHomeDir(); err == nil {
61- viper.AddConfigPath(homeDir + "/.gordon")
62- viper.AddConfigPath(homeDir)
63- }
64-
65- // System-wide config directories
66- viper.AddConfigPath("/etc/gordon")
67- viper.AddConfigPath("/usr/local/etc/gordon")
68- }
69-
70- viper.AutomaticEnv()
71-
72- if err := viper.ReadInConfig(); err == nil {
73- fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
74- } else {
75- if cfgFile != "" {
76- fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err)
77- }
78- // Don't fatal for version command - config not needed
79- // Other commands will check for config when needed
80- }
81-}
D cmd/root_test.go +0 -269
@@ -1,269 +0,0 @@
1-package cmd
2-
3-import (
4- "bytes"
5- "os"
6- "path/filepath"
7- "testing"
8-
9- "github.com/spf13/cobra"
10- "github.com/spf13/viper"
11- "github.com/stretchr/testify/assert"
12- "github.com/stretchr/testify/require"
13-)
14-
15-func TestRootCommand(t *testing.T) {
16- tests := []struct {
17- name string
18- args []string
19- }{
20- {
21- name: "no arguments shows help",
22- args: []string{},
23- },
24- {
25- name: "help flag",
26- args: []string{"--help"},
27- },
28- }
29-
30- for _, tt := range tests {
31- t.Run(tt.name, func(t *testing.T) {
32- // Create new command to avoid state pollution
33- cmd := &cobra.Command{
34- Use: "gordon",
35- Short: "Gordon - Container Registry & Deployment System",
36- Long: `Gordon is a single-binary application that combines a container registry
37-with an intelligent reverse proxy and automated deployment system.`,
38- }
39-
40- cmd.SetArgs(tt.args)
41-
42- var output bytes.Buffer
43- cmd.SetOut(&output)
44- cmd.SetErr(&output)
45-
46- // Should not panic
47- assert.NotPanics(t, func() {
48- cmd.Execute()
49- })
50- })
51- }
52-}
53-
54-func TestExecute(t *testing.T) {
55- // Create a temporary config file to avoid config errors
56- tempDir := t.TempDir()
57- configFile := filepath.Join(tempDir, "gordon.toml")
58-
59- configContent := `
60-[server]
61-port = 8080
62-registry_port = 5000
63-runtime = "docker"
64-
65-[logging]
66-enabled = false
67-
68-[routes]
69-`
70- err := os.WriteFile(configFile, []byte(configContent), 0644)
71- require.NoError(t, err)
72-
73- // Save original values
74- originalArgs := os.Args
75- originalCfgFile := cfgFile
76-
77- // Test with help flag to avoid starting actual server
78- os.Args = []string{"gordon", "--help"}
79- cfgFile = configFile
80-
81- // Restore after test
82- defer func() {
83- os.Args = originalArgs
84- cfgFile = originalCfgFile
85- viper.Reset()
86- }()
87-
88- // Should not panic
89- assert.NotPanics(t, func() {
90- Execute()
91- })
92-}
93-
94-func TestInitConfig(t *testing.T) {
95- tests := []struct {
96- name string
97- setupFunc func(t *testing.T) string // returns temp dir
98- configFile string
99- configContent string
100- expectedError bool
101- shouldFindFile bool
102- }{
103- {
104- name: "explicit config file",
105- setupFunc: func(t *testing.T) string {
106- return t.TempDir()
107- },
108- configFile: "test-gordon.toml",
109- configContent: `
110-[server]
111-port = 8080
112-registry_port = 5000
113-runtime = "docker"
114-
115-[logging]
116-enabled = false
117-
118-[routes]
119-`,
120- shouldFindFile: true,
121- },
122- {
123- name: "config file in current directory",
124- setupFunc: func(t *testing.T) string {
125- tempDir := t.TempDir()
126- // Change to temp directory
127- originalDir, _ := os.Getwd()
128- os.Chdir(tempDir)
129- t.Cleanup(func() {
130- os.Chdir(originalDir)
131- })
132- return tempDir
133- },
134- configFile: "gordon.toml",
135- configContent: `
136-[server]
137-port = 8080
138-registry_port = 5000
139-runtime = "docker"
140-
141-[logging]
142-enabled = false
143-
144-[routes]
145-`,
146- shouldFindFile: true,
147- },
148- }
149-
150- for _, tt := range tests {
151- t.Run(tt.name, func(t *testing.T) {
152- // Reset viper for each test
153- viper.Reset()
154-
155- tempDir := tt.setupFunc(t)
156-
157- // Save original values
158- originalCfgFile := cfgFile
159-
160- if tt.configContent != "" {
161- configPath := tt.configFile
162- if !filepath.IsAbs(configPath) {
163- configPath = filepath.Join(tempDir, tt.configFile)
164- }
165-
166- err := os.WriteFile(configPath, []byte(tt.configContent), 0644)
167- require.NoError(t, err)
168-
169- if tt.name == "explicit config file" {
170- cfgFile = configPath
171- }
172- }
173-
174- // Restore after test
175- t.Cleanup(func() {
176- cfgFile = originalCfgFile
177- viper.Reset()
178- })
179-
180- assert.NotPanics(t, func() {
181- initConfig()
182- })
183-
184- if tt.shouldFindFile {
185- assert.NotEmpty(t, viper.ConfigFileUsed())
186- }
187- })
188- }
189-}
190-
191-func TestRootCmdStructure(t *testing.T) {
192- // Test root command basic properties
193- assert.Equal(t, "gordon", rootCmd.Use)
194- assert.Contains(t, rootCmd.Short, "Gordon")
195- assert.Contains(t, rootCmd.Long, "container registry")
196-
197- // Test that persistent flags are set
198- flag := rootCmd.PersistentFlags().Lookup("config")
199- assert.NotNil(t, flag)
200- assert.Equal(t, "config", flag.Name)
201-}
202-
203-func TestRootCmdSubcommands(t *testing.T) {
204- // Check that expected subcommands are registered
205- commands := rootCmd.Commands()
206-
207- commandNames := make([]string, len(commands))
208- for i, cmd := range commands {
209- commandNames[i] = cmd.Name()
210- }
211-
212- // Should have start and reload commands
213- assert.Contains(t, commandNames, "start")
214- assert.Contains(t, commandNames, "reload")
215-}
216-
217-func TestConfigFileFlag(t *testing.T) {
218- tempDir := t.TempDir()
219- configFile := filepath.Join(tempDir, "test-config.toml")
220-
221- configContent := `
222-[server]
223-port = 9999
224-registry_port = 6000
225-runtime = "docker"
226-
227-[logging]
228-enabled = false
229-
230-[routes]
231-`
232- err := os.WriteFile(configFile, []byte(configContent), 0644)
233- require.NoError(t, err)
234-
235- // Reset viper and cfgFile
236- viper.Reset()
237- originalCfgFile := cfgFile
238-
239- defer func() {
240- cfgFile = originalCfgFile
241- viper.Reset()
242- }()
243-
244- // Test setting config file via flag
245- cfgFile = configFile
246-
247- assert.NotPanics(t, func() {
248- initConfig()
249- })
250-
251- // Should use the specified config file
252- assert.Equal(t, configFile, viper.ConfigFileUsed())
253-}
254-
255-func TestRootCmdHelp(t *testing.T) {
256- var output bytes.Buffer
257- rootCmd.SetOut(&output)
258- rootCmd.SetArgs([]string{"--help"})
259-
260- err := rootCmd.Execute()
261- assert.NoError(t, err)
262-
263- helpOutput := output.String()
264- assert.Contains(t, helpOutput, "Gordon")
265- assert.Contains(t, helpOutput, "container registry")
266- assert.Contains(t, helpOutput, "Available Commands:")
267- assert.Contains(t, helpOutput, "start")
268- assert.Contains(t, helpOutput, "reload")
269-}⏎̸
D cmd/start.go +0 -327
@@ -1,327 +0,0 @@
1-package cmd
2-
3-import (
4- "context"
5- "fmt"
6- "os"
7- "os/signal"
8- "path/filepath"
9- "sync"
10- "syscall"
11- "time"
12-
13- "gordon/internal/config"
14- "gordon/internal/container"
15- "gordon/internal/events"
16- "gordon/internal/logging"
17- "gordon/internal/proxy"
18- "gordon/internal/registry"
19-
20- "github.com/fsnotify/fsnotify"
21- "github.com/rs/zerolog"
22- "github.com/rs/zerolog/log"
23- "github.com/spf13/cobra"
24- "github.com/spf13/viper"
25-)
26-
27-var startCmd = &cobra.Command{
28- Use: "start",
29- Short: "Start the Gordon server",
30- Long: `Start the container registry and reverse proxy server`,
31- Run: runStart,
32-}
33-
34-func init() {
35- rootCmd.AddCommand(startCmd)
36-}
37-
38-func runStart(cmd *cobra.Command, args []string) {
39- // Load configuration first
40- cfg, err := config.Load()
41- if err != nil {
42- // Use basic console logging for config errors
43- zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
44- log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
45- log.Fatal().Err(err).Msg("Failed to load config")
46- }
47-
48- // Initialize logging system
49- if err := logging.Setup(cfg); err != nil {
50- log.Fatal().Err(err).Msg("Failed to initialize logging")
51- }
52- defer logging.Close()
53-
54- log.Info().Msg("Starting Gordon server...")
55- log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")
56- log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")
57- log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime")
58-
59- // Create PID file for reload command
60- pidFile := createPidFile()
61- if pidFile != "" {
62- defer removePidFile(pidFile)
63- }
64-
65- // Initialize event bus
66- eventBus := events.NewInMemoryEventBus(100)
67- if err := eventBus.Start(); err != nil {
68- log.Fatal().Err(err).Msg("Failed to start event bus")
69- }
70-
71- // Initialize container manager
72- manager, err := container.NewManager(cfg)
73- if err != nil {
74- log.Fatal().Err(err).Msg("Failed to initialize container manager")
75- }
76-
77- // Create and subscribe container event handler
78- containerHandler := events.NewContainerEventHandler(manager, cfg)
79- if err := eventBus.Subscribe(containerHandler); err != nil {
80- log.Fatal().Err(err).Msg("Failed to subscribe container event handler")
81- }
82-
83- // Create and subscribe auto-route event handler
84- var autoRouteHandler *events.AutoRouteHandler
85- autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
86- if err := eventBus.Subscribe(autoRouteHandler); err != nil {
87- log.Fatal().Err(err).Msg("Failed to subscribe auto-route event handler")
88- }
89-
90- // Create and subscribe version event handler
91- var versionHandler *events.VersionHandler
92- versionHandler = events.NewVersionHandler(manager, cfg)
93- if err := eventBus.Subscribe(versionHandler); err != nil {
94- log.Fatal().Err(err).Msg("Failed to subscribe version event handler")
95- }
96-
97- // Start proxy server (needs to be accessible for config reload)
98- proxyServer := proxy.NewServer(cfg, manager)
99-
100- // Create and subscribe proxy event handler
101- var proxyHandler *proxy.ProxyEventHandler
102- proxyHandler = proxy.NewProxyEventHandler(proxyServer)
103- if err := eventBus.Subscribe(proxyHandler); err != nil {
104- log.Fatal().Err(err).Msg("Failed to subscribe proxy event handler")
105- }
106-
107- // Create context for the entire server lifecycle
108- ctx, cancel := context.WithCancel(context.Background())
109-
110- // Setup config file watching for live reload with debouncing
111- var reloadTimer *time.Timer
112- var reloadMutex sync.Mutex
113-
114- viper.WatchConfig()
115- viper.OnConfigChange(func(e fsnotify.Event) {
116- log.Info().
117- Str("file", e.Name).
118- Str("op", e.Op.String()).
119- Msg("Configuration file changed, reloading...")
120-
121- // Debounce config reloads to prevent multiple events from a single logical change
122- reloadMutex.Lock()
123- defer reloadMutex.Unlock()
124-
125- if reloadTimer != nil {
126- reloadTimer.Stop()
127- }
128-
129- reloadTimer = time.AfterFunc(500*time.Millisecond, func() {
130- // Reload configuration
131- newCfg, err := config.Load()
132- if err != nil {
133- log.Error().Err(err).Msg("Failed to reload configuration")
134- return
135- }
136-
137- // Update the configuration reference
138- cfg = newCfg
139-
140- // Reinitialize logging with new config
141- if err := logging.Setup(cfg); err != nil {
142- log.Error().Err(err).Msg("Failed to reinitialize logging after config reload")
143- }
144-
145- // Update proxy server config
146- proxyServer.UpdateConfig(cfg)
147-
148- // Update container manager config (includes env file creation for new routes)
149- manager.UpdateConfig(cfg)
150-
151- // Unsubscribe old handlers before creating new ones
152- if err := eventBus.Unsubscribe(containerHandler); err != nil {
153- log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler")
154- }
155- if err := eventBus.Unsubscribe(autoRouteHandler); err != nil {
156- log.Warn().Err(err).Msg("Failed to unsubscribe old auto-route event handler")
157- }
158- if err := eventBus.Unsubscribe(versionHandler); err != nil {
159- log.Warn().Err(err).Msg("Failed to unsubscribe old version event handler")
160- }
161- if err := eventBus.Unsubscribe(proxyHandler); err != nil {
162- log.Warn().Err(err).Msg("Failed to unsubscribe old proxy event handler")
163- }
164-
165- // Update container handler config
166- containerHandler = events.NewContainerEventHandler(manager, cfg)
167- if err := eventBus.Subscribe(containerHandler); err != nil {
168- log.Error().Err(err).Msg("Failed to re-subscribe container event handler")
169- return
170- }
171-
172- // Update auto-route handler config
173- autoRouteHandler = events.NewAutoRouteHandler(cfg, manager)
174- if err := eventBus.Subscribe(autoRouteHandler); err != nil {
175- log.Error().Err(err).Msg("Failed to re-subscribe auto-route event handler")
176- return
177- }
178-
179- // Update version handler config
180- versionHandler = events.NewVersionHandler(manager, cfg)
181- if err := eventBus.Subscribe(versionHandler); err != nil {
182- log.Error().Err(err).Msg("Failed to re-subscribe version event handler")
183- return
184- }
185-
186- // Re-subscribe proxy handler (config ref updated automatically)
187- proxyHandler = proxy.NewProxyEventHandler(proxyServer)
188- if err := eventBus.Subscribe(proxyHandler); err != nil {
189- log.Error().Err(err).Msg("Failed to re-subscribe proxy event handler")
190- return
191- }
192-
193- // Publish config reload event to handle route changes
194- if err := eventBus.Publish(events.ConfigReload, nil); err != nil {
195- log.Error().Err(err).Msg("Failed to publish config reload event")
196- } else {
197- log.Info().Msg("Configuration reloaded successfully")
198- }
199- })
200- })
201-
202- // Sync existing containers
203- if err := manager.SyncContainers(ctx); err != nil {
204- log.Warn().Err(err).Msg("Failed to sync existing containers")
205- }
206-
207- // Auto-start containers that match config routes
208- log.Info().Msg("Auto-starting containers for configured routes...")
209- if err := manager.AutoStartContainers(ctx); err != nil {
210- log.Warn().Err(err).Msg("Failed to auto-start containers")
211- }
212-
213- var wg sync.WaitGroup
214-
215- // Start registry server
216- registryServer, err := registry.NewServer(cfg, eventBus)
217- if err != nil {
218- log.Fatal().Err(err).Msg("Failed to initialize registry server")
219- }
220- wg.Add(1)
221- go func() {
222- defer wg.Done()
223- if err := registryServer.Start(ctx); err != nil {
224- log.Error().Err(err).Msg("Registry server error")
225- }
226- }()
227-
228- // Start proxy server (already created above)
229- wg.Add(1)
230- go func() {
231- defer wg.Done()
232- if err := proxyServer.Start(ctx); err != nil {
233- log.Error().Err(err).Msg("Proxy server error")
234- }
235- }()
236-
237- // Wait for interrupt or reload signals
238- sigChan := make(chan os.Signal, 1)
239- reloadChan := make(chan os.Signal, 1)
240- signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
241- signal.Notify(reloadChan, syscall.SIGUSR1)
242-
243- // Handle signals in a loop
244- for {
245- select {
246- case <-sigChan:
247- log.Info().Msg("Received shutdown signal")
248- goto shutdown
249- case <-reloadChan:
250- log.Info().Msg("Received manual reload signal (SIGUSR1)")
251- // Trigger manual reload event
252- if err := eventBus.Publish(events.ManualReload, nil); err != nil {
253- log.Error().Err(err).Msg("Failed to publish manual reload event")
254- } else {
255- log.Info().Msg("Manual reload event published successfully")
256- }
257- }
258- }
259-
260-shutdown:
261-
262- log.Info().Msg("Shutting down servers...")
263- cancel()
264-
265- // Stop all managed containers with a separate context for shutdown
266- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
267- defer shutdownCancel()
268-
269- log.Info().Msg("Shutting down container manager...")
270- if err := manager.Shutdown(shutdownCtx); err != nil {
271- log.Error().Err(err).Msg("Failed to shutdown container manager")
272- }
273-
274- // Stop event bus
275- if err := eventBus.Stop(); err != nil {
276- log.Error().Err(err).Msg("Failed to stop event bus")
277- }
278-
279- // Give servers time to shutdown gracefully
280- done := make(chan struct{})
281- go func() {
282- wg.Wait()
283- close(done)
284- }()
285-
286- select {
287- case <-done:
288- log.Info().Msg("Servers stopped gracefully")
289- case <-time.After(10 * time.Second):
290- log.Warn().Msg("Force shutdown after timeout")
291- }
292-}
293-
294-// createPidFile creates a PID file for the Gordon process
295-func createPidFile() string {
296- pid := os.Getpid()
297-
298- // Try multiple locations for the PID file
299- locations := []string{
300- "/tmp/gordon.pid",
301- filepath.Join(os.TempDir(), "gordon.pid"),
302- }
303-
304- // Also try user's home directory
305- if homeDir, err := os.UserHomeDir(); err == nil {
306- locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
307- }
308-
309- for _, location := range locations {
310- if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0644); err == nil {
311- log.Debug().Str("pid_file", location).Int("pid", pid).Msg("Created PID file")
312- return location
313- }
314- }
315-
316- log.Warn().Int("pid", pid).Msg("Failed to create PID file in any location")
317- return ""
318-}
319-
320-// removePidFile removes the PID file
321-func removePidFile(pidFile string) {
322- if err := os.Remove(pidFile); err != nil {
323- log.Warn().Err(err).Str("pid_file", pidFile).Msg("Failed to remove PID file")
324- } else {
325- log.Debug().Str("pid_file", pidFile).Msg("Removed PID file")
326- }
327-}
D cmd/start_test.go +0 -333
@@ -1,333 +0,0 @@
1-package cmd
2-
3-import (
4- "bytes"
5- "context"
6- "fmt"
7- "os"
8- "path/filepath"
9- "syscall"
10- "testing"
11- "time"
12-
13- "github.com/spf13/cobra"
14- "github.com/spf13/viper"
15- "github.com/stretchr/testify/assert"
16- "github.com/stretchr/testify/require"
17-)
18-
19-func TestStartCommand(t *testing.T) {
20- // Test start command structure
21- assert.Equal(t, "start", startCmd.Use)
22- assert.Contains(t, startCmd.Short, "Start")
23- assert.Contains(t, startCmd.Long, "registry")
24-}
25-
26-func TestRunStart_ConfigError(t *testing.T) {
27- // Save original values
28- originalCfgFile := cfgFile
29- defer func() {
30- cfgFile = originalCfgFile
31- viper.Reset()
32- }()
33-
34- // Set non-existent config file
35- cfgFile = "/nonexistent/config.toml"
36-
37- // Capture stderr to check config error message
38- var stderr bytes.Buffer
39- originalStderr := os.Stderr
40- r, w, _ := os.Pipe()
41- os.Stderr = w
42-
43- // Initialize config to trigger error handling
44- done := make(chan bool)
45- go func() {
46- defer func() {
47- if recover() != nil {
48- // Recovery from log.Fatal is expected in some cases
49- }
50- done <- true
51- }()
52-
53- // Call initConfig directly to test config error handling
54- initConfig()
55- }()
56-
57- // Give it a moment to process
58- select {
59- case <-done:
60- // Expected
61- case <-time.After(1 * time.Second):
62- t.Fatal("initConfig should have completed quickly")
63- }
64-
65- w.Close()
66- os.Stderr = originalStderr
67- stderr.ReadFrom(r)
68-
69- // Check that an error message was printed
70- stderrContent := stderr.String()
71- assert.Contains(t, stderrContent, "Error reading config file")
72-}
73-
74-func TestCreatePidFile(t *testing.T) {
75- // Test PID file creation
76- pidFile := createPidFile()
77-
78- if pidFile != "" {
79- // Verify file exists and contains valid PID
80- assert.FileExists(t, pidFile)
81-
82- content, err := os.ReadFile(pidFile)
83- require.NoError(t, err)
84-
85- var pid int
86- _, err = fmt.Sscanf(string(content), "%d", &pid)
87- assert.NoError(t, err)
88- assert.Equal(t, os.Getpid(), pid)
89-
90- // Clean up
91- removePidFile(pidFile)
92- assert.NoFileExists(t, pidFile)
93- }
94-
95- // Test should pass even if no writable location is found
96- // (function returns empty string in that case)
97-}
98-
99-func TestRemovePidFile(t *testing.T) {
100- tests := []struct {
101- name string
102- setupFunc func(t *testing.T) string
103- expectError bool
104- }{
105- {
106- name: "remove existing file",
107- setupFunc: func(t *testing.T) string {
108- tempDir := t.TempDir()
109- pidFile := filepath.Join(tempDir, "test.pid")
110- err := os.WriteFile(pidFile, []byte("12345"), 0644)
111- require.NoError(t, err)
112- return pidFile
113- },
114- expectError: false,
115- },
116- {
117- name: "remove non-existent file",
118- setupFunc: func(t *testing.T) string {
119- tempDir := t.TempDir()
120- return filepath.Join(tempDir, "nonexistent.pid")
121- },
122- expectError: true, // Warning logged, but no panic
123- },
124- }
125-
126- for _, tt := range tests {
127- t.Run(tt.name, func(t *testing.T) {
128- pidFile := tt.setupFunc(t)
129-
130- // Should not panic regardless of whether file exists
131- assert.NotPanics(t, func() {
132- removePidFile(pidFile)
133- })
134-
135- // File should not exist after removal attempt
136- assert.NoFileExists(t, pidFile)
137- })
138- }
139-}
140-
141-func TestCreatePidFile_Locations(t *testing.T) {
142- // Test that createPidFile tries multiple locations
143- // We can't easily mock os.WriteFile to test all failure scenarios,
144- // but we can verify the function doesn't panic
145- assert.NotPanics(t, func() {
146- pidFile := createPidFile()
147- if pidFile != "" {
148- removePidFile(pidFile)
149- }
150- })
151-}
152-
153-func TestCreatePidFile_Content(t *testing.T) {
154- pidFile := createPidFile()
155- if pidFile == "" {
156- t.Skip("No writable location found for PID file")
157- }
158-
159- defer removePidFile(pidFile)
160-
161- // Verify PID file contains current process ID
162- content, err := os.ReadFile(pidFile)
163- require.NoError(t, err)
164-
165- var storedPid int
166- _, err = fmt.Sscanf(string(content), "%d", &storedPid)
167- require.NoError(t, err)
168-
169- assert.Equal(t, os.Getpid(), storedPid)
170-}
171-
172-func TestPidFilePermissions(t *testing.T) {
173- pidFile := createPidFile()
174- if pidFile == "" {
175- t.Skip("No writable location found for PID file")
176- }
177-
178- defer removePidFile(pidFile)
179-
180- // Check file permissions
181- info, err := os.Stat(pidFile)
182- require.NoError(t, err)
183-
184- // Should be readable by owner and group
185- assert.True(t, info.Mode().Perm()&0644 != 0)
186-}
187-
188-func TestPidFileLocations(t *testing.T) {
189- // Test different potential PID file locations
190- // This mainly tests that the function handles various scenarios
191-
192- originalHome := os.Getenv("HOME")
193- defer os.Setenv("HOME", originalHome)
194-
195- // Test with invalid home directory
196- os.Setenv("HOME", "/nonexistent/invalid/path")
197-
198- assert.NotPanics(t, func() {
199- pidFile := createPidFile()
200- if pidFile != "" {
201- removePidFile(pidFile)
202- }
203- })
204-}
205-
206-func TestStartCmdStructure(t *testing.T) {
207- // Verify command is properly registered
208- found := false
209- for _, cmd := range rootCmd.Commands() {
210- if cmd.Name() == "start" {
211- found = true
212- break
213- }
214- }
215- assert.True(t, found, "start command should be registered with root command")
216-}
217-
218-func TestStartCmdHelp(t *testing.T) {
219- var output bytes.Buffer
220-
221- // Create a copy of the command to avoid affecting the global command
222- cmd := &cobra.Command{
223- Use: startCmd.Use,
224- Short: startCmd.Short,
225- Long: startCmd.Long,
226- Run: startCmd.Run,
227- }
228-
229- cmd.SetOut(&output)
230- cmd.SetErr(&output)
231- cmd.SetArgs([]string{"--help"})
232-
233- err := cmd.Execute()
234- assert.NoError(t, err)
235-
236- helpOutput := output.String()
237- assert.Contains(t, helpOutput, "Start")
238- assert.Contains(t, helpOutput, "container registry")
239- assert.Contains(t, helpOutput, "reverse proxy")
240- assert.Contains(t, helpOutput, "server")
241-}
242-
243-// Test helper functions for signal handling scenarios
244-func TestStartCommand_SignalHandling(t *testing.T) {
245- // This is a simplified test since we can't easily test the full signal handling
246- // without actually starting the server. We mainly test that the command structure is correct.
247-
248- // Verify the command can be created and has the right properties
249- assert.NotNil(t, startCmd.Run)
250- assert.Equal(t, "start", startCmd.Use)
251-}
252-
253-func TestRunStart_ValidConfig(t *testing.T) {
254- // Create a valid config file
255- tempDir := t.TempDir()
256- configFile := filepath.Join(tempDir, "gordon.toml")
257-
258- configContent := `
259-[server]
260-port = 8080
261-registry_port = 5000
262-runtime = "docker"
263-
264-[logging]
265-enabled = false
266-dir = "` + filepath.Join(tempDir, "logs") + `"
267-
268-[registry]
269-enabled = false
270-
271-[routes]
272-`
273-
274- err := os.WriteFile(configFile, []byte(configContent), 0644)
275- require.NoError(t, err)
276-
277- // Save original values
278- originalCfgFile := cfgFile
279- defer func() {
280- cfgFile = originalCfgFile
281- viper.Reset()
282- }()
283-
284- // Set config file
285- cfgFile = configFile
286- viper.Reset()
287-
288- // We can't actually run the full start command without Docker,
289- // but we can test that it doesn't immediately fail with config errors
290- // by mocking the execution
291-
292- // This is a limited test since runStart calls log.Fatal on various errors
293- // and we can't easily test the full server startup without actual dependencies
294- assert.NotNil(t, startCmd.Run, "Start command should have a run function")
295-}
296-
297-func TestSignalHandling_Concepts(t *testing.T) {
298- // Test the concepts used in signal handling
299-
300- // Test that we can create signal channels (used in runStart)
301- sigChan := make(chan os.Signal, 1)
302- reloadChan := make(chan os.Signal, 1)
303-
304- assert.NotNil(t, sigChan)
305- assert.NotNil(t, reloadChan)
306-
307- // Test signal types that would be used
308- signals := []os.Signal{
309- syscall.SIGINT,
310- syscall.SIGTERM,
311- syscall.SIGUSR1,
312- }
313-
314- for _, sig := range signals {
315- assert.NotNil(t, sig)
316- }
317-}
318-
319-func TestContextCancellation(t *testing.T) {
320- // Test context cancellation patterns used in runStart
321- ctx, cancel := context.WithCancel(context.Background())
322- assert.NotNil(t, ctx)
323- assert.NotNil(t, cancel)
324-
325- // Test timeout context
326- timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Second)
327- assert.NotNil(t, timeoutCtx)
328- assert.NotNil(t, timeoutCancel)
329-
330- // Clean up
331- cancel()
332- timeoutCancel()
333-}⏎̸
D cmd/version.go +0 -33
@@ -1,33 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5-
6- "github.com/spf13/cobra"
7-)
8-
9-var versionCmd = &cobra.Command{
10- Use: "version",
11- Short: "Show version information",
12- Long: `Display the Gordon version, commit hash, and build date.`,
13- Run: func(cmd *cobra.Command, args []string) {
14- fmt.Printf("Gordon %s\n", BuildVersion)
15- fmt.Printf("Commit: %s\n", BuildCommit)
16- fmt.Printf("Built: %s\n", BuildDate)
17- },
18-}
19-
20-func init() {
21- rootCmd.AddCommand(versionCmd)
22- versionCmd.Flags().BoolP("short", "s", false, "Show only version number")
23-
24- // Override run function to handle short flag
25- originalRun := versionCmd.Run
26- versionCmd.Run = func(cmd *cobra.Command, args []string) {
27- if short, _ := cmd.Flags().GetBool("short"); short {
28- fmt.Println(BuildVersion)
29- return
30- }
31- originalRun(cmd, args)
32- }
33-}⏎̸
M examples/README.md +19 -386
@@ -1,396 +1,29 @@
1-# Gordon Configuration Examples
1+# Examples
22
3-This directory contains configuration examples for different use cases and environments.
3+Config is auto-generated on first `gordon start`. Use these examples as reference or replace the generated config:
44
5-## Full Configuration Reference
6-
7-Here's the complete Gordon configuration with all default values:
8-
9-```toml
10-# gordon.toml - Complete configuration reference with defaults
11-
12-[server]
13-port = 8080 # HTTP/HTTPS proxy port (default: 8080)
14-registry_port = 5000 # Container registry port (default: 5000)
15-registry_domain = "" # REQUIRED - Your registry domain (no default)
16-runtime = "auto" # Container runtime (default: "auto")
17- # Options: auto, docker, podman, podman-rootless
18-socket_path = "" # Custom socket path (default: "" - auto-detected)
19-data_dir = "~/.local/share/gordon" # Data directory (default: ~/.local/share/gordon)
20- # Root user default: ./data
21-ssl_email = "" # Email for Let's Encrypt (default: "" - uses Cloudflare)
22-
23-[registry_auth]
24-enabled = true # Enable registry authentication (default: true)
25-username = "" # REQUIRED when enabled (no default)
26-password = "" # REQUIRED when enabled (no default)
27-
28-[routes]
29-# Domain to image mappings (default: empty)
30-# "app.example.com" = "myapp:latest"
31-# "api.example.com" = "myapi:v1.0.0"
32-
33-[network_groups]
34-# Group domains for shared networking (default: empty)
35-# "backend" = ["app.example.com", "api.example.com"]
36-# "monitoring" = ["grafana.example.com", "prometheus.example.com"]
37-
38-[attachments]
39-# Attach services to apps or groups (default: empty)
40-# "app.example.com" = ["postgres:latest", "redis:latest"]
41-# "backend" = ["shared-cache:latest", "message-queue:latest"]
42-
43-[auto_route]
44-enabled = false # Auto-create routes from image names (default: false)
45-
46-[volumes]
47-auto_create = true # Auto-create volumes from VOLUME directives (default: true)
48-prefix = "gordon" # Volume name prefix (default: "gordon")
49-preserve = true # Keep volumes when removing containers (default: true)
50-
51-[env]
52-dir = "{data_dir}/env" # Environment files directory (default: {data_dir}/env)
53-providers = ["pass", "sops"] # Secret providers (default: ["pass", "sops"])
54-
55-[logging]
56-enabled = true # Enable file logging (default: true)
57-level = "info" # Log level (default: "info")
58- # Options: trace, debug, info, warn, error, fatal, panic
59-dir = "{data_dir}/logs" # Log directory (default: {data_dir}/logs)
60-main_log_file = "gordon.log" # Main log file (default: "gordon.log")
61-proxy_log_file = "proxy.log" # Proxy log file (default: "proxy.log")
62-container_log_dir = "containers" # Container logs subdirectory (default: "containers")
63-max_size = 100 # Max log size in MB (default: 100)
64-max_backups = 3 # Number of old logs to keep (default: 3)
65-max_age = 28 # Days to keep old logs (default: 28)
66-compress = true # Compress rotated logs (default: true)
67-```
68-
69-## 📁 Available Examples
70-
71-### 🚀 [`minimal.toml`](minimal.toml)
72-**Perfect for getting started**
73-- Single route configuration
74-- Default settings
75-- No authentication
76-- Ideal for testing and learning
77-
78-### 🏠 [`development.toml`](development.toml)
79-**Local development setup**
80-- Multiple .local domains
81-- No authentication for ease of use
82-- Third-party development tools
83-- localhost registry
84-
85-### 🧪 [`staging.toml`](staging.toml)
86-**Staging and preview environments**
87-- Branch-based deployments
88-- Feature branch testing
89-- PR preview environments
90-- Separate staging registry
91-
92-### 🏭 [`production.toml`](production.toml)
93-**Production-ready configuration**
94-- Pinned image versions
95-- Registry authentication
96-- Multiple production services
97-- Monitoring tools included
98-
99-### 🏢 [`saas-multi-tenant.toml`](saas-multi-tenant.toml)
100-**Multi-tenant SaaS platform**
101-- Customer subdomains
102-- Custom domains
103-- Shared application architecture
104-- Enterprise features
105-
106-### 📊 [`logging.toml`](logging.toml)
107-**Comprehensive logging configuration**
108-- Complete logging setup examples
109-- Different logging levels and configurations
110-- Production and development logging strategies
111-- Log rotation and monitoring examples
112-
113-### 🔄 [`rollback-workflow-example.md`](rollback-workflow-example.md)
114-**Simple version deployment and rollback workflow**
115-- Manifest annotation-based deployments
116-- Simple version changes for deployments and rollbacks
117-- Version tracking and deployment strategies
118-
119-## 🚀 Quick Start
120-
121-1. **Choose an example** that matches your use case
122-2. **Copy the config file** to your Gordon directory:
123- ```bash
124- cp examples/minimal.toml gordon.toml
125- ```
126-3. **Edit the configuration** with your domains and settings
127-4. **Start Gordon**:
128- ```bash
129- gordon start
130- ```
131-
132-## 🔧 Customization Tips
133-
134-### Domain Configuration
135-Update the `[routes]` section with your actual domains:
136-```toml
137-[routes]
138-"your-domain.com" = "your-app:latest"
139-"api.your-domain.com" = "your-api:v1.0.0"
140-```
141-
142-### Registry Setup
143-Configure your registry domain and authentication:
144-```toml
145-[server]
146-registry_domain = "registry.your-domain.com"
147-
148-[registry_auth]
149-enabled = true
150-username = "your-username"
151-password = "your-secure-password"
152-```
153-
154-### Container Runtime Setup
155-Gordon supports Docker and Podman with automatic detection:
156-```toml
157-[server]
158-runtime = "auto" # auto, docker, podman, podman-rootless
159-socket_path = "" # optional custom socket path
160-
161-# Examples:
162-# runtime = "docker" # Force Docker
163-# runtime = "podman" # Force Podman root
164-# runtime = "podman-rootless" # Force Podman rootless
165-# socket_path = "unix:///run/user/1000/podman/podman.sock"
166-```
167-
168-Override with environment variables:
1695 ```bash
170-# Works for both Docker and Podman
171-export CONTAINER_HOST=unix:///custom/path/container.sock
172-export CONTAINER_HOST=tcp://remote-docker:2376
173-gordon start
174-```
175-
176-### Logging Configuration
177-Enable comprehensive logging to monitor your deployments:
178-```toml
179-[logging]
180-enabled = true # Enable file-based logging
181-level = "info" # Log level: trace, debug, info, warn, error
182-dir = "./logs" # Directory for log files
183-main_log_file = "gordon.log" # Main application logs
184-proxy_log_file = "proxy.log" # HTTP proxy traffic logs
185-container_log_dir = "containers" # Container logs subdirectory
186-
187-# Log rotation settings
188-max_size = 100 # Max log file size in MB
189-max_backups = 5 # Number of old log files to keep
190-max_age = 30 # Max age in days
191-compress = true # Compress old log files
6+cp examples/production.toml ~/.config/gordon/gordon.toml
1927 ```
1938
194-See [`logging.toml`](logging.toml) for comprehensive logging examples.
9+## Configurations
19510
196-### Environment Variables
197-Gordon intelligently merges environment variables from multiple sources:
198-
199-1. **Dockerfile ENV directives** (automatically detected)
200-2. **Your .env files** (override Dockerfile ENV)
201-3. **Runtime environment** (lowest priority)
202-
203-#### Dockerfile ENV (Automatic Detection)
204-```dockerfile
205-FROM node:18-alpine
206-WORKDIR /app
207-
208-# Gordon automatically reads these ENV directives
209-ENV NODE_ENV=production
210-ENV PORT=3000
211-ENV LOG_LEVEL=info
212-ENV DATABASE_HOST=localhost
213-
214-COPY package*.json ./
215-RUN npm install
216-COPY . .
217-EXPOSE 3000
218-CMD ["npm", "start"]
219-```
220-
221-#### Custom Environment Files
222-Create `.env` files for each route: `./data/env/app_yourdomain_com.env`
223-```bash
224-# Your custom overrides (these take precedence over Dockerfile ENV)
225-DATABASE_HOST=db.mydomain.com
226-DATABASE_USER=myapp
227-DATABASE_PASSWORD=${pass:myapp/db-password}
228-API_KEY=${sops:secrets.yaml:api_key}
229-# NODE_ENV, PORT, LOG_LEVEL from Dockerfile are preserved
230-```
231-
232-#### Final Applied Environment
233-```bash
234-NODE_ENV=production # From Dockerfile ENV
235-PORT=3000 # From Dockerfile ENV
236-LOG_LEVEL=info # From Dockerfile ENV
237-DATABASE_HOST=db.mydomain.com # From .env (overridden)
238-DATABASE_USER=myapp # From .env (new)
239-DATABASE_PASSWORD=secret123 # From .env via pass
240-API_KEY=abc123 # From .env via SOPS
241-```
242-
243-#### Production Environment Variables
244-For sensitive production data:
245-```bash
246-export GORDON_REGISTRY_PASSWORD="your-secure-password"
247-export GORDON_SSL_EMAIL="admin@your-domain.com"
248-```
249-
250-### Volume Management
251-Gordon automatically creates persistent volumes from Dockerfile VOLUME directives:
252-
253-#### Dockerfile VOLUME (Automatic Detection)
254-```dockerfile
255-FROM postgres:15
256-# Gordon automatically creates persistent volumes for these paths
257-VOLUME ["/var/lib/postgresql/data"]
258-
259-# Multi-volume example
260-FROM myapp:latest
261-VOLUME ["/app/data", "/app/logs", "/app/uploads"]
262-```
263-
264-#### Volume Features
265-- **Zero Configuration**: Reads VOLUME directives automatically
266-- **Persistent Storage**: Data survives container restarts and updates
267-- **Predictable Naming**: `gordon-{domain}-{path-hash}` format
268-- **Cross-Platform**: Works with Docker and Podman
269-
270-#### Volume Configuration (Optional)
271-```toml
272-[volumes]
273-auto_create = true # Default: true (automatically handle VOLUME directives)
274-prefix = "gordon" # Default: "gordon" (volume name prefix)
275-preserve = true # Default: true (keep volumes when containers are removed)
276-```
277-
278-#### Complete Dockerfile Example
279-```dockerfile
280-FROM node:18-alpine
281-WORKDIR /app
282-
283-# Environment variables (automatically merged with your .env files)
284-ENV NODE_ENV=production
285-ENV PORT=3000
286-ENV LOG_LEVEL=info
287-
288-# Install dependencies
289-COPY package*.json ./
290-RUN npm install
291-
292-# Copy application code
293-COPY . .
294-
295-# Persistent storage (automatically managed by Gordon)
296-VOLUME ["/app/data", "/app/logs"]
297-
298-# Network configuration
299-EXPOSE 3000
300-
301-# Start command
302-CMD ["npm", "start"]
303-```
304-
305-When you deploy this image:
306-- Gordon reads ENV directives and merges with your .env files
307-- Gordon creates volumes for `/app/data` and `/app/logs`
308-- Your data persists across deployments and reboots
309-- Environment variables are intelligently managed
310-
311-## 🌐 DNS Configuration
312-
313-### Cloudflare Setup (Recommended)
314-For all examples except development, set up these DNS records:
315-
316-```
317-Type Name Content
318-A app.your-domain.com YOUR_SERVER_IP
319-A api.your-domain.com YOUR_SERVER_IP
320-A registry.your-domain.com YOUR_SERVER_IP
321-```
322-
323-Or use a wildcard:
324-```
325-Type Name Content
326-A *.your-domain.com YOUR_SERVER_IP
327-```
328-
329-### Local Development
330-For the development example, add to `/etc/hosts`:
331-```
332-127.0.0.1 app.local
333-127.0.0.1 api.local
334-127.0.0.1 admin.local
335-```
336-
337-## 🔄 Workflow Examples
338-
339-### Simple Deployment
340-```bash
341-# Build your app
342-export VERSION=latest
343-docker build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
344-
345-# Push to deploy
346-docker push registry.your-domain.com/myapp:$VERSION
347-```
348-
349-### Version Management
350-```bash
351-# Build and push specific version
352-export VERSION=v1.2.0
353-docker build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
354-docker push registry.your-domain.com/myapp:$VERSION
355-
356-# Update config
357-# "app.your-domain.com" = "myapp:v1.2.0"
358-```
359-
360-### Simple Version Deployment with Manifest Annotations
361-```bash
362-# Build and push your versions first
363-export VERSION=v1.1.0
364-podman build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
365-podman push registry.your-domain.com/myapp:$VERSION
366-
367-export VERSION=v1.2.0
368-podman build --tag myapp:$VERSION --tag registry.your-domain.com/myapp:$VERSION .
369-podman push registry.your-domain.com/myapp:$VERSION
370-
371-# Deploy v1.2.0
372-export VERSION=v1.2.0
373-podman manifest create myapp:latest
374-podman manifest add myapp:latest registry.your-domain.com/myapp:$VERSION
375-podman manifest annotate myapp:latest --annotation version=$VERSION registry.your-domain.com/myapp:$VERSION
376-podman manifest push myapp:latest registry.your-domain.com/myapp:latest
377-
378-# Rollback to v1.1.0 (just change the version)
379-export VERSION=v1.1.0
380-podman manifest create myapp:latest --amend
381-podman manifest add myapp:latest registry.your-domain.com/myapp:$VERSION
382-podman manifest annotate myapp:latest --annotation version=$VERSION registry.your-domain.com/myapp:$VERSION
383-podman manifest push myapp:latest registry.your-domain.com/myapp:latest
384-```
11+| File | Use case |
12+|------|----------|
13+| [`minimal.toml`](minimal.toml) | Single app, no auth. Start here. |
14+| [`development.toml`](development.toml) | Local .local domains, unsafe secrets |
15+| [`staging.toml`](staging.toml) | Branch previews, token auth |
16+| [`production.toml`](production.toml) | Pinned versions, full logging |
17+| [`saas-multi-tenant.toml`](saas-multi-tenant.toml) | Customer subdomains, shared services |
18+| [`logging.toml`](logging.toml) | All logging options explained |
38519
386-See [`rollback-workflow-example.md`](rollback-workflow-example.md) for complete version deployment workflow documentation.
20+## Workflows
38721
388-## 🤝 Need Help?
22+| File | Purpose |
23+|------|---------|
24+| [`github-workflow.yml`](github-workflow.yml) | Deploy on git tag push |
25+| [`rollback-workflow-example.md`](rollback-workflow-example.md) | Version management with manifests |
38926
390-- 📖 **Read the main [README](../README.md)** for detailed documentation
391-- 🔍 **Check the logs** for deployment issues
392-- 🐛 **Open an issue** if you find problems with these examples
393-- 💡 **Contribute** your own configuration examples!
27+## Config Reference
39428
395----
396-*These examples are starting points - customize them for your specific needs!*⏎̸
29+See comments in each `.toml` file. Full docs: [README](../README.md#configuration)
M examples/development.toml +11 -2
@@ -9,9 +9,19 @@ runtime = "auto" # auto-detects Docker/Podman
99 ssl_email = "dev@localhost"
1010 data_dir = "./dev-data"
1111
12+# Secrets backend for development
13+[secrets]
14+backend = "unsafe" # Plain text storage for local development
15+
1216 [registry_auth]
1317 enabled = false # Disable auth for easier local development
1418
19+# To enable auth for development:
20+# enabled = true
21+# type = "password"
22+# username = "dev"
23+# password_hash = "gordon/registry/password_hash" # Generate: gordon auth password hash
24+
1525 [auto_route]
1626 enabled = true # Enable automatic route creation from image names
1727
@@ -40,8 +50,7 @@ compress = true # Compress old logs
4050 [env]
4151 # Directory where .env files are stored for each route
4252 dir = "./dev-data/env"
43-# Secret providers (optional for development)
44-providers = ["pass"] # For development secrets
53+# Note: Secret providers use [secrets].backend configuration
4554
4655 # Development routes using your domain
4756 [routes]
A examples/github-workflow.yml +129 -0
@@ -0,0 +1,129 @@
1+# Example GitHub Actions workflow for deploying to Gordon
2+# Copy this file to your repository as .github/workflows/deploy.yml
3+#
4+# Prerequisites:
5+# 1. Generate a Gordon token on your server:
6+# gordon auth token generate --subject github-actions --scopes push,pull --expiry 0
7+#
8+# 2. Add these secrets to your GitHub repository:
9+# - GORDON_REGISTRY: Your registry URL (e.g., registry.mydomain.com)
10+# - GORDON_USERNAME: Token subject (e.g., github-actions)
11+# - GORDON_TOKEN: The generated JWT token
12+
13+name: Deploy to Gordon
14+
15+on:
16+ # Deploy on version tags
17+ push:
18+ tags:
19+ - 'v*' # Matches v1.0.0, v2.1.3, etc.
20+ - 'release-*' # Matches release-2024-01-15, etc.
21+
22+ # Optional: Allow manual deployments
23+ workflow_dispatch:
24+ inputs:
25+ tag:
26+ description: 'Tag/version to deploy (leave empty for latest commit)'
27+ required: false
28+ type: string
29+
30+jobs:
31+ deploy:
32+ runs-on: ubuntu-latest
33+
34+ # Optional: Only deploy from main branch for manual triggers
35+ # if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
36+
37+ steps:
38+ - name: Checkout code
39+ uses: actions/checkout@v6
40+ with:
41+ # For manual dispatch with specific tag
42+ ref: ${{ github.event.inputs.tag || github.ref }}
43+
44+ - name: Deploy to Gordon
45+ id: deploy
46+ uses: bnema/gordon/.github/actions/deploy@main
47+ with:
48+ registry: ${{ secrets.GORDON_REGISTRY }}
49+ username: ${{ secrets.GORDON_USERNAME }}
50+ password: ${{ secrets.GORDON_TOKEN }}
51+ tag: ${{ github.event.inputs.tag }}
52+ # Optional customizations:
53+ # image: myapp # Custom image name (defaults to repo name)
54+ # dockerfile: ./Dockerfile # Custom Dockerfile path
55+ # context: . # Build context
56+ # push-latest: 'true' # Also push :latest tag
57+ # platforms: linux/amd64 # Target platform(s)
58+
59+ - name: Deployment summary
60+ run: |
61+ echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
62+ echo "" >> $GITHUB_STEP_SUMMARY
63+ echo "**Image:** \`${{ steps.deploy.outputs.image }}\`" >> $GITHUB_STEP_SUMMARY
64+ echo "**Tag:** \`${{ steps.deploy.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
65+ echo "" >> $GITHUB_STEP_SUMMARY
66+ echo "Gordon will automatically deploy this image to your configured routes." >> $GITHUB_STEP_SUMMARY
67+
68+# ============================================================================
69+# ALTERNATIVE CONFIGURATIONS
70+# ============================================================================
71+
72+# --- Deploy on push to main (continuous deployment) ---
73+#
74+# on:
75+# push:
76+# branches:
77+# - main
78+#
79+# Then in the action, the image will be tagged with the commit SHA.
80+
81+# --- Deploy multiple services (monorepo) ---
82+#
83+# jobs:
84+# deploy-api:
85+# runs-on: ubuntu-latest
86+# steps:
87+# - uses: actions/checkout@v6
88+# - uses: bnema/gordon/.github/actions/deploy@main
89+# with:
90+# registry: ${{ secrets.GORDON_REGISTRY }}
91+# username: ${{ secrets.GORDON_USERNAME }}
92+# password: ${{ secrets.GORDON_TOKEN }}
93+# image: myapp-api
94+# dockerfile: ./services/api/Dockerfile
95+# context: ./services/api
96+#
97+# deploy-web:
98+# runs-on: ubuntu-latest
99+# steps:
100+# - uses: actions/checkout@v6
101+# - uses: bnema/gordon/.github/actions/deploy@main
102+# with:
103+# registry: ${{ secrets.GORDON_REGISTRY }}
104+# username: ${{ secrets.GORDON_USERNAME }}
105+# password: ${{ secrets.GORDON_TOKEN }}
106+# image: myapp-web
107+# dockerfile: ./services/web/Dockerfile
108+# context: ./services/web
109+
110+# --- Deploy with build arguments ---
111+#
112+# - uses: bnema/gordon/.github/actions/deploy@main
113+# with:
114+# registry: ${{ secrets.GORDON_REGISTRY }}
115+# username: ${{ secrets.GORDON_USERNAME }}
116+# password: ${{ secrets.GORDON_TOKEN }}
117+# build-args: |
118+# NODE_ENV=production
119+# API_URL=https://api.example.com
120+# BUILD_DATE=${{ github.event.head_commit.timestamp }}
121+
122+# --- Multi-platform builds ---
123+#
124+# - uses: bnema/gordon/.github/actions/deploy@main
125+# with:
126+# registry: ${{ secrets.GORDON_REGISTRY }}
127+# username: ${{ secrets.GORDON_USERNAME }}
128+# password: ${{ secrets.GORDON_TOKEN }}
129+# platforms: linux/amd64,linux/arm64
M examples/logging.toml +6 -1
@@ -8,10 +8,15 @@ registry_domain = "registry.yourdomain.com"
88 runtime = "auto"
99 data_dir = "./data"
1010
11+# Secrets backend
12+[secrets]
13+backend = "pass" # "pass", "sops", or "unsafe"
14+
1115 [registry_auth]
1216 enabled = true
17+type = "password" # or "token"
1318 username = "admin"
14-password = "secure-password"
19+password_hash = "gordon/registry/password_hash" # bcrypt hash in secrets backend
1520
1621 # ============================================================================
1722 # LOGGING CONFIGURATION EXAMPLES
M examples/minimal.toml +16 -1
@@ -6,8 +6,23 @@ port = 8080
66 registry_port = 5000
77 registry_domain = "registry.local"
88
9+# Secrets backend (optional, defaults to "pass")
10+# [secrets]
11+# backend = "unsafe" # Use "unsafe" for local dev without pass/sops
12+
913 [registry_auth]
10-enabled = false # Disabled for simplicity
14+enabled = false # Disabled for local development
15+
16+# To enable auth, choose a type:
17+# enabled = true
18+# type = "password" # Simple username/password
19+# username = "dev"
20+# password_hash = "gordon/registry/password_hash" # Generate with: gordon auth password hash
21+#
22+# OR for token auth (better for CI):
23+# type = "token"
24+# token_secret = "gordon/registry/token_secret"
25+# Generate tokens with: gordon auth token generate --subject dev --expiry 0
1126
1227 # Optional: Enable basic logging
1328 # [logging]
M examples/production.toml +19 -4
@@ -10,10 +10,26 @@ runtime = "auto" # auto-detects Docker/Podman
1010 ssl_email = "devops@company.com"
1111 data_dir = "/var/lib/gordon"
1212
13+# Secrets backend for sensitive data
14+[secrets]
15+backend = "pass" # "pass" (recommended), "sops", or "unsafe"
16+
1317 [registry_auth]
1418 enabled = true
15-username = "deploy-bot"
16-password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!
19+type = "token" # "token" (recommended for CI/CD) or "password"
20+
21+# Token auth: JWT-based, ideal for CI pipelines
22+token_secret = "gordon/registry/token_secret" # path in secrets backend
23+
24+# Generate tokens with:
25+# gordon auth token generate --subject deploy-bot --scopes push,pull --expiry 0
26+# Each Gordon instance has unique tokens (different token_secret = incompatible tokens)
27+
28+# Alternative: Password auth (simpler but less flexible)
29+# type = "password"
30+# username = "deploy-bot"
31+# password_hash = "gordon/registry/password_hash" # bcrypt hash in secrets backend
32+# Generate hash with: gordon auth password hash
1733
1834 # Production logging configuration
1935 [logging]
@@ -34,8 +50,7 @@ compress = true # Compress old logs to save space
3450 [env]
3551 # Directory where .env files are stored for each route
3652 dir = "/var/lib/gordon/env"
37-# Secret providers for secure credential management
38-providers = ["pass", "sops"]
53+# Note: Secret providers now use [secrets].backend configuration
3954
4055 # Production service routes
4156 [routes]
M examples/saas-multi-tenant.toml +12 -2
@@ -9,10 +9,20 @@ runtime = "auto" # auto-detects Docker/Podman
99 ssl_email = "ops@saas-platform.com"
1010 data_dir = "/var/lib/gordon"
1111
12+# Secrets backend for secure credential storage
13+[secrets]
14+backend = "pass" # Production should use "pass" or "sops"
15+
1216 [registry_auth]
1317 enabled = true
14-username = "deploy"
15-password = "secure-deployment-key"
18+type = "token" # Token auth for automated deployments
19+
20+# JWT signing secret (unique to this Gordon instance)
21+token_secret = "gordon/saas/token_secret"
22+
23+# Generate deployment tokens:
24+# gordon auth token generate --subject deploy-bot --scopes push,pull --expiry 0
25+# Each Gordon instance has unique tokens (prevents cross-instance token reuse)
1626
1727 # Multi-tenant customer routes
1828 [routes]
M examples/staging.toml +17 -2
@@ -9,10 +9,25 @@ runtime = "auto" # auto-detects Docker/Podman
99 ssl_email = "devops@company.com"
1010 data_dir = "./data"
1111
12+# Secrets backend for staging
13+[secrets]
14+backend = "pass" # Use pass for secure secret storage
15+
1216 [registry_auth]
1317 enabled = true
14-username = "staging-user"
15-password = "staging-pass-123"
18+type = "token" # Token auth for CI/CD pipelines
19+
20+# JWT signing secret stored in pass
21+token_secret = "gordon/staging/token_secret"
22+
23+# Generate CI tokens with:
24+# gordon auth token generate --subject ci-staging --scopes push,pull --expiry 0
25+# Tokens are unique to this Gordon instance (different token_secret = incompatible tokens)
26+
27+# Alternative: Password auth
28+# type = "password"
29+# username = "staging-user"
30+# password_hash = "gordon/staging/password_hash" # bcrypt hash in pass
1631
1732 # Staging logging configuration
1833 [logging]
M go.mod +31 -25
@@ -1,18 +1,27 @@
11 module gordon
22
3-go 1.24.3
3+go 1.25.5
44
55 require (
6+ github.com/bnema/zerowrap v1.3.0
67 github.com/docker/docker v28.3.3+incompatible
78 github.com/docker/go-connections v0.5.0
9+ github.com/golang-jwt/jwt/v5 v5.3.0
810 github.com/google/uuid v1.6.0
9- github.com/rs/zerolog v1.34.0
10- github.com/spf13/cobra v1.9.1
11- github.com/spf13/viper v1.20.1
12- github.com/stretchr/testify v1.10.0
11+ github.com/spf13/cobra v1.10.2
12+ github.com/spf13/viper v1.21.0
13+ github.com/stretchr/testify v1.11.1
14+ golang.org/x/crypto v0.47.0
15+ golang.org/x/term v0.39.0
1316 gopkg.in/natefinch/lumberjack.v2 v2.2.1
1417 )
1518
19+require (
20+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
21+ github.com/rs/zerolog v1.34.0 // indirect
22+ go.yaml.in/yaml/v3 v3.0.4 // indirect
23+)
24+
1625 require (
1726 github.com/davecgh/go-spew v1.1.1 // indirect
1827 github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -23,45 +32,42 @@ require (
2332
2433 require (
2534 github.com/Microsoft/go-winio v0.4.14 // indirect
26- github.com/containerd/errdefs v1.0.0 // indirect
35+ github.com/containerd/errdefs v1.0.0
2736 github.com/containerd/errdefs/pkg v0.3.0 // indirect
2837 github.com/containerd/log v0.1.0 // indirect
2938 github.com/distribution/reference v0.6.0 // indirect
3039 github.com/docker/go-units v0.5.0 // indirect
3140 github.com/felixge/httpsnoop v1.0.4 // indirect
32- github.com/fsnotify/fsnotify v1.8.0 // direct
33- github.com/go-logr/logr v1.4.2 // indirect
41+ github.com/fsnotify/fsnotify v1.9.0 // direct
42+ github.com/go-logr/logr v1.4.3 // indirect
3443 github.com/go-logr/stdr v1.2.2 // indirect
35- github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
44+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
3645 github.com/gogo/protobuf v1.3.2 // indirect
3746 github.com/inconshreveable/mousetrap v1.1.0 // indirect
38- github.com/mattn/go-colorable v0.1.13 // indirect
39- github.com/mattn/go-isatty v0.0.19 // indirect
47+ github.com/mattn/go-colorable v0.1.14 // indirect
48+ github.com/mattn/go-isatty v0.0.20 // indirect
4049 github.com/moby/docker-image-spec v1.3.1 // indirect
4150 github.com/moby/sys/atomicwriter v0.1.0 // indirect
4251 github.com/moby/term v0.5.2 // indirect
4352 github.com/morikuni/aec v1.0.0 // indirect
4453 github.com/opencontainers/go-digest v1.0.0 // indirect
4554 github.com/opencontainers/image-spec v1.1.1 // indirect
46- github.com/pelletier/go-toml/v2 v2.2.3 // indirect
55+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
4756 github.com/pkg/errors v0.9.1 // indirect
48- github.com/sagikazarmark/locafero v0.7.0 // indirect
49- github.com/sourcegraph/conc v0.3.0 // indirect
50- github.com/spf13/afero v1.14.0
51- github.com/spf13/cast v1.7.1 // indirect
52- github.com/spf13/pflag v1.0.6 // indirect
57+ github.com/sagikazarmark/locafero v0.12.0 // indirect
58+ github.com/spf13/afero v1.15.0
59+ github.com/spf13/cast v1.10.0 // indirect
60+ github.com/spf13/pflag v1.0.10 // indirect
5361 github.com/subosito/gotenv v1.6.0 // indirect
54- go.opentelemetry.io/auto/sdk v1.1.0 // indirect
62+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
5563 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
56- go.opentelemetry.io/otel v1.36.0 // indirect
64+ go.opentelemetry.io/otel v1.39.0 // indirect
5765 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
58- go.opentelemetry.io/otel/metric v1.36.0 // indirect
66+ go.opentelemetry.io/otel/metric v1.39.0 // indirect
5967 go.opentelemetry.io/otel/sdk v1.36.0 // indirect
60- go.opentelemetry.io/otel/trace v1.36.0 // indirect
61- go.uber.org/atomic v1.9.0 // indirect
62- go.uber.org/multierr v1.9.0 // indirect
63- golang.org/x/sys v0.33.0 // indirect
64- golang.org/x/text v0.26.0 // indirect
68+ go.opentelemetry.io/otel/trace v1.39.0 // indirect
69+ golang.org/x/sys v0.40.0 // indirect
70+ golang.org/x/text v0.33.0 // indirect
6571 gopkg.in/yaml.v3 v3.0.1 // indirect
6672 gotest.tools/v3 v3.5.2 // indirect
6773 )
M go.sum +55 -49 generated file: collapsed
A internal/adapters/in/cli/auth.go +392 -0
@@ -0,0 +1,392 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "strings"
9+ "syscall"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/spf13/cobra"
14+ "github.com/spf13/viper"
15+ "golang.org/x/term"
16+
17+ "gordon/internal/adapters/out/secrets"
18+ "gordon/internal/adapters/out/tokenstore"
19+ "gordon/internal/app"
20+ "gordon/internal/domain"
21+ "gordon/internal/usecase/auth"
22+)
23+
24+// newAuthCmd creates the auth command group.
25+func newAuthCmd() *cobra.Command {
26+ cmd := &cobra.Command{
27+ Use: "auth",
28+ Short: "Manage registry authentication",
29+ Long: `Commands for managing registry authentication tokens and passwords.`,
30+ }
31+
32+ cmd.AddCommand(newAuthTokenCmd())
33+ cmd.AddCommand(newAuthPasswordCmd())
34+
35+ return cmd
36+}
37+
38+// newAuthTokenCmd creates the token subcommand group.
39+func newAuthTokenCmd() *cobra.Command {
40+ cmd := &cobra.Command{
41+ Use: "token",
42+ Short: "Manage authentication tokens",
43+ Long: `Commands for generating, listing, and revoking authentication tokens.`,
44+ }
45+
46+ cmd.AddCommand(newTokenGenerateCmd())
47+ cmd.AddCommand(newTokenListCmd())
48+ cmd.AddCommand(newTokenRevokeCmd())
49+
50+ return cmd
51+}
52+
53+// newTokenGenerateCmd creates the token generate command.
54+func newTokenGenerateCmd() *cobra.Command {
55+ var (
56+ subject string
57+ scopes string
58+ expiry string
59+ configPath string
60+ )
61+
62+ cmd := &cobra.Command{
63+ Use: "generate",
64+ Short: "Generate a new authentication token",
65+ Long: `Generate a new JWT authentication token for registry access.
66+
67+The token will be stored in the configured secrets backend and can be used
68+for docker login as: docker login -u <subject> -p <token>
69+
70+Use --expiry=0 to generate a token that never expires (useful for CI).`,
71+ RunE: func(cmd *cobra.Command, args []string) error {
72+ return runTokenGenerate(subject, scopes, expiry, configPath)
73+ },
74+ }
75+
76+ cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
77+ cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
78+ cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)")
79+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
80+
81+ _ = cmd.MarkFlagRequired("subject")
82+
83+ return cmd
84+}
85+
86+// newTokenListCmd creates the token list command.
87+func newTokenListCmd() *cobra.Command {
88+ var configPath string
89+
90+ cmd := &cobra.Command{
91+ Use: "list",
92+ Short: "List all authentication tokens",
93+ Long: `List all stored authentication tokens with their subjects and expiry information.`,
94+ RunE: func(cmd *cobra.Command, args []string) error {
95+ return runTokenList(configPath)
96+ },
97+ }
98+
99+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
100+
101+ return cmd
102+}
103+
104+// newTokenRevokeCmd creates the token revoke command.
105+func newTokenRevokeCmd() *cobra.Command {
106+ var configPath string
107+
108+ cmd := &cobra.Command{
109+ Use: "revoke <token-id>",
110+ Short: "Revoke an authentication token",
111+ Long: `Revoke a token by its ID. The token will no longer be valid for authentication.`,
112+ Args: cobra.ExactArgs(1),
113+ RunE: func(cmd *cobra.Command, args []string) error {
114+ return runTokenRevoke(args[0], configPath)
115+ },
116+ }
117+
118+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
119+
120+ return cmd
121+}
122+
123+// newAuthPasswordCmd creates the password subcommand group.
124+func newAuthPasswordCmd() *cobra.Command {
125+ cmd := &cobra.Command{
126+ Use: "password",
127+ Short: "Password utilities",
128+ Long: `Utilities for managing password authentication.`,
129+ }
130+
131+ cmd.AddCommand(newPasswordHashCmd())
132+
133+ return cmd
134+}
135+
136+// newPasswordHashCmd creates the password hash command.
137+func newPasswordHashCmd() *cobra.Command {
138+ return &cobra.Command{
139+ Use: "hash",
140+ Short: "Generate a bcrypt hash for a password",
141+ Long: `Interactively prompt for a password and output its bcrypt hash.
142+
143+The hash can be stored in your secrets backend and referenced in the config:
144+
145+ [registry_auth]
146+ type = "password"
147+ password_hash = "gordon/registry/password_hash"`,
148+ RunE: func(cmd *cobra.Command, args []string) error {
149+ return runPasswordHash()
150+ },
151+ }
152+}
153+
154+// runTokenGenerate generates a new authentication token.
155+func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
156+ cfg, err := loadAuthConfig(configPath)
157+ if err != nil {
158+ return err
159+ }
160+
161+ // Parse expiry
162+ var expiry time.Duration
163+ if expiryStr != "0" {
164+ expiry, err = time.ParseDuration(expiryStr)
165+ if err != nil {
166+ return fmt.Errorf("invalid expiry duration: %w", err)
167+ }
168+ }
169+
170+ // Parse scopes
171+ scopes := strings.Split(scopesStr, ",")
172+ for i := range scopes {
173+ scopes[i] = strings.TrimSpace(scopes[i])
174+ }
175+
176+ // Create auth service
177+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
178+ authSvc, err := createAuthServiceForCLI(cfg, log)
179+ if err != nil {
180+ return err
181+ }
182+
183+ // Generate token
184+ ctx := context.Background()
185+ token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
186+ if err != nil {
187+ return fmt.Errorf("failed to generate token: %w", err)
188+ }
189+
190+ fmt.Println("Token generated successfully!")
191+ fmt.Printf("Subject: %s\n", subject)
192+ fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
193+ if expiry == 0 {
194+ fmt.Println("Expiry: never")
195+ } else {
196+ fmt.Printf("Expiry: %s\n", expiry)
197+ }
198+ fmt.Println()
199+ fmt.Println("Token (use as password with docker login):")
200+ fmt.Println(token)
201+ fmt.Println()
202+ fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
203+
204+ return nil
205+}
206+
207+// runTokenList lists all stored tokens.
208+func runTokenList(configPath string) error {
209+ cfg, err := loadAuthConfig(configPath)
210+ if err != nil {
211+ return err
212+ }
213+
214+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
215+ authSvc, err := createAuthServiceForCLI(cfg, log)
216+ if err != nil {
217+ return err
218+ }
219+
220+ ctx := context.Background()
221+ tokens, err := authSvc.ListTokens(ctx)
222+ if err != nil {
223+ return fmt.Errorf("failed to list tokens: %w", err)
224+ }
225+
226+ if len(tokens) == 0 {
227+ fmt.Println("No tokens found.")
228+ return nil
229+ }
230+
231+ fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
232+ fmt.Println(strings.Repeat("-", 90))
233+
234+ for _, t := range tokens {
235+ expiry := "never"
236+ if !t.ExpiresAt.IsZero() {
237+ expiry = t.ExpiresAt.Format("2006-01-02 15:04")
238+ }
239+ revoked := "no"
240+ if t.Revoked {
241+ revoked = "yes"
242+ }
243+ fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
244+ }
245+
246+ return nil
247+}
248+
249+// runTokenRevoke revokes a token by ID.
250+func runTokenRevoke(tokenID, configPath string) error {
251+ cfg, err := loadAuthConfig(configPath)
252+ if err != nil {
253+ return err
254+ }
255+
256+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
257+ authSvc, err := createAuthServiceForCLI(cfg, log)
258+ if err != nil {
259+ return err
260+ }
261+
262+ ctx := context.Background()
263+ if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
264+ return fmt.Errorf("failed to revoke token: %w", err)
265+ }
266+
267+ fmt.Printf("Token %s has been revoked.\n", tokenID)
268+ return nil
269+}
270+
271+// runPasswordHash generates a bcrypt hash for a password.
272+func runPasswordHash() error {
273+ fmt.Print("Enter password: ")
274+
275+ // Read password without echo
276+ passwordBytes, err := term.ReadPassword(syscall.Stdin)
277+ if err != nil {
278+ // Fallback for non-terminal input
279+ reader := bufio.NewReader(os.Stdin)
280+ password, err := reader.ReadString('\n')
281+ if err != nil {
282+ return fmt.Errorf("failed to read password: %w", err)
283+ }
284+ passwordBytes = []byte(strings.TrimSpace(password))
285+ }
286+ fmt.Println()
287+
288+ if len(passwordBytes) == 0 {
289+ return fmt.Errorf("password cannot be empty")
290+ }
291+
292+ // Create a temporary auth service to generate the hash
293+ authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
294+ hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
295+ if err != nil {
296+ return fmt.Errorf("failed to generate hash: %w", err)
297+ }
298+
299+ fmt.Println()
300+ fmt.Println("Bcrypt hash (store in your secrets backend):")
301+ fmt.Println(hash)
302+ fmt.Println()
303+ fmt.Println("Then reference the path in your config:")
304+ fmt.Println(" [registry_auth]")
305+ fmt.Println(" type = \"password\"")
306+ fmt.Println(" password_hash = \"gordon/registry/password_hash\"")
307+
308+ return nil
309+}
310+
311+// cliConfig holds the configuration needed for CLI commands.
312+type cliConfig struct {
313+ Backend domain.SecretsBackend
314+ DataDir string
315+ TokenSecret []byte
316+}
317+
318+// loadAuthConfig loads the configuration needed for auth CLI commands.
319+func loadAuthConfig(configPath string) (*cliConfig, error) {
320+ v := viper.New()
321+
322+ // Set defaults
323+ v.SetDefault("server.data_dir", app.DefaultDataDir())
324+ v.SetDefault("secrets.backend", "unsafe")
325+
326+ app.ConfigureViper(v, configPath)
327+
328+ if err := v.ReadInConfig(); err != nil {
329+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
330+ return nil, fmt.Errorf("failed to read config file: %w", err)
331+ }
332+ // Config file not found is OK for CLI commands
333+ }
334+
335+ cfg := &cliConfig{
336+ DataDir: v.GetString("server.data_dir"),
337+ }
338+
339+ // Determine backend
340+ switch v.GetString("secrets.backend") {
341+ case "pass":
342+ cfg.Backend = domain.SecretsBackendPass
343+ case "sops":
344+ cfg.Backend = domain.SecretsBackendSops
345+ default:
346+ cfg.Backend = domain.SecretsBackendUnsafe
347+ }
348+
349+ // Load token secret from secrets backend
350+ tokenSecretPath := v.GetString("registry_auth.token_secret")
351+ if tokenSecretPath != "" {
352+ // Load actual secret from the configured backend
353+ ctx := context.Background()
354+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
355+
356+ switch cfg.Backend {
357+ case domain.SecretsBackendPass:
358+ provider := secrets.NewPassProvider(log)
359+ secret, err := provider.GetSecret(ctx, tokenSecretPath)
360+ if err != nil {
361+ return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
362+ }
363+ cfg.TokenSecret = []byte(secret)
364+ default:
365+ // For unsafe backend, use the path as the secret (backwards compatible)
366+ cfg.TokenSecret = []byte(tokenSecretPath)
367+ }
368+ } else {
369+ // Generate a default secret for CLI token generation
370+ cfg.TokenSecret = []byte("gordon-cli-default-secret")
371+ }
372+
373+ return cfg, nil
374+}
375+
376+// createAuthServiceForCLI creates an auth service for CLI commands.
377+func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
378+ // Create token store
379+ store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
380+ if err != nil {
381+ return nil, fmt.Errorf("failed to create token store: %w", err)
382+ }
383+
384+ // Create auth service config
385+ authConfig := auth.Config{
386+ Enabled: true,
387+ AuthType: domain.AuthTypeToken,
388+ TokenSecret: cfg.TokenSecret,
389+ }
390+
391+ return auth.NewService(authConfig, store, log), nil
392+}
A internal/adapters/in/cli/root.go +96 -0
@@ -0,0 +1,96 @@
1+// Package cli implements the CLI adapter for Gordon.
2+// This package provides Cobra commands that delegate to the app layer.
3+package cli
4+
5+import (
6+ "context"
7+
8+ "gordon/internal/app"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+var (
14+ // Version information (set at build time)
15+ Version = "dev"
16+ Commit = "unknown"
17+ BuildDate = "unknown"
18+)
19+
20+// NewRootCmd creates the root command for Gordon CLI.
21+func NewRootCmd() *cobra.Command {
22+ rootCmd := &cobra.Command{
23+ Use: "gordon",
24+ Short: "Gordon - A lightweight container deployment platform",
25+ Long: `Gordon is a self-contained container deployment platform that combines
26+a Docker registry with automatic container deployment capabilities.
27+
28+It listens for image pushes and automatically deploys containers based on
29+configuration rules, making it ideal for single-server deployments.`,
30+ }
31+
32+ // Add subcommands
33+ rootCmd.AddCommand(newStartCmd())
34+ rootCmd.AddCommand(newReloadCmd())
35+ rootCmd.AddCommand(newVersionCmd())
36+ rootCmd.AddCommand(newAuthCmd())
37+
38+ return rootCmd
39+}
40+
41+// newStartCmd creates the start command.
42+func newStartCmd() *cobra.Command {
43+ var configPath string
44+
45+ cmd := &cobra.Command{
46+ Use: "start",
47+ Short: "Start the Gordon server",
48+ Long: `Start the Gordon server, including the registry and proxy components.`,
49+ RunE: func(cmd *cobra.Command, args []string) error {
50+ return app.Run(context.Background(), configPath)
51+ },
52+ }
53+
54+ // Add flags
55+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
56+
57+ return cmd
58+}
59+
60+// newReloadCmd creates the reload command.
61+func newReloadCmd() *cobra.Command {
62+ return &cobra.Command{
63+ Use: "reload",
64+ Short: "Reload containers with updated environment",
65+ Long: `Triggers a reload of all managed containers with updated environment
66+variables from their respective .env files.`,
67+ RunE: func(cmd *cobra.Command, args []string) error {
68+ return runReload()
69+ },
70+ }
71+}
72+
73+// newVersionCmd creates the version command.
74+func newVersionCmd() *cobra.Command {
75+ return &cobra.Command{
76+ Use: "version",
77+ Short: "Print version information",
78+ Run: func(cmd *cobra.Command, args []string) {
79+ cmd.Printf("Gordon %s\n", Version)
80+ cmd.Printf("Commit: %s\n", Commit)
81+ cmd.Printf("Build Date: %s\n", BuildDate)
82+ },
83+ }
84+}
85+
86+// runReload sends SIGUSR1 to the running Gordon process.
87+func runReload() error {
88+ return app.SendReloadSignal()
89+}
90+
91+// SetVersionInfo sets the version information for the CLI.
92+func SetVersionInfo(version, commit, date string) {
93+ Version = version
94+ Commit = commit
95+ BuildDate = date
96+}
A internal/adapters/in/http/middleware/auth.go +303 -0
@@ -0,0 +1,303 @@
1+package middleware
2+
3+import (
4+ "context"
5+ "crypto/subtle"
6+ "net/http"
7+ "strings"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/domain"
13+)
14+
15+// RegistryAuth middleware provides Docker Registry authentication.
16+func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {
17+ return func(next http.Handler) http.Handler {
18+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ // Require auth for all registry operations
20+ if !isAuthenticated(r, username, password, log) {
21+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
22+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
23+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
24+
25+ log.Warn().
26+ Str(zerowrap.FieldLayer, "adapter").
27+ Str(zerowrap.FieldAdapter, "http").
28+ Str(zerowrap.FieldMethod, r.Method).
29+ Str(zerowrap.FieldPath, r.URL.Path).
30+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
31+ Msg("unauthorized registry access attempt")
32+ return
33+ }
34+
35+ next.ServeHTTP(w, r)
36+ })
37+ }
38+}
39+
40+// isAuthenticated checks basic auth credentials.
41+func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool {
42+ authHeader := r.Header.Get("Authorization")
43+ log.Debug().
44+ Str(zerowrap.FieldLayer, "adapter").
45+ Str(zerowrap.FieldAdapter, "http").
46+ Str(zerowrap.FieldMethod, r.Method).
47+ Str(zerowrap.FieldPath, r.URL.Path).
48+ Bool("has_auth_header", authHeader != "").
49+ Msg("processing authentication request")
50+
51+ username, password, ok := r.BasicAuth()
52+ if !ok {
53+ log.Debug().
54+ Str(zerowrap.FieldMethod, r.Method).
55+ Str(zerowrap.FieldPath, r.URL.Path).
56+ Msg("no basic auth provided")
57+ return false
58+ }
59+
60+ // Use constant-time comparison to prevent timing attacks
61+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
62+ passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
63+
64+ authenticated := usernameMatch && passwordMatch
65+ if !authenticated {
66+ log.Debug().
67+ Str("provided_username", username).
68+ Str(zerowrap.FieldMethod, r.Method).
69+ Str(zerowrap.FieldPath, r.URL.Path).
70+ Msg("authentication failed")
71+ } else {
72+ log.Debug().
73+ Str("username", username).
74+ Str(zerowrap.FieldMethod, r.Method).
75+ Str(zerowrap.FieldPath, r.URL.Path).
76+ Msg("authentication successful")
77+ }
78+
79+ return authenticated
80+}
81+
82+// InternalRegistryAuth holds the credentials used for loopback-only registry access.
83+// These are generated per Gordon instance and are never exposed in config.
84+type InternalRegistryAuth struct {
85+ Username string
86+ Password string
87+}
88+
89+// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
90+// supporting both password and token-based authentication.
91+func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler {
92+ return func(next http.Handler) http.Handler {
93+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94+ // Allow localhost requests only with internal instance credentials.
95+ if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
96+ log.Debug().
97+ Str(zerowrap.FieldLayer, "adapter").
98+ Str(zerowrap.FieldAdapter, "http").
99+ Str(zerowrap.FieldMethod, r.Method).
100+ Str(zerowrap.FieldPath, r.URL.Path).
101+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
102+ Msg("localhost request with internal auth - skipping auth")
103+ next.ServeHTTP(w, r)
104+ return
105+ }
106+
107+ // Warn if not using TLS
108+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
109+ log.Warn().
110+ Str(zerowrap.FieldLayer, "adapter").
111+ Str(zerowrap.FieldAdapter, "http").
112+ Str(zerowrap.FieldMethod, r.Method).
113+ Str(zerowrap.FieldPath, r.URL.Path).
114+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
115+ Msg("registry auth over insecure HTTP connection")
116+ }
117+
118+ // Check if auth is enabled
119+ if !authSvc.IsEnabled() {
120+ next.ServeHTTP(w, r)
121+ return
122+ }
123+
124+ ctx := r.Context()
125+
126+ // Authenticate based on auth type
127+ var authenticated bool
128+ switch authSvc.GetAuthType() {
129+ case domain.AuthTypePassword:
130+ authenticated = authenticatePassword(ctx, r, authSvc, log)
131+ case domain.AuthTypeToken:
132+ authenticated = authenticateToken(ctx, r, authSvc, log)
133+ default:
134+ log.Error().
135+ Str("auth_type", string(authSvc.GetAuthType())).
136+ Msg("unknown auth type")
137+ authenticated = false
138+ }
139+
140+ if !authenticated {
141+ sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
142+ return
143+ }
144+
145+ next.ServeHTTP(w, r)
146+ })
147+ }
148+}
149+
150+// authenticatePassword handles password-based authentication.
151+func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
152+ username, password, ok := r.BasicAuth()
153+ if !ok {
154+ log.Debug().
155+ Str(zerowrap.FieldMethod, r.Method).
156+ Str(zerowrap.FieldPath, r.URL.Path).
157+ Msg("no basic auth provided")
158+ return false
159+ }
160+
161+ if authSvc.ValidatePassword(ctx, username, password) {
162+ log.Debug().
163+ Str("username", username).
164+ Str(zerowrap.FieldMethod, r.Method).
165+ Str(zerowrap.FieldPath, r.URL.Path).
166+ Msg("password authentication successful")
167+ return true
168+ }
169+
170+ log.Debug().
171+ Str("provided_username", username).
172+ Str(zerowrap.FieldMethod, r.Method).
173+ Str(zerowrap.FieldPath, r.URL.Path).
174+ Msg("password authentication failed")
175+ return false
176+}
177+
178+// authenticateToken handles token-based authentication.
179+// It supports both Bearer token in Authorization header and token-as-password for CI.
180+func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
181+ // First, check for Bearer token
182+ authHeader := r.Header.Get("Authorization")
183+ if strings.HasPrefix(authHeader, "Bearer ") {
184+ token := strings.TrimPrefix(authHeader, "Bearer ")
185+ claims, err := authSvc.ValidateToken(ctx, token)
186+ if err != nil {
187+ log.Debug().
188+ Err(err).
189+ Str(zerowrap.FieldMethod, r.Method).
190+ Str(zerowrap.FieldPath, r.URL.Path).
191+ Msg("bearer token validation failed")
192+ return false
193+ }
194+
195+ log.Debug().
196+ Str("subject", claims.Subject).
197+ Str(zerowrap.FieldMethod, r.Method).
198+ Str(zerowrap.FieldPath, r.URL.Path).
199+ Msg("bearer token authentication successful")
200+ return true
201+ }
202+
203+ // Fall back to token-as-password (for CI/automation)
204+ // Username is the subject, password is the JWT token
205+ username, password, ok := r.BasicAuth()
206+ if !ok {
207+ log.Debug().
208+ Str(zerowrap.FieldMethod, r.Method).
209+ Str(zerowrap.FieldPath, r.URL.Path).
210+ Msg("no auth credentials provided")
211+ return false
212+ }
213+
214+ // Try to validate the password as a JWT token
215+ claims, err := authSvc.ValidateToken(ctx, password)
216+ if err != nil {
217+ log.Debug().
218+ Err(err).
219+ Str("provided_username", username).
220+ Str(zerowrap.FieldMethod, r.Method).
221+ Str(zerowrap.FieldPath, r.URL.Path).
222+ Msg("token-as-password validation failed")
223+ return false
224+ }
225+
226+ // Verify the username matches the token subject
227+ if claims.Subject != username {
228+ log.Debug().
229+ Str("provided_username", username).
230+ Str("token_subject", claims.Subject).
231+ Str(zerowrap.FieldMethod, r.Method).
232+ Str(zerowrap.FieldPath, r.URL.Path).
233+ Msg("username does not match token subject")
234+ return false
235+ }
236+
237+ log.Debug().
238+ Str("subject", claims.Subject).
239+ Str(zerowrap.FieldMethod, r.Method).
240+ Str(zerowrap.FieldPath, r.URL.Path).
241+ Msg("token-as-password authentication successful")
242+ return true
243+}
244+
245+// isLocalhostRequest checks if the request originates from localhost.
246+// This is used to allow Gordon to pull from its own registry with internal auth.
247+func isLocalhostRequest(r *http.Request) bool {
248+ host := r.RemoteAddr
249+ // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
250+ if strings.HasPrefix(host, "127.") ||
251+ strings.HasPrefix(host, "[::1]") ||
252+ strings.HasPrefix(host, "::1") ||
253+ strings.HasPrefix(host, "localhost") {
254+ return true
255+ }
256+ return false
257+}
258+
259+func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
260+ if internalAuth.Username == "" || internalAuth.Password == "" {
261+ return false
262+ }
263+
264+ username, password, ok := r.BasicAuth()
265+ if !ok {
266+ return false
267+ }
268+
269+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
270+ passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
271+ return usernameMatch && passwordMatch
272+}
273+
274+// sendUnauthorized sends an HTTP 401 response with appropriate headers.
275+func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
276+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
277+
278+ switch authType {
279+ case domain.AuthTypeToken:
280+ // For token auth, indicate the token server endpoint
281+ // Detect scheme from request (TLS or X-Forwarded-Proto header)
282+ scheme := "http"
283+ if r.TLS != nil {
284+ scheme = "https"
285+ } else if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
286+ scheme = proto
287+ }
288+ realm := scheme + "://" + host + "/v2/token"
289+ w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
290+ default:
291+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
292+ }
293+
294+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
295+
296+ log.Warn().
297+ Str(zerowrap.FieldLayer, "adapter").
298+ Str(zerowrap.FieldAdapter, "http").
299+ Str(zerowrap.FieldMethod, r.Method).
300+ Str(zerowrap.FieldPath, r.URL.Path).
301+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
302+ Msg("unauthorized registry access attempt")
303+}
A internal/adapters/in/http/middleware/auth_test.go +494 -0
@@ -0,0 +1,494 @@
1+package middleware
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/stretchr/testify/assert"
14+
15+ "gordon/internal/domain"
16+)
17+
18+func testLogger() zerowrap.Logger {
19+ return zerowrap.Default()
20+}
21+
22+func TestRegistryAuth_ValidCredentials(t *testing.T) {
23+ log := testLogger()
24+ middleware := RegistryAuth("admin", "secret", log)
25+
26+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ w.WriteHeader(http.StatusOK)
28+ w.Write([]byte("authenticated"))
29+ }))
30+
31+ req := httptest.NewRequest("GET", "/v2/", nil)
32+ req.SetBasicAuth("admin", "secret")
33+ rec := httptest.NewRecorder()
34+
35+ handler.ServeHTTP(rec, req)
36+
37+ assert.Equal(t, http.StatusOK, rec.Code)
38+ assert.Equal(t, "authenticated", rec.Body.String())
39+}
40+
41+func TestRegistryAuth_InvalidUsername(t *testing.T) {
42+ log := testLogger()
43+ middleware := RegistryAuth("admin", "secret", log)
44+
45+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+ w.WriteHeader(http.StatusOK)
47+ }))
48+
49+ req := httptest.NewRequest("GET", "/v2/", nil)
50+ req.SetBasicAuth("wronguser", "secret")
51+ rec := httptest.NewRecorder()
52+
53+ handler.ServeHTTP(rec, req)
54+
55+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
56+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
57+ assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
58+}
59+
60+func TestRegistryAuth_InvalidPassword(t *testing.T) {
61+ log := testLogger()
62+ middleware := RegistryAuth("admin", "secret", log)
63+
64+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65+ w.WriteHeader(http.StatusOK)
66+ }))
67+
68+ req := httptest.NewRequest("GET", "/v2/", nil)
69+ req.SetBasicAuth("admin", "wrongpassword")
70+ rec := httptest.NewRecorder()
71+
72+ handler.ServeHTTP(rec, req)
73+
74+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
75+}
76+
77+func TestRegistryAuth_NoCredentials(t *testing.T) {
78+ log := testLogger()
79+ middleware := RegistryAuth("admin", "secret", log)
80+
81+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82+ w.WriteHeader(http.StatusOK)
83+ }))
84+
85+ req := httptest.NewRequest("GET", "/v2/", nil)
86+ // No auth header
87+ rec := httptest.NewRecorder()
88+
89+ handler.ServeHTTP(rec, req)
90+
91+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
92+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
93+}
94+
95+func TestRegistryAuth_MalformedAuthHeader(t *testing.T) {
96+ log := testLogger()
97+ middleware := RegistryAuth("admin", "secret", log)
98+
99+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100+ w.WriteHeader(http.StatusOK)
101+ }))
102+
103+ req := httptest.NewRequest("GET", "/v2/", nil)
104+ req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64
105+ rec := httptest.NewRecorder()
106+
107+ handler.ServeHTTP(rec, req)
108+
109+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
110+}
111+
112+func TestRegistryAuth_WrongAuthScheme(t *testing.T) {
113+ log := testLogger()
114+ middleware := RegistryAuth("admin", "secret", log)
115+
116+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117+ w.WriteHeader(http.StatusOK)
118+ }))
119+
120+ req := httptest.NewRequest("GET", "/v2/", nil)
121+ req.Header.Set("Authorization", "Bearer some-token")
122+ rec := httptest.NewRecorder()
123+
124+ handler.ServeHTTP(rec, req)
125+
126+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
127+}
128+
129+func TestRegistryAuth_EmptyCredentials(t *testing.T) {
130+ log := testLogger()
131+ middleware := RegistryAuth("admin", "secret", log)
132+
133+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+ w.WriteHeader(http.StatusOK)
135+ }))
136+
137+ req := httptest.NewRequest("GET", "/v2/", nil)
138+ req.SetBasicAuth("", "")
139+ rec := httptest.NewRecorder()
140+
141+ handler.ServeHTTP(rec, req)
142+
143+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
144+}
145+
146+func TestRegistryAuth_PartialCredentials(t *testing.T) {
147+ log := testLogger()
148+ middleware := RegistryAuth("admin", "secret", log)
149+
150+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151+ w.WriteHeader(http.StatusOK)
152+ }))
153+
154+ // Only username, empty password
155+ req := httptest.NewRequest("GET", "/v2/", nil)
156+ req.SetBasicAuth("admin", "")
157+ rec := httptest.NewRecorder()
158+
159+ handler.ServeHTTP(rec, req)
160+
161+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
162+}
163+
164+func TestIsAuthenticated_ValidCredentials(t *testing.T) {
165+ log := testLogger()
166+
167+ req := httptest.NewRequest("GET", "/v2/", nil)
168+ req.SetBasicAuth("admin", "secret")
169+
170+ result := isAuthenticated(req, "admin", "secret", log)
171+
172+ assert.True(t, result)
173+}
174+
175+func TestIsAuthenticated_InvalidCredentials(t *testing.T) {
176+ log := testLogger()
177+
178+ req := httptest.NewRequest("GET", "/v2/", nil)
179+ req.SetBasicAuth("admin", "wrong")
180+
181+ result := isAuthenticated(req, "admin", "secret", log)
182+
183+ assert.False(t, result)
184+}
185+
186+func TestIsAuthenticated_NoAuthHeader(t *testing.T) {
187+ log := testLogger()
188+
189+ req := httptest.NewRequest("GET", "/v2/", nil)
190+
191+ result := isAuthenticated(req, "admin", "secret", log)
192+
193+ assert.False(t, result)
194+}
195+
196+func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) {
197+ // This test verifies that constant-time comparison is used
198+ // by checking both username and password are validated
199+ log := testLogger()
200+
201+ tests := []struct {
202+ name string
203+ username string
204+ password string
205+ expectedUser string
206+ expectedPass string
207+ shouldAuth bool
208+ }{
209+ {
210+ name: "both correct",
211+ username: "admin",
212+ password: "secret",
213+ expectedUser: "admin",
214+ expectedPass: "secret",
215+ shouldAuth: true,
216+ },
217+ {
218+ name: "username wrong first char",
219+ username: "bdmin",
220+ password: "secret",
221+ expectedUser: "admin",
222+ expectedPass: "secret",
223+ shouldAuth: false,
224+ },
225+ {
226+ name: "username wrong last char",
227+ username: "admio",
228+ password: "secret",
229+ expectedUser: "admin",
230+ expectedPass: "secret",
231+ shouldAuth: false,
232+ },
233+ {
234+ name: "password wrong first char",
235+ username: "admin",
236+ password: "aecret",
237+ expectedUser: "admin",
238+ expectedPass: "secret",
239+ shouldAuth: false,
240+ },
241+ {
242+ name: "password wrong last char",
243+ username: "admin",
244+ password: "secreo",
245+ expectedUser: "admin",
246+ expectedPass: "secret",
247+ shouldAuth: false,
248+ },
249+ {
250+ name: "different length username",
251+ username: "adm",
252+ password: "secret",
253+ expectedUser: "admin",
254+ expectedPass: "secret",
255+ shouldAuth: false,
256+ },
257+ {
258+ name: "different length password",
259+ username: "admin",
260+ password: "sec",
261+ expectedUser: "admin",
262+ expectedPass: "secret",
263+ shouldAuth: false,
264+ },
265+ }
266+
267+ for _, tt := range tests {
268+ t.Run(tt.name, func(t *testing.T) {
269+ req := httptest.NewRequest("GET", "/v2/", nil)
270+ req.SetBasicAuth(tt.username, tt.password)
271+
272+ result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log)
273+
274+ assert.Equal(t, tt.shouldAuth, result)
275+ })
276+ }
277+}
278+
279+func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) {
280+ log := testLogger()
281+
282+ tests := []struct {
283+ name string
284+ username string
285+ password string
286+ }{
287+ {"unicode username", "用户", "password"},
288+ {"unicode password", "admin", "密码"},
289+ {"special chars", "admin@example.com", "p@ss:w0rd!"},
290+ {"spaces", "admin user", "pass word"},
291+ {"colon in password", "admin", "pass:word"},
292+ }
293+
294+ for _, tt := range tests {
295+ t.Run(tt.name, func(t *testing.T) {
296+ middleware := RegistryAuth(tt.username, tt.password, log)
297+
298+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299+ w.WriteHeader(http.StatusOK)
300+ }))
301+
302+ req := httptest.NewRequest("GET", "/v2/", nil)
303+ req.SetBasicAuth(tt.username, tt.password)
304+ rec := httptest.NewRecorder()
305+
306+ handler.ServeHTTP(rec, req)
307+
308+ assert.Equal(t, http.StatusOK, rec.Code)
309+ })
310+ }
311+}
312+
313+func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
314+ log := testLogger()
315+ middleware := RegistryAuth("admin", "secret", log)
316+
317+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
318+ w.WriteHeader(http.StatusOK)
319+ }))
320+
321+ tests := []struct {
322+ name string
323+ authHeader string
324+ wantStatus int
325+ }{
326+ {
327+ name: "empty basic auth",
328+ authHeader: "Basic ",
329+ wantStatus: http.StatusUnauthorized,
330+ },
331+ {
332+ name: "basic auth with only colon",
333+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
334+ wantStatus: http.StatusUnauthorized,
335+ },
336+ {
337+ name: "missing colon in decoded value",
338+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")),
339+ wantStatus: http.StatusUnauthorized,
340+ },
341+ }
342+
343+ for _, tt := range tests {
344+ t.Run(tt.name, func(t *testing.T) {
345+ req := httptest.NewRequest("GET", "/v2/", nil)
346+ req.Header.Set("Authorization", tt.authHeader)
347+ rec := httptest.NewRecorder()
348+
349+ handler.ServeHTTP(rec, req)
350+
351+ assert.Equal(t, tt.wantStatus, rec.Code)
352+ })
353+ }
354+}
355+
356+func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
357+ log := testLogger()
358+ called := false
359+ authSvc := stubAuthService{
360+ enabled: true,
361+ authType: domain.AuthTypePassword,
362+ validatePassword: func(_ context.Context, _ string, _ string) bool {
363+ called = true
364+ return false
365+ },
366+ }
367+ internalAuth := InternalRegistryAuth{
368+ Username: "internal",
369+ Password: "secret",
370+ }
371+
372+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
373+ w.WriteHeader(http.StatusOK)
374+ }))
375+
376+ req := httptest.NewRequest("GET", "/v2/", nil)
377+ req.RemoteAddr = "127.0.0.1:12345"
378+ req.SetBasicAuth("internal", "secret")
379+ rec := httptest.NewRecorder()
380+
381+ handler.ServeHTTP(rec, req)
382+
383+ assert.Equal(t, http.StatusOK, rec.Code)
384+ assert.False(t, called, "auth service should not be used for internal loopback auth")
385+}
386+
387+func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
388+ log := testLogger()
389+ called := false
390+ authSvc := stubAuthService{
391+ enabled: true,
392+ authType: domain.AuthTypePassword,
393+ validatePassword: func(_ context.Context, _ string, _ string) bool {
394+ called = true
395+ return false
396+ },
397+ }
398+ internalAuth := InternalRegistryAuth{
399+ Username: "internal",
400+ Password: "secret",
401+ }
402+
403+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
404+ w.WriteHeader(http.StatusOK)
405+ }))
406+
407+ req := httptest.NewRequest("GET", "/v2/", nil)
408+ req.RemoteAddr = "127.0.0.1:12345"
409+ req.SetBasicAuth("internal", "wrong")
410+ rec := httptest.NewRecorder()
411+
412+ handler.ServeHTTP(rec, req)
413+
414+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
415+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
416+ assert.True(t, called, "auth service should be used when internal auth fails")
417+}
418+
419+func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
420+ log := testLogger()
421+ called := false
422+ authSvc := stubAuthService{
423+ enabled: true,
424+ authType: domain.AuthTypePassword,
425+ validatePassword: func(_ context.Context, _ string, _ string) bool {
426+ called = true
427+ return false
428+ },
429+ }
430+ internalAuth := InternalRegistryAuth{
431+ Username: "internal",
432+ Password: "secret",
433+ }
434+
435+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
436+ w.WriteHeader(http.StatusOK)
437+ }))
438+
439+ req := httptest.NewRequest("GET", "/v2/", nil)
440+ req.RemoteAddr = "192.168.1.10:12345"
441+ req.SetBasicAuth("internal", "secret")
442+ rec := httptest.NewRecorder()
443+
444+ handler.ServeHTTP(rec, req)
445+
446+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
447+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
448+ assert.True(t, called, "auth service should be used for non-localhost requests")
449+}
450+
451+type stubAuthService struct {
452+ enabled bool
453+ authType domain.AuthType
454+ validatePassword func(ctx context.Context, username, password string) bool
455+ validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
456+}
457+
458+func (s stubAuthService) GetAuthType() domain.AuthType {
459+ return s.authType
460+}
461+
462+func (s stubAuthService) IsEnabled() bool {
463+ return s.enabled
464+}
465+
466+func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
467+ if s.validatePassword != nil {
468+ return s.validatePassword(ctx, username, password)
469+ }
470+ return false
471+}
472+
473+func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
474+ if s.validateToken != nil {
475+ return s.validateToken(ctx, tokenString)
476+ }
477+ return nil, errors.New("not implemented")
478+}
479+
480+func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
481+ return "", errors.New("not implemented")
482+}
483+
484+func (s stubAuthService) RevokeToken(context.Context, string) error {
485+ return errors.New("not implemented")
486+}
487+
488+func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
489+ return nil, errors.New("not implemented")
490+}
491+
492+func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
493+ return "", errors.New("not implemented")
494+}
R internal/middleware/logging.go → internal/adapters/in/http/middleware/logging.go +71 -96 renamed, 53% similar
@@ -1,20 +1,21 @@
1+// Package middleware provides HTTP middleware for the adapters layer.
12 package middleware
23
34 import (
45 "net/http"
56 "time"
67
7- "gordon/internal/logging"
8- "github.com/rs/zerolog/log"
8+ "github.com/bnema/zerowrap"
99 )
1010
11-// ResponseWriter wraps http.ResponseWriter to capture status code and bytes written
11+// ResponseWriter wraps http.ResponseWriter to capture status code and bytes written.
1212 type ResponseWriter struct {
1313 http.ResponseWriter
1414 statusCode int
1515 bytes int
1616 }
1717
18+// NewResponseWriter creates a new wrapped response writer.
1819 func NewResponseWriter(w http.ResponseWriter) *ResponseWriter {
1920 return &ResponseWriter{
2021 ResponseWriter: w,
@@ -22,60 +23,68 @@ func NewResponseWriter(w http.ResponseWriter) *ResponseWriter {
2223 }
2324 }
2425
26+// WriteHeader captures the status code.
2527 func (rw *ResponseWriter) WriteHeader(code int) {
2628 rw.statusCode = code
2729 rw.ResponseWriter.WriteHeader(code)
2830 }
2931
32+// Write captures bytes written.
3033 func (rw *ResponseWriter) Write(b []byte) (int, error) {
3134 n, err := rw.ResponseWriter.Write(b)
3235 rw.bytes += n
3336 return n, err
3437 }
3538
39+// StatusCode returns the captured status code.
3640 func (rw *ResponseWriter) StatusCode() int {
3741 return rw.statusCode
3842 }
3943
44+// BytesWritten returns the number of bytes written.
4045 func (rw *ResponseWriter) BytesWritten() int {
4146 return rw.bytes
4247 }
4348
44-// RequestLogger is a middleware that logs HTTP requests
45-func RequestLogger(next http.Handler) http.Handler {
46- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47- start := time.Now()
48-
49- // Wrap the response writer to capture status and bytes
50- rw := NewResponseWriter(w)
51-
52- // Get client IP (handle X-Forwarded-For and X-Real-IP headers)
53- clientIP := getClientIP(r)
54-
55- // Call the next handler
56- next.ServeHTTP(rw, r)
57-
58- // Calculate duration
59- duration := time.Since(start)
60-
61- // Log the request using proxy logger
62- logging.ProxyLogger.Info().
63- Str("method", r.Method).
64- Str("path", r.URL.Path).
65- Str("query", r.URL.RawQuery).
66- Str("host", r.Host).
67- Str("user_agent", r.UserAgent()).
68- Str("referer", r.Referer()).
69- Str("client_ip", clientIP).
70- Int("status", rw.StatusCode()).
71- Int("bytes", rw.BytesWritten()).
72- Dur("duration", duration).
73- Str("proto", r.Proto).
74- Msg("HTTP Request")
75- })
49+// RequestLogger is a middleware that logs HTTP requests using zerowrap.
50+func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
51+ return func(next http.Handler) http.Handler {
52+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ start := time.Now()
54+
55+ // Wrap the response writer to capture status and bytes
56+ rw := NewResponseWriter(w)
57+
58+ // Get client IP (handle X-Forwarded-For and X-Real-IP headers)
59+ clientIP := getClientIP(r)
60+
61+ // Call the next handler
62+ next.ServeHTTP(rw, r)
63+
64+ // Calculate duration
65+ duration := time.Since(start)
66+
67+ // Log the request
68+ log.Info().
69+ Str(zerowrap.FieldLayer, "adapter").
70+ Str(zerowrap.FieldAdapter, "http").
71+ Str(zerowrap.FieldMethod, r.Method).
72+ Str(zerowrap.FieldPath, r.URL.Path).
73+ Str("query", r.URL.RawQuery).
74+ Str(zerowrap.FieldHost, r.Host).
75+ Str("user_agent", r.UserAgent()).
76+ Str("referer", r.Referer()).
77+ Str(zerowrap.FieldClientIP, clientIP).
78+ Int(zerowrap.FieldStatus, rw.StatusCode()).
79+ Int("bytes", rw.BytesWritten()).
80+ Dur(zerowrap.FieldDuration, duration).
81+ Str("proto", r.Proto).
82+ Msg("HTTP request")
83+ })
84+ }
7685 }
7786
78-// getClientIP extracts the client IP from various headers
87+// getClientIP extracts the client IP from various headers.
7988 func getClientIP(r *http.Request) string {
8089 // Check X-Forwarded-For header (comma-separated list, first is original client)
8190 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
@@ -87,97 +96,63 @@ func getClientIP(r *http.Request) string {
8796 }
8897 return xff
8998 }
90-
99+
91100 // Check X-Real-IP header
92101 if xri := r.Header.Get("X-Real-IP"); xri != "" {
93102 return xri
94103 }
95-
104+
96105 // Check X-Forwarded header (less common)
97106 if xf := r.Header.Get("X-Forwarded"); xf != "" {
98107 return xf
99108 }
100-
109+
101110 // Fallback to RemoteAddr
102111 return r.RemoteAddr
103112 }
104113
105-// ContainerRequestLogger logs requests with container-specific information
106-func ContainerRequestLogger(containerID string, domain string) func(http.Handler) http.Handler {
114+// PanicRecovery middleware recovers from panics and logs them.
115+func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
107116 return func(next http.Handler) http.Handler {
108117 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109- start := time.Now()
110-
111- // Wrap the response writer to capture status and bytes
112- rw := NewResponseWriter(w)
113-
114- // Get client IP
115- clientIP := getClientIP(r)
116-
117- // Call the next handler
118- next.ServeHTTP(rw, r)
119-
120- // Calculate duration
121- duration := time.Since(start)
122-
123- // Log the request with container information using proxy logger
124- logging.ProxyLogger.Info().
125- Str("method", r.Method).
126- Str("path", r.URL.Path).
127- Str("query", r.URL.RawQuery).
128- Str("host", r.Host).
129- Str("domain", domain).
130- Str("container_id", containerID).
131- Str("user_agent", r.UserAgent()).
132- Str("referer", r.Referer()).
133- Str("client_ip", clientIP).
134- Int("status", rw.StatusCode()).
135- Int("bytes", rw.BytesWritten()).
136- Dur("duration", duration).
137- Str("proto", r.Proto).
138- Msg("Container Request")
118+ defer func() {
119+ if err := recover(); err != nil {
120+ log.Error().
121+ Str(zerowrap.FieldLayer, "adapter").
122+ Str(zerowrap.FieldAdapter, "http").
123+ Interface("panic", err).
124+ Str(zerowrap.FieldMethod, r.Method).
125+ Str(zerowrap.FieldPath, r.URL.Path).
126+ Str(zerowrap.FieldHost, r.Host).
127+ Msg("panic recovered")
128+
129+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
130+ }
131+ }()
132+
133+ next.ServeHTTP(w, r)
139134 })
140135 }
141136 }
142137
143-// PanicRecovery middleware recovers from panics and logs them
144-func PanicRecovery(next http.Handler) http.Handler {
145- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146- defer func() {
147- if err := recover(); err != nil {
148- log.Error().
149- Interface("panic", err).
150- Str("method", r.Method).
151- Str("path", r.URL.Path).
152- Str("host", r.Host).
153- Msg("Panic recovered")
154-
155- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
156- }
157- }()
158-
159- next.ServeHTTP(w, r)
160- })
161-}
162-
163-// CORS middleware adds CORS headers
138+// CORS middleware adds CORS headers.
164139 func CORS(next http.Handler) http.Handler {
165140 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
166141 w.Header().Set("Access-Control-Allow-Origin", "*")
167142 w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
168143 w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
169-
144+
170145 // Handle preflight OPTIONS request
171146 if r.Method == "OPTIONS" {
172147 w.WriteHeader(http.StatusOK)
173148 return
174149 }
175-
150+
176151 next.ServeHTTP(w, r)
177152 })
178153 }
179154
180-// Chain combines multiple middleware functions
155+// Chain combines multiple middleware functions.
181156 func Chain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
182157 return func(final http.Handler) http.Handler {
183158 for i := len(middlewares) - 1; i >= 0; i-- {
@@ -185,4 +160,4 @@ func Chain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) ht
185160 }
186161 return final
187162 }
188-}⏎̸
163+}
A internal/adapters/in/http/proxy/handler.go +72 -0
@@ -0,0 +1,72 @@
1+// Package proxy implements the HTTP adapter for the reverse proxy.
2+package proxy
3+
4+import (
5+ "context"
6+ "net/http"
7+ "strconv"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "gordon/internal/boundaries/in"
13+)
14+
15+// Handler wraps the proxy service for HTTP.
16+// The actual proxying logic is in the usecase layer (ProxyService implements http.Handler).
17+type Handler struct {
18+ proxySvc in.ProxyService
19+ log zerowrap.Logger
20+ port int
21+}
22+
23+// NewHandler creates a new proxy HTTP handler.
24+func NewHandler(proxySvc in.ProxyService, port int, log zerowrap.Logger) *Handler {
25+ return &Handler{
26+ proxySvc: proxySvc,
27+ port: port,
28+ log: log,
29+ }
30+}
31+
32+// ServeHTTP implements http.Handler by delegating to the proxy service.
33+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
34+ h.proxySvc.ServeHTTP(w, r)
35+}
36+
37+// Start starts the proxy HTTP server.
38+func (h *Handler) Start(ctx context.Context, handler http.Handler) error {
39+ addr := ":" + strconv.Itoa(h.port)
40+
41+ server := &http.Server{
42+ Addr: addr,
43+ Handler: handler,
44+ ReadHeaderTimeout: 10 * time.Second,
45+ }
46+
47+ h.log.Info().
48+ Str(zerowrap.FieldLayer, "adapter").
49+ Str(zerowrap.FieldAdapter, "http").
50+ Str("address", addr).
51+ Msg("proxy server starting")
52+
53+ // Start server in a goroutine
54+ errChan := make(chan error, 1)
55+ go func() {
56+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
57+ errChan <- err
58+ }
59+ }()
60+
61+ // Wait for context cancellation or server error
62+ select {
63+ case err := <-errChan:
64+ return err
65+ case <-ctx.Done():
66+ h.log.Info().
67+ Str(zerowrap.FieldLayer, "adapter").
68+ Str(zerowrap.FieldAdapter, "http").
69+ Msg("proxy server shutting down")
70+ return server.Shutdown(context.Background())
71+ }
72+}
R internal/registry/server.go → internal/adapters/in/http/registry/handler.go +136 -207 renamed, 50% similar
@@ -1,7 +1,7 @@
1+// Package registry implements the HTTP adapter for the registry API.
12 package registry
23
34 import (
4- "context"
55 "crypto/sha256"
66 "encoding/json"
77 "fmt"
@@ -10,131 +10,139 @@ import (
1010 "strconv"
1111 "strings"
1212
13- "gordon/internal/config"
14- "gordon/internal/events"
15- "gordon/internal/middleware"
16- "gordon/pkg/manifest"
13+ "github.com/bnema/zerowrap"
1714
18- "github.com/rs/zerolog/log"
15+ "gordon/internal/boundaries/in"
16+ "gordon/internal/boundaries/out"
17+ "gordon/internal/domain"
18+ "gordon/pkg/manifest"
1919 )
2020
21-type Server struct {
22- config *config.Config
23- mux *http.ServeMux
24- storage Storage
25- eventBus events.EventBus
21+// Handler implements the HTTP handler for Docker Registry API v2.
22+type Handler struct {
23+ registrySvc in.RegistryService
24+ blobStorage out.BlobStorage
25+ eventBus out.EventPublisher
26+ log zerowrap.Logger
2627 }
2728
28-func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) {
29- // Initialize storage
30- registryPath := cfg.Server.DataDir + "/registry"
31- log.Debug().Str("data_dir", cfg.Server.DataDir).Str("registry_path", registryPath).Msg("Initializing registry storage")
32-
33- storage, err := NewFilesystemStorage(registryPath)
34- if err != nil {
35- return nil, fmt.Errorf("failed to initialize storage: %w", err)
29+// NewHandler creates a new registry HTTP handler.
30+func NewHandler(
31+ registrySvc in.RegistryService,
32+ blobStorage out.BlobStorage,
33+ eventBus out.EventPublisher,
34+ log zerowrap.Logger,
35+) *Handler {
36+ return &Handler{
37+ registrySvc: registrySvc,
38+ blobStorage: blobStorage,
39+ eventBus: eventBus,
40+ log: log,
3641 }
42+}
3743
38- s := &Server{
39- config: cfg,
40- mux: http.NewServeMux(),
41- storage: storage,
42- eventBus: eventBus,
43- }
44- s.setupRoutes()
45- return s, nil
44+// RegisterRoutes registers the registry routes on the given mux.
45+func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
46+ mux.HandleFunc("/v2/", h.handleRegistryRoutes)
4647 }
4748
48-func (s *Server) setupRoutes() {
49- // Docker Registry API v2 endpoints
50- // Use traditional patterns since repository names can contain slashes
51- s.mux.HandleFunc("/v2/", s.handleRegistryRoutes)
49+// ServeHTTP implements http.Handler.
50+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
51+ h.handleRegistryRoutes(w, r)
5252 }
5353
54-func (s *Server) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
54+func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
55+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
56+ zerowrap.FieldLayer: "adapter",
57+ zerowrap.FieldAdapter: "http",
58+ zerowrap.FieldHandler: "registry",
59+ zerowrap.FieldMethod: r.Method,
60+ zerowrap.FieldPath: r.URL.Path,
61+ })
62+ r = r.WithContext(ctx)
63+
5564 path := r.URL.Path
56-
65+
5766 // Route manifest operations: /v2/{name}/manifests/{reference}
5867 if strings.Contains(path, "/manifests/") {
59- s.handleManifestRoutes(w, r)
68+ h.handleManifestRoutes(w, r)
6069 return
6170 }
62-
71+
6372 // Route blob operations: /v2/{name}/blobs/{digest}
6473 if strings.Contains(path, "/blobs/") && !strings.Contains(path, "/uploads/") {
65- s.handleBlobRoutes(w, r)
74+ h.handleBlobRoutes(w, r)
6675 return
6776 }
68-
77+
6978 // Route blob upload operations: /v2/{name}/blobs/uploads/
7079 if strings.Contains(path, "/blobs/uploads/") {
71- s.handleBlobUploadRoutes(w, r)
80+ h.handleBlobUploadRoutes(w, r)
7281 return
7382 }
74-
83+
7584 // Route tag list operations: /v2/{name}/tags/list
7685 if strings.Contains(path, "/tags/list") {
77- s.handleTagListRoutes(w, r)
86+ h.handleTagListRoutes(w, r)
7887 return
7988 }
80-
89+
8190 // Base endpoint: /v2/
8291 if path == "/v2/" {
83- s.handleBase(w, r)
92+ h.handleBase(w, r)
8493 return
8594 }
86-
95+
8796 http.NotFound(w, r)
8897 }
8998
90-func (s *Server) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
99+func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
91100 // Parse path: /v2/{name}/manifests/{reference}
92101 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
93102 if len(parts) < 3 || parts[len(parts)-2] != "manifests" {
94103 http.NotFound(w, r)
95104 return
96105 }
97-
106+
98107 reference := parts[len(parts)-1]
99108 name := strings.Join(parts[:len(parts)-2], "/")
100-
101- // Set path values for compatibility with existing handlers
109+
102110 r.SetPathValue("name", name)
103111 r.SetPathValue("reference", reference)
104-
112+
105113 switch r.Method {
106114 case "HEAD", "GET":
107- s.handleGetManifest(w, r)
115+ h.handleGetManifest(w, r)
108116 case "PUT":
109- s.handlePutManifest(w, r)
117+ h.handlePutManifest(w, r)
110118 default:
111119 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
112120 }
113121 }
114122
115-func (s *Server) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
123+func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
116124 // Parse path: /v2/{name}/blobs/{digest}
117125 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
118126 if len(parts) < 3 || parts[len(parts)-2] != "blobs" {
119127 http.NotFound(w, r)
120128 return
121129 }
122-
130+
123131 digest := parts[len(parts)-1]
124132 name := strings.Join(parts[:len(parts)-2], "/")
125-
133+
126134 r.SetPathValue("name", name)
127135 r.SetPathValue("digest", digest)
128-
136+
129137 switch r.Method {
130138 case "HEAD", "GET":
131- s.handleGetBlob(w, r)
139+ h.handleGetBlob(w, r)
132140 default:
133141 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
134142 }
135143 }
136144
137-func (s *Server) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) {
145+func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) {
138146 // Parse path: /v2/{name}/blobs/uploads/{uuid?}
139147 path := strings.TrimPrefix(r.URL.Path, "/v2/")
140148 uploadIndex := strings.Index(path, "/blobs/uploads/")
@@ -142,17 +150,17 @@ func (s *Server) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
142150 http.NotFound(w, r)
143151 return
144152 }
145-
153+
146154 name := path[:uploadIndex]
147155 uploadPart := path[uploadIndex+15:] // len("/blobs/uploads/") = 15
148-
156+
149157 r.SetPathValue("name", name)
150-
158+
151159 if uploadPart == "" {
152160 // POST /v2/{name}/blobs/uploads/
153161 switch r.Method {
154162 case "POST":
155- s.handleStartBlobUpload(w, r)
163+ h.handleStartBlobUpload(w, r)
156164 default:
157165 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
158166 }
@@ -161,73 +169,64 @@ func (s *Server) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
161169 r.SetPathValue("uuid", uploadPart)
162170 switch r.Method {
163171 case "PATCH", "PUT":
164- s.handleBlobUpload(w, r)
172+ h.handleBlobUpload(w, r)
165173 default:
166174 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
167175 }
168176 }
169177 }
170178
171-func (s *Server) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
179+func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
172180 // Parse path: /v2/{name}/tags/list
173181 path := strings.TrimPrefix(r.URL.Path, "/v2/")
174182 if !strings.HasSuffix(path, "/tags/list") {
175183 http.NotFound(w, r)
176184 return
177185 }
178-
186+
179187 name := strings.TrimSuffix(path, "/tags/list")
180188 r.SetPathValue("name", name)
181-
189+
182190 switch r.Method {
183191 case "GET":
184- s.handleListTags(w, r)
192+ h.handleListTags(w, r)
185193 default:
186194 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
187195 }
188196 }
189197
190-func (s *Server) handleBase(w http.ResponseWriter, r *http.Request) {
191- // For Docker clients, we need to respond with a 200 OK to this endpoint
192- // even if authentication is required. The client will then check the
193- // WWW-Authenticate header.
194- // see: https://docs.docker.com/registry/spec/api/#base
195- //
196- // Podman, as of v4.4.1, will not send credentials in the initial /v2/
197- // request. It expects a 401 Unauthorized to then send credentials.
198- //
199- // To support both, we'll check for the "Docker-Client" user agent.
200- // For now, we will just return a 200 OK to all clients.
198+func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) {
201199 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
202200 w.WriteHeader(http.StatusOK)
203201 }
204202
205-func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) {
203+func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
204+ ctx := r.Context()
205+ log := zerowrap.FromCtx(ctx)
206206 name := r.PathValue("name")
207207 reference := r.PathValue("reference")
208208
209209 log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
210210
211- manifest, contentType, err := s.storage.GetManifest(name, reference)
211+ manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
212212 if err != nil {
213- log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found")
213+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
214214 http.NotFound(w, r)
215215 return
216216 }
217217
218- log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("GET manifest - serving")
219-
220- // Return the appropriate content type based on what was stored
221- w.Header().Set("Content-Type", contentType)
222- w.Header().Set("Content-Length", strconv.Itoa(len(manifest)))
218+ w.Header().Set("Content-Type", manifestData.ContentType)
219+ w.Header().Set("Content-Length", strconv.Itoa(len(manifestData.Data)))
223220 w.WriteHeader(http.StatusOK)
224221
225222 if r.Method == "GET" {
226- _, _ = w.Write(manifest)
223+ _, _ = w.Write(manifestData.Data)
227224 }
228225 }
229226
230-func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
227+func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
228+ ctx := r.Context()
229+ log := zerowrap.FromCtx(ctx)
231230 name := r.PathValue("name")
232231 reference := r.PathValue("reference")
233232
@@ -235,7 +234,7 @@ func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
235234 log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
236235
237236 if contentType == "" {
238- log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing for PUT manifest")
237+ log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
239238 http.Error(w, "Content-Type header required", http.StatusBadRequest)
240239 return
241240 }
@@ -243,58 +242,51 @@ func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
243242 // Read manifest data
244243 data, err := io.ReadAll(r.Body)
245244 if err != nil {
246- log.Error().Err(err).Msg("Failed to read manifest data")
245+ log.Error().Err(err).Msg("failed to read manifest data")
247246 http.Error(w, "Bad Request", http.StatusBadRequest)
248247 return
249248 }
250249
251- // Store manifest
252- if err := s.storage.PutManifest(name, reference, contentType, data); err != nil {
253- log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest")
254- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
255- return
256- }
257-
258- // Calculate digest for Docker-Content-Digest header
259- digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
260- w.Header().Set("Docker-Content-Digest", digest)
261- w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
262- w.WriteHeader(http.StatusCreated)
263-
264250 // Parse manifest annotations
265251 annotations, err := manifest.ParseManifestAnnotations(data, contentType)
266252 if err != nil {
267- log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to parse manifest annotations, continuing without them")
253+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("failed to parse manifest annotations")
268254 annotations = map[string]string{}
269255 }
270256
271- // Log discovered annotations
272- if len(annotations) > 0 {
273- log.Info().
274- Str("name", name).
275- Str("reference", reference).
276- Interface("annotations", annotations).
277- Msg("Manifest annotations discovered")
278- }
279-
280- // Emit image pushed event
281- s.eventBus.Publish(events.ImagePushed, events.ImagePushedPayload{
257+ // Store manifest via service
258+ manifestObj := &domain.Manifest{
282259 Name: name,
283260 Reference: reference,
284- Manifest: data,
261+ ContentType: contentType,
262+ Data: data,
285263 Annotations: annotations,
286- })
264+ }
265+
266+ if err := h.registrySvc.PutManifest(ctx, manifestObj); err != nil {
267+ log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
268+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
269+ return
270+ }
271+
272+ // Calculate digest for Docker-Content-Digest header
273+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
274+ w.Header().Set("Docker-Content-Digest", digest)
275+ w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
276+ w.WriteHeader(http.StatusCreated)
287277 }
288278
289-func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
279+func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
280+ ctx := r.Context()
281+ log := zerowrap.FromCtx(ctx)
290282 name := r.PathValue("name")
291283 digest := r.PathValue("digest")
292284
293285 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
294286
295- path, err := s.storage.GetBlobPath(digest)
287+ path, err := h.blobStorage.GetBlobPath(digest)
296288 if err != nil {
297- log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("Blob not found")
289+ log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
298290 http.NotFound(w, r)
299291 return
300292 }
@@ -303,26 +295,28 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
303295 http.ServeFile(w, r, path)
304296 }
305297
306-func (s *Server) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
298+func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
299+ ctx := r.Context()
300+ log := zerowrap.FromCtx(ctx)
307301 name := r.PathValue("name")
308302
309- log.Debug().Str("name", name).Msg("Starting blob upload")
303+ log.Debug().Str("name", name).Msg("starting blob upload")
310304
311- // Generate a unique upload ID
312- uuid, err := s.storage.StartBlobUpload(name)
305+ uuid, err := h.registrySvc.StartUpload(ctx, name)
313306 if err != nil {
314- log.Error().Err(err).Msg("Failed to start blob upload")
307+ log.Error().Err(err).Msg("failed to start blob upload")
315308 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
316309 return
317310 }
318311
319- // Set headers for the response
320312 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
321- w.Header().Set("Range", "0-0") // Indicate that no bytes have been received yet
313+ w.Header().Set("Range", "0-0")
322314 w.WriteHeader(http.StatusAccepted)
323315 }
324316
325-func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
317+func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
318+ ctx := r.Context()
319+ log := zerowrap.FromCtx(ctx)
326320 name := r.PathValue("name")
327321 uuid := r.PathValue("uuid")
328322 digest := r.URL.Query().Get("digest")
@@ -331,36 +325,34 @@ func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
331325 Str("name", name).
332326 Str("uuid", uuid).
333327 Str("digest", digest).
334- Str("method", r.Method).
335- Msg("Handling blob upload chunk")
328+ Str(zerowrap.FieldMethod, r.Method).
329+ Msg("handling blob upload chunk")
336330
337331 // Read the chunk from the request body
338332 chunk, err := io.ReadAll(r.Body)
339333 if err != nil {
340- log.Error().Err(err).Msg("Failed to read blob chunk")
334+ log.Error().Err(err).Msg("failed to read blob chunk")
341335 http.Error(w, "Bad Request", http.StatusBadRequest)
342336 return
343337 }
344338
345339 // Append the chunk to the upload
346- length, err := s.storage.AppendBlobChunk(name, uuid, chunk)
340+ length, err := h.blobStorage.AppendBlobChunk(name, uuid, chunk)
347341 if err != nil {
348- log.Error().Err(err).Msg("Failed to append blob chunk")
342+ log.Error().Err(err).Msg("failed to append blob chunk")
349343 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
350344 return
351345 }
352346
353347 // If this is the final chunk (PUT request with digest), finalize the upload
354348 if r.Method == "PUT" && digest != "" {
355- if err := s.storage.FinishBlobUpload(uuid, digest); err != nil {
356- log.Error().Err(err).Str("digest", digest).Msg("Failed to finalize blob upload")
357- // The upload is invalid, delete it
358- _ = s.storage.CancelBlobUpload(uuid)
349+ if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
350+ log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
351+ _ = h.registrySvc.CancelUpload(ctx, uuid)
359352 http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest)
360353 return
361354 }
362355
363- // Respond with 201 Created
364356 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
365357 w.Header().Set("Docker-Content-Digest", digest)
366358 w.WriteHeader(http.StatusCreated)
@@ -373,26 +365,27 @@ func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
373365 w.WriteHeader(http.StatusAccepted)
374366 }
375367
376-func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
368+func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
369+ ctx := r.Context()
370+ log := zerowrap.FromCtx(ctx)
377371 name := r.PathValue("name")
378372
379- log.Debug().Str("name", name).Msg("Listing tags")
373+ log.Debug().Str("name", name).Msg("listing tags")
380374
381- tags, err := s.storage.ListTags(name)
375+ tags, err := h.registrySvc.ListTags(ctx, name)
382376 if err != nil {
383- log.Warn().Err(err).Str("name", name).Msg("Tag not found")
377+ log.Warn().Err(err).Str("name", name).Msg("tags not found")
384378 w.WriteHeader(http.StatusNotFound)
385379 return
386380 }
387381
388- // Construct the response JSON properly
389382 tagsJSON, err := json.Marshal(tags)
390383 if err != nil {
391- log.Error().Err(err).Str("name", name).Msg("Failed to marshal tags to JSON")
384+ log.Error().Err(err).Str("name", name).Msg("failed to marshal tags")
392385 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
393386 return
394387 }
395-
388+
396389 response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, string(tagsJSON))
397390
398391 w.Header().Set("Content-Type", "application/json")
@@ -400,67 +393,3 @@ func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
400393 w.WriteHeader(http.StatusOK)
401394 w.Write([]byte(response))
402395 }
403-
404-func (s *Server) Start(ctx context.Context) error {
405- addr := ":" + strconv.Itoa(s.config.Server.RegistryPort)
406-
407- // Build middleware chain
408- middlewares := []func(http.Handler) http.Handler{
409- middleware.PanicRecovery,
410- middleware.RequestLogger,
411- }
412-
413- // Add registry authentication if enabled
414- if s.config.RegistryAuth.Enabled {
415- middlewares = append(middlewares, middleware.RegistryAuth(s.config.RegistryAuth.Username, s.config.RegistryAuth.Password))
416- }
417-
418- // Wrap the mux with middleware
419- handler := middleware.Chain(middlewares...)(s.mux)
420-
421- server := &http.Server{
422- Addr: addr,
423- Handler: handler,
424- }
425-
426- log.Info().Str("address", addr).Msg("Registry server starting")
427-
428- // Start server in a goroutine
429- errChan := make(chan error, 1)
430- go func() {
431- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
432- errChan <- err
433- }
434- }()
435-
436- // Wait for context cancellation or server error
437- select {
438- case err := <-errChan:
439- return err
440- case <-ctx.Done():
441- log.Info().Msg("Registry server shutting down...")
442- return server.Shutdown(context.Background())
443- }
444-}
445-
446-// ServeHTTP implements http.Handler interface
447-func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
448- s.mux.ServeHTTP(w, r)
449-}
450-
451-// GetHandler returns the handler with middleware applied (for testing)
452-func (s *Server) GetHandler() http.Handler {
453- // Build middleware chain
454- middlewares := []func(http.Handler) http.Handler{
455- middleware.PanicRecovery,
456- middleware.RequestLogger,
457- }
458-
459- // Add registry authentication if enabled
460- if s.config.RegistryAuth.Enabled {
461- middlewares = append(middlewares, middleware.RegistryAuth(s.config.RegistryAuth.Username, s.config.RegistryAuth.Password))
462- }
463-
464- // Wrap the mux with middleware
465- return middleware.Chain(middlewares...)(s.mux)
466-}
A internal/adapters/in/http/registry/handler_test.go +444 -0
@@ -0,0 +1,444 @@
1+package registry
2+
3+import (
4+ "bytes"
5+ "net/http"
6+ "net/http/httptest"
7+ "os"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+
14+ inmocks "gordon/internal/boundaries/in/mocks"
15+ "gordon/internal/boundaries/out/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func testLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func TestHandler_Base(t *testing.T) {
24+ registrySvc := inmocks.NewMockRegistryService(t)
25+ blobStorage := mocks.NewMockBlobStorage(t)
26+ eventBus := mocks.NewMockEventPublisher(t)
27+
28+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
29+
30+ req := httptest.NewRequest("GET", "/v2/", nil)
31+ rec := httptest.NewRecorder()
32+
33+ handler.ServeHTTP(rec, req)
34+
35+ assert.Equal(t, http.StatusOK, rec.Code)
36+ assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
37+}
38+
39+func TestHandler_NotFound(t *testing.T) {
40+ registrySvc := inmocks.NewMockRegistryService(t)
41+ blobStorage := mocks.NewMockBlobStorage(t)
42+ eventBus := mocks.NewMockEventPublisher(t)
43+
44+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
45+
46+ req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
47+ rec := httptest.NewRecorder()
48+
49+ handler.ServeHTTP(rec, req)
50+
51+ assert.Equal(t, http.StatusNotFound, rec.Code)
52+}
53+
54+func TestHandler_GetManifest_Success(t *testing.T) {
55+ registrySvc := inmocks.NewMockRegistryService(t)
56+ blobStorage := mocks.NewMockBlobStorage(t)
57+ eventBus := mocks.NewMockEventPublisher(t)
58+
59+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
60+
61+ manifestData := []byte(`{"schemaVersion": 2}`)
62+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
63+ Name: "myapp",
64+ Reference: "latest",
65+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
66+ Data: manifestData,
67+ }, nil)
68+
69+ req := httptest.NewRequest("GET", "/v2/myapp/manifests/latest", nil)
70+ rec := httptest.NewRecorder()
71+
72+ handler.ServeHTTP(rec, req)
73+
74+ assert.Equal(t, http.StatusOK, rec.Code)
75+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
76+ assert.Equal(t, manifestData, rec.Body.Bytes())
77+}
78+
79+func TestHandler_GetManifest_HEAD(t *testing.T) {
80+ registrySvc := inmocks.NewMockRegistryService(t)
81+ blobStorage := mocks.NewMockBlobStorage(t)
82+ eventBus := mocks.NewMockEventPublisher(t)
83+
84+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
85+
86+ manifestData := []byte(`{"schemaVersion": 2}`)
87+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
88+ Name: "myapp",
89+ Reference: "latest",
90+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
91+ Data: manifestData,
92+ }, nil)
93+
94+ req := httptest.NewRequest("HEAD", "/v2/myapp/manifests/latest", nil)
95+ rec := httptest.NewRecorder()
96+
97+ handler.ServeHTTP(rec, req)
98+
99+ assert.Equal(t, http.StatusOK, rec.Code)
100+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
101+ // HEAD should not return body
102+ assert.Empty(t, rec.Body.Bytes())
103+}
104+
105+func TestHandler_GetManifest_NotFound(t *testing.T) {
106+ registrySvc := inmocks.NewMockRegistryService(t)
107+ blobStorage := mocks.NewMockBlobStorage(t)
108+ eventBus := mocks.NewMockEventPublisher(t)
109+
110+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
111+
112+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
113+
114+ req := httptest.NewRequest("GET", "/v2/myapp/manifests/notexists", nil)
115+ rec := httptest.NewRecorder()
116+
117+ handler.ServeHTTP(rec, req)
118+
119+ assert.Equal(t, http.StatusNotFound, rec.Code)
120+}
121+
122+func TestHandler_GetManifest_NestedName(t *testing.T) {
123+ registrySvc := inmocks.NewMockRegistryService(t)
124+ blobStorage := mocks.NewMockBlobStorage(t)
125+ eventBus := mocks.NewMockEventPublisher(t)
126+
127+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
128+
129+ manifestData := []byte(`{"schemaVersion": 2}`)
130+ registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
131+ Name: "org/project/app",
132+ Reference: "v1.0",
133+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
134+ Data: manifestData,
135+ }, nil)
136+
137+ req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
138+ rec := httptest.NewRecorder()
139+
140+ handler.ServeHTTP(rec, req)
141+
142+ assert.Equal(t, http.StatusOK, rec.Code)
143+}
144+
145+func TestHandler_PutManifest_Success(t *testing.T) {
146+ registrySvc := inmocks.NewMockRegistryService(t)
147+ blobStorage := mocks.NewMockBlobStorage(t)
148+ eventBus := mocks.NewMockEventPublisher(t)
149+
150+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
151+
152+ manifestData := []byte(`{"schemaVersion": 2}`)
153+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
154+ return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
155+ })).Return(nil)
156+
157+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
158+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
159+ rec := httptest.NewRecorder()
160+
161+ handler.ServeHTTP(rec, req)
162+
163+ assert.Equal(t, http.StatusCreated, rec.Code)
164+ assert.NotEmpty(t, rec.Header().Get("Docker-Content-Digest"))
165+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
166+}
167+
168+func TestHandler_PutManifest_MissingContentType(t *testing.T) {
169+ registrySvc := inmocks.NewMockRegistryService(t)
170+ blobStorage := mocks.NewMockBlobStorage(t)
171+ eventBus := mocks.NewMockEventPublisher(t)
172+
173+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
174+
175+ manifestData := []byte(`{"schemaVersion": 2}`)
176+
177+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
178+ // No Content-Type header
179+ rec := httptest.NewRecorder()
180+
181+ handler.ServeHTTP(rec, req)
182+
183+ assert.Equal(t, http.StatusBadRequest, rec.Code)
184+}
185+
186+func TestHandler_PutManifest_StorageError(t *testing.T) {
187+ registrySvc := inmocks.NewMockRegistryService(t)
188+ blobStorage := mocks.NewMockBlobStorage(t)
189+ eventBus := mocks.NewMockEventPublisher(t)
190+
191+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
192+
193+ manifestData := []byte(`{"schemaVersion": 2}`)
194+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return(assert.AnError)
195+
196+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
197+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
198+ rec := httptest.NewRecorder()
199+
200+ handler.ServeHTTP(rec, req)
201+
202+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
203+}
204+
205+func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
206+ registrySvc := inmocks.NewMockRegistryService(t)
207+ blobStorage := mocks.NewMockBlobStorage(t)
208+ eventBus := mocks.NewMockEventPublisher(t)
209+
210+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
211+
212+ req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
213+ rec := httptest.NewRecorder()
214+
215+ handler.ServeHTTP(rec, req)
216+
217+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
218+}
219+
220+func TestHandler_GetBlob_Success(t *testing.T) {
221+ registrySvc := inmocks.NewMockRegistryService(t)
222+ blobStorage := mocks.NewMockBlobStorage(t)
223+ eventBus := mocks.NewMockEventPublisher(t)
224+
225+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
226+
227+ // Create a temp file for http.ServeFile
228+ tmpFile, err := os.CreateTemp("", "blob-*")
229+ assert.NoError(t, err)
230+ defer tmpFile.Close()
231+
232+ blobContent := []byte("blob content here")
233+ _, err = tmpFile.Write(blobContent)
234+ assert.NoError(t, err)
235+
236+ blobStorage.EXPECT().GetBlobPath("sha256:abc123").Return(tmpFile.Name(), nil)
237+
238+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:abc123", nil)
239+ rec := httptest.NewRecorder()
240+
241+ handler.ServeHTTP(rec, req)
242+
243+ assert.Equal(t, http.StatusOK, rec.Code)
244+ assert.Equal(t, blobContent, rec.Body.Bytes())
245+}
246+
247+func TestHandler_GetBlob_NotFound(t *testing.T) {
248+ registrySvc := inmocks.NewMockRegistryService(t)
249+ blobStorage := mocks.NewMockBlobStorage(t)
250+ eventBus := mocks.NewMockEventPublisher(t)
251+
252+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
253+
254+ blobStorage.EXPECT().GetBlobPath("sha256:notexists").Return("", assert.AnError)
255+
256+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:notexists", nil)
257+ rec := httptest.NewRecorder()
258+
259+ handler.ServeHTTP(rec, req)
260+
261+ assert.Equal(t, http.StatusNotFound, rec.Code)
262+}
263+
264+func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
265+ registrySvc := inmocks.NewMockRegistryService(t)
266+ blobStorage := mocks.NewMockBlobStorage(t)
267+ eventBus := mocks.NewMockEventPublisher(t)
268+
269+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
270+
271+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:abc123", nil)
272+ rec := httptest.NewRecorder()
273+
274+ handler.ServeHTTP(rec, req)
275+
276+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
277+}
278+
279+func TestHandler_StartBlobUpload_Success(t *testing.T) {
280+ registrySvc := inmocks.NewMockRegistryService(t)
281+ blobStorage := mocks.NewMockBlobStorage(t)
282+ eventBus := mocks.NewMockEventPublisher(t)
283+
284+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
285+
286+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("upload-uuid-123", nil)
287+
288+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
289+ rec := httptest.NewRecorder()
290+
291+ handler.ServeHTTP(rec, req)
292+
293+ assert.Equal(t, http.StatusAccepted, rec.Code)
294+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/upload-uuid-123")
295+ assert.Equal(t, "0-0", rec.Header().Get("Range"))
296+}
297+
298+func TestHandler_StartBlobUpload_Error(t *testing.T) {
299+ registrySvc := inmocks.NewMockRegistryService(t)
300+ blobStorage := mocks.NewMockBlobStorage(t)
301+ eventBus := mocks.NewMockEventPublisher(t)
302+
303+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
304+
305+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
306+
307+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
308+ rec := httptest.NewRecorder()
309+
310+ handler.ServeHTTP(rec, req)
311+
312+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
313+}
314+
315+func TestHandler_BlobUpload_PATCH(t *testing.T) {
316+ registrySvc := inmocks.NewMockRegistryService(t)
317+ blobStorage := mocks.NewMockBlobStorage(t)
318+ eventBus := mocks.NewMockEventPublisher(t)
319+
320+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
321+
322+ chunkData := []byte("chunk content")
323+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
324+
325+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/upload-uuid-123", bytes.NewReader(chunkData))
326+ rec := httptest.NewRecorder()
327+
328+ handler.ServeHTTP(rec, req)
329+
330+ assert.Equal(t, http.StatusAccepted, rec.Code)
331+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/upload-uuid-123")
332+}
333+
334+func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
335+ registrySvc := inmocks.NewMockRegistryService(t)
336+ blobStorage := mocks.NewMockBlobStorage(t)
337+ eventBus := mocks.NewMockEventPublisher(t)
338+
339+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
340+
341+ chunkData := []byte("final chunk")
342+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
343+ registrySvc.EXPECT().FinishUpload(mock.Anything, "upload-uuid-123", "sha256:abc123").Return(nil)
344+
345+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/upload-uuid-123?digest=sha256:abc123", bytes.NewReader(chunkData))
346+ rec := httptest.NewRecorder()
347+
348+ handler.ServeHTTP(rec, req)
349+
350+ assert.Equal(t, http.StatusCreated, rec.Code)
351+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/sha256:abc123")
352+ assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
353+}
354+
355+func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
356+ registrySvc := inmocks.NewMockRegistryService(t)
357+ blobStorage := mocks.NewMockBlobStorage(t)
358+ eventBus := mocks.NewMockEventPublisher(t)
359+
360+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
361+
362+ chunkData := []byte("final chunk")
363+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
364+ registrySvc.EXPECT().FinishUpload(mock.Anything, "upload-uuid-123", "sha256:wrong").Return(assert.AnError)
365+ registrySvc.EXPECT().CancelUpload(mock.Anything, "upload-uuid-123").Return(nil)
366+
367+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/upload-uuid-123?digest=sha256:wrong", bytes.NewReader(chunkData))
368+ rec := httptest.NewRecorder()
369+
370+ handler.ServeHTTP(rec, req)
371+
372+ assert.Equal(t, http.StatusBadRequest, rec.Code)
373+}
374+
375+func TestHandler_ListTags_Success(t *testing.T) {
376+ registrySvc := inmocks.NewMockRegistryService(t)
377+ blobStorage := mocks.NewMockBlobStorage(t)
378+ eventBus := mocks.NewMockEventPublisher(t)
379+
380+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
381+
382+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
383+
384+ req := httptest.NewRequest("GET", "/v2/myapp/tags/list", nil)
385+ rec := httptest.NewRecorder()
386+
387+ handler.ServeHTTP(rec, req)
388+
389+ assert.Equal(t, http.StatusOK, rec.Code)
390+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
391+ assert.Contains(t, rec.Body.String(), "myapp")
392+ assert.Contains(t, rec.Body.String(), "latest")
393+}
394+
395+func TestHandler_ListTags_NotFound(t *testing.T) {
396+ registrySvc := inmocks.NewMockRegistryService(t)
397+ blobStorage := mocks.NewMockBlobStorage(t)
398+ eventBus := mocks.NewMockEventPublisher(t)
399+
400+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
401+
402+ registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
403+
404+ req := httptest.NewRequest("GET", "/v2/notexists/tags/list", nil)
405+ rec := httptest.NewRecorder()
406+
407+ handler.ServeHTTP(rec, req)
408+
409+ assert.Equal(t, http.StatusNotFound, rec.Code)
410+}
411+
412+func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
413+ registrySvc := inmocks.NewMockRegistryService(t)
414+ blobStorage := mocks.NewMockBlobStorage(t)
415+ eventBus := mocks.NewMockEventPublisher(t)
416+
417+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
418+
419+ req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
420+ rec := httptest.NewRecorder()
421+
422+ handler.ServeHTTP(rec, req)
423+
424+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
425+}
426+
427+func TestHandler_RegisterRoutes(t *testing.T) {
428+ registrySvc := inmocks.NewMockRegistryService(t)
429+ blobStorage := mocks.NewMockBlobStorage(t)
430+ eventBus := mocks.NewMockEventPublisher(t)
431+
432+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
433+
434+ mux := http.NewServeMux()
435+ handler.RegisterRoutes(mux)
436+
437+ // Verify routes are registered by making a request through the mux
438+ req := httptest.NewRequest("GET", "/v2/", nil)
439+ rec := httptest.NewRecorder()
440+
441+ mux.ServeHTTP(rec, req)
442+
443+ assert.Equal(t, http.StatusOK, rec.Code)
444+}
A internal/adapters/in/http/registry/token.go +170 -0
@@ -0,0 +1,170 @@
1+package registry
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "gordon/internal/boundaries/in"
11+ "gordon/internal/domain"
12+)
13+
14+// TokenResponse represents the response from the token server.
15+type TokenResponse struct {
16+ Token string `json:"token"`
17+ AccessToken string `json:"access_token,omitempty"`
18+ ExpiresIn int `json:"expires_in,omitempty"`
19+ IssuedAt string `json:"issued_at,omitempty"`
20+}
21+
22+// InternalAuth holds credentials for internal loopback registry access.
23+type InternalAuth struct {
24+ Username string
25+ Password string
26+}
27+
28+// TokenHandler handles token server requests for Docker Registry authentication.
29+type TokenHandler struct {
30+ authSvc in.AuthService
31+ internalAuth InternalAuth
32+ log zerowrap.Logger
33+}
34+
35+// NewTokenHandler creates a new token handler.
36+func NewTokenHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *TokenHandler {
37+ return &TokenHandler{
38+ authSvc: authSvc,
39+ internalAuth: internalAuth,
40+ log: log,
41+ }
42+}
43+
44+// ServeHTTP implements http.Handler for the token endpoint.
45+func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
46+ h.handleToken(w, r)
47+}
48+
49+// handleToken handles GET /v2/token requests.
50+// This endpoint issues short-lived access tokens per the Docker Registry auth spec.
51+func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
52+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
53+ zerowrap.FieldLayer: "adapter",
54+ zerowrap.FieldAdapter: "http",
55+ zerowrap.FieldHandler: "token",
56+ zerowrap.FieldMethod: r.Method,
57+ zerowrap.FieldPath: r.URL.Path,
58+ })
59+ log := zerowrap.FromCtx(ctx)
60+
61+ if r.Method != http.MethodGet {
62+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
63+ return
64+ }
65+
66+ // Check if auth is enabled
67+ if h.authSvc == nil || !h.authSvc.IsEnabled() {
68+ // If auth is disabled, return an anonymous token
69+ h.sendAnonymousToken(w, log)
70+ return
71+ }
72+
73+ // Get credentials from Basic Auth
74+ username, password, ok := r.BasicAuth()
75+ if !ok {
76+ // No credentials provided - return anonymous token with limited scope
77+ h.sendAnonymousToken(w, log)
78+ return
79+ }
80+
81+ // Check for internal registry auth (localhost requests with internal creds)
82+ var authenticated bool
83+ if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
84+ authenticated = true
85+ log.Debug().Str("username", username).Msg("internal registry auth accepted")
86+ } else {
87+ // Validate credentials based on auth type
88+ switch h.authSvc.GetAuthType() {
89+ case domain.AuthTypePassword:
90+ authenticated = h.authSvc.ValidatePassword(ctx, username, password)
91+ case domain.AuthTypeToken:
92+ // For token auth, the password might be an existing JWT
93+ claims, err := h.authSvc.ValidateToken(ctx, password)
94+ if err == nil && claims.Subject == username {
95+ authenticated = true
96+ }
97+ }
98+ }
99+
100+ if !authenticated {
101+ log.Debug().
102+ Str("username", username).
103+ Msg("token request authentication failed")
104+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
105+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
106+ return
107+ }
108+
109+ // Generate a short-lived access token (5 minutes)
110+ accessToken, err := h.authSvc.GenerateToken(ctx, username, []string{"push", "pull"}, 5*time.Minute)
111+ if err != nil {
112+ log.Error().Err(err).Msg("failed to generate access token")
113+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
114+ return
115+ }
116+
117+ response := TokenResponse{
118+ Token: accessToken,
119+ ExpiresIn: 300, // 5 minutes in seconds
120+ IssuedAt: time.Now().Format(time.RFC3339),
121+ }
122+
123+ w.Header().Set("Content-Type", "application/json")
124+ w.WriteHeader(http.StatusOK)
125+
126+ if err := json.NewEncoder(w).Encode(response); err != nil {
127+ log.Error().Err(err).Msg("failed to encode token response")
128+ }
129+
130+ log.Debug().
131+ Str("username", username).
132+ Int("expires_in", response.ExpiresIn).
133+ Msg("access token issued")
134+}
135+
136+// sendAnonymousToken sends a token for anonymous/unauthenticated access.
137+func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
138+ // For anonymous access, we issue a very short-lived token with limited scope
139+ response := TokenResponse{
140+ Token: "", // Empty token indicates limited access
141+ ExpiresIn: 60, // 1 minute
142+ IssuedAt: time.Now().Format(time.RFC3339),
143+ }
144+
145+ w.Header().Set("Content-Type", "application/json")
146+ w.WriteHeader(http.StatusOK)
147+
148+ if err := json.NewEncoder(w).Encode(response); err != nil {
149+ log.Error().Err(err).Msg("failed to encode anonymous token response")
150+ }
151+
152+ log.Debug().Msg("anonymous token issued")
153+}
154+
155+// isLocalhostRequest checks if the request is from localhost.
156+func isLocalhostRequest(r *http.Request) bool {
157+ host := r.Host
158+ if host == "" {
159+ host = r.URL.Host
160+ }
161+ return host == "localhost:5000" || host == "127.0.0.1:5000" || host == "[::1]:5000"
162+}
163+
164+// isInternalAuth checks if the credentials match internal registry auth.
165+func (h *TokenHandler) isInternalAuth(username, password string) bool {
166+ if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
167+ return false
168+ }
169+ return username == h.internalAuth.Username && password == h.internalAuth.Password
170+}
A internal/adapters/out/docker/runtime.go +1020 -0
@@ -0,0 +1,1020 @@
1+// Package docker implements the container runtime adapter using Docker API.
2+package docker
3+
4+import (
5+ "context"
6+ "encoding/base64"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "strconv"
11+ "strings"
12+
13+ "github.com/bnema/zerowrap"
14+ cerrdefs "github.com/containerd/errdefs"
15+ "github.com/docker/docker/api/types/container"
16+ "github.com/docker/docker/api/types/image"
17+ "github.com/docker/docker/api/types/network"
18+ "github.com/docker/docker/api/types/registry"
19+ "github.com/docker/docker/api/types/volume"
20+ "github.com/docker/docker/client"
21+ "github.com/docker/go-connections/nat"
22+
23+ "gordon/internal/domain"
24+)
25+
26+// Runtime implements the ContainerRuntime interface using Docker API.
27+type Runtime struct {
28+ client *client.Client
29+}
30+
31+// NewRuntime creates a new Docker runtime instance.
32+func NewRuntime() (*Runtime, error) {
33+ cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
34+ if err != nil {
35+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
36+ }
37+
38+ return &Runtime{
39+ client: cli,
40+ }, nil
41+}
42+
43+// NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
44+func NewRuntimeWithClient(cli *client.Client) *Runtime {
45+ return &Runtime{
46+ client: cli,
47+ }
48+}
49+
50+// CreateContainer creates a new container.
51+func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) {
52+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
53+ zerowrap.FieldLayer: "adapter",
54+ zerowrap.FieldAdapter: "docker",
55+ zerowrap.FieldAction: "CreateContainer",
56+ "container_name": config.Name,
57+ "image": config.Image,
58+ })
59+ log := zerowrap.FromCtx(ctx)
60+
61+ // Convert ports to Docker format
62+ exposedPorts := make(nat.PortSet)
63+ portBindings := make(nat.PortMap)
64+
65+ for _, port := range config.Ports {
66+ containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
67+ exposedPorts[containerPort] = struct{}{}
68+
69+ // Bind to random available port on host
70+ portBindings[containerPort] = []nat.PortBinding{
71+ {
72+ HostIP: "0.0.0.0",
73+ HostPort: "0", // Docker will assign a random available port
74+ },
75+ }
76+ }
77+
78+ // Convert volumes to Docker format
79+ var binds []string
80+ if config.Volumes != nil {
81+ for containerPath, volumeName := range config.Volumes {
82+ bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
83+ binds = append(binds, bind)
84+ log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
85+ }
86+ }
87+
88+ // Create container configuration
89+ containerConfig := &container.Config{
90+ Image: config.Image,
91+ Hostname: config.Hostname,
92+ Env: config.Env,
93+ ExposedPorts: exposedPorts,
94+ WorkingDir: config.WorkingDir,
95+ Cmd: config.Cmd,
96+ Labels: config.Labels,
97+ }
98+
99+ hostConfig := &container.HostConfig{
100+ PortBindings: portBindings,
101+ AutoRemove: config.AutoRemove,
102+ Binds: binds,
103+ NetworkMode: container.NetworkMode(config.NetworkMode),
104+ }
105+
106+ // Create network configuration for container
107+ var networkConfig *network.NetworkingConfig
108+ if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
109+ networkConfig = &network.NetworkingConfig{
110+ EndpointsConfig: map[string]*network.EndpointSettings{
111+ config.NetworkMode: {
112+ Aliases: config.Aliases,
113+ },
114+ },
115+ }
116+ if config.Hostname != "" {
117+ networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
118+ }
119+ }
120+
121+ // Create the container
122+ resp, err := r.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
123+ if err != nil {
124+ return nil, log.WrapErr(err, "failed to create container")
125+ }
126+
127+ log.Info().Str(zerowrap.FieldEntityID, resp.ID).Msg("container created")
128+
129+ // Inspect the created container to get full details
130+ return r.InspectContainer(ctx, resp.ID)
131+}
132+
133+// StartContainer starts a container.
134+func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
135+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
136+ zerowrap.FieldLayer: "adapter",
137+ zerowrap.FieldAdapter: "docker",
138+ zerowrap.FieldAction: "StartContainer",
139+ zerowrap.FieldEntityID: containerID,
140+ })
141+ log := zerowrap.FromCtx(ctx)
142+
143+ err := r.client.ContainerStart(ctx, containerID, container.StartOptions{})
144+ if err != nil {
145+ return log.WrapErr(err, "failed to start container")
146+ }
147+
148+ log.Info().Msg("container started")
149+ return nil
150+}
151+
152+// StopContainer stops a container.
153+func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
154+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
155+ zerowrap.FieldLayer: "adapter",
156+ zerowrap.FieldAdapter: "docker",
157+ zerowrap.FieldAction: "StopContainer",
158+ zerowrap.FieldEntityID: containerID,
159+ })
160+ log := zerowrap.FromCtx(ctx)
161+
162+ timeout := 30 // 30 seconds timeout
163+ err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
164+ if err != nil {
165+ return log.WrapErr(err, "failed to stop container")
166+ }
167+
168+ log.Info().Msg("container stopped")
169+ return nil
170+}
171+
172+// RestartContainer restarts a container.
173+func (r *Runtime) RestartContainer(ctx context.Context, containerID string) error {
174+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
175+ zerowrap.FieldLayer: "adapter",
176+ zerowrap.FieldAdapter: "docker",
177+ zerowrap.FieldAction: "RestartContainer",
178+ zerowrap.FieldEntityID: containerID,
179+ })
180+ log := zerowrap.FromCtx(ctx)
181+
182+ timeout := 30 // 30 seconds timeout
183+ err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
184+ if err != nil {
185+ return log.WrapErr(err, "failed to restart container")
186+ }
187+
188+ log.Info().Msg("container restarted")
189+ return nil
190+}
191+
192+// RemoveContainer removes a container.
193+func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
194+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
195+ zerowrap.FieldLayer: "adapter",
196+ zerowrap.FieldAdapter: "docker",
197+ zerowrap.FieldAction: "RemoveContainer",
198+ zerowrap.FieldEntityID: containerID,
199+ "force": force,
200+ })
201+ log := zerowrap.FromCtx(ctx)
202+
203+ err := r.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
204+ if err != nil {
205+ return log.WrapErr(err, "failed to remove container")
206+ }
207+
208+ log.Info().Msg("container removed")
209+ return nil
210+}
211+
212+// RenameContainer renames a container.
213+func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName string) error {
214+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
215+ zerowrap.FieldLayer: "adapter",
216+ zerowrap.FieldAdapter: "docker",
217+ zerowrap.FieldAction: "RenameContainer",
218+ zerowrap.FieldEntityID: containerID,
219+ "new_name": newName,
220+ })
221+ log := zerowrap.FromCtx(ctx)
222+
223+ err := r.client.ContainerRename(ctx, containerID, newName)
224+ if err != nil {
225+ return log.WrapErr(err, "failed to rename container")
226+ }
227+
228+ log.Info().Msg("container renamed")
229+ return nil
230+}
231+
232+// ListContainers lists containers.
233+func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
234+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
235+ zerowrap.FieldLayer: "adapter",
236+ zerowrap.FieldAdapter: "docker",
237+ zerowrap.FieldAction: "ListContainers",
238+ "all": all,
239+ })
240+ log := zerowrap.FromCtx(ctx)
241+
242+ containers, err := r.client.ContainerList(ctx, container.ListOptions{All: all})
243+ if err != nil {
244+ return nil, log.WrapErr(err, "failed to list containers")
245+ }
246+
247+ var result []*domain.Container
248+ for _, c := range containers {
249+ // Extract ports
250+ var ports []int
251+ for _, port := range c.Ports {
252+ if port.PublicPort > 0 {
253+ ports = append(ports, int(port.PublicPort))
254+ }
255+ }
256+
257+ // Get the primary name (remove leading slash)
258+ name := ""
259+ if len(c.Names) > 0 {
260+ name = strings.TrimPrefix(c.Names[0], "/")
261+ }
262+
263+ result = append(result, &domain.Container{
264+ ID: c.ID,
265+ Image: c.Image,
266+ Name: name,
267+ Status: c.Status,
268+ Ports: ports,
269+ Labels: c.Labels,
270+ })
271+ }
272+
273+ return result, nil
274+}
275+
276+// InspectContainer inspects a container.
277+func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*domain.Container, error) {
278+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
279+ zerowrap.FieldLayer: "adapter",
280+ zerowrap.FieldAdapter: "docker",
281+ zerowrap.FieldAction: "InspectContainer",
282+ zerowrap.FieldEntityID: containerID,
283+ })
284+ log := zerowrap.FromCtx(ctx)
285+
286+ resp, err := r.client.ContainerInspect(ctx, containerID)
287+ if err != nil {
288+ return nil, log.WrapErr(err, "failed to inspect container")
289+ }
290+
291+ // Extract published ports
292+ var ports []int
293+ if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
294+ for _, bindings := range resp.NetworkSettings.Ports {
295+ for _, binding := range bindings {
296+ if binding.HostPort != "" {
297+ if port, err := strconv.Atoi(binding.HostPort); err == nil {
298+ ports = append(ports, port)
299+ }
300+ }
301+ }
302+ }
303+ }
304+
305+ // Get container name (remove leading slash)
306+ name := strings.TrimPrefix(resp.Name, "/")
307+
308+ return &domain.Container{
309+ ID: resp.ID,
310+ Image: resp.Config.Image,
311+ Name: name,
312+ Status: resp.State.Status,
313+ Ports: ports,
314+ Labels: resp.Config.Labels,
315+ }, nil
316+}
317+
318+// GetContainerLogs gets container logs.
319+func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
320+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
321+ zerowrap.FieldLayer: "adapter",
322+ zerowrap.FieldAdapter: "docker",
323+ zerowrap.FieldAction: "GetContainerLogs",
324+ zerowrap.FieldEntityID: containerID,
325+ })
326+ log := zerowrap.FromCtx(ctx)
327+
328+ logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{
329+ ShowStdout: true,
330+ ShowStderr: true,
331+ Follow: follow,
332+ Timestamps: true,
333+ })
334+ if err != nil {
335+ return nil, log.WrapErr(err, "failed to get container logs")
336+ }
337+
338+ return logs, nil
339+}
340+
341+// PullImage pulls an image.
342+func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
343+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
344+ zerowrap.FieldLayer: "adapter",
345+ zerowrap.FieldAdapter: "docker",
346+ zerowrap.FieldAction: "PullImage",
347+ "image": imageRef,
348+ })
349+ log := zerowrap.FromCtx(ctx)
350+
351+ log.Info().Msg("pulling image")
352+
353+ reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})
354+ if err != nil {
355+ return log.WrapErr(err, "failed to pull image")
356+ }
357+ defer reader.Close()
358+
359+ // Read the response to completion (this is required for the pull to complete)
360+ _, err = io.Copy(io.Discard, reader)
361+ if err != nil {
362+ return log.WrapErr(err, "failed to read pull response")
363+ }
364+
365+ log.Info().Msg("image pulled successfully")
366+ return nil
367+}
368+
369+// PullImageWithAuth pulls an image with authentication.
370+func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
371+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
372+ zerowrap.FieldLayer: "adapter",
373+ zerowrap.FieldAdapter: "docker",
374+ zerowrap.FieldAction: "PullImageWithAuth",
375+ "image": imageRef,
376+ "username": username,
377+ })
378+ log := zerowrap.FromCtx(ctx)
379+
380+ log.Info().Msg("pulling image with authentication")
381+
382+ // Extract registry server address from image reference
383+ // e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev"
384+ serverAddress := imageRef
385+ if idx := strings.Index(imageRef, "/"); idx > 0 {
386+ serverAddress = imageRef[:idx]
387+ }
388+
389+ // Create authentication configuration
390+ authConfig := registry.AuthConfig{
391+ Username: username,
392+ Password: password,
393+ ServerAddress: serverAddress,
394+ }
395+
396+ // Encode authentication to base64 JSON
397+ // Note: Docker API accepts both StdEncoding and URLEncoding, but Podman
398+ // may be more strict. Using StdEncoding for maximum compatibility.
399+ authConfigBytes, err := json.Marshal(authConfig)
400+ if err != nil {
401+ return log.WrapErr(err, "failed to marshal auth config")
402+ }
403+ authStr := base64.StdEncoding.EncodeToString(authConfigBytes)
404+
405+ log.Debug().
406+ Str("server_address", serverAddress).
407+ Str("auth_json", string(authConfigBytes)).
408+ Msg("auth config for pull")
409+
410+ // Pull with authentication
411+ reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{
412+ RegistryAuth: authStr,
413+ })
414+ if err != nil {
415+ return log.WrapErr(err, "failed to pull image with auth")
416+ }
417+ defer reader.Close()
418+
419+ // Read the response to completion (this is required for the pull to complete)
420+ _, err = io.Copy(io.Discard, reader)
421+ if err != nil {
422+ return log.WrapErr(err, "failed to read pull response")
423+ }
424+
425+ log.Info().Msg("image pulled successfully with authentication")
426+ return nil
427+}
428+
429+// TagImage tags a locally available image with a new reference.
430+func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error {
431+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
432+ zerowrap.FieldLayer: "adapter",
433+ zerowrap.FieldAdapter: "docker",
434+ zerowrap.FieldAction: "TagImage",
435+ "source": sourceRef,
436+ "target": targetRef,
437+ })
438+ log := zerowrap.FromCtx(ctx)
439+
440+ if err := r.client.ImageTag(ctx, sourceRef, targetRef); err != nil {
441+ return log.WrapErr(err, "failed to tag image")
442+ }
443+
444+ log.Info().Msg("image tagged successfully")
445+ return nil
446+}
447+
448+// UntagImage removes a tag from an image without deleting the underlying image layers.
449+func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error {
450+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
451+ zerowrap.FieldLayer: "adapter",
452+ zerowrap.FieldAdapter: "docker",
453+ zerowrap.FieldAction: "UntagImage",
454+ "image": imageRef,
455+ })
456+ log := zerowrap.FromCtx(ctx)
457+
458+ // ImageRemove with PruneChildren=false only removes the tag, not the image layers
459+ _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{
460+ Force: false,
461+ PruneChildren: false,
462+ })
463+ if err != nil {
464+ return log.WrapErr(err, "failed to untag image")
465+ }
466+
467+ log.Debug().Msg("image untagged successfully")
468+ return nil
469+}
470+
471+// RemoveImage removes an image.
472+func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
473+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
474+ zerowrap.FieldLayer: "adapter",
475+ zerowrap.FieldAdapter: "docker",
476+ zerowrap.FieldAction: "RemoveImage",
477+ "image": imageRef,
478+ "force": force,
479+ })
480+ log := zerowrap.FromCtx(ctx)
481+
482+ _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
483+ if err != nil {
484+ return log.WrapErr(err, "failed to remove image")
485+ }
486+
487+ log.Info().Msg("image removed")
488+ return nil
489+}
490+
491+// ListImages lists images.
492+func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
493+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
494+ zerowrap.FieldLayer: "adapter",
495+ zerowrap.FieldAdapter: "docker",
496+ zerowrap.FieldAction: "ListImages",
497+ })
498+ log := zerowrap.FromCtx(ctx)
499+
500+ images, err := r.client.ImageList(ctx, image.ListOptions{})
501+ if err != nil {
502+ return nil, log.WrapErr(err, "failed to list images")
503+ }
504+
505+ var result []string
506+ for _, img := range images {
507+ for _, tag := range img.RepoTags {
508+ if tag != "<none>:<none>" {
509+ result = append(result, tag)
510+ }
511+ }
512+ }
513+
514+ return result, nil
515+}
516+
517+// Ping checks if Docker is responsive.
518+func (r *Runtime) Ping(ctx context.Context) error {
519+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
520+ zerowrap.FieldLayer: "adapter",
521+ zerowrap.FieldAdapter: "docker",
522+ zerowrap.FieldAction: "Ping",
523+ })
524+ log := zerowrap.FromCtx(ctx)
525+
526+ _, err := r.client.Ping(ctx)
527+ if err != nil {
528+ return log.WrapErr(err, "Docker ping failed")
529+ }
530+ return nil
531+}
532+
533+// Version returns Docker version.
534+func (r *Runtime) Version(ctx context.Context) (string, error) {
535+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
536+ zerowrap.FieldLayer: "adapter",
537+ zerowrap.FieldAdapter: "docker",
538+ zerowrap.FieldAction: "Version",
539+ })
540+ log := zerowrap.FromCtx(ctx)
541+
542+ version, err := r.client.ServerVersion(ctx)
543+ if err != nil {
544+ return "", log.WrapErr(err, "failed to get Docker version")
545+ }
546+ return version.Version, nil
547+}
548+
549+// IsContainerRunning checks if a container is running.
550+func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
551+ ctr, err := r.InspectContainer(ctx, containerID)
552+ if err != nil {
553+ return false, err
554+ }
555+ return ctr.Status == string(domain.ContainerStatusRunning), nil
556+}
557+
558+// GetContainerPort gets the host port for a container's internal port.
559+func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
560+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
561+ zerowrap.FieldLayer: "adapter",
562+ zerowrap.FieldAdapter: "docker",
563+ zerowrap.FieldAction: "GetContainerPort",
564+ zerowrap.FieldEntityID: containerID,
565+ "internal_port": internalPort,
566+ })
567+ log := zerowrap.FromCtx(ctx)
568+
569+ resp, err := r.client.ContainerInspect(ctx, containerID)
570+ if err != nil {
571+ return 0, log.WrapErr(err, "failed to inspect container")
572+ }
573+
574+ if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
575+ return 0, fmt.Errorf("no port mappings found for container %s", containerID)
576+ }
577+
578+ containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
579+ bindings, exists := resp.NetworkSettings.Ports[containerPort]
580+ if !exists || len(bindings) == 0 {
581+ return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
582+ }
583+
584+ hostPort, err := strconv.Atoi(bindings[0].HostPort)
585+ if err != nil {
586+ return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
587+ }
588+
589+ return hostPort, nil
590+}
591+
592+// GetImageExposedPorts gets the exposed ports from an image.
593+func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
594+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
595+ zerowrap.FieldLayer: "adapter",
596+ zerowrap.FieldAdapter: "docker",
597+ zerowrap.FieldAction: "GetImageExposedPorts",
598+ "image": imageRef,
599+ })
600+ log := zerowrap.FromCtx(ctx)
601+
602+ imageInspect, err := r.client.ImageInspect(ctx, imageRef)
603+ if err != nil {
604+ return nil, log.WrapErr(err, "failed to inspect image")
605+ }
606+
607+ var ports []int
608+ if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
609+ for portSpec := range imageInspect.Config.ExposedPorts {
610+ // Parse port from format like "80/tcp"
611+ portStr := strings.Split(portSpec, "/")[0]
612+ if port, err := strconv.Atoi(portStr); err == nil {
613+ ports = append(ports, port)
614+ }
615+ }
616+ }
617+
618+ if len(ports) == 0 {
619+ return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
620+ }
621+
622+ log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image")
623+ return ports, nil
624+}
625+
626+// GetContainerExposedPorts gets all exposed ports from a running container.
627+func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
628+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
629+ zerowrap.FieldLayer: "adapter",
630+ zerowrap.FieldAdapter: "docker",
631+ zerowrap.FieldAction: "GetContainerExposedPorts",
632+ zerowrap.FieldEntityID: containerID,
633+ })
634+ log := zerowrap.FromCtx(ctx)
635+
636+ resp, err := r.client.ContainerInspect(ctx, containerID)
637+ if err != nil {
638+ return nil, log.WrapErr(err, "failed to inspect container")
639+ }
640+
641+ var ports []int
642+ if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
643+ for portSpec := range resp.NetworkSettings.Ports {
644+ // Parse port from format like "80/tcp"
645+ portStr := strings.Split(string(portSpec), "/")[0]
646+ if port, err := strconv.Atoi(portStr); err == nil {
647+ ports = append(ports, port)
648+ }
649+ }
650+ }
651+
652+ return ports, nil
653+}
654+
655+// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use.
656+func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
657+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
658+ zerowrap.FieldLayer: "adapter",
659+ zerowrap.FieldAdapter: "docker",
660+ zerowrap.FieldAction: "GetContainerNetworkInfo",
661+ zerowrap.FieldEntityID: containerID,
662+ })
663+ log := zerowrap.FromCtx(ctx)
664+
665+ resp, err := r.client.ContainerInspect(ctx, containerID)
666+ if err != nil {
667+ return "", 0, log.WrapErr(err, "failed to inspect container")
668+ }
669+
670+ // Get container's internal IP address
671+ var containerIP string
672+ if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
673+ // Use the first available network (usually bridge or custom network)
674+ for _, net := range resp.NetworkSettings.Networks {
675+ if net.IPAddress != "" {
676+ containerIP = net.IPAddress
677+ break
678+ }
679+ }
680+ }
681+
682+ if containerIP == "" {
683+ return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
684+ }
685+
686+ // Get exposed ports from container
687+ var exposedPorts []int
688+ if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
689+ for portSpec := range resp.NetworkSettings.Ports {
690+ // Parse port from format like "80/tcp"
691+ portStr := strings.Split(string(portSpec), "/")[0]
692+ if port, err := strconv.Atoi(portStr); err == nil {
693+ exposedPorts = append(exposedPorts, port)
694+ }
695+ }
696+ }
697+
698+ if len(exposedPorts) == 0 {
699+ return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
700+ }
701+
702+ // Use the first exposed port
703+ selectedPort := exposedPorts[0]
704+
705+ log.Info().
706+ Str("ip", containerIP).
707+ Int("selected_port", selectedPort).
708+ Ints("exposed_ports", exposedPorts).
709+ Msg("container network info detected")
710+
711+ return containerIP, selectedPort, nil
712+}
713+
714+// InspectImageVolumes gets the volume mount points declared in the image.
715+func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
716+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
717+ zerowrap.FieldLayer: "adapter",
718+ zerowrap.FieldAdapter: "docker",
719+ zerowrap.FieldAction: "InspectImageVolumes",
720+ "image": imageRef,
721+ })
722+ log := zerowrap.FromCtx(ctx)
723+
724+ imageInspect, err := r.client.ImageInspect(ctx, imageRef)
725+ if err != nil {
726+ return nil, log.WrapErr(err, "failed to inspect image")
727+ }
728+
729+ var volumes []string
730+ if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
731+ for volumePath := range imageInspect.Config.Volumes {
732+ volumes = append(volumes, volumePath)
733+ }
734+ }
735+
736+ if len(volumes) > 0 {
737+ log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image")
738+ } else {
739+ log.Debug().Msg("no VOLUME directives found in image")
740+ }
741+
742+ return volumes, nil
743+}
744+
745+// VolumeExists checks if a Docker volume exists.
746+func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
747+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
748+ zerowrap.FieldLayer: "adapter",
749+ zerowrap.FieldAdapter: "docker",
750+ zerowrap.FieldAction: "VolumeExists",
751+ "volume": volumeName,
752+ })
753+ log := zerowrap.FromCtx(ctx)
754+
755+ _, err := r.client.VolumeInspect(ctx, volumeName)
756+ if err != nil {
757+ if cerrdefs.IsNotFound(err) {
758+ return false, nil
759+ }
760+ return false, log.WrapErr(err, "failed to inspect volume")
761+ }
762+ return true, nil
763+}
764+
765+// CreateVolume creates a new Docker volume.
766+func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error {
767+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
768+ zerowrap.FieldLayer: "adapter",
769+ zerowrap.FieldAdapter: "docker",
770+ zerowrap.FieldAction: "CreateVolume",
771+ "volume": volumeName,
772+ })
773+ log := zerowrap.FromCtx(ctx)
774+
775+ _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{
776+ Name: volumeName,
777+ Labels: map[string]string{
778+ "gordon.managed": "true",
779+ "gordon.created": "auto",
780+ },
781+ })
782+ if err != nil {
783+ return log.WrapErr(err, "failed to create volume")
784+ }
785+
786+ log.Info().Msg("volume created")
787+ return nil
788+}
789+
790+// RemoveVolume removes a Docker volume.
791+func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
792+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
793+ zerowrap.FieldLayer: "adapter",
794+ zerowrap.FieldAdapter: "docker",
795+ zerowrap.FieldAction: "RemoveVolume",
796+ "volume": volumeName,
797+ "force": force,
798+ })
799+ log := zerowrap.FromCtx(ctx)
800+
801+ err := r.client.VolumeRemove(ctx, volumeName, force)
802+ if err != nil {
803+ if cerrdefs.IsNotFound(err) {
804+ log.Debug().Msg("volume not found, already removed")
805+ return nil
806+ }
807+ return log.WrapErr(err, "failed to remove volume")
808+ }
809+
810+ log.Info().Msg("volume removed")
811+ return nil
812+}
813+
814+// InspectImageEnv gets the environment variables declared in the image.
815+func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
816+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
817+ zerowrap.FieldLayer: "adapter",
818+ zerowrap.FieldAdapter: "docker",
819+ zerowrap.FieldAction: "InspectImageEnv",
820+ "image": imageRef,
821+ })
822+ log := zerowrap.FromCtx(ctx)
823+
824+ imageInspect, err := r.client.ImageInspect(ctx, imageRef)
825+ if err != nil {
826+ return nil, log.WrapErr(err, "failed to inspect image")
827+ }
828+
829+ var envVars []string
830+ if imageInspect.Config != nil && imageInspect.Config.Env != nil {
831+ envVars = imageInspect.Config.Env
832+ }
833+
834+ if len(envVars) > 0 {
835+ log.Debug().Strs("env_vars", envVars).Msg("found ENV directives in image")
836+ } else {
837+ log.Debug().Msg("no ENV directives found in image")
838+ }
839+
840+ return envVars, nil
841+}
842+
843+// GetImageLabels returns the labels defined on an image.
844+func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) {
845+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
846+ zerowrap.FieldLayer: "adapter",
847+ zerowrap.FieldAdapter: "docker",
848+ zerowrap.FieldAction: "GetImageLabels",
849+ "image": imageRef,
850+ })
851+ log := zerowrap.FromCtx(ctx)
852+
853+ imageInspect, err := r.client.ImageInspect(ctx, imageRef)
854+ if err != nil {
855+ return nil, log.WrapErr(err, "failed to inspect image")
856+ }
857+
858+ labels := make(map[string]string)
859+ if imageInspect.Config != nil && imageInspect.Config.Labels != nil {
860+ labels = imageInspect.Config.Labels
861+ }
862+
863+ log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels")
864+ return labels, nil
865+}
866+
867+// CreateNetwork creates a new Docker network.
868+func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
869+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
870+ zerowrap.FieldLayer: "adapter",
871+ zerowrap.FieldAdapter: "docker",
872+ zerowrap.FieldAction: "CreateNetwork",
873+ "network": name,
874+ })
875+ log := zerowrap.FromCtx(ctx)
876+
877+ // Set default driver to bridge if not specified
878+ driver := "bridge"
879+ if driverOption, exists := options["driver"]; exists {
880+ driver = driverOption
881+ }
882+
883+ createOptions := network.CreateOptions{
884+ Driver: driver,
885+ Labels: map[string]string{
886+ "gordon.managed": "true",
887+ },
888+ }
889+
890+ // Add any additional options to labels
891+ for key, value := range options {
892+ if key != "driver" {
893+ createOptions.Labels["gordon."+key] = value
894+ }
895+ }
896+
897+ _, err := r.client.NetworkCreate(ctx, name, createOptions)
898+ if err != nil {
899+ return log.WrapErr(err, "failed to create network")
900+ }
901+
902+ log.Info().Str("driver", driver).Msg("network created")
903+ return nil
904+}
905+
906+// RemoveNetwork removes a Docker network.
907+func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
908+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
909+ zerowrap.FieldLayer: "adapter",
910+ zerowrap.FieldAdapter: "docker",
911+ zerowrap.FieldAction: "RemoveNetwork",
912+ "network": name,
913+ })
914+ log := zerowrap.FromCtx(ctx)
915+
916+ err := r.client.NetworkRemove(ctx, name)
917+ if err != nil {
918+ if cerrdefs.IsNotFound(err) {
919+ log.Debug().Msg("network not found, already removed")
920+ return nil
921+ }
922+ return log.WrapErr(err, "failed to remove network")
923+ }
924+
925+ log.Info().Msg("network removed")
926+ return nil
927+}
928+
929+// ListNetworks lists all Docker networks.
930+func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
931+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
932+ zerowrap.FieldLayer: "adapter",
933+ zerowrap.FieldAdapter: "docker",
934+ zerowrap.FieldAction: "ListNetworks",
935+ })
936+ log := zerowrap.FromCtx(ctx)
937+
938+ networks, err := r.client.NetworkList(ctx, network.ListOptions{})
939+ if err != nil {
940+ return nil, log.WrapErr(err, "failed to list networks")
941+ }
942+
943+ var result []*domain.NetworkInfo
944+ for _, net := range networks {
945+ var containers []string
946+ for containerID := range net.Containers {
947+ containers = append(containers, containerID)
948+ }
949+
950+ result = append(result, &domain.NetworkInfo{
951+ ID: net.ID,
952+ Name: net.Name,
953+ Driver: net.Driver,
954+ Containers: containers,
955+ Labels: net.Labels,
956+ })
957+ }
958+
959+ return result, nil
960+}
961+
962+// NetworkExists checks if a Docker network exists.
963+func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) {
964+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
965+ zerowrap.FieldLayer: "adapter",
966+ zerowrap.FieldAdapter: "docker",
967+ zerowrap.FieldAction: "NetworkExists",
968+ "network": name,
969+ })
970+ log := zerowrap.FromCtx(ctx)
971+
972+ _, err := r.client.NetworkInspect(ctx, name, network.InspectOptions{})
973+ if err != nil {
974+ if cerrdefs.IsNotFound(err) {
975+ return false, nil
976+ }
977+ return false, log.WrapErr(err, "failed to inspect network")
978+ }
979+ return true, nil
980+}
981+
982+// ConnectContainerToNetwork connects a container to a network.
983+func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
984+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
985+ zerowrap.FieldLayer: "adapter",
986+ zerowrap.FieldAdapter: "docker",
987+ zerowrap.FieldAction: "ConnectContainerToNetwork",
988+ "container": containerName,
989+ "network": networkName,
990+ })
991+ log := zerowrap.FromCtx(ctx)
992+
993+ err := r.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
994+ if err != nil {
995+ return log.WrapErr(err, "failed to connect container to network")
996+ }
997+
998+ log.Info().Msg("container connected to network")
999+ return nil
1000+}
1001+
1002+// DisconnectContainerFromNetwork disconnects a container from a network.
1003+func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
1004+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1005+ zerowrap.FieldLayer: "adapter",
1006+ zerowrap.FieldAdapter: "docker",
1007+ zerowrap.FieldAction: "DisconnectContainerFromNetwork",
1008+ "container": containerName,
1009+ "network": networkName,
1010+ })
1011+ log := zerowrap.FromCtx(ctx)
1012+
1013+ err := r.client.NetworkDisconnect(ctx, networkName, containerName, false)
1014+ if err != nil {
1015+ return log.WrapErr(err, "failed to disconnect container from network")
1016+ }
1017+
1018+ log.Info().Msg("container disconnected from network")
1019+ return nil
1020+}
A internal/adapters/out/envloader/file.go +231 -0
@@ -0,0 +1,231 @@
1+// Package envloader implements the environment variable loader adapter.
2+package envloader
3+
4+import (
5+ "bufio"
6+ "context"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/boundaries/out"
15+)
16+
17+// FileLoader implements the EnvLoader interface using filesystem-based env files.
18+type FileLoader struct {
19+ envDir string
20+ secretProviders map[string]out.SecretProvider
21+ log zerowrap.Logger
22+}
23+
24+// NewFileLoader creates a new file-based environment loader.
25+func NewFileLoader(envDir string, log zerowrap.Logger) (*FileLoader, error) {
26+ // Ensure env directory exists
27+ if err := os.MkdirAll(envDir, 0700); err != nil {
28+ return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
29+ }
30+
31+ log.Debug().
32+ Str(zerowrap.FieldLayer, "adapter").
33+ Str(zerowrap.FieldAdapter, "envloader").
34+ Str("env_dir", envDir).
35+ Msg("env loader initialized")
36+
37+ return &FileLoader{
38+ envDir: envDir,
39+ secretProviders: make(map[string]out.SecretProvider),
40+ log: log,
41+ }, nil
42+}
43+
44+// RegisterSecretProvider registers a secret provider for resolving secrets.
45+func (l *FileLoader) RegisterSecretProvider(provider out.SecretProvider) {
46+ l.secretProviders[provider.Name()] = provider
47+ l.log.Debug().
48+ Str(zerowrap.FieldLayer, "adapter").
49+ Str(zerowrap.FieldAdapter, "envloader").
50+ Str("provider", provider.Name()).
51+ Msg("secret provider registered")
52+}
53+
54+// LoadEnv loads environment variables for a given domain.
55+func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
56+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
57+ zerowrap.FieldLayer: "adapter",
58+ zerowrap.FieldAdapter: "envloader",
59+ zerowrap.FieldAction: "LoadEnv",
60+ "domain": domain,
61+ })
62+ log := zerowrap.FromCtx(ctx)
63+
64+ envVars := []string{}
65+ envFile := l.getEnvFilePath(domain)
66+
67+ // Check if env file exists
68+ if _, err := os.Stat(envFile); os.IsNotExist(err) {
69+ log.Debug().Str("env_file", envFile).Msg("no env file found for route")
70+ return envVars, nil
71+ }
72+
73+ log.Debug().Str("env_file", envFile).Msg("loading env file for route")
74+
75+ file, err := os.Open(envFile)
76+ if err != nil {
77+ return nil, log.WrapErr(err, "failed to open env file")
78+ }
79+ defer file.Close()
80+
81+ scanner := bufio.NewScanner(file)
82+ lineNum := 0
83+
84+ for scanner.Scan() {
85+ lineNum++
86+ line := strings.TrimSpace(scanner.Text())
87+
88+ // Skip empty lines and comments
89+ if line == "" || strings.HasPrefix(line, "#") {
90+ continue
91+ }
92+
93+ // Parse KEY=VALUE format
94+ parts := strings.SplitN(line, "=", 2)
95+ if len(parts) != 2 {
96+ log.Warn().
97+ Int("line", lineNum).
98+ Str("content", line).
99+ Msg("invalid env file line format, skipping")
100+ continue
101+ }
102+
103+ key := strings.TrimSpace(parts[0])
104+ value := strings.TrimSpace(parts[1])
105+
106+ // Remove quotes if present
107+ if len(value) >= 2 {
108+ if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
109+ (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
110+ value = value[1 : len(value)-1]
111+ }
112+ }
113+
114+ // Resolve secrets if value contains secret syntax
115+ resolvedValue, err := l.resolveSecrets(ctx, value)
116+ if err != nil {
117+ return nil, log.WrapErrWithFields(err, "failed to resolve secret", map[string]any{
118+ "key": key,
119+ "line": lineNum,
120+ })
121+ }
122+
123+ envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
124+ }
125+
126+ if err := scanner.Err(); err != nil {
127+ return nil, log.WrapErr(err, "error reading env file")
128+ }
129+
130+ log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route")
131+
132+ return envVars, nil
133+}
134+
135+// CreateEnvFile creates an empty environment file for a new domain.
136+func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
137+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
138+ zerowrap.FieldLayer: "adapter",
139+ zerowrap.FieldAdapter: "envloader",
140+ zerowrap.FieldAction: "CreateEnvFile",
141+ "domain": domain,
142+ })
143+ log := zerowrap.FromCtx(ctx)
144+
145+ envFile := l.getEnvFilePath(domain)
146+
147+ // Check if file already exists
148+ if _, err := os.Stat(envFile); err == nil {
149+ log.Debug().Str("env_file", envFile).Msg("env file already exists, skipping creation")
150+ return nil
151+ } else if !os.IsNotExist(err) {
152+ return log.WrapErr(err, "error checking env file")
153+ }
154+
155+ // Create empty env file with helpful comments
156+ content := fmt.Sprintf(`# Environment variables for route: %s
157+# Add KEY=VALUE pairs, one per line
158+# Secrets can be referenced with ${provider:path} syntax
159+
160+`, domain)
161+
162+ if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
163+ return log.WrapErr(err, "failed to create env file")
164+ }
165+
166+ log.Info().Str("env_file", envFile).Msg("created empty env file for route")
167+
168+ return nil
169+}
170+
171+// EnvFileExists checks if an environment file exists for a domain.
172+func (l *FileLoader) EnvFileExists(domain string) bool {
173+ envFile := l.getEnvFilePath(domain)
174+ _, err := os.Stat(envFile)
175+ return err == nil
176+}
177+
178+// resolveSecrets resolves any secret references in the value.
179+// Secret syntax: ${provider:path}
180+func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, error) {
181+ // Check if value contains secret syntax: ${provider:path}
182+ if !strings.Contains(value, "${") {
183+ return value, nil
184+ }
185+
186+ result := value
187+ for {
188+ start := strings.Index(result, "${")
189+ if start == -1 {
190+ break
191+ }
192+
193+ end := strings.Index(result[start:], "}")
194+ if end == -1 {
195+ return "", fmt.Errorf("unclosed secret syntax in value: %s", value)
196+ }
197+ end += start
198+
199+ secretRef := result[start+2 : end]
200+ parts := strings.SplitN(secretRef, ":", 2)
201+ if len(parts) != 2 {
202+ return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)
203+ }
204+
205+ providerName := parts[0]
206+ secretPath := parts[1]
207+
208+ provider, exists := l.secretProviders[providerName]
209+ if !exists {
210+ return "", fmt.Errorf("unknown secret provider: %s", providerName)
211+ }
212+
213+ secretValue, err := provider.GetSecret(ctx, secretPath)
214+ if err != nil {
215+ return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)
216+ }
217+
218+ // Replace the secret reference with the actual value
219+ result = result[:start] + secretValue + result[end+1:]
220+ }
221+
222+ return result, nil
223+}
224+
225+func (l *FileLoader) getEnvFilePath(domain string) string {
226+ // Create domain-safe filename (replace dots and other chars with underscores)
227+ safeDomain := strings.ReplaceAll(domain, ".", "_")
228+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
229+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
230+ return filepath.Join(l.envDir, safeDomain+".env")
231+}
A internal/adapters/out/envloader/file_test.go +219 -0
@@ -0,0 +1,219 @@
1+package envloader
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/boundaries/out/mocks"
14+)
15+
16+func TestFileLoader_ResolveSecrets(t *testing.T) {
17+ // Create a temp directory for tests
18+ tmpDir := t.TempDir()
19+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
20+
21+ loader, err := NewFileLoader(tmpDir, log)
22+ require.NoError(t, err)
23+
24+ ctx := context.Background()
25+
26+ t.Run("no secret syntax returns value unchanged", func(t *testing.T) {
27+ result, err := loader.resolveSecrets(ctx, "plain-value")
28+ assert.NoError(t, err)
29+ assert.Equal(t, "plain-value", result)
30+ })
31+
32+ t.Run("unclosed secret syntax returns error", func(t *testing.T) {
33+ _, err := loader.resolveSecrets(ctx, "${pass:secret")
34+ assert.Error(t, err)
35+ assert.Contains(t, err.Error(), "unclosed secret syntax")
36+ })
37+
38+ t.Run("invalid secret syntax without colon returns error", func(t *testing.T) {
39+ _, err := loader.resolveSecrets(ctx, "${invalid}")
40+ assert.Error(t, err)
41+ assert.Contains(t, err.Error(), "invalid secret syntax")
42+ })
43+
44+ t.Run("unknown provider returns error", func(t *testing.T) {
45+ _, err := loader.resolveSecrets(ctx, "${unknown:path}")
46+ assert.Error(t, err)
47+ assert.Contains(t, err.Error(), "unknown secret provider")
48+ })
49+
50+ t.Run("pass provider resolves secret", func(t *testing.T) {
51+ mockPass := mocks.NewMockSecretProvider(t)
52+ mockPass.EXPECT().Name().Return("pass")
53+ mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil)
54+
55+ loader.RegisterSecretProvider(mockPass)
56+
57+ result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}")
58+ assert.NoError(t, err)
59+ assert.Equal(t, "secret-value-123", result)
60+ })
61+
62+ t.Run("sops provider resolves secret", func(t *testing.T) {
63+ mockSops := mocks.NewMockSecretProvider(t)
64+ mockSops.EXPECT().Name().Return("sops")
65+ mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil)
66+
67+ loader.RegisterSecretProvider(mockSops)
68+
69+ result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}")
70+ assert.NoError(t, err)
71+ assert.Equal(t, "sops-secret", result)
72+ })
73+
74+ t.Run("multiple secrets in one value", func(t *testing.T) {
75+ mockPass := mocks.NewMockSecretProvider(t)
76+ mockPass.EXPECT().Name().Return("pass")
77+ mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil)
78+ mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil)
79+
80+ loader.RegisterSecretProvider(mockPass)
81+
82+ result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db")
83+ assert.NoError(t, err)
84+ assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result)
85+ })
86+
87+ t.Run("provider error is propagated", func(t *testing.T) {
88+ mockPass := mocks.NewMockSecretProvider(t)
89+ mockPass.EXPECT().Name().Return("pass")
90+ mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError)
91+
92+ loader.RegisterSecretProvider(mockPass)
93+
94+ _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}")
95+ assert.Error(t, err)
96+ assert.Contains(t, err.Error(), "failed to get secret from provider")
97+ })
98+}
99+
100+func TestFileLoader_LoadEnv(t *testing.T) {
101+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
102+ ctx := context.Background()
103+
104+ t.Run("missing env file returns empty slice", func(t *testing.T) {
105+ tmpDir := t.TempDir()
106+ loader, err := NewFileLoader(tmpDir, log)
107+ require.NoError(t, err)
108+
109+ envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com")
110+ assert.NoError(t, err)
111+ assert.Empty(t, envVars)
112+ })
113+
114+ t.Run("loads simple env file", func(t *testing.T) {
115+ tmpDir := t.TempDir()
116+ loader, err := NewFileLoader(tmpDir, log)
117+ require.NoError(t, err)
118+
119+ // Create env file
120+ envContent := `# Comment line
121+NODE_ENV=production
122+API_URL=https://api.example.com
123+`
124+ envFile := filepath.Join(tmpDir, "app_example_com.env")
125+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126+
127+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
128+ assert.NoError(t, err)
129+ assert.Len(t, envVars, 2)
130+ assert.Contains(t, envVars, "NODE_ENV=production")
131+ assert.Contains(t, envVars, "API_URL=https://api.example.com")
132+ })
133+
134+ t.Run("loads env file with secrets", func(t *testing.T) {
135+ tmpDir := t.TempDir()
136+ loader, err := NewFileLoader(tmpDir, log)
137+ require.NoError(t, err)
138+
139+ // Register mock provider
140+ mockPass := mocks.NewMockSecretProvider(t)
141+ mockPass.EXPECT().Name().Return("pass")
142+ mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil)
143+ loader.RegisterSecretProvider(mockPass)
144+
145+ // Create env file with secret reference
146+ envContent := `NODE_ENV=production
147+API_KEY=${pass:app/api-key}
148+`
149+ envFile := filepath.Join(tmpDir, "app_example_com.env")
150+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151+
152+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
153+ assert.NoError(t, err)
154+ assert.Len(t, envVars, 2)
155+ assert.Contains(t, envVars, "NODE_ENV=production")
156+ assert.Contains(t, envVars, "API_KEY=super-secret-key")
157+ })
158+
159+ t.Run("handles quoted values", func(t *testing.T) {
160+ tmpDir := t.TempDir()
161+ loader, err := NewFileLoader(tmpDir, log)
162+ require.NoError(t, err)
163+
164+ envContent := `DOUBLE_QUOTED="hello world"
165+SINGLE_QUOTED='hello world'
166+`
167+ envFile := filepath.Join(tmpDir, "app_example_com.env")
168+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169+
170+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
171+ assert.NoError(t, err)
172+ assert.Len(t, envVars, 2)
173+ assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world")
174+ assert.Contains(t, envVars, "SINGLE_QUOTED=hello world")
175+ })
176+
177+ t.Run("skips invalid lines", func(t *testing.T) {
178+ tmpDir := t.TempDir()
179+ loader, err := NewFileLoader(tmpDir, log)
180+ require.NoError(t, err)
181+
182+ envContent := `VALID=value
183+invalid line without equals
184+ALSO_VALID=another
185+`
186+ envFile := filepath.Join(tmpDir, "app_example_com.env")
187+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188+
189+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
190+ assert.NoError(t, err)
191+ assert.Len(t, envVars, 2)
192+ assert.Contains(t, envVars, "VALID=value")
193+ assert.Contains(t, envVars, "ALSO_VALID=another")
194+ })
195+}
196+
197+func TestFileLoader_GetEnvFilePath(t *testing.T) {
198+ tmpDir := t.TempDir()
199+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
200+
201+ loader, err := NewFileLoader(tmpDir, log)
202+ require.NoError(t, err)
203+
204+ tests := []struct {
205+ domain string
206+ expected string
207+ }{
208+ {"app.example.com", "app_example_com.env"},
209+ {"api.sub.example.com", "api_sub_example_com.env"},
210+ {"localhost:8080", "localhost_8080.env"},
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.domain, func(t *testing.T) {
215+ result := loader.getEnvFilePath(tt.domain)
216+ assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
217+ })
218+ }
219+}
A internal/adapters/out/eventbus/inmemory.go +206 -0
@@ -0,0 +1,206 @@
1+// Package eventbus implements the event bus adapter.
2+package eventbus
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/google/uuid"
12+
13+ "gordon/internal/boundaries/out"
14+ "gordon/internal/domain"
15+)
16+
17+// InMemory implements the EventBus interface using in-memory channels.
18+type InMemory struct {
19+ handlers []out.EventHandler
20+ eventChan chan domain.Event
21+ done chan struct{}
22+ mu sync.RWMutex
23+ ctx context.Context
24+ cancel context.CancelFunc
25+ bufferSize int
26+ log zerowrap.Logger
27+}
28+
29+// NewInMemory creates a new in-memory event bus.
30+func NewInMemory(bufferSize int, log zerowrap.Logger) *InMemory {
31+ if bufferSize <= 0 {
32+ bufferSize = 100
33+ }
34+
35+ ctx, cancel := context.WithCancel(context.Background())
36+
37+ return &InMemory{
38+ handlers: make([]out.EventHandler, 0),
39+ eventChan: make(chan domain.Event, bufferSize),
40+ done: make(chan struct{}),
41+ ctx: ctx,
42+ cancel: cancel,
43+ bufferSize: bufferSize,
44+ log: log,
45+ }
46+}
47+
48+// Publish publishes an event to the bus.
49+func (bus *InMemory) Publish(eventType domain.EventType, payload any) error {
50+ event := domain.Event{
51+ ID: uuid.New().String(),
52+ Type: eventType,
53+ Timestamp: time.Now(),
54+ Data: payload,
55+ }
56+
57+ // Add specific fields for known event types
58+ switch p := payload.(type) {
59+ case domain.ImagePushedPayload:
60+ event.ImageName = p.Name
61+ event.Tag = p.Reference
62+ }
63+
64+ select {
65+ case bus.eventChan <- event:
66+ bus.log.Debug().
67+ Str(zerowrap.FieldLayer, "adapter").
68+ Str(zerowrap.FieldAdapter, "eventbus").
69+ Str("event_id", event.ID).
70+ Str(zerowrap.FieldEvent, string(event.Type)).
71+ Str("image_name", event.ImageName).
72+ Str("tag", event.Tag).
73+ Msg("event published")
74+ return nil
75+ case <-bus.ctx.Done():
76+ return fmt.Errorf("event bus is stopped")
77+ default:
78+ return fmt.Errorf("event channel is full, dropping event %s", event.ID)
79+ }
80+}
81+
82+// Subscribe adds an event handler to the bus.
83+func (bus *InMemory) Subscribe(handler out.EventHandler) error {
84+ bus.mu.Lock()
85+ defer bus.mu.Unlock()
86+
87+ bus.handlers = append(bus.handlers, handler)
88+ bus.log.Debug().
89+ Str(zerowrap.FieldLayer, "adapter").
90+ Str(zerowrap.FieldAdapter, "eventbus").
91+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
92+ Int("total_handlers", len(bus.handlers)).
93+ Msg("event handler subscribed")
94+
95+ return nil
96+}
97+
98+// Unsubscribe removes an event handler from the bus.
99+func (bus *InMemory) Unsubscribe(handler out.EventHandler) error {
100+ bus.mu.Lock()
101+ defer bus.mu.Unlock()
102+
103+ for i, h := range bus.handlers {
104+ if h == handler {
105+ bus.handlers = append(bus.handlers[:i], bus.handlers[i+1:]...)
106+ bus.log.Debug().
107+ Str(zerowrap.FieldLayer, "adapter").
108+ Str(zerowrap.FieldAdapter, "eventbus").
109+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
110+ Int("total_handlers", len(bus.handlers)).
111+ Msg("event handler unsubscribed")
112+ return nil
113+ }
114+ }
115+
116+ return fmt.Errorf("handler not found")
117+}
118+
119+// Start starts the event bus processing loop.
120+func (bus *InMemory) Start() error {
121+ bus.log.Info().
122+ Str(zerowrap.FieldLayer, "adapter").
123+ Str(zerowrap.FieldAdapter, "eventbus").
124+ Int("buffer_size", bus.bufferSize).
125+ Msg("starting event bus")
126+
127+ go bus.processEvents()
128+ return nil
129+}
130+
131+// Stop stops the event bus.
132+func (bus *InMemory) Stop() error {
133+ bus.log.Info().
134+ Str(zerowrap.FieldLayer, "adapter").
135+ Str(zerowrap.FieldAdapter, "eventbus").
136+ Msg("stopping event bus")
137+
138+ bus.cancel()
139+
140+ select {
141+ case <-bus.done:
142+ bus.log.Info().
143+ Str(zerowrap.FieldLayer, "adapter").
144+ Str(zerowrap.FieldAdapter, "eventbus").
145+ Msg("event bus stopped")
146+ return nil
147+ case <-time.After(5 * time.Second):
148+ bus.log.Warn().
149+ Str(zerowrap.FieldLayer, "adapter").
150+ Str(zerowrap.FieldAdapter, "eventbus").
151+ Msg("event bus stop timeout")
152+ return fmt.Errorf("timeout waiting for event bus to stop")
153+ }
154+}
155+
156+func (bus *InMemory) processEvents() {
157+ defer close(bus.done)
158+
159+ for {
160+ select {
161+ case event := <-bus.eventChan:
162+ bus.handleEvent(event)
163+ case <-bus.ctx.Done():
164+ bus.log.Debug().
165+ Str(zerowrap.FieldLayer, "adapter").
166+ Str(zerowrap.FieldAdapter, "eventbus").
167+ Msg("event bus processing stopped")
168+ return
169+ }
170+ }
171+}
172+
173+func (bus *InMemory) handleEvent(event domain.Event) {
174+ bus.mu.RLock()
175+ handlers := make([]out.EventHandler, len(bus.handlers))
176+ copy(handlers, bus.handlers)
177+ bus.mu.RUnlock()
178+
179+ for _, handler := range handlers {
180+ if handler.CanHandle(event.Type) {
181+ go func(h out.EventHandler) {
182+ start := time.Now()
183+
184+ if err := h.Handle(event); err != nil {
185+ bus.log.Error().
186+ Str(zerowrap.FieldLayer, "adapter").
187+ Str(zerowrap.FieldAdapter, "eventbus").
188+ Err(err).
189+ Str("event_id", event.ID).
190+ Str(zerowrap.FieldEvent, string(event.Type)).
191+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).
192+ Msg("error handling event")
193+ } else {
194+ bus.log.Debug().
195+ Str(zerowrap.FieldLayer, "adapter").
196+ Str(zerowrap.FieldAdapter, "eventbus").
197+ Str("event_id", event.ID).
198+ Str(zerowrap.FieldEvent, string(event.Type)).
199+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).
200+ Dur(zerowrap.FieldDuration, time.Since(start)).
201+ Msg("event handled successfully")
202+ }
203+ }(handler)
204+ }
205+ }
206+}
A internal/adapters/out/filesystem/blob.go +294 -0
@@ -0,0 +1,294 @@
1+// Package filesystem implements storage adapters using the local filesystem.
2+package filesystem
3+
4+import (
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+)
14+
15+// BlobStorage implements the BlobStorage interface using the local filesystem.
16+type BlobStorage struct {
17+ rootDir string
18+ log zerowrap.Logger
19+}
20+
21+// NewBlobStorage creates a new filesystem blob storage instance.
22+func NewBlobStorage(rootDir string, log zerowrap.Logger) (*BlobStorage, error) {
23+ // Create directory structure if it doesn't exist
24+ dirs := []string{
25+ filepath.Join(rootDir, "blobs"),
26+ filepath.Join(rootDir, "uploads"),
27+ }
28+
29+ for _, dir := range dirs {
30+ if err := os.MkdirAll(dir, 0750); err != nil {
31+ return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
32+ }
33+ }
34+
35+ log.Info().
36+ Str(zerowrap.FieldLayer, "adapter").
37+ Str(zerowrap.FieldAdapter, "filesystem").
38+ Str("root_dir", rootDir).
39+ Msg("blob storage initialized")
40+
41+ return &BlobStorage{
42+ rootDir: rootDir,
43+ log: log,
44+ }, nil
45+}
46+
47+// GetBlob retrieves a blob by digest.
48+func (s *BlobStorage) GetBlob(digest string) (io.ReadCloser, error) {
49+ blobPath := s.getBlobPath(digest)
50+
51+ file, err := os.Open(blobPath)
52+ if err != nil {
53+ if os.IsNotExist(err) {
54+ return nil, fmt.Errorf("blob not found: %s", digest)
55+ }
56+ return nil, fmt.Errorf("failed to open blob: %w", err)
57+ }
58+
59+ return file, nil
60+}
61+
62+// GetBlobPath returns the filesystem path to a blob.
63+func (s *BlobStorage) GetBlobPath(digest string) (string, error) {
64+ path := s.getBlobPath(digest)
65+ if _, err := os.Stat(path); err != nil {
66+ if os.IsNotExist(err) {
67+ return "", fmt.Errorf("blob not found: %s", digest)
68+ }
69+ return "", err
70+ }
71+ return path, nil
72+}
73+
74+// PutBlob stores a blob with the given digest.
75+func (s *BlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
76+ blobPath := s.getBlobPath(digest)
77+
78+ // Create directory if it doesn't exist
79+ if err := os.MkdirAll(filepath.Dir(blobPath), 0750); err != nil {
80+ return fmt.Errorf("failed to create blob directory: %w", err)
81+ }
82+
83+ // Create temporary file first
84+ tmpPath := blobPath + ".tmp"
85+ file, err := os.Create(tmpPath)
86+ if err != nil {
87+ return fmt.Errorf("failed to create temporary blob file: %w", err)
88+ }
89+ defer file.Close()
90+
91+ // Copy data to file
92+ written, err := io.Copy(file, data)
93+ if err != nil {
94+ os.Remove(tmpPath)
95+ return fmt.Errorf("failed to write blob data: %w", err)
96+ }
97+
98+ // Verify size if provided
99+ if size > 0 && written != size {
100+ os.Remove(tmpPath)
101+ return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)
102+ }
103+
104+ // Move to final location
105+ if err := os.Rename(tmpPath, blobPath); err != nil {
106+ os.Remove(tmpPath)
107+ return fmt.Errorf("failed to move blob to final location: %w", err)
108+ }
109+
110+ s.log.Info().
111+ Str(zerowrap.FieldLayer, "adapter").
112+ Str(zerowrap.FieldAdapter, "filesystem").
113+ Str("digest", digest).
114+ Int64(zerowrap.FieldSize, written).
115+ Msg("blob stored")
116+
117+ return nil
118+}
119+
120+// DeleteBlob removes a blob by digest.
121+func (s *BlobStorage) DeleteBlob(digest string) error {
122+ blobPath := s.getBlobPath(digest)
123+
124+ if err := os.Remove(blobPath); err != nil {
125+ if os.IsNotExist(err) {
126+ return fmt.Errorf("blob not found: %s", digest)
127+ }
128+ return fmt.Errorf("failed to delete blob: %w", err)
129+ }
130+
131+ s.log.Info().
132+ Str(zerowrap.FieldLayer, "adapter").
133+ Str(zerowrap.FieldAdapter, "filesystem").
134+ Str("digest", digest).
135+ Msg("blob deleted")
136+
137+ return nil
138+}
139+
140+// BlobExists checks if a blob exists.
141+func (s *BlobStorage) BlobExists(digest string) bool {
142+ blobPath := s.getBlobPath(digest)
143+ _, err := os.Stat(blobPath)
144+ return err == nil
145+}
146+
147+// StartBlobUpload starts a new blob upload and returns the upload UUID.
148+func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
149+ // Generate UUID-like upload ID using timestamp
150+ uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))
151+
152+ uploadPath := s.getUploadPath(uuid)
153+
154+ // Create directory if it doesn't exist
155+ if err := os.MkdirAll(filepath.Dir(uploadPath), 0750); err != nil {
156+ return "", fmt.Errorf("failed to create upload directory: %w", err)
157+ }
158+
159+ // Create empty upload file
160+ file, err := os.Create(uploadPath)
161+ if err != nil {
162+ return "", fmt.Errorf("failed to create upload file: %w", err)
163+ }
164+ file.Close()
165+
166+ s.log.Info().
167+ Str(zerowrap.FieldLayer, "adapter").
168+ Str(zerowrap.FieldAdapter, "filesystem").
169+ Str("uuid", uuid).
170+ Str("name", name).
171+ Msg("blob upload started")
172+
173+ return uuid, nil
174+}
175+
176+// AppendBlobChunk appends data to an in-progress upload.
177+func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) {
178+ uploadPath := s.getUploadPath(uuid)
179+
180+ file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
181+ if err != nil {
182+ if os.IsNotExist(err) {
183+ return 0, fmt.Errorf("upload not found: %s", uuid)
184+ }
185+ return 0, fmt.Errorf("failed to open upload file: %w", err)
186+ }
187+ defer file.Close()
188+
189+ written, err := file.Write(chunk)
190+ if err != nil {
191+ return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
192+ }
193+
194+ fi, err := file.Stat()
195+ if err != nil {
196+ return 0, fmt.Errorf("failed to get file info: %w", err)
197+ }
198+
199+ s.log.Debug().
200+ Str(zerowrap.FieldLayer, "adapter").
201+ Str(zerowrap.FieldAdapter, "filesystem").
202+ Str("uuid", uuid).
203+ Str("name", name).
204+ Int("chunk_size", written).
205+ Int64("total_size", fi.Size()).
206+ Msg("appended chunk to blob upload")
207+
208+ return fi.Size(), nil
209+}
210+
211+// GetBlobUpload returns a writer for the upload.
212+func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
213+ uploadPath := s.getUploadPath(uuid)
214+
215+ file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
216+ if err != nil {
217+ if os.IsNotExist(err) {
218+ return nil, fmt.Errorf("upload not found: %s", uuid)
219+ }
220+ return nil, fmt.Errorf("failed to open upload file: %w", err)
221+ }
222+
223+ return file, nil
224+}
225+
226+// FinishBlobUpload completes an upload and moves it to blob storage.
227+func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
228+ uploadPath := s.getUploadPath(uuid)
229+ blobPath := s.getBlobPath(digest)
230+
231+ // Create blob directory if it doesn't exist
232+ if err := os.MkdirAll(filepath.Dir(blobPath), 0750); err != nil {
233+ return fmt.Errorf("failed to create blob directory: %w", err)
234+ }
235+
236+ // Move upload file to blob location
237+ if err := os.Rename(uploadPath, blobPath); err != nil {
238+ return fmt.Errorf("failed to move upload to blob location: %w", err)
239+ }
240+
241+ s.log.Info().
242+ Str(zerowrap.FieldLayer, "adapter").
243+ Str(zerowrap.FieldAdapter, "filesystem").
244+ Str("uuid", uuid).
245+ Str("digest", digest).
246+ Msg("blob upload finished")
247+
248+ return nil
249+}
250+
251+// CancelBlobUpload cancels an in-progress upload.
252+func (s *BlobStorage) CancelBlobUpload(uuid string) error {
253+ uploadPath := s.getUploadPath(uuid)
254+
255+ if err := os.Remove(uploadPath); err != nil {
256+ if os.IsNotExist(err) {
257+ return fmt.Errorf("upload not found: %s", uuid)
258+ }
259+ return fmt.Errorf("failed to cancel upload: %w", err)
260+ }
261+
262+ s.log.Info().
263+ Str(zerowrap.FieldLayer, "adapter").
264+ Str(zerowrap.FieldAdapter, "filesystem").
265+ Str("uuid", uuid).
266+ Msg("blob upload cancelled")
267+
268+ return nil
269+}
270+
271+// Helper methods for path generation
272+
273+func (s *BlobStorage) getBlobPath(digest string) string {
274+ // Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
275+ parts := strings.SplitN(digest, ":", 2)
276+ if len(parts) != 2 {
277+ // Fallback if digest format is unexpected
278+ return filepath.Join(s.rootDir, "blobs", digest)
279+ }
280+
281+ algorithm := parts[0]
282+ hash := parts[1]
283+
284+ // Create two-level directory structure for better performance
285+ if len(hash) >= 2 {
286+ return filepath.Join(s.rootDir, "blobs", algorithm, hash[:2], hash)
287+ }
288+
289+ return filepath.Join(s.rootDir, "blobs", algorithm, hash)
290+}
291+
292+func (s *BlobStorage) getUploadPath(uuid string) string {
293+ return filepath.Join(s.rootDir, "uploads", uuid)
294+}
A internal/adapters/out/filesystem/blob_test.go +473 -0
@@ -0,0 +1,473 @@
1+package filesystem
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func testLogger() zerowrap.Logger {
16+ return zerowrap.Default()
17+}
18+
19+func TestNewBlobStorage(t *testing.T) {
20+ tmpDir := t.TempDir()
21+ log := testLogger()
22+
23+ storage, err := NewBlobStorage(tmpDir, log)
24+
25+ require.NoError(t, err)
26+ assert.NotNil(t, storage)
27+
28+ // Verify directories were created
29+ assert.DirExists(t, filepath.Join(tmpDir, "blobs"))
30+ assert.DirExists(t, filepath.Join(tmpDir, "uploads"))
31+}
32+
33+func TestNewBlobStorage_InvalidPath(t *testing.T) {
34+ log := testLogger()
35+
36+ // Try to create in a path that can't be created (no permissions)
37+ _, err := NewBlobStorage("/nonexistent/deeply/nested/path/that/should/fail", log)
38+
39+ assert.Error(t, err)
40+}
41+
42+func TestBlobStorage_PutAndGetBlob(t *testing.T) {
43+ tmpDir := t.TempDir()
44+ log := testLogger()
45+
46+ storage, err := NewBlobStorage(tmpDir, log)
47+ require.NoError(t, err)
48+
49+ blobData := []byte("test blob content")
50+ digest := "sha256:abc123def456"
51+
52+ // Put blob
53+ err = storage.PutBlob(digest, bytes.NewReader(blobData), int64(len(blobData)))
54+ require.NoError(t, err)
55+
56+ // Get blob
57+ reader, err := storage.GetBlob(digest)
58+ require.NoError(t, err)
59+ defer reader.Close()
60+
61+ data, err := io.ReadAll(reader)
62+ require.NoError(t, err)
63+
64+ assert.Equal(t, blobData, data)
65+}
66+
67+func TestBlobStorage_GetBlob_NotFound(t *testing.T) {
68+ tmpDir := t.TempDir()
69+ log := testLogger()
70+
71+ storage, err := NewBlobStorage(tmpDir, log)
72+ require.NoError(t, err)
73+
74+ reader, err := storage.GetBlob("sha256:notexists")
75+
76+ assert.Error(t, err)
77+ assert.Nil(t, reader)
78+ assert.Contains(t, err.Error(), "blob not found")
79+}
80+
81+func TestBlobStorage_GetBlobPath(t *testing.T) {
82+ tmpDir := t.TempDir()
83+ log := testLogger()
84+
85+ storage, err := NewBlobStorage(tmpDir, log)
86+ require.NoError(t, err)
87+
88+ blobData := []byte("test blob content")
89+ digest := "sha256:abc123def456"
90+
91+ // Put blob first
92+ err = storage.PutBlob(digest, bytes.NewReader(blobData), int64(len(blobData)))
93+ require.NoError(t, err)
94+
95+ // Get blob path
96+ path, err := storage.GetBlobPath(digest)
97+ require.NoError(t, err)
98+ assert.NotEmpty(t, path)
99+
100+ // Verify file exists at path
101+ _, err = os.Stat(path)
102+ assert.NoError(t, err)
103+}
104+
105+func TestBlobStorage_GetBlobPath_NotFound(t *testing.T) {
106+ tmpDir := t.TempDir()
107+ log := testLogger()
108+
109+ storage, err := NewBlobStorage(tmpDir, log)
110+ require.NoError(t, err)
111+
112+ path, err := storage.GetBlobPath("sha256:notexists")
113+
114+ assert.Error(t, err)
115+ assert.Empty(t, path)
116+ assert.Contains(t, err.Error(), "blob not found")
117+}
118+
119+func TestBlobStorage_PutBlob_SizeMismatch(t *testing.T) {
120+ tmpDir := t.TempDir()
121+ log := testLogger()
122+
123+ storage, err := NewBlobStorage(tmpDir, log)
124+ require.NoError(t, err)
125+
126+ blobData := []byte("test blob content")
127+ digest := "sha256:abc123"
128+
129+ // Put blob with wrong size
130+ err = storage.PutBlob(digest, bytes.NewReader(blobData), 999)
131+
132+ assert.Error(t, err)
133+ assert.Contains(t, err.Error(), "size mismatch")
134+}
135+
136+func TestBlobStorage_PutBlob_ZeroSize(t *testing.T) {
137+ tmpDir := t.TempDir()
138+ log := testLogger()
139+
140+ storage, err := NewBlobStorage(tmpDir, log)
141+ require.NoError(t, err)
142+
143+ blobData := []byte("test blob content")
144+ digest := "sha256:abc123"
145+
146+ // Put blob with size=0 (should skip size check)
147+ err = storage.PutBlob(digest, bytes.NewReader(blobData), 0)
148+
149+ assert.NoError(t, err)
150+
151+ // Verify blob was stored
152+ assert.True(t, storage.BlobExists(digest))
153+}
154+
155+func TestBlobStorage_DeleteBlob(t *testing.T) {
156+ tmpDir := t.TempDir()
157+ log := testLogger()
158+
159+ storage, err := NewBlobStorage(tmpDir, log)
160+ require.NoError(t, err)
161+
162+ blobData := []byte("test blob content")
163+ digest := "sha256:abc123"
164+
165+ // Put blob
166+ err = storage.PutBlob(digest, bytes.NewReader(blobData), int64(len(blobData)))
167+ require.NoError(t, err)
168+
169+ // Verify it exists
170+ assert.True(t, storage.BlobExists(digest))
171+
172+ // Delete blob
173+ err = storage.DeleteBlob(digest)
174+ require.NoError(t, err)
175+
176+ // Verify it's gone
177+ assert.False(t, storage.BlobExists(digest))
178+}
179+
180+func TestBlobStorage_DeleteBlob_NotFound(t *testing.T) {
181+ tmpDir := t.TempDir()
182+ log := testLogger()
183+
184+ storage, err := NewBlobStorage(tmpDir, log)
185+ require.NoError(t, err)
186+
187+ err = storage.DeleteBlob("sha256:notexists")
188+
189+ assert.Error(t, err)
190+ assert.Contains(t, err.Error(), "blob not found")
191+}
192+
193+func TestBlobStorage_BlobExists(t *testing.T) {
194+ tmpDir := t.TempDir()
195+ log := testLogger()
196+
197+ storage, err := NewBlobStorage(tmpDir, log)
198+ require.NoError(t, err)
199+
200+ digest := "sha256:abc123"
201+
202+ // Should not exist initially
203+ assert.False(t, storage.BlobExists(digest))
204+
205+ // Put blob
206+ blobData := []byte("test blob content")
207+ err = storage.PutBlob(digest, bytes.NewReader(blobData), int64(len(blobData)))
208+ require.NoError(t, err)
209+
210+ // Should exist now
211+ assert.True(t, storage.BlobExists(digest))
212+}
213+
214+func TestBlobStorage_StartBlobUpload(t *testing.T) {
215+ tmpDir := t.TempDir()
216+ log := testLogger()
217+
218+ storage, err := NewBlobStorage(tmpDir, log)
219+ require.NoError(t, err)
220+
221+ uuid, err := storage.StartBlobUpload("myapp")
222+
223+ require.NoError(t, err)
224+ assert.NotEmpty(t, uuid)
225+ assert.Contains(t, uuid, "myapp")
226+
227+ // Verify upload file was created
228+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
229+ _, err = os.Stat(uploadPath)
230+ assert.NoError(t, err)
231+}
232+
233+func TestBlobStorage_AppendBlobChunk(t *testing.T) {
234+ tmpDir := t.TempDir()
235+ log := testLogger()
236+
237+ storage, err := NewBlobStorage(tmpDir, log)
238+ require.NoError(t, err)
239+
240+ uuid, err := storage.StartBlobUpload("myapp")
241+ require.NoError(t, err)
242+
243+ // Append first chunk
244+ chunk1 := []byte("first chunk")
245+ size1, err := storage.AppendBlobChunk("myapp", uuid, chunk1)
246+ require.NoError(t, err)
247+ assert.Equal(t, int64(len(chunk1)), size1)
248+
249+ // Append second chunk
250+ chunk2 := []byte(" second chunk")
251+ size2, err := storage.AppendBlobChunk("myapp", uuid, chunk2)
252+ require.NoError(t, err)
253+ assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)
254+}
255+
256+func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
257+ tmpDir := t.TempDir()
258+ log := testLogger()
259+
260+ storage, err := NewBlobStorage(tmpDir, log)
261+ require.NoError(t, err)
262+
263+ _, err = storage.AppendBlobChunk("myapp", "nonexistent-uuid", []byte("data"))
264+
265+ assert.Error(t, err)
266+ assert.Contains(t, err.Error(), "upload not found")
267+}
268+
269+func TestBlobStorage_GetBlobUpload(t *testing.T) {
270+ tmpDir := t.TempDir()
271+ log := testLogger()
272+
273+ storage, err := NewBlobStorage(tmpDir, log)
274+ require.NoError(t, err)
275+
276+ uuid, err := storage.StartBlobUpload("myapp")
277+ require.NoError(t, err)
278+
279+ writer, err := storage.GetBlobUpload(uuid)
280+ require.NoError(t, err)
281+ defer writer.Close()
282+
283+ // Write some data
284+ _, err = writer.Write([]byte("test data"))
285+ assert.NoError(t, err)
286+}
287+
288+func TestBlobStorage_GetBlobUpload_NotFound(t *testing.T) {
289+ tmpDir := t.TempDir()
290+ log := testLogger()
291+
292+ storage, err := NewBlobStorage(tmpDir, log)
293+ require.NoError(t, err)
294+
295+ writer, err := storage.GetBlobUpload("nonexistent-uuid")
296+
297+ assert.Error(t, err)
298+ assert.Nil(t, writer)
299+ assert.Contains(t, err.Error(), "upload not found")
300+}
301+
302+func TestBlobStorage_FinishBlobUpload(t *testing.T) {
303+ tmpDir := t.TempDir()
304+ log := testLogger()
305+
306+ storage, err := NewBlobStorage(tmpDir, log)
307+ require.NoError(t, err)
308+
309+ // Start upload
310+ uuid, err := storage.StartBlobUpload("myapp")
311+ require.NoError(t, err)
312+
313+ // Append data
314+ blobData := []byte("complete blob content")
315+ _, err = storage.AppendBlobChunk("myapp", uuid, blobData)
316+ require.NoError(t, err)
317+
318+ // Finish upload
319+ digest := "sha256:finished123"
320+ err = storage.FinishBlobUpload(uuid, digest)
321+ require.NoError(t, err)
322+
323+ // Verify blob exists at final location
324+ assert.True(t, storage.BlobExists(digest))
325+
326+ // Verify upload file is gone
327+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
328+ _, err = os.Stat(uploadPath)
329+ assert.True(t, os.IsNotExist(err))
330+}
331+
332+func TestBlobStorage_CancelBlobUpload(t *testing.T) {
333+ tmpDir := t.TempDir()
334+ log := testLogger()
335+
336+ storage, err := NewBlobStorage(tmpDir, log)
337+ require.NoError(t, err)
338+
339+ // Start upload
340+ uuid, err := storage.StartBlobUpload("myapp")
341+ require.NoError(t, err)
342+
343+ // Verify upload file exists
344+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
345+ _, err = os.Stat(uploadPath)
346+ require.NoError(t, err)
347+
348+ // Cancel upload
349+ err = storage.CancelBlobUpload(uuid)
350+ require.NoError(t, err)
351+
352+ // Verify upload file is gone
353+ _, err = os.Stat(uploadPath)
354+ assert.True(t, os.IsNotExist(err))
355+}
356+
357+func TestBlobStorage_CancelBlobUpload_NotFound(t *testing.T) {
358+ tmpDir := t.TempDir()
359+ log := testLogger()
360+
361+ storage, err := NewBlobStorage(tmpDir, log)
362+ require.NoError(t, err)
363+
364+ err = storage.CancelBlobUpload("nonexistent-uuid")
365+
366+ assert.Error(t, err)
367+ assert.Contains(t, err.Error(), "upload not found")
368+}
369+
370+func TestBlobStorage_BlobPathStructure(t *testing.T) {
371+ tmpDir := t.TempDir()
372+ log := testLogger()
373+
374+ storage, err := NewBlobStorage(tmpDir, log)
375+ require.NoError(t, err)
376+
377+ // Test that blobs are stored with proper directory structure
378+ blobData := []byte("test blob content")
379+ digest := "sha256:abcdef123456"
380+
381+ err = storage.PutBlob(digest, bytes.NewReader(blobData), int64(len(blobData)))
382+ require.NoError(t, err)
383+
384+ // Check the path structure: blobs/sha256/ab/abcdef123456
385+ expectedPath := filepath.Join(tmpDir, "blobs", "sha256", "ab", "abcdef123456")
386+ _, err = os.Stat(expectedPath)
387+ assert.NoError(t, err)
388+}
389+
390+func TestBlobStorage_DigestFormats(t *testing.T) {
391+ tmpDir := t.TempDir()
392+ log := testLogger()
393+
394+ storage, err := NewBlobStorage(tmpDir, log)
395+ require.NoError(t, err)
396+
397+ tests := []struct {
398+ name string
399+ digest string
400+ }{
401+ {"sha256 standard", "sha256:abc123def456"},
402+ {"sha512", "sha512:xyz789abc"},
403+ {"short hash", "sha256:ab"},
404+ {"long hash", "sha256:abcdefghijklmnopqrstuvwxyz0123456789"},
405+ }
406+
407+ for _, tt := range tests {
408+ t.Run(tt.name, func(t *testing.T) {
409+ blobData := []byte("test content for " + tt.digest)
410+
411+ err := storage.PutBlob(tt.digest, bytes.NewReader(blobData), int64(len(blobData)))
412+ require.NoError(t, err)
413+
414+ assert.True(t, storage.BlobExists(tt.digest))
415+
416+ reader, err := storage.GetBlob(tt.digest)
417+ require.NoError(t, err)
418+ defer reader.Close()
419+
420+ data, err := io.ReadAll(reader)
421+ require.NoError(t, err)
422+ assert.Equal(t, blobData, data)
423+ })
424+ }
425+}
426+
427+func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
428+ tmpDir := t.TempDir()
429+ log := testLogger()
430+
431+ storage, err := NewBlobStorage(tmpDir, log)
432+ require.NoError(t, err)
433+
434+ // Simulate a complete upload flow
435+ repoName := "myorg/myapp"
436+ digest := "sha256:completedupload123"
437+
438+ // 1. Start upload
439+ uuid, err := storage.StartBlobUpload(repoName)
440+ require.NoError(t, err)
441+
442+ // 2. Send multiple chunks
443+ chunks := [][]byte{
444+ []byte("first chunk of data"),
445+ []byte(" - middle chunk"),
446+ []byte(" - final chunk"),
447+ }
448+
449+ var totalSize int64
450+ for _, chunk := range chunks {
451+ size, err := storage.AppendBlobChunk(repoName, uuid, chunk)
452+ require.NoError(t, err)
453+ totalSize = size
454+ }
455+
456+ expectedTotal := int64(len(chunks[0]) + len(chunks[1]) + len(chunks[2]))
457+ assert.Equal(t, expectedTotal, totalSize)
458+
459+ // 3. Finish upload
460+ err = storage.FinishBlobUpload(uuid, digest)
461+ require.NoError(t, err)
462+
463+ // 4. Verify blob is accessible
464+ reader, err := storage.GetBlob(digest)
465+ require.NoError(t, err)
466+ defer reader.Close()
467+
468+ data, err := io.ReadAll(reader)
469+ require.NoError(t, err)
470+
471+ expectedData := "first chunk of data - middle chunk - final chunk"
472+ assert.Equal(t, expectedData, string(data))
473+}
A internal/adapters/out/filesystem/manifest.go +304 -0
@@ -0,0 +1,304 @@
1+package filesystem
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/zerowrap"
10+)
11+
12+// ManifestStorage implements the ManifestStorage interface using the local filesystem.
13+type ManifestStorage struct {
14+ rootDir string
15+ log zerowrap.Logger
16+}
17+
18+// NewManifestStorage creates a new filesystem manifest storage instance.
19+func NewManifestStorage(rootDir string, log zerowrap.Logger) (*ManifestStorage, error) {
20+ // Create directory structure if it doesn't exist
21+ reposDir := filepath.Join(rootDir, "repositories")
22+ if err := os.MkdirAll(reposDir, 0750); err != nil {
23+ return nil, fmt.Errorf("failed to create repositories directory: %w", err)
24+ }
25+
26+ log.Info().
27+ Str(zerowrap.FieldLayer, "adapter").
28+ Str(zerowrap.FieldAdapter, "filesystem").
29+ Str("root_dir", rootDir).
30+ Msg("manifest storage initialized")
31+
32+ return &ManifestStorage{
33+ rootDir: rootDir,
34+ log: log,
35+ }, nil
36+}
37+
38+// GetManifest retrieves a manifest by name and reference.
39+// Returns the manifest data and content type.
40+func (s *ManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
41+ manifestPath := s.getManifestPath(name, reference)
42+
43+ data, err := os.ReadFile(manifestPath)
44+ if err != nil {
45+ if os.IsNotExist(err) {
46+ return nil, "", fmt.Errorf("manifest not found: %s/%s", name, reference)
47+ }
48+ return nil, "", fmt.Errorf("failed to read manifest: %w", err)
49+ }
50+
51+ contentType, err := s.getManifestContentType(name, reference)
52+ if err != nil {
53+ // Fallback for manifests stored before content type tracking
54+ s.log.Warn().
55+ Str(zerowrap.FieldLayer, "adapter").
56+ Str(zerowrap.FieldAdapter, "filesystem").
57+ Err(err).
58+ Str("name", name).
59+ Str("reference", reference).
60+ Msg("could not get manifest content type, falling back to default")
61+ contentType = "application/vnd.docker.distribution.manifest.v2+json"
62+ }
63+
64+ return data, contentType, nil
65+}
66+
67+// PutManifest stores a manifest.
68+func (s *ManifestStorage) PutManifest(name, reference, contentType string, data []byte) error {
69+ manifestPath := s.getManifestPath(name, reference)
70+
71+ // Create directory if it doesn't exist
72+ if err := os.MkdirAll(filepath.Dir(manifestPath), 0750); err != nil {
73+ return fmt.Errorf("failed to create manifest directory: %w", err)
74+ }
75+
76+ // Write manifest file
77+ if err := os.WriteFile(manifestPath, data, 0600); err != nil {
78+ return fmt.Errorf("failed to write manifest: %w", err)
79+ }
80+
81+ // Store content type
82+ if err := s.putManifestContentType(name, reference, contentType); err != nil {
83+ // Don't fail the whole operation, but log a warning
84+ s.log.Warn().
85+ Str(zerowrap.FieldLayer, "adapter").
86+ Str(zerowrap.FieldAdapter, "filesystem").
87+ Err(err).
88+ Str("name", name).
89+ Str("reference", reference).
90+ Msg("failed to store manifest content type")
91+ }
92+
93+ // Update tags list
94+ if err := s.updateTagsList(name, reference); err != nil {
95+ s.log.Warn().
96+ Str(zerowrap.FieldLayer, "adapter").
97+ Str(zerowrap.FieldAdapter, "filesystem").
98+ Err(err).
99+ Str("name", name).
100+ Str("reference", reference).
101+ Msg("failed to update tags list")
102+ }
103+
104+ s.log.Info().
105+ Str(zerowrap.FieldLayer, "adapter").
106+ Str(zerowrap.FieldAdapter, "filesystem").
107+ Str("name", name).
108+ Str("reference", reference).
109+ Msg("manifest stored")
110+
111+ return nil
112+}
113+
114+// DeleteManifest removes a manifest.
115+func (s *ManifestStorage) DeleteManifest(name, reference string) error {
116+ manifestPath := s.getManifestPath(name, reference)
117+
118+ if err := os.Remove(manifestPath); err != nil {
119+ if os.IsNotExist(err) {
120+ return fmt.Errorf("manifest not found: %s/%s", name, reference)
121+ }
122+ return fmt.Errorf("failed to delete manifest: %w", err)
123+ }
124+
125+ // Delete content type file
126+ if err := s.deleteManifestContentType(name, reference); err != nil {
127+ s.log.Warn().
128+ Str(zerowrap.FieldLayer, "adapter").
129+ Str(zerowrap.FieldAdapter, "filesystem").
130+ Err(err).
131+ Str("name", name).
132+ Str("reference", reference).
133+ Msg("failed to delete manifest content type")
134+ }
135+
136+ // Remove from tags list
137+ if err := s.removeFromTagsList(name, reference); err != nil {
138+ s.log.Warn().
139+ Str(zerowrap.FieldLayer, "adapter").
140+ Str(zerowrap.FieldAdapter, "filesystem").
141+ Err(err).
142+ Str("name", name).
143+ Str("reference", reference).
144+ Msg("failed to update tags list")
145+ }
146+
147+ s.log.Info().
148+ Str(zerowrap.FieldLayer, "adapter").
149+ Str(zerowrap.FieldAdapter, "filesystem").
150+ Str("name", name).
151+ Str("reference", reference).
152+ Msg("manifest deleted")
153+
154+ return nil
155+}
156+
157+// ListTags returns all tags for a repository.
158+func (s *ManifestStorage) ListTags(name string) ([]string, error) {
159+ tagsPath := s.getTagsPath(name)
160+
161+ data, err := os.ReadFile(tagsPath)
162+ if err != nil {
163+ if os.IsNotExist(err) {
164+ return []string{}, nil // No tags file means no tags
165+ }
166+ return nil, fmt.Errorf("failed to read tags file: %w", err)
167+ }
168+
169+ var tags []string
170+ if err := json.Unmarshal(data, &tags); err != nil {
171+ return nil, fmt.Errorf("failed to parse tags file: %w", err)
172+ }
173+
174+ return tags, nil
175+}
176+
177+// ListRepositories returns all repository names.
178+func (s *ManifestStorage) ListRepositories() ([]string, error) {
179+ reposDir := filepath.Join(s.rootDir, "repositories")
180+
181+ var repositories []string
182+ err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error {
183+ if err != nil {
184+ return err
185+ }
186+
187+ if info.IsDir() && path != reposDir {
188+ // Get relative path from repositories directory
189+ relPath, err := filepath.Rel(reposDir, path)
190+ if err != nil {
191+ return err
192+ }
193+
194+ // Check if this directory contains manifests
195+ manifestsDir := filepath.Join(path, "manifests")
196+ if _, err := os.Stat(manifestsDir); err == nil {
197+ repositories = append(repositories, relPath)
198+ }
199+ }
200+
201+ return nil
202+ })
203+
204+ if err != nil {
205+ return nil, fmt.Errorf("failed to list repositories: %w", err)
206+ }
207+
208+ return repositories, nil
209+}
210+
211+// Helper methods for path generation
212+
213+func (s *ManifestStorage) getManifestPath(name, reference string) string {
214+ return filepath.Join(s.rootDir, "repositories", name, "manifests", reference)
215+}
216+
217+func (s *ManifestStorage) getManifestContentTypePath(name, reference string) string {
218+ return s.getManifestPath(name, reference) + ".contenttype"
219+}
220+
221+func (s *ManifestStorage) getTagsPath(name string) string {
222+ return filepath.Join(s.rootDir, "repositories", name, "tags.json")
223+}
224+
225+// Content type helpers
226+
227+func (s *ManifestStorage) getManifestContentType(name, reference string) (string, error) {
228+ contentTypePath := s.getManifestContentTypePath(name, reference)
229+ data, err := os.ReadFile(contentTypePath)
230+ if err != nil {
231+ return "", err
232+ }
233+ return string(data), nil
234+}
235+
236+func (s *ManifestStorage) putManifestContentType(name, reference, contentType string) error {
237+ contentTypePath := s.getManifestContentTypePath(name, reference)
238+ return os.WriteFile(contentTypePath, []byte(contentType), 0600)
239+}
240+
241+func (s *ManifestStorage) deleteManifestContentType(name, reference string) error {
242+ contentTypePath := s.getManifestContentTypePath(name, reference)
243+ err := os.Remove(contentTypePath)
244+ if err != nil && !os.IsNotExist(err) {
245+ return err
246+ }
247+ return nil
248+}
249+
250+// Tags management helpers
251+
252+func (s *ManifestStorage) updateTagsList(name, reference string) error {
253+ tags, err := s.ListTags(name)
254+ if err != nil {
255+ return err
256+ }
257+
258+ // Add tag if not already present
259+ for _, tag := range tags {
260+ if tag == reference {
261+ return nil // Already exists
262+ }
263+ }
264+
265+ tags = append(tags, reference)
266+ return s.saveTagsList(name, tags)
267+}
268+
269+func (s *ManifestStorage) removeFromTagsList(name, reference string) error {
270+ tags, err := s.ListTags(name)
271+ if err != nil {
272+ return err
273+ }
274+
275+ // Remove tag if present
276+ var newTags []string
277+ for _, tag := range tags {
278+ if tag != reference {
279+ newTags = append(newTags, tag)
280+ }
281+ }
282+
283+ return s.saveTagsList(name, newTags)
284+}
285+
286+func (s *ManifestStorage) saveTagsList(name string, tags []string) error {
287+ tagsPath := s.getTagsPath(name)
288+
289+ // Create directory if it doesn't exist
290+ if err := os.MkdirAll(filepath.Dir(tagsPath), 0750); err != nil {
291+ return fmt.Errorf("failed to create tags directory: %w", err)
292+ }
293+
294+ data, err := json.Marshal(tags)
295+ if err != nil {
296+ return fmt.Errorf("failed to marshal tags: %w", err)
297+ }
298+
299+ if err := os.WriteFile(tagsPath, data, 0600); err != nil {
300+ return fmt.Errorf("failed to write tags file: %w", err)
301+ }
302+
303+ return nil
304+}
A internal/adapters/out/filesystem/manifest_test.go +440 -0
@@ -0,0 +1,440 @@
1+package filesystem
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestNewManifestStorage(t *testing.T) {
13+ tmpDir := t.TempDir()
14+ log := testLogger()
15+
16+ storage, err := NewManifestStorage(tmpDir, log)
17+
18+ require.NoError(t, err)
19+ assert.NotNil(t, storage)
20+
21+ // Verify repositories directory was created
22+ assert.DirExists(t, filepath.Join(tmpDir, "repositories"))
23+}
24+
25+func TestNewManifestStorage_InvalidPath(t *testing.T) {
26+ log := testLogger()
27+
28+ _, err := NewManifestStorage("/nonexistent/deeply/nested/path/that/should/fail", log)
29+
30+ assert.Error(t, err)
31+}
32+
33+func TestManifestStorage_PutAndGetManifest(t *testing.T) {
34+ tmpDir := t.TempDir()
35+ log := testLogger()
36+
37+ storage, err := NewManifestStorage(tmpDir, log)
38+ require.NoError(t, err)
39+
40+ manifestData := []byte(`{"schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json"}`)
41+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
42+
43+ // Put manifest
44+ err = storage.PutManifest("myapp", "latest", contentType, manifestData)
45+ require.NoError(t, err)
46+
47+ // Get manifest
48+ data, ct, err := storage.GetManifest("myapp", "latest")
49+ require.NoError(t, err)
50+
51+ assert.Equal(t, manifestData, data)
52+ assert.Equal(t, contentType, ct)
53+}
54+
55+func TestManifestStorage_GetManifest_NotFound(t *testing.T) {
56+ tmpDir := t.TempDir()
57+ log := testLogger()
58+
59+ storage, err := NewManifestStorage(tmpDir, log)
60+ require.NoError(t, err)
61+
62+ data, ct, err := storage.GetManifest("notexists", "latest")
63+
64+ assert.Error(t, err)
65+ assert.Nil(t, data)
66+ assert.Empty(t, ct)
67+ assert.Contains(t, err.Error(), "manifest not found")
68+}
69+
70+func TestManifestStorage_GetManifest_FallbackContentType(t *testing.T) {
71+ tmpDir := t.TempDir()
72+ log := testLogger()
73+
74+ storage, err := NewManifestStorage(tmpDir, log)
75+ require.NoError(t, err)
76+
77+ // Create manifest file directly without content type file
78+ manifestDir := filepath.Join(tmpDir, "repositories", "myapp", "manifests")
79+ err = os.MkdirAll(manifestDir, 0750)
80+ require.NoError(t, err)
81+
82+ manifestData := []byte(`{"schemaVersion": 2}`)
83+ err = os.WriteFile(filepath.Join(manifestDir, "latest"), manifestData, 0600)
84+ require.NoError(t, err)
85+
86+ // Get manifest should use fallback content type
87+ data, ct, err := storage.GetManifest("myapp", "latest")
88+ require.NoError(t, err)
89+
90+ assert.Equal(t, manifestData, data)
91+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", ct)
92+}
93+
94+func TestManifestStorage_DeleteManifest(t *testing.T) {
95+ tmpDir := t.TempDir()
96+ log := testLogger()
97+
98+ storage, err := NewManifestStorage(tmpDir, log)
99+ require.NoError(t, err)
100+
101+ manifestData := []byte(`{"schemaVersion": 2}`)
102+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
103+
104+ // Put manifest
105+ err = storage.PutManifest("myapp", "latest", contentType, manifestData)
106+ require.NoError(t, err)
107+
108+ // Verify it exists
109+ _, _, err = storage.GetManifest("myapp", "latest")
110+ require.NoError(t, err)
111+
112+ // Delete manifest
113+ err = storage.DeleteManifest("myapp", "latest")
114+ require.NoError(t, err)
115+
116+ // Verify it's gone
117+ _, _, err = storage.GetManifest("myapp", "latest")
118+ assert.Error(t, err)
119+}
120+
121+func TestManifestStorage_DeleteManifest_NotFound(t *testing.T) {
122+ tmpDir := t.TempDir()
123+ log := testLogger()
124+
125+ storage, err := NewManifestStorage(tmpDir, log)
126+ require.NoError(t, err)
127+
128+ err = storage.DeleteManifest("notexists", "latest")
129+
130+ assert.Error(t, err)
131+ assert.Contains(t, err.Error(), "manifest not found")
132+}
133+
134+func TestManifestStorage_ListTags(t *testing.T) {
135+ tmpDir := t.TempDir()
136+ log := testLogger()
137+
138+ storage, err := NewManifestStorage(tmpDir, log)
139+ require.NoError(t, err)
140+
141+ manifestData := []byte(`{"schemaVersion": 2}`)
142+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
143+
144+ // Put multiple manifests
145+ err = storage.PutManifest("myapp", "latest", contentType, manifestData)
146+ require.NoError(t, err)
147+ err = storage.PutManifest("myapp", "v1.0", contentType, manifestData)
148+ require.NoError(t, err)
149+ err = storage.PutManifest("myapp", "v2.0", contentType, manifestData)
150+ require.NoError(t, err)
151+
152+ // List tags
153+ tags, err := storage.ListTags("myapp")
154+ require.NoError(t, err)
155+
156+ assert.Len(t, tags, 3)
157+ assert.Contains(t, tags, "latest")
158+ assert.Contains(t, tags, "v1.0")
159+ assert.Contains(t, tags, "v2.0")
160+}
161+
162+func TestManifestStorage_ListTags_Empty(t *testing.T) {
163+ tmpDir := t.TempDir()
164+ log := testLogger()
165+
166+ storage, err := NewManifestStorage(tmpDir, log)
167+ require.NoError(t, err)
168+
169+ // List tags for non-existent repo
170+ tags, err := storage.ListTags("notexists")
171+ require.NoError(t, err)
172+
173+ assert.Empty(t, tags)
174+}
175+
176+func TestManifestStorage_ListTags_AfterDelete(t *testing.T) {
177+ tmpDir := t.TempDir()
178+ log := testLogger()
179+
180+ storage, err := NewManifestStorage(tmpDir, log)
181+ require.NoError(t, err)
182+
183+ manifestData := []byte(`{"schemaVersion": 2}`)
184+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
185+
186+ // Put manifests
187+ err = storage.PutManifest("myapp", "latest", contentType, manifestData)
188+ require.NoError(t, err)
189+ err = storage.PutManifest("myapp", "v1.0", contentType, manifestData)
190+ require.NoError(t, err)
191+
192+ // Delete one
193+ err = storage.DeleteManifest("myapp", "v1.0")
194+ require.NoError(t, err)
195+
196+ // List tags
197+ tags, err := storage.ListTags("myapp")
198+ require.NoError(t, err)
199+
200+ assert.Len(t, tags, 1)
201+ assert.Contains(t, tags, "latest")
202+ assert.NotContains(t, tags, "v1.0")
203+}
204+
205+func TestManifestStorage_ListRepositories(t *testing.T) {
206+ tmpDir := t.TempDir()
207+ log := testLogger()
208+
209+ storage, err := NewManifestStorage(tmpDir, log)
210+ require.NoError(t, err)
211+
212+ manifestData := []byte(`{"schemaVersion": 2}`)
213+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
214+
215+ // Put manifests for different repos
216+ err = storage.PutManifest("app1", "latest", contentType, manifestData)
217+ require.NoError(t, err)
218+ err = storage.PutManifest("app2", "latest", contentType, manifestData)
219+ require.NoError(t, err)
220+ err = storage.PutManifest("org/app3", "latest", contentType, manifestData)
221+ require.NoError(t, err)
222+
223+ // List repositories
224+ repos, err := storage.ListRepositories()
225+ require.NoError(t, err)
226+
227+ assert.Len(t, repos, 3)
228+ assert.Contains(t, repos, "app1")
229+ assert.Contains(t, repos, "app2")
230+ assert.Contains(t, repos, "org/app3")
231+}
232+
233+func TestManifestStorage_ListRepositories_Empty(t *testing.T) {
234+ tmpDir := t.TempDir()
235+ log := testLogger()
236+
237+ storage, err := NewManifestStorage(tmpDir, log)
238+ require.NoError(t, err)
239+
240+ repos, err := storage.ListRepositories()
241+ require.NoError(t, err)
242+
243+ assert.Empty(t, repos)
244+}
245+
246+func TestManifestStorage_NestedRepositoryNames(t *testing.T) {
247+ tmpDir := t.TempDir()
248+ log := testLogger()
249+
250+ storage, err := NewManifestStorage(tmpDir, log)
251+ require.NoError(t, err)
252+
253+ manifestData := []byte(`{"schemaVersion": 2}`)
254+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
255+
256+ tests := []struct {
257+ name string
258+ reference string
259+ }{
260+ {"simple", "latest"},
261+ {"org/project", "v1.0"},
262+ {"org/team/project", "v2.0"},
263+ {"deeply/nested/repo/name", "sha256:abc123"},
264+ }
265+
266+ for _, tt := range tests {
267+ t.Run(tt.name, func(t *testing.T) {
268+ err := storage.PutManifest(tt.name, tt.reference, contentType, manifestData)
269+ require.NoError(t, err)
270+
271+ data, ct, err := storage.GetManifest(tt.name, tt.reference)
272+ require.NoError(t, err)
273+ assert.Equal(t, manifestData, data)
274+ assert.Equal(t, contentType, ct)
275+ })
276+ }
277+}
278+
279+func TestManifestStorage_ContentTypes(t *testing.T) {
280+ tmpDir := t.TempDir()
281+ log := testLogger()
282+
283+ storage, err := NewManifestStorage(tmpDir, log)
284+ require.NoError(t, err)
285+
286+ tests := []struct {
287+ name string
288+ contentType string
289+ }{
290+ {"v2 manifest", "application/vnd.docker.distribution.manifest.v2+json"},
291+ {"v1 manifest", "application/vnd.docker.distribution.manifest.v1+json"},
292+ {"OCI manifest", "application/vnd.oci.image.manifest.v1+json"},
293+ {"OCI index", "application/vnd.oci.image.index.v1+json"},
294+ {"manifest list", "application/vnd.docker.distribution.manifest.list.v2+json"},
295+ }
296+
297+ for i, tt := range tests {
298+ t.Run(tt.name, func(t *testing.T) {
299+ repoName := "test-repo"
300+ reference := "tag-" + string(rune('a'+i))
301+ manifestData := []byte(`{"schemaVersion": 2}`)
302+
303+ err := storage.PutManifest(repoName, reference, tt.contentType, manifestData)
304+ require.NoError(t, err)
305+
306+ _, ct, err := storage.GetManifest(repoName, reference)
307+ require.NoError(t, err)
308+ assert.Equal(t, tt.contentType, ct)
309+ })
310+ }
311+}
312+
313+func TestManifestStorage_DuplicateTag(t *testing.T) {
314+ tmpDir := t.TempDir()
315+ log := testLogger()
316+
317+ storage, err := NewManifestStorage(tmpDir, log)
318+ require.NoError(t, err)
319+
320+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
321+
322+ // Put first manifest
323+ manifestData1 := []byte(`{"schemaVersion": 2, "version": 1}`)
324+ err = storage.PutManifest("myapp", "latest", contentType, manifestData1)
325+ require.NoError(t, err)
326+
327+ // Put second manifest with same tag (overwrite)
328+ manifestData2 := []byte(`{"schemaVersion": 2, "version": 2}`)
329+ err = storage.PutManifest("myapp", "latest", contentType, manifestData2)
330+ require.NoError(t, err)
331+
332+ // Get manifest should return the latest one
333+ data, _, err := storage.GetManifest("myapp", "latest")
334+ require.NoError(t, err)
335+ assert.Equal(t, manifestData2, data)
336+
337+ // Tags list should only have one entry
338+ tags, err := storage.ListTags("myapp")
339+ require.NoError(t, err)
340+ assert.Len(t, tags, 1)
341+}
342+
343+func TestManifestStorage_LargeManifest(t *testing.T) {
344+ tmpDir := t.TempDir()
345+ log := testLogger()
346+
347+ storage, err := NewManifestStorage(tmpDir, log)
348+ require.NoError(t, err)
349+
350+ // Create a large manifest (simulating one with many layers)
351+ largeManifest := make([]byte, 1024*1024) // 1MB
352+ for i := range largeManifest {
353+ largeManifest[i] = byte('a' + (i % 26))
354+ }
355+
356+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
357+
358+ err = storage.PutManifest("large-app", "latest", contentType, largeManifest)
359+ require.NoError(t, err)
360+
361+ data, _, err := storage.GetManifest("large-app", "latest")
362+ require.NoError(t, err)
363+ assert.Equal(t, largeManifest, data)
364+}
365+
366+func TestManifestStorage_SpecialCharactersInReference(t *testing.T) {
367+ tmpDir := t.TempDir()
368+ log := testLogger()
369+
370+ storage, err := NewManifestStorage(tmpDir, log)
371+ require.NoError(t, err)
372+
373+ manifestData := []byte(`{"schemaVersion": 2}`)
374+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
375+
376+ // SHA256 digests are valid references
377+ reference := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
378+
379+ err = storage.PutManifest("myapp", reference, contentType, manifestData)
380+ require.NoError(t, err)
381+
382+ data, _, err := storage.GetManifest("myapp", reference)
383+ require.NoError(t, err)
384+ assert.Equal(t, manifestData, data)
385+}
386+
387+func TestManifestStorage_CompleteWorkflow(t *testing.T) {
388+ tmpDir := t.TempDir()
389+ log := testLogger()
390+
391+ storage, err := NewManifestStorage(tmpDir, log)
392+ require.NoError(t, err)
393+
394+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
395+
396+ // Simulate a complete registry workflow
397+ // 1. Push first version
398+ v1Data := []byte(`{"schemaVersion": 2, "version": "1.0"}`)
399+ err = storage.PutManifest("myorg/myapp", "v1.0", contentType, v1Data)
400+ require.NoError(t, err)
401+ err = storage.PutManifest("myorg/myapp", "latest", contentType, v1Data)
402+ require.NoError(t, err)
403+
404+ // 2. Push second version
405+ v2Data := []byte(`{"schemaVersion": 2, "version": "2.0"}`)
406+ err = storage.PutManifest("myorg/myapp", "v2.0", contentType, v2Data)
407+ require.NoError(t, err)
408+ err = storage.PutManifest("myorg/myapp", "latest", contentType, v2Data)
409+ require.NoError(t, err)
410+
411+ // 3. Verify tags
412+ tags, err := storage.ListTags("myorg/myapp")
413+ require.NoError(t, err)
414+ assert.Len(t, tags, 3)
415+
416+ // 4. Verify latest points to v2
417+ data, _, err := storage.GetManifest("myorg/myapp", "latest")
418+ require.NoError(t, err)
419+ assert.Equal(t, v2Data, data)
420+
421+ // 5. Verify v1 is still accessible
422+ data, _, err = storage.GetManifest("myorg/myapp", "v1.0")
423+ require.NoError(t, err)
424+ assert.Equal(t, v1Data, data)
425+
426+ // 6. Delete old version
427+ err = storage.DeleteManifest("myorg/myapp", "v1.0")
428+ require.NoError(t, err)
429+
430+ // 7. Verify tags updated
431+ tags, err = storage.ListTags("myorg/myapp")
432+ require.NoError(t, err)
433+ assert.Len(t, tags, 2)
434+ assert.NotContains(t, tags, "v1.0")
435+
436+ // 8. List repositories
437+ repos, err := storage.ListRepositories()
438+ require.NoError(t, err)
439+ assert.Contains(t, repos, "myorg/myapp")
440+}
A internal/adapters/out/logwriter/logwriter.go +179 -0
@@ -0,0 +1,179 @@
1+// Package logwriter provides container log collection with file rotation.
2+package logwriter
3+
4+import (
5+ "context"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/docker/docker/pkg/stdcopy"
14+ "gopkg.in/natefinch/lumberjack.v2"
15+)
16+
17+// Config holds the configuration for the log writer.
18+type Config struct {
19+ // Dir is the directory where container logs are stored.
20+ Dir string
21+ // MaxSize is the maximum size in megabytes before rotation.
22+ MaxSize int
23+ // MaxBackups is the number of old log files to retain.
24+ MaxBackups int
25+ // MaxAge is the maximum number of days to retain old log files.
26+ MaxAge int
27+}
28+
29+// LogWriter implements the ContainerLogWriter interface.
30+type LogWriter struct {
31+ config Config
32+ streams map[string]*streamInfo
33+ mu sync.RWMutex
34+}
35+
36+// streamInfo tracks an active log stream for a container.
37+type streamInfo struct {
38+ containerID string
39+ domain string
40+ logStream io.ReadCloser
41+ logger *lumberjack.Logger
42+ cancel context.CancelFunc
43+ done chan struct{}
44+}
45+
46+// New creates a new LogWriter.
47+func New(config Config) (*LogWriter, error) {
48+ // Ensure the log directory exists
49+ if err := os.MkdirAll(config.Dir, 0700); err != nil {
50+ return nil, err
51+ }
52+
53+ return &LogWriter{
54+ config: config,
55+ streams: make(map[string]*streamInfo),
56+ }, nil
57+}
58+
59+// StartLogging begins collecting logs for a container.
60+func (w *LogWriter) StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error {
61+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
62+ zerowrap.FieldLayer: "adapter",
63+ zerowrap.FieldAdapter: "logwriter",
64+ zerowrap.FieldAction: "StartLogging",
65+ zerowrap.FieldEntityID: containerID,
66+ "domain": domain,
67+ })
68+ log := zerowrap.FromCtx(ctx)
69+
70+ w.mu.Lock()
71+ defer w.mu.Unlock()
72+
73+ // Stop existing logging for this container if any
74+ if existing, ok := w.streams[containerID]; ok {
75+ log.Debug().Msg("stopping existing log stream before starting new one")
76+ w.stopStreamLocked(existing)
77+ }
78+
79+ // Sanitize domain for filename
80+ filename := sanitizeDomain(domain) + ".log"
81+ logPath := filepath.Join(w.config.Dir, filename)
82+
83+ // Create lumberjack logger for rotation
84+ logger := &lumberjack.Logger{
85+ Filename: logPath,
86+ MaxSize: w.config.MaxSize,
87+ MaxBackups: w.config.MaxBackups,
88+ MaxAge: w.config.MaxAge,
89+ Compress: true,
90+ }
91+
92+ // Create cancellable context for the streaming goroutine
93+ streamCtx, cancel := context.WithCancel(context.Background())
94+ done := make(chan struct{})
95+
96+ info := &streamInfo{
97+ containerID: containerID,
98+ domain: domain,
99+ logStream: logStream,
100+ logger: logger,
101+ cancel: cancel,
102+ done: done,
103+ }
104+
105+ w.streams[containerID] = info
106+
107+ // Start streaming goroutine
108+ go w.streamLogs(streamCtx, info, log)
109+
110+ log.Info().Str("path", logPath).Msg("started container log collection")
111+ return nil
112+}
113+
114+// StopLogging stops log collection for a container.
115+func (w *LogWriter) StopLogging(containerID string) error {
116+ w.mu.Lock()
117+ defer w.mu.Unlock()
118+
119+ if info, ok := w.streams[containerID]; ok {
120+ w.stopStreamLocked(info)
121+ delete(w.streams, containerID)
122+ }
123+ return nil
124+}
125+
126+// Close stops all logging and releases resources.
127+func (w *LogWriter) Close() error {
128+ w.mu.Lock()
129+ defer w.mu.Unlock()
130+
131+ for containerID, info := range w.streams {
132+ w.stopStreamLocked(info)
133+ delete(w.streams, containerID)
134+ }
135+ return nil
136+}
137+
138+// stopStreamLocked stops a stream (must be called with lock held).
139+func (w *LogWriter) stopStreamLocked(info *streamInfo) {
140+ // Signal the goroutine to stop
141+ info.cancel()
142+
143+ // Close the log stream to unblock reads
144+ info.logStream.Close()
145+
146+ // Wait for goroutine to finish
147+ <-info.done
148+
149+ // Close the lumberjack logger
150+ info.logger.Close()
151+}
152+
153+// streamLogs copies from the Docker log stream to the file.
154+func (w *LogWriter) streamLogs(ctx context.Context, info *streamInfo, log zerowrap.Logger) {
155+ defer close(info.done)
156+
157+ // Docker container logs are multiplexed with 8-byte headers.
158+ // stdcopy.StdCopy demultiplexes them to separate stdout/stderr writers.
159+ // We combine both to a single file.
160+ _, err := stdcopy.StdCopy(info.logger, info.logger, info.logStream)
161+
162+ if err != nil && ctx.Err() == nil {
163+ // Only log error if we weren't cancelled
164+ log.Warn().Err(err).
165+ Str("container_id", info.containerID).
166+ Str("domain", info.domain).
167+ Msg("container log streaming ended with error")
168+ }
169+}
170+
171+// sanitizeDomain converts a domain to a safe filename.
172+func sanitizeDomain(domain string) string {
173+ // Replace dots with underscores, remove other unsafe characters
174+ safe := strings.ReplaceAll(domain, ".", "_")
175+ safe = strings.ReplaceAll(safe, "/", "_")
176+ safe = strings.ReplaceAll(safe, ":", "_")
177+ safe = strings.ReplaceAll(safe, " ", "_")
178+ return safe
179+}
A internal/adapters/out/logwriter/logwriter_test.go +283 -0
@@ -0,0 +1,283 @@
1+package logwriter
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestNew(t *testing.T) {
18+ t.Run("creates log directory", func(t *testing.T) {
19+ dir := t.TempDir()
20+ logDir := filepath.Join(dir, "containers")
21+
22+ writer, err := New(Config{
23+ Dir: logDir,
24+ MaxSize: 100,
25+ MaxBackups: 3,
26+ MaxAge: 28,
27+ })
28+
29+ require.NoError(t, err)
30+ require.NotNil(t, writer)
31+ defer writer.Close()
32+
33+ // Verify directory was created
34+ info, err := os.Stat(logDir)
35+ require.NoError(t, err)
36+ assert.True(t, info.IsDir())
37+ })
38+
39+ t.Run("uses existing directory", func(t *testing.T) {
40+ logDir := t.TempDir()
41+
42+ writer, err := New(Config{
43+ Dir: logDir,
44+ MaxSize: 100,
45+ MaxBackups: 3,
46+ MaxAge: 28,
47+ })
48+
49+ require.NoError(t, err)
50+ require.NotNil(t, writer)
51+ defer writer.Close()
52+ })
53+}
54+
55+func TestStartLogging(t *testing.T) {
56+ t.Run("creates log file and writes data", func(t *testing.T) {
57+ logDir := t.TempDir()
58+ writer, err := New(Config{
59+ Dir: logDir,
60+ MaxSize: 100,
61+ MaxBackups: 3,
62+ MaxAge: 28,
63+ })
64+ require.NoError(t, err)
65+ defer writer.Close()
66+
67+ // Create a mock log stream with Docker multiplex format
68+ // Format: 1 byte stream type, 3 bytes reserved, 4 bytes size (big endian), then data
69+ logData := createDockerLogEntry(1, "Hello from container\n")
70+ logStream := io.NopCloser(bytes.NewReader(logData))
71+
72+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream)
73+ require.NoError(t, err)
74+
75+ // Wait for the goroutine to process
76+ time.Sleep(100 * time.Millisecond)
77+
78+ // Verify log file was created
79+ logFile := filepath.Join(logDir, "app_example_com.log")
80+ content, err := os.ReadFile(logFile)
81+ require.NoError(t, err)
82+ assert.Contains(t, string(content), "Hello from container")
83+ })
84+
85+ t.Run("replaces existing log stream for same container", func(t *testing.T) {
86+ logDir := t.TempDir()
87+ writer, err := New(Config{
88+ Dir: logDir,
89+ MaxSize: 100,
90+ MaxBackups: 3,
91+ MaxAge: 28,
92+ })
93+ require.NoError(t, err)
94+ defer writer.Close()
95+
96+ // Start first logging
97+ logData1 := createDockerLogEntry(1, "First log\n")
98+ logStream1 := io.NopCloser(bytes.NewReader(logData1))
99+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream1)
100+ require.NoError(t, err)
101+
102+ time.Sleep(50 * time.Millisecond)
103+
104+ // Start second logging for same container
105+ logData2 := createDockerLogEntry(1, "Second log\n")
106+ logStream2 := io.NopCloser(bytes.NewReader(logData2))
107+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream2)
108+ require.NoError(t, err)
109+
110+ time.Sleep(100 * time.Millisecond)
111+
112+ // Should have both logs (file is appended)
113+ logFile := filepath.Join(logDir, "app_example_com.log")
114+ content, err := os.ReadFile(logFile)
115+ require.NoError(t, err)
116+ assert.Contains(t, string(content), "Second log")
117+ })
118+}
119+
120+func TestStopLogging(t *testing.T) {
121+ t.Run("stops log collection", func(t *testing.T) {
122+ logDir := t.TempDir()
123+ writer, err := New(Config{
124+ Dir: logDir,
125+ MaxSize: 100,
126+ MaxBackups: 3,
127+ MaxAge: 28,
128+ })
129+ require.NoError(t, err)
130+ defer writer.Close()
131+
132+ // Use a pipe so we can control the stream
133+ pr, pw := io.Pipe()
134+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", pr)
135+ require.NoError(t, err)
136+
137+ // Write some data
138+ go func() {
139+ pw.Write(createDockerLogEntry(1, "Test log\n"))
140+ // Keep pipe open until stopped
141+ }()
142+
143+ time.Sleep(50 * time.Millisecond)
144+
145+ // Stop logging
146+ err = writer.StopLogging("container-123")
147+ require.NoError(t, err)
148+
149+ // Clean up pipe
150+ pw.Close()
151+ })
152+
153+ t.Run("noop for non-existent container", func(t *testing.T) {
154+ logDir := t.TempDir()
155+ writer, err := New(Config{
156+ Dir: logDir,
157+ MaxSize: 100,
158+ MaxBackups: 3,
159+ MaxAge: 28,
160+ })
161+ require.NoError(t, err)
162+ defer writer.Close()
163+
164+ // Should not error
165+ err = writer.StopLogging("non-existent")
166+ require.NoError(t, err)
167+ })
168+}
169+
170+func TestClose(t *testing.T) {
171+ t.Run("stops all log streams", func(t *testing.T) {
172+ logDir := t.TempDir()
173+ writer, err := New(Config{
174+ Dir: logDir,
175+ MaxSize: 100,
176+ MaxBackups: 3,
177+ MaxAge: 28,
178+ })
179+ require.NoError(t, err)
180+
181+ // Start multiple log streams
182+ for i := 0; i < 3; i++ {
183+ pr, pw := io.Pipe()
184+ containerID := "container-" + string(rune('a'+i))
185+ domain := "app" + string(rune('0'+i)) + ".example.com"
186+ err := writer.StartLogging(context.Background(), containerID, domain, pr)
187+ require.NoError(t, err)
188+
189+ // Keep pipe alive briefly
190+ go func(pw *io.PipeWriter) {
191+ time.Sleep(50 * time.Millisecond)
192+ pw.Close()
193+ }(pw)
194+ }
195+
196+ time.Sleep(25 * time.Millisecond)
197+
198+ // Close should stop all
199+ err = writer.Close()
200+ require.NoError(t, err)
201+
202+ // Verify all streams are gone
203+ assert.Empty(t, writer.streams)
204+ })
205+}
206+
207+func TestSanitizeDomain(t *testing.T) {
208+ tests := []struct {
209+ input string
210+ expected string
211+ }{
212+ {"app.example.com", "app_example_com"},
213+ {"api.v1.example.com", "api_v1_example_com"},
214+ {"localhost:8080", "localhost_8080"},
215+ {"app/service", "app_service"},
216+ {"app with spaces", "app_with_spaces"},
217+ {"simple", "simple"},
218+ }
219+
220+ for _, tc := range tests {
221+ t.Run(tc.input, func(t *testing.T) {
222+ result := sanitizeDomain(tc.input)
223+ assert.Equal(t, tc.expected, result)
224+ })
225+ }
226+}
227+
228+func TestMultipleContainers(t *testing.T) {
229+ logDir := t.TempDir()
230+ writer, err := New(Config{
231+ Dir: logDir,
232+ MaxSize: 100,
233+ MaxBackups: 3,
234+ MaxAge: 28,
235+ })
236+ require.NoError(t, err)
237+ defer writer.Close()
238+
239+ // Start logging for multiple containers
240+ containers := []struct {
241+ id string
242+ domain string
243+ msg string
244+ }{
245+ {"container-1", "app1.example.com", "Log from app1"},
246+ {"container-2", "app2.example.com", "Log from app2"},
247+ {"container-3", "api.example.com", "Log from api"},
248+ }
249+
250+ for _, c := range containers {
251+ logData := createDockerLogEntry(1, c.msg+"\n")
252+ logStream := io.NopCloser(bytes.NewReader(logData))
253+ err := writer.StartLogging(context.Background(), c.id, c.domain, logStream)
254+ require.NoError(t, err)
255+ }
256+
257+ // Wait for processing
258+ time.Sleep(100 * time.Millisecond)
259+
260+ // Verify each container has its own log file
261+ for _, c := range containers {
262+ filename := strings.ReplaceAll(c.domain, ".", "_") + ".log"
263+ logFile := filepath.Join(logDir, filename)
264+ content, err := os.ReadFile(logFile)
265+ require.NoError(t, err, "Log file should exist for %s", c.domain)
266+ assert.Contains(t, string(content), c.msg)
267+ }
268+}
269+
270+// createDockerLogEntry creates a Docker multiplexed log entry.
271+// streamType: 0=stdin, 1=stdout, 2=stderr
272+func createDockerLogEntry(streamType byte, data string) []byte {
273+ size := len(data)
274+ header := make([]byte, 8)
275+ header[0] = streamType
276+ // bytes 1-3 are reserved (zeros)
277+ header[4] = byte(size >> 24)
278+ header[5] = byte(size >> 16)
279+ header[6] = byte(size >> 8)
280+ header[7] = byte(size)
281+
282+ return append(header, []byte(data)...)
283+}
A internal/adapters/out/secrets/pass.go +67 -0
@@ -0,0 +1,67 @@
1+// Package secrets implements secret provider adapters.
2+package secrets
3+
4+import (
5+ "context"
6+ "fmt"
7+ "os/exec"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// PassProvider implements the SecretProvider interface using the pass password manager.
15+type PassProvider struct {
16+ timeout time.Duration
17+ log zerowrap.Logger
18+}
19+
20+// NewPassProvider creates a new pass provider.
21+func NewPassProvider(log zerowrap.Logger) *PassProvider {
22+ return &PassProvider{
23+ timeout: 10 * time.Second,
24+ log: log,
25+ }
26+}
27+
28+// Name returns the provider name.
29+func (p *PassProvider) Name() string {
30+ return "pass"
31+}
32+
33+// GetSecret retrieves a secret from pass by path.
34+func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, error) {
35+ ctx, cancel := context.WithTimeout(ctx, p.timeout)
36+ defer cancel()
37+
38+ cmd := exec.CommandContext(ctx, "pass", "show", path)
39+ output, err := cmd.Output()
40+ if err != nil {
41+ if exitError, ok := err.(*exec.ExitError); ok {
42+ return "", fmt.Errorf("pass command failed: %s", string(exitError.Stderr))
43+ }
44+ return "", fmt.Errorf("failed to execute pass command: %w", err)
45+ }
46+
47+ secret := strings.TrimSpace(string(output))
48+ if secret == "" {
49+ return "", fmt.Errorf("empty secret returned from pass for path: %s", path)
50+ }
51+
52+ p.log.Debug().
53+ Str(zerowrap.FieldLayer, "adapter").
54+ Str(zerowrap.FieldAdapter, "secrets").
55+ Str("provider", "pass").
56+ Str("path", path).
57+ Msg("successfully retrieved secret from pass")
58+
59+ return secret, nil
60+}
61+
62+// IsAvailable checks if pass is available in the system.
63+func (p *PassProvider) IsAvailable() bool {
64+ cmd := exec.Command("pass", "version")
65+ err := cmd.Run()
66+ return err == nil
67+}
A internal/adapters/out/secrets/sops.go +136 -0
@@ -0,0 +1,136 @@
1+package secrets
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "path/filepath"
8+ "regexp"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+)
14+
15+// sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).
16+var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`)
17+
18+// convertToSopsExtractPath converts dot notation (app.database.password) to
19+// sops --extract Python dictionary syntax (["app"]["database"]["password"]).
20+// Array indices like [0] are preserved as-is.
21+func convertToSopsExtractPath(keyPath string) string {
22+ if keyPath == "" {
23+ return ""
24+ }
25+
26+ var result strings.Builder
27+ parts := strings.Split(keyPath, ".")
28+
29+ for _, part := range parts {
30+ if part == "" {
31+ continue
32+ }
33+ // Check if the part already contains array access like "items[0]"
34+ // Split on '[' to handle cases like "items[0]" -> ["items"] + [0]
35+ if idx := strings.Index(part, "["); idx > 0 {
36+ // Has both key and array index, e.g., "items[0]"
37+ key := part[:idx]
38+ arrayPart := part[idx:] // includes "[0]" or "[0][1]" etc.
39+ result.WriteString(`["`)
40+ result.WriteString(key)
41+ result.WriteString(`"]`)
42+ result.WriteString(arrayPart)
43+ } else if strings.HasPrefix(part, "[") {
44+ // Pure array index like "[0]"
45+ result.WriteString(part)
46+ } else {
47+ // Regular key
48+ result.WriteString(`["`)
49+ result.WriteString(part)
50+ result.WriteString(`"]`)
51+ }
52+ }
53+
54+ return result.String()
55+}
56+
57+// SopsProvider implements the SecretProvider interface using sops.
58+type SopsProvider struct {
59+ timeout time.Duration
60+ log zerowrap.Logger
61+}
62+
63+// NewSopsProvider creates a new sops provider.
64+func NewSopsProvider(log zerowrap.Logger) *SopsProvider {
65+ return &SopsProvider{
66+ timeout: 10 * time.Second,
67+ log: log,
68+ }
69+}
70+
71+// Name returns the provider name.
72+func (s *SopsProvider) Name() string {
73+ return "sops"
74+}
75+
76+// GetSecret retrieves a secret from a sops-encrypted file.
77+// The path format is "file.yaml:key.nested.path".
78+func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, error) {
79+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
80+ defer cancel()
81+
82+ // Extract key from path format: file.yaml:key.nested.path
83+ parts := strings.SplitN(path, ":", 2)
84+ if len(parts) != 2 {
85+ return "", fmt.Errorf("sops path must be in format 'file:key', got: %s", path)
86+ }
87+
88+ filePath := parts[0]
89+ keyPath := parts[1]
90+
91+ // Validate file path to prevent command injection
92+ cleanPath := filepath.Clean(filePath)
93+ if strings.Contains(cleanPath, "..") {
94+ return "", fmt.Errorf("invalid file path: path traversal not allowed")
95+ }
96+
97+ // Validate key path to prevent command injection
98+ if !sopsKeyPathRegex.MatchString(keyPath) {
99+ return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets")
100+ }
101+
102+ // Convert dot notation to sops --extract Python dictionary syntax
103+ extractPath := convertToSopsExtractPath(keyPath)
104+
105+ // Use sops -d --extract to get specific key
106+ // #nosec G204 - filePath and keyPath are validated above
107+ cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
108+ output, err := cmd.Output()
109+ if err != nil {
110+ if exitError, ok := err.(*exec.ExitError); ok {
111+ return "", fmt.Errorf("sops command failed: %s", string(exitError.Stderr))
112+ }
113+ return "", fmt.Errorf("failed to execute sops command: %w", err)
114+ }
115+
116+ secret := strings.TrimSpace(string(output))
117+ if secret == "" {
118+ return "", fmt.Errorf("empty secret returned from sops for path: %s", path)
119+ }
120+
121+ s.log.Debug().
122+ Str(zerowrap.FieldLayer, "adapter").
123+ Str(zerowrap.FieldAdapter, "secrets").
124+ Str("provider", "sops").
125+ Str("path", path).
126+ Msg("successfully retrieved secret from sops")
127+
128+ return secret, nil
129+}
130+
131+// IsAvailable checks if sops is available in the system.
132+func (s *SopsProvider) IsAvailable() bool {
133+ cmd := exec.Command("sops", "--version")
134+ err := cmd.Run()
135+ return err == nil
136+}
A internal/adapters/out/secrets/sops_test.go +83 -0
@@ -0,0 +1,83 @@
1+package secrets
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestConvertToSopsExtractPath(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "empty string",
17+ input: "",
18+ expected: "",
19+ },
20+ {
21+ name: "single key",
22+ input: "password",
23+ expected: `["password"]`,
24+ },
25+ {
26+ name: "two level nested",
27+ input: "app.secret",
28+ expected: `["app"]["secret"]`,
29+ },
30+ {
31+ name: "three level nested",
32+ input: "app.database.password",
33+ expected: `["app"]["database"]["password"]`,
34+ },
35+ {
36+ name: "key with underscores",
37+ input: "app.database_password",
38+ expected: `["app"]["database_password"]`,
39+ },
40+ {
41+ name: "key with hyphens",
42+ input: "app.api-key",
43+ expected: `["app"]["api-key"]`,
44+ },
45+ {
46+ name: "array index only",
47+ input: "[0]",
48+ expected: `[0]`,
49+ },
50+ {
51+ name: "key with array index",
52+ input: "items[0]",
53+ expected: `["items"][0]`,
54+ },
55+ {
56+ name: "nested key with array index",
57+ input: "app.items[0]",
58+ expected: `["app"]["items"][0]`,
59+ },
60+ {
61+ name: "nested key with array then key",
62+ input: "app.items[0].name",
63+ expected: `["app"]["items"][0]["name"]`,
64+ },
65+ {
66+ name: "multiple array indices",
67+ input: "matrix[0][1]",
68+ expected: `["matrix"][0][1]`,
69+ },
70+ {
71+ name: "deeply nested with array",
72+ input: "config.databases[0].credentials.password",
73+ expected: `["config"]["databases"][0]["credentials"]["password"]`,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ result := convertToSopsExtractPath(tt.input)
80+ assert.Equal(t, tt.expected, result)
81+ })
82+ }
83+}
A internal/adapters/out/tokenstore/pass.go +294 -0
@@ -0,0 +1,294 @@
1+// Package tokenstore implements token storage adapters.
2+package tokenstore
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
15+)
16+
17+const (
18+ // passTokenPath is the base path for tokens in pass.
19+ passTokenPath = "gordon/registry/tokens"
20+ // passRevokedPath is the path for the revocation list in pass.
21+ passRevokedPath = "gordon/registry/revoked"
22+)
23+
24+// PassStore implements TokenStore using the pass password manager.
25+type PassStore struct {
26+ timeout time.Duration
27+ log zerowrap.Logger
28+}
29+
30+// NewPassStore creates a new pass-based token store.
31+func NewPassStore(log zerowrap.Logger) *PassStore {
32+ return &PassStore{
33+ timeout: 10 * time.Second,
34+ log: log,
35+ }
36+}
37+
38+// tokenMetadata holds the non-sensitive token information.
39+type tokenMetadata struct {
40+ ID string `json:"id"`
41+ Subject string `json:"subject"`
42+ Scopes []string `json:"scopes"`
43+ IssuedAt time.Time `json:"issued_at"`
44+ ExpiresAt time.Time `json:"expires_at,omitempty"`
45+ Revoked bool `json:"revoked"`
46+}
47+
48+// SaveToken stores a token JWT and metadata in pass.
49+func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
50+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
51+ defer cancel()
52+
53+ // Store JWT
54+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, token.Subject)
55+ if err := s.passInsert(ctx, jwtPath, jwt); err != nil {
56+ return fmt.Errorf("failed to store token: %w", err)
57+ }
58+
59+ // Store metadata
60+ meta := tokenMetadata{
61+ ID: token.ID,
62+ Subject: token.Subject,
63+ Scopes: token.Scopes,
64+ IssuedAt: token.IssuedAt,
65+ ExpiresAt: token.ExpiresAt,
66+ Revoked: token.Revoked,
67+ }
68+ metaJSON, err := json.Marshal(meta)
69+ if err != nil {
70+ return fmt.Errorf("failed to marshal token metadata: %w", err)
71+ }
72+
73+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, token.Subject)
74+ if err := s.passInsert(ctx, metaPath, string(metaJSON)); err != nil {
75+ return fmt.Errorf("failed to store token metadata: %w", err)
76+ }
77+
78+ s.log.Debug().
79+ Str(zerowrap.FieldLayer, "adapter").
80+ Str(zerowrap.FieldAdapter, "tokenstore").
81+ Str("provider", "pass").
82+ Str("subject", token.Subject).
83+ Msg("token stored in pass")
84+
85+ return nil
86+}
87+
88+// GetToken retrieves token JWT by subject from pass.
89+func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
90+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
91+ defer cancel()
92+
93+ // Get JWT
94+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
95+ jwt, err := s.passShow(ctx, jwtPath)
96+ if err != nil {
97+ return "", nil, domain.ErrTokenNotFound
98+ }
99+
100+ // Get metadata
101+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
102+ metaJSON, err := s.passShow(ctx, metaPath)
103+ if err != nil {
104+ return "", nil, fmt.Errorf("failed to get token metadata: %w", err)
105+ }
106+
107+ var meta tokenMetadata
108+ if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
109+ return "", nil, fmt.Errorf("failed to unmarshal token metadata: %w", err)
110+ }
111+
112+ token := &domain.Token{
113+ ID: meta.ID,
114+ Subject: meta.Subject,
115+ Scopes: meta.Scopes,
116+ IssuedAt: meta.IssuedAt,
117+ ExpiresAt: meta.ExpiresAt,
118+ Revoked: meta.Revoked,
119+ }
120+
121+ return jwt, token, nil
122+}
123+
124+// ListTokens returns all stored tokens from pass.
125+func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
126+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
127+ defer cancel()
128+
129+ // List all entries under the token path
130+ cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath)
131+ output, err := cmd.Output()
132+ if err != nil {
133+ // If the path doesn't exist, return empty list
134+ return []domain.Token{}, nil
135+ }
136+
137+ var tokens []domain.Token
138+ lines := strings.Split(string(output), "\n")
139+
140+ for _, line := range lines {
141+ line = strings.TrimSpace(line)
142+ // Skip tree formatting characters and empty lines
143+ line = strings.TrimPrefix(line, "├── ")
144+ line = strings.TrimPrefix(line, "└── ")
145+ line = strings.TrimPrefix(line, "│ ")
146+
147+ // Skip .meta files and empty lines
148+ if line == "" || strings.HasSuffix(line, ".meta") {
149+ continue
150+ }
151+
152+ // Try to get the token metadata
153+ _, token, err := s.GetToken(ctx, line)
154+ if err != nil {
155+ s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token")
156+ continue
157+ }
158+
159+ tokens = append(tokens, *token)
160+ }
161+
162+ return tokens, nil
163+}
164+
165+// Revoke adds token ID to revocation list in pass.
166+func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
167+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
168+ defer cancel()
169+
170+ // Get current revocation list
171+ revokedList, err := s.getRevokedList(ctx)
172+ if err != nil {
173+ return err
174+ }
175+
176+ // Add to list if not already present
177+ for _, id := range revokedList {
178+ if id == tokenID {
179+ return nil // Already revoked
180+ }
181+ }
182+ revokedList = append(revokedList, tokenID)
183+
184+ // Store updated list
185+ listJSON, err := json.Marshal(revokedList)
186+ if err != nil {
187+ return fmt.Errorf("failed to marshal revocation list: %w", err)
188+ }
189+
190+ if err := s.passInsert(ctx, passRevokedPath, string(listJSON)); err != nil {
191+ return fmt.Errorf("failed to store revocation list: %w", err)
192+ }
193+
194+ s.log.Info().
195+ Str(zerowrap.FieldLayer, "adapter").
196+ Str(zerowrap.FieldAdapter, "tokenstore").
197+ Str("token_id", tokenID).
198+ Msg("token revoked")
199+
200+ return nil
201+}
202+
203+// IsRevoked checks if token ID is in revocation list.
204+func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
205+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
206+ defer cancel()
207+
208+ revokedList, err := s.getRevokedList(ctx)
209+ if err != nil {
210+ return false, err
211+ }
212+
213+ for _, id := range revokedList {
214+ if id == tokenID {
215+ return true, nil
216+ }
217+ }
218+
219+ return false, nil
220+}
221+
222+// DeleteToken removes token from pass.
223+func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
224+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
225+ defer cancel()
226+
227+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
228+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
229+
230+ // Remove JWT
231+ // #nosec G204 -- command and args are fixed; subject is treated as data only.
232+ cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
233+ if err := cmd.Run(); err != nil {
234+ return fmt.Errorf("failed to delete token: %w", err)
235+ }
236+
237+ // Remove metadata
238+ // #nosec G204 -- command and args are fixed; subject is treated as data only.
239+ cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
240+ if err := cmd.Run(); err != nil {
241+ s.log.Warn().Err(err).Msg("failed to delete token metadata")
242+ }
243+
244+ s.log.Debug().
245+ Str(zerowrap.FieldLayer, "adapter").
246+ Str(zerowrap.FieldAdapter, "tokenstore").
247+ Str("subject", subject).
248+ Msg("token deleted from pass")
249+
250+ return nil
251+}
252+
253+// passInsert inserts a value into pass.
254+func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
255+ cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
256+ cmd.Stdin = strings.NewReader(value)
257+ output, err := cmd.CombinedOutput()
258+ if err != nil {
259+ return fmt.Errorf("pass insert failed: %s: %w", string(output), err)
260+ }
261+ return nil
262+}
263+
264+// passShow retrieves a value from pass.
265+func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
266+ cmd := exec.CommandContext(ctx, "pass", "show", path)
267+ output, err := cmd.Output()
268+ if err != nil {
269+ return "", fmt.Errorf("pass show failed: %w", err)
270+ }
271+ return strings.TrimSpace(string(output)), nil
272+}
273+
274+// getRevokedList retrieves the current revocation list.
275+func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
276+ listJSON, err := s.passShow(ctx, passRevokedPath)
277+ if err != nil {
278+ // If the list doesn't exist, return empty list
279+ return []string{}, nil
280+ }
281+
282+ var revokedList []string
283+ if err := json.Unmarshal([]byte(listJSON), &revokedList); err != nil {
284+ return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
285+ }
286+
287+ return revokedList, nil
288+}
289+
290+// IsAvailable checks if pass is available in the system.
291+func (s *PassStore) IsAvailable() bool {
292+ cmd := exec.Command("pass", "version")
293+ return cmd.Run() == nil
294+}
A internal/adapters/out/tokenstore/store.go +36 -0
@@ -0,0 +1,36 @@
1+package tokenstore
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/zerowrap"
7+
8+ "gordon/internal/boundaries/out"
9+ "gordon/internal/domain"
10+)
11+
12+// NewStore creates a TokenStore based on the configured backend.
13+func NewStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
14+ switch backend {
15+ case domain.SecretsBackendPass:
16+ store := NewPassStore(log)
17+ if !store.IsAvailable() {
18+ return nil, fmt.Errorf("pass is not available in the system")
19+ }
20+ return store, nil
21+
22+ case domain.SecretsBackendSops:
23+ // SOPS implementation would go here
24+ // For now, return an error indicating it's not yet implemented
25+ return nil, fmt.Errorf("sops backend is not yet implemented")
26+
27+ case domain.SecretsBackendUnsafe:
28+ if dataDir == "" {
29+ return nil, fmt.Errorf("data_dir is required for unsafe backend")
30+ }
31+ return NewUnsafeStore(dataDir, log), nil
32+
33+ default:
34+ return nil, fmt.Errorf("unknown secrets backend: %s", backend)
35+ }
36+}
A internal/adapters/out/tokenstore/unsafe.go +254 -0
@@ -0,0 +1,254 @@
1+package tokenstore
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "gordon/internal/domain"
14+)
15+
16+const (
17+ // unsafeTokenDir is the subdirectory for tokens in the data directory.
18+ unsafeTokenDir = "secrets/gordon/registry/tokens"
19+ // unsafeRevokedFile is the filename for the revocation list.
20+ unsafeRevokedFile = "secrets/gordon/registry/revoked.json"
21+)
22+
23+// UnsafeStore implements TokenStore using plain text files.
24+// WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
25+type UnsafeStore struct {
26+ dataDir string
27+ log zerowrap.Logger
28+}
29+
30+// NewUnsafeStore creates a new file-based token store.
31+// dataDir is the base directory for storing secrets (typically gordon's data_dir).
32+func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {
33+ store := &UnsafeStore{
34+ dataDir: dataDir,
35+ log: log,
36+ }
37+
38+ log.Warn().
39+ Str(zerowrap.FieldLayer, "adapter").
40+ Str(zerowrap.FieldAdapter, "tokenstore").
41+ Str("provider", "unsafe").
42+ Str("data_dir", dataDir).
43+ Msg("using unsafe secrets backend - secrets are stored in plain text")
44+
45+ return store
46+}
47+
48+// unsafeTokenData holds both JWT and metadata in a single file.
49+type unsafeTokenData struct {
50+ JWT string `json:"jwt"`
51+ Metadata tokenMetadata `json:"metadata"`
52+}
53+
54+// SaveToken stores a token JWT and metadata as a JSON file.
55+func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt string) error {
56+ tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
57+ if err := os.MkdirAll(tokenDir, 0700); err != nil {
58+ return fmt.Errorf("failed to create token directory: %w", err)
59+ }
60+
61+ data := unsafeTokenData{
62+ JWT: jwt,
63+ Metadata: tokenMetadata{
64+ ID: token.ID,
65+ Subject: token.Subject,
66+ Scopes: token.Scopes,
67+ IssuedAt: token.IssuedAt,
68+ ExpiresAt: token.ExpiresAt,
69+ Revoked: token.Revoked,
70+ },
71+ }
72+
73+ dataJSON, err := json.MarshalIndent(data, "", " ")
74+ if err != nil {
75+ return fmt.Errorf("failed to marshal token data: %w", err)
76+ }
77+
78+ tokenFile := filepath.Join(tokenDir, token.Subject+".json")
79+ if err := os.WriteFile(tokenFile, dataJSON, 0600); err != nil {
80+ return fmt.Errorf("failed to write token file: %w", err)
81+ }
82+
83+ s.log.Debug().
84+ Str(zerowrap.FieldLayer, "adapter").
85+ Str(zerowrap.FieldAdapter, "tokenstore").
86+ Str("provider", "unsafe").
87+ Str("subject", token.Subject).
88+ Msg("token stored in file")
89+
90+ return nil
91+}
92+
93+// GetToken retrieves token JWT by subject from file.
94+func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *domain.Token, error) {
95+ tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, subject+".json")
96+
97+ dataJSON, err := os.ReadFile(tokenFile)
98+ if err != nil {
99+ if os.IsNotExist(err) {
100+ return "", nil, domain.ErrTokenNotFound
101+ }
102+ return "", nil, fmt.Errorf("failed to read token file: %w", err)
103+ }
104+
105+ var data unsafeTokenData
106+ if err := json.Unmarshal(dataJSON, &data); err != nil {
107+ return "", nil, fmt.Errorf("failed to unmarshal token data: %w", err)
108+ }
109+
110+ token := &domain.Token{
111+ ID: data.Metadata.ID,
112+ Subject: data.Metadata.Subject,
113+ Scopes: data.Metadata.Scopes,
114+ IssuedAt: data.Metadata.IssuedAt,
115+ ExpiresAt: data.Metadata.ExpiresAt,
116+ Revoked: data.Metadata.Revoked,
117+ }
118+
119+ return data.JWT, token, nil
120+}
121+
122+// ListTokens returns all stored tokens from files.
123+func (s *UnsafeStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
124+ tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
125+
126+ entries, err := os.ReadDir(tokenDir)
127+ if err != nil {
128+ if os.IsNotExist(err) {
129+ return []domain.Token{}, nil
130+ }
131+ return nil, fmt.Errorf("failed to read token directory: %w", err)
132+ }
133+
134+ var tokens []domain.Token
135+ for _, entry := range entries {
136+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
137+ continue
138+ }
139+
140+ subject := strings.TrimSuffix(entry.Name(), ".json")
141+ _, token, err := s.GetToken(ctx, subject)
142+ if err != nil {
143+ s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
144+ continue
145+ }
146+
147+ tokens = append(tokens, *token)
148+ }
149+
150+ return tokens, nil
151+}
152+
153+// Revoke adds token ID to revocation list file.
154+func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
155+ revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
156+
157+ // Ensure parent directory exists
158+ if err := os.MkdirAll(filepath.Dir(revokedFile), 0700); err != nil {
159+ return fmt.Errorf("failed to create revoked directory: %w", err)
160+ }
161+
162+ // Get current list
163+ revokedList, err := s.getRevokedList()
164+ if err != nil {
165+ return err
166+ }
167+
168+ // Add to list if not already present
169+ for _, id := range revokedList {
170+ if id == tokenID {
171+ return nil // Already revoked
172+ }
173+ }
174+ revokedList = append(revokedList, tokenID)
175+
176+ // Store updated list
177+ listJSON, err := json.MarshalIndent(revokedList, "", " ")
178+ if err != nil {
179+ return fmt.Errorf("failed to marshal revocation list: %w", err)
180+ }
181+
182+ if err := os.WriteFile(revokedFile, listJSON, 0600); err != nil {
183+ return fmt.Errorf("failed to write revocation list: %w", err)
184+ }
185+
186+ s.log.Info().
187+ Str(zerowrap.FieldLayer, "adapter").
188+ Str(zerowrap.FieldAdapter, "tokenstore").
189+ Str("token_id", tokenID).
190+ Msg("token revoked")
191+
192+ return nil
193+}
194+
195+// IsRevoked checks if token ID is in revocation list.
196+func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
197+ revokedList, err := s.getRevokedList()
198+ if err != nil {
199+ return false, err
200+ }
201+
202+ for _, id := range revokedList {
203+ if id == tokenID {
204+ return true, nil
205+ }
206+ }
207+
208+ return false, nil
209+}
210+
211+// DeleteToken removes token file.
212+func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
213+ tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, subject+".json")
214+
215+ if err := os.Remove(tokenFile); err != nil {
216+ if os.IsNotExist(err) {
217+ return domain.ErrTokenNotFound
218+ }
219+ return fmt.Errorf("failed to delete token file: %w", err)
220+ }
221+
222+ s.log.Debug().
223+ Str(zerowrap.FieldLayer, "adapter").
224+ Str(zerowrap.FieldAdapter, "tokenstore").
225+ Str("subject", subject).
226+ Msg("token deleted from file")
227+
228+ return nil
229+}
230+
231+// getRevokedList retrieves the current revocation list from file.
232+func (s *UnsafeStore) getRevokedList() ([]string, error) {
233+ revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
234+
235+ listJSON, err := os.ReadFile(revokedFile)
236+ if err != nil {
237+ if os.IsNotExist(err) {
238+ return []string{}, nil
239+ }
240+ return nil, fmt.Errorf("failed to read revocation list: %w", err)
241+ }
242+
243+ var revokedList []string
244+ if err := json.Unmarshal(listJSON, &revokedList); err != nil {
245+ return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
246+ }
247+
248+ return revokedList, nil
249+}
250+
251+// IsAvailable always returns true for file-based storage.
252+func (s *UnsafeStore) IsAvailable() bool {
253+ return true
254+}
A internal/app/paths.go +33 -0
@@ -0,0 +1,33 @@
1+// Package app provides the application initialization and wiring.
2+package app
3+
4+import (
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/spf13/viper"
9+)
10+
11+// DefaultDataDir returns the default data directory path.
12+// Uses ~/.gordon for user installations, /var/lib/gordon as fallback.
13+func DefaultDataDir() string {
14+ if homeDir, err := os.UserHomeDir(); err == nil {
15+ return filepath.Join(homeDir, ".gordon")
16+ }
17+ return "/var/lib/gordon"
18+}
19+
20+// ConfigureViper sets up viper with standard config file search paths.
21+// Config file: gordon.toml
22+// Search paths (in order): /etc/gordon, ~/.config/gordon, current directory
23+func ConfigureViper(v *viper.Viper, configPath string) {
24+ if configPath != "" {
25+ v.SetConfigFile(configPath)
26+ } else {
27+ v.SetConfigName("gordon")
28+ v.SetConfigType("toml")
29+ v.AddConfigPath("/etc/gordon")
30+ v.AddConfigPath("$HOME/.config/gordon")
31+ v.AddConfigPath(".")
32+ }
33+}
A internal/app/run.go +856 -0
@@ -0,0 +1,856 @@
1+// Package app provides the application initialization and wiring.
2+package app
3+
4+import (
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "fmt"
9+ "net/http"
10+ "os"
11+ "os/signal"
12+ "path/filepath"
13+ "syscall"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+ "github.com/fsnotify/fsnotify"
18+ "github.com/spf13/viper"
19+
20+ // Adapters - Output
21+ "gordon/internal/adapters/out/docker"
22+ "gordon/internal/adapters/out/envloader"
23+ "gordon/internal/adapters/out/eventbus"
24+ "gordon/internal/adapters/out/filesystem"
25+ "gordon/internal/adapters/out/logwriter"
26+ "gordon/internal/adapters/out/secrets"
27+ "gordon/internal/adapters/out/tokenstore"
28+
29+ // Adapters - Input
30+ "gordon/internal/adapters/in/http/middleware"
31+ "gordon/internal/adapters/in/http/registry"
32+
33+ // Boundaries
34+ "gordon/internal/boundaries/out"
35+
36+ // Domain
37+ "gordon/internal/domain"
38+
39+ // Use cases
40+ "gordon/internal/usecase/auth"
41+ "gordon/internal/usecase/config"
42+ "gordon/internal/usecase/container"
43+ "gordon/internal/usecase/proxy"
44+ registrySvc "gordon/internal/usecase/registry"
45+)
46+
47+// Config holds the application configuration.
48+type Config struct {
49+ Server struct {
50+ Port int `mapstructure:"port"`
51+ RegistryPort int `mapstructure:"registry_port"`
52+ RegistryDomain string `mapstructure:"registry_domain"`
53+ DataDir string `mapstructure:"data_dir"`
54+ } `mapstructure:"server"`
55+
56+ Logging struct {
57+ Level string `mapstructure:"level"`
58+ Format string `mapstructure:"format"`
59+ File struct {
60+ Enabled bool `mapstructure:"enabled"`
61+ Path string `mapstructure:"path"`
62+ MaxSize int `mapstructure:"max_size"`
63+ MaxBackups int `mapstructure:"max_backups"`
64+ MaxAge int `mapstructure:"max_age"`
65+ } `mapstructure:"file"`
66+ ContainerLogs struct {
67+ Enabled bool `mapstructure:"enabled"`
68+ Dir string `mapstructure:"dir"`
69+ MaxSize int `mapstructure:"max_size"`
70+ MaxBackups int `mapstructure:"max_backups"`
71+ MaxAge int `mapstructure:"max_age"`
72+ } `mapstructure:"container_logs"`
73+ } `mapstructure:"logging"`
74+
75+ Env struct {
76+ Dir string `mapstructure:"dir"`
77+ } `mapstructure:"env"`
78+
79+ Secrets struct {
80+ Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe"
81+ } `mapstructure:"secrets"`
82+
83+ RegistryAuth struct {
84+ Enabled bool `mapstructure:"enabled"`
85+ Type string `mapstructure:"type"` // "password" or "token"
86+ Username string `mapstructure:"username"`
87+ Password string `mapstructure:"password"` // deprecated: use password_hash
88+ PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
89+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
90+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h"
91+ } `mapstructure:"registry_auth"`
92+}
93+
94+// services holds all the services used by the application.
95+type services struct {
96+ runtime *docker.Runtime
97+ eventBus *eventbus.InMemory
98+ blobStorage *filesystem.BlobStorage
99+ manifestStorage *filesystem.ManifestStorage
100+ envLoader *envloader.FileLoader
101+ logWriter *logwriter.LogWriter
102+ tokenStore out.TokenStore
103+ configSvc *config.Service
104+ containerSvc *container.Service
105+ registrySvc *registrySvc.Service
106+ proxySvc *proxy.Service
107+ authSvc *auth.Service
108+ tokenHandler *registry.TokenHandler
109+ internalRegUser string
110+ internalRegPass string
111+}
112+
113+// Run initializes and starts the Gordon application.
114+func Run(ctx context.Context, configPath string) error {
115+ // Load configuration
116+ v, cfg, err := initConfig(configPath)
117+ if err != nil {
118+ return err
119+ }
120+
121+ // Initialize logger
122+ log, cleanup, err := initLogger(cfg)
123+ if err != nil {
124+ return err
125+ }
126+ if cleanup != nil {
127+ defer cleanup()
128+ }
129+
130+ ctx = zerowrap.WithCtx(ctx, log)
131+ log.Info().Msg("Gordon starting")
132+
133+ // Create PID file
134+ pidFile := createPidFile(log)
135+ if pidFile != "" {
136+ defer removePidFile(pidFile, log)
137+ }
138+
139+ // Create all services
140+ svc, err := createServices(ctx, v, cfg, log)
141+ if err != nil {
142+ return err
143+ }
144+
145+ // Register event handlers
146+ if err := registerEventHandlers(ctx, svc); err != nil {
147+ return err
148+ }
149+
150+ // Set up config hot reload
151+ setupConfigHotReload(ctx, v, svc, log)
152+
153+ // Start event bus
154+ if err := svc.eventBus.Start(); err != nil {
155+ return log.WrapErr(err, "failed to start event bus")
156+ }
157+ defer svc.eventBus.Stop()
158+
159+ // Sync and auto-start containers
160+ syncAndAutoStart(ctx, svc, log)
161+
162+ // Create HTTP handlers
163+ registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
164+
165+ // Start servers and wait for shutdown
166+ return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, log)
167+}
168+
169+// initConfig loads configuration from file.
170+func initConfig(configPath string) (*viper.Viper, Config, error) {
171+ v := viper.New()
172+ if err := loadConfig(v, configPath); err != nil {
173+ return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
174+ }
175+
176+ var cfg Config
177+ if err := v.Unmarshal(&cfg); err != nil {
178+ return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
179+ }
180+
181+ return v, cfg, nil
182+}
183+
184+// initLogger initializes the zerowrap logger.
185+func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
186+ logConfig := zerowrap.Config{
187+ Level: cfg.Logging.Level,
188+ Format: cfg.Logging.Format,
189+ }
190+
191+ if cfg.Logging.File.Enabled {
192+ log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
193+ Enabled: true,
194+ Path: cfg.Logging.File.Path,
195+ MaxSize: cfg.Logging.File.MaxSize,
196+ MaxBackups: cfg.Logging.File.MaxBackups,
197+ MaxAge: cfg.Logging.File.MaxAge,
198+ Compress: true,
199+ })
200+ if err != nil {
201+ return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
202+ }
203+ return log, cleanup, nil
204+ }
205+
206+ return zerowrap.New(logConfig), nil, nil
207+}
208+
209+// createServices creates all the application services.
210+func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
211+ svc := &services{}
212+ var err error
213+
214+ // Create output adapters
215+ if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
216+ return nil, err
217+ }
218+
219+ // Create storage
220+ if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
221+ return nil, err
222+ }
223+
224+ // Create env loader
225+ if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
226+ return nil, err
227+ }
228+
229+ // Create log writer
230+ if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
231+ return nil, err
232+ }
233+
234+ // Create auth service (if enabled)
235+ if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
236+ return nil, err
237+ }
238+
239+ // Generate internal registry credentials for loopback-only pulls
240+ if cfg.RegistryAuth.Enabled {
241+ svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
242+ if err != nil {
243+ return nil, log.WrapErr(err, "failed to generate internal registry credentials")
244+ }
245+ log.Debug().Msg("internal registry auth generated for loopback pulls")
246+ }
247+
248+ // Create use case services
249+ svc.configSvc = config.NewService(v, svc.eventBus)
250+ if err := svc.configSvc.Load(ctx); err != nil {
251+ return nil, log.WrapErr(err, "failed to load configuration")
252+ }
253+
254+ svc.containerSvc = createContainerService(v, cfg, svc)
255+ svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
256+ svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
257+ RegistryDomain: cfg.Server.RegistryDomain,
258+ RegistryPort: cfg.Server.RegistryPort,
259+ })
260+
261+ // Create token handler for registry token endpoint
262+ if svc.authSvc != nil {
263+ internalAuth := registry.InternalAuth{
264+ Username: svc.internalRegUser,
265+ Password: svc.internalRegPass,
266+ }
267+ svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
268+ }
269+
270+ return svc, nil
271+}
272+
273+// createOutputAdapters creates the Docker runtime and event bus.
274+func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
275+ runtime, err := docker.NewRuntime()
276+ if err != nil {
277+ return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
278+ }
279+
280+ if err := runtime.Ping(ctx); err != nil {
281+ return nil, nil, log.WrapErr(err, "Docker is not available")
282+ }
283+
284+ dockerVersion, _ := runtime.Version(ctx)
285+ log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
286+
287+ eventBus := eventbus.NewInMemory(100, log)
288+
289+ return runtime, eventBus, nil
290+}
291+
292+// createStorage creates blob and manifest storage.
293+func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
294+ dataDir := cfg.Server.DataDir
295+ if dataDir == "" {
296+ dataDir = DefaultDataDir()
297+ }
298+
299+ registryDir := filepath.Join(dataDir, "registry")
300+
301+ blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
302+ if err != nil {
303+ return nil, nil, log.WrapErr(err, "failed to create blob storage")
304+ }
305+
306+ manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
307+ if err != nil {
308+ return nil, nil, log.WrapErr(err, "failed to create manifest storage")
309+ }
310+
311+ return blobStorage, manifestStorage, nil
312+}
313+
314+// createEnvLoader creates the environment loader with secret providers.
315+func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) {
316+ dataDir := cfg.Server.DataDir
317+ if dataDir == "" {
318+ dataDir = DefaultDataDir()
319+ }
320+
321+ envDir := cfg.Env.Dir
322+ if envDir == "" {
323+ envDir = filepath.Join(dataDir, "env")
324+ }
325+
326+ envLoader, err := envloader.NewFileLoader(envDir, log)
327+ if err != nil {
328+ return nil, log.WrapErr(err, "failed to create env loader")
329+ }
330+
331+ // Register secret providers
332+ passProvider := secrets.NewPassProvider(log)
333+ if passProvider.IsAvailable() {
334+ envLoader.RegisterSecretProvider(passProvider)
335+ log.Debug().Msg("pass secret provider registered")
336+ }
337+
338+ sopsProvider := secrets.NewSopsProvider(log)
339+ if sopsProvider.IsAvailable() {
340+ envLoader.RegisterSecretProvider(sopsProvider)
341+ log.Debug().Msg("sops secret provider registered")
342+ }
343+
344+ return envLoader, nil
345+}
346+
347+// createLogWriter creates the container log writer.
348+func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
349+ if !cfg.Logging.ContainerLogs.Enabled {
350+ log.Debug().Msg("container log collection disabled")
351+ return nil, nil
352+ }
353+
354+ // Determine log directory
355+ logDir := cfg.Logging.ContainerLogs.Dir
356+ if logDir == "" {
357+ dataDir := cfg.Server.DataDir
358+ if dataDir == "" {
359+ dataDir = DefaultDataDir()
360+ }
361+ logDir = filepath.Join(dataDir, "logs", "containers")
362+ }
363+
364+ writer, err := logwriter.New(logwriter.Config{
365+ Dir: logDir,
366+ MaxSize: cfg.Logging.ContainerLogs.MaxSize,
367+ MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
368+ MaxAge: cfg.Logging.ContainerLogs.MaxAge,
369+ })
370+ if err != nil {
371+ return nil, log.WrapErr(err, "failed to create container log writer")
372+ }
373+
374+ log.Info().Str("dir", logDir).Msg("container log collection enabled")
375+ return writer, nil
376+}
377+
378+const internalRegistryUsername = "gordon-internal"
379+
380+func generateInternalRegistryAuth() (string, string, error) {
381+ password, err := randomTokenHex(32)
382+ if err != nil {
383+ return "", "", err
384+ }
385+ return internalRegistryUsername, password, nil
386+}
387+
388+func randomTokenHex(size int) (string, error) {
389+ buf := make([]byte, size)
390+ if _, err := rand.Read(buf); err != nil {
391+ return "", err
392+ }
393+ return hex.EncodeToString(buf), nil
394+}
395+
396+// createAuthService creates the authentication service and token store.
397+func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
398+ if !cfg.RegistryAuth.Enabled {
399+ return nil, nil, nil
400+ }
401+
402+ authType := resolveAuthType(cfg.RegistryAuth.Type)
403+ backend := resolveSecretsBackend(cfg.Secrets.Backend)
404+ dataDir := resolveDataDir(cfg.Server.DataDir)
405+
406+ store, err := createTokenStore(authType, backend, dataDir, log)
407+ if err != nil {
408+ return nil, nil, err
409+ }
410+
411+ authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
412+ if err != nil {
413+ return nil, nil, err
414+ }
415+
416+ authSvc := auth.NewService(authConfig, store, log)
417+
418+ log.Info().
419+ Str("type", string(authType)).
420+ Str("backend", string(backend)).
421+ Msg("registry authentication enabled")
422+
423+ return store, authSvc, nil
424+}
425+
426+func resolveAuthType(authType string) domain.AuthType {
427+ if authType == "token" {
428+ return domain.AuthTypeToken
429+ }
430+ return domain.AuthTypePassword
431+}
432+
433+func resolveSecretsBackend(backend string) domain.SecretsBackend {
434+ switch backend {
435+ case "pass":
436+ return domain.SecretsBackendPass
437+ case "sops":
438+ return domain.SecretsBackendSops
439+ case "unsafe", "":
440+ return domain.SecretsBackendUnsafe
441+ default:
442+ return domain.SecretsBackendUnsafe
443+ }
444+}
445+
446+func resolveDataDir(dataDir string) string {
447+ if dataDir == "" {
448+ return DefaultDataDir()
449+ }
450+ return dataDir
451+}
452+
453+func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
454+ if authType != domain.AuthTypeToken {
455+ return nil, nil
456+ }
457+
458+ store, err := tokenstore.NewStore(backend, dataDir, log)
459+ if err != nil {
460+ return nil, log.WrapErr(err, "failed to create token store")
461+ }
462+ return store, nil
463+}
464+
465+func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
466+ authConfig := auth.Config{
467+ Enabled: cfg.RegistryAuth.Enabled,
468+ AuthType: authType,
469+ Username: cfg.RegistryAuth.Username,
470+ }
471+
472+ switch authType {
473+ case domain.AuthTypePassword:
474+ hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
475+ if err != nil {
476+ return auth.Config{}, err
477+ }
478+ authConfig.PasswordHash = hash
479+ case domain.AuthTypeToken:
480+ secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
481+ if err != nil {
482+ return auth.Config{}, err
483+ }
484+ authConfig.TokenSecret = secret
485+ authConfig.TokenExpiry = expiry
486+ }
487+
488+ return authConfig, nil
489+}
490+
491+func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
492+ if cfg.RegistryAuth.PasswordHash != "" {
493+ hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log)
494+ if err != nil {
495+ return "", log.WrapErr(err, "failed to load password hash")
496+ }
497+ return hash, nil
498+ }
499+
500+ if cfg.RegistryAuth.Password != "" {
501+ log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
502+ return cfg.RegistryAuth.Password, nil
503+ }
504+
505+ return "", nil
506+}
507+
508+func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
509+ secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
510+ if err != nil {
511+ return nil, 0, err
512+ }
513+
514+ expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry)
515+ if err != nil {
516+ return nil, 0, err
517+ }
518+
519+ return secret, expiry, nil
520+}
521+
522+func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
523+ if cfg.RegistryAuth.TokenSecret == "" {
524+ return nil, fmt.Errorf("token_secret is required for token authentication")
525+ }
526+
527+ secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log)
528+ if err != nil {
529+ return nil, log.WrapErr(err, "failed to load token secret")
530+ }
531+
532+ return []byte(secret), nil
533+}
534+
535+func parseTokenExpiry(expiry string) (time.Duration, error) {
536+ if expiry == "" {
537+ return 0, nil
538+ }
539+
540+ parsed, err := time.ParseDuration(expiry)
541+ if err != nil {
542+ return 0, fmt.Errorf("invalid token_expiry: %w", err)
543+ }
544+
545+ return parsed, nil
546+}
547+
548+// loadSecret loads a secret from the configured backend.
549+func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
550+ switch backend {
551+ case domain.SecretsBackendPass:
552+ provider := secrets.NewPassProvider(log)
553+ return provider.GetSecret(ctx, path)
554+ case domain.SecretsBackendSops:
555+ provider := secrets.NewSopsProvider(log)
556+ return provider.GetSecret(ctx, path)
557+ case domain.SecretsBackendUnsafe:
558+ // For unsafe backend, path is relative to dataDir/secrets/
559+ secretFile := filepath.Join(dataDir, "secrets", path)
560+ data, err := os.ReadFile(secretFile)
561+ if err != nil {
562+ return "", fmt.Errorf("failed to read secret file: %w", err)
563+ }
564+ return string(data), nil
565+ default:
566+ return "", fmt.Errorf("unknown secrets backend: %s", backend)
567+ }
568+}
569+
570+// createContainerService creates the container service with configuration.
571+func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
572+ containerConfig := container.Config{
573+ RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
574+ RegistryDomain: cfg.Server.RegistryDomain,
575+ RegistryPort: cfg.Server.RegistryPort,
576+ RegistryUsername: cfg.RegistryAuth.Username,
577+ RegistryPassword: cfg.RegistryAuth.Password,
578+ InternalRegistryUsername: svc.internalRegUser,
579+ InternalRegistryPassword: svc.internalRegPass,
580+ VolumeAutoCreate: v.GetBool("volumes.auto_create"),
581+ VolumePrefix: v.GetString("volumes.prefix"),
582+ VolumePreserve: v.GetBool("volumes.preserve"),
583+ NetworkIsolation: v.GetBool("network_isolation.enabled"),
584+ NetworkPrefix: v.GetString("network_isolation.network_prefix"),
585+ DNSSuffix: v.GetString("network_isolation.dns_suffix"),
586+ NetworkGroups: svc.configSvc.GetNetworkGroups(),
587+ Attachments: svc.configSvc.GetAttachments(),
588+ }
589+ return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
590+}
591+
592+// registerEventHandlers registers all event handlers.
593+func registerEventHandlers(ctx context.Context, svc *services) error {
594+ imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
595+ if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
596+ return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
597+ }
598+
599+ configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
600+ if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
601+ return fmt.Errorf("failed to subscribe config reload handler: %w", err)
602+ }
603+
604+ // Proxy cache invalidation on container deployment (for zero-downtime)
605+ containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
606+ if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
607+ return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
608+ }
609+
610+ return nil
611+}
612+
613+// setupConfigHotReload sets up Viper config hot reload.
614+func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
615+ v.OnConfigChange(func(e fsnotify.Event) {
616+ log.Info().Str("file", e.Name).Msg("config file changed")
617+
618+ if err := v.ReadInConfig(); err != nil {
619+ log.Error().Err(err).Msg("failed to reload config")
620+ return
621+ }
622+
623+ if err := svc.configSvc.Load(ctx); err != nil {
624+ log.Error().Err(err).Msg("failed to reload configuration")
625+ return
626+ }
627+
628+ svc.proxySvc.UpdateConfig(proxy.Config{
629+ RegistryDomain: v.GetString("server.registry_domain"),
630+ RegistryPort: v.GetInt("server.registry_port"),
631+ })
632+ })
633+ v.WatchConfig()
634+}
635+
636+// syncAndAutoStart syncs existing containers and auto-starts if configured.
637+func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
638+ if err := svc.containerSvc.SyncContainers(ctx); err != nil {
639+ log.Warn().Err(err).Msg("failed to sync existing containers")
640+ }
641+
642+ if svc.configSvc.IsAutoRouteEnabled() {
643+ if err := svc.containerSvc.AutoStart(ctx); err != nil {
644+ log.Warn().Err(err).Msg("failed to auto-start containers")
645+ }
646+ }
647+}
648+
649+// createHTTPHandlers creates HTTP handlers with middleware.
650+func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
651+ registryHandler := registry.NewHandler(svc.registrySvc, svc.blobStorage, svc.eventBus, log)
652+
653+ registryMiddlewares := []func(http.Handler) http.Handler{
654+ middleware.PanicRecovery(log),
655+ middleware.RequestLogger(log),
656+ }
657+
658+ if cfg.RegistryAuth.Enabled && svc.authSvc != nil {
659+ internalAuth := middleware.InternalRegistryAuth{
660+ Username: svc.internalRegUser,
661+ Password: svc.internalRegPass,
662+ }
663+ registryMiddlewares = append(registryMiddlewares,
664+ middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
665+ }
666+
667+ registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
668+
669+ // Create a mux that routes /v2/token to the token handler (no auth required)
670+ // and all other /v2/* routes to the registry handler (with auth)
671+ registryMux := http.NewServeMux()
672+ if svc.tokenHandler != nil {
673+ // Token endpoint is NOT protected by auth - it's where clients get tokens
674+ tokenWithLogging := middleware.Chain(
675+ middleware.PanicRecovery(log),
676+ middleware.RequestLogger(log),
677+ )(svc.tokenHandler)
678+ registryMux.Handle("/v2/token", tokenWithLogging)
679+ }
680+ registryMux.Handle("/v2/", registryWithMiddleware)
681+
682+ proxyMiddlewares := []func(http.Handler) http.Handler{
683+ middleware.PanicRecovery(log),
684+ middleware.RequestLogger(log),
685+ middleware.CORS,
686+ }
687+
688+ proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
689+
690+ return registryMux, proxyWithMiddleware
691+}
692+
693+// runServers starts the HTTP servers and waits for shutdown.
694+func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, log zerowrap.Logger) error {
695+ ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
696+ defer cancel()
697+
698+ errChan := make(chan error, 2)
699+
700+ go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
701+ go startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
702+
703+ log.Info().
704+ Int("proxy_port", cfg.Server.Port).
705+ Int("registry_port", cfg.Server.RegistryPort).
706+ Msg("Gordon is running")
707+
708+ select {
709+ case err := <-errChan:
710+ return err
711+ case <-ctx.Done():
712+ log.Info().Msg("shutdown signal received")
713+ }
714+
715+ log.Info().Msg("shutting down Gordon...")
716+
717+ if err := containerSvc.Shutdown(ctx); err != nil {
718+ log.Warn().Err(err).Msg("error during container shutdown")
719+ }
720+
721+ log.Info().Msg("Gordon stopped")
722+ return nil
723+}
724+
725+// startServer starts an HTTP server.
726+func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) {
727+ log.Info().Str("address", addr).Msgf("%s server starting", name)
728+
729+ server := &http.Server{
730+ Addr: addr,
731+ Handler: handler,
732+ ReadHeaderTimeout: 10 * time.Second,
733+ }
734+
735+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
736+ errChan <- fmt.Errorf("%s server error: %w", name, err)
737+ }
738+}
739+
740+// SendReloadSignal sends SIGUSR1 to the running Gordon process.
741+func SendReloadSignal() error {
742+ pidFile := findPidFile()
743+ if pidFile == "" {
744+ return fmt.Errorf("gordon PID file not found, is Gordon running?")
745+ }
746+
747+ pidBytes, err := os.ReadFile(pidFile)
748+ if err != nil {
749+ return fmt.Errorf("failed to read PID file: %w", err)
750+ }
751+
752+ var pid int
753+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
754+ return fmt.Errorf("failed to parse PID: %w", err)
755+ }
756+
757+ process, err := os.FindProcess(pid)
758+ if err != nil {
759+ return fmt.Errorf("failed to find process: %w", err)
760+ }
761+
762+ if err := process.Signal(syscall.SIGUSR1); err != nil {
763+ return fmt.Errorf("failed to send reload signal: %w", err)
764+ }
765+
766+ return nil
767+}
768+
769+// createPidFile creates a PID file for the Gordon process.
770+func createPidFile(log zerowrap.Logger) string {
771+ pid := os.Getpid()
772+
773+ locations := []string{
774+ "/tmp/gordon.pid",
775+ filepath.Join(os.TempDir(), "gordon.pid"),
776+ }
777+
778+ if homeDir, err := os.UserHomeDir(); err == nil {
779+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
780+ }
781+
782+ for _, location := range locations {
783+ if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
784+ log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
785+ return location
786+ }
787+ }
788+
789+ log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
790+ return ""
791+}
792+
793+// removePidFile removes the PID file.
794+func removePidFile(pidFile string, log zerowrap.Logger) {
795+ if err := os.Remove(pidFile); err != nil {
796+ log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
797+ } else {
798+ log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
799+ }
800+}
801+
802+// findPidFile finds the Gordon PID file.
803+func findPidFile() string {
804+ locations := []string{
805+ "/tmp/gordon.pid",
806+ filepath.Join(os.TempDir(), "gordon.pid"),
807+ }
808+
809+ if homeDir, err := os.UserHomeDir(); err == nil {
810+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
811+ }
812+
813+ for _, location := range locations {
814+ if _, err := os.Stat(location); err == nil {
815+ return location
816+ }
817+ }
818+
819+ return ""
820+}
821+
822+// loadConfig loads configuration from file and sets defaults.
823+func loadConfig(v *viper.Viper, configPath string) error {
824+ v.SetDefault("server.port", 80)
825+ v.SetDefault("server.registry_port", 5000)
826+ v.SetDefault("server.data_dir", DefaultDataDir())
827+ v.SetDefault("logging.level", "info")
828+ v.SetDefault("logging.format", "console")
829+ v.SetDefault("logging.file.enabled", false)
830+ v.SetDefault("logging.file.max_size", 100)
831+ v.SetDefault("logging.file.max_backups", 3)
832+ v.SetDefault("logging.file.max_age", 28)
833+ v.SetDefault("logging.container_logs.enabled", true)
834+ v.SetDefault("logging.container_logs.dir", "")
835+ v.SetDefault("logging.container_logs.max_size", 100)
836+ v.SetDefault("logging.container_logs.max_backups", 3)
837+ v.SetDefault("logging.container_logs.max_age", 28)
838+ v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
839+ v.SetDefault("secrets.backend", "unsafe")
840+ v.SetDefault("registry_auth.enabled", false)
841+ v.SetDefault("registry_auth.type", "password")
842+ v.SetDefault("registry_auth.token_expiry", "720h")
843+
844+ ConfigureViper(v, configPath)
845+
846+ if err := v.ReadInConfig(); err != nil {
847+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
848+ return fmt.Errorf("failed to read config file: %w", err)
849+ }
850+ }
851+
852+ v.SetEnvPrefix("GORDON")
853+ v.AutomaticEnv()
854+
855+ return nil
856+}
A internal/boundaries/in/auth.go +38 -0
@@ -0,0 +1,38 @@
1+package in
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "gordon/internal/domain"
8+)
9+
10+// AuthService defines the contract for registry authentication operations.
11+type AuthService interface {
12+ // GetAuthType returns the configured authentication type.
13+ GetAuthType() domain.AuthType
14+
15+ // IsEnabled returns whether authentication is enabled.
16+ IsEnabled() bool
17+
18+ // Password authentication
19+ // ValidatePassword checks if the username and password are valid.
20+ ValidatePassword(ctx context.Context, username, password string) bool
21+
22+ // Token authentication
23+ // ValidateToken validates a JWT token and returns its claims.
24+ ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
25+
26+ // GenerateToken creates a new JWT token for the given subject.
27+ // If expiry is 0, the token never expires.
28+ GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
29+
30+ // RevokeToken revokes a token by its ID.
31+ RevokeToken(ctx context.Context, tokenID string) error
32+
33+ // ListTokens returns all stored tokens.
34+ ListTokens(ctx context.Context) ([]domain.Token, error)
35+
36+ // GeneratePasswordHash generates a bcrypt hash for a password.
37+ GeneratePasswordHash(password string) (string, error)
38+}
A internal/boundaries/in/config.go +50 -0
@@ -0,0 +1,50 @@
1+package in
2+
3+import (
4+ "context"
5+
6+ "gordon/internal/domain"
7+)
8+
9+// ConfigService defines the contract for configuration management.
10+type ConfigService interface {
11+ // Load loads the configuration from the configured source.
12+ Load(ctx context.Context) error
13+
14+ // GetRoutes returns all configured routes.
15+ GetRoutes(ctx context.Context) []domain.Route
16+
17+ // AddRoute adds a new route to the configuration.
18+ AddRoute(ctx context.Context, route domain.Route) error
19+
20+ // UpdateRoute updates an existing route.
21+ UpdateRoute(ctx context.Context, route domain.Route) error
22+
23+ // RemoveRoute removes a route from the configuration.
24+ RemoveRoute(ctx context.Context, domain string) error
25+
26+ // Watch starts watching for configuration changes.
27+ // The onChange callback is called when configuration changes are detected.
28+ Watch(ctx context.Context, onChange func()) error
29+
30+ // GetServerPort returns the configured server port.
31+ GetServerPort() int
32+
33+ // GetRegistryPort returns the configured registry port.
34+ GetRegistryPort() int
35+
36+ // GetRegistryDomain returns the configured registry domain.
37+ GetRegistryDomain() string
38+
39+ // GetDataDir returns the configured data directory.
40+ GetDataDir() string
41+
42+ // IsAutoRouteEnabled returns whether auto-route is enabled.
43+ IsAutoRouteEnabled() bool
44+
45+ // IsNetworkIsolationEnabled returns whether network isolation is enabled.
46+ IsNetworkIsolationEnabled() bool
47+
48+ // GetNetworkPrefix returns the prefix for created networks.
49+ GetNetworkPrefix() string
50+}
A internal/boundaries/in/container.go +40 -0
@@ -0,0 +1,40 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+
9+ "gordon/internal/domain"
10+)
11+
12+// ContainerService defines the contract for container management operations.
13+type ContainerService interface {
14+ // Deploy creates and starts a container for the given route.
15+ Deploy(ctx context.Context, route domain.Route) (*domain.Container, error)
16+
17+ // Stop stops a running container.
18+ Stop(ctx context.Context, containerID string) error
19+
20+ // Remove removes a container, optionally forcing removal.
21+ Remove(ctx context.Context, containerID string, force bool) error
22+
23+ // Get retrieves a container by domain name.
24+ Get(ctx context.Context, domain string) (*domain.Container, bool)
25+
26+ // List returns all managed containers.
27+ List(ctx context.Context) map[string]*domain.Container
28+
29+ // HealthCheck performs health checks on all containers.
30+ HealthCheck(ctx context.Context) map[string]bool
31+
32+ // SyncContainers synchronizes containers with configured routes.
33+ SyncContainers(ctx context.Context) error
34+
35+ // AutoStart starts containers for all configured routes.
36+ AutoStart(ctx context.Context) error
37+
38+ // Shutdown gracefully shuts down all managed containers.
39+ Shutdown(ctx context.Context) error
40+}
A internal/boundaries/in/mocks/mock_auth_service.go +516 -0
@@ -0,0 +1,516 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+ "time"
11+
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockAuthService creates a new instance of MockAuthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockAuthService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockAuthService {
21+ mock := &MockAuthService{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockAuthService is an autogenerated mock type for the AuthService type
30+type MockAuthService struct {
31+ mock.Mock
32+}
33+
34+type MockAuthService_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockAuthService) EXPECT() *MockAuthService_Expecter {
39+ return &MockAuthService_Expecter{mock: &_m.Mock}
40+}
41+
42+// GeneratePasswordHash provides a mock function for the type MockAuthService
43+func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {
44+ ret := _mock.Called(password)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GeneratePasswordHash")
48+ }
49+
50+ var r0 string
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {
53+ return returnFunc(password)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(string) string); ok {
56+ r0 = returnFunc(password)
57+ } else {
58+ r0 = ret.Get(0).(string)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
61+ r1 = returnFunc(password)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockAuthService_GeneratePasswordHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GeneratePasswordHash'
69+type MockAuthService_GeneratePasswordHash_Call struct {
70+ *mock.Call
71+}
72+
73+// GeneratePasswordHash is a helper method to define mock.On call
74+// - password string
75+func (_e *MockAuthService_Expecter) GeneratePasswordHash(password interface{}) *MockAuthService_GeneratePasswordHash_Call {
76+ return &MockAuthService_GeneratePasswordHash_Call{Call: _e.mock.On("GeneratePasswordHash", password)}
77+}
78+
79+func (_c *MockAuthService_GeneratePasswordHash_Call) Run(run func(password string)) *MockAuthService_GeneratePasswordHash_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 string
82+ if args[0] != nil {
83+ arg0 = args[0].(string)
84+ }
85+ run(
86+ arg0,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MockAuthService_GeneratePasswordHash_Call) Return(s string, err error) *MockAuthService_GeneratePasswordHash_Call {
93+ _c.Call.Return(s, err)
94+ return _c
95+}
96+
97+func (_c *MockAuthService_GeneratePasswordHash_Call) RunAndReturn(run func(password string) (string, error)) *MockAuthService_GeneratePasswordHash_Call {
98+ _c.Call.Return(run)
99+ return _c
100+}
101+
102+// GenerateToken provides a mock function for the type MockAuthService
103+func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
104+ ret := _mock.Called(ctx, subject, scopes, expiry)
105+
106+ if len(ret) == 0 {
107+ panic("no return value specified for GenerateToken")
108+ }
109+
110+ var r0 string
111+ var r1 error
112+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) (string, error)); ok {
113+ return returnFunc(ctx, subject, scopes, expiry)
114+ }
115+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) string); ok {
116+ r0 = returnFunc(ctx, subject, scopes, expiry)
117+ } else {
118+ r0 = ret.Get(0).(string)
119+ }
120+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, time.Duration) error); ok {
121+ r1 = returnFunc(ctx, subject, scopes, expiry)
122+ } else {
123+ r1 = ret.Error(1)
124+ }
125+ return r0, r1
126+}
127+
128+// MockAuthService_GenerateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateToken'
129+type MockAuthService_GenerateToken_Call struct {
130+ *mock.Call
131+}
132+
133+// GenerateToken is a helper method to define mock.On call
134+// - ctx context.Context
135+// - subject string
136+// - scopes []string
137+// - expiry time.Duration
138+func (_e *MockAuthService_Expecter) GenerateToken(ctx interface{}, subject interface{}, scopes interface{}, expiry interface{}) *MockAuthService_GenerateToken_Call {
139+ return &MockAuthService_GenerateToken_Call{Call: _e.mock.On("GenerateToken", ctx, subject, scopes, expiry)}
140+}
141+
142+func (_c *MockAuthService_GenerateToken_Call) Run(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration)) *MockAuthService_GenerateToken_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ var arg1 string
149+ if args[1] != nil {
150+ arg1 = args[1].(string)
151+ }
152+ var arg2 []string
153+ if args[2] != nil {
154+ arg2 = args[2].([]string)
155+ }
156+ var arg3 time.Duration
157+ if args[3] != nil {
158+ arg3 = args[3].(time.Duration)
159+ }
160+ run(
161+ arg0,
162+ arg1,
163+ arg2,
164+ arg3,
165+ )
166+ })
167+ return _c
168+}
169+
170+func (_c *MockAuthService_GenerateToken_Call) Return(s string, err error) *MockAuthService_GenerateToken_Call {
171+ _c.Call.Return(s, err)
172+ return _c
173+}
174+
175+func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)) *MockAuthService_GenerateToken_Call {
176+ _c.Call.Return(run)
177+ return _c
178+}
179+
180+// GetAuthType provides a mock function for the type MockAuthService
181+func (_mock *MockAuthService) GetAuthType() domain.AuthType {
182+ ret := _mock.Called()
183+
184+ if len(ret) == 0 {
185+ panic("no return value specified for GetAuthType")
186+ }
187+
188+ var r0 domain.AuthType
189+ if returnFunc, ok := ret.Get(0).(func() domain.AuthType); ok {
190+ r0 = returnFunc()
191+ } else {
192+ r0 = ret.Get(0).(domain.AuthType)
193+ }
194+ return r0
195+}
196+
197+// MockAuthService_GetAuthType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAuthType'
198+type MockAuthService_GetAuthType_Call struct {
199+ *mock.Call
200+}
201+
202+// GetAuthType is a helper method to define mock.On call
203+func (_e *MockAuthService_Expecter) GetAuthType() *MockAuthService_GetAuthType_Call {
204+ return &MockAuthService_GetAuthType_Call{Call: _e.mock.On("GetAuthType")}
205+}
206+
207+func (_c *MockAuthService_GetAuthType_Call) Run(run func()) *MockAuthService_GetAuthType_Call {
208+ _c.Call.Run(func(args mock.Arguments) {
209+ run()
210+ })
211+ return _c
212+}
213+
214+func (_c *MockAuthService_GetAuthType_Call) Return(authType domain.AuthType) *MockAuthService_GetAuthType_Call {
215+ _c.Call.Return(authType)
216+ return _c
217+}
218+
219+func (_c *MockAuthService_GetAuthType_Call) RunAndReturn(run func() domain.AuthType) *MockAuthService_GetAuthType_Call {
220+ _c.Call.Return(run)
221+ return _c
222+}
223+
224+// IsEnabled provides a mock function for the type MockAuthService
225+func (_mock *MockAuthService) IsEnabled() bool {
226+ ret := _mock.Called()
227+
228+ if len(ret) == 0 {
229+ panic("no return value specified for IsEnabled")
230+ }
231+
232+ var r0 bool
233+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
234+ r0 = returnFunc()
235+ } else {
236+ r0 = ret.Get(0).(bool)
237+ }
238+ return r0
239+}
240+
241+// MockAuthService_IsEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsEnabled'
242+type MockAuthService_IsEnabled_Call struct {
243+ *mock.Call
244+}
245+
246+// IsEnabled is a helper method to define mock.On call
247+func (_e *MockAuthService_Expecter) IsEnabled() *MockAuthService_IsEnabled_Call {
248+ return &MockAuthService_IsEnabled_Call{Call: _e.mock.On("IsEnabled")}
249+}
250+
251+func (_c *MockAuthService_IsEnabled_Call) Run(run func()) *MockAuthService_IsEnabled_Call {
252+ _c.Call.Run(func(args mock.Arguments) {
253+ run()
254+ })
255+ return _c
256+}
257+
258+func (_c *MockAuthService_IsEnabled_Call) Return(b bool) *MockAuthService_IsEnabled_Call {
259+ _c.Call.Return(b)
260+ return _c
261+}
262+
263+func (_c *MockAuthService_IsEnabled_Call) RunAndReturn(run func() bool) *MockAuthService_IsEnabled_Call {
264+ _c.Call.Return(run)
265+ return _c
266+}
267+
268+// ListTokens provides a mock function for the type MockAuthService
269+func (_mock *MockAuthService) ListTokens(ctx context.Context) ([]domain.Token, error) {
270+ ret := _mock.Called(ctx)
271+
272+ if len(ret) == 0 {
273+ panic("no return value specified for ListTokens")
274+ }
275+
276+ var r0 []domain.Token
277+ var r1 error
278+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Token, error)); ok {
279+ return returnFunc(ctx)
280+ }
281+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Token); ok {
282+ r0 = returnFunc(ctx)
283+ } else {
284+ if ret.Get(0) != nil {
285+ r0 = ret.Get(0).([]domain.Token)
286+ }
287+ }
288+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
289+ r1 = returnFunc(ctx)
290+ } else {
291+ r1 = ret.Error(1)
292+ }
293+ return r0, r1
294+}
295+
296+// MockAuthService_ListTokens_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTokens'
297+type MockAuthService_ListTokens_Call struct {
298+ *mock.Call
299+}
300+
301+// ListTokens is a helper method to define mock.On call
302+// - ctx context.Context
303+func (_e *MockAuthService_Expecter) ListTokens(ctx interface{}) *MockAuthService_ListTokens_Call {
304+ return &MockAuthService_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}
305+}
306+
307+func (_c *MockAuthService_ListTokens_Call) Run(run func(ctx context.Context)) *MockAuthService_ListTokens_Call {
308+ _c.Call.Run(func(args mock.Arguments) {
309+ var arg0 context.Context
310+ if args[0] != nil {
311+ arg0 = args[0].(context.Context)
312+ }
313+ run(
314+ arg0,
315+ )
316+ })
317+ return _c
318+}
319+
320+func (_c *MockAuthService_ListTokens_Call) Return(tokens []domain.Token, err error) *MockAuthService_ListTokens_Call {
321+ _c.Call.Return(tokens, err)
322+ return _c
323+}
324+
325+func (_c *MockAuthService_ListTokens_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Token, error)) *MockAuthService_ListTokens_Call {
326+ _c.Call.Return(run)
327+ return _c
328+}
329+
330+// RevokeToken provides a mock function for the type MockAuthService
331+func (_mock *MockAuthService) RevokeToken(ctx context.Context, tokenID string) error {
332+ ret := _mock.Called(ctx, tokenID)
333+
334+ if len(ret) == 0 {
335+ panic("no return value specified for RevokeToken")
336+ }
337+
338+ var r0 error
339+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
340+ r0 = returnFunc(ctx, tokenID)
341+ } else {
342+ r0 = ret.Error(0)
343+ }
344+ return r0
345+}
346+
347+// MockAuthService_RevokeToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeToken'
348+type MockAuthService_RevokeToken_Call struct {
349+ *mock.Call
350+}
351+
352+// RevokeToken is a helper method to define mock.On call
353+// - ctx context.Context
354+// - tokenID string
355+func (_e *MockAuthService_Expecter) RevokeToken(ctx interface{}, tokenID interface{}) *MockAuthService_RevokeToken_Call {
356+ return &MockAuthService_RevokeToken_Call{Call: _e.mock.On("RevokeToken", ctx, tokenID)}
357+}
358+
359+func (_c *MockAuthService_RevokeToken_Call) Run(run func(ctx context.Context, tokenID string)) *MockAuthService_RevokeToken_Call {
360+ _c.Call.Run(func(args mock.Arguments) {
361+ var arg0 context.Context
362+ if args[0] != nil {
363+ arg0 = args[0].(context.Context)
364+ }
365+ var arg1 string
366+ if args[1] != nil {
367+ arg1 = args[1].(string)
368+ }
369+ run(
370+ arg0,
371+ arg1,
372+ )
373+ })
374+ return _c
375+}
376+
377+func (_c *MockAuthService_RevokeToken_Call) Return(err error) *MockAuthService_RevokeToken_Call {
378+ _c.Call.Return(err)
379+ return _c
380+}
381+
382+func (_c *MockAuthService_RevokeToken_Call) RunAndReturn(run func(ctx context.Context, tokenID string) error) *MockAuthService_RevokeToken_Call {
383+ _c.Call.Return(run)
384+ return _c
385+}
386+
387+// ValidatePassword provides a mock function for the type MockAuthService
388+func (_mock *MockAuthService) ValidatePassword(ctx context.Context, username string, password string) bool {
389+ ret := _mock.Called(ctx, username, password)
390+
391+ if len(ret) == 0 {
392+ panic("no return value specified for ValidatePassword")
393+ }
394+
395+ var r0 bool
396+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok {
397+ r0 = returnFunc(ctx, username, password)
398+ } else {
399+ r0 = ret.Get(0).(bool)
400+ }
401+ return r0
402+}
403+
404+// MockAuthService_ValidatePassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidatePassword'
405+type MockAuthService_ValidatePassword_Call struct {
406+ *mock.Call
407+}
408+
409+// ValidatePassword is a helper method to define mock.On call
410+// - ctx context.Context
411+// - username string
412+// - password string
413+func (_e *MockAuthService_Expecter) ValidatePassword(ctx interface{}, username interface{}, password interface{}) *MockAuthService_ValidatePassword_Call {
414+ return &MockAuthService_ValidatePassword_Call{Call: _e.mock.On("ValidatePassword", ctx, username, password)}
415+}
416+
417+func (_c *MockAuthService_ValidatePassword_Call) Run(run func(ctx context.Context, username string, password string)) *MockAuthService_ValidatePassword_Call {
418+ _c.Call.Run(func(args mock.Arguments) {
419+ var arg0 context.Context
420+ if args[0] != nil {
421+ arg0 = args[0].(context.Context)
422+ }
423+ var arg1 string
424+ if args[1] != nil {
425+ arg1 = args[1].(string)
426+ }
427+ var arg2 string
428+ if args[2] != nil {
429+ arg2 = args[2].(string)
430+ }
431+ run(
432+ arg0,
433+ arg1,
434+ arg2,
435+ )
436+ })
437+ return _c
438+}
439+
440+func (_c *MockAuthService_ValidatePassword_Call) Return(b bool) *MockAuthService_ValidatePassword_Call {
441+ _c.Call.Return(b)
442+ return _c
443+}
444+
445+func (_c *MockAuthService_ValidatePassword_Call) RunAndReturn(run func(ctx context.Context, username string, password string) bool) *MockAuthService_ValidatePassword_Call {
446+ _c.Call.Return(run)
447+ return _c
448+}
449+
450+// ValidateToken provides a mock function for the type MockAuthService
451+func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
452+ ret := _mock.Called(ctx, tokenString)
453+
454+ if len(ret) == 0 {
455+ panic("no return value specified for ValidateToken")
456+ }
457+
458+ var r0 *domain.TokenClaims
459+ var r1 error
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.TokenClaims, error)); ok {
461+ return returnFunc(ctx, tokenString)
462+ }
463+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.TokenClaims); ok {
464+ r0 = returnFunc(ctx, tokenString)
465+ } else {
466+ if ret.Get(0) != nil {
467+ r0 = ret.Get(0).(*domain.TokenClaims)
468+ }
469+ }
470+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
471+ r1 = returnFunc(ctx, tokenString)
472+ } else {
473+ r1 = ret.Error(1)
474+ }
475+ return r0, r1
476+}
477+
478+// MockAuthService_ValidateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateToken'
479+type MockAuthService_ValidateToken_Call struct {
480+ *mock.Call
481+}
482+
483+// ValidateToken is a helper method to define mock.On call
484+// - ctx context.Context
485+// - tokenString string
486+func (_e *MockAuthService_Expecter) ValidateToken(ctx interface{}, tokenString interface{}) *MockAuthService_ValidateToken_Call {
487+ return &MockAuthService_ValidateToken_Call{Call: _e.mock.On("ValidateToken", ctx, tokenString)}
488+}
489+
490+func (_c *MockAuthService_ValidateToken_Call) Run(run func(ctx context.Context, tokenString string)) *MockAuthService_ValidateToken_Call {
491+ _c.Call.Run(func(args mock.Arguments) {
492+ var arg0 context.Context
493+ if args[0] != nil {
494+ arg0 = args[0].(context.Context)
495+ }
496+ var arg1 string
497+ if args[1] != nil {
498+ arg1 = args[1].(string)
499+ }
500+ run(
501+ arg0,
502+ arg1,
503+ )
504+ })
505+ return _c
506+}
507+
508+func (_c *MockAuthService_ValidateToken_Call) Return(tokenClaims *domain.TokenClaims, err error) *MockAuthService_ValidateToken_Call {
509+ _c.Call.Return(tokenClaims, err)
510+ return _c
511+}
512+
513+func (_c *MockAuthService_ValidateToken_Call) RunAndReturn(run func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)) *MockAuthService_ValidateToken_Call {
514+ _c.Call.Return(run)
515+ return _c
516+}
A internal/boundaries/in/mocks/mock_config_service.go +679 -0
@@ -0,0 +1,679 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockConfigService creates a new instance of MockConfigService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockConfigService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockConfigService {
20+ mock := &MockConfigService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockConfigService is an autogenerated mock type for the ConfigService type
29+type MockConfigService struct {
30+ mock.Mock
31+}
32+
33+type MockConfigService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockConfigService) EXPECT() *MockConfigService_Expecter {
38+ return &MockConfigService_Expecter{mock: &_m.Mock}
39+}
40+
41+// AddRoute provides a mock function for the type MockConfigService
42+func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {
43+ ret := _mock.Called(ctx, route)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for AddRoute")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok {
51+ r0 = returnFunc(ctx, route)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockConfigService_AddRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddRoute'
59+type MockConfigService_AddRoute_Call struct {
60+ *mock.Call
61+}
62+
63+// AddRoute is a helper method to define mock.On call
64+// - ctx context.Context
65+// - route domain.Route
66+func (_e *MockConfigService_Expecter) AddRoute(ctx interface{}, route interface{}) *MockConfigService_AddRoute_Call {
67+ return &MockConfigService_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}
68+}
69+
70+func (_c *MockConfigService_AddRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockConfigService_AddRoute_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 domain.Route
77+ if args[1] != nil {
78+ arg1 = args[1].(domain.Route)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *MockConfigService_AddRoute_Call) Return(err error) *MockConfigService_AddRoute_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) error) *MockConfigService_AddRoute_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// GetDataDir provides a mock function for the type MockConfigService
99+func (_mock *MockConfigService) GetDataDir() string {
100+ ret := _mock.Called()
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for GetDataDir")
104+ }
105+
106+ var r0 string
107+ if returnFunc, ok := ret.Get(0).(func() string); ok {
108+ r0 = returnFunc()
109+ } else {
110+ r0 = ret.Get(0).(string)
111+ }
112+ return r0
113+}
114+
115+// MockConfigService_GetDataDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDataDir'
116+type MockConfigService_GetDataDir_Call struct {
117+ *mock.Call
118+}
119+
120+// GetDataDir is a helper method to define mock.On call
121+func (_e *MockConfigService_Expecter) GetDataDir() *MockConfigService_GetDataDir_Call {
122+ return &MockConfigService_GetDataDir_Call{Call: _e.mock.On("GetDataDir")}
123+}
124+
125+func (_c *MockConfigService_GetDataDir_Call) Run(run func()) *MockConfigService_GetDataDir_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ run()
128+ })
129+ return _c
130+}
131+
132+func (_c *MockConfigService_GetDataDir_Call) Return(s string) *MockConfigService_GetDataDir_Call {
133+ _c.Call.Return(s)
134+ return _c
135+}
136+
137+func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *MockConfigService_GetDataDir_Call {
138+ _c.Call.Return(run)
139+ return _c
140+}
141+
142+// GetNetworkPrefix provides a mock function for the type MockConfigService
143+func (_mock *MockConfigService) GetNetworkPrefix() string {
144+ ret := _mock.Called()
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for GetNetworkPrefix")
148+ }
149+
150+ var r0 string
151+ if returnFunc, ok := ret.Get(0).(func() string); ok {
152+ r0 = returnFunc()
153+ } else {
154+ r0 = ret.Get(0).(string)
155+ }
156+ return r0
157+}
158+
159+// MockConfigService_GetNetworkPrefix_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNetworkPrefix'
160+type MockConfigService_GetNetworkPrefix_Call struct {
161+ *mock.Call
162+}
163+
164+// GetNetworkPrefix is a helper method to define mock.On call
165+func (_e *MockConfigService_Expecter) GetNetworkPrefix() *MockConfigService_GetNetworkPrefix_Call {
166+ return &MockConfigService_GetNetworkPrefix_Call{Call: _e.mock.On("GetNetworkPrefix")}
167+}
168+
169+func (_c *MockConfigService_GetNetworkPrefix_Call) Run(run func()) *MockConfigService_GetNetworkPrefix_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ run()
172+ })
173+ return _c
174+}
175+
176+func (_c *MockConfigService_GetNetworkPrefix_Call) Return(s string) *MockConfigService_GetNetworkPrefix_Call {
177+ _c.Call.Return(s)
178+ return _c
179+}
180+
181+func (_c *MockConfigService_GetNetworkPrefix_Call) RunAndReturn(run func() string) *MockConfigService_GetNetworkPrefix_Call {
182+ _c.Call.Return(run)
183+ return _c
184+}
185+
186+// GetRegistryDomain provides a mock function for the type MockConfigService
187+func (_mock *MockConfigService) GetRegistryDomain() string {
188+ ret := _mock.Called()
189+
190+ if len(ret) == 0 {
191+ panic("no return value specified for GetRegistryDomain")
192+ }
193+
194+ var r0 string
195+ if returnFunc, ok := ret.Get(0).(func() string); ok {
196+ r0 = returnFunc()
197+ } else {
198+ r0 = ret.Get(0).(string)
199+ }
200+ return r0
201+}
202+
203+// MockConfigService_GetRegistryDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRegistryDomain'
204+type MockConfigService_GetRegistryDomain_Call struct {
205+ *mock.Call
206+}
207+
208+// GetRegistryDomain is a helper method to define mock.On call
209+func (_e *MockConfigService_Expecter) GetRegistryDomain() *MockConfigService_GetRegistryDomain_Call {
210+ return &MockConfigService_GetRegistryDomain_Call{Call: _e.mock.On("GetRegistryDomain")}
211+}
212+
213+func (_c *MockConfigService_GetRegistryDomain_Call) Run(run func()) *MockConfigService_GetRegistryDomain_Call {
214+ _c.Call.Run(func(args mock.Arguments) {
215+ run()
216+ })
217+ return _c
218+}
219+
220+func (_c *MockConfigService_GetRegistryDomain_Call) Return(s string) *MockConfigService_GetRegistryDomain_Call {
221+ _c.Call.Return(s)
222+ return _c
223+}
224+
225+func (_c *MockConfigService_GetRegistryDomain_Call) RunAndReturn(run func() string) *MockConfigService_GetRegistryDomain_Call {
226+ _c.Call.Return(run)
227+ return _c
228+}
229+
230+// GetRegistryPort provides a mock function for the type MockConfigService
231+func (_mock *MockConfigService) GetRegistryPort() int {
232+ ret := _mock.Called()
233+
234+ if len(ret) == 0 {
235+ panic("no return value specified for GetRegistryPort")
236+ }
237+
238+ var r0 int
239+ if returnFunc, ok := ret.Get(0).(func() int); ok {
240+ r0 = returnFunc()
241+ } else {
242+ r0 = ret.Get(0).(int)
243+ }
244+ return r0
245+}
246+
247+// MockConfigService_GetRegistryPort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRegistryPort'
248+type MockConfigService_GetRegistryPort_Call struct {
249+ *mock.Call
250+}
251+
252+// GetRegistryPort is a helper method to define mock.On call
253+func (_e *MockConfigService_Expecter) GetRegistryPort() *MockConfigService_GetRegistryPort_Call {
254+ return &MockConfigService_GetRegistryPort_Call{Call: _e.mock.On("GetRegistryPort")}
255+}
256+
257+func (_c *MockConfigService_GetRegistryPort_Call) Run(run func()) *MockConfigService_GetRegistryPort_Call {
258+ _c.Call.Run(func(args mock.Arguments) {
259+ run()
260+ })
261+ return _c
262+}
263+
264+func (_c *MockConfigService_GetRegistryPort_Call) Return(n int) *MockConfigService_GetRegistryPort_Call {
265+ _c.Call.Return(n)
266+ return _c
267+}
268+
269+func (_c *MockConfigService_GetRegistryPort_Call) RunAndReturn(run func() int) *MockConfigService_GetRegistryPort_Call {
270+ _c.Call.Return(run)
271+ return _c
272+}
273+
274+// GetRoutes provides a mock function for the type MockConfigService
275+func (_mock *MockConfigService) GetRoutes(ctx context.Context) []domain.Route {
276+ ret := _mock.Called(ctx)
277+
278+ if len(ret) == 0 {
279+ panic("no return value specified for GetRoutes")
280+ }
281+
282+ var r0 []domain.Route
283+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Route); ok {
284+ r0 = returnFunc(ctx)
285+ } else {
286+ if ret.Get(0) != nil {
287+ r0 = ret.Get(0).([]domain.Route)
288+ }
289+ }
290+ return r0
291+}
292+
293+// MockConfigService_GetRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoutes'
294+type MockConfigService_GetRoutes_Call struct {
295+ *mock.Call
296+}
297+
298+// GetRoutes is a helper method to define mock.On call
299+// - ctx context.Context
300+func (_e *MockConfigService_Expecter) GetRoutes(ctx interface{}) *MockConfigService_GetRoutes_Call {
301+ return &MockConfigService_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}
302+}
303+
304+func (_c *MockConfigService_GetRoutes_Call) Run(run func(ctx context.Context)) *MockConfigService_GetRoutes_Call {
305+ _c.Call.Run(func(args mock.Arguments) {
306+ var arg0 context.Context
307+ if args[0] != nil {
308+ arg0 = args[0].(context.Context)
309+ }
310+ run(
311+ arg0,
312+ )
313+ })
314+ return _c
315+}
316+
317+func (_c *MockConfigService_GetRoutes_Call) Return(routes []domain.Route) *MockConfigService_GetRoutes_Call {
318+ _c.Call.Return(routes)
319+ return _c
320+}
321+
322+func (_c *MockConfigService_GetRoutes_Call) RunAndReturn(run func(ctx context.Context) []domain.Route) *MockConfigService_GetRoutes_Call {
323+ _c.Call.Return(run)
324+ return _c
325+}
326+
327+// GetServerPort provides a mock function for the type MockConfigService
328+func (_mock *MockConfigService) GetServerPort() int {
329+ ret := _mock.Called()
330+
331+ if len(ret) == 0 {
332+ panic("no return value specified for GetServerPort")
333+ }
334+
335+ var r0 int
336+ if returnFunc, ok := ret.Get(0).(func() int); ok {
337+ r0 = returnFunc()
338+ } else {
339+ r0 = ret.Get(0).(int)
340+ }
341+ return r0
342+}
343+
344+// MockConfigService_GetServerPort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetServerPort'
345+type MockConfigService_GetServerPort_Call struct {
346+ *mock.Call
347+}
348+
349+// GetServerPort is a helper method to define mock.On call
350+func (_e *MockConfigService_Expecter) GetServerPort() *MockConfigService_GetServerPort_Call {
351+ return &MockConfigService_GetServerPort_Call{Call: _e.mock.On("GetServerPort")}
352+}
353+
354+func (_c *MockConfigService_GetServerPort_Call) Run(run func()) *MockConfigService_GetServerPort_Call {
355+ _c.Call.Run(func(args mock.Arguments) {
356+ run()
357+ })
358+ return _c
359+}
360+
361+func (_c *MockConfigService_GetServerPort_Call) Return(n int) *MockConfigService_GetServerPort_Call {
362+ _c.Call.Return(n)
363+ return _c
364+}
365+
366+func (_c *MockConfigService_GetServerPort_Call) RunAndReturn(run func() int) *MockConfigService_GetServerPort_Call {
367+ _c.Call.Return(run)
368+ return _c
369+}
370+
371+// IsAutoRouteEnabled provides a mock function for the type MockConfigService
372+func (_mock *MockConfigService) IsAutoRouteEnabled() bool {
373+ ret := _mock.Called()
374+
375+ if len(ret) == 0 {
376+ panic("no return value specified for IsAutoRouteEnabled")
377+ }
378+
379+ var r0 bool
380+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
381+ r0 = returnFunc()
382+ } else {
383+ r0 = ret.Get(0).(bool)
384+ }
385+ return r0
386+}
387+
388+// MockConfigService_IsAutoRouteEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAutoRouteEnabled'
389+type MockConfigService_IsAutoRouteEnabled_Call struct {
390+ *mock.Call
391+}
392+
393+// IsAutoRouteEnabled is a helper method to define mock.On call
394+func (_e *MockConfigService_Expecter) IsAutoRouteEnabled() *MockConfigService_IsAutoRouteEnabled_Call {
395+ return &MockConfigService_IsAutoRouteEnabled_Call{Call: _e.mock.On("IsAutoRouteEnabled")}
396+}
397+
398+func (_c *MockConfigService_IsAutoRouteEnabled_Call) Run(run func()) *MockConfigService_IsAutoRouteEnabled_Call {
399+ _c.Call.Run(func(args mock.Arguments) {
400+ run()
401+ })
402+ return _c
403+}
404+
405+func (_c *MockConfigService_IsAutoRouteEnabled_Call) Return(b bool) *MockConfigService_IsAutoRouteEnabled_Call {
406+ _c.Call.Return(b)
407+ return _c
408+}
409+
410+func (_c *MockConfigService_IsAutoRouteEnabled_Call) RunAndReturn(run func() bool) *MockConfigService_IsAutoRouteEnabled_Call {
411+ _c.Call.Return(run)
412+ return _c
413+}
414+
415+// IsNetworkIsolationEnabled provides a mock function for the type MockConfigService
416+func (_mock *MockConfigService) IsNetworkIsolationEnabled() bool {
417+ ret := _mock.Called()
418+
419+ if len(ret) == 0 {
420+ panic("no return value specified for IsNetworkIsolationEnabled")
421+ }
422+
423+ var r0 bool
424+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
425+ r0 = returnFunc()
426+ } else {
427+ r0 = ret.Get(0).(bool)
428+ }
429+ return r0
430+}
431+
432+// MockConfigService_IsNetworkIsolationEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsNetworkIsolationEnabled'
433+type MockConfigService_IsNetworkIsolationEnabled_Call struct {
434+ *mock.Call
435+}
436+
437+// IsNetworkIsolationEnabled is a helper method to define mock.On call
438+func (_e *MockConfigService_Expecter) IsNetworkIsolationEnabled() *MockConfigService_IsNetworkIsolationEnabled_Call {
439+ return &MockConfigService_IsNetworkIsolationEnabled_Call{Call: _e.mock.On("IsNetworkIsolationEnabled")}
440+}
441+
442+func (_c *MockConfigService_IsNetworkIsolationEnabled_Call) Run(run func()) *MockConfigService_IsNetworkIsolationEnabled_Call {
443+ _c.Call.Run(func(args mock.Arguments) {
444+ run()
445+ })
446+ return _c
447+}
448+
449+func (_c *MockConfigService_IsNetworkIsolationEnabled_Call) Return(b bool) *MockConfigService_IsNetworkIsolationEnabled_Call {
450+ _c.Call.Return(b)
451+ return _c
452+}
453+
454+func (_c *MockConfigService_IsNetworkIsolationEnabled_Call) RunAndReturn(run func() bool) *MockConfigService_IsNetworkIsolationEnabled_Call {
455+ _c.Call.Return(run)
456+ return _c
457+}
458+
459+// Load provides a mock function for the type MockConfigService
460+func (_mock *MockConfigService) Load(ctx context.Context) error {
461+ ret := _mock.Called(ctx)
462+
463+ if len(ret) == 0 {
464+ panic("no return value specified for Load")
465+ }
466+
467+ var r0 error
468+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
469+ r0 = returnFunc(ctx)
470+ } else {
471+ r0 = ret.Error(0)
472+ }
473+ return r0
474+}
475+
476+// MockConfigService_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
477+type MockConfigService_Load_Call struct {
478+ *mock.Call
479+}
480+
481+// Load is a helper method to define mock.On call
482+// - ctx context.Context
483+func (_e *MockConfigService_Expecter) Load(ctx interface{}) *MockConfigService_Load_Call {
484+ return &MockConfigService_Load_Call{Call: _e.mock.On("Load", ctx)}
485+}
486+
487+func (_c *MockConfigService_Load_Call) Run(run func(ctx context.Context)) *MockConfigService_Load_Call {
488+ _c.Call.Run(func(args mock.Arguments) {
489+ var arg0 context.Context
490+ if args[0] != nil {
491+ arg0 = args[0].(context.Context)
492+ }
493+ run(
494+ arg0,
495+ )
496+ })
497+ return _c
498+}
499+
500+func (_c *MockConfigService_Load_Call) Return(err error) *MockConfigService_Load_Call {
501+ _c.Call.Return(err)
502+ return _c
503+}
504+
505+func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context) error) *MockConfigService_Load_Call {
506+ _c.Call.Return(run)
507+ return _c
508+}
509+
510+// RemoveRoute provides a mock function for the type MockConfigService
511+func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {
512+ ret := _mock.Called(ctx, domain1)
513+
514+ if len(ret) == 0 {
515+ panic("no return value specified for RemoveRoute")
516+ }
517+
518+ var r0 error
519+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
520+ r0 = returnFunc(ctx, domain1)
521+ } else {
522+ r0 = ret.Error(0)
523+ }
524+ return r0
525+}
526+
527+// MockConfigService_RemoveRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRoute'
528+type MockConfigService_RemoveRoute_Call struct {
529+ *mock.Call
530+}
531+
532+// RemoveRoute is a helper method to define mock.On call
533+// - ctx context.Context
534+// - domain1 string
535+func (_e *MockConfigService_Expecter) RemoveRoute(ctx interface{}, domain1 interface{}) *MockConfigService_RemoveRoute_Call {
536+ return &MockConfigService_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, domain1)}
537+}
538+
539+func (_c *MockConfigService_RemoveRoute_Call) Run(run func(ctx context.Context, domain1 string)) *MockConfigService_RemoveRoute_Call {
540+ _c.Call.Run(func(args mock.Arguments) {
541+ var arg0 context.Context
542+ if args[0] != nil {
543+ arg0 = args[0].(context.Context)
544+ }
545+ var arg1 string
546+ if args[1] != nil {
547+ arg1 = args[1].(string)
548+ }
549+ run(
550+ arg0,
551+ arg1,
552+ )
553+ })
554+ return _c
555+}
556+
557+func (_c *MockConfigService_RemoveRoute_Call) Return(err error) *MockConfigService_RemoveRoute_Call {
558+ _c.Call.Return(err)
559+ return _c
560+}
561+
562+func (_c *MockConfigService_RemoveRoute_Call) RunAndReturn(run func(ctx context.Context, domain1 string) error) *MockConfigService_RemoveRoute_Call {
563+ _c.Call.Return(run)
564+ return _c
565+}
566+
567+// UpdateRoute provides a mock function for the type MockConfigService
568+func (_mock *MockConfigService) UpdateRoute(ctx context.Context, route domain.Route) error {
569+ ret := _mock.Called(ctx, route)
570+
571+ if len(ret) == 0 {
572+ panic("no return value specified for UpdateRoute")
573+ }
574+
575+ var r0 error
576+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) error); ok {
577+ r0 = returnFunc(ctx, route)
578+ } else {
579+ r0 = ret.Error(0)
580+ }
581+ return r0
582+}
583+
584+// MockConfigService_UpdateRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRoute'
585+type MockConfigService_UpdateRoute_Call struct {
586+ *mock.Call
587+}
588+
589+// UpdateRoute is a helper method to define mock.On call
590+// - ctx context.Context
591+// - route domain.Route
592+func (_e *MockConfigService_Expecter) UpdateRoute(ctx interface{}, route interface{}) *MockConfigService_UpdateRoute_Call {
593+ return &MockConfigService_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}
594+}
595+
596+func (_c *MockConfigService_UpdateRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockConfigService_UpdateRoute_Call {
597+ _c.Call.Run(func(args mock.Arguments) {
598+ var arg0 context.Context
599+ if args[0] != nil {
600+ arg0 = args[0].(context.Context)
601+ }
602+ var arg1 domain.Route
603+ if args[1] != nil {
604+ arg1 = args[1].(domain.Route)
605+ }
606+ run(
607+ arg0,
608+ arg1,
609+ )
610+ })
611+ return _c
612+}
613+
614+func (_c *MockConfigService_UpdateRoute_Call) Return(err error) *MockConfigService_UpdateRoute_Call {
615+ _c.Call.Return(err)
616+ return _c
617+}
618+
619+func (_c *MockConfigService_UpdateRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) error) *MockConfigService_UpdateRoute_Call {
620+ _c.Call.Return(run)
621+ return _c
622+}
623+
624+// Watch provides a mock function for the type MockConfigService
625+func (_mock *MockConfigService) Watch(ctx context.Context, onChange func()) error {
626+ ret := _mock.Called(ctx, onChange)
627+
628+ if len(ret) == 0 {
629+ panic("no return value specified for Watch")
630+ }
631+
632+ var r0 error
633+ if returnFunc, ok := ret.Get(0).(func(context.Context, func()) error); ok {
634+ r0 = returnFunc(ctx, onChange)
635+ } else {
636+ r0 = ret.Error(0)
637+ }
638+ return r0
639+}
640+
641+// MockConfigService_Watch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Watch'
642+type MockConfigService_Watch_Call struct {
643+ *mock.Call
644+}
645+
646+// Watch is a helper method to define mock.On call
647+// - ctx context.Context
648+// - onChange func()
649+func (_e *MockConfigService_Expecter) Watch(ctx interface{}, onChange interface{}) *MockConfigService_Watch_Call {
650+ return &MockConfigService_Watch_Call{Call: _e.mock.On("Watch", ctx, onChange)}
651+}
652+
653+func (_c *MockConfigService_Watch_Call) Run(run func(ctx context.Context, onChange func())) *MockConfigService_Watch_Call {
654+ _c.Call.Run(func(args mock.Arguments) {
655+ var arg0 context.Context
656+ if args[0] != nil {
657+ arg0 = args[0].(context.Context)
658+ }
659+ var arg1 func()
660+ if args[1] != nil {
661+ arg1 = args[1].(func())
662+ }
663+ run(
664+ arg0,
665+ arg1,
666+ )
667+ })
668+ return _c
669+}
670+
671+func (_c *MockConfigService_Watch_Call) Return(err error) *MockConfigService_Watch_Call {
672+ _c.Call.Return(err)
673+ return _c
674+}
675+
676+func (_c *MockConfigService_Watch_Call) RunAndReturn(run func(ctx context.Context, onChange func()) error) *MockConfigService_Watch_Call {
677+ _c.Call.Return(run)
678+ return _c
679+}
A internal/boundaries/in/mocks/mock_container_service.go +554 -0
@@ -0,0 +1,554 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockContainerService creates a new instance of MockContainerService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockContainerService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockContainerService {
20+ mock := &MockContainerService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockContainerService is an autogenerated mock type for the ContainerService type
29+type MockContainerService struct {
30+ mock.Mock
31+}
32+
33+type MockContainerService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockContainerService) EXPECT() *MockContainerService_Expecter {
38+ return &MockContainerService_Expecter{mock: &_m.Mock}
39+}
40+
41+// AutoStart provides a mock function for the type MockContainerService
42+func (_mock *MockContainerService) AutoStart(ctx context.Context) error {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for AutoStart")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
51+ r0 = returnFunc(ctx)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockContainerService_AutoStart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AutoStart'
59+type MockContainerService_AutoStart_Call struct {
60+ *mock.Call
61+}
62+
63+// AutoStart is a helper method to define mock.On call
64+// - ctx context.Context
65+func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}) *MockContainerService_AutoStart_Call {
66+ return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx)}
67+}
68+
69+func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context)) *MockContainerService_AutoStart_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ run(
76+ arg0,
77+ )
78+ })
79+ return _c
80+}
81+
82+func (_c *MockContainerService_AutoStart_Call) Return(err error) *MockContainerService_AutoStart_Call {
83+ _c.Call.Return(err)
84+ return _c
85+}
86+
87+func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context) error) *MockContainerService_AutoStart_Call {
88+ _c.Call.Return(run)
89+ return _c
90+}
91+
92+// Deploy provides a mock function for the type MockContainerService
93+func (_mock *MockContainerService) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
94+ ret := _mock.Called(ctx, route)
95+
96+ if len(ret) == 0 {
97+ panic("no return value specified for Deploy")
98+ }
99+
100+ var r0 *domain.Container
101+ var r1 error
102+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) (*domain.Container, error)); ok {
103+ return returnFunc(ctx, route)
104+ }
105+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) *domain.Container); ok {
106+ r0 = returnFunc(ctx, route)
107+ } else {
108+ if ret.Get(0) != nil {
109+ r0 = ret.Get(0).(*domain.Container)
110+ }
111+ }
112+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Route) error); ok {
113+ r1 = returnFunc(ctx, route)
114+ } else {
115+ r1 = ret.Error(1)
116+ }
117+ return r0, r1
118+}
119+
120+// MockContainerService_Deploy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Deploy'
121+type MockContainerService_Deploy_Call struct {
122+ *mock.Call
123+}
124+
125+// Deploy is a helper method to define mock.On call
126+// - ctx context.Context
127+// - route domain.Route
128+func (_e *MockContainerService_Expecter) Deploy(ctx interface{}, route interface{}) *MockContainerService_Deploy_Call {
129+ return &MockContainerService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, route)}
130+}
131+
132+func (_c *MockContainerService_Deploy_Call) Run(run func(ctx context.Context, route domain.Route)) *MockContainerService_Deploy_Call {
133+ _c.Call.Run(func(args mock.Arguments) {
134+ var arg0 context.Context
135+ if args[0] != nil {
136+ arg0 = args[0].(context.Context)
137+ }
138+ var arg1 domain.Route
139+ if args[1] != nil {
140+ arg1 = args[1].(domain.Route)
141+ }
142+ run(
143+ arg0,
144+ arg1,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *MockContainerService_Deploy_Call) Return(container *domain.Container, err error) *MockContainerService_Deploy_Call {
151+ _c.Call.Return(container, err)
152+ return _c
153+}
154+
155+func (_c *MockContainerService_Deploy_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) (*domain.Container, error)) *MockContainerService_Deploy_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
159+
160+// Get provides a mock function for the type MockContainerService
161+func (_mock *MockContainerService) Get(ctx context.Context, domain1 string) (*domain.Container, bool) {
162+ ret := _mock.Called(ctx, domain1)
163+
164+ if len(ret) == 0 {
165+ panic("no return value specified for Get")
166+ }
167+
168+ var r0 *domain.Container
169+ var r1 bool
170+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.Container, bool)); ok {
171+ return returnFunc(ctx, domain1)
172+ }
173+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.Container); ok {
174+ r0 = returnFunc(ctx, domain1)
175+ } else {
176+ if ret.Get(0) != nil {
177+ r0 = ret.Get(0).(*domain.Container)
178+ }
179+ }
180+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
181+ r1 = returnFunc(ctx, domain1)
182+ } else {
183+ r1 = ret.Get(1).(bool)
184+ }
185+ return r0, r1
186+}
187+
188+// MockContainerService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
189+type MockContainerService_Get_Call struct {
190+ *mock.Call
191+}
192+
193+// Get is a helper method to define mock.On call
194+// - ctx context.Context
195+// - domain1 string
196+func (_e *MockContainerService_Expecter) Get(ctx interface{}, domain1 interface{}) *MockContainerService_Get_Call {
197+ return &MockContainerService_Get_Call{Call: _e.mock.On("Get", ctx, domain1)}
198+}
199+
200+func (_c *MockContainerService_Get_Call) Run(run func(ctx context.Context, domain1 string)) *MockContainerService_Get_Call {
201+ _c.Call.Run(func(args mock.Arguments) {
202+ var arg0 context.Context
203+ if args[0] != nil {
204+ arg0 = args[0].(context.Context)
205+ }
206+ var arg1 string
207+ if args[1] != nil {
208+ arg1 = args[1].(string)
209+ }
210+ run(
211+ arg0,
212+ arg1,
213+ )
214+ })
215+ return _c
216+}
217+
218+func (_c *MockContainerService_Get_Call) Return(container *domain.Container, b bool) *MockContainerService_Get_Call {
219+ _c.Call.Return(container, b)
220+ return _c
221+}
222+
223+func (_c *MockContainerService_Get_Call) RunAndReturn(run func(ctx context.Context, domain1 string) (*domain.Container, bool)) *MockContainerService_Get_Call {
224+ _c.Call.Return(run)
225+ return _c
226+}
227+
228+// HealthCheck provides a mock function for the type MockContainerService
229+func (_mock *MockContainerService) HealthCheck(ctx context.Context) map[string]bool {
230+ ret := _mock.Called(ctx)
231+
232+ if len(ret) == 0 {
233+ panic("no return value specified for HealthCheck")
234+ }
235+
236+ var r0 map[string]bool
237+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]bool); ok {
238+ r0 = returnFunc(ctx)
239+ } else {
240+ if ret.Get(0) != nil {
241+ r0 = ret.Get(0).(map[string]bool)
242+ }
243+ }
244+ return r0
245+}
246+
247+// MockContainerService_HealthCheck_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HealthCheck'
248+type MockContainerService_HealthCheck_Call struct {
249+ *mock.Call
250+}
251+
252+// HealthCheck is a helper method to define mock.On call
253+// - ctx context.Context
254+func (_e *MockContainerService_Expecter) HealthCheck(ctx interface{}) *MockContainerService_HealthCheck_Call {
255+ return &MockContainerService_HealthCheck_Call{Call: _e.mock.On("HealthCheck", ctx)}
256+}
257+
258+func (_c *MockContainerService_HealthCheck_Call) Run(run func(ctx context.Context)) *MockContainerService_HealthCheck_Call {
259+ _c.Call.Run(func(args mock.Arguments) {
260+ var arg0 context.Context
261+ if args[0] != nil {
262+ arg0 = args[0].(context.Context)
263+ }
264+ run(
265+ arg0,
266+ )
267+ })
268+ return _c
269+}
270+
271+func (_c *MockContainerService_HealthCheck_Call) Return(stringToBool map[string]bool) *MockContainerService_HealthCheck_Call {
272+ _c.Call.Return(stringToBool)
273+ return _c
274+}
275+
276+func (_c *MockContainerService_HealthCheck_Call) RunAndReturn(run func(ctx context.Context) map[string]bool) *MockContainerService_HealthCheck_Call {
277+ _c.Call.Return(run)
278+ return _c
279+}
280+
281+// List provides a mock function for the type MockContainerService
282+func (_mock *MockContainerService) List(ctx context.Context) map[string]*domain.Container {
283+ ret := _mock.Called(ctx)
284+
285+ if len(ret) == 0 {
286+ panic("no return value specified for List")
287+ }
288+
289+ var r0 map[string]*domain.Container
290+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]*domain.Container); ok {
291+ r0 = returnFunc(ctx)
292+ } else {
293+ if ret.Get(0) != nil {
294+ r0 = ret.Get(0).(map[string]*domain.Container)
295+ }
296+ }
297+ return r0
298+}
299+
300+// MockContainerService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
301+type MockContainerService_List_Call struct {
302+ *mock.Call
303+}
304+
305+// List is a helper method to define mock.On call
306+// - ctx context.Context
307+func (_e *MockContainerService_Expecter) List(ctx interface{}) *MockContainerService_List_Call {
308+ return &MockContainerService_List_Call{Call: _e.mock.On("List", ctx)}
309+}
310+
311+func (_c *MockContainerService_List_Call) Run(run func(ctx context.Context)) *MockContainerService_List_Call {
312+ _c.Call.Run(func(args mock.Arguments) {
313+ var arg0 context.Context
314+ if args[0] != nil {
315+ arg0 = args[0].(context.Context)
316+ }
317+ run(
318+ arg0,
319+ )
320+ })
321+ return _c
322+}
323+
324+func (_c *MockContainerService_List_Call) Return(stringToContainer map[string]*domain.Container) *MockContainerService_List_Call {
325+ _c.Call.Return(stringToContainer)
326+ return _c
327+}
328+
329+func (_c *MockContainerService_List_Call) RunAndReturn(run func(ctx context.Context) map[string]*domain.Container) *MockContainerService_List_Call {
330+ _c.Call.Return(run)
331+ return _c
332+}
333+
334+// Remove provides a mock function for the type MockContainerService
335+func (_mock *MockContainerService) Remove(ctx context.Context, containerID string, force bool) error {
336+ ret := _mock.Called(ctx, containerID, force)
337+
338+ if len(ret) == 0 {
339+ panic("no return value specified for Remove")
340+ }
341+
342+ var r0 error
343+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) error); ok {
344+ r0 = returnFunc(ctx, containerID, force)
345+ } else {
346+ r0 = ret.Error(0)
347+ }
348+ return r0
349+}
350+
351+// MockContainerService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
352+type MockContainerService_Remove_Call struct {
353+ *mock.Call
354+}
355+
356+// Remove is a helper method to define mock.On call
357+// - ctx context.Context
358+// - containerID string
359+// - force bool
360+func (_e *MockContainerService_Expecter) Remove(ctx interface{}, containerID interface{}, force interface{}) *MockContainerService_Remove_Call {
361+ return &MockContainerService_Remove_Call{Call: _e.mock.On("Remove", ctx, containerID, force)}
362+}
363+
364+func (_c *MockContainerService_Remove_Call) Run(run func(ctx context.Context, containerID string, force bool)) *MockContainerService_Remove_Call {
365+ _c.Call.Run(func(args mock.Arguments) {
366+ var arg0 context.Context
367+ if args[0] != nil {
368+ arg0 = args[0].(context.Context)
369+ }
370+ var arg1 string
371+ if args[1] != nil {
372+ arg1 = args[1].(string)
373+ }
374+ var arg2 bool
375+ if args[2] != nil {
376+ arg2 = args[2].(bool)
377+ }
378+ run(
379+ arg0,
380+ arg1,
381+ arg2,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *MockContainerService_Remove_Call) Return(err error) *MockContainerService_Remove_Call {
388+ _c.Call.Return(err)
389+ return _c
390+}
391+
392+func (_c *MockContainerService_Remove_Call) RunAndReturn(run func(ctx context.Context, containerID string, force bool) error) *MockContainerService_Remove_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// Shutdown provides a mock function for the type MockContainerService
398+func (_mock *MockContainerService) Shutdown(ctx context.Context) error {
399+ ret := _mock.Called(ctx)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for Shutdown")
403+ }
404+
405+ var r0 error
406+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
407+ r0 = returnFunc(ctx)
408+ } else {
409+ r0 = ret.Error(0)
410+ }
411+ return r0
412+}
413+
414+// MockContainerService_Shutdown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Shutdown'
415+type MockContainerService_Shutdown_Call struct {
416+ *mock.Call
417+}
418+
419+// Shutdown is a helper method to define mock.On call
420+// - ctx context.Context
421+func (_e *MockContainerService_Expecter) Shutdown(ctx interface{}) *MockContainerService_Shutdown_Call {
422+ return &MockContainerService_Shutdown_Call{Call: _e.mock.On("Shutdown", ctx)}
423+}
424+
425+func (_c *MockContainerService_Shutdown_Call) Run(run func(ctx context.Context)) *MockContainerService_Shutdown_Call {
426+ _c.Call.Run(func(args mock.Arguments) {
427+ var arg0 context.Context
428+ if args[0] != nil {
429+ arg0 = args[0].(context.Context)
430+ }
431+ run(
432+ arg0,
433+ )
434+ })
435+ return _c
436+}
437+
438+func (_c *MockContainerService_Shutdown_Call) Return(err error) *MockContainerService_Shutdown_Call {
439+ _c.Call.Return(err)
440+ return _c
441+}
442+
443+func (_c *MockContainerService_Shutdown_Call) RunAndReturn(run func(ctx context.Context) error) *MockContainerService_Shutdown_Call {
444+ _c.Call.Return(run)
445+ return _c
446+}
447+
448+// Stop provides a mock function for the type MockContainerService
449+func (_mock *MockContainerService) Stop(ctx context.Context, containerID string) error {
450+ ret := _mock.Called(ctx, containerID)
451+
452+ if len(ret) == 0 {
453+ panic("no return value specified for Stop")
454+ }
455+
456+ var r0 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
458+ r0 = returnFunc(ctx, containerID)
459+ } else {
460+ r0 = ret.Error(0)
461+ }
462+ return r0
463+}
464+
465+// MockContainerService_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
466+type MockContainerService_Stop_Call struct {
467+ *mock.Call
468+}
469+
470+// Stop is a helper method to define mock.On call
471+// - ctx context.Context
472+// - containerID string
473+func (_e *MockContainerService_Expecter) Stop(ctx interface{}, containerID interface{}) *MockContainerService_Stop_Call {
474+ return &MockContainerService_Stop_Call{Call: _e.mock.On("Stop", ctx, containerID)}
475+}
476+
477+func (_c *MockContainerService_Stop_Call) Run(run func(ctx context.Context, containerID string)) *MockContainerService_Stop_Call {
478+ _c.Call.Run(func(args mock.Arguments) {
479+ var arg0 context.Context
480+ if args[0] != nil {
481+ arg0 = args[0].(context.Context)
482+ }
483+ var arg1 string
484+ if args[1] != nil {
485+ arg1 = args[1].(string)
486+ }
487+ run(
488+ arg0,
489+ arg1,
490+ )
491+ })
492+ return _c
493+}
494+
495+func (_c *MockContainerService_Stop_Call) Return(err error) *MockContainerService_Stop_Call {
496+ _c.Call.Return(err)
497+ return _c
498+}
499+
500+func (_c *MockContainerService_Stop_Call) RunAndReturn(run func(ctx context.Context, containerID string) error) *MockContainerService_Stop_Call {
501+ _c.Call.Return(run)
502+ return _c
503+}
504+
505+// SyncContainers provides a mock function for the type MockContainerService
506+func (_mock *MockContainerService) SyncContainers(ctx context.Context) error {
507+ ret := _mock.Called(ctx)
508+
509+ if len(ret) == 0 {
510+ panic("no return value specified for SyncContainers")
511+ }
512+
513+ var r0 error
514+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
515+ r0 = returnFunc(ctx)
516+ } else {
517+ r0 = ret.Error(0)
518+ }
519+ return r0
520+}
521+
522+// MockContainerService_SyncContainers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SyncContainers'
523+type MockContainerService_SyncContainers_Call struct {
524+ *mock.Call
525+}
526+
527+// SyncContainers is a helper method to define mock.On call
528+// - ctx context.Context
529+func (_e *MockContainerService_Expecter) SyncContainers(ctx interface{}) *MockContainerService_SyncContainers_Call {
530+ return &MockContainerService_SyncContainers_Call{Call: _e.mock.On("SyncContainers", ctx)}
531+}
532+
533+func (_c *MockContainerService_SyncContainers_Call) Run(run func(ctx context.Context)) *MockContainerService_SyncContainers_Call {
534+ _c.Call.Run(func(args mock.Arguments) {
535+ var arg0 context.Context
536+ if args[0] != nil {
537+ arg0 = args[0].(context.Context)
538+ }
539+ run(
540+ arg0,
541+ )
542+ })
543+ return _c
544+}
545+
546+func (_c *MockContainerService_SyncContainers_Call) Return(err error) *MockContainerService_SyncContainers_Call {
547+ _c.Call.Return(err)
548+ return _c
549+}
550+
551+func (_c *MockContainerService_SyncContainers_Call) RunAndReturn(run func(ctx context.Context) error) *MockContainerService_SyncContainers_Call {
552+ _c.Call.Return(run)
553+ return _c
554+}
A internal/boundaries/in/mocks/mock_proxy_service.go +325 -0
@@ -0,0 +1,325 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+ "net/http"
11+
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockProxyService creates a new instance of MockProxyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockProxyService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockProxyService {
21+ mock := &MockProxyService{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockProxyService is an autogenerated mock type for the ProxyService type
30+type MockProxyService struct {
31+ mock.Mock
32+}
33+
34+type MockProxyService_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockProxyService) EXPECT() *MockProxyService_Expecter {
39+ return &MockProxyService_Expecter{mock: &_m.Mock}
40+}
41+
42+// GetTarget provides a mock function for the type MockProxyService
43+func (_mock *MockProxyService) GetTarget(ctx context.Context, domain1 string) (*domain.ProxyTarget, error) {
44+ ret := _mock.Called(ctx, domain1)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GetTarget")
48+ }
49+
50+ var r0 *domain.ProxyTarget
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*domain.ProxyTarget, error)); ok {
53+ return returnFunc(ctx, domain1)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) *domain.ProxyTarget); ok {
56+ r0 = returnFunc(ctx, domain1)
57+ } else {
58+ if ret.Get(0) != nil {
59+ r0 = ret.Get(0).(*domain.ProxyTarget)
60+ }
61+ }
62+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
63+ r1 = returnFunc(ctx, domain1)
64+ } else {
65+ r1 = ret.Error(1)
66+ }
67+ return r0, r1
68+}
69+
70+// MockProxyService_GetTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTarget'
71+type MockProxyService_GetTarget_Call struct {
72+ *mock.Call
73+}
74+
75+// GetTarget is a helper method to define mock.On call
76+// - ctx context.Context
77+// - domain1 string
78+func (_e *MockProxyService_Expecter) GetTarget(ctx interface{}, domain1 interface{}) *MockProxyService_GetTarget_Call {
79+ return &MockProxyService_GetTarget_Call{Call: _e.mock.On("GetTarget", ctx, domain1)}
80+}
81+
82+func (_c *MockProxyService_GetTarget_Call) Run(run func(ctx context.Context, domain1 string)) *MockProxyService_GetTarget_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 string
89+ if args[1] != nil {
90+ arg1 = args[1].(string)
91+ }
92+ run(
93+ arg0,
94+ arg1,
95+ )
96+ })
97+ return _c
98+}
99+
100+func (_c *MockProxyService_GetTarget_Call) Return(proxyTarget *domain.ProxyTarget, err error) *MockProxyService_GetTarget_Call {
101+ _c.Call.Return(proxyTarget, err)
102+ return _c
103+}
104+
105+func (_c *MockProxyService_GetTarget_Call) RunAndReturn(run func(ctx context.Context, domain1 string) (*domain.ProxyTarget, error)) *MockProxyService_GetTarget_Call {
106+ _c.Call.Return(run)
107+ return _c
108+}
109+
110+// RefreshTargets provides a mock function for the type MockProxyService
111+func (_mock *MockProxyService) RefreshTargets(ctx context.Context) error {
112+ ret := _mock.Called(ctx)
113+
114+ if len(ret) == 0 {
115+ panic("no return value specified for RefreshTargets")
116+ }
117+
118+ var r0 error
119+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
120+ r0 = returnFunc(ctx)
121+ } else {
122+ r0 = ret.Error(0)
123+ }
124+ return r0
125+}
126+
127+// MockProxyService_RefreshTargets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefreshTargets'
128+type MockProxyService_RefreshTargets_Call struct {
129+ *mock.Call
130+}
131+
132+// RefreshTargets is a helper method to define mock.On call
133+// - ctx context.Context
134+func (_e *MockProxyService_Expecter) RefreshTargets(ctx interface{}) *MockProxyService_RefreshTargets_Call {
135+ return &MockProxyService_RefreshTargets_Call{Call: _e.mock.On("RefreshTargets", ctx)}
136+}
137+
138+func (_c *MockProxyService_RefreshTargets_Call) Run(run func(ctx context.Context)) *MockProxyService_RefreshTargets_Call {
139+ _c.Call.Run(func(args mock.Arguments) {
140+ var arg0 context.Context
141+ if args[0] != nil {
142+ arg0 = args[0].(context.Context)
143+ }
144+ run(
145+ arg0,
146+ )
147+ })
148+ return _c
149+}
150+
151+func (_c *MockProxyService_RefreshTargets_Call) Return(err error) *MockProxyService_RefreshTargets_Call {
152+ _c.Call.Return(err)
153+ return _c
154+}
155+
156+func (_c *MockProxyService_RefreshTargets_Call) RunAndReturn(run func(ctx context.Context) error) *MockProxyService_RefreshTargets_Call {
157+ _c.Call.Return(run)
158+ return _c
159+}
160+
161+// RegisterTarget provides a mock function for the type MockProxyService
162+func (_mock *MockProxyService) RegisterTarget(ctx context.Context, domain1 string, target *domain.ProxyTarget) error {
163+ ret := _mock.Called(ctx, domain1, target)
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for RegisterTarget")
167+ }
168+
169+ var r0 error
170+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.ProxyTarget) error); ok {
171+ r0 = returnFunc(ctx, domain1, target)
172+ } else {
173+ r0 = ret.Error(0)
174+ }
175+ return r0
176+}
177+
178+// MockProxyService_RegisterTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterTarget'
179+type MockProxyService_RegisterTarget_Call struct {
180+ *mock.Call
181+}
182+
183+// RegisterTarget is a helper method to define mock.On call
184+// - ctx context.Context
185+// - domain1 string
186+// - target *domain.ProxyTarget
187+func (_e *MockProxyService_Expecter) RegisterTarget(ctx interface{}, domain1 interface{}, target interface{}) *MockProxyService_RegisterTarget_Call {
188+ return &MockProxyService_RegisterTarget_Call{Call: _e.mock.On("RegisterTarget", ctx, domain1, target)}
189+}
190+
191+func (_c *MockProxyService_RegisterTarget_Call) Run(run func(ctx context.Context, domain1 string, target *domain.ProxyTarget)) *MockProxyService_RegisterTarget_Call {
192+ _c.Call.Run(func(args mock.Arguments) {
193+ var arg0 context.Context
194+ if args[0] != nil {
195+ arg0 = args[0].(context.Context)
196+ }
197+ var arg1 string
198+ if args[1] != nil {
199+ arg1 = args[1].(string)
200+ }
201+ var arg2 *domain.ProxyTarget
202+ if args[2] != nil {
203+ arg2 = args[2].(*domain.ProxyTarget)
204+ }
205+ run(
206+ arg0,
207+ arg1,
208+ arg2,
209+ )
210+ })
211+ return _c
212+}
213+
214+func (_c *MockProxyService_RegisterTarget_Call) Return(err error) *MockProxyService_RegisterTarget_Call {
215+ _c.Call.Return(err)
216+ return _c
217+}
218+
219+func (_c *MockProxyService_RegisterTarget_Call) RunAndReturn(run func(ctx context.Context, domain1 string, target *domain.ProxyTarget) error) *MockProxyService_RegisterTarget_Call {
220+ _c.Call.Return(run)
221+ return _c
222+}
223+
224+// ServeHTTP provides a mock function for the type MockProxyService
225+func (_mock *MockProxyService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
226+ _mock.Called(w, r)
227+ return
228+}
229+
230+// MockProxyService_ServeHTTP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServeHTTP'
231+type MockProxyService_ServeHTTP_Call struct {
232+ *mock.Call
233+}
234+
235+// ServeHTTP is a helper method to define mock.On call
236+// - w http.ResponseWriter
237+// - r *http.Request
238+func (_e *MockProxyService_Expecter) ServeHTTP(w interface{}, r interface{}) *MockProxyService_ServeHTTP_Call {
239+ return &MockProxyService_ServeHTTP_Call{Call: _e.mock.On("ServeHTTP", w, r)}
240+}
241+
242+func (_c *MockProxyService_ServeHTTP_Call) Run(run func(w http.ResponseWriter, r *http.Request)) *MockProxyService_ServeHTTP_Call {
243+ _c.Call.Run(func(args mock.Arguments) {
244+ var arg0 http.ResponseWriter
245+ if args[0] != nil {
246+ arg0 = args[0].(http.ResponseWriter)
247+ }
248+ var arg1 *http.Request
249+ if args[1] != nil {
250+ arg1 = args[1].(*http.Request)
251+ }
252+ run(
253+ arg0,
254+ arg1,
255+ )
256+ })
257+ return _c
258+}
259+
260+func (_c *MockProxyService_ServeHTTP_Call) Return() *MockProxyService_ServeHTTP_Call {
261+ _c.Call.Return()
262+ return _c
263+}
264+
265+func (_c *MockProxyService_ServeHTTP_Call) RunAndReturn(run func(w http.ResponseWriter, r *http.Request)) *MockProxyService_ServeHTTP_Call {
266+ _c.Run(run)
267+ return _c
268+}
269+
270+// UnregisterTarget provides a mock function for the type MockProxyService
271+func (_mock *MockProxyService) UnregisterTarget(ctx context.Context, domain1 string) error {
272+ ret := _mock.Called(ctx, domain1)
273+
274+ if len(ret) == 0 {
275+ panic("no return value specified for UnregisterTarget")
276+ }
277+
278+ var r0 error
279+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
280+ r0 = returnFunc(ctx, domain1)
281+ } else {
282+ r0 = ret.Error(0)
283+ }
284+ return r0
285+}
286+
287+// MockProxyService_UnregisterTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnregisterTarget'
288+type MockProxyService_UnregisterTarget_Call struct {
289+ *mock.Call
290+}
291+
292+// UnregisterTarget is a helper method to define mock.On call
293+// - ctx context.Context
294+// - domain1 string
295+func (_e *MockProxyService_Expecter) UnregisterTarget(ctx interface{}, domain1 interface{}) *MockProxyService_UnregisterTarget_Call {
296+ return &MockProxyService_UnregisterTarget_Call{Call: _e.mock.On("UnregisterTarget", ctx, domain1)}
297+}
298+
299+func (_c *MockProxyService_UnregisterTarget_Call) Run(run func(ctx context.Context, domain1 string)) *MockProxyService_UnregisterTarget_Call {
300+ _c.Call.Run(func(args mock.Arguments) {
301+ var arg0 context.Context
302+ if args[0] != nil {
303+ arg0 = args[0].(context.Context)
304+ }
305+ var arg1 string
306+ if args[1] != nil {
307+ arg1 = args[1].(string)
308+ }
309+ run(
310+ arg0,
311+ arg1,
312+ )
313+ })
314+ return _c
315+}
316+
317+func (_c *MockProxyService_UnregisterTarget_Call) Return(err error) *MockProxyService_UnregisterTarget_Call {
318+ _c.Call.Return(err)
319+ return _c
320+}
321+
322+func (_c *MockProxyService_UnregisterTarget_Call) RunAndReturn(run func(ctx context.Context, domain1 string) error) *MockProxyService_UnregisterTarget_Call {
323+ _c.Call.Return(run)
324+ return _c
325+}
A internal/boundaries/in/mocks/mock_registry_service.go +744 -0
@@ -0,0 +1,744 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+ "io"
11+
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockRegistryService creates a new instance of MockRegistryService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockRegistryService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockRegistryService {
21+ mock := &MockRegistryService{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockRegistryService is an autogenerated mock type for the RegistryService type
30+type MockRegistryService struct {
31+ mock.Mock
32+}
33+
34+type MockRegistryService_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter {
39+ return &MockRegistryService_Expecter{mock: &_m.Mock}
40+}
41+
42+// BlobExists provides a mock function for the type MockRegistryService
43+func (_mock *MockRegistryService) BlobExists(ctx context.Context, digest string) bool {
44+ ret := _mock.Called(ctx, digest)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for BlobExists")
48+ }
49+
50+ var r0 bool
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
52+ r0 = returnFunc(ctx, digest)
53+ } else {
54+ r0 = ret.Get(0).(bool)
55+ }
56+ return r0
57+}
58+
59+// MockRegistryService_BlobExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BlobExists'
60+type MockRegistryService_BlobExists_Call struct {
61+ *mock.Call
62+}
63+
64+// BlobExists is a helper method to define mock.On call
65+// - ctx context.Context
66+// - digest string
67+func (_e *MockRegistryService_Expecter) BlobExists(ctx interface{}, digest interface{}) *MockRegistryService_BlobExists_Call {
68+ return &MockRegistryService_BlobExists_Call{Call: _e.mock.On("BlobExists", ctx, digest)}
69+}
70+
71+func (_c *MockRegistryService_BlobExists_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_BlobExists_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ var arg1 string
78+ if args[1] != nil {
79+ arg1 = args[1].(string)
80+ }
81+ run(
82+ arg0,
83+ arg1,
84+ )
85+ })
86+ return _c
87+}
88+
89+func (_c *MockRegistryService_BlobExists_Call) Return(b bool) *MockRegistryService_BlobExists_Call {
90+ _c.Call.Return(b)
91+ return _c
92+}
93+
94+func (_c *MockRegistryService_BlobExists_Call) RunAndReturn(run func(ctx context.Context, digest string) bool) *MockRegistryService_BlobExists_Call {
95+ _c.Call.Return(run)
96+ return _c
97+}
98+
99+// CancelUpload provides a mock function for the type MockRegistryService
100+func (_mock *MockRegistryService) CancelUpload(ctx context.Context, uuid string) error {
101+ ret := _mock.Called(ctx, uuid)
102+
103+ if len(ret) == 0 {
104+ panic("no return value specified for CancelUpload")
105+ }
106+
107+ var r0 error
108+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
109+ r0 = returnFunc(ctx, uuid)
110+ } else {
111+ r0 = ret.Error(0)
112+ }
113+ return r0
114+}
115+
116+// MockRegistryService_CancelUpload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelUpload'
117+type MockRegistryService_CancelUpload_Call struct {
118+ *mock.Call
119+}
120+
121+// CancelUpload is a helper method to define mock.On call
122+// - ctx context.Context
123+// - uuid string
124+func (_e *MockRegistryService_Expecter) CancelUpload(ctx interface{}, uuid interface{}) *MockRegistryService_CancelUpload_Call {
125+ return &MockRegistryService_CancelUpload_Call{Call: _e.mock.On("CancelUpload", ctx, uuid)}
126+}
127+
128+func (_c *MockRegistryService_CancelUpload_Call) Run(run func(ctx context.Context, uuid string)) *MockRegistryService_CancelUpload_Call {
129+ _c.Call.Run(func(args mock.Arguments) {
130+ var arg0 context.Context
131+ if args[0] != nil {
132+ arg0 = args[0].(context.Context)
133+ }
134+ var arg1 string
135+ if args[1] != nil {
136+ arg1 = args[1].(string)
137+ }
138+ run(
139+ arg0,
140+ arg1,
141+ )
142+ })
143+ return _c
144+}
145+
146+func (_c *MockRegistryService_CancelUpload_Call) Return(err error) *MockRegistryService_CancelUpload_Call {
147+ _c.Call.Return(err)
148+ return _c
149+}
150+
151+func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx context.Context, uuid string) error) *MockRegistryService_CancelUpload_Call {
152+ _c.Call.Return(run)
153+ return _c
154+}
155+
156+// DeleteManifest provides a mock function for the type MockRegistryService
157+func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {
158+ ret := _mock.Called(ctx, name, reference)
159+
160+ if len(ret) == 0 {
161+ panic("no return value specified for DeleteManifest")
162+ }
163+
164+ var r0 error
165+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
166+ r0 = returnFunc(ctx, name, reference)
167+ } else {
168+ r0 = ret.Error(0)
169+ }
170+ return r0
171+}
172+
173+// MockRegistryService_DeleteManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteManifest'
174+type MockRegistryService_DeleteManifest_Call struct {
175+ *mock.Call
176+}
177+
178+// DeleteManifest is a helper method to define mock.On call
179+// - ctx context.Context
180+// - name string
181+// - reference string
182+func (_e *MockRegistryService_Expecter) DeleteManifest(ctx interface{}, name interface{}, reference interface{}) *MockRegistryService_DeleteManifest_Call {
183+ return &MockRegistryService_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", ctx, name, reference)}
184+}
185+
186+func (_c *MockRegistryService_DeleteManifest_Call) Run(run func(ctx context.Context, name string, reference string)) *MockRegistryService_DeleteManifest_Call {
187+ _c.Call.Run(func(args mock.Arguments) {
188+ var arg0 context.Context
189+ if args[0] != nil {
190+ arg0 = args[0].(context.Context)
191+ }
192+ var arg1 string
193+ if args[1] != nil {
194+ arg1 = args[1].(string)
195+ }
196+ var arg2 string
197+ if args[2] != nil {
198+ arg2 = args[2].(string)
199+ }
200+ run(
201+ arg0,
202+ arg1,
203+ arg2,
204+ )
205+ })
206+ return _c
207+}
208+
209+func (_c *MockRegistryService_DeleteManifest_Call) Return(err error) *MockRegistryService_DeleteManifest_Call {
210+ _c.Call.Return(err)
211+ return _c
212+}
213+
214+func (_c *MockRegistryService_DeleteManifest_Call) RunAndReturn(run func(ctx context.Context, name string, reference string) error) *MockRegistryService_DeleteManifest_Call {
215+ _c.Call.Return(run)
216+ return _c
217+}
218+
219+// FinishUpload provides a mock function for the type MockRegistryService
220+func (_mock *MockRegistryService) FinishUpload(ctx context.Context, uuid string, digest string) error {
221+ ret := _mock.Called(ctx, uuid, digest)
222+
223+ if len(ret) == 0 {
224+ panic("no return value specified for FinishUpload")
225+ }
226+
227+ var r0 error
228+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
229+ r0 = returnFunc(ctx, uuid, digest)
230+ } else {
231+ r0 = ret.Error(0)
232+ }
233+ return r0
234+}
235+
236+// MockRegistryService_FinishUpload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishUpload'
237+type MockRegistryService_FinishUpload_Call struct {
238+ *mock.Call
239+}
240+
241+// FinishUpload is a helper method to define mock.On call
242+// - ctx context.Context
243+// - uuid string
244+// - digest string
245+func (_e *MockRegistryService_Expecter) FinishUpload(ctx interface{}, uuid interface{}, digest interface{}) *MockRegistryService_FinishUpload_Call {
246+ return &MockRegistryService_FinishUpload_Call{Call: _e.mock.On("FinishUpload", ctx, uuid, digest)}
247+}
248+
249+func (_c *MockRegistryService_FinishUpload_Call) Run(run func(ctx context.Context, uuid string, digest string)) *MockRegistryService_FinishUpload_Call {
250+ _c.Call.Run(func(args mock.Arguments) {
251+ var arg0 context.Context
252+ if args[0] != nil {
253+ arg0 = args[0].(context.Context)
254+ }
255+ var arg1 string
256+ if args[1] != nil {
257+ arg1 = args[1].(string)
258+ }
259+ var arg2 string
260+ if args[2] != nil {
261+ arg2 = args[2].(string)
262+ }
263+ run(
264+ arg0,
265+ arg1,
266+ arg2,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *MockRegistryService_FinishUpload_Call) Return(err error) *MockRegistryService_FinishUpload_Call {
273+ _c.Call.Return(err)
274+ return _c
275+}
276+
277+func (_c *MockRegistryService_FinishUpload_Call) RunAndReturn(run func(ctx context.Context, uuid string, digest string) error) *MockRegistryService_FinishUpload_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
281+
282+// GetBlob provides a mock function for the type MockRegistryService
283+func (_mock *MockRegistryService) GetBlob(ctx context.Context, digest string) (io.ReadCloser, error) {
284+ ret := _mock.Called(ctx, digest)
285+
286+ if len(ret) == 0 {
287+ panic("no return value specified for GetBlob")
288+ }
289+
290+ var r0 io.ReadCloser
291+ var r1 error
292+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (io.ReadCloser, error)); ok {
293+ return returnFunc(ctx, digest)
294+ }
295+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) io.ReadCloser); ok {
296+ r0 = returnFunc(ctx, digest)
297+ } else {
298+ if ret.Get(0) != nil {
299+ r0 = ret.Get(0).(io.ReadCloser)
300+ }
301+ }
302+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
303+ r1 = returnFunc(ctx, digest)
304+ } else {
305+ r1 = ret.Error(1)
306+ }
307+ return r0, r1
308+}
309+
310+// MockRegistryService_GetBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlob'
311+type MockRegistryService_GetBlob_Call struct {
312+ *mock.Call
313+}
314+
315+// GetBlob is a helper method to define mock.On call
316+// - ctx context.Context
317+// - digest string
318+func (_e *MockRegistryService_Expecter) GetBlob(ctx interface{}, digest interface{}) *MockRegistryService_GetBlob_Call {
319+ return &MockRegistryService_GetBlob_Call{Call: _e.mock.On("GetBlob", ctx, digest)}
320+}
321+
322+func (_c *MockRegistryService_GetBlob_Call) Run(run func(ctx context.Context, digest string)) *MockRegistryService_GetBlob_Call {
323+ _c.Call.Run(func(args mock.Arguments) {
324+ var arg0 context.Context
325+ if args[0] != nil {
326+ arg0 = args[0].(context.Context)
327+ }
328+ var arg1 string
329+ if args[1] != nil {
330+ arg1 = args[1].(string)
331+ }
332+ run(
333+ arg0,
334+ arg1,
335+ )
336+ })
337+ return _c
338+}
339+
340+func (_c *MockRegistryService_GetBlob_Call) Return(readCloser io.ReadCloser, err error) *MockRegistryService_GetBlob_Call {
341+ _c.Call.Return(readCloser, err)
342+ return _c
343+}
344+
345+func (_c *MockRegistryService_GetBlob_Call) RunAndReturn(run func(ctx context.Context, digest string) (io.ReadCloser, error)) *MockRegistryService_GetBlob_Call {
346+ _c.Call.Return(run)
347+ return _c
348+}
349+
350+// GetManifest provides a mock function for the type MockRegistryService
351+func (_mock *MockRegistryService) GetManifest(ctx context.Context, name string, reference string) (*domain.Manifest, error) {
352+ ret := _mock.Called(ctx, name, reference)
353+
354+ if len(ret) == 0 {
355+ panic("no return value specified for GetManifest")
356+ }
357+
358+ var r0 *domain.Manifest
359+ var r1 error
360+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*domain.Manifest, error)); ok {
361+ return returnFunc(ctx, name, reference)
362+ }
363+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *domain.Manifest); ok {
364+ r0 = returnFunc(ctx, name, reference)
365+ } else {
366+ if ret.Get(0) != nil {
367+ r0 = ret.Get(0).(*domain.Manifest)
368+ }
369+ }
370+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
371+ r1 = returnFunc(ctx, name, reference)
372+ } else {
373+ r1 = ret.Error(1)
374+ }
375+ return r0, r1
376+}
377+
378+// MockRegistryService_GetManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetManifest'
379+type MockRegistryService_GetManifest_Call struct {
380+ *mock.Call
381+}
382+
383+// GetManifest is a helper method to define mock.On call
384+// - ctx context.Context
385+// - name string
386+// - reference string
387+func (_e *MockRegistryService_Expecter) GetManifest(ctx interface{}, name interface{}, reference interface{}) *MockRegistryService_GetManifest_Call {
388+ return &MockRegistryService_GetManifest_Call{Call: _e.mock.On("GetManifest", ctx, name, reference)}
389+}
390+
391+func (_c *MockRegistryService_GetManifest_Call) Run(run func(ctx context.Context, name string, reference string)) *MockRegistryService_GetManifest_Call {
392+ _c.Call.Run(func(args mock.Arguments) {
393+ var arg0 context.Context
394+ if args[0] != nil {
395+ arg0 = args[0].(context.Context)
396+ }
397+ var arg1 string
398+ if args[1] != nil {
399+ arg1 = args[1].(string)
400+ }
401+ var arg2 string
402+ if args[2] != nil {
403+ arg2 = args[2].(string)
404+ }
405+ run(
406+ arg0,
407+ arg1,
408+ arg2,
409+ )
410+ })
411+ return _c
412+}
413+
414+func (_c *MockRegistryService_GetManifest_Call) Return(manifest *domain.Manifest, err error) *MockRegistryService_GetManifest_Call {
415+ _c.Call.Return(manifest, err)
416+ return _c
417+}
418+
419+func (_c *MockRegistryService_GetManifest_Call) RunAndReturn(run func(ctx context.Context, name string, reference string) (*domain.Manifest, error)) *MockRegistryService_GetManifest_Call {
420+ _c.Call.Return(run)
421+ return _c
422+}
423+
424+// ListRepositories provides a mock function for the type MockRegistryService
425+func (_mock *MockRegistryService) ListRepositories(ctx context.Context) ([]string, error) {
426+ ret := _mock.Called(ctx)
427+
428+ if len(ret) == 0 {
429+ panic("no return value specified for ListRepositories")
430+ }
431+
432+ var r0 []string
433+ var r1 error
434+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
435+ return returnFunc(ctx)
436+ }
437+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
438+ r0 = returnFunc(ctx)
439+ } else {
440+ if ret.Get(0) != nil {
441+ r0 = ret.Get(0).([]string)
442+ }
443+ }
444+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
445+ r1 = returnFunc(ctx)
446+ } else {
447+ r1 = ret.Error(1)
448+ }
449+ return r0, r1
450+}
451+
452+// MockRegistryService_ListRepositories_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRepositories'
453+type MockRegistryService_ListRepositories_Call struct {
454+ *mock.Call
455+}
456+
457+// ListRepositories is a helper method to define mock.On call
458+// - ctx context.Context
459+func (_e *MockRegistryService_Expecter) ListRepositories(ctx interface{}) *MockRegistryService_ListRepositories_Call {
460+ return &MockRegistryService_ListRepositories_Call{Call: _e.mock.On("ListRepositories", ctx)}
461+}
462+
463+func (_c *MockRegistryService_ListRepositories_Call) Run(run func(ctx context.Context)) *MockRegistryService_ListRepositories_Call {
464+ _c.Call.Run(func(args mock.Arguments) {
465+ var arg0 context.Context
466+ if args[0] != nil {
467+ arg0 = args[0].(context.Context)
468+ }
469+ run(
470+ arg0,
471+ )
472+ })
473+ return _c
474+}
475+
476+func (_c *MockRegistryService_ListRepositories_Call) Return(strings []string, err error) *MockRegistryService_ListRepositories_Call {
477+ _c.Call.Return(strings, err)
478+ return _c
479+}
480+
481+func (_c *MockRegistryService_ListRepositories_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockRegistryService_ListRepositories_Call {
482+ _c.Call.Return(run)
483+ return _c
484+}
485+
486+// ListTags provides a mock function for the type MockRegistryService
487+func (_mock *MockRegistryService) ListTags(ctx context.Context, name string) ([]string, error) {
488+ ret := _mock.Called(ctx, name)
489+
490+ if len(ret) == 0 {
491+ panic("no return value specified for ListTags")
492+ }
493+
494+ var r0 []string
495+ var r1 error
496+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
497+ return returnFunc(ctx, name)
498+ }
499+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
500+ r0 = returnFunc(ctx, name)
501+ } else {
502+ if ret.Get(0) != nil {
503+ r0 = ret.Get(0).([]string)
504+ }
505+ }
506+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
507+ r1 = returnFunc(ctx, name)
508+ } else {
509+ r1 = ret.Error(1)
510+ }
511+ return r0, r1
512+}
513+
514+// MockRegistryService_ListTags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTags'
515+type MockRegistryService_ListTags_Call struct {
516+ *mock.Call
517+}
518+
519+// ListTags is a helper method to define mock.On call
520+// - ctx context.Context
521+// - name string
522+func (_e *MockRegistryService_Expecter) ListTags(ctx interface{}, name interface{}) *MockRegistryService_ListTags_Call {
523+ return &MockRegistryService_ListTags_Call{Call: _e.mock.On("ListTags", ctx, name)}
524+}
525+
526+func (_c *MockRegistryService_ListTags_Call) Run(run func(ctx context.Context, name string)) *MockRegistryService_ListTags_Call {
527+ _c.Call.Run(func(args mock.Arguments) {
528+ var arg0 context.Context
529+ if args[0] != nil {
530+ arg0 = args[0].(context.Context)
531+ }
532+ var arg1 string
533+ if args[1] != nil {
534+ arg1 = args[1].(string)
535+ }
536+ run(
537+ arg0,
538+ arg1,
539+ )
540+ })
541+ return _c
542+}
543+
544+func (_c *MockRegistryService_ListTags_Call) Return(strings []string, err error) *MockRegistryService_ListTags_Call {
545+ _c.Call.Return(strings, err)
546+ return _c
547+}
548+
549+func (_c *MockRegistryService_ListTags_Call) RunAndReturn(run func(ctx context.Context, name string) ([]string, error)) *MockRegistryService_ListTags_Call {
550+ _c.Call.Return(run)
551+ return _c
552+}
553+
554+// PutBlob provides a mock function for the type MockRegistryService
555+func (_mock *MockRegistryService) PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error {
556+ ret := _mock.Called(ctx, digest, data, size)
557+
558+ if len(ret) == 0 {
559+ panic("no return value specified for PutBlob")
560+ }
561+
562+ var r0 error
563+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) error); ok {
564+ r0 = returnFunc(ctx, digest, data, size)
565+ } else {
566+ r0 = ret.Error(0)
567+ }
568+ return r0
569+}
570+
571+// MockRegistryService_PutBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutBlob'
572+type MockRegistryService_PutBlob_Call struct {
573+ *mock.Call
574+}
575+
576+// PutBlob is a helper method to define mock.On call
577+// - ctx context.Context
578+// - digest string
579+// - data io.Reader
580+// - size int64
581+func (_e *MockRegistryService_Expecter) PutBlob(ctx interface{}, digest interface{}, data interface{}, size interface{}) *MockRegistryService_PutBlob_Call {
582+ return &MockRegistryService_PutBlob_Call{Call: _e.mock.On("PutBlob", ctx, digest, data, size)}
583+}
584+
585+func (_c *MockRegistryService_PutBlob_Call) Run(run func(ctx context.Context, digest string, data io.Reader, size int64)) *MockRegistryService_PutBlob_Call {
586+ _c.Call.Run(func(args mock.Arguments) {
587+ var arg0 context.Context
588+ if args[0] != nil {
589+ arg0 = args[0].(context.Context)
590+ }
591+ var arg1 string
592+ if args[1] != nil {
593+ arg1 = args[1].(string)
594+ }
595+ var arg2 io.Reader
596+ if args[2] != nil {
597+ arg2 = args[2].(io.Reader)
598+ }
599+ var arg3 int64
600+ if args[3] != nil {
601+ arg3 = args[3].(int64)
602+ }
603+ run(
604+ arg0,
605+ arg1,
606+ arg2,
607+ arg3,
608+ )
609+ })
610+ return _c
611+}
612+
613+func (_c *MockRegistryService_PutBlob_Call) Return(err error) *MockRegistryService_PutBlob_Call {
614+ _c.Call.Return(err)
615+ return _c
616+}
617+
618+func (_c *MockRegistryService_PutBlob_Call) RunAndReturn(run func(ctx context.Context, digest string, data io.Reader, size int64) error) *MockRegistryService_PutBlob_Call {
619+ _c.Call.Return(run)
620+ return _c
621+}
622+
623+// PutManifest provides a mock function for the type MockRegistryService
624+func (_mock *MockRegistryService) PutManifest(ctx context.Context, manifest *domain.Manifest) error {
625+ ret := _mock.Called(ctx, manifest)
626+
627+ if len(ret) == 0 {
628+ panic("no return value specified for PutManifest")
629+ }
630+
631+ var r0 error
632+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Manifest) error); ok {
633+ r0 = returnFunc(ctx, manifest)
634+ } else {
635+ r0 = ret.Error(0)
636+ }
637+ return r0
638+}
639+
640+// MockRegistryService_PutManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutManifest'
641+type MockRegistryService_PutManifest_Call struct {
642+ *mock.Call
643+}
644+
645+// PutManifest is a helper method to define mock.On call
646+// - ctx context.Context
647+// - manifest *domain.Manifest
648+func (_e *MockRegistryService_Expecter) PutManifest(ctx interface{}, manifest interface{}) *MockRegistryService_PutManifest_Call {
649+ return &MockRegistryService_PutManifest_Call{Call: _e.mock.On("PutManifest", ctx, manifest)}
650+}
651+
652+func (_c *MockRegistryService_PutManifest_Call) Run(run func(ctx context.Context, manifest *domain.Manifest)) *MockRegistryService_PutManifest_Call {
653+ _c.Call.Run(func(args mock.Arguments) {
654+ var arg0 context.Context
655+ if args[0] != nil {
656+ arg0 = args[0].(context.Context)
657+ }
658+ var arg1 *domain.Manifest
659+ if args[1] != nil {
660+ arg1 = args[1].(*domain.Manifest)
661+ }
662+ run(
663+ arg0,
664+ arg1,
665+ )
666+ })
667+ return _c
668+}
669+
670+func (_c *MockRegistryService_PutManifest_Call) Return(err error) *MockRegistryService_PutManifest_Call {
671+ _c.Call.Return(err)
672+ return _c
673+}
674+
675+func (_c *MockRegistryService_PutManifest_Call) RunAndReturn(run func(ctx context.Context, manifest *domain.Manifest) error) *MockRegistryService_PutManifest_Call {
676+ _c.Call.Return(run)
677+ return _c
678+}
679+
680+// StartUpload provides a mock function for the type MockRegistryService
681+func (_mock *MockRegistryService) StartUpload(ctx context.Context, name string) (string, error) {
682+ ret := _mock.Called(ctx, name)
683+
684+ if len(ret) == 0 {
685+ panic("no return value specified for StartUpload")
686+ }
687+
688+ var r0 string
689+ var r1 error
690+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
691+ return returnFunc(ctx, name)
692+ }
693+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
694+ r0 = returnFunc(ctx, name)
695+ } else {
696+ r0 = ret.Get(0).(string)
697+ }
698+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
699+ r1 = returnFunc(ctx, name)
700+ } else {
701+ r1 = ret.Error(1)
702+ }
703+ return r0, r1
704+}
705+
706+// MockRegistryService_StartUpload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartUpload'
707+type MockRegistryService_StartUpload_Call struct {
708+ *mock.Call
709+}
710+
711+// StartUpload is a helper method to define mock.On call
712+// - ctx context.Context
713+// - name string
714+func (_e *MockRegistryService_Expecter) StartUpload(ctx interface{}, name interface{}) *MockRegistryService_StartUpload_Call {
715+ return &MockRegistryService_StartUpload_Call{Call: _e.mock.On("StartUpload", ctx, name)}
716+}
717+
718+func (_c *MockRegistryService_StartUpload_Call) Run(run func(ctx context.Context, name string)) *MockRegistryService_StartUpload_Call {
719+ _c.Call.Run(func(args mock.Arguments) {
720+ var arg0 context.Context
721+ if args[0] != nil {
722+ arg0 = args[0].(context.Context)
723+ }
724+ var arg1 string
725+ if args[1] != nil {
726+ arg1 = args[1].(string)
727+ }
728+ run(
729+ arg0,
730+ arg1,
731+ )
732+ })
733+ return _c
734+}
735+
736+func (_c *MockRegistryService_StartUpload_Call) Return(s string, err error) *MockRegistryService_StartUpload_Call {
737+ _c.Call.Return(s, err)
738+ return _c
739+}
740+
741+func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx context.Context, name string) (string, error)) *MockRegistryService_StartUpload_Call {
742+ _c.Call.Return(run)
743+ return _c
744+}
A internal/boundaries/in/proxy.go +26 -0
@@ -0,0 +1,26 @@
1+package in
2+
3+import (
4+ "context"
5+ "net/http"
6+
7+ "gordon/internal/domain"
8+)
9+
10+// ProxyService defines the contract for reverse proxy operations.
11+type ProxyService interface {
12+ // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
13+ ServeHTTP(w http.ResponseWriter, r *http.Request)
14+
15+ // GetTarget returns the proxy target for a given domain.
16+ GetTarget(ctx context.Context, domain string) (*domain.ProxyTarget, error)
17+
18+ // RegisterTarget registers a new proxy target for a domain.
19+ RegisterTarget(ctx context.Context, domain string, target *domain.ProxyTarget) error
20+
21+ // UnregisterTarget removes a proxy target for a domain.
22+ UnregisterTarget(ctx context.Context, domain string) error
23+
24+ // RefreshTargets refreshes all proxy targets from container state.
25+ RefreshTargets(ctx context.Context) error
26+}
A internal/boundaries/in/registry.go +30 -0
@@ -0,0 +1,30 @@
1+package in
2+
3+import (
4+ "context"
5+ "io"
6+
7+ "gordon/internal/domain"
8+)
9+
10+// RegistryService defines the contract for container registry operations.
11+type RegistryService interface {
12+ // Manifest operations
13+ GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error)
14+ PutManifest(ctx context.Context, manifest *domain.Manifest) error
15+ DeleteManifest(ctx context.Context, name, reference string) error
16+
17+ // Blob operations
18+ GetBlob(ctx context.Context, digest string) (io.ReadCloser, error)
19+ PutBlob(ctx context.Context, digest string, data io.Reader, size int64) error
20+ BlobExists(ctx context.Context, digest string) bool
21+
22+ // Upload operations
23+ StartUpload(ctx context.Context, name string) (string, error)
24+ FinishUpload(ctx context.Context, uuid, digest string) error
25+ CancelUpload(ctx context.Context, uuid string) error
26+
27+ // Tag operations
28+ ListTags(ctx context.Context, name string) ([]string, error)
29+ ListRepositories(ctx context.Context) ([]string, error)
30+}
A internal/boundaries/out/envloader.go +16 -0
@@ -0,0 +1,16 @@
1+package out
2+
3+import "context"
4+
5+// EnvLoader defines the contract for loading environment variables for containers.
6+type EnvLoader interface {
7+ // LoadEnv loads environment variables for a given domain.
8+ // Returns a slice of "KEY=VALUE" strings.
9+ LoadEnv(ctx context.Context, domain string) ([]string, error)
10+
11+ // CreateEnvFile creates an empty environment file for a new domain.
12+ CreateEnvFile(ctx context.Context, domain string) error
13+
14+ // EnvFileExists checks if an environment file exists for a domain.
15+ EnvFileExists(domain string) bool
16+}
A internal/boundaries/out/eventbus.go +30 -0
@@ -0,0 +1,30 @@
1+package out
2+
3+import (
4+ "gordon/internal/domain"
5+)
6+
7+// EventHandler defines the contract for handling events.
8+type EventHandler interface {
9+ Handle(event domain.Event) error
10+ CanHandle(eventType domain.EventType) bool
11+}
12+
13+// EventPublisher defines the contract for publishing events.
14+type EventPublisher interface {
15+ Publish(eventType domain.EventType, payload any) error
16+}
17+
18+// EventSubscriber defines the contract for subscribing to events.
19+type EventSubscriber interface {
20+ Subscribe(handler EventHandler) error
21+ Unsubscribe(handler EventHandler) error
22+}
23+
24+// EventBus combines publishing and subscribing capabilities with lifecycle management.
25+type EventBus interface {
26+ EventPublisher
27+ EventSubscriber
28+ Start() error
29+ Stop() error
30+}
A internal/boundaries/out/logwriter.go +21 -0
@@ -0,0 +1,21 @@
1+package out
2+
3+import (
4+ "context"
5+ "io"
6+)
7+
8+// ContainerLogWriter defines the contract for container log collection.
9+// This interface abstracts the storage of container stdout/stderr streams.
10+type ContainerLogWriter interface {
11+ // StartLogging begins collecting logs for a container.
12+ // The logStream should be from ContainerRuntime.GetContainerLogs with follow=true.
13+ // Domain is used for the log filename.
14+ StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error
15+
16+ // StopLogging stops log collection for a container.
17+ StopLogging(containerID string) error
18+
19+ // Close stops all logging and releases resources.
20+ Close() error
21+}
A internal/boundaries/out/mocks/mock_blob_storage.go +627 -0
@@ -0,0 +1,627 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "io"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockBlobStorage creates a new instance of MockBlobStorage. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockBlobStorage(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockBlobStorage {
19+ mock := &MockBlobStorage{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockBlobStorage is an autogenerated mock type for the BlobStorage type
28+type MockBlobStorage struct {
29+ mock.Mock
30+}
31+
32+type MockBlobStorage_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter {
37+ return &MockBlobStorage_Expecter{mock: &_m.Mock}
38+}
39+
40+// AppendBlobChunk provides a mock function for the type MockBlobStorage
41+func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, chunk []byte) (int64, error) {
42+ ret := _mock.Called(name, uuid, chunk)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for AppendBlobChunk")
46+ }
47+
48+ var r0 int64
49+ var r1 error
50+ if returnFunc, ok := ret.Get(0).(func(string, string, []byte) (int64, error)); ok {
51+ return returnFunc(name, uuid, chunk)
52+ }
53+ if returnFunc, ok := ret.Get(0).(func(string, string, []byte) int64); ok {
54+ r0 = returnFunc(name, uuid, chunk)
55+ } else {
56+ r0 = ret.Get(0).(int64)
57+ }
58+ if returnFunc, ok := ret.Get(1).(func(string, string, []byte) error); ok {
59+ r1 = returnFunc(name, uuid, chunk)
60+ } else {
61+ r1 = ret.Error(1)
62+ }
63+ return r0, r1
64+}
65+
66+// MockBlobStorage_AppendBlobChunk_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendBlobChunk'
67+type MockBlobStorage_AppendBlobChunk_Call struct {
68+ *mock.Call
69+}
70+
71+// AppendBlobChunk is a helper method to define mock.On call
72+// - name string
73+// - uuid string
74+// - chunk []byte
75+func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, chunk interface{}) *MockBlobStorage_AppendBlobChunk_Call {
76+ return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, chunk)}
77+}
78+
79+func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, chunk []byte)) *MockBlobStorage_AppendBlobChunk_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 string
82+ if args[0] != nil {
83+ arg0 = args[0].(string)
84+ }
85+ var arg1 string
86+ if args[1] != nil {
87+ arg1 = args[1].(string)
88+ }
89+ var arg2 []byte
90+ if args[2] != nil {
91+ arg2 = args[2].([]byte)
92+ }
93+ run(
94+ arg0,
95+ arg1,
96+ arg2,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *MockBlobStorage_AppendBlobChunk_Call {
103+ _c.Call.Return(n, err)
104+ return _c
105+}
106+
107+func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, chunk []byte) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
111+
112+// BlobExists provides a mock function for the type MockBlobStorage
113+func (_mock *MockBlobStorage) BlobExists(digest string) bool {
114+ ret := _mock.Called(digest)
115+
116+ if len(ret) == 0 {
117+ panic("no return value specified for BlobExists")
118+ }
119+
120+ var r0 bool
121+ if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
122+ r0 = returnFunc(digest)
123+ } else {
124+ r0 = ret.Get(0).(bool)
125+ }
126+ return r0
127+}
128+
129+// MockBlobStorage_BlobExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BlobExists'
130+type MockBlobStorage_BlobExists_Call struct {
131+ *mock.Call
132+}
133+
134+// BlobExists is a helper method to define mock.On call
135+// - digest string
136+func (_e *MockBlobStorage_Expecter) BlobExists(digest interface{}) *MockBlobStorage_BlobExists_Call {
137+ return &MockBlobStorage_BlobExists_Call{Call: _e.mock.On("BlobExists", digest)}
138+}
139+
140+func (_c *MockBlobStorage_BlobExists_Call) Run(run func(digest string)) *MockBlobStorage_BlobExists_Call {
141+ _c.Call.Run(func(args mock.Arguments) {
142+ var arg0 string
143+ if args[0] != nil {
144+ arg0 = args[0].(string)
145+ }
146+ run(
147+ arg0,
148+ )
149+ })
150+ return _c
151+}
152+
153+func (_c *MockBlobStorage_BlobExists_Call) Return(b bool) *MockBlobStorage_BlobExists_Call {
154+ _c.Call.Return(b)
155+ return _c
156+}
157+
158+func (_c *MockBlobStorage_BlobExists_Call) RunAndReturn(run func(digest string) bool) *MockBlobStorage_BlobExists_Call {
159+ _c.Call.Return(run)
160+ return _c
161+}
162+
163+// CancelBlobUpload provides a mock function for the type MockBlobStorage
164+func (_mock *MockBlobStorage) CancelBlobUpload(uuid string) error {
165+ ret := _mock.Called(uuid)
166+
167+ if len(ret) == 0 {
168+ panic("no return value specified for CancelBlobUpload")
169+ }
170+
171+ var r0 error
172+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
173+ r0 = returnFunc(uuid)
174+ } else {
175+ r0 = ret.Error(0)
176+ }
177+ return r0
178+}
179+
180+// MockBlobStorage_CancelBlobUpload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelBlobUpload'
181+type MockBlobStorage_CancelBlobUpload_Call struct {
182+ *mock.Call
183+}
184+
185+// CancelBlobUpload is a helper method to define mock.On call
186+// - uuid string
187+func (_e *MockBlobStorage_Expecter) CancelBlobUpload(uuid interface{}) *MockBlobStorage_CancelBlobUpload_Call {
188+ return &MockBlobStorage_CancelBlobUpload_Call{Call: _e.mock.On("CancelBlobUpload", uuid)}
189+}
190+
191+func (_c *MockBlobStorage_CancelBlobUpload_Call) Run(run func(uuid string)) *MockBlobStorage_CancelBlobUpload_Call {
192+ _c.Call.Run(func(args mock.Arguments) {
193+ var arg0 string
194+ if args[0] != nil {
195+ arg0 = args[0].(string)
196+ }
197+ run(
198+ arg0,
199+ )
200+ })
201+ return _c
202+}
203+
204+func (_c *MockBlobStorage_CancelBlobUpload_Call) Return(err error) *MockBlobStorage_CancelBlobUpload_Call {
205+ _c.Call.Return(err)
206+ return _c
207+}
208+
209+func (_c *MockBlobStorage_CancelBlobUpload_Call) RunAndReturn(run func(uuid string) error) *MockBlobStorage_CancelBlobUpload_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// DeleteBlob provides a mock function for the type MockBlobStorage
215+func (_mock *MockBlobStorage) DeleteBlob(digest string) error {
216+ ret := _mock.Called(digest)
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for DeleteBlob")
220+ }
221+
222+ var r0 error
223+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
224+ r0 = returnFunc(digest)
225+ } else {
226+ r0 = ret.Error(0)
227+ }
228+ return r0
229+}
230+
231+// MockBlobStorage_DeleteBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBlob'
232+type MockBlobStorage_DeleteBlob_Call struct {
233+ *mock.Call
234+}
235+
236+// DeleteBlob is a helper method to define mock.On call
237+// - digest string
238+func (_e *MockBlobStorage_Expecter) DeleteBlob(digest interface{}) *MockBlobStorage_DeleteBlob_Call {
239+ return &MockBlobStorage_DeleteBlob_Call{Call: _e.mock.On("DeleteBlob", digest)}
240+}
241+
242+func (_c *MockBlobStorage_DeleteBlob_Call) Run(run func(digest string)) *MockBlobStorage_DeleteBlob_Call {
243+ _c.Call.Run(func(args mock.Arguments) {
244+ var arg0 string
245+ if args[0] != nil {
246+ arg0 = args[0].(string)
247+ }
248+ run(
249+ arg0,
250+ )
251+ })
252+ return _c
253+}
254+
255+func (_c *MockBlobStorage_DeleteBlob_Call) Return(err error) *MockBlobStorage_DeleteBlob_Call {
256+ _c.Call.Return(err)
257+ return _c
258+}
259+
260+func (_c *MockBlobStorage_DeleteBlob_Call) RunAndReturn(run func(digest string) error) *MockBlobStorage_DeleteBlob_Call {
261+ _c.Call.Return(run)
262+ return _c
263+}
264+
265+// FinishBlobUpload provides a mock function for the type MockBlobStorage
266+func (_mock *MockBlobStorage) FinishBlobUpload(uuid string, digest string) error {
267+ ret := _mock.Called(uuid, digest)
268+
269+ if len(ret) == 0 {
270+ panic("no return value specified for FinishBlobUpload")
271+ }
272+
273+ var r0 error
274+ if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
275+ r0 = returnFunc(uuid, digest)
276+ } else {
277+ r0 = ret.Error(0)
278+ }
279+ return r0
280+}
281+
282+// MockBlobStorage_FinishBlobUpload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishBlobUpload'
283+type MockBlobStorage_FinishBlobUpload_Call struct {
284+ *mock.Call
285+}
286+
287+// FinishBlobUpload is a helper method to define mock.On call
288+// - uuid string
289+// - digest string
290+func (_e *MockBlobStorage_Expecter) FinishBlobUpload(uuid interface{}, digest interface{}) *MockBlobStorage_FinishBlobUpload_Call {
291+ return &MockBlobStorage_FinishBlobUpload_Call{Call: _e.mock.On("FinishBlobUpload", uuid, digest)}
292+}
293+
294+func (_c *MockBlobStorage_FinishBlobUpload_Call) Run(run func(uuid string, digest string)) *MockBlobStorage_FinishBlobUpload_Call {
295+ _c.Call.Run(func(args mock.Arguments) {
296+ var arg0 string
297+ if args[0] != nil {
298+ arg0 = args[0].(string)
299+ }
300+ var arg1 string
301+ if args[1] != nil {
302+ arg1 = args[1].(string)
303+ }
304+ run(
305+ arg0,
306+ arg1,
307+ )
308+ })
309+ return _c
310+}
311+
312+func (_c *MockBlobStorage_FinishBlobUpload_Call) Return(err error) *MockBlobStorage_FinishBlobUpload_Call {
313+ _c.Call.Return(err)
314+ return _c
315+}
316+
317+func (_c *MockBlobStorage_FinishBlobUpload_Call) RunAndReturn(run func(uuid string, digest string) error) *MockBlobStorage_FinishBlobUpload_Call {
318+ _c.Call.Return(run)
319+ return _c
320+}
321+
322+// GetBlob provides a mock function for the type MockBlobStorage
323+func (_mock *MockBlobStorage) GetBlob(digest string) (io.ReadCloser, error) {
324+ ret := _mock.Called(digest)
325+
326+ if len(ret) == 0 {
327+ panic("no return value specified for GetBlob")
328+ }
329+
330+ var r0 io.ReadCloser
331+ var r1 error
332+ if returnFunc, ok := ret.Get(0).(func(string) (io.ReadCloser, error)); ok {
333+ return returnFunc(digest)
334+ }
335+ if returnFunc, ok := ret.Get(0).(func(string) io.ReadCloser); ok {
336+ r0 = returnFunc(digest)
337+ } else {
338+ if ret.Get(0) != nil {
339+ r0 = ret.Get(0).(io.ReadCloser)
340+ }
341+ }
342+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
343+ r1 = returnFunc(digest)
344+ } else {
345+ r1 = ret.Error(1)
346+ }
347+ return r0, r1
348+}
349+
350+// MockBlobStorage_GetBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlob'
351+type MockBlobStorage_GetBlob_Call struct {
352+ *mock.Call
353+}
354+
355+// GetBlob is a helper method to define mock.On call
356+// - digest string
357+func (_e *MockBlobStorage_Expecter) GetBlob(digest interface{}) *MockBlobStorage_GetBlob_Call {
358+ return &MockBlobStorage_GetBlob_Call{Call: _e.mock.On("GetBlob", digest)}
359+}
360+
361+func (_c *MockBlobStorage_GetBlob_Call) Run(run func(digest string)) *MockBlobStorage_GetBlob_Call {
362+ _c.Call.Run(func(args mock.Arguments) {
363+ var arg0 string
364+ if args[0] != nil {
365+ arg0 = args[0].(string)
366+ }
367+ run(
368+ arg0,
369+ )
370+ })
371+ return _c
372+}
373+
374+func (_c *MockBlobStorage_GetBlob_Call) Return(readCloser io.ReadCloser, err error) *MockBlobStorage_GetBlob_Call {
375+ _c.Call.Return(readCloser, err)
376+ return _c
377+}
378+
379+func (_c *MockBlobStorage_GetBlob_Call) RunAndReturn(run func(digest string) (io.ReadCloser, error)) *MockBlobStorage_GetBlob_Call {
380+ _c.Call.Return(run)
381+ return _c
382+}
383+
384+// GetBlobPath provides a mock function for the type MockBlobStorage
385+func (_mock *MockBlobStorage) GetBlobPath(digest string) (string, error) {
386+ ret := _mock.Called(digest)
387+
388+ if len(ret) == 0 {
389+ panic("no return value specified for GetBlobPath")
390+ }
391+
392+ var r0 string
393+ var r1 error
394+ if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {
395+ return returnFunc(digest)
396+ }
397+ if returnFunc, ok := ret.Get(0).(func(string) string); ok {
398+ r0 = returnFunc(digest)
399+ } else {
400+ r0 = ret.Get(0).(string)
401+ }
402+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
403+ r1 = returnFunc(digest)
404+ } else {
405+ r1 = ret.Error(1)
406+ }
407+ return r0, r1
408+}
409+
410+// MockBlobStorage_GetBlobPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlobPath'
411+type MockBlobStorage_GetBlobPath_Call struct {
412+ *mock.Call
413+}
414+
415+// GetBlobPath is a helper method to define mock.On call
416+// - digest string
417+func (_e *MockBlobStorage_Expecter) GetBlobPath(digest interface{}) *MockBlobStorage_GetBlobPath_Call {
418+ return &MockBlobStorage_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", digest)}
419+}
420+
421+func (_c *MockBlobStorage_GetBlobPath_Call) Run(run func(digest string)) *MockBlobStorage_GetBlobPath_Call {
422+ _c.Call.Run(func(args mock.Arguments) {
423+ var arg0 string
424+ if args[0] != nil {
425+ arg0 = args[0].(string)
426+ }
427+ run(
428+ arg0,
429+ )
430+ })
431+ return _c
432+}
433+
434+func (_c *MockBlobStorage_GetBlobPath_Call) Return(s string, err error) *MockBlobStorage_GetBlobPath_Call {
435+ _c.Call.Return(s, err)
436+ return _c
437+}
438+
439+func (_c *MockBlobStorage_GetBlobPath_Call) RunAndReturn(run func(digest string) (string, error)) *MockBlobStorage_GetBlobPath_Call {
440+ _c.Call.Return(run)
441+ return _c
442+}
443+
444+// GetBlobUpload provides a mock function for the type MockBlobStorage
445+func (_mock *MockBlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
446+ ret := _mock.Called(uuid)
447+
448+ if len(ret) == 0 {
449+ panic("no return value specified for GetBlobUpload")
450+ }
451+
452+ var r0 io.WriteCloser
453+ var r1 error
454+ if returnFunc, ok := ret.Get(0).(func(string) (io.WriteCloser, error)); ok {
455+ return returnFunc(uuid)
456+ }
457+ if returnFunc, ok := ret.Get(0).(func(string) io.WriteCloser); ok {
458+ r0 = returnFunc(uuid)
459+ } else {
460+ if ret.Get(0) != nil {
461+ r0 = ret.Get(0).(io.WriteCloser)
462+ }
463+ }
464+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
465+ r1 = returnFunc(uuid)
466+ } else {
467+ r1 = ret.Error(1)
468+ }
469+ return r0, r1
470+}
471+
472+// MockBlobStorage_GetBlobUpload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlobUpload'
473+type MockBlobStorage_GetBlobUpload_Call struct {
474+ *mock.Call
475+}
476+
477+// GetBlobUpload is a helper method to define mock.On call
478+// - uuid string
479+func (_e *MockBlobStorage_Expecter) GetBlobUpload(uuid interface{}) *MockBlobStorage_GetBlobUpload_Call {
480+ return &MockBlobStorage_GetBlobUpload_Call{Call: _e.mock.On("GetBlobUpload", uuid)}
481+}
482+
483+func (_c *MockBlobStorage_GetBlobUpload_Call) Run(run func(uuid string)) *MockBlobStorage_GetBlobUpload_Call {
484+ _c.Call.Run(func(args mock.Arguments) {
485+ var arg0 string
486+ if args[0] != nil {
487+ arg0 = args[0].(string)
488+ }
489+ run(
490+ arg0,
491+ )
492+ })
493+ return _c
494+}
495+
496+func (_c *MockBlobStorage_GetBlobUpload_Call) Return(writeCloser io.WriteCloser, err error) *MockBlobStorage_GetBlobUpload_Call {
497+ _c.Call.Return(writeCloser, err)
498+ return _c
499+}
500+
501+func (_c *MockBlobStorage_GetBlobUpload_Call) RunAndReturn(run func(uuid string) (io.WriteCloser, error)) *MockBlobStorage_GetBlobUpload_Call {
502+ _c.Call.Return(run)
503+ return _c
504+}
505+
506+// PutBlob provides a mock function for the type MockBlobStorage
507+func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
508+ ret := _mock.Called(digest, data, size)
509+
510+ if len(ret) == 0 {
511+ panic("no return value specified for PutBlob")
512+ }
513+
514+ var r0 error
515+ if returnFunc, ok := ret.Get(0).(func(string, io.Reader, int64) error); ok {
516+ r0 = returnFunc(digest, data, size)
517+ } else {
518+ r0 = ret.Error(0)
519+ }
520+ return r0
521+}
522+
523+// MockBlobStorage_PutBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutBlob'
524+type MockBlobStorage_PutBlob_Call struct {
525+ *mock.Call
526+}
527+
528+// PutBlob is a helper method to define mock.On call
529+// - digest string
530+// - data io.Reader
531+// - size int64
532+func (_e *MockBlobStorage_Expecter) PutBlob(digest interface{}, data interface{}, size interface{}) *MockBlobStorage_PutBlob_Call {
533+ return &MockBlobStorage_PutBlob_Call{Call: _e.mock.On("PutBlob", digest, data, size)}
534+}
535+
536+func (_c *MockBlobStorage_PutBlob_Call) Run(run func(digest string, data io.Reader, size int64)) *MockBlobStorage_PutBlob_Call {
537+ _c.Call.Run(func(args mock.Arguments) {
538+ var arg0 string
539+ if args[0] != nil {
540+ arg0 = args[0].(string)
541+ }
542+ var arg1 io.Reader
543+ if args[1] != nil {
544+ arg1 = args[1].(io.Reader)
545+ }
546+ var arg2 int64
547+ if args[2] != nil {
548+ arg2 = args[2].(int64)
549+ }
550+ run(
551+ arg0,
552+ arg1,
553+ arg2,
554+ )
555+ })
556+ return _c
557+}
558+
559+func (_c *MockBlobStorage_PutBlob_Call) Return(err error) *MockBlobStorage_PutBlob_Call {
560+ _c.Call.Return(err)
561+ return _c
562+}
563+
564+func (_c *MockBlobStorage_PutBlob_Call) RunAndReturn(run func(digest string, data io.Reader, size int64) error) *MockBlobStorage_PutBlob_Call {
565+ _c.Call.Return(run)
566+ return _c
567+}
568+
569+// StartBlobUpload provides a mock function for the type MockBlobStorage
570+func (_mock *MockBlobStorage) StartBlobUpload(name string) (string, error) {
571+ ret := _mock.Called(name)
572+
573+ if len(ret) == 0 {
574+ panic("no return value specified for StartBlobUpload")
575+ }
576+
577+ var r0 string
578+ var r1 error
579+ if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {
580+ return returnFunc(name)
581+ }
582+ if returnFunc, ok := ret.Get(0).(func(string) string); ok {
583+ r0 = returnFunc(name)
584+ } else {
585+ r0 = ret.Get(0).(string)
586+ }
587+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
588+ r1 = returnFunc(name)
589+ } else {
590+ r1 = ret.Error(1)
591+ }
592+ return r0, r1
593+}
594+
595+// MockBlobStorage_StartBlobUpload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartBlobUpload'
596+type MockBlobStorage_StartBlobUpload_Call struct {
597+ *mock.Call
598+}
599+
600+// StartBlobUpload is a helper method to define mock.On call
601+// - name string
602+func (_e *MockBlobStorage_Expecter) StartBlobUpload(name interface{}) *MockBlobStorage_StartBlobUpload_Call {
603+ return &MockBlobStorage_StartBlobUpload_Call{Call: _e.mock.On("StartBlobUpload", name)}
604+}
605+
606+func (_c *MockBlobStorage_StartBlobUpload_Call) Run(run func(name string)) *MockBlobStorage_StartBlobUpload_Call {
607+ _c.Call.Run(func(args mock.Arguments) {
608+ var arg0 string
609+ if args[0] != nil {
610+ arg0 = args[0].(string)
611+ }
612+ run(
613+ arg0,
614+ )
615+ })
616+ return _c
617+}
618+
619+func (_c *MockBlobStorage_StartBlobUpload_Call) Return(s string, err error) *MockBlobStorage_StartBlobUpload_Call {
620+ _c.Call.Return(s, err)
621+ return _c
622+}
623+
624+func (_c *MockBlobStorage_StartBlobUpload_Call) RunAndReturn(run func(name string) (string, error)) *MockBlobStorage_StartBlobUpload_Call {
625+ _c.Call.Return(run)
626+ return _c
627+}
A internal/boundaries/out/mocks/mock_container_log_writer.go +203 -0
@@ -0,0 +1,203 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockContainerLogWriter creates a new instance of MockContainerLogWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockContainerLogWriter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockContainerLogWriter {
20+ mock := &MockContainerLogWriter{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockContainerLogWriter is an autogenerated mock type for the ContainerLogWriter type
29+type MockContainerLogWriter struct {
30+ mock.Mock
31+}
32+
33+type MockContainerLogWriter_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockContainerLogWriter) EXPECT() *MockContainerLogWriter_Expecter {
38+ return &MockContainerLogWriter_Expecter{mock: &_m.Mock}
39+}
40+
41+// Close provides a mock function for the type MockContainerLogWriter
42+func (_mock *MockContainerLogWriter) Close() error {
43+ ret := _mock.Called()
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Close")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func() error); ok {
51+ r0 = returnFunc()
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockContainerLogWriter_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
59+type MockContainerLogWriter_Close_Call struct {
60+ *mock.Call
61+}
62+
63+// Close is a helper method to define mock.On call
64+func (_e *MockContainerLogWriter_Expecter) Close() *MockContainerLogWriter_Close_Call {
65+ return &MockContainerLogWriter_Close_Call{Call: _e.mock.On("Close")}
66+}
67+
68+func (_c *MockContainerLogWriter_Close_Call) Run(run func()) *MockContainerLogWriter_Close_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ run()
71+ })
72+ return _c
73+}
74+
75+func (_c *MockContainerLogWriter_Close_Call) Return(err error) *MockContainerLogWriter_Close_Call {
76+ _c.Call.Return(err)
77+ return _c
78+}
79+
80+func (_c *MockContainerLogWriter_Close_Call) RunAndReturn(run func() error) *MockContainerLogWriter_Close_Call {
81+ _c.Call.Return(run)
82+ return _c
83+}
84+
85+// StartLogging provides a mock function for the type MockContainerLogWriter
86+func (_mock *MockContainerLogWriter) StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error {
87+ ret := _mock.Called(ctx, containerID, domain, logStream)
88+
89+ if len(ret) == 0 {
90+ panic("no return value specified for StartLogging")
91+ }
92+
93+ var r0 error
94+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.ReadCloser) error); ok {
95+ r0 = returnFunc(ctx, containerID, domain, logStream)
96+ } else {
97+ r0 = ret.Error(0)
98+ }
99+ return r0
100+}
101+
102+// MockContainerLogWriter_StartLogging_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartLogging'
103+type MockContainerLogWriter_StartLogging_Call struct {
104+ *mock.Call
105+}
106+
107+// StartLogging is a helper method to define mock.On call
108+// - ctx context.Context
109+// - containerID string
110+// - domain string
111+// - logStream io.ReadCloser
112+func (_e *MockContainerLogWriter_Expecter) StartLogging(ctx interface{}, containerID interface{}, domain interface{}, logStream interface{}) *MockContainerLogWriter_StartLogging_Call {
113+ return &MockContainerLogWriter_StartLogging_Call{Call: _e.mock.On("StartLogging", ctx, containerID, domain, logStream)}
114+}
115+
116+func (_c *MockContainerLogWriter_StartLogging_Call) Run(run func(ctx context.Context, containerID string, domain string, logStream io.ReadCloser)) *MockContainerLogWriter_StartLogging_Call {
117+ _c.Call.Run(func(args mock.Arguments) {
118+ var arg0 context.Context
119+ if args[0] != nil {
120+ arg0 = args[0].(context.Context)
121+ }
122+ var arg1 string
123+ if args[1] != nil {
124+ arg1 = args[1].(string)
125+ }
126+ var arg2 string
127+ if args[2] != nil {
128+ arg2 = args[2].(string)
129+ }
130+ var arg3 io.ReadCloser
131+ if args[3] != nil {
132+ arg3 = args[3].(io.ReadCloser)
133+ }
134+ run(
135+ arg0,
136+ arg1,
137+ arg2,
138+ arg3,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *MockContainerLogWriter_StartLogging_Call) Return(err error) *MockContainerLogWriter_StartLogging_Call {
145+ _c.Call.Return(err)
146+ return _c
147+}
148+
149+func (_c *MockContainerLogWriter_StartLogging_Call) RunAndReturn(run func(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error) *MockContainerLogWriter_StartLogging_Call {
150+ _c.Call.Return(run)
151+ return _c
152+}
153+
154+// StopLogging provides a mock function for the type MockContainerLogWriter
155+func (_mock *MockContainerLogWriter) StopLogging(containerID string) error {
156+ ret := _mock.Called(containerID)
157+
158+ if len(ret) == 0 {
159+ panic("no return value specified for StopLogging")
160+ }
161+
162+ var r0 error
163+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
164+ r0 = returnFunc(containerID)
165+ } else {
166+ r0 = ret.Error(0)
167+ }
168+ return r0
169+}
170+
171+// MockContainerLogWriter_StopLogging_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopLogging'
172+type MockContainerLogWriter_StopLogging_Call struct {
173+ *mock.Call
174+}
175+
176+// StopLogging is a helper method to define mock.On call
177+// - containerID string
178+func (_e *MockContainerLogWriter_Expecter) StopLogging(containerID interface{}) *MockContainerLogWriter_StopLogging_Call {
179+ return &MockContainerLogWriter_StopLogging_Call{Call: _e.mock.On("StopLogging", containerID)}
180+}
181+
182+func (_c *MockContainerLogWriter_StopLogging_Call) Run(run func(containerID string)) *MockContainerLogWriter_StopLogging_Call {
183+ _c.Call.Run(func(args mock.Arguments) {
184+ var arg0 string
185+ if args[0] != nil {
186+ arg0 = args[0].(string)
187+ }
188+ run(
189+ arg0,
190+ )
191+ })
192+ return _c
193+}
194+
195+func (_c *MockContainerLogWriter_StopLogging_Call) Return(err error) *MockContainerLogWriter_StopLogging_Call {
196+ _c.Call.Return(err)
197+ return _c
198+}
199+
200+func (_c *MockContainerLogWriter_StopLogging_Call) RunAndReturn(run func(containerID string) error) *MockContainerLogWriter_StopLogging_Call {
201+ _c.Call.Return(run)
202+ return _c
203+}
A internal/boundaries/out/mocks/mock_container_runtime.go +2207 -0 large diff (2207 lines changed): collapsed
A internal/boundaries/out/mocks/mock_env_loader.go +214 -0
@@ -0,0 +1,214 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockEnvLoader creates a new instance of MockEnvLoader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockEnvLoader(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockEnvLoader {
19+ mock := &MockEnvLoader{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockEnvLoader is an autogenerated mock type for the EnvLoader type
28+type MockEnvLoader struct {
29+ mock.Mock
30+}
31+
32+type MockEnvLoader_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockEnvLoader) EXPECT() *MockEnvLoader_Expecter {
37+ return &MockEnvLoader_Expecter{mock: &_m.Mock}
38+}
39+
40+// CreateEnvFile provides a mock function for the type MockEnvLoader
41+func (_mock *MockEnvLoader) CreateEnvFile(ctx context.Context, domain string) error {
42+ ret := _mock.Called(ctx, domain)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for CreateEnvFile")
46+ }
47+
48+ var r0 error
49+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
50+ r0 = returnFunc(ctx, domain)
51+ } else {
52+ r0 = ret.Error(0)
53+ }
54+ return r0
55+}
56+
57+// MockEnvLoader_CreateEnvFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEnvFile'
58+type MockEnvLoader_CreateEnvFile_Call struct {
59+ *mock.Call
60+}
61+
62+// CreateEnvFile is a helper method to define mock.On call
63+// - ctx context.Context
64+// - domain string
65+func (_e *MockEnvLoader_Expecter) CreateEnvFile(ctx interface{}, domain interface{}) *MockEnvLoader_CreateEnvFile_Call {
66+ return &MockEnvLoader_CreateEnvFile_Call{Call: _e.mock.On("CreateEnvFile", ctx, domain)}
67+}
68+
69+func (_c *MockEnvLoader_CreateEnvFile_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_CreateEnvFile_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 string
76+ if args[1] != nil {
77+ arg1 = args[1].(string)
78+ }
79+ run(
80+ arg0,
81+ arg1,
82+ )
83+ })
84+ return _c
85+}
86+
87+func (_c *MockEnvLoader_CreateEnvFile_Call) Return(err error) *MockEnvLoader_CreateEnvFile_Call {
88+ _c.Call.Return(err)
89+ return _c
90+}
91+
92+func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Context, domain string) error) *MockEnvLoader_CreateEnvFile_Call {
93+ _c.Call.Return(run)
94+ return _c
95+}
96+
97+// EnvFileExists provides a mock function for the type MockEnvLoader
98+func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {
99+ ret := _mock.Called(domain)
100+
101+ if len(ret) == 0 {
102+ panic("no return value specified for EnvFileExists")
103+ }
104+
105+ var r0 bool
106+ if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
107+ r0 = returnFunc(domain)
108+ } else {
109+ r0 = ret.Get(0).(bool)
110+ }
111+ return r0
112+}
113+
114+// MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'
115+type MockEnvLoader_EnvFileExists_Call struct {
116+ *mock.Call
117+}
118+
119+// EnvFileExists is a helper method to define mock.On call
120+// - domain string
121+func (_e *MockEnvLoader_Expecter) EnvFileExists(domain interface{}) *MockEnvLoader_EnvFileExists_Call {
122+ return &MockEnvLoader_EnvFileExists_Call{Call: _e.mock.On("EnvFileExists", domain)}
123+}
124+
125+func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEnvLoader_EnvFileExists_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 string
128+ if args[0] != nil {
129+ arg0 = args[0].(string)
130+ }
131+ run(
132+ arg0,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool) *MockEnvLoader_EnvFileExists_Call {
139+ _c.Call.Return(b)
140+ return _c
141+}
142+
143+func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) bool) *MockEnvLoader_EnvFileExists_Call {
144+ _c.Call.Return(run)
145+ return _c
146+}
147+
148+// LoadEnv provides a mock function for the type MockEnvLoader
149+func (_mock *MockEnvLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
150+ ret := _mock.Called(ctx, domain)
151+
152+ if len(ret) == 0 {
153+ panic("no return value specified for LoadEnv")
154+ }
155+
156+ var r0 []string
157+ var r1 error
158+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
159+ return returnFunc(ctx, domain)
160+ }
161+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
162+ r0 = returnFunc(ctx, domain)
163+ } else {
164+ if ret.Get(0) != nil {
165+ r0 = ret.Get(0).([]string)
166+ }
167+ }
168+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
169+ r1 = returnFunc(ctx, domain)
170+ } else {
171+ r1 = ret.Error(1)
172+ }
173+ return r0, r1
174+}
175+
176+// MockEnvLoader_LoadEnv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadEnv'
177+type MockEnvLoader_LoadEnv_Call struct {
178+ *mock.Call
179+}
180+
181+// LoadEnv is a helper method to define mock.On call
182+// - ctx context.Context
183+// - domain string
184+func (_e *MockEnvLoader_Expecter) LoadEnv(ctx interface{}, domain interface{}) *MockEnvLoader_LoadEnv_Call {
185+ return &MockEnvLoader_LoadEnv_Call{Call: _e.mock.On("LoadEnv", ctx, domain)}
186+}
187+
188+func (_c *MockEnvLoader_LoadEnv_Call) Run(run func(ctx context.Context, domain string)) *MockEnvLoader_LoadEnv_Call {
189+ _c.Call.Run(func(args mock.Arguments) {
190+ var arg0 context.Context
191+ if args[0] != nil {
192+ arg0 = args[0].(context.Context)
193+ }
194+ var arg1 string
195+ if args[1] != nil {
196+ arg1 = args[1].(string)
197+ }
198+ run(
199+ arg0,
200+ arg1,
201+ )
202+ })
203+ return _c
204+}
205+
206+func (_c *MockEnvLoader_LoadEnv_Call) Return(strings []string, err error) *MockEnvLoader_LoadEnv_Call {
207+ _c.Call.Return(strings, err)
208+ return _c
209+}
210+
211+func (_c *MockEnvLoader_LoadEnv_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockEnvLoader_LoadEnv_Call {
212+ _c.Call.Return(run)
213+ return _c
214+}
A internal/boundaries/out/mocks/mock_event_bus.go +286 -0
@@ -0,0 +1,286 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "gordon/internal/boundaries/out"
9+ "gordon/internal/domain"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockEventBus creates a new instance of MockEventBus. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockEventBus(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockEventBus {
20+ mock := &MockEventBus{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockEventBus is an autogenerated mock type for the EventBus type
29+type MockEventBus struct {
30+ mock.Mock
31+}
32+
33+type MockEventBus_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockEventBus) EXPECT() *MockEventBus_Expecter {
38+ return &MockEventBus_Expecter{mock: &_m.Mock}
39+}
40+
41+// Publish provides a mock function for the type MockEventBus
42+func (_mock *MockEventBus) Publish(eventType domain.EventType, payload any) error {
43+ ret := _mock.Called(eventType, payload)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Publish")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(domain.EventType, any) error); ok {
51+ r0 = returnFunc(eventType, payload)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockEventBus_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish'
59+type MockEventBus_Publish_Call struct {
60+ *mock.Call
61+}
62+
63+// Publish is a helper method to define mock.On call
64+// - eventType domain.EventType
65+// - payload any
66+func (_e *MockEventBus_Expecter) Publish(eventType interface{}, payload interface{}) *MockEventBus_Publish_Call {
67+ return &MockEventBus_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}
68+}
69+
70+func (_c *MockEventBus_Publish_Call) Run(run func(eventType domain.EventType, payload any)) *MockEventBus_Publish_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 domain.EventType
73+ if args[0] != nil {
74+ arg0 = args[0].(domain.EventType)
75+ }
76+ var arg1 any
77+ if args[1] != nil {
78+ arg1 = args[1].(any)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *MockEventBus_Publish_Call) Return(err error) *MockEventBus_Publish_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *MockEventBus_Publish_Call) RunAndReturn(run func(eventType domain.EventType, payload any) error) *MockEventBus_Publish_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// Start provides a mock function for the type MockEventBus
99+func (_mock *MockEventBus) Start() error {
100+ ret := _mock.Called()
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for Start")
104+ }
105+
106+ var r0 error
107+ if returnFunc, ok := ret.Get(0).(func() error); ok {
108+ r0 = returnFunc()
109+ } else {
110+ r0 = ret.Error(0)
111+ }
112+ return r0
113+}
114+
115+// MockEventBus_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
116+type MockEventBus_Start_Call struct {
117+ *mock.Call
118+}
119+
120+// Start is a helper method to define mock.On call
121+func (_e *MockEventBus_Expecter) Start() *MockEventBus_Start_Call {
122+ return &MockEventBus_Start_Call{Call: _e.mock.On("Start")}
123+}
124+
125+func (_c *MockEventBus_Start_Call) Run(run func()) *MockEventBus_Start_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ run()
128+ })
129+ return _c
130+}
131+
132+func (_c *MockEventBus_Start_Call) Return(err error) *MockEventBus_Start_Call {
133+ _c.Call.Return(err)
134+ return _c
135+}
136+
137+func (_c *MockEventBus_Start_Call) RunAndReturn(run func() error) *MockEventBus_Start_Call {
138+ _c.Call.Return(run)
139+ return _c
140+}
141+
142+// Stop provides a mock function for the type MockEventBus
143+func (_mock *MockEventBus) Stop() error {
144+ ret := _mock.Called()
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for Stop")
148+ }
149+
150+ var r0 error
151+ if returnFunc, ok := ret.Get(0).(func() error); ok {
152+ r0 = returnFunc()
153+ } else {
154+ r0 = ret.Error(0)
155+ }
156+ return r0
157+}
158+
159+// MockEventBus_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
160+type MockEventBus_Stop_Call struct {
161+ *mock.Call
162+}
163+
164+// Stop is a helper method to define mock.On call
165+func (_e *MockEventBus_Expecter) Stop() *MockEventBus_Stop_Call {
166+ return &MockEventBus_Stop_Call{Call: _e.mock.On("Stop")}
167+}
168+
169+func (_c *MockEventBus_Stop_Call) Run(run func()) *MockEventBus_Stop_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ run()
172+ })
173+ return _c
174+}
175+
176+func (_c *MockEventBus_Stop_Call) Return(err error) *MockEventBus_Stop_Call {
177+ _c.Call.Return(err)
178+ return _c
179+}
180+
181+func (_c *MockEventBus_Stop_Call) RunAndReturn(run func() error) *MockEventBus_Stop_Call {
182+ _c.Call.Return(run)
183+ return _c
184+}
185+
186+// Subscribe provides a mock function for the type MockEventBus
187+func (_mock *MockEventBus) Subscribe(handler out.EventHandler) error {
188+ ret := _mock.Called(handler)
189+
190+ if len(ret) == 0 {
191+ panic("no return value specified for Subscribe")
192+ }
193+
194+ var r0 error
195+ if returnFunc, ok := ret.Get(0).(func(out.EventHandler) error); ok {
196+ r0 = returnFunc(handler)
197+ } else {
198+ r0 = ret.Error(0)
199+ }
200+ return r0
201+}
202+
203+// MockEventBus_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
204+type MockEventBus_Subscribe_Call struct {
205+ *mock.Call
206+}
207+
208+// Subscribe is a helper method to define mock.On call
209+// - handler out.EventHandler
210+func (_e *MockEventBus_Expecter) Subscribe(handler interface{}) *MockEventBus_Subscribe_Call {
211+ return &MockEventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}
212+}
213+
214+func (_c *MockEventBus_Subscribe_Call) Run(run func(handler out.EventHandler)) *MockEventBus_Subscribe_Call {
215+ _c.Call.Run(func(args mock.Arguments) {
216+ var arg0 out.EventHandler
217+ if args[0] != nil {
218+ arg0 = args[0].(out.EventHandler)
219+ }
220+ run(
221+ arg0,
222+ )
223+ })
224+ return _c
225+}
226+
227+func (_c *MockEventBus_Subscribe_Call) Return(err error) *MockEventBus_Subscribe_Call {
228+ _c.Call.Return(err)
229+ return _c
230+}
231+
232+func (_c *MockEventBus_Subscribe_Call) RunAndReturn(run func(handler out.EventHandler) error) *MockEventBus_Subscribe_Call {
233+ _c.Call.Return(run)
234+ return _c
235+}
236+
237+// Unsubscribe provides a mock function for the type MockEventBus
238+func (_mock *MockEventBus) Unsubscribe(handler out.EventHandler) error {
239+ ret := _mock.Called(handler)
240+
241+ if len(ret) == 0 {
242+ panic("no return value specified for Unsubscribe")
243+ }
244+
245+ var r0 error
246+ if returnFunc, ok := ret.Get(0).(func(out.EventHandler) error); ok {
247+ r0 = returnFunc(handler)
248+ } else {
249+ r0 = ret.Error(0)
250+ }
251+ return r0
252+}
253+
254+// MockEventBus_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe'
255+type MockEventBus_Unsubscribe_Call struct {
256+ *mock.Call
257+}
258+
259+// Unsubscribe is a helper method to define mock.On call
260+// - handler out.EventHandler
261+func (_e *MockEventBus_Expecter) Unsubscribe(handler interface{}) *MockEventBus_Unsubscribe_Call {
262+ return &MockEventBus_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}
263+}
264+
265+func (_c *MockEventBus_Unsubscribe_Call) Run(run func(handler out.EventHandler)) *MockEventBus_Unsubscribe_Call {
266+ _c.Call.Run(func(args mock.Arguments) {
267+ var arg0 out.EventHandler
268+ if args[0] != nil {
269+ arg0 = args[0].(out.EventHandler)
270+ }
271+ run(
272+ arg0,
273+ )
274+ })
275+ return _c
276+}
277+
278+func (_c *MockEventBus_Unsubscribe_Call) Return(err error) *MockEventBus_Unsubscribe_Call {
279+ _c.Call.Return(err)
280+ return _c
281+}
282+
283+func (_c *MockEventBus_Unsubscribe_Call) RunAndReturn(run func(handler out.EventHandler) error) *MockEventBus_Unsubscribe_Call {
284+ _c.Call.Return(run)
285+ return _c
286+}
A internal/boundaries/out/mocks/mock_event_handler.go +140 -0
@@ -0,0 +1,140 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "gordon/internal/domain"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockEventHandler creates a new instance of MockEventHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockEventHandler(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockEventHandler {
19+ mock := &MockEventHandler{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockEventHandler is an autogenerated mock type for the EventHandler type
28+type MockEventHandler struct {
29+ mock.Mock
30+}
31+
32+type MockEventHandler_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockEventHandler) EXPECT() *MockEventHandler_Expecter {
37+ return &MockEventHandler_Expecter{mock: &_m.Mock}
38+}
39+
40+// CanHandle provides a mock function for the type MockEventHandler
41+func (_mock *MockEventHandler) CanHandle(eventType domain.EventType) bool {
42+ ret := _mock.Called(eventType)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for CanHandle")
46+ }
47+
48+ var r0 bool
49+ if returnFunc, ok := ret.Get(0).(func(domain.EventType) bool); ok {
50+ r0 = returnFunc(eventType)
51+ } else {
52+ r0 = ret.Get(0).(bool)
53+ }
54+ return r0
55+}
56+
57+// MockEventHandler_CanHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CanHandle'
58+type MockEventHandler_CanHandle_Call struct {
59+ *mock.Call
60+}
61+
62+// CanHandle is a helper method to define mock.On call
63+// - eventType domain.EventType
64+func (_e *MockEventHandler_Expecter) CanHandle(eventType interface{}) *MockEventHandler_CanHandle_Call {
65+ return &MockEventHandler_CanHandle_Call{Call: _e.mock.On("CanHandle", eventType)}
66+}
67+
68+func (_c *MockEventHandler_CanHandle_Call) Run(run func(eventType domain.EventType)) *MockEventHandler_CanHandle_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 domain.EventType
71+ if args[0] != nil {
72+ arg0 = args[0].(domain.EventType)
73+ }
74+ run(
75+ arg0,
76+ )
77+ })
78+ return _c
79+}
80+
81+func (_c *MockEventHandler_CanHandle_Call) Return(b bool) *MockEventHandler_CanHandle_Call {
82+ _c.Call.Return(b)
83+ return _c
84+}
85+
86+func (_c *MockEventHandler_CanHandle_Call) RunAndReturn(run func(eventType domain.EventType) bool) *MockEventHandler_CanHandle_Call {
87+ _c.Call.Return(run)
88+ return _c
89+}
90+
91+// Handle provides a mock function for the type MockEventHandler
92+func (_mock *MockEventHandler) Handle(event domain.Event) error {
93+ ret := _mock.Called(event)
94+
95+ if len(ret) == 0 {
96+ panic("no return value specified for Handle")
97+ }
98+
99+ var r0 error
100+ if returnFunc, ok := ret.Get(0).(func(domain.Event) error); ok {
101+ r0 = returnFunc(event)
102+ } else {
103+ r0 = ret.Error(0)
104+ }
105+ return r0
106+}
107+
108+// MockEventHandler_Handle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Handle'
109+type MockEventHandler_Handle_Call struct {
110+ *mock.Call
111+}
112+
113+// Handle is a helper method to define mock.On call
114+// - event domain.Event
115+func (_e *MockEventHandler_Expecter) Handle(event interface{}) *MockEventHandler_Handle_Call {
116+ return &MockEventHandler_Handle_Call{Call: _e.mock.On("Handle", event)}
117+}
118+
119+func (_c *MockEventHandler_Handle_Call) Run(run func(event domain.Event)) *MockEventHandler_Handle_Call {
120+ _c.Call.Run(func(args mock.Arguments) {
121+ var arg0 domain.Event
122+ if args[0] != nil {
123+ arg0 = args[0].(domain.Event)
124+ }
125+ run(
126+ arg0,
127+ )
128+ })
129+ return _c
130+}
131+
132+func (_c *MockEventHandler_Handle_Call) Return(err error) *MockEventHandler_Handle_Call {
133+ _c.Call.Return(err)
134+ return _c
135+}
136+
137+func (_c *MockEventHandler_Handle_Call) RunAndReturn(run func(event domain.Event) error) *MockEventHandler_Handle_Call {
138+ _c.Call.Return(run)
139+ return _c
140+}
A internal/boundaries/out/mocks/mock_event_publisher.go +95 -0
@@ -0,0 +1,95 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "gordon/internal/domain"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockEventPublisher creates a new instance of MockEventPublisher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockEventPublisher(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockEventPublisher {
19+ mock := &MockEventPublisher{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockEventPublisher is an autogenerated mock type for the EventPublisher type
28+type MockEventPublisher struct {
29+ mock.Mock
30+}
31+
32+type MockEventPublisher_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockEventPublisher) EXPECT() *MockEventPublisher_Expecter {
37+ return &MockEventPublisher_Expecter{mock: &_m.Mock}
38+}
39+
40+// Publish provides a mock function for the type MockEventPublisher
41+func (_mock *MockEventPublisher) Publish(eventType domain.EventType, payload any) error {
42+ ret := _mock.Called(eventType, payload)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for Publish")
46+ }
47+
48+ var r0 error
49+ if returnFunc, ok := ret.Get(0).(func(domain.EventType, any) error); ok {
50+ r0 = returnFunc(eventType, payload)
51+ } else {
52+ r0 = ret.Error(0)
53+ }
54+ return r0
55+}
56+
57+// MockEventPublisher_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish'
58+type MockEventPublisher_Publish_Call struct {
59+ *mock.Call
60+}
61+
62+// Publish is a helper method to define mock.On call
63+// - eventType domain.EventType
64+// - payload any
65+func (_e *MockEventPublisher_Expecter) Publish(eventType interface{}, payload interface{}) *MockEventPublisher_Publish_Call {
66+ return &MockEventPublisher_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}
67+}
68+
69+func (_c *MockEventPublisher_Publish_Call) Run(run func(eventType domain.EventType, payload any)) *MockEventPublisher_Publish_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 domain.EventType
72+ if args[0] != nil {
73+ arg0 = args[0].(domain.EventType)
74+ }
75+ var arg1 any
76+ if args[1] != nil {
77+ arg1 = args[1].(any)
78+ }
79+ run(
80+ arg0,
81+ arg1,
82+ )
83+ })
84+ return _c
85+}
86+
87+func (_c *MockEventPublisher_Publish_Call) Return(err error) *MockEventPublisher_Publish_Call {
88+ _c.Call.Return(err)
89+ return _c
90+}
91+
92+func (_c *MockEventPublisher_Publish_Call) RunAndReturn(run func(eventType domain.EventType, payload any) error) *MockEventPublisher_Publish_Call {
93+ _c.Call.Return(run)
94+ return _c
95+}
A internal/boundaries/out/mocks/mock_event_subscriber.go +140 -0
@@ -0,0 +1,140 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "gordon/internal/boundaries/out"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockEventSubscriber creates a new instance of MockEventSubscriber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockEventSubscriber(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockEventSubscriber {
19+ mock := &MockEventSubscriber{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockEventSubscriber is an autogenerated mock type for the EventSubscriber type
28+type MockEventSubscriber struct {
29+ mock.Mock
30+}
31+
32+type MockEventSubscriber_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockEventSubscriber) EXPECT() *MockEventSubscriber_Expecter {
37+ return &MockEventSubscriber_Expecter{mock: &_m.Mock}
38+}
39+
40+// Subscribe provides a mock function for the type MockEventSubscriber
41+func (_mock *MockEventSubscriber) Subscribe(handler out.EventHandler) error {
42+ ret := _mock.Called(handler)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for Subscribe")
46+ }
47+
48+ var r0 error
49+ if returnFunc, ok := ret.Get(0).(func(out.EventHandler) error); ok {
50+ r0 = returnFunc(handler)
51+ } else {
52+ r0 = ret.Error(0)
53+ }
54+ return r0
55+}
56+
57+// MockEventSubscriber_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
58+type MockEventSubscriber_Subscribe_Call struct {
59+ *mock.Call
60+}
61+
62+// Subscribe is a helper method to define mock.On call
63+// - handler out.EventHandler
64+func (_e *MockEventSubscriber_Expecter) Subscribe(handler interface{}) *MockEventSubscriber_Subscribe_Call {
65+ return &MockEventSubscriber_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}
66+}
67+
68+func (_c *MockEventSubscriber_Subscribe_Call) Run(run func(handler out.EventHandler)) *MockEventSubscriber_Subscribe_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 out.EventHandler
71+ if args[0] != nil {
72+ arg0 = args[0].(out.EventHandler)
73+ }
74+ run(
75+ arg0,
76+ )
77+ })
78+ return _c
79+}
80+
81+func (_c *MockEventSubscriber_Subscribe_Call) Return(err error) *MockEventSubscriber_Subscribe_Call {
82+ _c.Call.Return(err)
83+ return _c
84+}
85+
86+func (_c *MockEventSubscriber_Subscribe_Call) RunAndReturn(run func(handler out.EventHandler) error) *MockEventSubscriber_Subscribe_Call {
87+ _c.Call.Return(run)
88+ return _c
89+}
90+
91+// Unsubscribe provides a mock function for the type MockEventSubscriber
92+func (_mock *MockEventSubscriber) Unsubscribe(handler out.EventHandler) error {
93+ ret := _mock.Called(handler)
94+
95+ if len(ret) == 0 {
96+ panic("no return value specified for Unsubscribe")
97+ }
98+
99+ var r0 error
100+ if returnFunc, ok := ret.Get(0).(func(out.EventHandler) error); ok {
101+ r0 = returnFunc(handler)
102+ } else {
103+ r0 = ret.Error(0)
104+ }
105+ return r0
106+}
107+
108+// MockEventSubscriber_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe'
109+type MockEventSubscriber_Unsubscribe_Call struct {
110+ *mock.Call
111+}
112+
113+// Unsubscribe is a helper method to define mock.On call
114+// - handler out.EventHandler
115+func (_e *MockEventSubscriber_Expecter) Unsubscribe(handler interface{}) *MockEventSubscriber_Unsubscribe_Call {
116+ return &MockEventSubscriber_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}
117+}
118+
119+func (_c *MockEventSubscriber_Unsubscribe_Call) Run(run func(handler out.EventHandler)) *MockEventSubscriber_Unsubscribe_Call {
120+ _c.Call.Run(func(args mock.Arguments) {
121+ var arg0 out.EventHandler
122+ if args[0] != nil {
123+ arg0 = args[0].(out.EventHandler)
124+ }
125+ run(
126+ arg0,
127+ )
128+ })
129+ return _c
130+}
131+
132+func (_c *MockEventSubscriber_Unsubscribe_Call) Return(err error) *MockEventSubscriber_Unsubscribe_Call {
133+ _c.Call.Return(err)
134+ return _c
135+}
136+
137+func (_c *MockEventSubscriber_Unsubscribe_Call) RunAndReturn(run func(handler out.EventHandler) error) *MockEventSubscriber_Unsubscribe_Call {
138+ _c.Call.Return(run)
139+ return _c
140+}
A internal/boundaries/out/mocks/mock_manifest_storage.go +353 -0
@@ -0,0 +1,353 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockManifestStorage creates a new instance of MockManifestStorage. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockManifestStorage(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockManifestStorage {
17+ mock := &MockManifestStorage{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockManifestStorage is an autogenerated mock type for the ManifestStorage type
26+type MockManifestStorage struct {
27+ mock.Mock
28+}
29+
30+type MockManifestStorage_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockManifestStorage) EXPECT() *MockManifestStorage_Expecter {
35+ return &MockManifestStorage_Expecter{mock: &_m.Mock}
36+}
37+
38+// DeleteManifest provides a mock function for the type MockManifestStorage
39+func (_mock *MockManifestStorage) DeleteManifest(name string, reference string) error {
40+ ret := _mock.Called(name, reference)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for DeleteManifest")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
48+ r0 = returnFunc(name, reference)
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockManifestStorage_DeleteManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteManifest'
56+type MockManifestStorage_DeleteManifest_Call struct {
57+ *mock.Call
58+}
59+
60+// DeleteManifest is a helper method to define mock.On call
61+// - name string
62+// - reference string
63+func (_e *MockManifestStorage_Expecter) DeleteManifest(name interface{}, reference interface{}) *MockManifestStorage_DeleteManifest_Call {
64+ return &MockManifestStorage_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", name, reference)}
65+}
66+
67+func (_c *MockManifestStorage_DeleteManifest_Call) Run(run func(name string, reference string)) *MockManifestStorage_DeleteManifest_Call {
68+ _c.Call.Run(func(args mock.Arguments) {
69+ var arg0 string
70+ if args[0] != nil {
71+ arg0 = args[0].(string)
72+ }
73+ var arg1 string
74+ if args[1] != nil {
75+ arg1 = args[1].(string)
76+ }
77+ run(
78+ arg0,
79+ arg1,
80+ )
81+ })
82+ return _c
83+}
84+
85+func (_c *MockManifestStorage_DeleteManifest_Call) Return(err error) *MockManifestStorage_DeleteManifest_Call {
86+ _c.Call.Return(err)
87+ return _c
88+}
89+
90+func (_c *MockManifestStorage_DeleteManifest_Call) RunAndReturn(run func(name string, reference string) error) *MockManifestStorage_DeleteManifest_Call {
91+ _c.Call.Return(run)
92+ return _c
93+}
94+
95+// GetManifest provides a mock function for the type MockManifestStorage
96+func (_mock *MockManifestStorage) GetManifest(name string, reference string) ([]byte, string, error) {
97+ ret := _mock.Called(name, reference)
98+
99+ if len(ret) == 0 {
100+ panic("no return value specified for GetManifest")
101+ }
102+
103+ var r0 []byte
104+ var r1 string
105+ var r2 error
106+ if returnFunc, ok := ret.Get(0).(func(string, string) ([]byte, string, error)); ok {
107+ return returnFunc(name, reference)
108+ }
109+ if returnFunc, ok := ret.Get(0).(func(string, string) []byte); ok {
110+ r0 = returnFunc(name, reference)
111+ } else {
112+ if ret.Get(0) != nil {
113+ r0 = ret.Get(0).([]byte)
114+ }
115+ }
116+ if returnFunc, ok := ret.Get(1).(func(string, string) string); ok {
117+ r1 = returnFunc(name, reference)
118+ } else {
119+ r1 = ret.Get(1).(string)
120+ }
121+ if returnFunc, ok := ret.Get(2).(func(string, string) error); ok {
122+ r2 = returnFunc(name, reference)
123+ } else {
124+ r2 = ret.Error(2)
125+ }
126+ return r0, r1, r2
127+}
128+
129+// MockManifestStorage_GetManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetManifest'
130+type MockManifestStorage_GetManifest_Call struct {
131+ *mock.Call
132+}
133+
134+// GetManifest is a helper method to define mock.On call
135+// - name string
136+// - reference string
137+func (_e *MockManifestStorage_Expecter) GetManifest(name interface{}, reference interface{}) *MockManifestStorage_GetManifest_Call {
138+ return &MockManifestStorage_GetManifest_Call{Call: _e.mock.On("GetManifest", name, reference)}
139+}
140+
141+func (_c *MockManifestStorage_GetManifest_Call) Run(run func(name string, reference string)) *MockManifestStorage_GetManifest_Call {
142+ _c.Call.Run(func(args mock.Arguments) {
143+ var arg0 string
144+ if args[0] != nil {
145+ arg0 = args[0].(string)
146+ }
147+ var arg1 string
148+ if args[1] != nil {
149+ arg1 = args[1].(string)
150+ }
151+ run(
152+ arg0,
153+ arg1,
154+ )
155+ })
156+ return _c
157+}
158+
159+func (_c *MockManifestStorage_GetManifest_Call) Return(bytes []byte, s string, err error) *MockManifestStorage_GetManifest_Call {
160+ _c.Call.Return(bytes, s, err)
161+ return _c
162+}
163+
164+func (_c *MockManifestStorage_GetManifest_Call) RunAndReturn(run func(name string, reference string) ([]byte, string, error)) *MockManifestStorage_GetManifest_Call {
165+ _c.Call.Return(run)
166+ return _c
167+}
168+
169+// ListRepositories provides a mock function for the type MockManifestStorage
170+func (_mock *MockManifestStorage) ListRepositories() ([]string, error) {
171+ ret := _mock.Called()
172+
173+ if len(ret) == 0 {
174+ panic("no return value specified for ListRepositories")
175+ }
176+
177+ var r0 []string
178+ var r1 error
179+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
180+ return returnFunc()
181+ }
182+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
183+ r0 = returnFunc()
184+ } else {
185+ if ret.Get(0) != nil {
186+ r0 = ret.Get(0).([]string)
187+ }
188+ }
189+ if returnFunc, ok := ret.Get(1).(func() error); ok {
190+ r1 = returnFunc()
191+ } else {
192+ r1 = ret.Error(1)
193+ }
194+ return r0, r1
195+}
196+
197+// MockManifestStorage_ListRepositories_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRepositories'
198+type MockManifestStorage_ListRepositories_Call struct {
199+ *mock.Call
200+}
201+
202+// ListRepositories is a helper method to define mock.On call
203+func (_e *MockManifestStorage_Expecter) ListRepositories() *MockManifestStorage_ListRepositories_Call {
204+ return &MockManifestStorage_ListRepositories_Call{Call: _e.mock.On("ListRepositories")}
205+}
206+
207+func (_c *MockManifestStorage_ListRepositories_Call) Run(run func()) *MockManifestStorage_ListRepositories_Call {
208+ _c.Call.Run(func(args mock.Arguments) {
209+ run()
210+ })
211+ return _c
212+}
213+
214+func (_c *MockManifestStorage_ListRepositories_Call) Return(strings []string, err error) *MockManifestStorage_ListRepositories_Call {
215+ _c.Call.Return(strings, err)
216+ return _c
217+}
218+
219+func (_c *MockManifestStorage_ListRepositories_Call) RunAndReturn(run func() ([]string, error)) *MockManifestStorage_ListRepositories_Call {
220+ _c.Call.Return(run)
221+ return _c
222+}
223+
224+// ListTags provides a mock function for the type MockManifestStorage
225+func (_mock *MockManifestStorage) ListTags(name string) ([]string, error) {
226+ ret := _mock.Called(name)
227+
228+ if len(ret) == 0 {
229+ panic("no return value specified for ListTags")
230+ }
231+
232+ var r0 []string
233+ var r1 error
234+ if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok {
235+ return returnFunc(name)
236+ }
237+ if returnFunc, ok := ret.Get(0).(func(string) []string); ok {
238+ r0 = returnFunc(name)
239+ } else {
240+ if ret.Get(0) != nil {
241+ r0 = ret.Get(0).([]string)
242+ }
243+ }
244+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
245+ r1 = returnFunc(name)
246+ } else {
247+ r1 = ret.Error(1)
248+ }
249+ return r0, r1
250+}
251+
252+// MockManifestStorage_ListTags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTags'
253+type MockManifestStorage_ListTags_Call struct {
254+ *mock.Call
255+}
256+
257+// ListTags is a helper method to define mock.On call
258+// - name string
259+func (_e *MockManifestStorage_Expecter) ListTags(name interface{}) *MockManifestStorage_ListTags_Call {
260+ return &MockManifestStorage_ListTags_Call{Call: _e.mock.On("ListTags", name)}
261+}
262+
263+func (_c *MockManifestStorage_ListTags_Call) Run(run func(name string)) *MockManifestStorage_ListTags_Call {
264+ _c.Call.Run(func(args mock.Arguments) {
265+ var arg0 string
266+ if args[0] != nil {
267+ arg0 = args[0].(string)
268+ }
269+ run(
270+ arg0,
271+ )
272+ })
273+ return _c
274+}
275+
276+func (_c *MockManifestStorage_ListTags_Call) Return(strings []string, err error) *MockManifestStorage_ListTags_Call {
277+ _c.Call.Return(strings, err)
278+ return _c
279+}
280+
281+func (_c *MockManifestStorage_ListTags_Call) RunAndReturn(run func(name string) ([]string, error)) *MockManifestStorage_ListTags_Call {
282+ _c.Call.Return(run)
283+ return _c
284+}
285+
286+// PutManifest provides a mock function for the type MockManifestStorage
287+func (_mock *MockManifestStorage) PutManifest(name string, reference string, contentType string, data []byte) error {
288+ ret := _mock.Called(name, reference, contentType, data)
289+
290+ if len(ret) == 0 {
291+ panic("no return value specified for PutManifest")
292+ }
293+
294+ var r0 error
295+ if returnFunc, ok := ret.Get(0).(func(string, string, string, []byte) error); ok {
296+ r0 = returnFunc(name, reference, contentType, data)
297+ } else {
298+ r0 = ret.Error(0)
299+ }
300+ return r0
301+}
302+
303+// MockManifestStorage_PutManifest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutManifest'
304+type MockManifestStorage_PutManifest_Call struct {
305+ *mock.Call
306+}
307+
308+// PutManifest is a helper method to define mock.On call
309+// - name string
310+// - reference string
311+// - contentType string
312+// - data []byte
313+func (_e *MockManifestStorage_Expecter) PutManifest(name interface{}, reference interface{}, contentType interface{}, data interface{}) *MockManifestStorage_PutManifest_Call {
314+ return &MockManifestStorage_PutManifest_Call{Call: _e.mock.On("PutManifest", name, reference, contentType, data)}
315+}
316+
317+func (_c *MockManifestStorage_PutManifest_Call) Run(run func(name string, reference string, contentType string, data []byte)) *MockManifestStorage_PutManifest_Call {
318+ _c.Call.Run(func(args mock.Arguments) {
319+ var arg0 string
320+ if args[0] != nil {
321+ arg0 = args[0].(string)
322+ }
323+ var arg1 string
324+ if args[1] != nil {
325+ arg1 = args[1].(string)
326+ }
327+ var arg2 string
328+ if args[2] != nil {
329+ arg2 = args[2].(string)
330+ }
331+ var arg3 []byte
332+ if args[3] != nil {
333+ arg3 = args[3].([]byte)
334+ }
335+ run(
336+ arg0,
337+ arg1,
338+ arg2,
339+ arg3,
340+ )
341+ })
342+ return _c
343+}
344+
345+func (_c *MockManifestStorage_PutManifest_Call) Return(err error) *MockManifestStorage_PutManifest_Call {
346+ _c.Call.Return(err)
347+ return _c
348+}
349+
350+func (_c *MockManifestStorage_PutManifest_Call) RunAndReturn(run func(name string, reference string, contentType string, data []byte) error) *MockManifestStorage_PutManifest_Call {
351+ _c.Call.Return(run)
352+ return _c
353+}
A internal/boundaries/out/mocks/mock_secret_provider.go +192 -0
@@ -0,0 +1,192 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockSecretProvider creates a new instance of MockSecretProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockSecretProvider(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockSecretProvider {
19+ mock := &MockSecretProvider{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockSecretProvider is an autogenerated mock type for the SecretProvider type
28+type MockSecretProvider struct {
29+ mock.Mock
30+}
31+
32+type MockSecretProvider_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockSecretProvider) EXPECT() *MockSecretProvider_Expecter {
37+ return &MockSecretProvider_Expecter{mock: &_m.Mock}
38+}
39+
40+// GetSecret provides a mock function for the type MockSecretProvider
41+func (_mock *MockSecretProvider) GetSecret(ctx context.Context, key string) (string, error) {
42+ ret := _mock.Called(ctx, key)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for GetSecret")
46+ }
47+
48+ var r0 string
49+ var r1 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok {
51+ return returnFunc(ctx, key)
52+ }
53+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
54+ r0 = returnFunc(ctx, key)
55+ } else {
56+ r0 = ret.Get(0).(string)
57+ }
58+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
59+ r1 = returnFunc(ctx, key)
60+ } else {
61+ r1 = ret.Error(1)
62+ }
63+ return r0, r1
64+}
65+
66+// MockSecretProvider_GetSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSecret'
67+type MockSecretProvider_GetSecret_Call struct {
68+ *mock.Call
69+}
70+
71+// GetSecret is a helper method to define mock.On call
72+// - ctx context.Context
73+// - key string
74+func (_e *MockSecretProvider_Expecter) GetSecret(ctx interface{}, key interface{}) *MockSecretProvider_GetSecret_Call {
75+ return &MockSecretProvider_GetSecret_Call{Call: _e.mock.On("GetSecret", ctx, key)}
76+}
77+
78+func (_c *MockSecretProvider_GetSecret_Call) Run(run func(ctx context.Context, key string)) *MockSecretProvider_GetSecret_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 context.Context
81+ if args[0] != nil {
82+ arg0 = args[0].(context.Context)
83+ }
84+ var arg1 string
85+ if args[1] != nil {
86+ arg1 = args[1].(string)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ )
92+ })
93+ return _c
94+}
95+
96+func (_c *MockSecretProvider_GetSecret_Call) Return(s string, err error) *MockSecretProvider_GetSecret_Call {
97+ _c.Call.Return(s, err)
98+ return _c
99+}
100+
101+func (_c *MockSecretProvider_GetSecret_Call) RunAndReturn(run func(ctx context.Context, key string) (string, error)) *MockSecretProvider_GetSecret_Call {
102+ _c.Call.Return(run)
103+ return _c
104+}
105+
106+// IsAvailable provides a mock function for the type MockSecretProvider
107+func (_mock *MockSecretProvider) IsAvailable() bool {
108+ ret := _mock.Called()
109+
110+ if len(ret) == 0 {
111+ panic("no return value specified for IsAvailable")
112+ }
113+
114+ var r0 bool
115+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
116+ r0 = returnFunc()
117+ } else {
118+ r0 = ret.Get(0).(bool)
119+ }
120+ return r0
121+}
122+
123+// MockSecretProvider_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable'
124+type MockSecretProvider_IsAvailable_Call struct {
125+ *mock.Call
126+}
127+
128+// IsAvailable is a helper method to define mock.On call
129+func (_e *MockSecretProvider_Expecter) IsAvailable() *MockSecretProvider_IsAvailable_Call {
130+ return &MockSecretProvider_IsAvailable_Call{Call: _e.mock.On("IsAvailable")}
131+}
132+
133+func (_c *MockSecretProvider_IsAvailable_Call) Run(run func()) *MockSecretProvider_IsAvailable_Call {
134+ _c.Call.Run(func(args mock.Arguments) {
135+ run()
136+ })
137+ return _c
138+}
139+
140+func (_c *MockSecretProvider_IsAvailable_Call) Return(b bool) *MockSecretProvider_IsAvailable_Call {
141+ _c.Call.Return(b)
142+ return _c
143+}
144+
145+func (_c *MockSecretProvider_IsAvailable_Call) RunAndReturn(run func() bool) *MockSecretProvider_IsAvailable_Call {
146+ _c.Call.Return(run)
147+ return _c
148+}
149+
150+// Name provides a mock function for the type MockSecretProvider
151+func (_mock *MockSecretProvider) Name() string {
152+ ret := _mock.Called()
153+
154+ if len(ret) == 0 {
155+ panic("no return value specified for Name")
156+ }
157+
158+ var r0 string
159+ if returnFunc, ok := ret.Get(0).(func() string); ok {
160+ r0 = returnFunc()
161+ } else {
162+ r0 = ret.Get(0).(string)
163+ }
164+ return r0
165+}
166+
167+// MockSecretProvider_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name'
168+type MockSecretProvider_Name_Call struct {
169+ *mock.Call
170+}
171+
172+// Name is a helper method to define mock.On call
173+func (_e *MockSecretProvider_Expecter) Name() *MockSecretProvider_Name_Call {
174+ return &MockSecretProvider_Name_Call{Call: _e.mock.On("Name")}
175+}
176+
177+func (_c *MockSecretProvider_Name_Call) Run(run func()) *MockSecretProvider_Name_Call {
178+ _c.Call.Run(func(args mock.Arguments) {
179+ run()
180+ })
181+ return _c
182+}
183+
184+func (_c *MockSecretProvider_Name_Call) Return(s string) *MockSecretProvider_Name_Call {
185+ _c.Call.Return(s)
186+ return _c
187+}
188+
189+func (_c *MockSecretProvider_Name_Call) RunAndReturn(run func() string) *MockSecretProvider_Name_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
A internal/boundaries/out/mocks/mock_token_store.go +418 -0
@@ -0,0 +1,418 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockTokenStore creates a new instance of MockTokenStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockTokenStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockTokenStore {
20+ mock := &MockTokenStore{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockTokenStore is an autogenerated mock type for the TokenStore type
29+type MockTokenStore struct {
30+ mock.Mock
31+}
32+
33+type MockTokenStore_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockTokenStore) EXPECT() *MockTokenStore_Expecter {
38+ return &MockTokenStore_Expecter{mock: &_m.Mock}
39+}
40+
41+// DeleteToken provides a mock function for the type MockTokenStore
42+func (_mock *MockTokenStore) DeleteToken(ctx context.Context, subject string) error {
43+ ret := _mock.Called(ctx, subject)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for DeleteToken")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
51+ r0 = returnFunc(ctx, subject)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockTokenStore_DeleteToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteToken'
59+type MockTokenStore_DeleteToken_Call struct {
60+ *mock.Call
61+}
62+
63+// DeleteToken is a helper method to define mock.On call
64+// - ctx context.Context
65+// - subject string
66+func (_e *MockTokenStore_Expecter) DeleteToken(ctx interface{}, subject interface{}) *MockTokenStore_DeleteToken_Call {
67+ return &MockTokenStore_DeleteToken_Call{Call: _e.mock.On("DeleteToken", ctx, subject)}
68+}
69+
70+func (_c *MockTokenStore_DeleteToken_Call) Run(run func(ctx context.Context, subject string)) *MockTokenStore_DeleteToken_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 string
77+ if args[1] != nil {
78+ arg1 = args[1].(string)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *MockTokenStore_DeleteToken_Call) Return(err error) *MockTokenStore_DeleteToken_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *MockTokenStore_DeleteToken_Call) RunAndReturn(run func(ctx context.Context, subject string) error) *MockTokenStore_DeleteToken_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// GetToken provides a mock function for the type MockTokenStore
99+func (_mock *MockTokenStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
100+ ret := _mock.Called(ctx, subject)
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for GetToken")
104+ }
105+
106+ var r0 string
107+ var r1 *domain.Token
108+ var r2 error
109+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, *domain.Token, error)); ok {
110+ return returnFunc(ctx, subject)
111+ }
112+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
113+ r0 = returnFunc(ctx, subject)
114+ } else {
115+ r0 = ret.Get(0).(string)
116+ }
117+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) *domain.Token); ok {
118+ r1 = returnFunc(ctx, subject)
119+ } else {
120+ if ret.Get(1) != nil {
121+ r1 = ret.Get(1).(*domain.Token)
122+ }
123+ }
124+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
125+ r2 = returnFunc(ctx, subject)
126+ } else {
127+ r2 = ret.Error(2)
128+ }
129+ return r0, r1, r2
130+}
131+
132+// MockTokenStore_GetToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetToken'
133+type MockTokenStore_GetToken_Call struct {
134+ *mock.Call
135+}
136+
137+// GetToken is a helper method to define mock.On call
138+// - ctx context.Context
139+// - subject string
140+func (_e *MockTokenStore_Expecter) GetToken(ctx interface{}, subject interface{}) *MockTokenStore_GetToken_Call {
141+ return &MockTokenStore_GetToken_Call{Call: _e.mock.On("GetToken", ctx, subject)}
142+}
143+
144+func (_c *MockTokenStore_GetToken_Call) Run(run func(ctx context.Context, subject string)) *MockTokenStore_GetToken_Call {
145+ _c.Call.Run(func(args mock.Arguments) {
146+ var arg0 context.Context
147+ if args[0] != nil {
148+ arg0 = args[0].(context.Context)
149+ }
150+ var arg1 string
151+ if args[1] != nil {
152+ arg1 = args[1].(string)
153+ }
154+ run(
155+ arg0,
156+ arg1,
157+ )
158+ })
159+ return _c
160+}
161+
162+func (_c *MockTokenStore_GetToken_Call) Return(s string, token *domain.Token, err error) *MockTokenStore_GetToken_Call {
163+ _c.Call.Return(s, token, err)
164+ return _c
165+}
166+
167+func (_c *MockTokenStore_GetToken_Call) RunAndReturn(run func(ctx context.Context, subject string) (string, *domain.Token, error)) *MockTokenStore_GetToken_Call {
168+ _c.Call.Return(run)
169+ return _c
170+}
171+
172+// IsRevoked provides a mock function for the type MockTokenStore
173+func (_mock *MockTokenStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
174+ ret := _mock.Called(ctx, tokenID)
175+
176+ if len(ret) == 0 {
177+ panic("no return value specified for IsRevoked")
178+ }
179+
180+ var r0 bool
181+ var r1 error
182+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok {
183+ return returnFunc(ctx, tokenID)
184+ }
185+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
186+ r0 = returnFunc(ctx, tokenID)
187+ } else {
188+ r0 = ret.Get(0).(bool)
189+ }
190+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
191+ r1 = returnFunc(ctx, tokenID)
192+ } else {
193+ r1 = ret.Error(1)
194+ }
195+ return r0, r1
196+}
197+
198+// MockTokenStore_IsRevoked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRevoked'
199+type MockTokenStore_IsRevoked_Call struct {
200+ *mock.Call
201+}
202+
203+// IsRevoked is a helper method to define mock.On call
204+// - ctx context.Context
205+// - tokenID string
206+func (_e *MockTokenStore_Expecter) IsRevoked(ctx interface{}, tokenID interface{}) *MockTokenStore_IsRevoked_Call {
207+ return &MockTokenStore_IsRevoked_Call{Call: _e.mock.On("IsRevoked", ctx, tokenID)}
208+}
209+
210+func (_c *MockTokenStore_IsRevoked_Call) Run(run func(ctx context.Context, tokenID string)) *MockTokenStore_IsRevoked_Call {
211+ _c.Call.Run(func(args mock.Arguments) {
212+ var arg0 context.Context
213+ if args[0] != nil {
214+ arg0 = args[0].(context.Context)
215+ }
216+ var arg1 string
217+ if args[1] != nil {
218+ arg1 = args[1].(string)
219+ }
220+ run(
221+ arg0,
222+ arg1,
223+ )
224+ })
225+ return _c
226+}
227+
228+func (_c *MockTokenStore_IsRevoked_Call) Return(b bool, err error) *MockTokenStore_IsRevoked_Call {
229+ _c.Call.Return(b, err)
230+ return _c
231+}
232+
233+func (_c *MockTokenStore_IsRevoked_Call) RunAndReturn(run func(ctx context.Context, tokenID string) (bool, error)) *MockTokenStore_IsRevoked_Call {
234+ _c.Call.Return(run)
235+ return _c
236+}
237+
238+// ListTokens provides a mock function for the type MockTokenStore
239+func (_mock *MockTokenStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
240+ ret := _mock.Called(ctx)
241+
242+ if len(ret) == 0 {
243+ panic("no return value specified for ListTokens")
244+ }
245+
246+ var r0 []domain.Token
247+ var r1 error
248+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Token, error)); ok {
249+ return returnFunc(ctx)
250+ }
251+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Token); ok {
252+ r0 = returnFunc(ctx)
253+ } else {
254+ if ret.Get(0) != nil {
255+ r0 = ret.Get(0).([]domain.Token)
256+ }
257+ }
258+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
259+ r1 = returnFunc(ctx)
260+ } else {
261+ r1 = ret.Error(1)
262+ }
263+ return r0, r1
264+}
265+
266+// MockTokenStore_ListTokens_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTokens'
267+type MockTokenStore_ListTokens_Call struct {
268+ *mock.Call
269+}
270+
271+// ListTokens is a helper method to define mock.On call
272+// - ctx context.Context
273+func (_e *MockTokenStore_Expecter) ListTokens(ctx interface{}) *MockTokenStore_ListTokens_Call {
274+ return &MockTokenStore_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}
275+}
276+
277+func (_c *MockTokenStore_ListTokens_Call) Run(run func(ctx context.Context)) *MockTokenStore_ListTokens_Call {
278+ _c.Call.Run(func(args mock.Arguments) {
279+ var arg0 context.Context
280+ if args[0] != nil {
281+ arg0 = args[0].(context.Context)
282+ }
283+ run(
284+ arg0,
285+ )
286+ })
287+ return _c
288+}
289+
290+func (_c *MockTokenStore_ListTokens_Call) Return(tokens []domain.Token, err error) *MockTokenStore_ListTokens_Call {
291+ _c.Call.Return(tokens, err)
292+ return _c
293+}
294+
295+func (_c *MockTokenStore_ListTokens_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Token, error)) *MockTokenStore_ListTokens_Call {
296+ _c.Call.Return(run)
297+ return _c
298+}
299+
300+// Revoke provides a mock function for the type MockTokenStore
301+func (_mock *MockTokenStore) Revoke(ctx context.Context, tokenID string) error {
302+ ret := _mock.Called(ctx, tokenID)
303+
304+ if len(ret) == 0 {
305+ panic("no return value specified for Revoke")
306+ }
307+
308+ var r0 error
309+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
310+ r0 = returnFunc(ctx, tokenID)
311+ } else {
312+ r0 = ret.Error(0)
313+ }
314+ return r0
315+}
316+
317+// MockTokenStore_Revoke_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revoke'
318+type MockTokenStore_Revoke_Call struct {
319+ *mock.Call
320+}
321+
322+// Revoke is a helper method to define mock.On call
323+// - ctx context.Context
324+// - tokenID string
325+func (_e *MockTokenStore_Expecter) Revoke(ctx interface{}, tokenID interface{}) *MockTokenStore_Revoke_Call {
326+ return &MockTokenStore_Revoke_Call{Call: _e.mock.On("Revoke", ctx, tokenID)}
327+}
328+
329+func (_c *MockTokenStore_Revoke_Call) Run(run func(ctx context.Context, tokenID string)) *MockTokenStore_Revoke_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ var arg0 context.Context
332+ if args[0] != nil {
333+ arg0 = args[0].(context.Context)
334+ }
335+ var arg1 string
336+ if args[1] != nil {
337+ arg1 = args[1].(string)
338+ }
339+ run(
340+ arg0,
341+ arg1,
342+ )
343+ })
344+ return _c
345+}
346+
347+func (_c *MockTokenStore_Revoke_Call) Return(err error) *MockTokenStore_Revoke_Call {
348+ _c.Call.Return(err)
349+ return _c
350+}
351+
352+func (_c *MockTokenStore_Revoke_Call) RunAndReturn(run func(ctx context.Context, tokenID string) error) *MockTokenStore_Revoke_Call {
353+ _c.Call.Return(run)
354+ return _c
355+}
356+
357+// SaveToken provides a mock function for the type MockTokenStore
358+func (_mock *MockTokenStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
359+ ret := _mock.Called(ctx, token, jwt)
360+
361+ if len(ret) == 0 {
362+ panic("no return value specified for SaveToken")
363+ }
364+
365+ var r0 error
366+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Token, string) error); ok {
367+ r0 = returnFunc(ctx, token, jwt)
368+ } else {
369+ r0 = ret.Error(0)
370+ }
371+ return r0
372+}
373+
374+// MockTokenStore_SaveToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveToken'
375+type MockTokenStore_SaveToken_Call struct {
376+ *mock.Call
377+}
378+
379+// SaveToken is a helper method to define mock.On call
380+// - ctx context.Context
381+// - token *domain.Token
382+// - jwt string
383+func (_e *MockTokenStore_Expecter) SaveToken(ctx interface{}, token interface{}, jwt interface{}) *MockTokenStore_SaveToken_Call {
384+ return &MockTokenStore_SaveToken_Call{Call: _e.mock.On("SaveToken", ctx, token, jwt)}
385+}
386+
387+func (_c *MockTokenStore_SaveToken_Call) Run(run func(ctx context.Context, token *domain.Token, jwt string)) *MockTokenStore_SaveToken_Call {
388+ _c.Call.Run(func(args mock.Arguments) {
389+ var arg0 context.Context
390+ if args[0] != nil {
391+ arg0 = args[0].(context.Context)
392+ }
393+ var arg1 *domain.Token
394+ if args[1] != nil {
395+ arg1 = args[1].(*domain.Token)
396+ }
397+ var arg2 string
398+ if args[2] != nil {
399+ arg2 = args[2].(string)
400+ }
401+ run(
402+ arg0,
403+ arg1,
404+ arg2,
405+ )
406+ })
407+ return _c
408+}
409+
410+func (_c *MockTokenStore_SaveToken_Call) Return(err error) *MockTokenStore_SaveToken_Call {
411+ _c.Call.Return(err)
412+ return _c
413+}
414+
415+func (_c *MockTokenStore_SaveToken_Call) RunAndReturn(run func(ctx context.Context, token *domain.Token, jwt string) error) *MockTokenStore_SaveToken_Call {
416+ _c.Call.Return(run)
417+ return _c
418+}
A internal/boundaries/out/runtime.go +69 -0
@@ -0,0 +1,69 @@
1+// Package out defines output ports (interfaces) for infrastructure.
2+// These interfaces define the contract between use cases and driven adapters
3+// (Docker, filesystem, etc.).
4+package out
5+
6+import (
7+ "context"
8+ "io"
9+
10+ "gordon/internal/domain"
11+)
12+
13+// ContainerRuntime defines the contract for container runtime operations.
14+// This interface abstracts the underlying container runtime (Docker, Podman, etc.).
15+type ContainerRuntime interface {
16+ // Container lifecycle
17+ CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error)
18+ StartContainer(ctx context.Context, containerID string) error
19+ StopContainer(ctx context.Context, containerID string) error
20+ RestartContainer(ctx context.Context, containerID string) error
21+ RemoveContainer(ctx context.Context, containerID string, force bool) error
22+ RenameContainer(ctx context.Context, containerID, newName string) error
23+
24+ // Container inspection
25+ ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
26+ InspectContainer(ctx context.Context, containerID string) (*domain.Container, error)
27+ GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error)
28+
29+ // Image operations
30+ PullImage(ctx context.Context, image string) error
31+ PullImageWithAuth(ctx context.Context, image, username, password string) error
32+ TagImage(ctx context.Context, sourceRef, targetRef string) error
33+ UntagImage(ctx context.Context, imageRef string) error
34+ RemoveImage(ctx context.Context, image string, force bool) error
35+ ListImages(ctx context.Context) ([]string, error)
36+
37+ // Runtime information
38+ Ping(ctx context.Context) error
39+ Version(ctx context.Context) (string, error)
40+
41+ // Health and status
42+ IsContainerRunning(ctx context.Context, containerID string) (bool, error)
43+ GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error)
44+
45+ // Image and port inspection
46+ GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error)
47+ GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error)
48+ GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error)
49+
50+ // Volume management
51+ InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error)
52+ VolumeExists(ctx context.Context, volumeName string) (bool, error)
53+ CreateVolume(ctx context.Context, volumeName string) error
54+ RemoveVolume(ctx context.Context, volumeName string, force bool) error
55+
56+ // Environment inspection
57+ InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
58+
59+ // Label inspection
60+ GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error)
61+
62+ // Network management
63+ CreateNetwork(ctx context.Context, name string, options map[string]string) error
64+ RemoveNetwork(ctx context.Context, name string) error
65+ ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
66+ NetworkExists(ctx context.Context, name string) (bool, error)
67+ ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error
68+ DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
69+}
A internal/boundaries/out/secrets.go +15 -0
@@ -0,0 +1,15 @@
1+package out
2+
3+import "context"
4+
5+// SecretProvider defines the contract for retrieving secrets.
6+type SecretProvider interface {
7+ // Name returns the provider name (e.g., "pass", "sops").
8+ Name() string
9+
10+ // GetSecret retrieves a secret by key.
11+ GetSecret(ctx context.Context, key string) (string, error)
12+
13+ // IsAvailable checks if this provider is available in the current environment.
14+ IsAvailable() bool
15+}
A internal/boundaries/out/storage.go +57 -0
@@ -0,0 +1,57 @@
1+package out
2+
3+import (
4+ "io"
5+)
6+
7+// BlobStorage defines the contract for blob storage operations.
8+type BlobStorage interface {
9+ // GetBlob retrieves a blob by digest.
10+ GetBlob(digest string) (io.ReadCloser, error)
11+
12+ // GetBlobPath returns the filesystem path to a blob.
13+ GetBlobPath(digest string) (string, error)
14+
15+ // PutBlob stores a blob with the given digest.
16+ PutBlob(digest string, data io.Reader, size int64) error
17+
18+ // DeleteBlob removes a blob by digest.
19+ DeleteBlob(digest string) error
20+
21+ // BlobExists checks if a blob exists.
22+ BlobExists(digest string) bool
23+
24+ // StartBlobUpload starts a new blob upload and returns the upload UUID.
25+ StartBlobUpload(name string) (string, error)
26+
27+ // AppendBlobChunk appends data to an in-progress upload.
28+ AppendBlobChunk(name, uuid string, chunk []byte) (int64, error)
29+
30+ // GetBlobUpload returns a writer for the upload.
31+ GetBlobUpload(uuid string) (io.WriteCloser, error)
32+
33+ // FinishBlobUpload completes an upload and moves it to blob storage.
34+ FinishBlobUpload(uuid, digest string) error
35+
36+ // CancelBlobUpload cancels an in-progress upload.
37+ CancelBlobUpload(uuid string) error
38+}
39+
40+// ManifestStorage defines the contract for manifest storage operations.
41+type ManifestStorage interface {
42+ // GetManifest retrieves a manifest by name and reference.
43+ // Returns the manifest data and content type.
44+ GetManifest(name, reference string) ([]byte, string, error)
45+
46+ // PutManifest stores a manifest.
47+ PutManifest(name, reference, contentType string, data []byte) error
48+
49+ // DeleteManifest removes a manifest.
50+ DeleteManifest(name, reference string) error
51+
52+ // ListTags returns all tags for a repository.
53+ ListTags(name string) ([]string, error)
54+
55+ // ListRepositories returns all repository names.
56+ ListRepositories() ([]string, error)
57+}
A internal/boundaries/out/token_store.go +29 -0
@@ -0,0 +1,29 @@
1+package out
2+
3+import (
4+ "context"
5+
6+ "gordon/internal/domain"
7+)
8+
9+// TokenStore defines the contract for storing and managing authentication tokens.
10+// Implementations may use different backends (pass, sops, unsafe file storage).
11+type TokenStore interface {
12+ // SaveToken stores a token JWT and metadata.
13+ SaveToken(ctx context.Context, token *domain.Token, jwt string) error
14+
15+ // GetToken retrieves token JWT by subject.
16+ GetToken(ctx context.Context, subject string) (string, *domain.Token, error)
17+
18+ // ListTokens returns all stored tokens.
19+ ListTokens(ctx context.Context) ([]domain.Token, error)
20+
21+ // Revoke adds token ID to revocation list.
22+ Revoke(ctx context.Context, tokenID string) error
23+
24+ // IsRevoked checks if token ID is in revocation list.
25+ IsRevoked(ctx context.Context, tokenID string) (bool, error)
26+
27+ // DeleteToken removes token from store.
28+ DeleteToken(ctx context.Context, subject string) error
29+}
D internal/config/config.go +0 -529
@@ -1,529 +0,0 @@
1-package config
2-
3-import (
4- "fmt"
5- "os"
6- "path/filepath"
7- "regexp"
8- "strings"
9-
10- "github.com/rs/zerolog/log"
11- "github.com/spf13/viper"
12-)
13-
14-type Config struct {
15- Server ServerConfig `mapstructure:"server"`
16- RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17- Routes map[string]string `mapstructure:"routes"`
18- AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19- Env EnvConfig `mapstructure:"env"`
20- Logging LoggingConfig `mapstructure:"logging"`
21- Volumes VolumeConfig `mapstructure:"volumes"`
22- Attachments map[string][]string `mapstructure:"attachments"`
23- NetworkGroups map[string][]string `mapstructure:"network_groups"`
24- NetworkIsolation NetworkIsolationConfig `mapstructure:"network_isolation"`
25-}
26-
27-type ServerConfig struct {
28- Port int `mapstructure:"port"`
29- RegistryPort int `mapstructure:"registry_port"`
30- RegistryDomain string `mapstructure:"registry_domain"`
31- Runtime string `mapstructure:"runtime"`
32- SocketPath string `mapstructure:"socket_path"`
33- SSLEmail string `mapstructure:"ssl_email"`
34- DataDir string `mapstructure:"data_dir"`
35-}
36-
37-type RegistryAuthConfig struct {
38- Enabled bool `mapstructure:"enabled"`
39- Username string `mapstructure:"username"`
40- Password string `mapstructure:"password"`
41-}
42-
43-type AutoRouteConfig struct {
44- Enabled bool `mapstructure:"enabled"`
45-}
46-
47-type EnvConfig struct {
48- Dir string `mapstructure:"dir"`
49- Providers []string `mapstructure:"providers"`
50-}
51-
52-type LoggingConfig struct {
53- Enabled bool `mapstructure:"enabled"`
54- Level string `mapstructure:"level"`
55- Dir string `mapstructure:"dir"`
56- MainLogFile string `mapstructure:"main_log_file"`
57- ProxyLogFile string `mapstructure:"proxy_log_file"`
58- ContainerLogDir string `mapstructure:"container_log_dir"`
59- MaxSize int `mapstructure:"max_size"`
60- MaxBackups int `mapstructure:"max_backups"`
61- MaxAge int `mapstructure:"max_age"`
62- Compress bool `mapstructure:"compress"`
63-}
64-
65-type VolumeConfig struct {
66- AutoCreate bool `mapstructure:"auto_create"`
67- Prefix string `mapstructure:"prefix"`
68- Preserve bool `mapstructure:"preserve"`
69-}
70-
71-type NetworkIsolationConfig struct {
72- Enabled bool `mapstructure:"enabled"`
73- NetworkPrefix string `mapstructure:"network_prefix"`
74- DNSSuffix string `mapstructure:"dns_suffix"`
75-}
76-
77-type SecretProvider struct {
78- Type string `mapstructure:"type"`
79- Config map[string]string `mapstructure:"config"`
80-}
81-
82-type Route struct {
83- Domain string
84- Image string
85- HTTPS bool
86-}
87-
88-func Load() (*Config, error) {
89- var cfg Config
90-
91- // Set defaults
92- viper.SetDefault("server.port", 8080)
93- viper.SetDefault("server.registry_port", 5000)
94- viper.SetDefault("server.runtime", "auto")
95- viper.SetDefault("server.socket_path", "")
96-
97- // Set data_dir default based on environment
98- defaultDataDir := getDefaultDataDir()
99- viper.SetDefault("server.data_dir", defaultDataDir)
100- viper.SetDefault("registry_auth.enabled", true)
101- viper.SetDefault("auto_route.enabled", false)
102- viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))
103- viper.SetDefault("env.providers", []string{"pass", "sops"})
104-
105- // Logging defaults
106- viper.SetDefault("logging.enabled", true)
107- viper.SetDefault("logging.level", "info")
108- viper.SetDefault("logging.dir", filepath.Join(defaultDataDir, "logs"))
109- viper.SetDefault("logging.main_log_file", "gordon.log")
110- viper.SetDefault("logging.proxy_log_file", "proxy.log")
111- viper.SetDefault("logging.container_log_dir", "containers")
112- viper.SetDefault("logging.max_size", 100) // 100MB
113- viper.SetDefault("logging.max_backups", 3) // Keep 3 old files
114- viper.SetDefault("logging.max_age", 28) // 28 days
115- viper.SetDefault("logging.compress", true)
116-
117- // Volume defaults
118- viper.SetDefault("volumes.auto_create", true)
119- viper.SetDefault("volumes.prefix", "gordon")
120- viper.SetDefault("volumes.preserve", true)
121-
122- // Network isolation defaults
123- viper.SetDefault("network_isolation.enabled", true)
124- viper.SetDefault("network_isolation.network_prefix", "gordon")
125- viper.SetDefault("network_isolation.dns_suffix", ".internal")
126-
127- // Handle the routes manually since Viper struggles with domain names
128- cfg.Routes = make(map[string]string)
129- cfg.Attachments = make(map[string][]string)
130- cfg.NetworkGroups = make(map[string][]string)
131-
132- // Get server config first
133- if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
134- return nil, fmt.Errorf("unable to decode server config: %v", err)
135- }
136-
137- // If data_dir is empty after loading config, use the default
138- if cfg.Server.DataDir == "" {
139- cfg.Server.DataDir = defaultDataDir
140- log.Debug().Str("data_dir", cfg.Server.DataDir).Msg("Config had empty data_dir, using default")
141- }
142-
143- // If registry_port is 0 after loading config, use the default
144- if cfg.Server.RegistryPort == 0 {
145- cfg.Server.RegistryPort = 5000
146- log.Debug().Int("registry_port", cfg.Server.RegistryPort).Msg("Config had empty registry_port, using default")
147- }
148-
149- // Get registry auth config
150- if err := viper.UnmarshalKey("registry_auth", &cfg.RegistryAuth); err != nil {
151- return nil, fmt.Errorf("unable to decode registry auth config: %v", err)
152- }
153-
154- // Get auto route config
155- if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {
156- return nil, fmt.Errorf("unable to decode auto route config: %v", err)
157- }
158-
159- // Get env config
160- if err := viper.UnmarshalKey("env", &cfg.Env); err != nil {
161- return nil, fmt.Errorf("unable to decode env config: %v", err)
162- }
163-
164- // If env.dir is empty after loading config, use the default
165- if cfg.Env.Dir == "" {
166- cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")
167- log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")
168- }
169-
170- // Get logging config
171- if err := viper.UnmarshalKey("logging", &cfg.Logging); err != nil {
172- return nil, fmt.Errorf("unable to decode logging config: %v", err)
173- }
174-
175- // If logging.dir is empty after loading config, use the default
176- if cfg.Logging.Dir == "" {
177- cfg.Logging.Dir = filepath.Join(cfg.Server.DataDir, "logs")
178- log.Debug().Str("logging_dir", cfg.Logging.Dir).Msg("Config had empty logging.dir, using default")
179- }
180-
181- // Get volumes config
182- if err := viper.UnmarshalKey("volumes", &cfg.Volumes); err != nil {
183- return nil, fmt.Errorf("unable to decode volumes config: %v", err)
184- }
185-
186- // Get network isolation config
187- if err := viper.UnmarshalKey("network_isolation", &cfg.NetworkIsolation); err != nil {
188- return nil, fmt.Errorf("unable to decode network isolation config: %v", err)
189- }
190-
191- // Get routes manually from the raw config
192- routesRaw := viper.Get("routes")
193- if routesRaw != nil {
194- if routes, ok := routesRaw.(map[string]interface{}); ok {
195- for domain, image := range routes {
196- if imageStr, ok := image.(string); ok {
197- cfg.Routes[domain] = imageStr
198- }
199- }
200- }
201- }
202-
203- // Get attachments manually from the raw config
204- attachmentsRaw := viper.Get("attachments")
205- if attachmentsRaw != nil {
206- if attachments, ok := attachmentsRaw.(map[string]interface{}); ok {
207- for identifier, services := range attachments {
208- if servicesList, ok := services.([]interface{}); ok {
209- var serviceStrings []string
210- for _, service := range servicesList {
211- if serviceStr, ok := service.(string); ok {
212- serviceStrings = append(serviceStrings, serviceStr)
213- }
214- }
215- cfg.Attachments[identifier] = serviceStrings
216- }
217- }
218- }
219- }
220-
221- // Get network_groups manually from the raw config
222- networkGroupsRaw := viper.Get("network_groups")
223- if networkGroupsRaw != nil {
224- if networkGroups, ok := networkGroupsRaw.(map[string]interface{}); ok {
225- for groupName, domains := range networkGroups {
226- if domainsList, ok := domains.([]interface{}); ok {
227- var domainStrings []string
228- for _, domain := range domainsList {
229- if domainStr, ok := domain.(string); ok {
230- domainStrings = append(domainStrings, domainStr)
231- }
232- }
233- cfg.NetworkGroups[groupName] = domainStrings
234- }
235- }
236- }
237- }
238-
239- // Note: ssl_email is optional and only needed for future Let's Encrypt integration
240- // Currently using Cloudflare for HTTPS
241-
242- validRuntimes := []string{"auto", "docker", "podman", "podman-rootless"}
243- isValid := false
244- for _, valid := range validRuntimes {
245- if cfg.Server.Runtime == valid {
246- isValid = true
247- break
248- }
249- }
250- if !isValid {
251- return nil, fmt.Errorf("server.runtime must be one of: %s", strings.Join(validRuntimes, ", "))
252- }
253-
254- // Validate registry auth config
255- if cfg.RegistryAuth.Enabled {
256- if cfg.RegistryAuth.Username == "" || cfg.RegistryAuth.Password == "" {
257- return nil, fmt.Errorf("Registry auth enabled but username/password not provided")
258- }
259- }
260-
261- // Validate registry domain if provided
262- if cfg.Server.RegistryDomain != "" {
263- // Basic domain validation - should not contain protocol or paths
264- if strings.Contains(cfg.Server.RegistryDomain, "://") || strings.Contains(cfg.Server.RegistryDomain, "/") {
265- return nil, fmt.Errorf("registry_domain should be just the domain name (e.g. 'registry.example.com')")
266- }
267- }
268-
269- // Validate env config
270- if err := validateEnvConfig(&cfg.Env); err != nil {
271- return nil, fmt.Errorf("invalid env config: %w", err)
272- }
273-
274- return &cfg, nil
275-}
276-
277-func (c *Config) GetRoutes() []Route {
278- var routes []Route
279-
280- for domain, image := range c.Routes {
281- route := Route{
282- Image: image,
283- HTTPS: true, // Default to HTTPS
284- }
285-
286- // Check if domain has http:// prefix
287- if strings.HasPrefix(domain, "http://") {
288- route.Domain = strings.TrimPrefix(domain, "http://")
289- route.HTTPS = false
290- } else {
291- route.Domain = domain
292- }
293-
294- routes = append(routes, route)
295- }
296-
297- return routes
298-}
299-
300-// getDefaultDataDir returns a platform-appropriate default data directory
301-func getDefaultDataDir() string {
302- uid := os.Getuid()
303- log.Debug().Int("uid", uid).Msg("Detecting default data directory")
304-
305- // Check if we're running in a rootless environment
306- if uid != 0 {
307- // For rootless environments, use user's home directory or current directory
308- if homeDir, err := os.UserHomeDir(); err == nil {
309- dataDir := filepath.Join(homeDir, ".local/share/gordon")
310- log.Debug().Str("data_dir", dataDir).Msg("Using user data directory for rootless environment")
311- return dataDir
312- }
313- log.Debug().Msg("Failed to get user home directory, falling back to ./data")
314- } else {
315- log.Debug().Msg("Running as root, using ./data")
316- }
317-
318- // For root or when home directory is not available, use relative path
319- return "./data"
320-}
321-
322-// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest"
323-func ExtractDomainFromImageName(imageName string) (string, bool) {
324- // Split by colon to separate image name from tag
325- parts := strings.Split(imageName, ":")
326- imageNamePart := parts[0]
327-
328- // Domain pattern: should contain at least one dot and valid domain characters
329- domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
330-
331- if domainPattern.MatchString(imageNamePart) {
332- return imageNamePart, true
333- }
334-
335- return "", false
336-}
337-
338-// AddRoute adds a new route to the configuration and saves it
339-func (c *Config) AddRoute(domain, image string) error {
340- if c.Routes == nil {
341- c.Routes = make(map[string]string)
342- }
343-
344- c.Routes[domain] = image
345-
346- // Read the current config file to preserve formatting
347- configFile := viper.ConfigFileUsed()
348- if configFile == "" {
349- return fmt.Errorf("no config file path available")
350- }
351-
352- // Read existing content
353- content, err := os.ReadFile(configFile)
354- if err != nil {
355- return fmt.Errorf("failed to read config file: %w", err)
356- }
357-
358- // Parse and update only the routes section while preserving other formatting
359- lines := strings.Split(string(content), "\n")
360- var newLines []string
361- inRoutesSection := false
362- routeAdded := false
363-
364- for _, line := range lines {
365- trimmed := strings.TrimSpace(line)
366-
367- // Check if we're entering or leaving the routes section
368- if trimmed == "[routes]" {
369- inRoutesSection = true
370- newLines = append(newLines, line)
371- continue
372- }
373-
374- // Check if we're entering a new section
375- if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
376- // If we were in routes section and haven't added the route yet, add it
377- if inRoutesSection && !routeAdded {
378- newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
379- routeAdded = true
380- }
381- inRoutesSection = false
382- }
383-
384- // If we're in routes section, check if this route already exists (check both quote styles)
385- if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
386- // Update existing route using double quotes for consistency
387- newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
388- routeAdded = true
389- } else {
390- newLines = append(newLines, line)
391- }
392- }
393-
394- // If we were in routes section at the end and haven't added the route yet, add it
395- if inRoutesSection && !routeAdded {
396- newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
397- }
398-
399- // Write the updated content back
400- newContent := strings.Join(newLines, "\n")
401- if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
402- return fmt.Errorf("failed to write config file: %w", err)
403- }
404-
405- log.Info().
406- Str("domain", domain).
407- Str("image", image).
408- Msg("Auto-added route to configuration")
409-
410- // Create env file for the new route (if env loader is available)
411- // This will be called from the auto-route handler which has access to the env loader
412-
413- return nil
414-}
415-
416-// UpdateRoute updates an existing route configuration
417-func (c *Config) UpdateRoute(domain, image string) error {
418- if c.Routes == nil {
419- c.Routes = make(map[string]string)
420- }
421-
422- // Check if route exists
423- if _, exists := c.Routes[domain]; !exists {
424- return fmt.Errorf("route %s does not exist", domain)
425- }
426-
427- c.Routes[domain] = image
428-
429- // Read the current config file to preserve formatting
430- configFile := viper.ConfigFileUsed()
431- if configFile == "" {
432- return fmt.Errorf("no config file path available")
433- }
434-
435- // Read existing content
436- content, err := os.ReadFile(configFile)
437- if err != nil {
438- return fmt.Errorf("failed to read config file: %w", err)
439- }
440-
441- // Parse and update only the routes section while preserving other formatting
442- lines := strings.Split(string(content), "\n")
443- var newLines []string
444- inRoutesSection := false
445- routeUpdated := false
446-
447- for _, line := range lines {
448- trimmed := strings.TrimSpace(line)
449-
450- // Check if we're entering or leaving the routes section
451- if trimmed == "[routes]" {
452- inRoutesSection = true
453- newLines = append(newLines, line)
454- continue
455- }
456-
457- // Check if we're entering a new section
458- if strings.HasPrefix(trimmed, "[") && trimmed != "[routes]" {
459- inRoutesSection = false
460- }
461-
462- // If we're in routes section, check if this route matches (check both quote styles)
463- if inRoutesSection && (strings.Contains(trimmed, fmt.Sprintf("'%s'", domain)) || strings.Contains(trimmed, fmt.Sprintf("\"%s\"", domain))) {
464- // Update existing route using double quotes for consistency
465- newLines = append(newLines, fmt.Sprintf("\"%s\" = \"%s\"", domain, image))
466- routeUpdated = true
467- } else {
468- newLines = append(newLines, line)
469- }
470- }
471-
472- if !routeUpdated {
473- return fmt.Errorf("route %s not found in config file", domain)
474- }
475-
476- // Write the updated content back
477- newContent := strings.Join(newLines, "\n")
478- if err := os.WriteFile(configFile, []byte(newContent), 0644); err != nil {
479- return fmt.Errorf("failed to write config file: %w", err)
480- }
481-
482- log.Info().
483- Str("domain", domain).
484- Str("image", image).
485- Msg("Updated route configuration")
486-
487- return nil
488-}
489-
490-// validateEnvConfig validates the environment configuration
491-func validateEnvConfig(envCfg *EnvConfig) error {
492- // Validate env directory path
493- if envCfg.Dir != "" {
494- if filepath.IsAbs(envCfg.Dir) {
495- // For absolute paths, check if the directory is accessible
496- if info, err := os.Stat(envCfg.Dir); err != nil {
497- if !os.IsNotExist(err) {
498- return fmt.Errorf("env.dir path is not accessible: %w", err)
499- }
500- // Directory doesn't exist - that's okay, it will be created
501- } else if !info.IsDir() {
502- return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir)
503- }
504- }
505- // For relative paths, we can't easily validate without changing working directory
506- }
507-
508- // Validate secret providers configuration
509- validProviders := []string{"pass", "sops"}
510- for _, providerName := range envCfg.Providers {
511- if providerName == "" {
512- return fmt.Errorf("empty provider name in env.providers")
513- }
514-
515- isValid := false
516- for _, validType := range validProviders {
517- if providerName == validType {
518- isValid = true
519- break
520- }
521- }
522- if !isValid {
523- return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
524- providerName, strings.Join(validProviders, ", "))
525- }
526- }
527-
528- return nil
529-}
D internal/config/config_test.go +0 -650
@@ -1,650 +0,0 @@
1-package config
2-
3-import (
4- "os"
5- "path/filepath"
6- "testing"
7-
8- "github.com/spf13/viper"
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11-
12- "gordon/internal/testutils"
13-)
14-
15-func TestConfig_Load_ValidBasicConfig(t *testing.T) {
16- // Create temporary config file
17- configContent := testutils.LoadFixtureConfig(t, "basic.toml")
18-
19- // Create a temporary directory for the config file
20- tempDir := t.TempDir()
21- configFile := filepath.Join(tempDir, "gordon.toml")
22-
23- // Write config to temporary file
24- err := os.WriteFile(configFile, []byte(configContent), 0644)
25- require.NoError(t, err)
26-
27- // Set viper to use this config file
28- viper.Reset()
29- viper.SetConfigFile(configFile)
30- err = viper.ReadInConfig()
31- require.NoError(t, err)
32-
33- // Load config
34- cfg, err := Load()
35- require.NoError(t, err)
36- require.NotNil(t, cfg)
37-
38- // Verify server config
39- assert.Equal(t, 8080, cfg.Server.Port)
40- assert.Equal(t, 5000, cfg.Server.RegistryPort)
41- assert.Equal(t, "registry.example.com", cfg.Server.RegistryDomain)
42- assert.Equal(t, "docker", cfg.Server.Runtime)
43-
44- // Verify registry auth config
45- assert.True(t, cfg.RegistryAuth.Enabled)
46- assert.Equal(t, "admin", cfg.RegistryAuth.Username)
47- assert.Equal(t, "password123", cfg.RegistryAuth.Password)
48-
49- // Verify routes
50- assert.Len(t, cfg.Routes, 2)
51- assert.Equal(t, "nginx:latest", cfg.Routes["app.example.com"])
52- assert.Equal(t, "myapi:v1", cfg.Routes["api.example.com"])
53-
54- // Verify volumes config
55- assert.True(t, cfg.Volumes.AutoCreate)
56- assert.Equal(t, "gordon", cfg.Volumes.Prefix)
57- assert.True(t, cfg.Volumes.Preserve)
58-
59- // Verify env config
60- assert.Equal(t, "/tmp/env", cfg.Env.Dir)
61- assert.Contains(t, cfg.Env.Providers, "pass")
62- assert.Contains(t, cfg.Env.Providers, "sops")
63-
64- // Verify logging config
65- assert.True(t, cfg.Logging.Enabled)
66- assert.Equal(t, "info", cfg.Logging.Level)
67-}
68-
69-func TestConfig_Load_MinimalConfig(t *testing.T) {
70- configContent := testutils.LoadFixtureConfig(t, "minimal.toml")
71-
72- tempDir := t.TempDir()
73- configFile := filepath.Join(tempDir, "gordon.toml")
74-
75- err := os.WriteFile(configFile, []byte(configContent), 0644)
76- require.NoError(t, err)
77-
78- viper.Reset()
79- viper.SetConfigFile(configFile)
80- err = viper.ReadInConfig()
81- require.NoError(t, err)
82-
83- cfg, err := Load()
84- require.NoError(t, err)
85- require.NotNil(t, cfg)
86-
87- // Should have defaults applied where not specified, but explicit values where set
88- // Note: viper defaults may not apply if config structure is partially loaded
89- assert.Equal(t, "registry.example.com", cfg.Server.RegistryDomain)
90- assert.Equal(t, "docker", cfg.Server.Runtime) // explicitly set in minimal config
91- assert.False(t, cfg.RegistryAuth.Enabled) // explicitly set in minimal config
92- assert.True(t, cfg.Volumes.AutoCreate) // default
93- assert.Equal(t, "gordon", cfg.Volumes.Prefix) // default
94-}
95-
96-func TestConfig_Load_InvalidTOMLSyntax(t *testing.T) {
97- configContent := testutils.LoadFixtureConfig(t, "invalid.toml")
98-
99- tempDir := t.TempDir()
100- configFile := filepath.Join(tempDir, "gordon.toml")
101-
102- err := os.WriteFile(configFile, []byte(configContent), 0644)
103- require.NoError(t, err)
104-
105- viper.Reset()
106- viper.SetConfigFile(configFile)
107- err = viper.ReadInConfig()
108- require.Error(t, err)
109- assert.Contains(t, err.Error(), "toml")
110-}
111-
112-func TestConfig_Load_InvalidRuntime(t *testing.T) {
113- configContent := `[server]
114-registry_domain = "registry.example.com"
115-runtime = "invalid-runtime"`
116-
117- tempDir := t.TempDir()
118- configFile := filepath.Join(tempDir, "gordon.toml")
119-
120- err := os.WriteFile(configFile, []byte(configContent), 0644)
121- require.NoError(t, err)
122-
123- viper.Reset()
124- viper.SetConfigFile(configFile)
125- err = viper.ReadInConfig()
126- require.NoError(t, err)
127-
128- _, err = Load()
129- require.Error(t, err)
130- assert.Contains(t, err.Error(), "server.runtime must be one of")
131-}
132-
133-func TestConfig_Load_InvalidRegistryDomain(t *testing.T) {
134- testCases := []struct {
135- name string
136- domain string
137- }{
138- {"with protocol", "https://registry.example.com"},
139- {"with path", "registry.example.com/path"},
140- {"with protocol and path", "https://registry.example.com/path"},
141- }
142-
143- for _, tc := range testCases {
144- t.Run(tc.name, func(t *testing.T) {
145- configContent := `[server]
146-registry_domain = "` + tc.domain + `"
147-runtime = "docker"
148-
149-[registry_auth]
150-enabled = false`
151-
152- tempDir := t.TempDir()
153- configFile := filepath.Join(tempDir, "gordon.toml")
154-
155- err := os.WriteFile(configFile, []byte(configContent), 0644)
156- require.NoError(t, err)
157-
158- viper.Reset()
159- viper.SetConfigFile(configFile)
160- err = viper.ReadInConfig()
161- require.NoError(t, err)
162-
163- _, err = Load()
164- require.Error(t, err)
165- assert.Contains(t, err.Error(), "registry_domain should be just the domain name")
166- })
167- }
168-}
169-
170-func TestConfig_Load_RegistryAuthValidation(t *testing.T) {
171- configContent := `[server]
172-registry_domain = "registry.example.com"
173-runtime = "docker"
174-
175-[registry_auth]
176-enabled = true
177-username = ""
178-password = ""`
179-
180- tempDir := t.TempDir()
181- configFile := filepath.Join(tempDir, "gordon.toml")
182-
183- err := os.WriteFile(configFile, []byte(configContent), 0644)
184- require.NoError(t, err)
185-
186- viper.Reset()
187- viper.SetConfigFile(configFile)
188- err = viper.ReadInConfig()
189- require.NoError(t, err)
190-
191- _, err = Load()
192- require.Error(t, err)
193- assert.Contains(t, err.Error(), "Registry auth enabled but username/password not provided")
194-}
195-
196-func TestConfig_GetRoutes(t *testing.T) {
197- cfg := &Config{
198- Routes: map[string]string{
199- "app.example.com": "nginx:latest",
200- "http://api.example.com": "api:v1",
201- "secure.example.com": "secure:latest",
202- },
203- }
204-
205- routes := cfg.GetRoutes()
206-
207- require.Len(t, routes, 3)
208-
209- // Find routes by domain
210- var appRoute, apiRoute, secureRoute Route
211- for _, route := range routes {
212- switch route.Domain {
213- case "app.example.com":
214- appRoute = route
215- case "api.example.com":
216- apiRoute = route
217- case "secure.example.com":
218- secureRoute = route
219- }
220- }
221-
222- // Verify HTTPS defaults
223- assert.Equal(t, "nginx:latest", appRoute.Image)
224- assert.True(t, appRoute.HTTPS)
225-
226- // Verify HTTP override
227- assert.Equal(t, "api:v1", apiRoute.Image)
228- assert.False(t, apiRoute.HTTPS)
229-
230- // Verify HTTPS default
231- assert.Equal(t, "secure:latest", secureRoute.Image)
232- assert.True(t, secureRoute.HTTPS)
233-}
234-
235-func TestExtractDomainFromImageName(t *testing.T) {
236- testCases := []struct {
237- name string
238- imageName string
239- expectedDomain string
240- shouldMatch bool
241- }{
242- {"valid domain with tag", "app.example.com:latest", "app.example.com", true},
243- {"valid domain without tag", "api.example.com", "api.example.com", true},
244- {"subdomain", "staging.api.example.com:v1", "staging.api.example.com", true},
245- {"simple name", "nginx", "", false},
246- {"dockerhub format", "user/repo", "", false},
247- {"localhost", "localhost:5000", "", false},
248- {"ip address", "192.168.1.1:8080", "", false},
249- {"invalid domain", "app-.example.com", "", false},
250- }
251-
252- for _, tc := range testCases {
253- t.Run(tc.name, func(t *testing.T) {
254- domain, match := ExtractDomainFromImageName(tc.imageName)
255- assert.Equal(t, tc.shouldMatch, match)
256- assert.Equal(t, tc.expectedDomain, domain)
257- })
258- }
259-}
260-
261-func TestConfig_AddRoute(t *testing.T) {
262- // Create a config with existing routes
263- configContent := `[server]
264-registry_domain = "registry.example.com"
265-runtime = "docker"
266-
267-[registry_auth]
268-enabled = false
269-
270-[routes]
271-"existing.example.com" = "existing:latest"`
272-
273- tempDir := t.TempDir()
274- configFile := filepath.Join(tempDir, "gordon.toml")
275-
276- err := os.WriteFile(configFile, []byte(configContent), 0644)
277- require.NoError(t, err)
278-
279- viper.Reset()
280- viper.SetConfigFile(configFile)
281- err = viper.ReadInConfig()
282- require.NoError(t, err)
283-
284- cfg, err := Load()
285- require.NoError(t, err)
286-
287- // Add a new route
288- err = cfg.AddRoute("new.example.com", "newapp:v1")
289- require.NoError(t, err)
290-
291- // Verify route was added to memory
292- assert.Equal(t, "newapp:v1", cfg.Routes["new.example.com"])
293-
294- // Verify config file was updated
295- updatedContent, err := os.ReadFile(configFile)
296- require.NoError(t, err)
297- assert.Contains(t, string(updatedContent), `"new.example.com" = "newapp:v1"`)
298- assert.Contains(t, string(updatedContent), `"existing.example.com" = "existing:latest"`)
299-}
300-
301-func TestConfig_UpdateRoute(t *testing.T) {
302- configContent := `[server]
303-registry_domain = "registry.example.com"
304-runtime = "docker"
305-
306-[registry_auth]
307-enabled = false
308-
309-[routes]
310-"app.example.com" = "oldapp:v1"`
311-
312- tempDir := t.TempDir()
313- configFile := filepath.Join(tempDir, "gordon.toml")
314-
315- err := os.WriteFile(configFile, []byte(configContent), 0644)
316- require.NoError(t, err)
317-
318- viper.Reset()
319- viper.SetConfigFile(configFile)
320- err = viper.ReadInConfig()
321- require.NoError(t, err)
322-
323- cfg, err := Load()
324- require.NoError(t, err)
325-
326- // Update existing route
327- err = cfg.UpdateRoute("app.example.com", "newapp:v2")
328- require.NoError(t, err)
329-
330- // Verify route was updated in memory
331- assert.Equal(t, "newapp:v2", cfg.Routes["app.example.com"])
332-
333- // Verify config file was updated
334- updatedContent, err := os.ReadFile(configFile)
335- require.NoError(t, err)
336- assert.Contains(t, string(updatedContent), `"app.example.com" = "newapp:v2"`)
337- assert.NotContains(t, string(updatedContent), "oldapp:v1")
338-}
339-
340-func TestConfig_UpdateRoute_NonExistent(t *testing.T) {
341- configContent := `[server]
342-registry_domain = "registry.example.com"
343-runtime = "docker"
344-
345-[registry_auth]
346-enabled = false
347-
348-[routes]`
349-
350- tempDir := t.TempDir()
351- configFile := filepath.Join(tempDir, "gordon.toml")
352-
353- err := os.WriteFile(configFile, []byte(configContent), 0644)
354- require.NoError(t, err)
355-
356- viper.Reset()
357- viper.SetConfigFile(configFile)
358- err = viper.ReadInConfig()
359- require.NoError(t, err)
360-
361- cfg, err := Load()
362- require.NoError(t, err)
363-
364- // Try to update non-existent route
365- err = cfg.UpdateRoute("nonexistent.example.com", "app:v1")
366- require.Error(t, err)
367- assert.Contains(t, err.Error(), "route nonexistent.example.com does not exist")
368-}
369-
370-func TestValidateEnvConfig(t *testing.T) {
371- testCases := []struct {
372- name string
373- envConfig EnvConfig
374- expectError bool
375- errorText string
376- }{
377- {
378- name: "valid config",
379- envConfig: EnvConfig{
380- Dir: "/tmp/env",
381- Providers: []string{"pass", "sops"},
382- },
383- expectError: false,
384- },
385- {
386- name: "empty provider",
387- envConfig: EnvConfig{
388- Dir: "/tmp/env",
389- Providers: []string{"pass", "", "sops"},
390- },
391- expectError: true,
392- errorText: "empty provider name",
393- },
394- {
395- name: "invalid provider",
396- envConfig: EnvConfig{
397- Dir: "/tmp/env",
398- Providers: []string{"pass", "invalid", "sops"},
399- },
400- expectError: true,
401- errorText: "unsupported provider 'invalid'",
402- },
403- {
404- name: "non-existent directory file conflict",
405- envConfig: EnvConfig{
406- Dir: "/dev/null", // This exists but is not a directory
407- Providers: []string{"pass"},
408- },
409- expectError: true,
410- errorText: "not a directory",
411- },
412- }
413-
414- for _, tc := range testCases {
415- t.Run(tc.name, func(t *testing.T) {
416- err := validateEnvConfig(&tc.envConfig)
417- if tc.expectError {
418- require.Error(t, err)
419- assert.Contains(t, err.Error(), tc.errorText)
420- } else {
421- require.NoError(t, err)
422- }
423- })
424- }
425-}
426-
427-func TestGetDefaultDataDir(t *testing.T) {
428- // Test should not fail regardless of environment
429- dataDir := getDefaultDataDir()
430- assert.NotEmpty(t, dataDir)
431-
432- // Should be either absolute path (for user home) or relative "./data"
433- assert.True(t, filepath.IsAbs(dataDir) || dataDir == "./data")
434-}
435-
436-func TestConfig_Load_NetworkIsolation(t *testing.T) {
437- configContent := `[server]
438-registry_domain = "registry.example.com"
439-runtime = "docker"
440-
441-[registry_auth]
442-enabled = false
443-
444-[network_isolation]
445-enabled = true
446-network_prefix = "custom"
447-dns_suffix = ".local"`
448-
449- tempDir := t.TempDir()
450- configFile := filepath.Join(tempDir, "gordon.toml")
451-
452- err := os.WriteFile(configFile, []byte(configContent), 0644)
453- require.NoError(t, err)
454-
455- viper.Reset()
456- viper.SetConfigFile(configFile)
457- err = viper.ReadInConfig()
458- require.NoError(t, err)
459-
460- cfg, err := Load()
461- require.NoError(t, err)
462-
463- assert.True(t, cfg.NetworkIsolation.Enabled)
464- assert.Equal(t, "custom", cfg.NetworkIsolation.NetworkPrefix)
465- assert.Equal(t, ".local", cfg.NetworkIsolation.DNSSuffix)
466-}
467-
468-func TestConfig_Load_AttachmentsAndNetworkGroups(t *testing.T) {
469- configContent := `[server]
470-registry_domain = "registry.example.com"
471-runtime = "docker"
472-
473-[registry_auth]
474-enabled = false
475-
476-[attachments]
477-"app.example.com" = ["postgres:14", "redis:6"]
478-"backend" = ["postgres:14"]
479-
480-[network_groups]
481-"backend" = ["api.example.com", "worker.example.com"]`
482-
483- tempDir := t.TempDir()
484- configFile := filepath.Join(tempDir, "gordon.toml")
485-
486- err := os.WriteFile(configFile, []byte(configContent), 0644)
487- require.NoError(t, err)
488-
489- viper.Reset()
490- viper.SetConfigFile(configFile)
491- err = viper.ReadInConfig()
492- require.NoError(t, err)
493-
494- cfg, err := Load()
495- require.NoError(t, err)
496-
497- // Verify attachments
498- assert.Len(t, cfg.Attachments, 2)
499- assert.Equal(t, []string{"postgres:14", "redis:6"}, cfg.Attachments["app.example.com"])
500- assert.Equal(t, []string{"postgres:14"}, cfg.Attachments["backend"])
501-
502- // Verify network groups
503- assert.Len(t, cfg.NetworkGroups, 1)
504- assert.Equal(t, []string{"api.example.com", "worker.example.com"}, cfg.NetworkGroups["backend"])
505-}
506-
507-func TestConfig_Load_EmptyConfiguration(t *testing.T) {
508- // Test loading with completely empty config file but needs minimal valid config
509- configContent := `[registry_auth]
510-enabled = false`
511-
512- tempDir := t.TempDir()
513- configFile := filepath.Join(tempDir, "gordon.toml")
514-
515- err := os.WriteFile(configFile, []byte(configContent), 0644)
516- require.NoError(t, err)
517-
518- viper.Reset()
519- viper.SetConfigFile(configFile)
520- err = viper.ReadInConfig()
521- require.NoError(t, err)
522-
523- cfg, err := Load()
524- require.NoError(t, err)
525-
526- // Should have all defaults except what we explicitly set
527- assert.Equal(t, 8080, cfg.Server.Port)
528- assert.Equal(t, 5000, cfg.Server.RegistryPort)
529- assert.Equal(t, "auto", cfg.Server.Runtime)
530- assert.False(t, cfg.RegistryAuth.Enabled) // explicitly disabled
531- assert.True(t, cfg.Volumes.AutoCreate)
532- assert.Equal(t, "gordon", cfg.Volumes.Prefix)
533- assert.True(t, cfg.Volumes.Preserve)
534- assert.True(t, cfg.NetworkIsolation.Enabled)
535- assert.Equal(t, "gordon", cfg.NetworkIsolation.NetworkPrefix)
536- assert.Equal(t, ".internal", cfg.NetworkIsolation.DNSSuffix)
537-}
538-
539-func TestConfig_AddRoute_NoConfigFile(t *testing.T) {
540- // Clear viper state to ensure no config file is set
541- viper.Reset()
542-
543- cfg := &Config{
544- Routes: make(map[string]string),
545- }
546-
547- // Try to add route when no config file is available
548- err := cfg.AddRoute("test.example.com", "test:latest")
549- require.Error(t, err)
550- assert.Contains(t, err.Error(), "no config file path available")
551-}
552-
553-func TestConfig_AddRoute_FileReadError(t *testing.T) {
554- // Create a config file in a non-existent directory
555- configFile := "/nonexistent/directory/gordon.toml"
556-
557- viper.Reset()
558- viper.SetConfigFile(configFile)
559-
560- cfg := &Config{
561- Routes: make(map[string]string),
562- }
563-
564- err := cfg.AddRoute("test.example.com", "test:latest")
565- require.Error(t, err)
566- assert.Contains(t, err.Error(), "failed to read config file")
567-}
568-
569-func TestConfig_UpdateRoute_FileReadError(t *testing.T) {
570- // Create a config file in a non-existent directory
571- configFile := "/nonexistent/directory/gordon.toml"
572-
573- viper.Reset()
574- viper.SetConfigFile(configFile)
575-
576- cfg := &Config{
577- Routes: map[string]string{
578- "test.example.com": "test:v1",
579- },
580- }
581-
582- err := cfg.UpdateRoute("test.example.com", "test:v2")
583- require.Error(t, err)
584- assert.Contains(t, err.Error(), "failed to read config file")
585-}
586-
587-func TestConfig_AddRoute_RouteAtEndOfFile(t *testing.T) {
588- configContent := `[server]
589-registry_domain = "registry.example.com"
590-runtime = "docker"
591-
592-[registry_auth]
593-enabled = false
594-
595-[routes]
596-"existing.example.com" = "existing:latest"`
597-
598- tempDir := t.TempDir()
599- configFile := filepath.Join(tempDir, "gordon.toml")
600-
601- err := os.WriteFile(configFile, []byte(configContent), 0644)
602- require.NoError(t, err)
603-
604- viper.Reset()
605- viper.SetConfigFile(configFile)
606- err = viper.ReadInConfig()
607- require.NoError(t, err)
608-
609- cfg, err := Load()
610- require.NoError(t, err)
611-
612- // Add a new route at the end of the routes section
613- err = cfg.AddRoute("end.example.com", "endapp:v1")
614- require.NoError(t, err)
615-
616- // Verify route was added to memory
617- assert.Equal(t, "endapp:v1", cfg.Routes["end.example.com"])
618-
619- // Verify config file was updated
620- updatedContent, err := os.ReadFile(configFile)
621- require.NoError(t, err)
622- assert.Contains(t, string(updatedContent), `"end.example.com" = "endapp:v1"`)
623-}
624-
625-func TestValidateEnvConfig_RelativePath(t *testing.T) {
626- envConfig := EnvConfig{
627- Dir: "./relative/path", // relative path should not cause error
628- Providers: []string{"pass"},
629- }
630-
631- err := validateEnvConfig(&envConfig)
632- require.NoError(t, err)
633-}
634-
635-func TestValidateEnvConfig_EmptyDir(t *testing.T) {
636- envConfig := EnvConfig{
637- Dir: "", // empty dir should not cause error
638- Providers: []string{"pass"},
639- }
640-
641- err := validateEnvConfig(&envConfig)
642- require.NoError(t, err)
643-}
644-
645-func TestGetDefaultDataDir_Coverage(t *testing.T) {
646- // This test mainly exists to ensure coverage of getDefaultDataDir branches
647- // We can't easily test root vs non-root scenarios, but we can test the function
648- dataDir := getDefaultDataDir()
649- assert.NotEmpty(t, dataDir)
650-}⏎̸
D internal/container/docker.go +0 -681
@@ -1,681 +0,0 @@
1-package container
2-
3-import (
4- "context"
5- "encoding/base64"
6- "encoding/json"
7- "fmt"
8- "gordon/pkg/runtime"
9- "io"
10- "strconv"
11- "strings"
12-
13- "github.com/docker/docker/api/types/container"
14- "github.com/docker/docker/api/types/image"
15- "github.com/docker/docker/api/types/network"
16- "github.com/docker/docker/api/types/registry"
17- "github.com/docker/docker/api/types/volume"
18- "github.com/docker/docker/client"
19- "github.com/docker/docker/errdefs"
20- "github.com/docker/go-connections/nat"
21- "github.com/rs/zerolog/log"
22-)
23-
24-// DockerRuntime implements the Runtime interface using Docker API
25-type DockerRuntime struct {
26- client *client.Client
27-}
28-
29-// NewDockerRuntime creates a new Docker runtime instance
30-func NewDockerRuntime() (*DockerRuntime, error) {
31- cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
32- if err != nil {
33- return nil, fmt.Errorf("failed to create Docker client: %w", err)
34- }
35-
36- return &DockerRuntime{
37- client: cli,
38- }, nil
39-}
40-
41-// NewDockerRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing)
42-func NewDockerRuntimeWithClient(cli *client.Client) *DockerRuntime {
43- return &DockerRuntime{
44- client: cli,
45- }
46-}
47-
48-// CreateContainer creates a new container
49-func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
50- // Convert ports to Docker format
51- exposedPorts := make(nat.PortSet)
52- portBindings := make(nat.PortMap)
53-
54- for _, port := range config.Ports {
55- containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
56- exposedPorts[containerPort] = struct{}{}
57-
58- // Bind to random available port on host
59- portBindings[containerPort] = []nat.PortBinding{
60- {
61- HostIP: "0.0.0.0",
62- HostPort: "0", // Docker will assign a random available port
63- },
64- }
65- }
66-
67- // Convert volumes to Docker format
68- var binds []string
69- if config.Volumes != nil {
70- for containerPath, volumeName := range config.Volumes {
71- bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
72- binds = append(binds, bind)
73- log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("Adding volume mount")
74- }
75- }
76-
77- // Create container configuration
78- containerConfig := &container.Config{
79- Image: config.Image,
80- Env: config.Env,
81- ExposedPorts: exposedPorts,
82- WorkingDir: config.WorkingDir,
83- Cmd: config.Cmd,
84- Labels: config.Labels,
85- }
86-
87- hostConfig := &container.HostConfig{
88- PortBindings: portBindings,
89- AutoRemove: config.AutoRemove,
90- Binds: binds,
91- NetworkMode: container.NetworkMode(config.NetworkMode),
92- }
93-
94- // Create network configuration for container
95- var networkConfig *network.NetworkingConfig
96- if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
97- networkConfig = &network.NetworkingConfig{
98- EndpointsConfig: map[string]*network.EndpointSettings{
99- config.NetworkMode: {
100- Aliases: config.Aliases,
101- },
102- },
103- }
104- if config.Hostname != "" {
105- networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
106- }
107- }
108-
109- // Create the container
110- resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
111- if err != nil {
112- return nil, fmt.Errorf("failed to create container: %w", err)
113- }
114-
115- log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created")
116-
117- // Inspect the created container to get full details
118- return d.InspectContainer(ctx, resp.ID)
119-}
120-
121-// StartContainer starts a container
122-func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error {
123- err := d.client.ContainerStart(ctx, containerID, container.StartOptions{})
124- if err != nil {
125- return fmt.Errorf("failed to start container %s: %w", containerID, err)
126- }
127-
128- log.Info().Str("id", containerID).Msg("Container started")
129- return nil
130-}
131-
132-// StopContainer stops a container
133-func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error {
134- timeout := 30 // 30 seconds timeout
135- err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
136- if err != nil {
137- return fmt.Errorf("failed to stop container %s: %w", containerID, err)
138- }
139-
140- log.Info().Str("id", containerID).Msg("Container stopped")
141- return nil
142-}
143-
144-// RestartContainer restarts a container
145-func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error {
146- timeout := 30 // 30 seconds timeout
147- err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
148- if err != nil {
149- return fmt.Errorf("failed to restart container %s: %w", containerID, err)
150- }
151-
152- log.Info().Str("id", containerID).Msg("Container restarted")
153- return nil
154-}
155-
156-// RemoveContainer removes a container
157-func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
158- err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
159- if err != nil {
160- return fmt.Errorf("failed to remove container %s: %w", containerID, err)
161- }
162-
163- log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed")
164- return nil
165-}
166-
167-// ListContainers lists containers
168-func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
169- containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all})
170- if err != nil {
171- return nil, fmt.Errorf("failed to list containers: %w", err)
172- }
173-
174- var result []*runtime.Container
175- for _, c := range containers {
176- // Extract ports
177- var ports []int
178- for _, port := range c.Ports {
179- if port.PublicPort > 0 {
180- ports = append(ports, int(port.PublicPort))
181- }
182- }
183-
184- // Get the primary name (remove leading slash)
185- name := ""
186- if len(c.Names) > 0 {
187- name = strings.TrimPrefix(c.Names[0], "/")
188- }
189-
190- result = append(result, &runtime.Container{
191- ID: c.ID,
192- Image: c.Image,
193- Name: name,
194- Status: c.Status,
195- Ports: ports,
196- Labels: c.Labels,
197- })
198- }
199-
200- return result, nil
201-}
202-
203-// InspectContainer inspects a container
204-func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
205- resp, err := d.client.ContainerInspect(ctx, containerID)
206- if err != nil {
207- return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
208- }
209-
210- // Extract published ports
211- var ports []int
212- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
213- for _, bindings := range resp.NetworkSettings.Ports {
214- for _, binding := range bindings {
215- if binding.HostPort != "" {
216- if port, err := strconv.Atoi(binding.HostPort); err == nil {
217- ports = append(ports, port)
218- }
219- }
220- }
221- }
222- }
223-
224- // Get container name (remove leading slash)
225- name := strings.TrimPrefix(resp.Name, "/")
226-
227- return &runtime.Container{
228- ID: resp.ID,
229- Image: resp.Config.Image,
230- Name: name,
231- Status: resp.State.Status,
232- Ports: ports,
233- Labels: resp.Config.Labels,
234- }, nil
235-}
236-
237-// GetContainerLogs gets container logs
238-func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
239- logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{
240- ShowStdout: true,
241- ShowStderr: true,
242- Follow: follow,
243- Timestamps: true,
244- })
245- if err != nil {
246- return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
247- }
248-
249- return logs, nil
250-}
251-
252-// PullImage pulls an image
253-func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
254- log.Info().Str("image", imageRef).Msg("Pulling image")
255-
256- reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{})
257- if err != nil {
258- return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
259- }
260- defer reader.Close()
261-
262- // Read the response to completion (this is required for the pull to complete)
263- _, err = io.Copy(io.Discard, reader)
264- if err != nil {
265- return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
266- }
267-
268- log.Info().Str("image", imageRef).Msg("Image pulled successfully")
269- return nil
270-}
271-
272-// PullImageWithAuth pulls an image with authentication
273-func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
274- log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication")
275-
276- // Create authentication configuration
277- authConfig := registry.AuthConfig{
278- Username: username,
279- Password: password,
280- }
281-
282- // Encode authentication to base64 JSON
283- authConfigBytes, err := json.Marshal(authConfig)
284- if err != nil {
285- return fmt.Errorf("failed to marshal auth config: %w", err)
286- }
287- authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
288-
289- // Pull with authentication
290- reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{
291- RegistryAuth: authStr,
292- })
293- if err != nil {
294- return fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
295- }
296- defer reader.Close()
297-
298- // Read the response to completion (this is required for the pull to complete)
299- _, err = io.Copy(io.Discard, reader)
300- if err != nil {
301- return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
302- }
303-
304- log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
305- return nil
306-}
307-
308-// RemoveImage removes an image
309-func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
310- _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
311- if err != nil {
312- return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
313- }
314-
315- log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed")
316- return nil
317-}
318-
319-// ListImages lists images
320-func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) {
321- images, err := d.client.ImageList(ctx, image.ListOptions{})
322- if err != nil {
323- return nil, fmt.Errorf("failed to list images: %w", err)
324- }
325-
326- var result []string
327- for _, img := range images {
328- for _, tag := range img.RepoTags {
329- if tag != "<none>:<none>" {
330- result = append(result, tag)
331- }
332- }
333- }
334-
335- return result, nil
336-}
337-
338-// Ping checks if Docker is responsive
339-func (d *DockerRuntime) Ping(ctx context.Context) error {
340- _, err := d.client.Ping(ctx)
341- if err != nil {
342- return fmt.Errorf("Docker ping failed: %w", err)
343- }
344- return nil
345-}
346-
347-// Version returns Docker version
348-func (d *DockerRuntime) Version(ctx context.Context) (string, error) {
349- version, err := d.client.ServerVersion(ctx)
350- if err != nil {
351- return "", fmt.Errorf("failed to get Docker version: %w", err)
352- }
353- return version.Version, nil
354-}
355-
356-// IsContainerRunning checks if a container is running
357-func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
358- container, err := d.InspectContainer(ctx, containerID)
359- if err != nil {
360- return false, err
361- }
362- return container.Status == "running", nil
363-}
364-
365-// GetContainerPort gets the host port for a container's internal port
366-func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
367- resp, err := d.client.ContainerInspect(ctx, containerID)
368- if err != nil {
369- return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
370- }
371-
372- if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
373- return 0, fmt.Errorf("no port mappings found for container %s", containerID)
374- }
375-
376- containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
377- bindings, exists := resp.NetworkSettings.Ports[containerPort]
378- if !exists || len(bindings) == 0 {
379- return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
380- }
381-
382- hostPort, err := strconv.Atoi(bindings[0].HostPort)
383- if err != nil {
384- return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
385- }
386-
387- return hostPort, nil
388-}
389-
390-// GetImageExposedPorts gets the exposed ports from an image
391-func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
392- imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
393- if err != nil {
394- return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
395- }
396-
397- var ports []int
398- if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
399- for portSpec := range imageInspect.Config.ExposedPorts {
400- // Parse port from format like "80/tcp"
401- portStr := strings.Split(string(portSpec), "/")[0]
402- if port, err := strconv.Atoi(portStr); err == nil {
403- ports = append(ports, port)
404- }
405- }
406- }
407-
408- if len(ports) == 0 {
409- return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
410- }
411-
412- log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
413- return ports, nil
414-}
415-
416-// GetContainerExposedPorts gets all exposed ports from a running container
417-func (d *DockerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
418- resp, err := d.client.ContainerInspect(ctx, containerID)
419- if err != nil {
420- return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
421- }
422-
423- var ports []int
424- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
425- for portSpec := range resp.NetworkSettings.Ports {
426- // Parse port from format like "80/tcp"
427- portStr := strings.Split(string(portSpec), "/")[0]
428- if port, err := strconv.Atoi(portStr); err == nil {
429- ports = append(ports, port)
430- }
431- }
432- }
433-
434- return ports, nil
435-}
436-
437-// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use
438-func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
439- resp, err := d.client.ContainerInspect(ctx, containerID)
440- if err != nil {
441- return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
442- }
443-
444- // Get container's internal IP address
445- var containerIP string
446- if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
447- // Use the first available network (usually bridge or custom network)
448- for _, network := range resp.NetworkSettings.Networks {
449- if network.IPAddress != "" {
450- containerIP = network.IPAddress
451- break
452- }
453- }
454- }
455-
456- if containerIP == "" {
457- return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
458- }
459-
460- // Get exposed ports from container
461- var exposedPorts []int
462- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
463- for portSpec := range resp.NetworkSettings.Ports {
464- // Parse port from format like "80/tcp"
465- portStr := strings.Split(string(portSpec), "/")[0]
466- if port, err := strconv.Atoi(portStr); err == nil {
467- exposedPorts = append(exposedPorts, port)
468- }
469- }
470- }
471-
472- if len(exposedPorts) == 0 {
473- return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
474- }
475-
476- // Use the first exposed port
477- selectedPort := exposedPorts[0]
478-
479- log.Info().
480- Str("container_id", containerID).
481- Str("ip", containerIP).
482- Int("selected_port", selectedPort).
483- Ints("exposed_ports", exposedPorts).
484- Msg("Container network info detected")
485-
486- return containerIP, selectedPort, nil
487-}
488-
489-// InspectImageVolumes gets the volume mount points declared in the image
490-func (d *DockerRuntime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
491- imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
492- if err != nil {
493- return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
494- }
495-
496- var volumes []string
497- if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
498- for volumePath := range imageInspect.Config.Volumes {
499- volumes = append(volumes, volumePath)
500- }
501- }
502-
503- if len(volumes) > 0 {
504- log.Info().Str("image", imageRef).Strs("volumes", volumes).Msg("Found VOLUME directives in image")
505- } else {
506- log.Debug().Str("image", imageRef).Msg("No VOLUME directives found in image")
507- }
508-
509- return volumes, nil
510-}
511-
512-// VolumeExists checks if a Docker volume exists
513-func (d *DockerRuntime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
514- _, err := d.client.VolumeInspect(ctx, volumeName)
515- if err != nil {
516- if errdefs.IsNotFound(err) {
517- return false, nil
518- }
519- return false, fmt.Errorf("failed to inspect volume %s: %w", volumeName, err)
520- }
521- return true, nil
522-}
523-
524-// CreateVolume creates a new Docker volume
525-func (d *DockerRuntime) CreateVolume(ctx context.Context, volumeName string) error {
526- _, err := d.client.VolumeCreate(ctx, volume.CreateOptions{
527- Name: volumeName,
528- Labels: map[string]string{
529- "gordon.managed": "true",
530- "gordon.created": "auto",
531- },
532- })
533- if err != nil {
534- return fmt.Errorf("failed to create volume %s: %w", volumeName, err)
535- }
536-
537- log.Info().Str("volume", volumeName).Msg("Volume created")
538- return nil
539-}
540-
541-// RemoveVolume removes a Docker volume
542-func (d *DockerRuntime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
543- err := d.client.VolumeRemove(ctx, volumeName, force)
544- if err != nil {
545- if errdefs.IsNotFound(err) {
546- log.Debug().Str("volume", volumeName).Msg("Volume not found, already removed")
547- return nil
548- }
549- return fmt.Errorf("failed to remove volume %s: %w", volumeName, err)
550- }
551-
552- log.Info().Str("volume", volumeName).Msg("Volume removed")
553- return nil
554-}
555-
556-// InspectImageEnv gets the environment variables declared in the image
557-func (d *DockerRuntime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
558- imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
559- if err != nil {
560- return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
561- }
562-
563- var envVars []string
564- if imageInspect.Config != nil && imageInspect.Config.Env != nil {
565- envVars = imageInspect.Config.Env
566- }
567-
568- if len(envVars) > 0 {
569- log.Debug().Str("image", imageRef).Strs("env_vars", envVars).Msg("Found ENV directives in image")
570- } else {
571- log.Debug().Str("image", imageRef).Msg("No ENV directives found in image")
572- }
573-
574- return envVars, nil
575-}
576-
577-// CreateNetwork creates a new Docker network
578-func (d *DockerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
579- // Set default driver to bridge if not specified
580- driver := "bridge"
581- if driverOption, exists := options["driver"]; exists {
582- driver = driverOption
583- }
584-
585- createOptions := network.CreateOptions{
586- Driver: driver,
587- Labels: map[string]string{
588- "gordon.managed": "true",
589- },
590- }
591-
592- // Add any additional options to labels
593- for key, value := range options {
594- if key != "driver" {
595- createOptions.Labels["gordon."+key] = value
596- }
597- }
598-
599- _, err := d.client.NetworkCreate(ctx, name, createOptions)
600- if err != nil {
601- return fmt.Errorf("failed to create network %s: %w", name, err)
602- }
603-
604- log.Info().Str("network", name).Str("driver", driver).Msg("Network created")
605- return nil
606-}
607-
608-// RemoveNetwork removes a Docker network
609-func (d *DockerRuntime) RemoveNetwork(ctx context.Context, name string) error {
610- err := d.client.NetworkRemove(ctx, name)
611- if err != nil {
612- if errdefs.IsNotFound(err) {
613- log.Debug().Str("network", name).Msg("Network not found, already removed")
614- return nil
615- }
616- return fmt.Errorf("failed to remove network %s: %w", name, err)
617- }
618-
619- log.Info().Str("network", name).Msg("Network removed")
620- return nil
621-}
622-
623-// ListNetworks lists all Docker networks
624-func (d *DockerRuntime) ListNetworks(ctx context.Context) ([]*runtime.NetworkInfo, error) {
625- networks, err := d.client.NetworkList(ctx, network.ListOptions{})
626- if err != nil {
627- return nil, fmt.Errorf("failed to list networks: %w", err)
628- }
629-
630- var result []*runtime.NetworkInfo
631- for _, net := range networks {
632- var containers []string
633- for containerID := range net.Containers {
634- containers = append(containers, containerID)
635- }
636-
637- result = append(result, &runtime.NetworkInfo{
638- ID: net.ID,
639- Name: net.Name,
640- Driver: net.Driver,
641- Containers: containers,
642- Labels: net.Labels,
643- })
644- }
645-
646- return result, nil
647-}
648-
649-// NetworkExists checks if a Docker network exists
650-func (d *DockerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
651- _, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{})
652- if err != nil {
653- if errdefs.IsNotFound(err) {
654- return false, nil
655- }
656- return false, fmt.Errorf("failed to inspect network %s: %w", name, err)
657- }
658- return true, nil
659-}
660-
661-// ConnectContainerToNetwork connects a container to a network
662-func (d *DockerRuntime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
663- err := d.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
664- if err != nil {
665- return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
666- }
667-
668- log.Info().Str("container", containerName).Str("network", networkName).Msg("Container connected to network")
669- return nil
670-}
671-
672-// DisconnectContainerFromNetwork disconnects a container from a network
673-func (d *DockerRuntime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
674- err := d.client.NetworkDisconnect(ctx, networkName, containerName, false)
675- if err != nil {
676- return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
677- }
678-
679- log.Info().Str("container", containerName).Str("network", networkName).Msg("Container disconnected from network")
680- return nil
681-}
D internal/container/factory.go +0 -263
@@ -1,263 +0,0 @@
1-package container
2-
3-import (
4- "context"
5- "fmt"
6- "os"
7- "os/user"
8- "strings"
9-
10- "github.com/docker/docker/client"
11- "github.com/rs/zerolog/log"
12-
13- "gordon/internal/config"
14- "gordon/pkg/runtime"
15-)
16-
17-// RuntimeType represents the container runtime type
18-type RuntimeType string
19-
20-const (
21- RuntimeAuto RuntimeType = "auto"
22- RuntimeDocker RuntimeType = "docker"
23- RuntimePodman RuntimeType = "podman"
24- RuntimePodmanRootless RuntimeType = "podman-rootless"
25-)
26-
27-// RuntimeInfo contains information about a detected runtime
28-type RuntimeInfo struct {
29- Type RuntimeType
30- SocketPath string
31- Version string
32-}
33-
34-// CreateRuntime creates a container runtime based on configuration
35-func CreateRuntime(ctx context.Context, cfg *config.Config) (runtime.Runtime, error) {
36- runtimeType := RuntimeType(cfg.Server.Runtime)
37-
38- // Get socket path from config or environment
39- socketPath := getSocketPath(cfg.Server.SocketPath)
40-
41- switch runtimeType {
42- case RuntimeAuto:
43- return createAutoRuntime(ctx, socketPath)
44- case RuntimeDocker:
45- return createDockerRuntime(ctx, socketPath)
46- case RuntimePodman, RuntimePodmanRootless:
47- return createPodmanRuntime(ctx, socketPath, runtimeType == RuntimePodmanRootless)
48- default:
49- return nil, fmt.Errorf("unsupported runtime: %s", runtimeType)
50- }
51-}
52-
53-// getSocketPath determines the socket path to use
54-func getSocketPath(configPath string) string {
55- // 1. Use config if provided
56- if configPath != "" {
57- log.Debug().Str("path", configPath).Msg("Using socket path from config")
58- return configPath
59- }
60-
61- // 2. Check CONTAINER_HOST environment variable
62- if envPath := os.Getenv("CONTAINER_HOST"); envPath != "" {
63- log.Debug().Str("path", envPath).Msg("Using socket path from CONTAINER_HOST")
64- return envPath
65- }
66-
67- // 3. Return empty string for auto-detection
68- return ""
69-}
70-
71-// createAutoRuntime automatically detects and creates the best available runtime
72-func createAutoRuntime(ctx context.Context, customSocketPath string) (runtime.Runtime, error) {
73- var candidatePaths []string
74-
75- if customSocketPath != "" {
76- // If custom path provided, try it first
77- candidatePaths = []string{customSocketPath}
78- } else {
79- // Auto-detect socket paths
80- candidatePaths = getDefaultSocketPaths()
81- }
82-
83- // Try each socket path
84- for _, socketPath := range candidatePaths {
85- if rt, info, err := tryCreateRuntime(ctx, socketPath); err == nil {
86- log.Info().
87- Str("runtime", string(info.Type)).
88- Str("socket", info.SocketPath).
89- Str("version", info.Version).
90- Msg("Container runtime auto-detected")
91- return rt, nil
92- }
93- }
94-
95- return nil, fmt.Errorf("no container runtime found. Tried paths: %v", candidatePaths)
96-}
97-
98-// createDockerRuntime creates a Docker runtime with optional custom socket
99-func createDockerRuntime(ctx context.Context, socketPath string) (runtime.Runtime, error) {
100- var opts []client.Opt
101- opts = append(opts, client.FromEnv, client.WithAPIVersionNegotiation())
102-
103- if socketPath != "" {
104- opts = append(opts, client.WithHost(socketPath))
105- }
106-
107- cli, err := client.NewClientWithOpts(opts...)
108- if err != nil {
109- return nil, fmt.Errorf("failed to create Docker client: %w", err)
110- }
111-
112- // Test connectivity
113- if _, err := cli.Ping(ctx); err != nil {
114- return nil, fmt.Errorf("Docker ping failed: %w", err)
115- }
116-
117- return &DockerRuntime{client: cli}, nil
118-}
119-
120-// createPodmanRuntime creates a Podman runtime with optional custom socket
121-func createPodmanRuntime(ctx context.Context, socketPath string, rootless bool) (runtime.Runtime, error) {
122- if socketPath == "" {
123- socketPath = getDefaultPodmanSocket(rootless)
124- }
125-
126- return createPodmanRuntimeWithSocket(ctx, socketPath)
127-}
128-
129-// tryCreateRuntime attempts to create a runtime for a given socket path
130-func tryCreateRuntime(ctx context.Context, socketPath string) (runtime.Runtime, *RuntimeInfo, error) {
131- // Try to create client
132- opts := []client.Opt{
133- client.WithHost(socketPath),
134- client.WithAPIVersionNegotiation(),
135- }
136-
137- cli, err := client.NewClientWithOpts(opts...)
138- if err != nil {
139- return nil, nil, fmt.Errorf("failed to create client: %w", err)
140- }
141-
142- // Test connectivity and get version
143- ping, err := cli.Ping(ctx)
144- if err != nil {
145- return nil, nil, fmt.Errorf("ping failed: %w", err)
146- }
147-
148- // Determine runtime type based on socket path and server info
149- runtimeType := detectRuntimeType(socketPath)
150-
151- // Since Podman uses Docker-compatible API, we can use DockerRuntime for both
152- rt := &DockerRuntime{client: cli}
153-
154- info := &RuntimeInfo{
155- Type: runtimeType,
156- SocketPath: socketPath,
157- Version: ping.APIVersion,
158- }
159-
160- return rt, info, nil
161-}
162-
163-// detectRuntimeType determines if the socket is Docker or Podman
164-func detectRuntimeType(socketPath string) RuntimeType {
165- // Check socket path patterns
166- if strings.Contains(socketPath, "podman") {
167- if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") {
168- return RuntimePodmanRootless
169- }
170- return RuntimePodman
171- }
172-
173- if strings.Contains(socketPath, "docker") {
174- return RuntimeDocker
175- }
176-
177- // Default socket paths
178- switch socketPath {
179- case "/var/run/docker.sock", "unix:///var/run/docker.sock":
180- return RuntimeDocker
181- case "/run/podman/podman.sock", "unix:///run/podman/podman.sock":
182- return RuntimePodman
183- }
184-
185- // Check if it's in user runtime directory (rootless)
186- if strings.Contains(socketPath, "/run/user/") {
187- return RuntimePodmanRootless
188- }
189-
190- // Default to Docker for unknown paths
191- return RuntimeDocker
192-}
193-
194-// getDefaultSocketPaths returns the default socket paths to try
195-func getDefaultSocketPaths() []string {
196- paths := []string{
197- "unix:///var/run/docker.sock", // Docker
198- }
199-
200- // Add Podman rootless socket first (preferred)
201- if rootlessSocket := getDefaultPodmanSocket(true); rootlessSocket != "" {
202- paths = append(paths, rootlessSocket)
203- }
204-
205- // Add Podman root socket last
206- paths = append(paths, "unix:///run/podman/podman.sock")
207-
208- return paths
209-}
210-
211-// getDefaultPodmanSocket returns the default Podman socket path
212-func getDefaultPodmanSocket(rootless bool) string {
213- if !rootless {
214- return "unix:///run/podman/podman.sock"
215- }
216-
217- // Get user runtime directory for rootless Podman
218- if xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR"); xdgRuntimeDir != "" {
219- return fmt.Sprintf("unix://%s/podman/podman.sock", xdgRuntimeDir)
220- }
221-
222- // Fallback: construct from user ID
223- if currentUser, err := user.Current(); err == nil {
224- return fmt.Sprintf("unix:///run/user/%s/podman/podman.sock", currentUser.Uid)
225- }
226-
227- log.Warn().Msg("Could not determine rootless Podman socket path")
228- return ""
229-}
230-
231-// createPodmanRuntimeWithSocket creates a Podman runtime for a specific socket
232-func createPodmanRuntimeWithSocket(ctx context.Context, socketPath string) (runtime.Runtime, error) {
233- opts := []client.Opt{
234- client.WithHost(socketPath),
235- client.WithAPIVersionNegotiation(),
236- }
237-
238- cli, err := client.NewClientWithOpts(opts...)
239- if err != nil {
240- return nil, fmt.Errorf("failed to create Podman client: %w", err)
241- }
242-
243- // Test connectivity
244- if _, err := cli.Ping(ctx); err != nil {
245- return nil, fmt.Errorf("Podman ping failed: %w", err)
246- }
247-
248- return &DockerRuntime{client: cli}, nil
249-}
250-
251-// GetAvailableRuntimes returns information about all available runtimes
252-func GetAvailableRuntimes() []RuntimeInfo {
253- var runtimes []RuntimeInfo
254- ctx := context.Background()
255-
256- for _, socketPath := range getDefaultSocketPaths() {
257- if _, info, err := tryCreateRuntime(ctx, socketPath); err == nil {
258- runtimes = append(runtimes, *info)
259- }
260- }
261-
262- return runtimes
263-}
D internal/container/factory_test.go +0 -337
@@ -1,337 +0,0 @@
1-package container
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "strings"
8- "testing"
9-
10- "github.com/stretchr/testify/assert"
11- "github.com/stretchr/testify/require"
12-
13- "gordon/internal/config"
14-)
15-
16-func TestCreateRuntime(t *testing.T) {
17- ctx := context.Background()
18-
19- tests := []struct {
20- name string
21- config *config.Config
22- expectError bool
23- expectedError string
24- }{
25- {
26- name: "docker runtime",
27- config: &config.Config{
28- Server: config.ServerConfig{
29- Runtime: "docker",
30- SocketPath: "/var/run/docker.sock",
31- },
32- },
33- expectError: true, // Will fail because Docker isn't running in test
34- },
35- {
36- name: "podman runtime",
37- config: &config.Config{
38- Server: config.ServerConfig{
39- Runtime: "podman",
40- SocketPath: "/run/user/1000/podman/podman.sock",
41- },
42- },
43- expectError: true, // Will fail because Podman isn't running in test
44- },
45- {
46- name: "auto runtime",
47- config: &config.Config{
48- Server: config.ServerConfig{
49- Runtime: "auto",
50- },
51- },
52- expectError: false, // May succeed if runtime is available
53- },
54- {
55- name: "invalid runtime",
56- config: &config.Config{
57- Server: config.ServerConfig{
58- Runtime: "invalid",
59- },
60- },
61- expectError: true,
62- expectedError: "unsupported runtime",
63- },
64- }
65-
66- for _, tt := range tests {
67- t.Run(tt.name, func(t *testing.T) {
68- runtime, err := CreateRuntime(ctx, tt.config)
69-
70- if tt.name == "auto runtime" {
71- // Auto runtime may succeed or fail depending on environment
72- if err != nil {
73- assert.Nil(t, runtime)
74- } else {
75- assert.NotNil(t, runtime)
76- }
77- } else if tt.expectError {
78- assert.Error(t, err)
79- if tt.expectedError != "" {
80- assert.Contains(t, err.Error(), tt.expectedError)
81- }
82- assert.Nil(t, runtime)
83- } else {
84- assert.NoError(t, err)
85- assert.NotNil(t, runtime)
86- }
87- })
88- }
89-}
90-
91-func TestGetSocketPath(t *testing.T) {
92- tests := []struct {
93- name string
94- configPath string
95- setupEnv func()
96- cleanupEnv func()
97- expected string
98- }{
99- {
100- name: "with provided config path",
101- configPath: "/custom/docker.sock",
102- setupEnv: func() {},
103- cleanupEnv: func() {},
104- expected: "/custom/docker.sock",
105- },
106- {
107- name: "with CONTAINER_HOST env var",
108- configPath: "",
109- setupEnv: func() {
110- os.Setenv("CONTAINER_HOST", "/env/container.sock")
111- },
112- cleanupEnv: func() {
113- os.Unsetenv("CONTAINER_HOST")
114- },
115- expected: "/env/container.sock",
116- },
117- {
118- name: "no config or env - returns empty",
119- configPath: "",
120- setupEnv: func() {
121- os.Unsetenv("CONTAINER_HOST")
122- },
123- cleanupEnv: func() {},
124- expected: "",
125- },
126- }
127-
128- for _, tt := range tests {
129- t.Run(tt.name, func(t *testing.T) {
130- tt.setupEnv()
131- defer tt.cleanupEnv()
132-
133- result := getSocketPath(tt.configPath)
134- assert.Equal(t, tt.expected, result)
135- })
136- }
137-}
138-
139-func TestCreateAutoRuntime(t *testing.T) {
140- ctx := context.Background()
141-
142- // Test that createAutoRuntime tries different runtimes
143- runtime, err := createAutoRuntime(ctx, "")
144- // In test environment, this may succeed or fail depending on available runtimes
145- if err != nil {
146- // If it fails, should be because no runtime is available
147- assert.Nil(t, runtime)
148- assert.Contains(t, err.Error(), "no container runtime available")
149- } else {
150- // If it succeeds, should return a runtime
151- assert.NotNil(t, runtime)
152- }
153-}
154-
155-func TestCreateDockerRuntime(t *testing.T) {
156- ctx := context.Background()
157-
158- tests := []struct {
159- name string
160- socketPath string
161- expectError bool
162- }{
163- {
164- name: "default docker socket",
165- socketPath: "/var/run/docker.sock",
166- expectError: true, // Will fail because Docker isn't running
167- },
168- {
169- name: "custom socket path",
170- socketPath: "/custom/docker.sock",
171- expectError: true, // Will fail because socket doesn't exist
172- },
173- }
174-
175- for _, tt := range tests {
176- t.Run(tt.name, func(t *testing.T) {
177- runtime, err := createDockerRuntime(ctx, tt.socketPath)
178-
179- if tt.expectError {
180- assert.Error(t, err)
181- assert.Nil(t, runtime)
182- } else {
183- assert.NoError(t, err)
184- assert.NotNil(t, runtime)
185- }
186- })
187- }
188-}
189-
190-func TestCreatePodmanRuntime(t *testing.T) {
191- ctx := context.Background()
192-
193- tests := []struct {
194- name string
195- socketPath string
196- rootless bool
197- expectError bool
198- }{
199- {
200- name: "custom podman socket",
201- socketPath: "/custom/podman.sock",
202- rootless: false,
203- expectError: true, // Will fail because socket doesn't exist
204- },
205- {
206- name: "rootless podman socket",
207- socketPath: "/custom/podman.sock",
208- rootless: true,
209- expectError: true, // Will fail because socket doesn't exist
210- },
211- }
212-
213- for _, tt := range tests {
214- t.Run(tt.name, func(t *testing.T) {
215- runtime, err := createPodmanRuntime(ctx, tt.socketPath, tt.rootless)
216-
217- if tt.expectError {
218- assert.Error(t, err)
219- assert.Nil(t, runtime)
220- } else {
221- assert.NoError(t, err)
222- assert.NotNil(t, runtime)
223- }
224- })
225- }
226-}
227-
228-func TestDetectRuntimeType(t *testing.T) {
229- tests := []struct {
230- name string
231- socketPath string
232- expected RuntimeType
233- }{
234- {
235- name: "docker socket",
236- socketPath: "/var/run/docker.sock",
237- expected: RuntimeDocker,
238- },
239- {
240- name: "docker unix socket",
241- socketPath: "unix:///var/run/docker.sock",
242- expected: RuntimeDocker,
243- },
244- {
245- name: "podman socket",
246- socketPath: "/run/podman/podman.sock",
247- expected: RuntimePodman,
248- },
249- {
250- name: "rootless podman socket",
251- socketPath: "/run/user/1000/podman/podman.sock",
252- expected: RuntimePodmanRootless,
253- },
254- {
255- name: "unknown socket defaults to docker",
256- socketPath: "/unknown/socket.sock",
257- expected: RuntimeDocker,
258- },
259- }
260-
261- for _, tt := range tests {
262- t.Run(tt.name, func(t *testing.T) {
263- result := detectRuntimeType(tt.socketPath)
264- assert.Equal(t, tt.expected, result)
265- })
266- }
267-}
268-
269-func TestGetDefaultSocketPaths(t *testing.T) {
270- paths := getDefaultSocketPaths()
271-
272- // Should return common socket paths
273- assert.True(t, len(paths) > 0)
274-
275- // Should contain at least one Docker socket path
276- found := false
277- for _, path := range paths {
278- if strings.Contains(path, "docker") {
279- found = true
280- break
281- }
282- }
283- assert.True(t, found, "Should contain a Docker socket path")
284-}
285-
286-func TestGetDefaultPodmanSocket(t *testing.T) {
287- socket := getDefaultPodmanSocket(false) // Regular mode
288-
289- // Should return a valid path
290- assert.NotEmpty(t, socket)
291-
292- // Should contain "podman" in the path
293- assert.Contains(t, socket, "podman")
294-
295- // Test rootless mode
296- rootlessSocket := getDefaultPodmanSocket(true)
297- assert.NotEmpty(t, rootlessSocket)
298- assert.Contains(t, rootlessSocket, "podman")
299-}
300-
301-func TestGetAvailableRuntimes(t *testing.T) {
302- runtimes := GetAvailableRuntimes()
303-
304- // Should return a slice of runtime info
305- assert.IsType(t, []RuntimeInfo{}, runtimes)
306-
307- // Should not be empty
308- assert.True(t, len(runtimes) > 0)
309-
310- // Each runtime should have basic info
311- for _, rt := range runtimes {
312- assert.NotEmpty(t, rt.Type)
313- assert.NotEmpty(t, rt.SocketPath)
314- // Version may be empty if runtime not actually available
315- }
316-}
317-
318-// Test socket existence checking (private function behavior via public interface)
319-func TestSocketExistenceChecking(t *testing.T) {
320- ctx := context.Background()
321-
322- // Create a temporary socket file
323- tmpDir := t.TempDir()
324- socketPath := filepath.Join(tmpDir, "test.sock")
325-
326- // Create an empty file to simulate a socket
327- file, err := os.Create(socketPath)
328- require.NoError(t, err)
329- file.Close()
330-
331- // Test with existing file (won't be a real socket, but tests file existence)
332- _, err = createDockerRuntime(ctx, socketPath)
333- // This will still fail because it's not a real Docker socket, but for a different reason
334- assert.Error(t, err)
335- // Error should not be about file not existing
336- assert.NotContains(t, err.Error(), "no such file")
337-}⏎̸
D internal/container/helpers.go +0 -87
@@ -1,87 +0,0 @@
1-package container
2-
3-import (
4- "crypto/sha256"
5- "fmt"
6- "regexp"
7- "strings"
8-)
9-
10-// generateVolumeName creates a deterministic volume name for a domain and path
11-func generateVolumeName(prefix, domain, path string) string {
12- // Sanitize domain name for use in volume name
13- sanitizedDomain := sanitizeDomainForVolume(domain)
14-
15- // Create a short hash of the path to handle long paths and special characters
16- hasher := sha256.New()
17- hasher.Write([]byte(path))
18- pathHash := fmt.Sprintf("%x", hasher.Sum(nil))[:8]
19-
20- return fmt.Sprintf("%s-%s-%s", prefix, sanitizedDomain, pathHash)
21-}
22-
23-// sanitizeDomainForVolume converts domain to volume-safe format
24-func sanitizeDomainForVolume(domain string) string {
25- // Replace dots and other special characters with hyphens
26- reg := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
27- sanitized := reg.ReplaceAllString(domain, "-")
28-
29- // Remove leading/trailing hyphens and convert to lowercase
30- sanitized = strings.Trim(sanitized, "-")
31- sanitized = strings.ToLower(sanitized)
32-
33- return sanitized
34-}
35-
36-// normalizeImageRef normalizes image references for comparison
37-func normalizeImageRef(imageRef string) string {
38- // Split image and tag
39- parts := strings.Split(imageRef, ":")
40- image := parts[0]
41- tag := "latest"
42- if len(parts) > 1 {
43- tag = parts[1]
44- }
45-
46- // Normalize Docker Hub references
47- if !strings.Contains(image, "/") {
48- // Official library image (e.g., "nginx" -> "docker.io/library/nginx")
49- image = "docker.io/library/" + image
50- } else if strings.Count(image, "/") == 1 && !strings.Contains(strings.Split(image, "/")[0], ".") {
51- // User repository (e.g., "user/repo" -> "docker.io/user/repo")
52- image = "docker.io/" + image
53- }
54-
55- return image + ":" + tag
56-}
57-
58-// mergeEnvironmentVariables merges Dockerfile ENV directives with user-provided env vars
59-// User-provided env vars take precedence over Dockerfile ENV directives
60-func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
61- // Create a map to store environment variables by key
62- envMap := make(map[string]string)
63-
64- // First, add Dockerfile ENV directives
65- for _, env := range dockerfileEnv {
66- parts := strings.SplitN(env, "=", 2)
67- if len(parts) == 2 {
68- envMap[parts[0]] = parts[1]
69- }
70- }
71-
72- // Then, add user-provided env vars (these override Dockerfile ENV)
73- for _, env := range userEnv {
74- parts := strings.SplitN(env, "=", 2)
75- if len(parts) == 2 {
76- envMap[parts[0]] = parts[1]
77- }
78- }
79-
80- // Convert back to slice format
81- var result []string
82- for key, value := range envMap {
83- result = append(result, fmt.Sprintf("%s=%s", key, value))
84- }
85-
86- return result
87-}
D internal/container/helpers_test.go +0 -198
@@ -1,198 +0,0 @@
1-package container
2-
3-import (
4- "strings"
5- "testing"
6-
7- "github.com/stretchr/testify/assert"
8-)
9-
10-func TestNormalizeImageRef(t *testing.T) {
11- testCases := []struct {
12- name string
13- input string
14- expected string
15- }{
16- {
17- name: "simple image with tag",
18- input: "nginx:latest",
19- expected: "docker.io/library/nginx:latest",
20- },
21- {
22- name: "image without tag",
23- input: "nginx",
24- expected: "docker.io/library/nginx:latest",
25- },
26- {
27- name: "registry with image and tag",
28- input: "registry.example.com/nginx:v1.0",
29- expected: "registry.example.com/nginx:v1.0",
30- },
31- {
32- name: "dockerhub image with user",
33- input: "user/repo:tag",
34- expected: "docker.io/user/repo:tag",
35- },
36- {
37- name: "official library image without tag",
38- input: "postgres",
39- expected: "docker.io/library/postgres:latest",
40- },
41- }
42-
43- for _, tc := range testCases {
44- t.Run(tc.name, func(t *testing.T) {
45- result := normalizeImageRef(tc.input)
46- assert.Equal(t, tc.expected, result)
47- })
48- }
49-}
50-
51-func TestGenerateVolumeName(t *testing.T) {
52- testCases := []struct {
53- name string
54- prefix string
55- domain string
56- path string
57- }{
58- {
59- name: "basic volume name",
60- prefix: "gordon",
61- domain: "app.example.com",
62- path: "/data",
63- },
64- {
65- name: "complex path",
66- prefix: "test",
67- domain: "my-app.domain.com",
68- path: "/var/www/html/uploads",
69- },
70- }
71-
72- for _, tc := range testCases {
73- t.Run(tc.name, func(t *testing.T) {
74- result := generateVolumeName(tc.prefix, tc.domain, tc.path)
75-
76- // Check that it starts with prefix
77- assert.True(t, strings.HasPrefix(result, tc.prefix+"-"))
78-
79- // Check that it contains sanitized domain
80- sanitizedDomain := strings.ReplaceAll(strings.ToLower(tc.domain), ".", "-")
81- assert.Contains(t, result, sanitizedDomain)
82-
83- // Volume names should be consistent for same inputs
84- result2 := generateVolumeName(tc.prefix, tc.domain, tc.path)
85- assert.Equal(t, result, result2)
86-
87- // Volume names should be different for different paths
88- result3 := generateVolumeName(tc.prefix, tc.domain, tc.path+"/different")
89- assert.NotEqual(t, result, result3)
90- })
91- }
92-}
93-
94-func TestMergeEnvironmentVariables(t *testing.T) {
95- testCases := []struct {
96- name string
97- dockerfileEnv []string
98- userEnv []string
99- expected []string
100- }{
101- {
102- name: "user env overrides dockerfile env",
103- dockerfileEnv: []string{"NODE_ENV=development", "PORT=3000"},
104- userEnv: []string{"NODE_ENV=production", "API_KEY=secret"},
105- expected: []string{"NODE_ENV=production", "PORT=3000", "API_KEY=secret"},
106- },
107- {
108- name: "only dockerfile env",
109- dockerfileEnv: []string{"NODE_ENV=development", "PORT=3000"},
110- userEnv: []string{},
111- expected: []string{"NODE_ENV=development", "PORT=3000"},
112- },
113- {
114- name: "only user env",
115- dockerfileEnv: []string{},
116- userEnv: []string{"API_KEY=secret", "DEBUG=true"},
117- expected: []string{"API_KEY=secret", "DEBUG=true"},
118- },
119- {
120- name: "empty environment",
121- dockerfileEnv: []string{},
122- userEnv: []string{},
123- expected: []string{},
124- },
125- }
126-
127- for _, tc := range testCases {
128- t.Run(tc.name, func(t *testing.T) {
129- result := mergeEnvironmentVariables(tc.dockerfileEnv, tc.userEnv)
130- assert.Equal(t, len(tc.expected), len(result))
131-
132- // Convert to map for easier comparison
133- resultMap := make(map[string]string)
134- for _, env := range result {
135- parts := splitEnvVar(env)
136- if len(parts) == 2 {
137- resultMap[parts[0]] = parts[1]
138- }
139- }
140-
141- expectedMap := make(map[string]string)
142- for _, env := range tc.expected {
143- parts := splitEnvVar(env)
144- if len(parts) == 2 {
145- expectedMap[parts[0]] = parts[1]
146- }
147- }
148-
149- assert.Equal(t, expectedMap, resultMap)
150- })
151- }
152-}
153-
154-// Helper function to split environment variable string
155-func splitEnvVar(env string) []string {
156- for i, c := range env {
157- if c == '=' {
158- return []string{env[:i], env[i+1:]}
159- }
160- }
161- return []string{env}
162-}
163-
164-func TestSanitizeDomainForVolume(t *testing.T) {
165- testCases := []struct {
166- name string
167- input string
168- expected string
169- }{
170- {
171- name: "basic domain",
172- input: "app.example.com",
173- expected: "app-example-com",
174- },
175- {
176- name: "domain with port",
177- input: "localhost:3000",
178- expected: "localhost-3000",
179- },
180- {
181- name: "domain with special chars",
182- input: "my_app@example.com",
183- expected: "my-app-example-com",
184- },
185- {
186- name: "uppercase domain",
187- input: "API.EXAMPLE.COM",
188- expected: "api-example-com",
189- },
190- }
191-
192- for _, tc := range testCases {
193- t.Run(tc.name, func(t *testing.T) {
194- result := sanitizeDomainForVolume(tc.input)
195- assert.Equal(t, tc.expected, result)
196- })
197- }
198-}⏎̸
D internal/container/interfaces.go +0 -29
@@ -1,29 +0,0 @@
1-package container
2-
3-import (
4- "context"
5-
6- "gordon/internal/config"
7- "gordon/pkg/runtime"
8-)
9-
10-// ManagerInterface defines the interface for container management operations
11-// This interface is used to enable testing with mocks
12-type ManagerInterface interface {
13- DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error)
14- StopContainer(ctx context.Context, containerID string) error
15- RemoveContainer(ctx context.Context, containerID string, force bool) error
16- ListContainers() map[string]*runtime.Container
17- GetContainer(domain string) (*runtime.Container, bool)
18- StopContainerByDomain(ctx context.Context, domain string) error
19- Runtime() runtime.Runtime
20- HealthCheck(ctx context.Context) map[string]bool
21- SyncContainers(ctx context.Context) error
22- GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error)
23- GetNetworkForApp(domain string) string
24- AutoStartContainers(ctx context.Context) error
25- StopAllManagedContainers(ctx context.Context) error
26-}
27-
28-// Ensure Manager implements the interface
29-var _ ManagerInterface = (*Manager)(nil)⏎̸
D internal/container/manager.go +0 -1106 collapsed to keep the page light
D internal/container/manager_autostart_test.go +0 -253 collapsed to keep the page light
D internal/container/manager_deploy_test.go +0 -394 collapsed to keep the page light
D internal/container/manager_network_test.go +0 -421 collapsed to keep the page light
D internal/container/manager_operations_test.go +0 -517 collapsed to keep the page light
D internal/container/manager_test.go +0 -183 collapsed to keep the page light
D internal/container/manager_volume_test.go +0 -388 collapsed to keep the page light
D internal/container/test_manager.go +0 -35
@@ -1,35 +0,0 @@
1-package container
2-
3-import (
4- "gordon/internal/config"
5- "gordon/internal/env"
6- "gordon/internal/logging"
7- "gordon/pkg/runtime"
8-)
9-
10-// NewManagerWithRuntime creates a manager with an injected runtime for testing
11-func NewManagerWithRuntime(cfg *config.Config, rt runtime.Runtime) (*Manager, error) {
12- // Create environment loader
13- envLoader := env.NewLoader(cfg)
14-
15- // Register secret providers based on config
16- for _, providerName := range cfg.Env.Providers {
17- switch providerName {
18- case "pass":
19- envLoader.RegisterSecretProvider("pass", env.NewPassProvider())
20- case "sops":
21- envLoader.RegisterSecretProvider("sops", env.NewSopsProvider())
22- }
23- }
24-
25- // Create log manager
26- logManager := logging.NewLogManager(cfg, rt)
27-
28- return &Manager{
29- runtime: rt,
30- config: cfg,
31- containers: make(map[string]*runtime.Container),
32- envLoader: envLoader,
33- logManager: logManager,
34- }, nil
35-}⏎̸
A internal/domain/auth.go +54 -0
@@ -0,0 +1,54 @@
1+// Package domain contains pure business types without external dependencies.
2+package domain
3+
4+import "time"
5+
6+// AuthType represents the type of authentication configured for the registry.
7+type AuthType string
8+
9+const (
10+ // AuthTypePassword uses bcrypt-hashed password authentication.
11+ AuthTypePassword AuthType = "password"
12+ // AuthTypeToken uses JWT token-based authentication.
13+ AuthTypeToken AuthType = "token"
14+)
15+
16+// Token represents a generated authentication token stored in the secrets backend.
17+type Token struct {
18+ ID string
19+ Subject string
20+ Scopes []string
21+ IssuedAt time.Time
22+ ExpiresAt time.Time // Zero value means never expires
23+ Revoked bool
24+}
25+
26+// TokenClaims represents the JWT claims for a token.
27+type TokenClaims struct {
28+ ID string `json:"jti"`
29+ Subject string `json:"sub"`
30+ Scopes []string `json:"scopes"`
31+ IssuedAt int64 `json:"iat"`
32+ ExpiresAt int64 `json:"exp,omitempty"` // 0 means never expires
33+ Issuer string `json:"iss"`
34+}
35+
36+// IsExpired checks if the token has expired.
37+func (t *Token) IsExpired() bool {
38+ if t.ExpiresAt.IsZero() {
39+ return false // Never expires
40+ }
41+ return time.Now().After(t.ExpiresAt)
42+}
43+
44+// SecretsBackend represents the type of secrets storage backend.
45+type SecretsBackend string
46+
47+const (
48+ // SecretsBackendPass uses the pass password manager.
49+ SecretsBackendPass SecretsBackend = "pass"
50+ // SecretsBackendSops uses SOPS for encrypted secrets.
51+ SecretsBackendSops SecretsBackend = "sops"
52+ // SecretsBackendUnsafe stores secrets in plain text (development only).
53+ SecretsBackendUnsafe SecretsBackend = "unsafe"
54+)
A internal/domain/container.go +50 -0
@@ -0,0 +1,50 @@
1+// Package domain contains pure business types without external dependencies.
2+// These types are used throughout the application and have no tags or framework dependencies.
3+package domain
4+
5+// Container represents a running container in the system.
6+type Container struct {
7+ ID string
8+ Image string
9+ Name string
10+ Status string
11+ Ports []int
12+ Labels map[string]string
13+}
14+
15+// NetworkInfo represents network configuration and state.
16+type NetworkInfo struct {
17+ ID string
18+ Name string
19+ Driver string
20+ Containers []string
21+ Labels map[string]string
22+}
23+
24+// ContainerConfig holds configuration for creating a container.
25+type ContainerConfig struct {
26+ Image string
27+ Name string
28+ Env []string
29+ Ports []int
30+ Labels map[string]string
31+ WorkingDir string
32+ Cmd []string
33+ AutoRemove bool
34+ Volumes map[string]string // map[containerPath]volumeName
35+ NetworkMode string // Network to join
36+ Hostname string // Container hostname for DNS
37+ Aliases []string // Additional network aliases
38+}
39+
40+// ContainerStatus represents the current state of a container.
41+type ContainerStatus string
42+
43+const (
44+ ContainerStatusRunning ContainerStatus = "running"
45+ ContainerStatusStopped ContainerStatus = "stopped"
46+ ContainerStatusCreated ContainerStatus = "created"
47+ ContainerStatusExited ContainerStatus = "exited"
48+ ContainerStatusPaused ContainerStatus = "paused"
49+ ContainerStatusUnknown ContainerStatus = "unknown"
50+)
A internal/domain/errors.go +57 -0 collapsed to keep the page light
A internal/domain/event.go +77 -0 collapsed to keep the page light
A internal/domain/event_test.go +58 -0 collapsed to keep the page light
A internal/domain/labels.go +16 -0
@@ -0,0 +1,16 @@
1+package domain
2+
3+// Label keys used by Gordon for container and image metadata.
4+const (
5+ // Container labels
6+ LabelDomain = "gordon.domain"
7+ LabelImage = "gordon.image"
8+ LabelManaged = "gordon.managed"
9+ LabelRoute = "gordon.route"
10+ LabelAttachment = "gordon.attachment"
11+ LabelAttachedTo = "gordon.attached-to"
12+ LabelCreated = "gordon.created"
13+
14+ // Image labels (set in Dockerfile)
15+ LabelProxyPort = "gordon.proxy.port"
16+)
A internal/domain/registry.go +36 -0 collapsed to keep the page light
A internal/domain/route.go +23 -0 collapsed to keep the page light
D internal/env/loader.go +0 -294 collapsed to keep the page light
D internal/env/loader_test.go +0 -488 collapsed to keep the page light
D internal/env/providers.go +0 -104
@@ -1,104 +0,0 @@
1-package env
2-
3-import (
4- "context"
5- "fmt"
6- "os/exec"
7- "strings"
8- "time"
9-
10- "github.com/rs/zerolog/log"
11-)
12-
13-type SecretProvider interface {
14- GetSecret(path string) (string, error)
15- Name() string
16-}
17-
18-type PassProvider struct {
19- timeout time.Duration
20-}
21-
22-func NewPassProvider() *PassProvider {
23- return &PassProvider{
24- timeout: 10 * time.Second,
25- }
26-}
27-
28-func (p *PassProvider) Name() string {
29- return "pass"
30-}
31-
32-func (p *PassProvider) GetSecret(path string) (string, error) {
33- ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
34- defer cancel()
35-
36- cmd := exec.CommandContext(ctx, "pass", "show", path)
37- output, err := cmd.Output()
38- if err != nil {
39- if exitError, ok := err.(*exec.ExitError); ok {
40- return "", fmt.Errorf("pass command failed: %s", string(exitError.Stderr))
41- }
42- return "", fmt.Errorf("failed to execute pass command: %w", err)
43- }
44-
45- secret := strings.TrimSpace(string(output))
46- if secret == "" {
47- return "", fmt.Errorf("empty secret returned from pass for path: %s", path)
48- }
49-
50- log.Debug().
51- Str("path", path).
52- Msg("Successfully retrieved secret from pass")
53-
54- return secret, nil
55-}
56-
57-type SopsProvider struct {
58- timeout time.Duration
59-}
60-
61-func NewSopsProvider() *SopsProvider {
62- return &SopsProvider{
63- timeout: 10 * time.Second,
64- }
65-}
66-
67-func (s *SopsProvider) Name() string {
68- return "sops"
69-}
70-
71-func (s *SopsProvider) GetSecret(path string) (string, error) {
72- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
73- defer cancel()
74-
75- // Extract key from path format: file.yaml:key.nested.path
76- parts := strings.SplitN(path, ":", 2)
77- if len(parts) != 2 {
78- return "", fmt.Errorf("sops path must be in format 'file:key', got: %s", path)
79- }
80-
81- filePath := parts[0]
82- keyPath := parts[1]
83-
84- // Use sops -d --extract to get specific key
85- cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), filePath)
86- output, err := cmd.Output()
87- if err != nil {
88- if exitError, ok := err.(*exec.ExitError); ok {
89- return "", fmt.Errorf("sops command failed: %s", string(exitError.Stderr))
90- }
91- return "", fmt.Errorf("failed to execute sops command: %w", err)
92- }
93-
94- secret := strings.TrimSpace(string(output))
95- if secret == "" {
96- return "", fmt.Errorf("empty secret returned from sops for path: %s", path)
97- }
98-
99- log.Debug().
100- Str("path", path).
101- Msg("Successfully retrieved secret from sops")
102-
103- return secret, nil
104-}⏎̸
D internal/env/providers_test.go +0 -248 collapsed to keep the page light
D internal/events/bus.go +0 -169 collapsed to keep the page light
D internal/events/bus_test.go +0 -337 collapsed to keep the page light
D internal/events/handlers.go +0 -393 collapsed to keep the page light
D internal/events/handlers_test.go +0 -852 collapsed to keep the page light
D internal/events/rollback_handler.go +0 -188 collapsed to keep the page light
D internal/events/types.go +0 -48 collapsed to keep the page light
D internal/logging/collector.go +0 -236 collapsed to keep the page light
D internal/logging/collector_test.go +0 -597 collapsed to keep the page light
D internal/logging/setup.go +0 -161 collapsed to keep the page light
D internal/logging/setup_test.go +0 -381 collapsed to keep the page light
D internal/middleware/auth.go +0 -76 collapsed to keep the page light
D internal/middleware/auth_test.go +0 -301 collapsed to keep the page light
D internal/middleware/logging_test.go +0 -541 collapsed to keep the page light
D internal/proxy/server.go +0 -296 collapsed to keep the page light
D internal/proxy/server_test.go +0 -1101 collapsed to keep the page light
D internal/registry/manifest.go +0 -4
@@ -1,4 +0,0 @@
1-package registry
2-
3-// This file is deprecated - manifest processing has been moved to pkg/manifest
4-// to avoid circular import issues. This file will be removed in a future version.⏎̸
D internal/registry/mocks/storage.go +0 -106 collapsed to keep the page light
D internal/registry/server_test.go +0 -614 collapsed to keep the page light
D internal/registry/storage.go +0 -503 collapsed to keep the page light
D internal/registry/storage_test.go +0 -466 collapsed to keep the page light
M internal/testutils/helpers.go +1 -1
@@ -80,4 +80,4 @@ port = 8080`
8080 default:
8181 return content
8282 }
83-}⏎̸
83+}
A internal/usecase/auth/service.go +299 -0 collapsed to keep the page light
A internal/usecase/auth/service_test.go +468 -0 collapsed to keep the page light
A internal/usecase/config/service.go +382 -0 collapsed to keep the page light
A internal/usecase/config/service_test.go +381 -0 collapsed to keep the page light
A internal/usecase/container/events.go +267 -0 collapsed to keep the page light
A internal/usecase/container/events_test.go +448 -0 collapsed to keep the page light
A internal/usecase/container/service.go +1098 -0 collapsed to keep the page light
A internal/usecase/container/service_test.go +713 -0 collapsed to keep the page light
A internal/usecase/proxy/events.go +63 -0 collapsed to keep the page light
A internal/usecase/proxy/service.go +348 -0 collapsed to keep the page light
A internal/usecase/proxy/service_test.go +274 -0 collapsed to keep the page light
A internal/usecase/registry/service.go +229 -0 collapsed to keep the page light
A internal/usecase/registry/service_test.go +431 -0 collapsed to keep the page light
M main.go +5 -5
@@ -3,7 +3,7 @@ package main
33 import (
44 "os"
55
6- "gordon/cmd"
6+ "gordon/internal/adapters/in/cli"
77 )
88
99 // Build information set by goreleaser via ldflags
@@ -15,9 +15,9 @@ var (
1515
1616 func main() {
1717 // Set version information for the CLI
18- cmd.SetVersionInfo(version, commit, date)
19-
20- if err := cmd.Execute(); err != nil {
18+ cli.SetVersionInfo(version, commit, date)
19+
20+ if err := cli.NewRootCmd().Execute(); err != nil {
2121 os.Exit(1)
2222 }
23-}⏎̸
23+}
M pkg/manifest/annotations.go +6 -7 collapsed to keep the page light
M pkg/manifest/annotations_test.go +27 -27 collapsed to keep the page light
M pkg/manifest/types.go +19 -19 collapsed to keep the page light
M pkg/manifest/types_test.go +8 -8 collapsed to keep the page light
M pkg/runtime/interface.go +20 -20 collapsed to keep the page light

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close