Merge pull request #7 from bnema/refactor/clean-architecture
refactor: clean architecture implementation
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 @@ | |||
| 5 | 5 | ||
| 6 | 6 | version: 2 | |
| 7 | 7 | updates: | |
| 8 | - | - package-ecosystem: "" # See documentation for possible values | |
| 8 | + | - package-ecosystem: "gomod" # See documentation for possible values | |
| 9 | 9 | directory: "/" # Location of package manifests | |
| 10 | 10 | schedule: | |
| 11 | 11 | 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 | + | ||
| 1 | 3 | run: | |
| 2 | 4 | timeout: 5m | |
| 3 | 5 | tests: true | |
| 4 | 6 | ||
| 5 | 7 | linters: | |
| 6 | 8 | enable: | |
| 7 | - | - gofmt | |
| 8 | 9 | - govet | |
| 9 | 10 | - errcheck | |
| 10 | 11 | - staticcheck | |
| 11 | 12 | - unused | |
| 12 | - | - gosimple | |
| 13 | 13 | - ineffassign | |
| 14 | - | - typecheck | |
| 15 | 14 | - misspell | |
| 16 | 15 | - gosec | |
| 17 | 16 | - unconvert | |
| 18 | 17 | - 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 | |
| 19 | 61 | ||
| 20 | - | linters-settings: | |
| 21 | - | gocyclo: | |
| 22 | - | min-complexity: 15 | |
| 62 | + | formatters: | |
| 63 | + | enable: | |
| 64 | + | - gofmt | |
| 23 | 65 | ||
| 24 | 66 | issues: | |
| 25 | - | exclude-rules: | |
| 26 | - | - path: _test\.go | |
| 27 | - | linters: | |
| 28 | - | - gocyclo | |
| 29 | - | - errcheck | |
| 30 | - | - dupl | |
| 31 | - | - gosec | |
| 32 | - | ||
| 33 | 67 | max-same-issues: 50 | |
| 34 | 68 | ||
| 35 | 69 | severity: | |
| 36 | - | default-severity: error | |
| 37 | - | case-sensitive: false | |
| 70 | + | default: error | |
M .goreleaser.yaml +1 -1
| @@ -26,7 +26,7 @@ builds: | |||
| 26 | 26 | # docker_manifests: | |
| 27 | 27 | ||
| 28 | 28 | archives: | |
| 29 | - | - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" | |
| 29 | + | - name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" | |
| 30 | 30 | ||
| 31 | 31 | changelog: | |
| 32 | 32 | 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 \ | |||
| 21 | 21 | ARCHS := amd64 arm64 | |
| 22 | 22 | ||
| 23 | 23 | # 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 | |
| 25 | 27 | ||
| 26 | 28 | # Default target | |
| 27 | 29 | all: build | |
| 28 | 30 | ||
| 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) | |
| 31 | 83 | @echo "Building Go binaries..." | |
| 32 | 84 | @mkdir -p $(DIST_DIR) | |
| 33 | 85 | @rm -f $(DIST_DIR)/* | |
| @@ -36,8 +88,14 @@ build: | |||
| 36 | 88 | @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go | |
| 37 | 89 | @echo "Go binaries built successfully" | |
| 38 | 90 | ||
| 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 | |
| 41 | 99 | @echo "Cleaning up dangling images..." | |
| 42 | 100 | @$(ENGINE) image prune -f | |
| 43 | 101 | ||
| @@ -69,8 +127,7 @@ build-push: build | |||
| 69 | 127 | ||
| 70 | 128 | @echo "Script completed successfully." | |
| 71 | 129 | ||
| 72 | - | # Create dev GitHub release (separate from GoReleaser) | |
| 73 | - | dev-release: build | |
| 130 | + | dev-release: build ## Create dev GitHub release | |
| 74 | 131 | @echo "Creating dev GitHub release..." | |
| 75 | 132 | @if [ -z "$(shell which gh)" ]; then \ | |
| 76 | 133 | echo "Error: GitHub CLI (gh) is not installed. Please install it first."; \ | |
| @@ -92,27 +149,15 @@ dev-release: build | |||
| 92 | 149 | @echo "" | |
| 93 | 150 | @echo "🔗 Release page: https://github.com/bnema/gordon/releases/tag/$(DEV_TAG)" | |
| 94 | 151 | ||
| 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 | |
| 103 | 153 | ||
| 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 | |
| 115 | 155 | @echo "Cleaning up..." | |
| 116 | 156 | @rm -rf $(DIST_DIR) | |
| 157 | + | @echo "Cleanup completed." | |
| 158 | + | ||
| 159 | + | clean-all: clean clean-test ## Clean all artifacts including test files | |
| 117 | 160 | @$(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 @@ | |||
| 1 | 1 | # Gordon | |
| 2 | 2 | ||
| 3 | - | > Deploy containers to your VPS in seconds, not hours. One push, zero complexity. | |
| 3 | + | [](https://www.gnu.org/licenses/gpl-3.0) | |
| 4 | + | [](https://goreportcard.com/report/github.com/bnema/gordon) | |
| 4 | 5 | ||
| 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. | |
| 10 | 7 | ||
| 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) | |
| 12 | 11 | ||
| 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 | + | --- | |
| 32 | 13 | ||
| 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. | |
| 34 | 15 | ||
| 35 | 16 | ```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 | |
| 45 | 20 | ``` | |
| 46 | 21 | ||
| 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 | |
| 48 | 30 | ||
| 49 | - | ## Quick Start (5 minutes) | |
| 31 | + | ## Quick Start | |
| 50 | 32 | ||
| 51 | 33 | ### Prerequisites | |
| 52 | 34 | - Ubuntu/Debian VPS with root access | |
| 53 | 35 | - 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). | |
| 55 | 39 | ||
| 56 | 40 | ### Installation | |
| 57 | 41 | ||
| 58 | 42 | ```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 | |
| 64 | 46 | chmod +x gordon | |
| 65 | 47 | sudo mv gordon /usr/local/bin/ | |
| 66 | 48 | ||
| 67 | - | # Create initial config | |
| 49 | + | # Start Gordon (generates config on first run) | |
| 68 | 50 | gordon start | |
| 69 | - | # Press Ctrl+C after config is created | |
| 70 | 51 | ``` | |
| 71 | 52 | ||
| 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. | |
| 73 | 56 | ||
| 74 | 57 | ## Core Concepts | |
| 75 | 58 | ||
| @@ -79,13 +62,13 @@ Your dev machine likely has 8-16 cores and 16-32GB RAM. Your VPS has 1-2 cores a | |||
| 79 | 62 | ### Push-to-Deploy Workflow | |
| 80 | 63 | ```bash | |
| 81 | 64 | # 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 | |
| 84 | 67 | # Visit https://app.mydomain.com | |
| 85 | 68 | ||
| 86 | 69 | # 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 | |
| 89 | 72 | # Zero-downtime update applied automatically | |
| 90 | 73 | ``` | |
| 91 | 74 | ||
| @@ -99,10 +82,28 @@ FROM node:18 | |||
| 99 | 82 | VOLUME ["/app/uploads", "/app/data"] # Multiple volumes supported | |
| 100 | 83 | ``` | |
| 101 | 84 | ||
| 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 | + | ||
| 102 | 103 | ### Environment Configuration | |
| 103 | 104 | Gordon automatically creates and loads env files based on domain names: | |
| 104 | 105 | ```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 | |
| 106 | 107 | # Just add your variables: | |
| 107 | 108 | DATABASE_URL=postgresql://localhost:5432/myapp | |
| 108 | 109 | API_KEY=sk-1234567890 | |
| @@ -123,7 +124,7 @@ Gordon also supports network groups, see [examples/](examples/) for advanced usa | |||
| 123 | 124 | - **Volumes**: Automatically created from Dockerfile VOLUME directives, persist across updates | |
| 124 | 125 | ||
| 125 | 126 | ### 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/` | |
| 127 | 128 | - **Rotation**: Logs rotate at 100MB, keeping 3 backups for 28 days | |
| 128 | 129 | ||
| 129 | 130 | ## Configuration | |
| @@ -135,9 +136,19 @@ Gordon uses a single TOML file for all configuration: | |||
| 135 | 136 | [server] | |
| 136 | 137 | registry_domain = "registry.mydomain.com" # Required | |
| 137 | 138 | ||
| 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) | |
| 138 | 144 | [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 | |
| 141 | 152 | ||
| 142 | 153 | [routes] | |
| 143 | 154 | "app.mydomain.com" = "myapp:latest" # Domain → Image mapping | |
| @@ -151,49 +162,122 @@ password = "your-secure-password" | |||
| 151 | 162 | ||
| 152 | 163 | See [examples/](examples/) for advanced configurations including network groups and more. | |
| 153 | 164 | ||
| 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) | |
| 156 | 223 | ||
| 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 | |
| 158 | 227 | ```bash | |
| 159 | 228 | # Update system | |
| 160 | 229 | sudo apt update && sudo apt upgrade -y | |
| 161 | 230 | ||
| 162 | - | # Install podman and firewall | |
| 163 | - | sudo apt install -y podman ufw | |
| 231 | + | # Install podman and firewalld | |
| 232 | + | sudo apt install -y podman firewalld | |
| 164 | 233 | ||
| 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 | |
| 169 | 236 | ``` | |
| 170 | 237 | ||
| 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 | + | ||
| 172 | 242 | ```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 | |
| 176 | 250 | ||
| 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 | |
| 180 | 259 | ``` | |
| 181 | 260 | ||
| 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 | |
| 183 | 269 | ```bash | |
| 184 | 270 | # Enable user namespaces | |
| 185 | 271 | echo 'user.max_user_namespaces=28633' | sudo tee -a /etc/sysctl.conf | |
| 186 | 272 | sudo sysctl -p | |
| 187 | 273 | ||
| 188 | - | # Setup subuid/subgid | |
| 274 | + | # Setup subuid/subgid for your user | |
| 189 | 275 | sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER | |
| 190 | 276 | ||
| 191 | 277 | # Enable podman socket | |
| 192 | 278 | systemctl --user enable --now podman.socket | |
| 193 | - | ``` | |
| 194 | 279 | ||
| 195 | - | ### 4. Registry Configuration | |
| 196 | - | ```bash | |
| 280 | + | # Allow localhost registry (podman config) | |
| 197 | 281 | mkdir -p ~/.config/containers | |
| 198 | 282 | cat > ~/.config/containers/registries.conf <<EOF | |
| 199 | 283 | [registries.insecure] | |
| @@ -201,9 +285,28 @@ registries = ['localhost:5000'] | |||
| 201 | 285 | EOF | |
| 202 | 286 | ``` | |
| 203 | 287 | ||
| 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 | |
| 205 | 309 | ```bash | |
| 206 | - | # Create user service | |
| 207 | 310 | mkdir -p ~/.config/systemd/user | |
| 208 | 311 | cat > ~/.config/systemd/user/gordon.service <<EOF | |
| 209 | 312 | [Unit] | |
| @@ -215,19 +318,19 @@ Type=simple | |||
| 215 | 318 | Restart=always | |
| 216 | 319 | RestartSec=5 | |
| 217 | 320 | ExecStart=/usr/local/bin/gordon start | |
| 218 | - | WorkingDirectory=%h | |
| 219 | 321 | ||
| 220 | 322 | [Install] | |
| 221 | 323 | WantedBy=default.target | |
| 222 | 324 | EOF | |
| 223 | 325 | ||
| 224 | - | # Enable and start service | |
| 326 | + | # Enable and start | |
| 225 | 327 | systemctl --user daemon-reload | |
| 226 | 328 | systemctl --user enable --now gordon | |
| 227 | 329 | sudo loginctl enable-linger $USER | |
| 228 | 330 | ||
| 229 | - | # Verify it's running | |
| 331 | + | # Verify | |
| 230 | 332 | systemctl --user status gordon | |
| 333 | + | journalctl --user -u gordon -f # Watch logs | |
| 231 | 334 | ``` | |
| 232 | 335 | ||
| 233 | 336 | ## Deployment Strategies | |
| @@ -235,14 +338,14 @@ systemctl --user status gordon | |||
| 235 | 338 | ### Simple Deployment | |
| 236 | 339 | ```bash | |
| 237 | 340 | # Build locally | |
| 238 | - | podman build -t myapp . | |
| 341 | + | docker build -t myapp . | |
| 239 | 342 | ||
| 240 | 343 | # Test locally | |
| 241 | - | podman run -p 8080:8080 myapp | |
| 344 | + | docker run -p 8080:8080 myapp | |
| 242 | 345 | ||
| 243 | 346 | # 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 | |
| 246 | 349 | ||
| 247 | 350 | # Profit! | |
| 248 | 351 | ``` | |
| @@ -250,8 +353,8 @@ podman push registry.mydomain.com/myapp:latest | |||
| 250 | 353 | ### Versioned Deployment | |
| 251 | 354 | ```bash | |
| 252 | 355 | # 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 | |
| 255 | 358 | ||
| 256 | 359 | # Update route in gordon.toml | |
| 257 | 360 | # "app.mydomain.com" = "myapp:v1.2.0" | |
| @@ -261,10 +364,10 @@ podman push registry.mydomain.com/myapp:v1.2.0 | |||
| 261 | 364 | ```bash | |
| 262 | 365 | # Deploy with metadata | |
| 263 | 366 | 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 | |
| 268 | 371 | ``` | |
| 269 | 372 | ||
| 270 | 373 | ## Community | |
| @@ -274,19 +377,6 @@ Gordon is open source and welcomes contributions: | |||
| 274 | 377 | - [Suggest features](https://github.com/bnema/gordon/discussions) | |
| 275 | 378 | - [Submit PRs](https://github.com/bnema/gordon/pulls) | |
| 276 | 379 | ||
| 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 | - | ||
| 286 | 380 | ## License | |
| 287 | 381 | ||
| 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 | |
| 2 | 2 | ||
| 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: | |
| 4 | 4 | ||
| 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: | |
| 169 | 5 | ```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 | |
| 192 | 7 | ``` | |
| 193 | 8 | ||
| 194 | - | See [`logging.toml`](logging.toml) for comprehensive logging examples. | |
| 9 | + | ## Configurations | |
| 195 | 10 | ||
| 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 | | |
| 385 | 19 | ||
| 386 | - | See [`rollback-workflow-example.md`](rollback-workflow-example.md) for complete version deployment workflow documentation. | |
| 20 | + | ## Workflows | |
| 387 | 21 | ||
| 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 | | |
| 389 | 26 | ||
| 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 | |
| 394 | 28 | ||
| 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 | |||
| 9 | 9 | ssl_email = "dev@localhost" | |
| 10 | 10 | data_dir = "./dev-data" | |
| 11 | 11 | ||
| 12 | + | # Secrets backend for development | |
| 13 | + | [secrets] | |
| 14 | + | backend = "unsafe" # Plain text storage for local development | |
| 15 | + | ||
| 12 | 16 | [registry_auth] | |
| 13 | 17 | enabled = false # Disable auth for easier local development | |
| 14 | 18 | ||
| 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 | + | ||
| 15 | 25 | [auto_route] | |
| 16 | 26 | enabled = true # Enable automatic route creation from image names | |
| 17 | 27 | ||
| @@ -40,8 +50,7 @@ compress = true # Compress old logs | |||
| 40 | 50 | [env] | |
| 41 | 51 | # Directory where .env files are stored for each route | |
| 42 | 52 | dir = "./dev-data/env" | |
| 43 | - | # Secret providers (optional for development) | |
| 44 | - | providers = ["pass"] # For development secrets | |
| 53 | + | # Note: Secret providers use [secrets].backend configuration | |
| 45 | 54 | ||
| 46 | 55 | # Development routes using your domain | |
| 47 | 56 | [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" | |||
| 8 | 8 | runtime = "auto" | |
| 9 | 9 | data_dir = "./data" | |
| 10 | 10 | ||
| 11 | + | # Secrets backend | |
| 12 | + | [secrets] | |
| 13 | + | backend = "pass" # "pass", "sops", or "unsafe" | |
| 14 | + | ||
| 11 | 15 | [registry_auth] | |
| 12 | 16 | enabled = true | |
| 17 | + | type = "password" # or "token" | |
| 13 | 18 | username = "admin" | |
| 14 | - | password = "secure-password" | |
| 19 | + | password_hash = "gordon/registry/password_hash" # bcrypt hash in secrets backend | |
| 15 | 20 | ||
| 16 | 21 | # ============================================================================ | |
| 17 | 22 | # LOGGING CONFIGURATION EXAMPLES | |
M examples/minimal.toml +16 -1
| @@ -6,8 +6,23 @@ port = 8080 | |||
| 6 | 6 | registry_port = 5000 | |
| 7 | 7 | registry_domain = "registry.local" | |
| 8 | 8 | ||
| 9 | + | # Secrets backend (optional, defaults to "pass") | |
| 10 | + | # [secrets] | |
| 11 | + | # backend = "unsafe" # Use "unsafe" for local dev without pass/sops | |
| 12 | + | ||
| 9 | 13 | [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 | |
| 11 | 26 | ||
| 12 | 27 | # Optional: Enable basic logging | |
| 13 | 28 | # [logging] | |
M examples/production.toml +19 -4
| @@ -10,10 +10,26 @@ runtime = "auto" # auto-detects Docker/Podman | |||
| 10 | 10 | ssl_email = "devops@company.com" | |
| 11 | 11 | data_dir = "/var/lib/gordon" | |
| 12 | 12 | ||
| 13 | + | # Secrets backend for sensitive data | |
| 14 | + | [secrets] | |
| 15 | + | backend = "pass" # "pass" (recommended), "sops", or "unsafe" | |
| 16 | + | ||
| 13 | 17 | [registry_auth] | |
| 14 | 18 | 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 | |
| 17 | 33 | ||
| 18 | 34 | # Production logging configuration | |
| 19 | 35 | [logging] | |
| @@ -34,8 +50,7 @@ compress = true # Compress old logs to save space | |||
| 34 | 50 | [env] | |
| 35 | 51 | # Directory where .env files are stored for each route | |
| 36 | 52 | 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 | |
| 39 | 54 | ||
| 40 | 55 | # Production service routes | |
| 41 | 56 | [routes] | |
M examples/saas-multi-tenant.toml +12 -2
| @@ -9,10 +9,20 @@ runtime = "auto" # auto-detects Docker/Podman | |||
| 9 | 9 | ssl_email = "ops@saas-platform.com" | |
| 10 | 10 | data_dir = "/var/lib/gordon" | |
| 11 | 11 | ||
| 12 | + | # Secrets backend for secure credential storage | |
| 13 | + | [secrets] | |
| 14 | + | backend = "pass" # Production should use "pass" or "sops" | |
| 15 | + | ||
| 12 | 16 | [registry_auth] | |
| 13 | 17 | 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) | |
| 16 | 26 | ||
| 17 | 27 | # Multi-tenant customer routes | |
| 18 | 28 | [routes] | |
M examples/staging.toml +17 -2
| @@ -9,10 +9,25 @@ runtime = "auto" # auto-detects Docker/Podman | |||
| 9 | 9 | ssl_email = "devops@company.com" | |
| 10 | 10 | data_dir = "./data" | |
| 11 | 11 | ||
| 12 | + | # Secrets backend for staging | |
| 13 | + | [secrets] | |
| 14 | + | backend = "pass" # Use pass for secure secret storage | |
| 15 | + | ||
| 12 | 16 | [registry_auth] | |
| 13 | 17 | 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 | |
| 16 | 31 | ||
| 17 | 32 | # Staging logging configuration | |
| 18 | 33 | [logging] | |
M go.mod +31 -25
| @@ -1,18 +1,27 @@ | |||
| 1 | 1 | module gordon | |
| 2 | 2 | ||
| 3 | - | go 1.24.3 | |
| 3 | + | go 1.25.5 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | + | github.com/bnema/zerowrap v1.3.0 | |
| 6 | 7 | github.com/docker/docker v28.3.3+incompatible | |
| 7 | 8 | github.com/docker/go-connections v0.5.0 | |
| 9 | + | github.com/golang-jwt/jwt/v5 v5.3.0 | |
| 8 | 10 | 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 | |
| 13 | 16 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 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 | + | ||
| 16 | 25 | require ( | |
| 17 | 26 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 18 | 27 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| @@ -23,45 +32,42 @@ require ( | |||
| 23 | 32 | ||
| 24 | 33 | require ( | |
| 25 | 34 | 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 | |
| 27 | 36 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 28 | 37 | github.com/containerd/log v0.1.0 // indirect | |
| 29 | 38 | github.com/distribution/reference v0.6.0 // indirect | |
| 30 | 39 | github.com/docker/go-units v0.5.0 // indirect | |
| 31 | 40 | 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 | |
| 34 | 43 | 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 | |
| 36 | 45 | github.com/gogo/protobuf v1.3.2 // indirect | |
| 37 | 46 | 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 | |
| 40 | 49 | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 41 | 50 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |
| 42 | 51 | github.com/moby/term v0.5.2 // indirect | |
| 43 | 52 | github.com/morikuni/aec v1.0.0 // indirect | |
| 44 | 53 | github.com/opencontainers/go-digest v1.0.0 // indirect | |
| 45 | 54 | 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 | |
| 47 | 56 | 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 | |
| 53 | 61 | 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 | |
| 55 | 63 | 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 | |
| 57 | 65 | 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 | |
| 59 | 67 | 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 | |
| 65 | 71 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 66 | 72 | gotest.tools/v3 v3.5.2 // indirect | |
| 67 | 73 | ) | |
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. | |
| 1 | 2 | package middleware | |
| 2 | 3 | ||
| 3 | 4 | import ( | |
| 4 | 5 | "net/http" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| 7 | - | "gordon/internal/logging" | |
| 8 | - | "github.com/rs/zerolog/log" | |
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | // ResponseWriter wraps http.ResponseWriter to capture status code and bytes written | |
| 11 | + | // ResponseWriter wraps http.ResponseWriter to capture status code and bytes written. | |
| 12 | 12 | type ResponseWriter struct { | |
| 13 | 13 | http.ResponseWriter | |
| 14 | 14 | statusCode int | |
| 15 | 15 | bytes int | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | + | // NewResponseWriter creates a new wrapped response writer. | |
| 18 | 19 | func NewResponseWriter(w http.ResponseWriter) *ResponseWriter { | |
| 19 | 20 | return &ResponseWriter{ | |
| 20 | 21 | ResponseWriter: w, | |
| @@ -22,60 +23,68 @@ func NewResponseWriter(w http.ResponseWriter) *ResponseWriter { | |||
| 22 | 23 | } | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 26 | + | // WriteHeader captures the status code. | |
| 25 | 27 | func (rw *ResponseWriter) WriteHeader(code int) { | |
| 26 | 28 | rw.statusCode = code | |
| 27 | 29 | rw.ResponseWriter.WriteHeader(code) | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 32 | + | // Write captures bytes written. | |
| 30 | 33 | func (rw *ResponseWriter) Write(b []byte) (int, error) { | |
| 31 | 34 | n, err := rw.ResponseWriter.Write(b) | |
| 32 | 35 | rw.bytes += n | |
| 33 | 36 | return n, err | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 39 | + | // StatusCode returns the captured status code. | |
| 36 | 40 | func (rw *ResponseWriter) StatusCode() int { | |
| 37 | 41 | return rw.statusCode | |
| 38 | 42 | } | |
| 39 | 43 | ||
| 44 | + | // BytesWritten returns the number of bytes written. | |
| 40 | 45 | func (rw *ResponseWriter) BytesWritten() int { | |
| 41 | 46 | return rw.bytes | |
| 42 | 47 | } | |
| 43 | 48 | ||
| 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 | + | } | |
| 76 | 85 | } | |
| 77 | 86 | ||
| 78 | - | // getClientIP extracts the client IP from various headers | |
| 87 | + | // getClientIP extracts the client IP from various headers. | |
| 79 | 88 | func getClientIP(r *http.Request) string { | |
| 80 | 89 | // Check X-Forwarded-For header (comma-separated list, first is original client) | |
| 81 | 90 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| @@ -87,97 +96,63 @@ func getClientIP(r *http.Request) string { | |||
| 87 | 96 | } | |
| 88 | 97 | return xff | |
| 89 | 98 | } | |
| 90 | - | ||
| 99 | + | ||
| 91 | 100 | // Check X-Real-IP header | |
| 92 | 101 | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 93 | 102 | return xri | |
| 94 | 103 | } | |
| 95 | - | ||
| 104 | + | ||
| 96 | 105 | // Check X-Forwarded header (less common) | |
| 97 | 106 | if xf := r.Header.Get("X-Forwarded"); xf != "" { | |
| 98 | 107 | return xf | |
| 99 | 108 | } | |
| 100 | - | ||
| 109 | + | ||
| 101 | 110 | // Fallback to RemoteAddr | |
| 102 | 111 | return r.RemoteAddr | |
| 103 | 112 | } | |
| 104 | 113 | ||
| 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 { | |
| 107 | 116 | return func(next http.Handler) http.Handler { | |
| 108 | 117 | 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) | |
| 139 | 134 | }) | |
| 140 | 135 | } | |
| 141 | 136 | } | |
| 142 | 137 | ||
| 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. | |
| 164 | 139 | func CORS(next http.Handler) http.Handler { | |
| 165 | 140 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 166 | 141 | w.Header().Set("Access-Control-Allow-Origin", "*") | |
| 167 | 142 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") | |
| 168 | 143 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") | |
| 169 | - | ||
| 144 | + | ||
| 170 | 145 | // Handle preflight OPTIONS request | |
| 171 | 146 | if r.Method == "OPTIONS" { | |
| 172 | 147 | w.WriteHeader(http.StatusOK) | |
| 173 | 148 | return | |
| 174 | 149 | } | |
| 175 | - | ||
| 150 | + | ||
| 176 | 151 | next.ServeHTTP(w, r) | |
| 177 | 152 | }) | |
| 178 | 153 | } | |
| 179 | 154 | ||
| 180 | - | // Chain combines multiple middleware functions | |
| 155 | + | // Chain combines multiple middleware functions. | |
| 181 | 156 | func Chain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { | |
| 182 | 157 | return func(final http.Handler) http.Handler { | |
| 183 | 158 | for i := len(middlewares) - 1; i >= 0; i-- { | |
| @@ -185,4 +160,4 @@ func Chain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) ht | |||
| 185 | 160 | } | |
| 186 | 161 | return final | |
| 187 | 162 | } | |
| 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. | |
| 1 | 2 | package registry | |
| 2 | 3 | ||
| 3 | 4 | import ( | |
| 4 | - | "context" | |
| 5 | 5 | "crypto/sha256" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "fmt" | |
| @@ -10,131 +10,139 @@ import ( | |||
| 10 | 10 | "strconv" | |
| 11 | 11 | "strings" | |
| 12 | 12 | ||
| 13 | - | "gordon/internal/config" | |
| 14 | - | "gordon/internal/events" | |
| 15 | - | "gordon/internal/middleware" | |
| 16 | - | "gordon/pkg/manifest" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 17 | 14 | ||
| 18 | - | "github.com/rs/zerolog/log" | |
| 15 | + | "gordon/internal/boundaries/in" | |
| 16 | + | "gordon/internal/boundaries/out" | |
| 17 | + | "gordon/internal/domain" | |
| 18 | + | "gordon/pkg/manifest" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 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 | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 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, | |
| 36 | 41 | } | |
| 42 | + | } | |
| 37 | 43 | ||
| 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) | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 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) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 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 | + | ||
| 55 | 64 | path := r.URL.Path | |
| 56 | - | ||
| 65 | + | ||
| 57 | 66 | // Route manifest operations: /v2/{name}/manifests/{reference} | |
| 58 | 67 | if strings.Contains(path, "/manifests/") { | |
| 59 | - | s.handleManifestRoutes(w, r) | |
| 68 | + | h.handleManifestRoutes(w, r) | |
| 60 | 69 | return | |
| 61 | 70 | } | |
| 62 | - | ||
| 71 | + | ||
| 63 | 72 | // Route blob operations: /v2/{name}/blobs/{digest} | |
| 64 | 73 | if strings.Contains(path, "/blobs/") && !strings.Contains(path, "/uploads/") { | |
| 65 | - | s.handleBlobRoutes(w, r) | |
| 74 | + | h.handleBlobRoutes(w, r) | |
| 66 | 75 | return | |
| 67 | 76 | } | |
| 68 | - | ||
| 77 | + | ||
| 69 | 78 | // Route blob upload operations: /v2/{name}/blobs/uploads/ | |
| 70 | 79 | if strings.Contains(path, "/blobs/uploads/") { | |
| 71 | - | s.handleBlobUploadRoutes(w, r) | |
| 80 | + | h.handleBlobUploadRoutes(w, r) | |
| 72 | 81 | return | |
| 73 | 82 | } | |
| 74 | - | ||
| 83 | + | ||
| 75 | 84 | // Route tag list operations: /v2/{name}/tags/list | |
| 76 | 85 | if strings.Contains(path, "/tags/list") { | |
| 77 | - | s.handleTagListRoutes(w, r) | |
| 86 | + | h.handleTagListRoutes(w, r) | |
| 78 | 87 | return | |
| 79 | 88 | } | |
| 80 | - | ||
| 89 | + | ||
| 81 | 90 | // Base endpoint: /v2/ | |
| 82 | 91 | if path == "/v2/" { | |
| 83 | - | s.handleBase(w, r) | |
| 92 | + | h.handleBase(w, r) | |
| 84 | 93 | return | |
| 85 | 94 | } | |
| 86 | - | ||
| 95 | + | ||
| 87 | 96 | http.NotFound(w, r) | |
| 88 | 97 | } | |
| 89 | 98 | ||
| 90 | - | func (s *Server) handleManifestRoutes(w http.ResponseWriter, r *http.Request) { | |
| 99 | + | func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) { | |
| 91 | 100 | // Parse path: /v2/{name}/manifests/{reference} | |
| 92 | 101 | parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/") | |
| 93 | 102 | if len(parts) < 3 || parts[len(parts)-2] != "manifests" { | |
| 94 | 103 | http.NotFound(w, r) | |
| 95 | 104 | return | |
| 96 | 105 | } | |
| 97 | - | ||
| 106 | + | ||
| 98 | 107 | reference := parts[len(parts)-1] | |
| 99 | 108 | name := strings.Join(parts[:len(parts)-2], "/") | |
| 100 | - | ||
| 101 | - | // Set path values for compatibility with existing handlers | |
| 109 | + | ||
| 102 | 110 | r.SetPathValue("name", name) | |
| 103 | 111 | r.SetPathValue("reference", reference) | |
| 104 | - | ||
| 112 | + | ||
| 105 | 113 | switch r.Method { | |
| 106 | 114 | case "HEAD", "GET": | |
| 107 | - | s.handleGetManifest(w, r) | |
| 115 | + | h.handleGetManifest(w, r) | |
| 108 | 116 | case "PUT": | |
| 109 | - | s.handlePutManifest(w, r) | |
| 117 | + | h.handlePutManifest(w, r) | |
| 110 | 118 | default: | |
| 111 | 119 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 112 | 120 | } | |
| 113 | 121 | } | |
| 114 | 122 | ||
| 115 | - | func (s *Server) handleBlobRoutes(w http.ResponseWriter, r *http.Request) { | |
| 123 | + | func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) { | |
| 116 | 124 | // Parse path: /v2/{name}/blobs/{digest} | |
| 117 | 125 | parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/") | |
| 118 | 126 | if len(parts) < 3 || parts[len(parts)-2] != "blobs" { | |
| 119 | 127 | http.NotFound(w, r) | |
| 120 | 128 | return | |
| 121 | 129 | } | |
| 122 | - | ||
| 130 | + | ||
| 123 | 131 | digest := parts[len(parts)-1] | |
| 124 | 132 | name := strings.Join(parts[:len(parts)-2], "/") | |
| 125 | - | ||
| 133 | + | ||
| 126 | 134 | r.SetPathValue("name", name) | |
| 127 | 135 | r.SetPathValue("digest", digest) | |
| 128 | - | ||
| 136 | + | ||
| 129 | 137 | switch r.Method { | |
| 130 | 138 | case "HEAD", "GET": | |
| 131 | - | s.handleGetBlob(w, r) | |
| 139 | + | h.handleGetBlob(w, r) | |
| 132 | 140 | default: | |
| 133 | 141 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 134 | 142 | } | |
| 135 | 143 | } | |
| 136 | 144 | ||
| 137 | - | func (s *Server) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) { | |
| 145 | + | func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) { | |
| 138 | 146 | // Parse path: /v2/{name}/blobs/uploads/{uuid?} | |
| 139 | 147 | path := strings.TrimPrefix(r.URL.Path, "/v2/") | |
| 140 | 148 | uploadIndex := strings.Index(path, "/blobs/uploads/") | |
| @@ -142,17 +150,17 @@ func (s *Server) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) | |||
| 142 | 150 | http.NotFound(w, r) | |
| 143 | 151 | return | |
| 144 | 152 | } | |
| 145 | - | ||
| 153 | + | ||
| 146 | 154 | name := path[:uploadIndex] | |
| 147 | 155 | uploadPart := path[uploadIndex+15:] // len("/blobs/uploads/") = 15 | |
| 148 | - | ||
| 156 | + | ||
| 149 | 157 | r.SetPathValue("name", name) | |
| 150 | - | ||
| 158 | + | ||
| 151 | 159 | if uploadPart == "" { | |
| 152 | 160 | // POST /v2/{name}/blobs/uploads/ | |
| 153 | 161 | switch r.Method { | |
| 154 | 162 | case "POST": | |
| 155 | - | s.handleStartBlobUpload(w, r) | |
| 163 | + | h.handleStartBlobUpload(w, r) | |
| 156 | 164 | default: | |
| 157 | 165 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 158 | 166 | } | |
| @@ -161,73 +169,64 @@ func (s *Server) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) | |||
| 161 | 169 | r.SetPathValue("uuid", uploadPart) | |
| 162 | 170 | switch r.Method { | |
| 163 | 171 | case "PATCH", "PUT": | |
| 164 | - | s.handleBlobUpload(w, r) | |
| 172 | + | h.handleBlobUpload(w, r) | |
| 165 | 173 | default: | |
| 166 | 174 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 167 | 175 | } | |
| 168 | 176 | } | |
| 169 | 177 | } | |
| 170 | 178 | ||
| 171 | - | func (s *Server) handleTagListRoutes(w http.ResponseWriter, r *http.Request) { | |
| 179 | + | func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) { | |
| 172 | 180 | // Parse path: /v2/{name}/tags/list | |
| 173 | 181 | path := strings.TrimPrefix(r.URL.Path, "/v2/") | |
| 174 | 182 | if !strings.HasSuffix(path, "/tags/list") { | |
| 175 | 183 | http.NotFound(w, r) | |
| 176 | 184 | return | |
| 177 | 185 | } | |
| 178 | - | ||
| 186 | + | ||
| 179 | 187 | name := strings.TrimSuffix(path, "/tags/list") | |
| 180 | 188 | r.SetPathValue("name", name) | |
| 181 | - | ||
| 189 | + | ||
| 182 | 190 | switch r.Method { | |
| 183 | 191 | case "GET": | |
| 184 | - | s.handleListTags(w, r) | |
| 192 | + | h.handleListTags(w, r) | |
| 185 | 193 | default: | |
| 186 | 194 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 187 | 195 | } | |
| 188 | 196 | } | |
| 189 | 197 | ||
| 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) { | |
| 201 | 199 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 202 | 200 | w.WriteHeader(http.StatusOK) | |
| 203 | 201 | } | |
| 204 | 202 | ||
| 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) | |
| 206 | 206 | name := r.PathValue("name") | |
| 207 | 207 | reference := r.PathValue("reference") | |
| 208 | 208 | ||
| 209 | 209 | log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest") | |
| 210 | 210 | ||
| 211 | - | manifest, contentType, err := s.storage.GetManifest(name, reference) | |
| 211 | + | manifestData, err := h.registrySvc.GetManifest(ctx, name, reference) | |
| 212 | 212 | 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") | |
| 214 | 214 | http.NotFound(w, r) | |
| 215 | 215 | return | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 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))) | |
| 223 | 220 | w.WriteHeader(http.StatusOK) | |
| 224 | 221 | ||
| 225 | 222 | if r.Method == "GET" { | |
| 226 | - | _, _ = w.Write(manifest) | |
| 223 | + | _, _ = w.Write(manifestData.Data) | |
| 227 | 224 | } | |
| 228 | 225 | } | |
| 229 | 226 | ||
| 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) | |
| 231 | 230 | name := r.PathValue("name") | |
| 232 | 231 | reference := r.PathValue("reference") | |
| 233 | 232 | ||
| @@ -235,7 +234,7 @@ func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |||
| 235 | 234 | log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest") | |
| 236 | 235 | ||
| 237 | 236 | 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") | |
| 239 | 238 | http.Error(w, "Content-Type header required", http.StatusBadRequest) | |
| 240 | 239 | return | |
| 241 | 240 | } | |
| @@ -243,58 +242,51 @@ func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |||
| 243 | 242 | // Read manifest data | |
| 244 | 243 | data, err := io.ReadAll(r.Body) | |
| 245 | 244 | 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") | |
| 247 | 246 | http.Error(w, "Bad Request", http.StatusBadRequest) | |
| 248 | 247 | return | |
| 249 | 248 | } | |
| 250 | 249 | ||
| 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 | - | ||
| 264 | 250 | // Parse manifest annotations | |
| 265 | 251 | annotations, err := manifest.ParseManifestAnnotations(data, contentType) | |
| 266 | 252 | 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") | |
| 268 | 254 | annotations = map[string]string{} | |
| 269 | 255 | } | |
| 270 | 256 | ||
| 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{ | |
| 282 | 259 | Name: name, | |
| 283 | 260 | Reference: reference, | |
| 284 | - | Manifest: data, | |
| 261 | + | ContentType: contentType, | |
| 262 | + | Data: data, | |
| 285 | 263 | 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) | |
| 287 | 277 | } | |
| 288 | 278 | ||
| 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) | |
| 290 | 282 | name := r.PathValue("name") | |
| 291 | 283 | digest := r.PathValue("digest") | |
| 292 | 284 | ||
| 293 | 285 | log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob") | |
| 294 | 286 | ||
| 295 | - | path, err := s.storage.GetBlobPath(digest) | |
| 287 | + | path, err := h.blobStorage.GetBlobPath(digest) | |
| 296 | 288 | 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") | |
| 298 | 290 | http.NotFound(w, r) | |
| 299 | 291 | return | |
| 300 | 292 | } | |
| @@ -303,26 +295,28 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) { | |||
| 303 | 295 | http.ServeFile(w, r, path) | |
| 304 | 296 | } | |
| 305 | 297 | ||
| 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) | |
| 307 | 301 | name := r.PathValue("name") | |
| 308 | 302 | ||
| 309 | - | log.Debug().Str("name", name).Msg("Starting blob upload") | |
| 303 | + | log.Debug().Str("name", name).Msg("starting blob upload") | |
| 310 | 304 | ||
| 311 | - | // Generate a unique upload ID | |
| 312 | - | uuid, err := s.storage.StartBlobUpload(name) | |
| 305 | + | uuid, err := h.registrySvc.StartUpload(ctx, name) | |
| 313 | 306 | 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") | |
| 315 | 308 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 316 | 309 | return | |
| 317 | 310 | } | |
| 318 | 311 | ||
| 319 | - | // Set headers for the response | |
| 320 | 312 | 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") | |
| 322 | 314 | w.WriteHeader(http.StatusAccepted) | |
| 323 | 315 | } | |
| 324 | 316 | ||
| 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) | |
| 326 | 320 | name := r.PathValue("name") | |
| 327 | 321 | uuid := r.PathValue("uuid") | |
| 328 | 322 | digest := r.URL.Query().Get("digest") | |
| @@ -331,36 +325,34 @@ func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||
| 331 | 325 | Str("name", name). | |
| 332 | 326 | Str("uuid", uuid). | |
| 333 | 327 | 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") | |
| 336 | 330 | ||
| 337 | 331 | // Read the chunk from the request body | |
| 338 | 332 | chunk, err := io.ReadAll(r.Body) | |
| 339 | 333 | 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") | |
| 341 | 335 | http.Error(w, "Bad Request", http.StatusBadRequest) | |
| 342 | 336 | return | |
| 343 | 337 | } | |
| 344 | 338 | ||
| 345 | 339 | // Append the chunk to the upload | |
| 346 | - | length, err := s.storage.AppendBlobChunk(name, uuid, chunk) | |
| 340 | + | length, err := h.blobStorage.AppendBlobChunk(name, uuid, chunk) | |
| 347 | 341 | 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") | |
| 349 | 343 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 350 | 344 | return | |
| 351 | 345 | } | |
| 352 | 346 | ||
| 353 | 347 | // If this is the final chunk (PUT request with digest), finalize the upload | |
| 354 | 348 | 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) | |
| 359 | 352 | http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest) | |
| 360 | 353 | return | |
| 361 | 354 | } | |
| 362 | 355 | ||
| 363 | - | // Respond with 201 Created | |
| 364 | 356 | w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest)) | |
| 365 | 357 | w.Header().Set("Docker-Content-Digest", digest) | |
| 366 | 358 | w.WriteHeader(http.StatusCreated) | |
| @@ -373,26 +365,27 @@ func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||
| 373 | 365 | w.WriteHeader(http.StatusAccepted) | |
| 374 | 366 | } | |
| 375 | 367 | ||
| 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) | |
| 377 | 371 | name := r.PathValue("name") | |
| 378 | 372 | ||
| 379 | - | log.Debug().Str("name", name).Msg("Listing tags") | |
| 373 | + | log.Debug().Str("name", name).Msg("listing tags") | |
| 380 | 374 | ||
| 381 | - | tags, err := s.storage.ListTags(name) | |
| 375 | + | tags, err := h.registrySvc.ListTags(ctx, name) | |
| 382 | 376 | 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") | |
| 384 | 378 | w.WriteHeader(http.StatusNotFound) | |
| 385 | 379 | return | |
| 386 | 380 | } | |
| 387 | 381 | ||
| 388 | - | // Construct the response JSON properly | |
| 389 | 382 | tagsJSON, err := json.Marshal(tags) | |
| 390 | 383 | 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") | |
| 392 | 385 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 393 | 386 | return | |
| 394 | 387 | } | |
| 395 | - | ||
| 388 | + | ||
| 396 | 389 | response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, string(tagsJSON)) | |
| 397 | 390 | ||
| 398 | 391 | w.Header().Set("Content-Type", "application/json") | |
| @@ -400,67 +393,3 @@ func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) { | |||
| 400 | 393 | w.WriteHeader(http.StatusOK) | |
| 401 | 394 | w.Write([]byte(response)) | |
| 402 | 395 | } | |
| 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 collapsed to keep the page light
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` | |||
| 80 | 80 | default: | |
| 81 | 81 | return content | |
| 82 | 82 | } | |
| 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | ||
| 6 | - | "gordon/cmd" | |
| 6 | + | "gordon/internal/adapters/in/cli" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | // Build information set by goreleaser via ldflags | |
| @@ -15,9 +15,9 @@ var ( | |||
| 15 | 15 | ||
| 16 | 16 | func main() { | |
| 17 | 17 | // 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 { | |
| 21 | 21 | os.Exit(1) | |
| 22 | 22 | } | |
| 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
| @@ -2,19 +2,19 @@ package manifest | |||
| 2 | 2 | ||
| 3 | 3 | // OCIManifest represents an OCI image manifest structure | |
| 4 | 4 | type OCIManifest struct { | |
| 5 | - | SchemaVersion int `json:"schemaVersion"` | |
| 6 | - | MediaType string `json:"mediaType"` | |
| 7 | - | Config OCIDescriptor `json:"config"` | |
| 8 | - | Layers []OCIDescriptor `json:"layers"` | |
| 9 | - | Annotations map[string]string `json:"annotations,omitempty"` | |
| 5 | + | SchemaVersion int `json:"schemaVersion"` | |
| 6 | + | MediaType string `json:"mediaType"` | |
| 7 | + | Config OCIDescriptor `json:"config"` | |
| 8 | + | Layers []OCIDescriptor `json:"layers"` | |
| 9 | + | Annotations map[string]string `json:"annotations,omitempty"` | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | - | // DockerManifest represents a Docker v2.2 manifest structure | |
| 12 | + | // DockerManifest represents a Docker v2.2 manifest structure | |
| 13 | 13 | type DockerManifest struct { | |
| 14 | - | SchemaVersion int `json:"schemaVersion"` | |
| 15 | - | MediaType string `json:"mediaType"` | |
| 16 | - | Config OCIDescriptor `json:"config"` | |
| 17 | - | Layers []OCIDescriptor `json:"layers"` | |
| 14 | + | SchemaVersion int `json:"schemaVersion"` | |
| 15 | + | MediaType string `json:"mediaType"` | |
| 16 | + | Config OCIDescriptor `json:"config"` | |
| 17 | + | Layers []OCIDescriptor `json:"layers"` | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | // OCIDescriptor represents a content descriptor | |
| @@ -27,10 +27,10 @@ type OCIDescriptor struct { | |||
| 27 | 27 | ||
| 28 | 28 | // ManifestList represents a multi-platform manifest list | |
| 29 | 29 | type ManifestList struct { | |
| 30 | - | SchemaVersion int `json:"schemaVersion"` | |
| 31 | - | MediaType string `json:"mediaType"` | |
| 32 | - | Manifests []ManifestDescriptor `json:"manifests"` | |
| 33 | - | Annotations map[string]string `json:"annotations,omitempty"` | |
| 30 | + | SchemaVersion int `json:"schemaVersion"` | |
| 31 | + | MediaType string `json:"mediaType"` | |
| 32 | + | Manifests []ManifestDescriptor `json:"manifests"` | |
| 33 | + | Annotations map[string]string `json:"annotations,omitempty"` | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | // ManifestDescriptor represents a manifest in a manifest list | |
| @@ -44,9 +44,9 @@ type ManifestDescriptor struct { | |||
| 44 | 44 | ||
| 45 | 45 | // Platform represents the platform information | |
| 46 | 46 | type Platform struct { | |
| 47 | - | Architecture string `json:"architecture"` | |
| 48 | - | OS string `json:"os"` | |
| 49 | - | OSVersion string `json:"os.version,omitempty"` | |
| 47 | + | Architecture string `json:"architecture"` | |
| 48 | + | OS string `json:"os"` | |
| 49 | + | OSVersion string `json:"os.version,omitempty"` | |
| 50 | 50 | OSFeatures []string `json:"os.features,omitempty"` | |
| 51 | - | Variant string `json:"variant,omitempty"` | |
| 52 | - | }⏎̸ | |
| 51 | + | Variant string `json:"variant,omitempty"` | |
| 52 | + | } | |