Merge pull request #16 from bnema/fix/container-deployment-issues
fix(container): resolve deployment issues and add manual deploy command
30 files · +1623 -558
D docs/cli/auth.md +0 -224
| @@ -1,224 +0,0 @@ | |||
| 1 | - | # gordon auth | |
| 2 | - | ||
| 3 | - | Manage registry authentication tokens and passwords. | |
| 4 | - | ||
| 5 | - | ## Synopsis | |
| 6 | - | ||
| 7 | - | ```bash | |
| 8 | - | gordon auth token generate [options] | |
| 9 | - | gordon auth token list [options] | |
| 10 | - | gordon auth token revoke <token-id> [options] | |
| 11 | - | gordon auth password hash | |
| 12 | - | ``` | |
| 13 | - | ||
| 14 | - | ## Commands | |
| 15 | - | ||
| 16 | - | ### gordon auth token generate | |
| 17 | - | ||
| 18 | - | Generate a new JWT authentication token for registry access. | |
| 19 | - | ||
| 20 | - | ```bash | |
| 21 | - | gordon auth token generate --subject <name> [options] | |
| 22 | - | ``` | |
| 23 | - | ||
| 24 | - | **Options:** | |
| 25 | - | ||
| 26 | - | | Option | Default | Description | | |
| 27 | - | |--------|---------|-------------| | |
| 28 | - | | `--subject` | (required) | Username/subject for the token | | |
| 29 | - | | `--scopes` | `push,pull` | Comma-separated scopes | | |
| 30 | - | | `--expiry` | `720h` | Duration until expiry (0 = never) | | |
| 31 | - | | `-c, --config` | Auto | Path to config file | | |
| 32 | - | ||
| 33 | - | **Examples:** | |
| 34 | - | ||
| 35 | - | ```bash | |
| 36 | - | # CI token that never expires | |
| 37 | - | gordon auth token generate --subject github-actions --expiry 0 | |
| 38 | - | ||
| 39 | - | # Read-only token | |
| 40 | - | gordon auth token generate --subject reader --scopes pull --expiry 720h | |
| 41 | - | ||
| 42 | - | # Push-only token for build system | |
| 43 | - | gordon auth token generate --subject builder --scopes push --expiry 0 | |
| 44 | - | ||
| 45 | - | # Temporary token (24 hours) | |
| 46 | - | gordon auth token generate --subject temp --expiry 24h | |
| 47 | - | ``` | |
| 48 | - | ||
| 49 | - | **Output:** | |
| 50 | - | ||
| 51 | - | ``` | |
| 52 | - | Token generated successfully! | |
| 53 | - | Subject: github-actions | |
| 54 | - | Scopes: push, pull | |
| 55 | - | Expiry: never | |
| 56 | - | ||
| 57 | - | Token (use as password with docker login): | |
| 58 | - | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |
| 59 | - | ||
| 60 | - | Usage: docker login -u github-actions -p <token> <registry> | |
| 61 | - | ``` | |
| 62 | - | ||
| 63 | - | ### gordon auth token list | |
| 64 | - | ||
| 65 | - | List all stored authentication tokens. | |
| 66 | - | ||
| 67 | - | ```bash | |
| 68 | - | gordon auth token list [options] | |
| 69 | - | ``` | |
| 70 | - | ||
| 71 | - | **Options:** | |
| 72 | - | ||
| 73 | - | | Option | Default | Description | | |
| 74 | - | |--------|---------|-------------| | |
| 75 | - | | `-c, --config` | Auto | Path to config file | | |
| 76 | - | ||
| 77 | - | **Example:** | |
| 78 | - | ||
| 79 | - | ```bash | |
| 80 | - | gordon auth token list | |
| 81 | - | ``` | |
| 82 | - | ||
| 83 | - | **Output:** | |
| 84 | - | ||
| 85 | - | ``` | |
| 86 | - | ID Subject Expires Revoked | |
| 87 | - | ------------------------------------------------------------------------------------------ | |
| 88 | - | a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no | |
| 89 | - | b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no | |
| 90 | - | c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes | |
| 91 | - | ``` | |
| 92 | - | ||
| 93 | - | ### gordon auth token revoke | |
| 94 | - | ||
| 95 | - | Revoke a token by its ID. | |
| 96 | - | ||
| 97 | - | ```bash | |
| 98 | - | gordon auth token revoke <token-id> [options] | |
| 99 | - | ``` | |
| 100 | - | ||
| 101 | - | **Options:** | |
| 102 | - | ||
| 103 | - | | Option | Default | Description | | |
| 104 | - | |--------|---------|-------------| | |
| 105 | - | | `-c, --config` | Auto | Path to config file | | |
| 106 | - | ||
| 107 | - | **Example:** | |
| 108 | - | ||
| 109 | - | ```bash | |
| 110 | - | gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890 | |
| 111 | - | ``` | |
| 112 | - | ||
| 113 | - | **Output:** | |
| 114 | - | ||
| 115 | - | ``` | |
| 116 | - | Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |
| 117 | - | ``` | |
| 118 | - | ||
| 119 | - | ### gordon auth password hash | |
| 120 | - | ||
| 121 | - | Generate a bcrypt hash for password authentication. | |
| 122 | - | ||
| 123 | - | ```bash | |
| 124 | - | gordon auth password hash | |
| 125 | - | ``` | |
| 126 | - | ||
| 127 | - | Interactively prompts for a password and outputs the bcrypt hash. | |
| 128 | - | ||
| 129 | - | **Example:** | |
| 130 | - | ||
| 131 | - | ```bash | |
| 132 | - | gordon auth password hash | |
| 133 | - | ``` | |
| 134 | - | ||
| 135 | - | **Output:** | |
| 136 | - | ||
| 137 | - | ``` | |
| 138 | - | Enter password: ******** | |
| 139 | - | ||
| 140 | - | Bcrypt hash (store in your secrets backend): | |
| 141 | - | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |
| 142 | - | ||
| 143 | - | Then reference the path in your config: | |
| 144 | - | [registry_auth] | |
| 145 | - | type = "password" | |
| 146 | - | password_hash = "gordon/registry/password_hash" | |
| 147 | - | ``` | |
| 148 | - | ||
| 149 | - | ## Token Scopes | |
| 150 | - | ||
| 151 | - | | Scope | Permission | | |
| 152 | - | |-------|------------| | |
| 153 | - | | `push` | Push images to registry | | |
| 154 | - | | `pull` | Pull images from registry | | |
| 155 | - | | `push,pull` | Both push and pull (default) | | |
| 156 | - | ||
| 157 | - | ## Token Expiry Formats | |
| 158 | - | ||
| 159 | - | | Format | Duration | | |
| 160 | - | |--------|----------| | |
| 161 | - | | `0` | Never expires | | |
| 162 | - | | `24h` | 24 hours | | |
| 163 | - | | `720h` | 30 days | | |
| 164 | - | | `8760h` | 1 year | | |
| 165 | - | ||
| 166 | - | ## Workflow Examples | |
| 167 | - | ||
| 168 | - | ### CI/CD Setup | |
| 169 | - | ||
| 170 | - | ```bash | |
| 171 | - | # Generate token for CI | |
| 172 | - | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |
| 173 | - | ||
| 174 | - | # Save token as CI secret | |
| 175 | - | # GitHub: Settings → Secrets → New repository secret | |
| 176 | - | # GitLab: Settings → CI/CD → Variables | |
| 177 | - | ``` | |
| 178 | - | ||
| 179 | - | ### Docker Login | |
| 180 | - | ||
| 181 | - | ```bash | |
| 182 | - | # Using generated token | |
| 183 | - | docker login -u ci-bot -p <token> registry.mydomain.com | |
| 184 | - | ||
| 185 | - | # In CI/CD | |
| 186 | - | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com | |
| 187 | - | ``` | |
| 188 | - | ||
| 189 | - | ### Token Rotation | |
| 190 | - | ||
| 191 | - | ```bash | |
| 192 | - | # Generate new token | |
| 193 | - | gordon auth token generate --subject ci-bot --expiry 0 | |
| 194 | - | ||
| 195 | - | # Update CI secrets with new token | |
| 196 | - | ||
| 197 | - | # Revoke old token | |
| 198 | - | gordon auth token revoke <old-token-id> | |
| 199 | - | ``` | |
| 200 | - | ||
| 201 | - | ### Password Setup | |
| 202 | - | ||
| 203 | - | ```bash | |
| 204 | - | # Generate hash | |
| 205 | - | gordon auth password hash | |
| 206 | - | # Enter: mypassword | |
| 207 | - | ||
| 208 | - | # Store in pass | |
| 209 | - | pass insert gordon/registry/password_hash | |
| 210 | - | # Paste: $2a$10$... | |
| 211 | - | ||
| 212 | - | # Configure gordon.toml | |
| 213 | - | [registry_auth] | |
| 214 | - | enabled = true | |
| 215 | - | type = "password" | |
| 216 | - | username = "deploy" | |
| 217 | - | password_hash = "gordon/registry/password_hash" | |
| 218 | - | ``` | |
| 219 | - | ||
| 220 | - | ## Related | |
| 221 | - | ||
| 222 | - | - [Registry Authentication](../config/registry-auth.md) | |
| 223 | - | - [Secrets Configuration](../config/secrets.md) | |
| 224 | - | - [CLI Overview](./index.md) | |
| @@ -1,224 +0,0 @@ | |||||
| 1 | - | # gordon auth | |||
| 2 | - | ||||
| 3 | - | Manage registry authentication tokens and passwords. | |||
| 4 | - | ||||
| 5 | - | ## Synopsis | |||
| 6 | - | ||||
| 7 | - | ```bash | |||
| 8 | - | gordon auth token generate [options] | |||
| 9 | - | gordon auth token list [options] | |||
| 10 | - | gordon auth token revoke <token-id> [options] | |||
| 11 | - | gordon auth password hash | |||
| 12 | - | ``` | |||
| 13 | - | ||||
| 14 | - | ## Commands | |||
| 15 | - | ||||
| 16 | - | ### gordon auth token generate | |||
| 17 | - | ||||
| 18 | - | Generate a new JWT authentication token for registry access. | |||
| 19 | - | ||||
| 20 | - | ```bash | |||
| 21 | - | gordon auth token generate --subject <name> [options] | |||
| 22 | - | ``` | |||
| 23 | - | ||||
| 24 | - | **Options:** | |||
| 25 | - | ||||
| 26 | - | | Option | Default | Description | | |||
| 27 | - | |--------|---------|-------------| | |||
| 28 | - | | `--subject` | (required) | Username/subject for the token | | |||
| 29 | - | | `--scopes` | `push,pull` | Comma-separated scopes | | |||
| 30 | - | | `--expiry` | `720h` | Duration until expiry (0 = never) | | |||
| 31 | - | | `-c, --config` | Auto | Path to config file | | |||
| 32 | - | ||||
| 33 | - | **Examples:** | |||
| 34 | - | ||||
| 35 | - | ```bash | |||
| 36 | - | # CI token that never expires | |||
| 37 | - | gordon auth token generate --subject github-actions --expiry 0 | |||
| 38 | - | ||||
| 39 | - | # Read-only token | |||
| 40 | - | gordon auth token generate --subject reader --scopes pull --expiry 720h | |||
| 41 | - | ||||
| 42 | - | # Push-only token for build system | |||
| 43 | - | gordon auth token generate --subject builder --scopes push --expiry 0 | |||
| 44 | - | ||||
| 45 | - | # Temporary token (24 hours) | |||
| 46 | - | gordon auth token generate --subject temp --expiry 24h | |||
| 47 | - | ``` | |||
| 48 | - | ||||
| 49 | - | **Output:** | |||
| 50 | - | ||||
| 51 | - | ``` | |||
| 52 | - | Token generated successfully! | |||
| 53 | - | Subject: github-actions | |||
| 54 | - | Scopes: push, pull | |||
| 55 | - | Expiry: never | |||
| 56 | - | ||||
| 57 | - | Token (use as password with docker login): | |||
| 58 | - | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |||
| 59 | - | ||||
| 60 | - | Usage: docker login -u github-actions -p <token> <registry> | |||
| 61 | - | ``` | |||
| 62 | - | ||||
| 63 | - | ### gordon auth token list | |||
| 64 | - | ||||
| 65 | - | List all stored authentication tokens. | |||
| 66 | - | ||||
| 67 | - | ```bash | |||
| 68 | - | gordon auth token list [options] | |||
| 69 | - | ``` | |||
| 70 | - | ||||
| 71 | - | **Options:** | |||
| 72 | - | ||||
| 73 | - | | Option | Default | Description | | |||
| 74 | - | |--------|---------|-------------| | |||
| 75 | - | | `-c, --config` | Auto | Path to config file | | |||
| 76 | - | ||||
| 77 | - | **Example:** | |||
| 78 | - | ||||
| 79 | - | ```bash | |||
| 80 | - | gordon auth token list | |||
| 81 | - | ``` | |||
| 82 | - | ||||
| 83 | - | **Output:** | |||
| 84 | - | ||||
| 85 | - | ``` | |||
| 86 | - | ID Subject Expires Revoked | |||
| 87 | - | ------------------------------------------------------------------------------------------ | |||
| 88 | - | a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no | |||
| 89 | - | b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no | |||
| 90 | - | c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes | |||
| 91 | - | ``` | |||
| 92 | - | ||||
| 93 | - | ### gordon auth token revoke | |||
| 94 | - | ||||
| 95 | - | Revoke a token by its ID. | |||
| 96 | - | ||||
| 97 | - | ```bash | |||
| 98 | - | gordon auth token revoke <token-id> [options] | |||
| 99 | - | ``` | |||
| 100 | - | ||||
| 101 | - | **Options:** | |||
| 102 | - | ||||
| 103 | - | | Option | Default | Description | | |||
| 104 | - | |--------|---------|-------------| | |||
| 105 | - | | `-c, --config` | Auto | Path to config file | | |||
| 106 | - | ||||
| 107 | - | **Example:** | |||
| 108 | - | ||||
| 109 | - | ```bash | |||
| 110 | - | gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890 | |||
| 111 | - | ``` | |||
| 112 | - | ||||
| 113 | - | **Output:** | |||
| 114 | - | ||||
| 115 | - | ``` | |||
| 116 | - | Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |||
| 117 | - | ``` | |||
| 118 | - | ||||
| 119 | - | ### gordon auth password hash | |||
| 120 | - | ||||
| 121 | - | Generate a bcrypt hash for password authentication. | |||
| 122 | - | ||||
| 123 | - | ```bash | |||
| 124 | - | gordon auth password hash | |||
| 125 | - | ``` | |||
| 126 | - | ||||
| 127 | - | Interactively prompts for a password and outputs the bcrypt hash. | |||
| 128 | - | ||||
| 129 | - | **Example:** | |||
| 130 | - | ||||
| 131 | - | ```bash | |||
| 132 | - | gordon auth password hash | |||
| 133 | - | ``` | |||
| 134 | - | ||||
| 135 | - | **Output:** | |||
| 136 | - | ||||
| 137 | - | ``` | |||
| 138 | - | Enter password: ******** | |||
| 139 | - | ||||
| 140 | - | Bcrypt hash (store in your secrets backend): | |||
| 141 | - | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |||
| 142 | - | ||||
| 143 | - | Then reference the path in your config: | |||
| 144 | - | [registry_auth] | |||
| 145 | - | type = "password" | |||
| 146 | - | password_hash = "gordon/registry/password_hash" | |||
| 147 | - | ``` | |||
| 148 | - | ||||
| 149 | - | ## Token Scopes | |||
| 150 | - | ||||
| 151 | - | | Scope | Permission | | |||
| 152 | - | |-------|------------| | |||
| 153 | - | | `push` | Push images to registry | | |||
| 154 | - | | `pull` | Pull images from registry | | |||
| 155 | - | | `push,pull` | Both push and pull (default) | | |||
| 156 | - | ||||
| 157 | - | ## Token Expiry Formats | |||
| 158 | - | ||||
| 159 | - | | Format | Duration | | |||
| 160 | - | |--------|----------| | |||
| 161 | - | | `0` | Never expires | | |||
| 162 | - | | `24h` | 24 hours | | |||
| 163 | - | | `720h` | 30 days | | |||
| 164 | - | | `8760h` | 1 year | | |||
| 165 | - | ||||
| 166 | - | ## Workflow Examples | |||
| 167 | - | ||||
| 168 | - | ### CI/CD Setup | |||
| 169 | - | ||||
| 170 | - | ```bash | |||
| 171 | - | # Generate token for CI | |||
| 172 | - | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |||
| 173 | - | ||||
| 174 | - | # Save token as CI secret | |||
| 175 | - | # GitHub: Settings → Secrets → New repository secret | |||
| 176 | - | # GitLab: Settings → CI/CD → Variables | |||
| 177 | - | ``` | |||
| 178 | - | ||||
| 179 | - | ### Docker Login | |||
| 180 | - | ||||
| 181 | - | ```bash | |||
| 182 | - | # Using generated token | |||
| 183 | - | docker login -u ci-bot -p <token> registry.mydomain.com | |||
| 184 | - | ||||
| 185 | - | # In CI/CD | |||
| 186 | - | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com | |||
| 187 | - | ``` | |||
| 188 | - | ||||
| 189 | - | ### Token Rotation | |||
| 190 | - | ||||
| 191 | - | ```bash | |||
| 192 | - | # Generate new token | |||
| 193 | - | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 194 | - | ||||
| 195 | - | # Update CI secrets with new token | |||
| 196 | - | ||||
| 197 | - | # Revoke old token | |||
| 198 | - | gordon auth token revoke <old-token-id> | |||
| 199 | - | ``` | |||
| 200 | - | ||||
| 201 | - | ### Password Setup | |||
| 202 | - | ||||
| 203 | - | ```bash | |||
| 204 | - | # Generate hash | |||
| 205 | - | gordon auth password hash | |||
| 206 | - | # Enter: mypassword | |||
| 207 | - | ||||
| 208 | - | # Store in pass | |||
| 209 | - | pass insert gordon/registry/password_hash | |||
| 210 | - | # Paste: $2a$10$... | |||
| 211 | - | ||||
| 212 | - | # Configure gordon.toml | |||
| 213 | - | [registry_auth] | |||
| 214 | - | enabled = true | |||
| 215 | - | type = "password" | |||
| 216 | - | username = "deploy" | |||
| 217 | - | password_hash = "gordon/registry/password_hash" | |||
| 218 | - | ``` | |||
| 219 | - | ||||
| 220 | - | ## Related | |||
| 221 | - | ||||
| 222 | - | - [Registry Authentication](../config/registry-auth.md) | |||
| 223 | - | - [Secrets Configuration](../config/secrets.md) | |||
| 224 | - | - [CLI Overview](./index.md) | |||
A docs/cli/commands.md +552 -0
| @@ -0,0 +1,552 @@ | |||
| 1 | + | # CLI Commands | |
| 2 | + | ||
| 3 | + | Gordon provides a command-line interface for server management, deployment, and authentication. | |
| 4 | + | ||
| 5 | + | ## Commands Overview | |
| 6 | + | ||
| 7 | + | | Command | Description | | |
| 8 | + | |---------|-------------| | |
| 9 | + | | `gordon start` | Start the Gordon server | | |
| 10 | + | | `gordon reload` | Reload configuration and sync containers | | |
| 11 | + | | `gordon deploy` | Manually deploy or redeploy a specific route | | |
| 12 | + | | `gordon logs` | Display Gordon process logs | | |
| 13 | + | | `gordon version` | Print version information | | |
| 14 | + | | `gordon auth` | Manage registry authentication | | |
| 15 | + | ||
| 16 | + | ## Quick Reference | |
| 17 | + | ||
| 18 | + | ```bash | |
| 19 | + | # Start Gordon | |
| 20 | + | gordon start | |
| 21 | + | gordon start --config /path/to/config.toml | |
| 22 | + | ||
| 23 | + | # Reload configuration | |
| 24 | + | gordon reload | |
| 25 | + | ||
| 26 | + | # Deploy a specific route | |
| 27 | + | gordon deploy myapp.example.com | |
| 28 | + | ||
| 29 | + | # View logs | |
| 30 | + | gordon logs | |
| 31 | + | gordon logs -f | |
| 32 | + | gordon logs -n 100 | |
| 33 | + | ||
| 34 | + | # Check version | |
| 35 | + | gordon version | |
| 36 | + | ||
| 37 | + | # Authentication | |
| 38 | + | gordon auth token generate --subject ci-bot --expiry 0 | |
| 39 | + | gordon auth token list | |
| 40 | + | gordon auth token revoke <token-id> | |
| 41 | + | gordon auth password hash | |
| 42 | + | gordon auth internal | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | ## Global Options | |
| 46 | + | ||
| 47 | + | | Option | Description | | |
| 48 | + | |--------|-------------| | |
| 49 | + | | `-c, --config` | Path to configuration file | | |
| 50 | + | ||
| 51 | + | --- | |
| 52 | + | ||
| 53 | + | ## gordon start | |
| 54 | + | ||
| 55 | + | Start the Gordon server with registry and proxy components. | |
| 56 | + | ||
| 57 | + | ### Synopsis | |
| 58 | + | ||
| 59 | + | ```bash | |
| 60 | + | gordon start [options] | |
| 61 | + | ``` | |
| 62 | + | ||
| 63 | + | ### Options | |
| 64 | + | ||
| 65 | + | | Option | Short | Default | Description | | |
| 66 | + | |--------|-------|---------|-------------| | |
| 67 | + | | `--config` | `-c` | Auto-detected | Path to configuration file | | |
| 68 | + | ||
| 69 | + | ### Description | |
| 70 | + | ||
| 71 | + | Starts the Gordon server, which includes: | |
| 72 | + | ||
| 73 | + | - **Container Registry** - Receives image pushes on the registry port | |
| 74 | + | - **HTTP Proxy** - Routes traffic to containers on the proxy port | |
| 75 | + | - **Event Bus** - Coordinates deployments and updates | |
| 76 | + | - **Config Watcher** - Monitors configuration file for changes | |
| 77 | + | ||
| 78 | + | ### Configuration File Detection | |
| 79 | + | ||
| 80 | + | Gordon looks for configuration in this order: | |
| 81 | + | ||
| 82 | + | 1. Path specified with `--config` | |
| 83 | + | 2. `/etc/gordon/gordon.toml` | |
| 84 | + | 3. `~/.config/gordon/gordon.toml` | |
| 85 | + | 4. `./gordon.toml` | |
| 86 | + | ||
| 87 | + | ### First Run | |
| 88 | + | ||
| 89 | + | On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`. | |
| 90 | + | ||
| 91 | + | ```bash | |
| 92 | + | # First run - creates default config | |
| 93 | + | gordon start | |
| 94 | + | # Edit the config, then restart | |
| 95 | + | ``` | |
| 96 | + | ||
| 97 | + | ### Examples | |
| 98 | + | ||
| 99 | + | ```bash | |
| 100 | + | # Basic start | |
| 101 | + | gordon start | |
| 102 | + | ||
| 103 | + | # With custom config | |
| 104 | + | gordon start --config /path/to/gordon.toml | |
| 105 | + | gordon start -c ./my-config.toml | |
| 106 | + | ||
| 107 | + | # With environment override | |
| 108 | + | GORDON_SERVER_PORT=8080 gordon start | |
| 109 | + | GORDON_LOGGING_LEVEL=debug gordon start | |
| 110 | + | ``` | |
| 111 | + | ||
| 112 | + | ### Signals | |
| 113 | + | ||
| 114 | + | Gordon responds to these signals: | |
| 115 | + | ||
| 116 | + | | Signal | Action | | |
| 117 | + | |--------|--------| | |
| 118 | + | | `SIGTERM` | Graceful shutdown | | |
| 119 | + | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |
| 120 | + | | `SIGUSR1` | Reload configuration | | |
| 121 | + | | `SIGUSR2` | Manual deploy (used by `gordon deploy`) | | |
| 122 | + | ||
| 123 | + | ### Running with systemd | |
| 124 | + | ||
| 125 | + | ```bash | |
| 126 | + | # Create user service | |
| 127 | + | mkdir -p ~/.config/systemd/user | |
| 128 | + | cat > ~/.config/systemd/user/gordon.service <<EOF | |
| 129 | + | [Unit] | |
| 130 | + | Description=Gordon Container Platform | |
| 131 | + | ||
| 132 | + | [Service] | |
| 133 | + | Type=simple | |
| 134 | + | Restart=always | |
| 135 | + | ExecStart=/usr/local/bin/gordon start | |
| 136 | + | ||
| 137 | + | [Install] | |
| 138 | + | WantedBy=default.target | |
| 139 | + | EOF | |
| 140 | + | ||
| 141 | + | # Enable and start | |
| 142 | + | systemctl --user daemon-reload | |
| 143 | + | systemctl --user enable --now gordon | |
| 144 | + | sudo loginctl enable-linger $USER | |
| 145 | + | ``` | |
| 146 | + | ||
| 147 | + | ### Startup Sequence | |
| 148 | + | ||
| 149 | + | 1. Load configuration | |
| 150 | + | 2. Initialize logger | |
| 151 | + | 3. Create PID file | |
| 152 | + | 4. Connect to Docker/Podman runtime | |
| 153 | + | 5. Create storage directories | |
| 154 | + | 6. Initialize services (registry, proxy, auth) | |
| 155 | + | 7. Register event handlers | |
| 156 | + | 8. Start config file watcher | |
| 157 | + | 9. Sync existing containers | |
| 158 | + | 10. Start HTTP servers | |
| 159 | + | ||
| 160 | + | ### Shutdown Sequence | |
| 161 | + | ||
| 162 | + | 1. Receive shutdown signal | |
| 163 | + | 2. Stop accepting new requests | |
| 164 | + | 3. Complete in-flight requests | |
| 165 | + | 4. Stop managed containers (if configured) | |
| 166 | + | 5. Remove PID file | |
| 167 | + | 6. Exit | |
| 168 | + | ||
| 169 | + | --- | |
| 170 | + | ||
| 171 | + | ## gordon reload | |
| 172 | + | ||
| 173 | + | Reload configuration and sync containers to match. | |
| 174 | + | ||
| 175 | + | ### Synopsis | |
| 176 | + | ||
| 177 | + | ```bash | |
| 178 | + | gordon reload | |
| 179 | + | ``` | |
| 180 | + | ||
| 181 | + | ### Description | |
| 182 | + | ||
| 183 | + | Sends `SIGUSR1` to the running Gordon process, triggering: | |
| 184 | + | ||
| 185 | + | - Configuration file reload | |
| 186 | + | - Route synchronization | |
| 187 | + | - Deployment of containers for routes missing containers | |
| 188 | + | - Attachment deployment | |
| 189 | + | ||
| 190 | + | ### Example | |
| 191 | + | ||
| 192 | + | ```bash | |
| 193 | + | gordon reload | |
| 194 | + | ``` | |
| 195 | + | ||
| 196 | + | --- | |
| 197 | + | ||
| 198 | + | ## gordon deploy | |
| 199 | + | ||
| 200 | + | Manually deploy or redeploy a specific route. | |
| 201 | + | ||
| 202 | + | ### Synopsis | |
| 203 | + | ||
| 204 | + | ```bash | |
| 205 | + | gordon deploy <domain> | |
| 206 | + | ``` | |
| 207 | + | ||
| 208 | + | ### Arguments | |
| 209 | + | ||
| 210 | + | | Argument | Description | | |
| 211 | + | |----------|-------------| | |
| 212 | + | | `<domain>` | The domain name of the route to deploy (required) | | |
| 213 | + | ||
| 214 | + | ### Description | |
| 215 | + | ||
| 216 | + | Sends `SIGUSR2` to the Gordon process with the specified domain, triggering: | |
| 217 | + | ||
| 218 | + | - Fresh image pull (always pulls latest, ignoring cache) | |
| 219 | + | - Container redeployment for the specified route | |
| 220 | + | ||
| 221 | + | ### Examples | |
| 222 | + | ||
| 223 | + | ```bash | |
| 224 | + | gordon deploy myapp.example.com | |
| 225 | + | gordon deploy api.example.com | |
| 226 | + | ``` | |
| 227 | + | ||
| 228 | + | ### Use Cases | |
| 229 | + | ||
| 230 | + | - Recover from a failed deployment | |
| 231 | + | - Force redeploy without pushing a new image | |
| 232 | + | - Manual deployment when automatic deploy didn't trigger | |
| 233 | + | ||
| 234 | + | --- | |
| 235 | + | ||
| 236 | + | ## gordon logs | |
| 237 | + | ||
| 238 | + | Display Gordon process logs. | |
| 239 | + | ||
| 240 | + | ### Synopsis | |
| 241 | + | ||
| 242 | + | ```bash | |
| 243 | + | gordon logs [options] | |
| 244 | + | ``` | |
| 245 | + | ||
| 246 | + | ### Options | |
| 247 | + | ||
| 248 | + | | Option | Short | Default | Description | | |
| 249 | + | |--------|-------|---------|-------------| | |
| 250 | + | | `--config` | `-c` | Auto | Path to config file | | |
| 251 | + | | `--follow` | `-f` | false | Follow log output (like `tail -f`) | | |
| 252 | + | | `--lines` | `-n` | 50 | Number of lines to show | | |
| 253 | + | ||
| 254 | + | ### Examples | |
| 255 | + | ||
| 256 | + | ```bash | |
| 257 | + | gordon logs # Last 50 lines | |
| 258 | + | gordon logs -f # Follow logs | |
| 259 | + | gordon logs -n 100 # Last 100 lines | |
| 260 | + | gordon logs -f -n 200 # Follow, starting from last 200 lines | |
| 261 | + | ``` | |
| 262 | + | ||
| 263 | + | ### Log Locations | |
| 264 | + | ||
| 265 | + | ```bash | |
| 266 | + | # Using gordon logs | |
| 267 | + | gordon logs -f | |
| 268 | + | ||
| 269 | + | # Direct file access | |
| 270 | + | tail -f ~/.gordon/logs/gordon.log | |
| 271 | + | ||
| 272 | + | # With systemd | |
| 273 | + | journalctl --user -u gordon -f | |
| 274 | + | ``` | |
| 275 | + | ||
| 276 | + | --- | |
| 277 | + | ||
| 278 | + | ## gordon version | |
| 279 | + | ||
| 280 | + | Print version information. | |
| 281 | + | ||
| 282 | + | ### Synopsis | |
| 283 | + | ||
| 284 | + | ```bash | |
| 285 | + | gordon version | |
| 286 | + | ``` | |
| 287 | + | ||
| 288 | + | ### Output | |
| 289 | + | ||
| 290 | + | ``` | |
| 291 | + | Gordon v2.0.0 | |
| 292 | + | Commit: abc1234 | |
| 293 | + | Build Date: 2024-01-15 | |
| 294 | + | ``` | |
| 295 | + | ||
| 296 | + | --- | |
| 297 | + | ||
| 298 | + | ## gordon auth | |
| 299 | + | ||
| 300 | + | Manage registry authentication tokens and passwords. | |
| 301 | + | ||
| 302 | + | ### Subcommands | |
| 303 | + | ||
| 304 | + | | Subcommand | Description | | |
| 305 | + | |------------|-------------| | |
| 306 | + | | `token generate` | Generate a new JWT token | | |
| 307 | + | | `token list` | List all stored tokens | | |
| 308 | + | | `token revoke` | Revoke a token by ID | | |
| 309 | + | | `password hash` | Generate bcrypt password hash | | |
| 310 | + | | `internal` | Show internal registry credentials | | |
| 311 | + | ||
| 312 | + | ### gordon auth token generate | |
| 313 | + | ||
| 314 | + | Generate a new JWT authentication token for registry access. | |
| 315 | + | ||
| 316 | + | ```bash | |
| 317 | + | gordon auth token generate --subject <name> [options] | |
| 318 | + | ``` | |
| 319 | + | ||
| 320 | + | **Options:** | |
| 321 | + | ||
| 322 | + | | Option | Default | Description | | |
| 323 | + | |--------|---------|-------------| | |
| 324 | + | | `--subject` | (required) | Username/subject for the token | | |
| 325 | + | | `--scopes` | `push,pull` | Comma-separated scopes | | |
| 326 | + | | `--expiry` | `720h` | Duration until expiry (0 = never) | | |
| 327 | + | | `-c, --config` | Auto | Path to config file | | |
| 328 | + | ||
| 329 | + | **Examples:** | |
| 330 | + | ||
| 331 | + | ```bash | |
| 332 | + | # CI token that never expires | |
| 333 | + | gordon auth token generate --subject github-actions --expiry 0 | |
| 334 | + | ||
| 335 | + | # Read-only token | |
| 336 | + | gordon auth token generate --subject reader --scopes pull --expiry 720h | |
| 337 | + | ||
| 338 | + | # Push-only token for build system | |
| 339 | + | gordon auth token generate --subject builder --scopes push --expiry 0 | |
| 340 | + | ||
| 341 | + | # Temporary token (24 hours) | |
| 342 | + | gordon auth token generate --subject temp --expiry 24h | |
| 343 | + | ``` | |
| 344 | + | ||
| 345 | + | **Output:** | |
| 346 | + | ||
| 347 | + | ``` | |
| 348 | + | Token generated successfully! | |
| 349 | + | Subject: github-actions | |
| 350 | + | Scopes: push, pull | |
| 351 | + | Expiry: never | |
| 352 | + | ||
| 353 | + | Token (use as password with docker login): | |
| 354 | + | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |
| 355 | + | ||
| 356 | + | Usage: docker login -u github-actions -p <token> <registry> | |
| 357 | + | ``` | |
| 358 | + | ||
| 359 | + | ### gordon auth token list | |
| 360 | + | ||
| 361 | + | List all stored authentication tokens. | |
| 362 | + | ||
| 363 | + | ```bash | |
| 364 | + | gordon auth token list [options] | |
| 365 | + | ``` | |
| 366 | + | ||
| 367 | + | **Output:** | |
| 368 | + | ||
| 369 | + | ``` | |
| 370 | + | ID Subject Expires Revoked | |
| 371 | + | ------------------------------------------------------------------------------------------ | |
| 372 | + | a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no | |
| 373 | + | b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no | |
| 374 | + | c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes | |
| 375 | + | ``` | |
| 376 | + | ||
| 377 | + | ### gordon auth token revoke | |
| 378 | + | ||
| 379 | + | Revoke a token by its ID. | |
| 380 | + | ||
| 381 | + | ```bash | |
| 382 | + | gordon auth token revoke <token-id> [options] | |
| 383 | + | ``` | |
| 384 | + | ||
| 385 | + | **Example:** | |
| 386 | + | ||
| 387 | + | ```bash | |
| 388 | + | gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890 | |
| 389 | + | ``` | |
| 390 | + | ||
| 391 | + | **Output:** | |
| 392 | + | ||
| 393 | + | ``` | |
| 394 | + | Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |
| 395 | + | ``` | |
| 396 | + | ||
| 397 | + | ### gordon auth password hash | |
| 398 | + | ||
| 399 | + | Generate a bcrypt hash for password authentication. | |
| 400 | + | ||
| 401 | + | ```bash | |
| 402 | + | gordon auth password hash | |
| 403 | + | ``` | |
| 404 | + | ||
| 405 | + | Interactively prompts for a password and outputs the bcrypt hash. | |
| 406 | + | ||
| 407 | + | **Output:** | |
| 408 | + | ||
| 409 | + | ``` | |
| 410 | + | Enter password: ******** | |
| 411 | + | ||
| 412 | + | Bcrypt hash (store in your secrets backend): | |
| 413 | + | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |
| 414 | + | ||
| 415 | + | Then reference the path in your config: | |
| 416 | + | [registry_auth] | |
| 417 | + | type = "password" | |
| 418 | + | password_hash = "gordon/registry/password_hash" | |
| 419 | + | ``` | |
| 420 | + | ||
| 421 | + | ### gordon auth internal | |
| 422 | + | ||
| 423 | + | Display the auto-generated internal registry credentials. | |
| 424 | + | ||
| 425 | + | ```bash | |
| 426 | + | gordon auth internal | |
| 427 | + | ``` | |
| 428 | + | ||
| 429 | + | Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. | |
| 430 | + | ||
| 431 | + | **Output:** | |
| 432 | + | ||
| 433 | + | ``` | |
| 434 | + | Internal Registry Credentials | |
| 435 | + | ============================== | |
| 436 | + | Username: gordon-internal | |
| 437 | + | Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |
| 438 | + | ||
| 439 | + | Usage: | |
| 440 | + | docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |
| 441 | + | ``` | |
| 442 | + | ||
| 443 | + | **Notes:** | |
| 444 | + | - Credentials are regenerated each time Gordon starts | |
| 445 | + | - Only available while Gordon is running | |
| 446 | + | - Used for manual `docker pull` from the local registry during debugging | |
| 447 | + | ||
| 448 | + | **Use cases:** | |
| 449 | + | - Debugging image pull failures | |
| 450 | + | - Manual recovery after deployment issues | |
| 451 | + | - Inspecting images in the local registry | |
| 452 | + | ||
| 453 | + | --- | |
| 454 | + | ||
| 455 | + | ## Token Scopes | |
| 456 | + | ||
| 457 | + | | Scope | Permission | | |
| 458 | + | |-------|------------| | |
| 459 | + | | `push` | Push images to registry | | |
| 460 | + | | `pull` | Pull images from registry | | |
| 461 | + | | `push,pull` | Both push and pull (default) | | |
| 462 | + | ||
| 463 | + | ## Token Expiry Formats | |
| 464 | + | ||
| 465 | + | | Format | Duration | | |
| 466 | + | |--------|----------| | |
| 467 | + | | `0` | Never expires | | |
| 468 | + | | `24h` | 24 hours | | |
| 469 | + | | `720h` | 30 days | | |
| 470 | + | | `8760h` | 1 year | | |
| 471 | + | ||
| 472 | + | --- | |
| 473 | + | ||
| 474 | + | ## Workflow Examples | |
| 475 | + | ||
| 476 | + | ### CI/CD Setup | |
| 477 | + | ||
| 478 | + | ```bash | |
| 479 | + | # Generate token for CI | |
| 480 | + | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |
| 481 | + | ||
| 482 | + | # Save token as CI secret | |
| 483 | + | # GitHub: Settings → Secrets → New repository secret | |
| 484 | + | # GitLab: Settings → CI/CD → Variables | |
| 485 | + | ``` | |
| 486 | + | ||
| 487 | + | ### Docker Login | |
| 488 | + | ||
| 489 | + | ```bash | |
| 490 | + | # Using generated token | |
| 491 | + | docker login -u ci-bot -p <token> registry.mydomain.com | |
| 492 | + | ||
| 493 | + | # In CI/CD | |
| 494 | + | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com | |
| 495 | + | ``` | |
| 496 | + | ||
| 497 | + | ### Token Rotation | |
| 498 | + | ||
| 499 | + | ```bash | |
| 500 | + | # Generate new token | |
| 501 | + | gordon auth token generate --subject ci-bot --expiry 0 | |
| 502 | + | ||
| 503 | + | # Update CI secrets with new token | |
| 504 | + | ||
| 505 | + | # Revoke old token | |
| 506 | + | gordon auth token revoke <old-token-id> | |
| 507 | + | ``` | |
| 508 | + | ||
| 509 | + | ### Password Setup | |
| 510 | + | ||
| 511 | + | ```bash | |
| 512 | + | # Generate hash | |
| 513 | + | gordon auth password hash | |
| 514 | + | # Enter: mypassword | |
| 515 | + | ||
| 516 | + | # Store in pass | |
| 517 | + | pass insert gordon/registry/password_hash | |
| 518 | + | # Paste: $2a$10$... | |
| 519 | + | ||
| 520 | + | # Configure gordon.toml | |
| 521 | + | [registry_auth] | |
| 522 | + | enabled = true | |
| 523 | + | type = "password" | |
| 524 | + | username = "deploy" | |
| 525 | + | password_hash = "gordon/registry/password_hash" | |
| 526 | + | ``` | |
| 527 | + | ||
| 528 | + | --- | |
| 529 | + | ||
| 530 | + | ## Exit Codes | |
| 531 | + | ||
| 532 | + | | Code | Meaning | | |
| 533 | + | |------|---------| | |
| 534 | + | | 0 | Success | | |
| 535 | + | | 1 | General error | | |
| 536 | + | | 2 | Configuration error | | |
| 537 | + | ||
| 538 | + | ## Environment Variables | |
| 539 | + | ||
| 540 | + | Gordon reads configuration from environment variables: | |
| 541 | + | ||
| 542 | + | ```bash | |
| 543 | + | GORDON_SERVER_PORT=8080 gordon start | |
| 544 | + | GORDON_LOGGING_LEVEL=debug gordon start | |
| 545 | + | ``` | |
| 546 | + | ||
| 547 | + | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | |
| 548 | + | ||
| 549 | + | ## Related | |
| 550 | + | ||
| 551 | + | - [Core Concepts](../concepts.md) | |
| 552 | + | - [Configuration Reference](../config/index.md) | |
| @@ -0,0 +1,552 @@ | |||||
| 1 | + | # CLI Commands | |||
| 2 | + | ||||
| 3 | + | Gordon provides a command-line interface for server management, deployment, and authentication. | |||
| 4 | + | ||||
| 5 | + | ## Commands Overview | |||
| 6 | + | ||||
| 7 | + | | Command | Description | | |||
| 8 | + | |---------|-------------| | |||
| 9 | + | | `gordon start` | Start the Gordon server | | |||
| 10 | + | | `gordon reload` | Reload configuration and sync containers | | |||
| 11 | + | | `gordon deploy` | Manually deploy or redeploy a specific route | | |||
| 12 | + | | `gordon logs` | Display Gordon process logs | | |||
| 13 | + | | `gordon version` | Print version information | | |||
| 14 | + | | `gordon auth` | Manage registry authentication | | |||
| 15 | + | ||||
| 16 | + | ## Quick Reference | |||
| 17 | + | ||||
| 18 | + | ```bash | |||
| 19 | + | # Start Gordon | |||
| 20 | + | gordon start | |||
| 21 | + | gordon start --config /path/to/config.toml | |||
| 22 | + | ||||
| 23 | + | # Reload configuration | |||
| 24 | + | gordon reload | |||
| 25 | + | ||||
| 26 | + | # Deploy a specific route | |||
| 27 | + | gordon deploy myapp.example.com | |||
| 28 | + | ||||
| 29 | + | # View logs | |||
| 30 | + | gordon logs | |||
| 31 | + | gordon logs -f | |||
| 32 | + | gordon logs -n 100 | |||
| 33 | + | ||||
| 34 | + | # Check version | |||
| 35 | + | gordon version | |||
| 36 | + | ||||
| 37 | + | # Authentication | |||
| 38 | + | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 39 | + | gordon auth token list | |||
| 40 | + | gordon auth token revoke <token-id> | |||
| 41 | + | gordon auth password hash | |||
| 42 | + | gordon auth internal | |||
| 43 | + | ``` | |||
| 44 | + | ||||
| 45 | + | ## Global Options | |||
| 46 | + | ||||
| 47 | + | | Option | Description | | |||
| 48 | + | |--------|-------------| | |||
| 49 | + | | `-c, --config` | Path to configuration file | | |||
| 50 | + | ||||
| 51 | + | --- | |||
| 52 | + | ||||
| 53 | + | ## gordon start | |||
| 54 | + | ||||
| 55 | + | Start the Gordon server with registry and proxy components. | |||
| 56 | + | ||||
| 57 | + | ### Synopsis | |||
| 58 | + | ||||
| 59 | + | ```bash | |||
| 60 | + | gordon start [options] | |||
| 61 | + | ``` | |||
| 62 | + | ||||
| 63 | + | ### Options | |||
| 64 | + | ||||
| 65 | + | | Option | Short | Default | Description | | |||
| 66 | + | |--------|-------|---------|-------------| | |||
| 67 | + | | `--config` | `-c` | Auto-detected | Path to configuration file | | |||
| 68 | + | ||||
| 69 | + | ### Description | |||
| 70 | + | ||||
| 71 | + | Starts the Gordon server, which includes: | |||
| 72 | + | ||||
| 73 | + | - **Container Registry** - Receives image pushes on the registry port | |||
| 74 | + | - **HTTP Proxy** - Routes traffic to containers on the proxy port | |||
| 75 | + | - **Event Bus** - Coordinates deployments and updates | |||
| 76 | + | - **Config Watcher** - Monitors configuration file for changes | |||
| 77 | + | ||||
| 78 | + | ### Configuration File Detection | |||
| 79 | + | ||||
| 80 | + | Gordon looks for configuration in this order: | |||
| 81 | + | ||||
| 82 | + | 1. Path specified with `--config` | |||
| 83 | + | 2. `/etc/gordon/gordon.toml` | |||
| 84 | + | 3. `~/.config/gordon/gordon.toml` | |||
| 85 | + | 4. `./gordon.toml` | |||
| 86 | + | ||||
| 87 | + | ### First Run | |||
| 88 | + | ||||
| 89 | + | On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`. | |||
| 90 | + | ||||
| 91 | + | ```bash | |||
| 92 | + | # First run - creates default config | |||
| 93 | + | gordon start | |||
| 94 | + | # Edit the config, then restart | |||
| 95 | + | ``` | |||
| 96 | + | ||||
| 97 | + | ### Examples | |||
| 98 | + | ||||
| 99 | + | ```bash | |||
| 100 | + | # Basic start | |||
| 101 | + | gordon start | |||
| 102 | + | ||||
| 103 | + | # With custom config | |||
| 104 | + | gordon start --config /path/to/gordon.toml | |||
| 105 | + | gordon start -c ./my-config.toml | |||
| 106 | + | ||||
| 107 | + | # With environment override | |||
| 108 | + | GORDON_SERVER_PORT=8080 gordon start | |||
| 109 | + | GORDON_LOGGING_LEVEL=debug gordon start | |||
| 110 | + | ``` | |||
| 111 | + | ||||
| 112 | + | ### Signals | |||
| 113 | + | ||||
| 114 | + | Gordon responds to these signals: | |||
| 115 | + | ||||
| 116 | + | | Signal | Action | | |||
| 117 | + | |--------|--------| | |||
| 118 | + | | `SIGTERM` | Graceful shutdown | | |||
| 119 | + | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |||
| 120 | + | | `SIGUSR1` | Reload configuration | | |||
| 121 | + | | `SIGUSR2` | Manual deploy (used by `gordon deploy`) | | |||
| 122 | + | ||||
| 123 | + | ### Running with systemd | |||
| 124 | + | ||||
| 125 | + | ```bash | |||
| 126 | + | # Create user service | |||
| 127 | + | mkdir -p ~/.config/systemd/user | |||
| 128 | + | cat > ~/.config/systemd/user/gordon.service <<EOF | |||
| 129 | + | [Unit] | |||
| 130 | + | Description=Gordon Container Platform | |||
| 131 | + | ||||
| 132 | + | [Service] | |||
| 133 | + | Type=simple | |||
| 134 | + | Restart=always | |||
| 135 | + | ExecStart=/usr/local/bin/gordon start | |||
| 136 | + | ||||
| 137 | + | [Install] | |||
| 138 | + | WantedBy=default.target | |||
| 139 | + | EOF | |||
| 140 | + | ||||
| 141 | + | # Enable and start | |||
| 142 | + | systemctl --user daemon-reload | |||
| 143 | + | systemctl --user enable --now gordon | |||
| 144 | + | sudo loginctl enable-linger $USER | |||
| 145 | + | ``` | |||
| 146 | + | ||||
| 147 | + | ### Startup Sequence | |||
| 148 | + | ||||
| 149 | + | 1. Load configuration | |||
| 150 | + | 2. Initialize logger | |||
| 151 | + | 3. Create PID file | |||
| 152 | + | 4. Connect to Docker/Podman runtime | |||
| 153 | + | 5. Create storage directories | |||
| 154 | + | 6. Initialize services (registry, proxy, auth) | |||
| 155 | + | 7. Register event handlers | |||
| 156 | + | 8. Start config file watcher | |||
| 157 | + | 9. Sync existing containers | |||
| 158 | + | 10. Start HTTP servers | |||
| 159 | + | ||||
| 160 | + | ### Shutdown Sequence | |||
| 161 | + | ||||
| 162 | + | 1. Receive shutdown signal | |||
| 163 | + | 2. Stop accepting new requests | |||
| 164 | + | 3. Complete in-flight requests | |||
| 165 | + | 4. Stop managed containers (if configured) | |||
| 166 | + | 5. Remove PID file | |||
| 167 | + | 6. Exit | |||
| 168 | + | ||||
| 169 | + | --- | |||
| 170 | + | ||||
| 171 | + | ## gordon reload | |||
| 172 | + | ||||
| 173 | + | Reload configuration and sync containers to match. | |||
| 174 | + | ||||
| 175 | + | ### Synopsis | |||
| 176 | + | ||||
| 177 | + | ```bash | |||
| 178 | + | gordon reload | |||
| 179 | + | ``` | |||
| 180 | + | ||||
| 181 | + | ### Description | |||
| 182 | + | ||||
| 183 | + | Sends `SIGUSR1` to the running Gordon process, triggering: | |||
| 184 | + | ||||
| 185 | + | - Configuration file reload | |||
| 186 | + | - Route synchronization | |||
| 187 | + | - Deployment of containers for routes missing containers | |||
| 188 | + | - Attachment deployment | |||
| 189 | + | ||||
| 190 | + | ### Example | |||
| 191 | + | ||||
| 192 | + | ```bash | |||
| 193 | + | gordon reload | |||
| 194 | + | ``` | |||
| 195 | + | ||||
| 196 | + | --- | |||
| 197 | + | ||||
| 198 | + | ## gordon deploy | |||
| 199 | + | ||||
| 200 | + | Manually deploy or redeploy a specific route. | |||
| 201 | + | ||||
| 202 | + | ### Synopsis | |||
| 203 | + | ||||
| 204 | + | ```bash | |||
| 205 | + | gordon deploy <domain> | |||
| 206 | + | ``` | |||
| 207 | + | ||||
| 208 | + | ### Arguments | |||
| 209 | + | ||||
| 210 | + | | Argument | Description | | |||
| 211 | + | |----------|-------------| | |||
| 212 | + | | `<domain>` | The domain name of the route to deploy (required) | | |||
| 213 | + | ||||
| 214 | + | ### Description | |||
| 215 | + | ||||
| 216 | + | Sends `SIGUSR2` to the Gordon process with the specified domain, triggering: | |||
| 217 | + | ||||
| 218 | + | - Fresh image pull (always pulls latest, ignoring cache) | |||
| 219 | + | - Container redeployment for the specified route | |||
| 220 | + | ||||
| 221 | + | ### Examples | |||
| 222 | + | ||||
| 223 | + | ```bash | |||
| 224 | + | gordon deploy myapp.example.com | |||
| 225 | + | gordon deploy api.example.com | |||
| 226 | + | ``` | |||
| 227 | + | ||||
| 228 | + | ### Use Cases | |||
| 229 | + | ||||
| 230 | + | - Recover from a failed deployment | |||
| 231 | + | - Force redeploy without pushing a new image | |||
| 232 | + | - Manual deployment when automatic deploy didn't trigger | |||
| 233 | + | ||||
| 234 | + | --- | |||
| 235 | + | ||||
| 236 | + | ## gordon logs | |||
| 237 | + | ||||
| 238 | + | Display Gordon process logs. | |||
| 239 | + | ||||
| 240 | + | ### Synopsis | |||
| 241 | + | ||||
| 242 | + | ```bash | |||
| 243 | + | gordon logs [options] | |||
| 244 | + | ``` | |||
| 245 | + | ||||
| 246 | + | ### Options | |||
| 247 | + | ||||
| 248 | + | | Option | Short | Default | Description | | |||
| 249 | + | |--------|-------|---------|-------------| | |||
| 250 | + | | `--config` | `-c` | Auto | Path to config file | | |||
| 251 | + | | `--follow` | `-f` | false | Follow log output (like `tail -f`) | | |||
| 252 | + | | `--lines` | `-n` | 50 | Number of lines to show | | |||
| 253 | + | ||||
| 254 | + | ### Examples | |||
| 255 | + | ||||
| 256 | + | ```bash | |||
| 257 | + | gordon logs # Last 50 lines | |||
| 258 | + | gordon logs -f # Follow logs | |||
| 259 | + | gordon logs -n 100 # Last 100 lines | |||
| 260 | + | gordon logs -f -n 200 # Follow, starting from last 200 lines | |||
| 261 | + | ``` | |||
| 262 | + | ||||
| 263 | + | ### Log Locations | |||
| 264 | + | ||||
| 265 | + | ```bash | |||
| 266 | + | # Using gordon logs | |||
| 267 | + | gordon logs -f | |||
| 268 | + | ||||
| 269 | + | # Direct file access | |||
| 270 | + | tail -f ~/.gordon/logs/gordon.log | |||
| 271 | + | ||||
| 272 | + | # With systemd | |||
| 273 | + | journalctl --user -u gordon -f | |||
| 274 | + | ``` | |||
| 275 | + | ||||
| 276 | + | --- | |||
| 277 | + | ||||
| 278 | + | ## gordon version | |||
| 279 | + | ||||
| 280 | + | Print version information. | |||
| 281 | + | ||||
| 282 | + | ### Synopsis | |||
| 283 | + | ||||
| 284 | + | ```bash | |||
| 285 | + | gordon version | |||
| 286 | + | ``` | |||
| 287 | + | ||||
| 288 | + | ### Output | |||
| 289 | + | ||||
| 290 | + | ``` | |||
| 291 | + | Gordon v2.0.0 | |||
| 292 | + | Commit: abc1234 | |||
| 293 | + | Build Date: 2024-01-15 | |||
| 294 | + | ``` | |||
| 295 | + | ||||
| 296 | + | --- | |||
| 297 | + | ||||
| 298 | + | ## gordon auth | |||
| 299 | + | ||||
| 300 | + | Manage registry authentication tokens and passwords. | |||
| 301 | + | ||||
| 302 | + | ### Subcommands | |||
| 303 | + | ||||
| 304 | + | | Subcommand | Description | | |||
| 305 | + | |------------|-------------| | |||
| 306 | + | | `token generate` | Generate a new JWT token | | |||
| 307 | + | | `token list` | List all stored tokens | | |||
| 308 | + | | `token revoke` | Revoke a token by ID | | |||
| 309 | + | | `password hash` | Generate bcrypt password hash | | |||
| 310 | + | | `internal` | Show internal registry credentials | | |||
| 311 | + | ||||
| 312 | + | ### gordon auth token generate | |||
| 313 | + | ||||
| 314 | + | Generate a new JWT authentication token for registry access. | |||
| 315 | + | ||||
| 316 | + | ```bash | |||
| 317 | + | gordon auth token generate --subject <name> [options] | |||
| 318 | + | ``` | |||
| 319 | + | ||||
| 320 | + | **Options:** | |||
| 321 | + | ||||
| 322 | + | | Option | Default | Description | | |||
| 323 | + | |--------|---------|-------------| | |||
| 324 | + | | `--subject` | (required) | Username/subject for the token | | |||
| 325 | + | | `--scopes` | `push,pull` | Comma-separated scopes | | |||
| 326 | + | | `--expiry` | `720h` | Duration until expiry (0 = never) | | |||
| 327 | + | | `-c, --config` | Auto | Path to config file | | |||
| 328 | + | ||||
| 329 | + | **Examples:** | |||
| 330 | + | ||||
| 331 | + | ```bash | |||
| 332 | + | # CI token that never expires | |||
| 333 | + | gordon auth token generate --subject github-actions --expiry 0 | |||
| 334 | + | ||||
| 335 | + | # Read-only token | |||
| 336 | + | gordon auth token generate --subject reader --scopes pull --expiry 720h | |||
| 337 | + | ||||
| 338 | + | # Push-only token for build system | |||
| 339 | + | gordon auth token generate --subject builder --scopes push --expiry 0 | |||
| 340 | + | ||||
| 341 | + | # Temporary token (24 hours) | |||
| 342 | + | gordon auth token generate --subject temp --expiry 24h | |||
| 343 | + | ``` | |||
| 344 | + | ||||
| 345 | + | **Output:** | |||
| 346 | + | ||||
| 347 | + | ``` | |||
| 348 | + | Token generated successfully! | |||
| 349 | + | Subject: github-actions | |||
| 350 | + | Scopes: push, pull | |||
| 351 | + | Expiry: never | |||
| 352 | + | ||||
| 353 | + | Token (use as password with docker login): | |||
| 354 | + | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |||
| 355 | + | ||||
| 356 | + | Usage: docker login -u github-actions -p <token> <registry> | |||
| 357 | + | ``` | |||
| 358 | + | ||||
| 359 | + | ### gordon auth token list | |||
| 360 | + | ||||
| 361 | + | List all stored authentication tokens. | |||
| 362 | + | ||||
| 363 | + | ```bash | |||
| 364 | + | gordon auth token list [options] | |||
| 365 | + | ``` | |||
| 366 | + | ||||
| 367 | + | **Output:** | |||
| 368 | + | ||||
| 369 | + | ``` | |||
| 370 | + | ID Subject Expires Revoked | |||
| 371 | + | ------------------------------------------------------------------------------------------ | |||
| 372 | + | a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no | |||
| 373 | + | b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no | |||
| 374 | + | c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes | |||
| 375 | + | ``` | |||
| 376 | + | ||||
| 377 | + | ### gordon auth token revoke | |||
| 378 | + | ||||
| 379 | + | Revoke a token by its ID. | |||
| 380 | + | ||||
| 381 | + | ```bash | |||
| 382 | + | gordon auth token revoke <token-id> [options] | |||
| 383 | + | ``` | |||
| 384 | + | ||||
| 385 | + | **Example:** | |||
| 386 | + | ||||
| 387 | + | ```bash | |||
| 388 | + | gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890 | |||
| 389 | + | ``` | |||
| 390 | + | ||||
| 391 | + | **Output:** | |||
| 392 | + | ||||
| 393 | + | ``` | |||
| 394 | + | Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |||
| 395 | + | ``` | |||
| 396 | + | ||||
| 397 | + | ### gordon auth password hash | |||
| 398 | + | ||||
| 399 | + | Generate a bcrypt hash for password authentication. | |||
| 400 | + | ||||
| 401 | + | ```bash | |||
| 402 | + | gordon auth password hash | |||
| 403 | + | ``` | |||
| 404 | + | ||||
| 405 | + | Interactively prompts for a password and outputs the bcrypt hash. | |||
| 406 | + | ||||
| 407 | + | **Output:** | |||
| 408 | + | ||||
| 409 | + | ``` | |||
| 410 | + | Enter password: ******** | |||
| 411 | + | ||||
| 412 | + | Bcrypt hash (store in your secrets backend): | |||
| 413 | + | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |||
| 414 | + | ||||
| 415 | + | Then reference the path in your config: | |||
| 416 | + | [registry_auth] | |||
| 417 | + | type = "password" | |||
| 418 | + | password_hash = "gordon/registry/password_hash" | |||
| 419 | + | ``` | |||
| 420 | + | ||||
| 421 | + | ### gordon auth internal | |||
| 422 | + | ||||
| 423 | + | Display the auto-generated internal registry credentials. | |||
| 424 | + | ||||
| 425 | + | ```bash | |||
| 426 | + | gordon auth internal | |||
| 427 | + | ``` | |||
| 428 | + | ||||
| 429 | + | Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues. | |||
| 430 | + | ||||
| 431 | + | **Output:** | |||
| 432 | + | ||||
| 433 | + | ``` | |||
| 434 | + | Internal Registry Credentials | |||
| 435 | + | ============================== | |||
| 436 | + | Username: gordon-internal | |||
| 437 | + | Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |||
| 438 | + | ||||
| 439 | + | Usage: | |||
| 440 | + | docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c... | |||
| 441 | + | ``` | |||
| 442 | + | ||||
| 443 | + | **Notes:** | |||
| 444 | + | - Credentials are regenerated each time Gordon starts | |||
| 445 | + | - Only available while Gordon is running | |||
| 446 | + | - Used for manual `docker pull` from the local registry during debugging | |||
| 447 | + | ||||
| 448 | + | **Use cases:** | |||
| 449 | + | - Debugging image pull failures | |||
| 450 | + | - Manual recovery after deployment issues | |||
| 451 | + | - Inspecting images in the local registry | |||
| 452 | + | ||||
| 453 | + | --- | |||
| 454 | + | ||||
| 455 | + | ## Token Scopes | |||
| 456 | + | ||||
| 457 | + | | Scope | Permission | | |||
| 458 | + | |-------|------------| | |||
| 459 | + | | `push` | Push images to registry | | |||
| 460 | + | | `pull` | Pull images from registry | | |||
| 461 | + | | `push,pull` | Both push and pull (default) | | |||
| 462 | + | ||||
| 463 | + | ## Token Expiry Formats | |||
| 464 | + | ||||
| 465 | + | | Format | Duration | | |||
| 466 | + | |--------|----------| | |||
| 467 | + | | `0` | Never expires | | |||
| 468 | + | | `24h` | 24 hours | | |||
| 469 | + | | `720h` | 30 days | | |||
| 470 | + | | `8760h` | 1 year | | |||
| 471 | + | ||||
| 472 | + | --- | |||
| 473 | + | ||||
| 474 | + | ## Workflow Examples | |||
| 475 | + | ||||
| 476 | + | ### CI/CD Setup | |||
| 477 | + | ||||
| 478 | + | ```bash | |||
| 479 | + | # Generate token for CI | |||
| 480 | + | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |||
| 481 | + | ||||
| 482 | + | # Save token as CI secret | |||
| 483 | + | # GitHub: Settings → Secrets → New repository secret | |||
| 484 | + | # GitLab: Settings → CI/CD → Variables | |||
| 485 | + | ``` | |||
| 486 | + | ||||
| 487 | + | ### Docker Login | |||
| 488 | + | ||||
| 489 | + | ```bash | |||
| 490 | + | # Using generated token | |||
| 491 | + | docker login -u ci-bot -p <token> registry.mydomain.com | |||
| 492 | + | ||||
| 493 | + | # In CI/CD | |||
| 494 | + | echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com | |||
| 495 | + | ``` | |||
| 496 | + | ||||
| 497 | + | ### Token Rotation | |||
| 498 | + | ||||
| 499 | + | ```bash | |||
| 500 | + | # Generate new token | |||
| 501 | + | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 502 | + | ||||
| 503 | + | # Update CI secrets with new token | |||
| 504 | + | ||||
| 505 | + | # Revoke old token | |||
| 506 | + | gordon auth token revoke <old-token-id> | |||
| 507 | + | ``` | |||
| 508 | + | ||||
| 509 | + | ### Password Setup | |||
| 510 | + | ||||
| 511 | + | ```bash | |||
| 512 | + | # Generate hash | |||
| 513 | + | gordon auth password hash | |||
| 514 | + | # Enter: mypassword | |||
| 515 | + | ||||
| 516 | + | # Store in pass | |||
| 517 | + | pass insert gordon/registry/password_hash | |||
| 518 | + | # Paste: $2a$10$... | |||
| 519 | + | ||||
| 520 | + | # Configure gordon.toml | |||
| 521 | + | [registry_auth] | |||
| 522 | + | enabled = true | |||
| 523 | + | type = "password" | |||
| 524 | + | username = "deploy" | |||
| 525 | + | password_hash = "gordon/registry/password_hash" | |||
| 526 | + | ``` | |||
| 527 | + | ||||
| 528 | + | --- | |||
| 529 | + | ||||
| 530 | + | ## Exit Codes | |||
| 531 | + | ||||
| 532 | + | | Code | Meaning | | |||
| 533 | + | |------|---------| | |||
| 534 | + | | 0 | Success | | |||
| 535 | + | | 1 | General error | | |||
| 536 | + | | 2 | Configuration error | | |||
| 537 | + | ||||
| 538 | + | ## Environment Variables | |||
| 539 | + | ||||
| 540 | + | Gordon reads configuration from environment variables: | |||
| 541 | + | ||||
| 542 | + | ```bash | |||
| 543 | + | GORDON_SERVER_PORT=8080 gordon start | |||
| 544 | + | GORDON_LOGGING_LEVEL=debug gordon start | |||
| 545 | + | ``` | |||
| 546 | + | ||||
| 547 | + | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | |||
| 548 | + | ||||
| 549 | + | ## Related | |||
| 550 | + | ||||
| 551 | + | - [Core Concepts](../concepts.md) | |||
| 552 | + | - [Configuration Reference](../config/index.md) | |||
D docs/cli/index.md +0 -151
| @@ -1,151 +0,0 @@ | |||
| 1 | - | # CLI Reference | |
| 2 | - | ||
| 3 | - | Gordon provides a command-line interface for server management and authentication. | |
| 4 | - | ||
| 5 | - | ## Commands | |
| 6 | - | ||
| 7 | - | | Command | Description | | |
| 8 | - | |---------|-------------| | |
| 9 | - | | `gordon start` | Start the Gordon server | | |
| 10 | - | | `gordon reload` | Reload configuration and sync containers | | |
| 11 | - | | `gordon logs` | Display Gordon process logs | | |
| 12 | - | | `gordon version` | Print version information | | |
| 13 | - | | `gordon auth` | Manage registry authentication | | |
| 14 | - | ||
| 15 | - | ## Quick Reference | |
| 16 | - | ||
| 17 | - | ```bash | |
| 18 | - | # Start Gordon | |
| 19 | - | gordon start | |
| 20 | - | gordon start --config /path/to/config.toml | |
| 21 | - | ||
| 22 | - | # Reload configuration | |
| 23 | - | gordon reload | |
| 24 | - | ||
| 25 | - | # View logs | |
| 26 | - | gordon logs | |
| 27 | - | gordon logs -f | |
| 28 | - | gordon logs -n 100 | |
| 29 | - | ||
| 30 | - | # Check version | |
| 31 | - | gordon version | |
| 32 | - | ||
| 33 | - | # Authentication | |
| 34 | - | gordon auth token generate --subject ci-bot --expiry 0 | |
| 35 | - | gordon auth token list | |
| 36 | - | gordon auth token revoke <token-id> | |
| 37 | - | gordon auth password hash | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | ## Global Options | |
| 41 | - | ||
| 42 | - | | Option | Description | | |
| 43 | - | |--------|-------------| | |
| 44 | - | | `-c, --config` | Path to configuration file | | |
| 45 | - | ||
| 46 | - | ## Command Details | |
| 47 | - | ||
| 48 | - | ### gordon start | |
| 49 | - | ||
| 50 | - | Starts the Gordon server with registry and proxy components. | |
| 51 | - | ||
| 52 | - | ```bash | |
| 53 | - | gordon start [options] | |
| 54 | - | ``` | |
| 55 | - | ||
| 56 | - | **Options:** | |
| 57 | - | - `-c, --config string` - Path to config file | |
| 58 | - | ||
| 59 | - | **Example:** | |
| 60 | - | ```bash | |
| 61 | - | gordon start | |
| 62 | - | gordon start --config ~/.config/gordon/gordon.toml | |
| 63 | - | gordon start -c /etc/gordon/gordon.toml | |
| 64 | - | ``` | |
| 65 | - | ||
| 66 | - | See [gordon start](./start.md) for details. | |
| 67 | - | ||
| 68 | - | ### gordon reload | |
| 69 | - | ||
| 70 | - | Sends a reload signal to the running Gordon process. Reloads configuration and syncs containers to match. | |
| 71 | - | ||
| 72 | - | ```bash | |
| 73 | - | gordon reload | |
| 74 | - | ``` | |
| 75 | - | ||
| 76 | - | This sends `SIGUSR1` to the Gordon process, triggering: | |
| 77 | - | - Configuration file reload | |
| 78 | - | - Route synchronization | |
| 79 | - | - Attachment deployment | |
| 80 | - | ||
| 81 | - | ### gordon logs | |
| 82 | - | ||
| 83 | - | Displays logs from the Gordon process. | |
| 84 | - | ||
| 85 | - | ```bash | |
| 86 | - | gordon logs [options] | |
| 87 | - | ``` | |
| 88 | - | ||
| 89 | - | **Options:** | |
| 90 | - | - `-c, --config string` - Path to config file | |
| 91 | - | - `-f, --follow` - Follow log output (like `tail -f`) | |
| 92 | - | - `-n, --lines int` - Number of lines to show (default: 50) | |
| 93 | - | ||
| 94 | - | **Examples:** | |
| 95 | - | ```bash | |
| 96 | - | gordon logs # Last 50 lines | |
| 97 | - | gordon logs -f # Follow logs | |
| 98 | - | gordon logs -n 100 # Last 100 lines | |
| 99 | - | gordon logs -f -n 200 # Follow, starting from last 200 lines | |
| 100 | - | ``` | |
| 101 | - | ||
| 102 | - | ### gordon version | |
| 103 | - | ||
| 104 | - | Prints version information. | |
| 105 | - | ||
| 106 | - | ```bash | |
| 107 | - | gordon version | |
| 108 | - | ``` | |
| 109 | - | ||
| 110 | - | **Output:** | |
| 111 | - | ``` | |
| 112 | - | Gordon v2.0.0 | |
| 113 | - | Commit: abc1234 | |
| 114 | - | Build Date: 2024-01-15 | |
| 115 | - | ``` | |
| 116 | - | ||
| 117 | - | ### gordon auth | |
| 118 | - | ||
| 119 | - | Manage registry authentication. See [gordon auth](./auth.md) for details. | |
| 120 | - | ||
| 121 | - | ```bash | |
| 122 | - | gordon auth token generate # Generate JWT token | |
| 123 | - | gordon auth token list # List all tokens | |
| 124 | - | gordon auth token revoke # Revoke a token | |
| 125 | - | gordon auth password hash # Generate bcrypt hash | |
| 126 | - | ``` | |
| 127 | - | ||
| 128 | - | ## Exit Codes | |
| 129 | - | ||
| 130 | - | | Code | Meaning | | |
| 131 | - | |------|---------| | |
| 132 | - | | 0 | Success | | |
| 133 | - | | 1 | General error | | |
| 134 | - | | 2 | Configuration error | | |
| 135 | - | ||
| 136 | - | ## Environment Variables | |
| 137 | - | ||
| 138 | - | Gordon reads configuration from environment variables: | |
| 139 | - | ||
| 140 | - | ```bash | |
| 141 | - | GORDON_SERVER_PORT=8080 gordon start | |
| 142 | - | GORDON_LOGGING_LEVEL=debug gordon start | |
| 143 | - | ``` | |
| 144 | - | ||
| 145 | - | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | |
| 146 | - | ||
| 147 | - | ## Related | |
| 148 | - | ||
| 149 | - | - [gordon start](./start.md) | |
| 150 | - | - [gordon auth](./auth.md) | |
| 151 | - | - [Configuration Reference](../config/index.md) | |
| @@ -1,151 +0,0 @@ | |||||
| 1 | - | # CLI Reference | |||
| 2 | - | ||||
| 3 | - | Gordon provides a command-line interface for server management and authentication. | |||
| 4 | - | ||||
| 5 | - | ## Commands | |||
| 6 | - | ||||
| 7 | - | | Command | Description | | |||
| 8 | - | |---------|-------------| | |||
| 9 | - | | `gordon start` | Start the Gordon server | | |||
| 10 | - | | `gordon reload` | Reload configuration and sync containers | | |||
| 11 | - | | `gordon logs` | Display Gordon process logs | | |||
| 12 | - | | `gordon version` | Print version information | | |||
| 13 | - | | `gordon auth` | Manage registry authentication | | |||
| 14 | - | ||||
| 15 | - | ## Quick Reference | |||
| 16 | - | ||||
| 17 | - | ```bash | |||
| 18 | - | # Start Gordon | |||
| 19 | - | gordon start | |||
| 20 | - | gordon start --config /path/to/config.toml | |||
| 21 | - | ||||
| 22 | - | # Reload configuration | |||
| 23 | - | gordon reload | |||
| 24 | - | ||||
| 25 | - | # View logs | |||
| 26 | - | gordon logs | |||
| 27 | - | gordon logs -f | |||
| 28 | - | gordon logs -n 100 | |||
| 29 | - | ||||
| 30 | - | # Check version | |||
| 31 | - | gordon version | |||
| 32 | - | ||||
| 33 | - | # Authentication | |||
| 34 | - | gordon auth token generate --subject ci-bot --expiry 0 | |||
| 35 | - | gordon auth token list | |||
| 36 | - | gordon auth token revoke <token-id> | |||
| 37 | - | gordon auth password hash | |||
| 38 | - | ``` | |||
| 39 | - | ||||
| 40 | - | ## Global Options | |||
| 41 | - | ||||
| 42 | - | | Option | Description | | |||
| 43 | - | |--------|-------------| | |||
| 44 | - | | `-c, --config` | Path to configuration file | | |||
| 45 | - | ||||
| 46 | - | ## Command Details | |||
| 47 | - | ||||
| 48 | - | ### gordon start | |||
| 49 | - | ||||
| 50 | - | Starts the Gordon server with registry and proxy components. | |||
| 51 | - | ||||
| 52 | - | ```bash | |||
| 53 | - | gordon start [options] | |||
| 54 | - | ``` | |||
| 55 | - | ||||
| 56 | - | **Options:** | |||
| 57 | - | - `-c, --config string` - Path to config file | |||
| 58 | - | ||||
| 59 | - | **Example:** | |||
| 60 | - | ```bash | |||
| 61 | - | gordon start | |||
| 62 | - | gordon start --config ~/.config/gordon/gordon.toml | |||
| 63 | - | gordon start -c /etc/gordon/gordon.toml | |||
| 64 | - | ``` | |||
| 65 | - | ||||
| 66 | - | See [gordon start](./start.md) for details. | |||
| 67 | - | ||||
| 68 | - | ### gordon reload | |||
| 69 | - | ||||
| 70 | - | Sends a reload signal to the running Gordon process. Reloads configuration and syncs containers to match. | |||
| 71 | - | ||||
| 72 | - | ```bash | |||
| 73 | - | gordon reload | |||
| 74 | - | ``` | |||
| 75 | - | ||||
| 76 | - | This sends `SIGUSR1` to the Gordon process, triggering: | |||
| 77 | - | - Configuration file reload | |||
| 78 | - | - Route synchronization | |||
| 79 | - | - Attachment deployment | |||
| 80 | - | ||||
| 81 | - | ### gordon logs | |||
| 82 | - | ||||
| 83 | - | Displays logs from the Gordon process. | |||
| 84 | - | ||||
| 85 | - | ```bash | |||
| 86 | - | gordon logs [options] | |||
| 87 | - | ``` | |||
| 88 | - | ||||
| 89 | - | **Options:** | |||
| 90 | - | - `-c, --config string` - Path to config file | |||
| 91 | - | - `-f, --follow` - Follow log output (like `tail -f`) | |||
| 92 | - | - `-n, --lines int` - Number of lines to show (default: 50) | |||
| 93 | - | ||||
| 94 | - | **Examples:** | |||
| 95 | - | ```bash | |||
| 96 | - | gordon logs # Last 50 lines | |||
| 97 | - | gordon logs -f # Follow logs | |||
| 98 | - | gordon logs -n 100 # Last 100 lines | |||
| 99 | - | gordon logs -f -n 200 # Follow, starting from last 200 lines | |||
| 100 | - | ``` | |||
| 101 | - | ||||
| 102 | - | ### gordon version | |||
| 103 | - | ||||
| 104 | - | Prints version information. | |||
| 105 | - | ||||
| 106 | - | ```bash | |||
| 107 | - | gordon version | |||
| 108 | - | ``` | |||
| 109 | - | ||||
| 110 | - | **Output:** | |||
| 111 | - | ``` | |||
| 112 | - | Gordon v2.0.0 | |||
| 113 | - | Commit: abc1234 | |||
| 114 | - | Build Date: 2024-01-15 | |||
| 115 | - | ``` | |||
| 116 | - | ||||
| 117 | - | ### gordon auth | |||
| 118 | - | ||||
| 119 | - | Manage registry authentication. See [gordon auth](./auth.md) for details. | |||
| 120 | - | ||||
| 121 | - | ```bash | |||
| 122 | - | gordon auth token generate # Generate JWT token | |||
| 123 | - | gordon auth token list # List all tokens | |||
| 124 | - | gordon auth token revoke # Revoke a token | |||
| 125 | - | gordon auth password hash # Generate bcrypt hash | |||
| 126 | - | ``` | |||
| 127 | - | ||||
| 128 | - | ## Exit Codes | |||
| 129 | - | ||||
| 130 | - | | Code | Meaning | | |||
| 131 | - | |------|---------| | |||
| 132 | - | | 0 | Success | | |||
| 133 | - | | 1 | General error | | |||
| 134 | - | | 2 | Configuration error | | |||
| 135 | - | ||||
| 136 | - | ## Environment Variables | |||
| 137 | - | ||||
| 138 | - | Gordon reads configuration from environment variables: | |||
| 139 | - | ||||
| 140 | - | ```bash | |||
| 141 | - | GORDON_SERVER_PORT=8080 gordon start | |||
| 142 | - | GORDON_LOGGING_LEVEL=debug gordon start | |||
| 143 | - | ``` | |||
| 144 | - | ||||
| 145 | - | Pattern: `GORDON_SECTION_KEY` (uppercase, underscores) | |||
| 146 | - | ||||
| 147 | - | ## Related | |||
| 148 | - | ||||
| 149 | - | - [gordon start](./start.md) | |||
| 150 | - | - [gordon auth](./auth.md) | |||
| 151 | - | - [Configuration Reference](../config/index.md) | |||
D docs/cli/start.md +0 -161
| @@ -1,161 +0,0 @@ | |||
| 1 | - | # gordon start | |
| 2 | - | ||
| 3 | - | Start the Gordon server with registry and proxy components. | |
| 4 | - | ||
| 5 | - | ## Synopsis | |
| 6 | - | ||
| 7 | - | ```bash | |
| 8 | - | gordon start [options] | |
| 9 | - | ``` | |
| 10 | - | ||
| 11 | - | ## Options | |
| 12 | - | ||
| 13 | - | | Option | Short | Default | Description | | |
| 14 | - | |--------|-------|---------|-------------| | |
| 15 | - | | `--config` | `-c` | Auto-detected | Path to configuration file | | |
| 16 | - | ||
| 17 | - | ## Description | |
| 18 | - | ||
| 19 | - | Starts the Gordon server, which includes: | |
| 20 | - | ||
| 21 | - | - **Container Registry** - Receives image pushes on the registry port | |
| 22 | - | - **HTTP Proxy** - Routes traffic to containers on the proxy port | |
| 23 | - | - **Event Bus** - Coordinates deployments and updates | |
| 24 | - | - **Config Watcher** - Monitors configuration file for changes | |
| 25 | - | ||
| 26 | - | ## Configuration File Detection | |
| 27 | - | ||
| 28 | - | Gordon looks for configuration in this order: | |
| 29 | - | ||
| 30 | - | 1. Path specified with `--config` | |
| 31 | - | 2. `/etc/gordon/gordon.toml` | |
| 32 | - | 3. `~/.config/gordon/gordon.toml` | |
| 33 | - | 4. `./gordon.toml` | |
| 34 | - | ||
| 35 | - | ## First Run | |
| 36 | - | ||
| 37 | - | On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`. | |
| 38 | - | ||
| 39 | - | ```bash | |
| 40 | - | # First run - creates default config | |
| 41 | - | gordon start | |
| 42 | - | # Edit the config, then restart | |
| 43 | - | ``` | |
| 44 | - | ||
| 45 | - | ## Examples | |
| 46 | - | ||
| 47 | - | ### Basic Start | |
| 48 | - | ||
| 49 | - | ```bash | |
| 50 | - | gordon start | |
| 51 | - | ``` | |
| 52 | - | ||
| 53 | - | ### With Custom Config | |
| 54 | - | ||
| 55 | - | ```bash | |
| 56 | - | gordon start --config /path/to/gordon.toml | |
| 57 | - | gordon start -c ./my-config.toml | |
| 58 | - | ``` | |
| 59 | - | ||
| 60 | - | ### With Environment Override | |
| 61 | - | ||
| 62 | - | ```bash | |
| 63 | - | GORDON_SERVER_PORT=8080 gordon start | |
| 64 | - | GORDON_LOGGING_LEVEL=debug gordon start | |
| 65 | - | ``` | |
| 66 | - | ||
| 67 | - | ## Signals | |
| 68 | - | ||
| 69 | - | Gordon responds to these signals: | |
| 70 | - | ||
| 71 | - | | Signal | Action | | |
| 72 | - | |--------|--------| | |
| 73 | - | | `SIGTERM` | Graceful shutdown | | |
| 74 | - | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |
| 75 | - | | `SIGUSR1` | Reload configuration | | |
| 76 | - | ||
| 77 | - | ## Process Management | |
| 78 | - | ||
| 79 | - | ### Running in Foreground | |
| 80 | - | ||
| 81 | - | ```bash | |
| 82 | - | gordon start | |
| 83 | - | # Press Ctrl+C to stop | |
| 84 | - | ``` | |
| 85 | - | ||
| 86 | - | ### Running with systemd | |
| 87 | - | ||
| 88 | - | ```bash | |
| 89 | - | # Create user service | |
| 90 | - | mkdir -p ~/.config/systemd/user | |
| 91 | - | cat > ~/.config/systemd/user/gordon.service <<EOF | |
| 92 | - | [Unit] | |
| 93 | - | Description=Gordon Container Platform | |
| 94 | - | ||
| 95 | - | [Service] | |
| 96 | - | Type=simple | |
| 97 | - | Restart=always | |
| 98 | - | ExecStart=/usr/local/bin/gordon start | |
| 99 | - | ||
| 100 | - | [Install] | |
| 101 | - | WantedBy=default.target | |
| 102 | - | EOF | |
| 103 | - | ||
| 104 | - | # Enable and start | |
| 105 | - | systemctl --user daemon-reload | |
| 106 | - | systemctl --user enable --now gordon | |
| 107 | - | sudo loginctl enable-linger $USER | |
| 108 | - | ``` | |
| 109 | - | ||
| 110 | - | ### Check Status | |
| 111 | - | ||
| 112 | - | ```bash | |
| 113 | - | # systemd | |
| 114 | - | systemctl --user status gordon | |
| 115 | - | ||
| 116 | - | # Process | |
| 117 | - | pgrep -f "gordon start" | |
| 118 | - | ``` | |
| 119 | - | ||
| 120 | - | ## Startup Sequence | |
| 121 | - | ||
| 122 | - | 1. Load configuration | |
| 123 | - | 2. Initialize logger | |
| 124 | - | 3. Create PID file | |
| 125 | - | 4. Connect to Docker/Podman runtime | |
| 126 | - | 5. Create storage directories | |
| 127 | - | 6. Initialize services (registry, proxy, auth) | |
| 128 | - | 7. Register event handlers | |
| 129 | - | 8. Start config file watcher | |
| 130 | - | 9. Sync existing containers | |
| 131 | - | 10. Start HTTP servers | |
| 132 | - | ||
| 133 | - | ## Shutdown Sequence | |
| 134 | - | ||
| 135 | - | 1. Receive shutdown signal | |
| 136 | - | 2. Stop accepting new requests | |
| 137 | - | 3. Complete in-flight requests | |
| 138 | - | 4. Stop managed containers (if configured) | |
| 139 | - | 5. Remove PID file | |
| 140 | - | 6. Exit | |
| 141 | - | ||
| 142 | - | ## Logs | |
| 143 | - | ||
| 144 | - | View startup and runtime logs: | |
| 145 | - | ||
| 146 | - | ```bash | |
| 147 | - | # Using gordon logs | |
| 148 | - | gordon logs -f | |
| 149 | - | ||
| 150 | - | # Direct file access | |
| 151 | - | tail -f ~/.gordon/logs/gordon.log | |
| 152 | - | ||
| 153 | - | # With systemd | |
| 154 | - | journalctl --user -u gordon -f | |
| 155 | - | ``` | |
| 156 | - | ||
| 157 | - | ## Related | |
| 158 | - | ||
| 159 | - | - [CLI Overview](./index.md) | |
| 160 | - | - [Configuration](../config/index.md) | |
| 161 | - | - [Installation](../installation.md) | |
| @@ -1,161 +0,0 @@ | |||||
| 1 | - | # gordon start | |||
| 2 | - | ||||
| 3 | - | Start the Gordon server with registry and proxy components. | |||
| 4 | - | ||||
| 5 | - | ## Synopsis | |||
| 6 | - | ||||
| 7 | - | ```bash | |||
| 8 | - | gordon start [options] | |||
| 9 | - | ``` | |||
| 10 | - | ||||
| 11 | - | ## Options | |||
| 12 | - | ||||
| 13 | - | | Option | Short | Default | Description | | |||
| 14 | - | |--------|-------|---------|-------------| | |||
| 15 | - | | `--config` | `-c` | Auto-detected | Path to configuration file | | |||
| 16 | - | ||||
| 17 | - | ## Description | |||
| 18 | - | ||||
| 19 | - | Starts the Gordon server, which includes: | |||
| 20 | - | ||||
| 21 | - | - **Container Registry** - Receives image pushes on the registry port | |||
| 22 | - | - **HTTP Proxy** - Routes traffic to containers on the proxy port | |||
| 23 | - | - **Event Bus** - Coordinates deployments and updates | |||
| 24 | - | - **Config Watcher** - Monitors configuration file for changes | |||
| 25 | - | ||||
| 26 | - | ## Configuration File Detection | |||
| 27 | - | ||||
| 28 | - | Gordon looks for configuration in this order: | |||
| 29 | - | ||||
| 30 | - | 1. Path specified with `--config` | |||
| 31 | - | 2. `/etc/gordon/gordon.toml` | |||
| 32 | - | 3. `~/.config/gordon/gordon.toml` | |||
| 33 | - | 4. `./gordon.toml` | |||
| 34 | - | ||||
| 35 | - | ## First Run | |||
| 36 | - | ||||
| 37 | - | On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`. | |||
| 38 | - | ||||
| 39 | - | ```bash | |||
| 40 | - | # First run - creates default config | |||
| 41 | - | gordon start | |||
| 42 | - | # Edit the config, then restart | |||
| 43 | - | ``` | |||
| 44 | - | ||||
| 45 | - | ## Examples | |||
| 46 | - | ||||
| 47 | - | ### Basic Start | |||
| 48 | - | ||||
| 49 | - | ```bash | |||
| 50 | - | gordon start | |||
| 51 | - | ``` | |||
| 52 | - | ||||
| 53 | - | ### With Custom Config | |||
| 54 | - | ||||
| 55 | - | ```bash | |||
| 56 | - | gordon start --config /path/to/gordon.toml | |||
| 57 | - | gordon start -c ./my-config.toml | |||
| 58 | - | ``` | |||
| 59 | - | ||||
| 60 | - | ### With Environment Override | |||
| 61 | - | ||||
| 62 | - | ```bash | |||
| 63 | - | GORDON_SERVER_PORT=8080 gordon start | |||
| 64 | - | GORDON_LOGGING_LEVEL=debug gordon start | |||
| 65 | - | ``` | |||
| 66 | - | ||||
| 67 | - | ## Signals | |||
| 68 | - | ||||
| 69 | - | Gordon responds to these signals: | |||
| 70 | - | ||||
| 71 | - | | Signal | Action | | |||
| 72 | - | |--------|--------| | |||
| 73 | - | | `SIGTERM` | Graceful shutdown | | |||
| 74 | - | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |||
| 75 | - | | `SIGUSR1` | Reload configuration | | |||
| 76 | - | ||||
| 77 | - | ## Process Management | |||
| 78 | - | ||||
| 79 | - | ### Running in Foreground | |||
| 80 | - | ||||
| 81 | - | ```bash | |||
| 82 | - | gordon start | |||
| 83 | - | # Press Ctrl+C to stop | |||
| 84 | - | ``` | |||
| 85 | - | ||||
| 86 | - | ### Running with systemd | |||
| 87 | - | ||||
| 88 | - | ```bash | |||
| 89 | - | # Create user service | |||
| 90 | - | mkdir -p ~/.config/systemd/user | |||
| 91 | - | cat > ~/.config/systemd/user/gordon.service <<EOF | |||
| 92 | - | [Unit] | |||
| 93 | - | Description=Gordon Container Platform | |||
| 94 | - | ||||
| 95 | - | [Service] | |||
| 96 | - | Type=simple | |||
| 97 | - | Restart=always | |||
| 98 | - | ExecStart=/usr/local/bin/gordon start | |||
| 99 | - | ||||
| 100 | - | [Install] | |||
| 101 | - | WantedBy=default.target | |||
| 102 | - | EOF | |||
| 103 | - | ||||
| 104 | - | # Enable and start | |||
| 105 | - | systemctl --user daemon-reload | |||
| 106 | - | systemctl --user enable --now gordon | |||
| 107 | - | sudo loginctl enable-linger $USER | |||
| 108 | - | ``` | |||
| 109 | - | ||||
| 110 | - | ### Check Status | |||
| 111 | - | ||||
| 112 | - | ```bash | |||
| 113 | - | # systemd | |||
| 114 | - | systemctl --user status gordon | |||
| 115 | - | ||||
| 116 | - | # Process | |||
| 117 | - | pgrep -f "gordon start" | |||
| 118 | - | ``` | |||
| 119 | - | ||||
| 120 | - | ## Startup Sequence | |||
| 121 | - | ||||
| 122 | - | 1. Load configuration | |||
| 123 | - | 2. Initialize logger | |||
| 124 | - | 3. Create PID file | |||
| 125 | - | 4. Connect to Docker/Podman runtime | |||
| 126 | - | 5. Create storage directories | |||
| 127 | - | 6. Initialize services (registry, proxy, auth) | |||
| 128 | - | 7. Register event handlers | |||
| 129 | - | 8. Start config file watcher | |||
| 130 | - | 9. Sync existing containers | |||
| 131 | - | 10. Start HTTP servers | |||
| 132 | - | ||||
| 133 | - | ## Shutdown Sequence | |||
| 134 | - | ||||
| 135 | - | 1. Receive shutdown signal | |||
| 136 | - | 2. Stop accepting new requests | |||
| 137 | - | 3. Complete in-flight requests | |||
| 138 | - | 4. Stop managed containers (if configured) | |||
| 139 | - | 5. Remove PID file | |||
| 140 | - | 6. Exit | |||
| 141 | - | ||||
| 142 | - | ## Logs | |||
| 143 | - | ||||
| 144 | - | View startup and runtime logs: | |||
| 145 | - | ||||
| 146 | - | ```bash | |||
| 147 | - | # Using gordon logs | |||
| 148 | - | gordon logs -f | |||
| 149 | - | ||||
| 150 | - | # Direct file access | |||
| 151 | - | tail -f ~/.gordon/logs/gordon.log | |||
| 152 | - | ||||
| 153 | - | # With systemd | |||
| 154 | - | journalctl --user -u gordon -f | |||
| 155 | - | ``` | |||
| 156 | - | ||||
| 157 | - | ## Related | |||
| 158 | - | ||||
| 159 | - | - [CLI Overview](./index.md) | |||
| 160 | - | - [Configuration](../config/index.md) | |||
| 161 | - | - [Installation](../installation.md) | |||
M docs/concepts.md +1 -0
| @@ -208,6 +208,7 @@ Gordon uses an internal event system for coordination: | |||
| 208 | 208 | | `image.pushed` | Image pushed to registry | Deploy container | | |
| 209 | 209 | | `config.reload` | Config file changed | Sync containers | | |
| 210 | 210 | | `manual.reload` | `gordon reload` command | Sync containers | | |
| 211 | + | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | |
| 211 | 212 | | `container.deployed` | Container started | Update proxy cache | | |
| 212 | 213 | ||
| 213 | 214 | ## Container Labels | |
| @@ -208,6 +208,7 @@ Gordon uses an internal event system for coordination: | |||||
| 208 | | `image.pushed` | Image pushed to registry | Deploy container | | 208 | | `image.pushed` | Image pushed to registry | Deploy container | | ||
| 209 | | `config.reload` | Config file changed | Sync containers | | 209 | | `config.reload` | Config file changed | Sync containers | | ||
| 210 | | `manual.reload` | `gordon reload` command | Sync containers | | 210 | | `manual.reload` | `gordon reload` command | Sync containers | | ||
| 211 | + | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | |||
| 211 | | `container.deployed` | Container started | Update proxy cache | | 212 | | `container.deployed` | Container started | Update proxy cache | | ||
| 212 | 213 | ||||
| 213 | ## Container Labels | 214 | ## Container Labels | ||
A docs/config/external-routes.md +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | # External Routes Configuration | |
| 2 | + | ||
| 3 | + | External routes allow proxying to non-containerized services running on the host or network. | |
| 4 | + | ||
| 5 | + | ## Configuration | |
| 6 | + | ||
| 7 | + | ```toml | |
| 8 | + | [external_routes] | |
| 9 | + | "service.mydomain.com" = "localhost:5000" | |
| 10 | + | "cache.mydomain.com" = "192.168.1.100:6379" | |
| 11 | + | ``` | |
| 12 | + | ||
| 13 | + | ## Syntax | |
| 14 | + | ||
| 15 | + | ```toml | |
| 16 | + | [external_routes] | |
| 17 | + | "<domain>" = "<host>:<port>" | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | | Component | Description | | |
| 21 | + | |-----------|-------------| | |
| 22 | + | | `domain` | Fully qualified domain name | | |
| 23 | + | | `host` | Target hostname or IP address | | |
| 24 | + | | `port` | Target port number | | |
| 25 | + | ||
| 26 | + | ## Use Cases | |
| 27 | + | ||
| 28 | + | ### Docker Registry | |
| 29 | + | ||
| 30 | + | Proxy to a Docker registry running outside of Gordon: | |
| 31 | + | ||
| 32 | + | ```toml | |
| 33 | + | [external_routes] | |
| 34 | + | "registry.mydomain.com" = "localhost:5000" | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | ### Database Admin Tools | |
| 38 | + | ||
| 39 | + | Proxy to database admin interfaces: | |
| 40 | + | ||
| 41 | + | ```toml | |
| 42 | + | [external_routes] | |
| 43 | + | "pgadmin.mydomain.com" = "localhost:5050" | |
| 44 | + | "redis-commander.mydomain.com" = "localhost:8081" | |
| 45 | + | ``` | |
| 46 | + | ||
| 47 | + | ### Legacy Services | |
| 48 | + | ||
| 49 | + | Proxy to services that can't be containerized: | |
| 50 | + | ||
| 51 | + | ```toml | |
| 52 | + | [external_routes] | |
| 53 | + | "legacy-api.mydomain.com" = "192.168.1.50:8080" | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | ## How It Works | |
| 57 | + | ||
| 58 | + | 1. Request arrives for `service.mydomain.com` | |
| 59 | + | 2. Gordon checks if domain matches an external route | |
| 60 | + | 3. If matched, proxies directly to the configured `host:port` | |
| 61 | + | 4. No container lookup is performed | |
| 62 | + | ||
| 63 | + | ``` | |
| 64 | + | Client ─> Gordon Proxy ─> External Service | |
| 65 | + | (port 80) (configured host:port) | |
| 66 | + | ``` | |
| 67 | + | ||
| 68 | + | ## Limitations | |
| 69 | + | ||
| 70 | + | - HTTP only (no HTTPS upstream) | |
| 71 | + | - No health checks | |
| 72 | + | - No load balancing | |
| 73 | + | ||
| 74 | + | ## Hot Reload | |
| 75 | + | ||
| 76 | + | External routes reload automatically when the config file changes: | |
| 77 | + | ||
| 78 | + | ```bash | |
| 79 | + | # Edit config | |
| 80 | + | vim ~/.config/gordon/gordon.toml | |
| 81 | + | ||
| 82 | + | # Add external route | |
| 83 | + | [external_routes] | |
| 84 | + | "newservice.mydomain.com" = "localhost:9000" | |
| 85 | + | ||
| 86 | + | # Save - Gordon reloads automatically | |
| 87 | + | ``` | |
| 88 | + | ||
| 89 | + | ## Related | |
| 90 | + | ||
| 91 | + | - [Routes Configuration](./routes.md) | |
| 92 | + | - [Configuration Overview](./index.md) | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | # External Routes Configuration | |||
| 2 | + | ||||
| 3 | + | External routes allow proxying to non-containerized services running on the host or network. | |||
| 4 | + | ||||
| 5 | + | ## Configuration | |||
| 6 | + | ||||
| 7 | + | ```toml | |||
| 8 | + | [external_routes] | |||
| 9 | + | "service.mydomain.com" = "localhost:5000" | |||
| 10 | + | "cache.mydomain.com" = "192.168.1.100:6379" | |||
| 11 | + | ``` | |||
| 12 | + | ||||
| 13 | + | ## Syntax | |||
| 14 | + | ||||
| 15 | + | ```toml | |||
| 16 | + | [external_routes] | |||
| 17 | + | "<domain>" = "<host>:<port>" | |||
| 18 | + | ``` | |||
| 19 | + | ||||
| 20 | + | | Component | Description | | |||
| 21 | + | |-----------|-------------| | |||
| 22 | + | | `domain` | Fully qualified domain name | | |||
| 23 | + | | `host` | Target hostname or IP address | | |||
| 24 | + | | `port` | Target port number | | |||
| 25 | + | ||||
| 26 | + | ## Use Cases | |||
| 27 | + | ||||
| 28 | + | ### Docker Registry | |||
| 29 | + | ||||
| 30 | + | Proxy to a Docker registry running outside of Gordon: | |||
| 31 | + | ||||
| 32 | + | ```toml | |||
| 33 | + | [external_routes] | |||
| 34 | + | "registry.mydomain.com" = "localhost:5000" | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | ### Database Admin Tools | |||
| 38 | + | ||||
| 39 | + | Proxy to database admin interfaces: | |||
| 40 | + | ||||
| 41 | + | ```toml | |||
| 42 | + | [external_routes] | |||
| 43 | + | "pgadmin.mydomain.com" = "localhost:5050" | |||
| 44 | + | "redis-commander.mydomain.com" = "localhost:8081" | |||
| 45 | + | ``` | |||
| 46 | + | ||||
| 47 | + | ### Legacy Services | |||
| 48 | + | ||||
| 49 | + | Proxy to services that can't be containerized: | |||
| 50 | + | ||||
| 51 | + | ```toml | |||
| 52 | + | [external_routes] | |||
| 53 | + | "legacy-api.mydomain.com" = "192.168.1.50:8080" | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | ## How It Works | |||
| 57 | + | ||||
| 58 | + | 1. Request arrives for `service.mydomain.com` | |||
| 59 | + | 2. Gordon checks if domain matches an external route | |||
| 60 | + | 3. If matched, proxies directly to the configured `host:port` | |||
| 61 | + | 4. No container lookup is performed | |||
| 62 | + | ||||
| 63 | + | ``` | |||
| 64 | + | Client ─> Gordon Proxy ─> External Service | |||
| 65 | + | (port 80) (configured host:port) | |||
| 66 | + | ``` | |||
| 67 | + | ||||
| 68 | + | ## Limitations | |||
| 69 | + | ||||
| 70 | + | - HTTP only (no HTTPS upstream) | |||
| 71 | + | - No health checks | |||
| 72 | + | - No load balancing | |||
| 73 | + | ||||
| 74 | + | ## Hot Reload | |||
| 75 | + | ||||
| 76 | + | External routes reload automatically when the config file changes: | |||
| 77 | + | ||||
| 78 | + | ```bash | |||
| 79 | + | # Edit config | |||
| 80 | + | vim ~/.config/gordon/gordon.toml | |||
| 81 | + | ||||
| 82 | + | # Add external route | |||
| 83 | + | [external_routes] | |||
| 84 | + | "newservice.mydomain.com" = "localhost:9000" | |||
| 85 | + | ||||
| 86 | + | # Save - Gordon reloads automatically | |||
| 87 | + | ``` | |||
| 88 | + | ||||
| 89 | + | ## Related | |||
| 90 | + | ||||
| 91 | + | - [Routes Configuration](./routes.md) | |||
| 92 | + | - [Configuration Overview](./index.md) | |||
M docs/config/index.md +2 -0
| @@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names | |||
| 115 | 115 | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | |
| 116 | 116 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | |
| 117 | 117 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | |
| 118 | + | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| 118 | 119 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 119 | 120 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 120 | 121 | ||
| @@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 173 | 174 | ||
| 174 | 175 | - [Server Configuration](./server.md) | |
| 175 | 176 | - [Routes Configuration](./routes.md) | |
| 177 | + | - [External Routes](./external-routes.md) | |
| 176 | 178 | - [Registry Authentication](./registry-auth.md) | |
| @@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names | |||||
| 115 | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | 115 | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | ||
| 116 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | 116 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | ||
| 117 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | 117 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | ||
| 118 | + | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |||
| 118 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | 119 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | ||
| 119 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | 120 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | ||
| 120 | 121 | ||||
| @@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||||
| 173 | 174 | ||||
| 174 | - [Server Configuration](./server.md) | 175 | - [Server Configuration](./server.md) | ||
| 175 | - [Routes Configuration](./routes.md) | 176 | - [Routes Configuration](./routes.md) | ||
| 177 | + | - [External Routes](./external-routes.md) | |||
| 176 | - [Registry Authentication](./registry-auth.md) | 178 | - [Registry Authentication](./registry-auth.md) | ||
M docs/config/logging.md +1 -1
| @@ -202,5 +202,5 @@ Sensitive values (passwords, tokens) are automatically redacted. | |||
| 202 | 202 | ## Related | |
| 203 | 203 | ||
| 204 | 204 | - [Configuration Overview](./index.md) | |
| 205 | - | - [CLI Commands](../cli/index.md) | |
| 205 | + | - [CLI Commands](../cli/commands.md) | |
| 206 | 206 | - [Troubleshooting](../reference/troubleshooting.md) | |
| @@ -202,5 +202,5 @@ Sensitive values (passwords, tokens) are automatically redacted. | |||||
| 202 | ## Related | 202 | ## Related | ||
| 203 | 203 | ||||
| 204 | - [Configuration Overview](./index.md) | 204 | - [Configuration Overview](./index.md) | ||
| 205 | - | - [CLI Commands](../cli/index.md) | 205 | + | - [CLI Commands](../cli/commands.md) |
| 206 | - [Troubleshooting](../reference/troubleshooting.md) | 206 | - [Troubleshooting](../reference/troubleshooting.md) | ||
M docs/config/registry-auth.md +1 -1
| @@ -197,5 +197,5 @@ Gordon generates internal credentials automatically when auth is enabled. These | |||
| 197 | 197 | ## Related | |
| 198 | 198 | ||
| 199 | 199 | - [Secrets Configuration](./secrets.md) | |
| 200 | - | - [CLI Auth Commands](../cli/auth.md) | |
| 200 | + | - [CLI Commands](../cli/commands.md) | |
| 201 | 201 | - [GitHub Actions Deployment](../deployment/github-actions.md) | |
| @@ -197,5 +197,5 @@ Gordon generates internal credentials automatically when auth is enabled. These | |||||
| 197 | ## Related | 197 | ## Related | ||
| 198 | 198 | ||||
| 199 | - [Secrets Configuration](./secrets.md) | 199 | - [Secrets Configuration](./secrets.md) | ||
| 200 | - | - [CLI Auth Commands](../cli/auth.md) | 200 | + | - [CLI Commands](../cli/commands.md) |
| 201 | - [GitHub Actions Deployment](../deployment/github-actions.md) | 201 | - [GitHub Actions Deployment](../deployment/github-actions.md) | ||
M docs/config/routes.md +5 -0
| @@ -194,8 +194,13 @@ Add to `/etc/hosts`: | |||
| 194 | 194 | "portal.acme-corp.com" = "saas-app:v2.1.0" | |
| 195 | 195 | ``` | |
| 196 | 196 | ||
| 197 | + | ## External Services | |
| 198 | + | ||
| 199 | + | For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md). | |
| 200 | + | ||
| 197 | 201 | ## Related | |
| 198 | 202 | ||
| 199 | 203 | - [Configuration Overview](./index.md) | |
| 200 | 204 | - [Auto Route](./auto-route.md) | |
| 201 | 205 | - [Attachments](./attachments.md) | |
| 206 | + | - [External Routes](./external-routes.md) | |
| @@ -194,8 +194,13 @@ Add to `/etc/hosts`: | |||||
| 194 | "portal.acme-corp.com" = "saas-app:v2.1.0" | 194 | "portal.acme-corp.com" = "saas-app:v2.1.0" | ||
| 195 | ``` | 195 | ``` | ||
| 196 | 196 | ||||
| 197 | + | ## External Services | |||
| 198 | + | ||||
| 199 | + | For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md). | |||
| 200 | + | ||||
| 197 | ## Related | 201 | ## Related | ||
| 198 | 202 | ||||
| 199 | - [Configuration Overview](./index.md) | 203 | - [Configuration Overview](./index.md) | ||
| 200 | - [Auto Route](./auto-route.md) | 204 | - [Auto Route](./auto-route.md) | ||
| 201 | - [Attachments](./attachments.md) | 205 | - [Attachments](./attachments.md) | ||
| 206 | + | - [External Routes](./external-routes.md) | |||
M docs/index.md +1 -3
| @@ -55,9 +55,7 @@ Gordon runs on your VPS and provides: | |||
| 55 | 55 | ||
| 56 | 56 | ### CLI Reference | |
| 57 | 57 | ||
| 58 | - | - [CLI Overview](./cli/index.md) - Available commands | |
| 59 | - | - [gordon start](./cli/start.md) - Start the server | |
| 60 | - | - [gordon auth](./cli/auth.md) - Manage authentication | |
| 58 | + | - [CLI Commands](./cli/commands.md) - All available commands | |
| 61 | 59 | ||
| 62 | 60 | ### Deployment | |
| 63 | 61 | ||
| @@ -55,9 +55,7 @@ Gordon runs on your VPS and provides: | |||||
| 55 | 55 | ||||
| 56 | ### CLI Reference | 56 | ### CLI Reference | ||
| 57 | 57 | ||||
| 58 | - | - [CLI Overview](./cli/index.md) - Available commands | 58 | + | - [CLI Commands](./cli/commands.md) - All available commands |
| 59 | - | - [gordon start](./cli/start.md) - Start the server | |||
| 60 | - | - [gordon auth](./cli/auth.md) - Manage authentication | |||
| 61 | 59 | ||||
| 62 | ### Deployment | 60 | ### Deployment | ||
| 63 | 61 | ||||
M docs/reference/troubleshooting.md +1 -1
| @@ -334,4 +334,4 @@ curl -v http://localhost:8080/ | |||
| 334 | 334 | ||
| 335 | 335 | - [Installation](../installation.md) | |
| 336 | 336 | - [Configuration](../config/index.md) | |
| 337 | - | - [CLI Reference](../cli/index.md) | |
| 337 | + | - [CLI Commands](../cli/commands.md) | |
| @@ -334,4 +334,4 @@ curl -v http://localhost:8080/ | |||||
| 334 | 334 | ||||
| 335 | - [Installation](../installation.md) | 335 | - [Installation](../installation.md) | ||
| 336 | - [Configuration](../config/index.md) | 336 | - [Configuration](../config/index.md) | ||
| 337 | - | - [CLI Reference](../cli/index.md) | 337 | + | - [CLI Commands](../cli/commands.md) |
M internal/adapters/in/cli/auth.go +39 -0
| @@ -30,6 +30,7 @@ func newAuthCmd() *cobra.Command { | |||
| 30 | 30 | ||
| 31 | 31 | cmd.AddCommand(newAuthTokenCmd()) | |
| 32 | 32 | cmd.AddCommand(newAuthPasswordCmd()) | |
| 33 | + | cmd.AddCommand(newAuthInternalCmd()) | |
| 33 | 34 | ||
| 34 | 35 | return cmd | |
| 35 | 36 | } | |
| @@ -132,6 +133,44 @@ func newAuthPasswordCmd() *cobra.Command { | |||
| 132 | 133 | return cmd | |
| 133 | 134 | } | |
| 134 | 135 | ||
| 136 | + | // newAuthInternalCmd creates the internal credentials command. | |
| 137 | + | func newAuthInternalCmd() *cobra.Command { | |
| 138 | + | return &cobra.Command{ | |
| 139 | + | Use: "internal", | |
| 140 | + | Short: "Show internal registry credentials for manual recovery", | |
| 141 | + | Long: `Display the auto-generated internal registry credentials. | |
| 142 | + | ||
| 143 | + | These credentials are used by Gordon for pulling images from the local registry | |
| 144 | + | (localhost:5000) during internal deploys. You can use them for manual recovery: | |
| 145 | + | ||
| 146 | + | docker login localhost:5000 -u gordon-internal -p <password> | |
| 147 | + | ||
| 148 | + | Note: Credentials are regenerated each time Gordon starts, and are only | |
| 149 | + | available while Gordon is running.`, | |
| 150 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 151 | + | return runShowInternalAuth() | |
| 152 | + | }, | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // runShowInternalAuth displays the internal registry credentials. | |
| 157 | + | func runShowInternalAuth() error { | |
| 158 | + | creds, err := app.GetInternalCredentials() | |
| 159 | + | if err != nil { | |
| 160 | + | return err | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | fmt.Println("Internal Registry Credentials") | |
| 164 | + | fmt.Println("==============================") | |
| 165 | + | fmt.Printf("Username: %s\n", creds.Username) | |
| 166 | + | fmt.Printf("Password: %s\n", creds.Password) | |
| 167 | + | fmt.Println() | |
| 168 | + | fmt.Println("Usage:") | |
| 169 | + | fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password) | |
| 170 | + | ||
| 171 | + | return nil | |
| 172 | + | } | |
| 173 | + | ||
| 135 | 174 | // newPasswordHashCmd creates the password hash command. | |
| 136 | 175 | func newPasswordHashCmd() *cobra.Command { | |
| 137 | 176 | return &cobra.Command{ | |
| @@ -30,6 +30,7 @@ func newAuthCmd() *cobra.Command { | |||||
| 30 | 30 | ||||
| 31 | cmd.AddCommand(newAuthTokenCmd()) | 31 | cmd.AddCommand(newAuthTokenCmd()) | ||
| 32 | cmd.AddCommand(newAuthPasswordCmd()) | 32 | cmd.AddCommand(newAuthPasswordCmd()) | ||
| 33 | + | cmd.AddCommand(newAuthInternalCmd()) | |||
| 33 | 34 | ||||
| 34 | return cmd | 35 | return cmd | ||
| 35 | } | 36 | } | ||
| @@ -132,6 +133,44 @@ func newAuthPasswordCmd() *cobra.Command { | |||||
| 132 | return cmd | 133 | return cmd | ||
| 133 | } | 134 | } | ||
| 134 | 135 | ||||
| 136 | + | // newAuthInternalCmd creates the internal credentials command. | |||
| 137 | + | func newAuthInternalCmd() *cobra.Command { | |||
| 138 | + | return &cobra.Command{ | |||
| 139 | + | Use: "internal", | |||
| 140 | + | Short: "Show internal registry credentials for manual recovery", | |||
| 141 | + | Long: `Display the auto-generated internal registry credentials. | |||
| 142 | + | ||||
| 143 | + | These credentials are used by Gordon for pulling images from the local registry | |||
| 144 | + | (localhost:5000) during internal deploys. You can use them for manual recovery: | |||
| 145 | + | ||||
| 146 | + | docker login localhost:5000 -u gordon-internal -p <password> | |||
| 147 | + | ||||
| 148 | + | Note: Credentials are regenerated each time Gordon starts, and are only | |||
| 149 | + | available while Gordon is running.`, | |||
| 150 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 151 | + | return runShowInternalAuth() | |||
| 152 | + | }, | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // runShowInternalAuth displays the internal registry credentials. | |||
| 157 | + | func runShowInternalAuth() error { | |||
| 158 | + | creds, err := app.GetInternalCredentials() | |||
| 159 | + | if err != nil { | |||
| 160 | + | return err | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | fmt.Println("Internal Registry Credentials") | |||
| 164 | + | fmt.Println("==============================") | |||
| 165 | + | fmt.Printf("Username: %s\n", creds.Username) | |||
| 166 | + | fmt.Printf("Password: %s\n", creds.Password) | |||
| 167 | + | fmt.Println() | |||
| 168 | + | fmt.Println("Usage:") | |||
| 169 | + | fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password) | |||
| 170 | + | ||||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 135 | // newPasswordHashCmd creates the password hash command. | 174 | // newPasswordHashCmd creates the password hash command. | ||
| 136 | func newPasswordHashCmd() *cobra.Command { | 175 | func newPasswordHashCmd() *cobra.Command { | ||
| 137 | return &cobra.Command{ | 176 | return &cobra.Command{ | ||
A internal/adapters/in/cli/deploy.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/spf13/cobra" | |
| 7 | + | ||
| 8 | + | "gordon/internal/app" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // newDeployCmd creates the deploy command. | |
| 12 | + | func newDeployCmd() *cobra.Command { | |
| 13 | + | return &cobra.Command{ | |
| 14 | + | Use: "deploy <domain>", | |
| 15 | + | Short: "Manually deploy or redeploy a route", | |
| 16 | + | Long: `Triggers a deployment for the specified route domain. | |
| 17 | + | The route must be configured in config.toml. | |
| 18 | + | ||
| 19 | + | This will pull the latest image and deploy/redeploy the container, | |
| 20 | + | even if a container is already running. | |
| 21 | + | ||
| 22 | + | Examples: | |
| 23 | + | gordon deploy myapp.example.com | |
| 24 | + | gordon deploy api.example.com`, | |
| 25 | + | Args: cobra.ExactArgs(1), | |
| 26 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 27 | + | domain, err := app.SendDeploySignal(args[0]) | |
| 28 | + | if err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | fmt.Printf("Deploy signal sent for domain: %s\n", domain) | |
| 32 | + | return nil | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/spf13/cobra" | |||
| 7 | + | ||||
| 8 | + | "gordon/internal/app" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // newDeployCmd creates the deploy command. | |||
| 12 | + | func newDeployCmd() *cobra.Command { | |||
| 13 | + | return &cobra.Command{ | |||
| 14 | + | Use: "deploy <domain>", | |||
| 15 | + | Short: "Manually deploy or redeploy a route", | |||
| 16 | + | Long: `Triggers a deployment for the specified route domain. | |||
| 17 | + | The route must be configured in config.toml. | |||
| 18 | + | ||||
| 19 | + | This will pull the latest image and deploy/redeploy the container, | |||
| 20 | + | even if a container is already running. | |||
| 21 | + | ||||
| 22 | + | Examples: | |||
| 23 | + | gordon deploy myapp.example.com | |||
| 24 | + | gordon deploy api.example.com`, | |||
| 25 | + | Args: cobra.ExactArgs(1), | |||
| 26 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 27 | + | domain, err := app.SendDeploySignal(args[0]) | |||
| 28 | + | if err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | fmt.Printf("Deploy signal sent for domain: %s\n", domain) | |||
| 32 | + | return nil | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | } | |||
M internal/adapters/in/cli/root.go +1 -0
| @@ -32,6 +32,7 @@ configuration rules, making it ideal for single-server deployments.`, | |||
| 32 | 32 | // Add subcommands | |
| 33 | 33 | rootCmd.AddCommand(newStartCmd()) | |
| 34 | 34 | rootCmd.AddCommand(newReloadCmd()) | |
| 35 | + | rootCmd.AddCommand(newDeployCmd()) | |
| 35 | 36 | rootCmd.AddCommand(newLogsCmd()) | |
| 36 | 37 | rootCmd.AddCommand(newVersionCmd()) | |
| 37 | 38 | rootCmd.AddCommand(newAuthCmd()) | |
| @@ -32,6 +32,7 @@ configuration rules, making it ideal for single-server deployments.`, | |||||
| 32 | // Add subcommands | 32 | // Add subcommands | ||
| 33 | rootCmd.AddCommand(newStartCmd()) | 33 | rootCmd.AddCommand(newStartCmd()) | ||
| 34 | rootCmd.AddCommand(newReloadCmd()) | 34 | rootCmd.AddCommand(newReloadCmd()) | ||
| 35 | + | rootCmd.AddCommand(newDeployCmd()) | |||
| 35 | rootCmd.AddCommand(newLogsCmd()) | 36 | rootCmd.AddCommand(newLogsCmd()) | ||
| 36 | rootCmd.AddCommand(newVersionCmd()) | 37 | rootCmd.AddCommand(newVersionCmd()) | ||
| 37 | rootCmd.AddCommand(newAuthCmd()) | 38 | rootCmd.AddCommand(newAuthCmd()) | ||
M internal/app/run.go +189 -2
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "crypto/rand" | |
| 7 | 7 | "encoding/hex" | |
| 8 | + | "encoding/json" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "net/http" | |
| 10 | 11 | "os" | |
| @@ -242,6 +243,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 242 | 243 | if err != nil { | |
| 243 | 244 | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | |
| 244 | 245 | } | |
| 246 | + | ||
| 247 | + | // Persist credentials to file for CLI access (gordon auth internal) | |
| 248 | + | if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil { | |
| 249 | + | log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access") | |
| 250 | + | } | |
| 251 | + | ||
| 245 | 252 | log.Debug().Msg("internal registry auth generated for loopback pulls") | |
| 246 | 253 | } | |
| 247 | 254 | ||
| @@ -393,6 +400,57 @@ func randomTokenHex(size int) (string, error) { | |||
| 393 | 400 | return hex.EncodeToString(buf), nil | |
| 394 | 401 | } | |
| 395 | 402 | ||
| 403 | + | // InternalCredentials holds the internal registry credentials for CLI access. | |
| 404 | + | type InternalCredentials struct { | |
| 405 | + | Username string `json:"username"` | |
| 406 | + | Password string `json:"password"` | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | // getInternalCredentialsFile returns the path to the internal credentials file. | |
| 410 | + | func getInternalCredentialsFile() string { | |
| 411 | + | return filepath.Join(os.TempDir(), "gordon-internal-creds.json") | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // persistInternalCredentials saves the internal registry credentials to a temp file. | |
| 415 | + | // Security note: Credentials are stored in the system temp directory with 0600 permissions. | |
| 416 | + | // The file is cleaned up on graceful shutdown but may persist if Gordon crashes. | |
| 417 | + | // These credentials are for internal loopback communication only and are regenerated on each start. | |
| 418 | + | func persistInternalCredentials(username, password string) error { | |
| 419 | + | creds := InternalCredentials{ | |
| 420 | + | Username: username, | |
| 421 | + | Password: password, | |
| 422 | + | } | |
| 423 | + | data, err := json.Marshal(creds) | |
| 424 | + | if err != nil { | |
| 425 | + | return fmt.Errorf("failed to marshal credentials: %w", err) | |
| 426 | + | } | |
| 427 | + | credFile := getInternalCredentialsFile() | |
| 428 | + | if err := os.WriteFile(credFile, data, 0600); err != nil { | |
| 429 | + | return fmt.Errorf("failed to write credentials file: %w", err) | |
| 430 | + | } | |
| 431 | + | return nil | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // cleanupInternalCredentials removes the internal credentials file. | |
| 435 | + | func cleanupInternalCredentials() { | |
| 436 | + | _ = os.Remove(getInternalCredentialsFile()) | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // GetInternalCredentials reads the internal registry credentials from file. | |
| 440 | + | // This is used by the CLI to display credentials for manual recovery. | |
| 441 | + | func GetInternalCredentials() (*InternalCredentials, error) { | |
| 442 | + | credFile := getInternalCredentialsFile() | |
| 443 | + | data, err := os.ReadFile(credFile) | |
| 444 | + | if err != nil { | |
| 445 | + | return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err) | |
| 446 | + | } | |
| 447 | + | var creds InternalCredentials | |
| 448 | + | if err := json.Unmarshal(data, &creds); err != nil { | |
| 449 | + | return nil, fmt.Errorf("failed to parse credentials: %w", err) | |
| 450 | + | } | |
| 451 | + | return &creds, nil | |
| 452 | + | } | |
| 453 | + | ||
| 396 | 454 | // createAuthService creates the authentication service and token store. | |
| 397 | 455 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | |
| 398 | 456 | if !cfg.RegistryAuth.Enabled { | |
| @@ -606,6 +664,11 @@ func registerEventHandlers(ctx context.Context, svc *services) error { | |||
| 606 | 664 | return fmt.Errorf("failed to subscribe manual reload handler: %w", err) | |
| 607 | 665 | } | |
| 608 | 666 | ||
| 667 | + | manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc) | |
| 668 | + | if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil { | |
| 669 | + | return fmt.Errorf("failed to subscribe manual deploy handler: %w", err) | |
| 670 | + | } | |
| 671 | + | ||
| 609 | 672 | // Proxy cache invalidation on container deployment (for zero-downtime) | |
| 610 | 673 | containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc) | |
| 611 | 674 | if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil { | |
| @@ -635,6 +698,11 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo | |||
| 635 | 698 | RegistryPort: v.GetInt("server.registry_port"), | |
| 636 | 699 | }) | |
| 637 | 700 | ||
| 701 | + | // Clear proxy target cache to pick up external route changes | |
| 702 | + | if err := svc.proxySvc.RefreshTargets(ctx); err != nil { | |
| 703 | + | log.Warn().Err(err).Msg("failed to refresh proxy targets") | |
| 704 | + | } | |
| 705 | + | ||
| 638 | 706 | // Publish config reload event to trigger container updates | |
| 639 | 707 | if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | |
| 640 | 708 | log.Error().Err(err).Msg("failed to publish config reload event") | |
| @@ -650,7 +718,8 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||
| 650 | 718 | } | |
| 651 | 719 | ||
| 652 | 720 | if svc.configSvc.IsAutoRouteEnabled() { | |
| 653 | - | if err := svc.containerSvc.AutoStart(ctx); err != nil { | |
| 721 | + | routes := svc.configSvc.GetRoutes(ctx) | |
| 722 | + | if err := svc.containerSvc.AutoStart(ctx, routes); err != nil { | |
| 654 | 723 | log.Warn().Err(err).Msg("failed to auto-start containers") | |
| 655 | 724 | } | |
| 656 | 725 | } | |
| @@ -704,7 +773,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 704 | 773 | // Signal handling notes: | |
| 705 | 774 | // - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext | |
| 706 | 775 | // - SIGUSR1: Triggers config reload without restart | |
| 707 | - | // The deferred signal.Stop(reloadChan) ensures the signal handler is properly | |
| 776 | + | // - SIGUSR2: Triggers manual deploy for a specific route | |
| 777 | + | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 708 | 778 | // cleaned up before program exit, preventing signal handler leaks. | |
| 709 | 779 | func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error { | |
| 710 | 780 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| @@ -717,6 +787,11 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||
| 717 | 787 | signal.Notify(reloadChan, syscall.SIGUSR1) | |
| 718 | 788 | defer signal.Stop(reloadChan) | |
| 719 | 789 | ||
| 790 | + | // Set up SIGUSR2 for manual deploy. | |
| 791 | + | deployChan := make(chan os.Signal, 1) | |
| 792 | + | signal.Notify(deployChan, syscall.SIGUSR2) | |
| 793 | + | defer signal.Stop(deployChan) | |
| 794 | + | ||
| 720 | 795 | errChan := make(chan error, 2) | |
| 721 | 796 | ||
| 722 | 797 | go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log) | |
| @@ -736,6 +811,17 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||
| 736 | 811 | if err := eventBus.Publish(domain.EventManualReload, nil); err != nil { | |
| 737 | 812 | log.Error().Err(err).Msg("failed to publish manual reload event") | |
| 738 | 813 | } | |
| 814 | + | case <-deployChan: | |
| 815 | + | log.Info().Msg("deploy signal received (SIGUSR2)") | |
| 816 | + | domainName, err := readDeployRequest() | |
| 817 | + | if err != nil { | |
| 818 | + | log.Error().Err(err).Msg("failed to read deploy request") | |
| 819 | + | continue | |
| 820 | + | } | |
| 821 | + | payload := &domain.ManualDeployPayload{Domain: domainName} | |
| 822 | + | if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil { | |
| 823 | + | log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event") | |
| 824 | + | } | |
| 739 | 825 | case <-ctx.Done(): | |
| 740 | 826 | log.Info().Msg("shutdown signal received") | |
| 741 | 827 | goto shutdown | |
| @@ -749,6 +835,9 @@ shutdown: | |||
| 749 | 835 | log.Warn().Err(err).Msg("error during container shutdown") | |
| 750 | 836 | } | |
| 751 | 837 | ||
| 838 | + | // Clean up internal credentials file | |
| 839 | + | cleanupInternalCredentials() | |
| 840 | + | ||
| 752 | 841 | log.Info().Msg("Gordon stopped") | |
| 753 | 842 | return nil | |
| 754 | 843 | } | |
| @@ -801,6 +890,104 @@ func SendReloadSignal() error { | |||
| 801 | 890 | return nil | |
| 802 | 891 | } | |
| 803 | 892 | ||
| 893 | + | // getDeployRequestFile returns the path to the deploy request file. | |
| 894 | + | func getDeployRequestFile() string { | |
| 895 | + | return filepath.Join(os.TempDir(), "gordon-deploy-request") | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | // writeDeployRequestFile creates the deploy request file exclusively with retry. | |
| 899 | + | // This prevents race conditions when multiple deploy commands run simultaneously. | |
| 900 | + | func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error { | |
| 901 | + | deadline := time.Now().Add(timeout) | |
| 902 | + | ||
| 903 | + | for { | |
| 904 | + | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) | |
| 905 | + | if err == nil { | |
| 906 | + | _, writeErr := f.Write(data) | |
| 907 | + | f.Close() | |
| 908 | + | if writeErr != nil { | |
| 909 | + | _ = os.Remove(path) | |
| 910 | + | return writeErr | |
| 911 | + | } | |
| 912 | + | return nil | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | if os.IsExist(err) { | |
| 916 | + | if time.Now().After(deadline) { | |
| 917 | + | return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress") | |
| 918 | + | } | |
| 919 | + | time.Sleep(100 * time.Millisecond) | |
| 920 | + | continue | |
| 921 | + | } | |
| 922 | + | ||
| 923 | + | return err | |
| 924 | + | } | |
| 925 | + | } | |
| 926 | + | ||
| 927 | + | // SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2. | |
| 928 | + | // Returns the domain name on success for the caller to display. | |
| 929 | + | func SendDeploySignal(domain string) (string, error) { | |
| 930 | + | deployFile := getDeployRequestFile() | |
| 931 | + | if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil { | |
| 932 | + | return "", fmt.Errorf("failed to write deploy request: %w", err) | |
| 933 | + | } | |
| 934 | + | ||
| 935 | + | // Find PID and send SIGUSR2 | |
| 936 | + | pidFile := findPidFile() | |
| 937 | + | if pidFile == "" { | |
| 938 | + | _ = os.Remove(deployFile) | |
| 939 | + | return "", fmt.Errorf("gordon PID file not found, is Gordon running?") | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | pidBytes, err := os.ReadFile(pidFile) | |
| 943 | + | if err != nil { | |
| 944 | + | _ = os.Remove(deployFile) | |
| 945 | + | return "", fmt.Errorf("failed to read PID file: %w", err) | |
| 946 | + | } | |
| 947 | + | ||
| 948 | + | var pid int | |
| 949 | + | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { | |
| 950 | + | _ = os.Remove(deployFile) | |
| 951 | + | return "", fmt.Errorf("failed to parse PID: %w", err) | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | process, err := os.FindProcess(pid) | |
| 955 | + | if err != nil { | |
| 956 | + | _ = os.Remove(deployFile) | |
| 957 | + | return "", fmt.Errorf("failed to find process: %w", err) | |
| 958 | + | } | |
| 959 | + | ||
| 960 | + | if err := process.Signal(syscall.SIGUSR2); err != nil { | |
| 961 | + | _ = os.Remove(deployFile) | |
| 962 | + | return "", fmt.Errorf("failed to send deploy signal: %w", err) | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | return domain, nil | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | // readDeployRequest reads and removes the deploy request file atomically. | |
| 969 | + | // Returns empty string if file doesn't exist (may have been consumed by another handler). | |
| 970 | + | func readDeployRequest() (string, error) { | |
| 971 | + | deployFile := getDeployRequestFile() | |
| 972 | + | ||
| 973 | + | // Rename to a temp file first to make the read-and-delete atomic | |
| 974 | + | tmpFile := deployFile + ".processing" | |
| 975 | + | if err := os.Rename(deployFile, tmpFile); err != nil { | |
| 976 | + | if os.IsNotExist(err) { | |
| 977 | + | return "", fmt.Errorf("deploy request file not found (may have been processed already)") | |
| 978 | + | } | |
| 979 | + | return "", fmt.Errorf("failed to acquire deploy request: %w", err) | |
| 980 | + | } | |
| 981 | + | ||
| 982 | + | data, err := os.ReadFile(tmpFile) | |
| 983 | + | _ = os.Remove(tmpFile) // Always clean up | |
| 984 | + | if err != nil { | |
| 985 | + | return "", fmt.Errorf("failed to read deploy request: %w", err) | |
| 986 | + | } | |
| 987 | + | ||
| 988 | + | return string(data), nil | |
| 989 | + | } | |
| 990 | + | ||
| 804 | 991 | // createPidFile creates a PID file for the Gordon process. | |
| 805 | 992 | func createPidFile(log zerowrap.Logger) string { | |
| 806 | 993 | pid := os.Getpid() | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "crypto/rand" | 6 | "crypto/rand" | ||
| 7 | "encoding/hex" | 7 | "encoding/hex" | ||
| 8 | + | "encoding/json" | |||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "net/http" | 10 | "net/http" | ||
| 10 | "os" | 11 | "os" | ||
| @@ -242,6 +243,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 242 | if err != nil { | 243 | if err != nil { | ||
| 243 | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | 244 | return nil, log.WrapErr(err, "failed to generate internal registry credentials") | ||
| 244 | } | 245 | } | ||
| 246 | + | ||||
| 247 | + | // Persist credentials to file for CLI access (gordon auth internal) | |||
| 248 | + | if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil { | |||
| 249 | + | log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access") | |||
| 250 | + | } | |||
| 251 | + | ||||
| 245 | log.Debug().Msg("internal registry auth generated for loopback pulls") | 252 | log.Debug().Msg("internal registry auth generated for loopback pulls") | ||
| 246 | } | 253 | } | ||
| 247 | 254 | ||||
| @@ -393,6 +400,57 @@ func randomTokenHex(size int) (string, error) { | |||||
| 393 | return hex.EncodeToString(buf), nil | 400 | return hex.EncodeToString(buf), nil | ||
| 394 | } | 401 | } | ||
| 395 | 402 | ||||
| 403 | + | // InternalCredentials holds the internal registry credentials for CLI access. | |||
| 404 | + | type InternalCredentials struct { | |||
| 405 | + | Username string `json:"username"` | |||
| 406 | + | Password string `json:"password"` | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // getInternalCredentialsFile returns the path to the internal credentials file. | |||
| 410 | + | func getInternalCredentialsFile() string { | |||
| 411 | + | return filepath.Join(os.TempDir(), "gordon-internal-creds.json") | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | // persistInternalCredentials saves the internal registry credentials to a temp file. | |||
| 415 | + | // Security note: Credentials are stored in the system temp directory with 0600 permissions. | |||
| 416 | + | // The file is cleaned up on graceful shutdown but may persist if Gordon crashes. | |||
| 417 | + | // These credentials are for internal loopback communication only and are regenerated on each start. | |||
| 418 | + | func persistInternalCredentials(username, password string) error { | |||
| 419 | + | creds := InternalCredentials{ | |||
| 420 | + | Username: username, | |||
| 421 | + | Password: password, | |||
| 422 | + | } | |||
| 423 | + | data, err := json.Marshal(creds) | |||
| 424 | + | if err != nil { | |||
| 425 | + | return fmt.Errorf("failed to marshal credentials: %w", err) | |||
| 426 | + | } | |||
| 427 | + | credFile := getInternalCredentialsFile() | |||
| 428 | + | if err := os.WriteFile(credFile, data, 0600); err != nil { | |||
| 429 | + | return fmt.Errorf("failed to write credentials file: %w", err) | |||
| 430 | + | } | |||
| 431 | + | return nil | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // cleanupInternalCredentials removes the internal credentials file. | |||
| 435 | + | func cleanupInternalCredentials() { | |||
| 436 | + | _ = os.Remove(getInternalCredentialsFile()) | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | // GetInternalCredentials reads the internal registry credentials from file. | |||
| 440 | + | // This is used by the CLI to display credentials for manual recovery. | |||
| 441 | + | func GetInternalCredentials() (*InternalCredentials, error) { | |||
| 442 | + | credFile := getInternalCredentialsFile() | |||
| 443 | + | data, err := os.ReadFile(credFile) | |||
| 444 | + | if err != nil { | |||
| 445 | + | return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err) | |||
| 446 | + | } | |||
| 447 | + | var creds InternalCredentials | |||
| 448 | + | if err := json.Unmarshal(data, &creds); err != nil { | |||
| 449 | + | return nil, fmt.Errorf("failed to parse credentials: %w", err) | |||
| 450 | + | } | |||
| 451 | + | return &creds, nil | |||
| 452 | + | } | |||
| 453 | + | ||||
| 396 | // createAuthService creates the authentication service and token store. | 454 | // createAuthService creates the authentication service and token store. | ||
| 397 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | 455 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | ||
| 398 | if !cfg.RegistryAuth.Enabled { | 456 | if !cfg.RegistryAuth.Enabled { | ||
| @@ -606,6 +664,11 @@ func registerEventHandlers(ctx context.Context, svc *services) error { | |||||
| 606 | return fmt.Errorf("failed to subscribe manual reload handler: %w", err) | 664 | return fmt.Errorf("failed to subscribe manual reload handler: %w", err) | ||
| 607 | } | 665 | } | ||
| 608 | 666 | ||||
| 667 | + | manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc) | |||
| 668 | + | if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil { | |||
| 669 | + | return fmt.Errorf("failed to subscribe manual deploy handler: %w", err) | |||
| 670 | + | } | |||
| 671 | + | ||||
| 609 | // Proxy cache invalidation on container deployment (for zero-downtime) | 672 | // Proxy cache invalidation on container deployment (for zero-downtime) | ||
| 610 | containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc) | 673 | containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc) | ||
| 611 | if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil { | 674 | if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil { | ||
| @@ -635,6 +698,11 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo | |||||
| 635 | RegistryPort: v.GetInt("server.registry_port"), | 698 | RegistryPort: v.GetInt("server.registry_port"), | ||
| 636 | }) | 699 | }) | ||
| 637 | 700 | ||||
| 701 | + | // Clear proxy target cache to pick up external route changes | |||
| 702 | + | if err := svc.proxySvc.RefreshTargets(ctx); err != nil { | |||
| 703 | + | log.Warn().Err(err).Msg("failed to refresh proxy targets") | |||
| 704 | + | } | |||
| 705 | + | ||||
| 638 | // Publish config reload event to trigger container updates | 706 | // Publish config reload event to trigger container updates | ||
| 639 | if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | 707 | if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | ||
| 640 | log.Error().Err(err).Msg("failed to publish config reload event") | 708 | log.Error().Err(err).Msg("failed to publish config reload event") | ||
| @@ -650,7 +718,8 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||||
| 650 | } | 718 | } | ||
| 651 | 719 | ||||
| 652 | if svc.configSvc.IsAutoRouteEnabled() { | 720 | if svc.configSvc.IsAutoRouteEnabled() { | ||
| 653 | - | if err := svc.containerSvc.AutoStart(ctx); err != nil { | 721 | + | routes := svc.configSvc.GetRoutes(ctx) |
| 722 | + | if err := svc.containerSvc.AutoStart(ctx, routes); err != nil { | |||
| 654 | log.Warn().Err(err).Msg("failed to auto-start containers") | 723 | log.Warn().Err(err).Msg("failed to auto-start containers") | ||
| 655 | } | 724 | } | ||
| 656 | } | 725 | } | ||
| @@ -704,7 +773,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 704 | // Signal handling notes: | 773 | // Signal handling notes: | ||
| 705 | // - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext | 774 | // - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext | ||
| 706 | // - SIGUSR1: Triggers config reload without restart | 775 | // - SIGUSR1: Triggers config reload without restart | ||
| 707 | - | // The deferred signal.Stop(reloadChan) ensures the signal handler is properly | 776 | + | // - SIGUSR2: Triggers manual deploy for a specific route |
| 777 | + | // The deferred signal.Stop calls ensure signal handlers are properly | |||
| 708 | // cleaned up before program exit, preventing signal handler leaks. | 778 | // cleaned up before program exit, preventing signal handler leaks. | ||
| 709 | func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error { | 779 | func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error { | ||
| 710 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 780 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| @@ -717,6 +787,11 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||||
| 717 | signal.Notify(reloadChan, syscall.SIGUSR1) | 787 | signal.Notify(reloadChan, syscall.SIGUSR1) | ||
| 718 | defer signal.Stop(reloadChan) | 788 | defer signal.Stop(reloadChan) | ||
| 719 | 789 | ||||
| 790 | + | // Set up SIGUSR2 for manual deploy. | |||
| 791 | + | deployChan := make(chan os.Signal, 1) | |||
| 792 | + | signal.Notify(deployChan, syscall.SIGUSR2) | |||
| 793 | + | defer signal.Stop(deployChan) | |||
| 794 | + | ||||
| 720 | errChan := make(chan error, 2) | 795 | errChan := make(chan error, 2) | ||
| 721 | 796 | ||||
| 722 | go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log) | 797 | go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log) | ||
| @@ -736,6 +811,17 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||||
| 736 | if err := eventBus.Publish(domain.EventManualReload, nil); err != nil { | 811 | if err := eventBus.Publish(domain.EventManualReload, nil); err != nil { | ||
| 737 | log.Error().Err(err).Msg("failed to publish manual reload event") | 812 | log.Error().Err(err).Msg("failed to publish manual reload event") | ||
| 738 | } | 813 | } | ||
| 814 | + | case <-deployChan: | |||
| 815 | + | log.Info().Msg("deploy signal received (SIGUSR2)") | |||
| 816 | + | domainName, err := readDeployRequest() | |||
| 817 | + | if err != nil { | |||
| 818 | + | log.Error().Err(err).Msg("failed to read deploy request") | |||
| 819 | + | continue | |||
| 820 | + | } | |||
| 821 | + | payload := &domain.ManualDeployPayload{Domain: domainName} | |||
| 822 | + | if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil { | |||
| 823 | + | log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event") | |||
| 824 | + | } | |||
| 739 | case <-ctx.Done(): | 825 | case <-ctx.Done(): | ||
| 740 | log.Info().Msg("shutdown signal received") | 826 | log.Info().Msg("shutdown signal received") | ||
| 741 | goto shutdown | 827 | goto shutdown | ||
| @@ -749,6 +835,9 @@ shutdown: | |||||
| 749 | log.Warn().Err(err).Msg("error during container shutdown") | 835 | log.Warn().Err(err).Msg("error during container shutdown") | ||
| 750 | } | 836 | } | ||
| 751 | 837 | ||||
| 838 | + | // Clean up internal credentials file | |||
| 839 | + | cleanupInternalCredentials() | |||
| 840 | + | ||||
| 752 | log.Info().Msg("Gordon stopped") | 841 | log.Info().Msg("Gordon stopped") | ||
| 753 | return nil | 842 | return nil | ||
| 754 | } | 843 | } | ||
| @@ -801,6 +890,104 @@ func SendReloadSignal() error { | |||||
| 801 | return nil | 890 | return nil | ||
| 802 | } | 891 | } | ||
| 803 | 892 | ||||
| 893 | + | // getDeployRequestFile returns the path to the deploy request file. | |||
| 894 | + | func getDeployRequestFile() string { | |||
| 895 | + | return filepath.Join(os.TempDir(), "gordon-deploy-request") | |||
| 896 | + | } | |||
| 897 | + | ||||
| 898 | + | // writeDeployRequestFile creates the deploy request file exclusively with retry. | |||
| 899 | + | // This prevents race conditions when multiple deploy commands run simultaneously. | |||
| 900 | + | func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error { | |||
| 901 | + | deadline := time.Now().Add(timeout) | |||
| 902 | + | ||||
| 903 | + | for { | |||
| 904 | + | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) | |||
| 905 | + | if err == nil { | |||
| 906 | + | _, writeErr := f.Write(data) | |||
| 907 | + | f.Close() | |||
| 908 | + | if writeErr != nil { | |||
| 909 | + | _ = os.Remove(path) | |||
| 910 | + | return writeErr | |||
| 911 | + | } | |||
| 912 | + | return nil | |||
| 913 | + | } | |||
| 914 | + | ||||
| 915 | + | if os.IsExist(err) { | |||
| 916 | + | if time.Now().After(deadline) { | |||
| 917 | + | return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress") | |||
| 918 | + | } | |||
| 919 | + | time.Sleep(100 * time.Millisecond) | |||
| 920 | + | continue | |||
| 921 | + | } | |||
| 922 | + | ||||
| 923 | + | return err | |||
| 924 | + | } | |||
| 925 | + | } | |||
| 926 | + | ||||
| 927 | + | // SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2. | |||
| 928 | + | // Returns the domain name on success for the caller to display. | |||
| 929 | + | func SendDeploySignal(domain string) (string, error) { | |||
| 930 | + | deployFile := getDeployRequestFile() | |||
| 931 | + | if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil { | |||
| 932 | + | return "", fmt.Errorf("failed to write deploy request: %w", err) | |||
| 933 | + | } | |||
| 934 | + | ||||
| 935 | + | // Find PID and send SIGUSR2 | |||
| 936 | + | pidFile := findPidFile() | |||
| 937 | + | if pidFile == "" { | |||
| 938 | + | _ = os.Remove(deployFile) | |||
| 939 | + | return "", fmt.Errorf("gordon PID file not found, is Gordon running?") | |||
| 940 | + | } | |||
| 941 | + | ||||
| 942 | + | pidBytes, err := os.ReadFile(pidFile) | |||
| 943 | + | if err != nil { | |||
| 944 | + | _ = os.Remove(deployFile) | |||
| 945 | + | return "", fmt.Errorf("failed to read PID file: %w", err) | |||
| 946 | + | } | |||
| 947 | + | ||||
| 948 | + | var pid int | |||
| 949 | + | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { | |||
| 950 | + | _ = os.Remove(deployFile) | |||
| 951 | + | return "", fmt.Errorf("failed to parse PID: %w", err) | |||
| 952 | + | } | |||
| 953 | + | ||||
| 954 | + | process, err := os.FindProcess(pid) | |||
| 955 | + | if err != nil { | |||
| 956 | + | _ = os.Remove(deployFile) | |||
| 957 | + | return "", fmt.Errorf("failed to find process: %w", err) | |||
| 958 | + | } | |||
| 959 | + | ||||
| 960 | + | if err := process.Signal(syscall.SIGUSR2); err != nil { | |||
| 961 | + | _ = os.Remove(deployFile) | |||
| 962 | + | return "", fmt.Errorf("failed to send deploy signal: %w", err) | |||
| 963 | + | } | |||
| 964 | + | ||||
| 965 | + | return domain, nil | |||
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | // readDeployRequest reads and removes the deploy request file atomically. | |||
| 969 | + | // Returns empty string if file doesn't exist (may have been consumed by another handler). | |||
| 970 | + | func readDeployRequest() (string, error) { | |||
| 971 | + | deployFile := getDeployRequestFile() | |||
| 972 | + | ||||
| 973 | + | // Rename to a temp file first to make the read-and-delete atomic | |||
| 974 | + | tmpFile := deployFile + ".processing" | |||
| 975 | + | if err := os.Rename(deployFile, tmpFile); err != nil { | |||
| 976 | + | if os.IsNotExist(err) { | |||
| 977 | + | return "", fmt.Errorf("deploy request file not found (may have been processed already)") | |||
| 978 | + | } | |||
| 979 | + | return "", fmt.Errorf("failed to acquire deploy request: %w", err) | |||
| 980 | + | } | |||
| 981 | + | ||||
| 982 | + | data, err := os.ReadFile(tmpFile) | |||
| 983 | + | _ = os.Remove(tmpFile) // Always clean up | |||
| 984 | + | if err != nil { | |||
| 985 | + | return "", fmt.Errorf("failed to read deploy request: %w", err) | |||
| 986 | + | } | |||
| 987 | + | ||||
| 988 | + | return string(data), nil | |||
| 989 | + | } | |||
| 990 | + | ||||
| 804 | // createPidFile creates a PID file for the Gordon process. | 991 | // createPidFile creates a PID file for the Gordon process. | ||
| 805 | func createPidFile(log zerowrap.Logger) string { | 992 | func createPidFile(log zerowrap.Logger) string { | ||
| 806 | pid := os.Getpid() | 993 | pid := os.Getpid() | ||
M internal/boundaries/in/config.go +3 -0
| @@ -47,4 +47,7 @@ type ConfigService interface { | |||
| 47 | 47 | ||
| 48 | 48 | // GetNetworkPrefix returns the prefix for created networks. | |
| 49 | 49 | GetNetworkPrefix() string | |
| 50 | + | ||
| 51 | + | // GetExternalRoutes returns all configured external routes. | |
| 52 | + | GetExternalRoutes() map[string]string | |
| 50 | 53 | } | |
| @@ -47,4 +47,7 @@ type ConfigService interface { | |||||
| 47 | 47 | ||||
| 48 | // GetNetworkPrefix returns the prefix for created networks. | 48 | // GetNetworkPrefix returns the prefix for created networks. | ||
| 49 | GetNetworkPrefix() string | 49 | GetNetworkPrefix() string | ||
| 50 | + | ||||
| 51 | + | // GetExternalRoutes returns all configured external routes. | |||
| 52 | + | GetExternalRoutes() map[string]string | |||
| 50 | } | 53 | } | ||
M internal/boundaries/in/container.go +2 -2
| @@ -32,8 +32,8 @@ type ContainerService interface { | |||
| 32 | 32 | // SyncContainers synchronizes containers with configured routes. | |
| 33 | 33 | SyncContainers(ctx context.Context) error | |
| 34 | 34 | ||
| 35 | - | // AutoStart starts containers for all configured routes. | |
| 36 | - | AutoStart(ctx context.Context) error | |
| 35 | + | // AutoStart starts containers for the provided routes that aren't running. | |
| 36 | + | AutoStart(ctx context.Context, routes []domain.Route) error | |
| 37 | 37 | ||
| 38 | 38 | // Shutdown gracefully shuts down all managed containers. | |
| 39 | 39 | Shutdown(ctx context.Context) error | |
| @@ -32,8 +32,8 @@ type ContainerService interface { | |||||
| 32 | // SyncContainers synchronizes containers with configured routes. | 32 | // SyncContainers synchronizes containers with configured routes. | ||
| 33 | SyncContainers(ctx context.Context) error | 33 | SyncContainers(ctx context.Context) error | ||
| 34 | 34 | ||||
| 35 | - | // AutoStart starts containers for all configured routes. | 35 | + | // AutoStart starts containers for the provided routes that aren't running. |
| 36 | - | AutoStart(ctx context.Context) error | 36 | + | AutoStart(ctx context.Context, routes []domain.Route) error |
| 37 | 37 | ||||
| 38 | // Shutdown gracefully shuts down all managed containers. | 38 | // Shutdown gracefully shuts down all managed containers. | ||
| 39 | Shutdown(ctx context.Context) error | 39 | Shutdown(ctx context.Context) error | ||
M internal/boundaries/in/mocks/mock_config_service.go +46 -0
| @@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo | |||
| 139 | 139 | return _c | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | // GetExternalRoutes provides a mock function for the type MockConfigService | |
| 143 | + | func (_mock *MockConfigService) GetExternalRoutes() map[string]string { | |
| 144 | + | ret := _mock.Called() | |
| 145 | + | ||
| 146 | + | if len(ret) == 0 { | |
| 147 | + | panic("no return value specified for GetExternalRoutes") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | var r0 map[string]string | |
| 151 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |
| 152 | + | r0 = returnFunc() | |
| 153 | + | } else { | |
| 154 | + | if ret.Get(0) != nil { | |
| 155 | + | r0 = ret.Get(0).(map[string]string) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | return r0 | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |
| 162 | + | type MockConfigService_GetExternalRoutes_Call struct { | |
| 163 | + | *mock.Call | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // GetExternalRoutes is a helper method to define mock.On call | |
| 167 | + | func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call { | |
| 168 | + | return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call { | |
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 173 | + | run() | |
| 174 | + | }) | |
| 175 | + | return _c | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call { | |
| 179 | + | _c.Call.Return(stringToString) | |
| 180 | + | return _c | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call { | |
| 184 | + | _c.Call.Return(run) | |
| 185 | + | return _c | |
| 186 | + | } | |
| 187 | + | ||
| 142 | 188 | // GetNetworkPrefix provides a mock function for the type MockConfigService | |
| 143 | 189 | func (_mock *MockConfigService) GetNetworkPrefix() string { | |
| 144 | 190 | ret := _mock.Called() | |
| @@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo | |||||
| 139 | return _c | 139 | return _c | ||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | + | // GetExternalRoutes provides a mock function for the type MockConfigService | |||
| 143 | + | func (_mock *MockConfigService) GetExternalRoutes() map[string]string { | |||
| 144 | + | ret := _mock.Called() | |||
| 145 | + | ||||
| 146 | + | if len(ret) == 0 { | |||
| 147 | + | panic("no return value specified for GetExternalRoutes") | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | var r0 map[string]string | |||
| 151 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |||
| 152 | + | r0 = returnFunc() | |||
| 153 | + | } else { | |||
| 154 | + | if ret.Get(0) != nil { | |||
| 155 | + | r0 = ret.Get(0).(map[string]string) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | return r0 | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |||
| 162 | + | type MockConfigService_GetExternalRoutes_Call struct { | |||
| 163 | + | *mock.Call | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // GetExternalRoutes is a helper method to define mock.On call | |||
| 167 | + | func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call { | |||
| 168 | + | return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call { | |||
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 173 | + | run() | |||
| 174 | + | }) | |||
| 175 | + | return _c | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call { | |||
| 179 | + | _c.Call.Return(stringToString) | |||
| 180 | + | return _c | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call { | |||
| 184 | + | _c.Call.Return(run) | |||
| 185 | + | return _c | |||
| 186 | + | } | |||
| 187 | + | ||||
| 142 | // GetNetworkPrefix provides a mock function for the type MockConfigService | 188 | // GetNetworkPrefix provides a mock function for the type MockConfigService | ||
| 143 | func (_mock *MockConfigService) GetNetworkPrefix() string { | 189 | func (_mock *MockConfigService) GetNetworkPrefix() string { | ||
| 144 | ret := _mock.Called() | 190 | ret := _mock.Called() | ||
M internal/boundaries/in/mocks/mock_container_service.go +14 -8
| @@ -39,16 +39,16 @@ func (_m *MockContainerService) EXPECT() *MockContainerService_Expecter { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | // AutoStart provides a mock function for the type MockContainerService | |
| 42 | - | func (_mock *MockContainerService) AutoStart(ctx context.Context) error { | |
| 43 | - | ret := _mock.Called(ctx) | |
| 42 | + | func (_mock *MockContainerService) AutoStart(ctx context.Context, routes []domain.Route) error { | |
| 43 | + | ret := _mock.Called(ctx, routes) | |
| 44 | 44 | ||
| 45 | 45 | if len(ret) == 0 { | |
| 46 | 46 | panic("no return value specified for AutoStart") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | var r0 error | |
| 50 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 51 | - | r0 = returnFunc(ctx) | |
| 50 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.Route) error); ok { | |
| 51 | + | r0 = returnFunc(ctx, routes) | |
| 52 | 52 | } else { | |
| 53 | 53 | r0 = ret.Error(0) | |
| 54 | 54 | } | |
| @@ -62,18 +62,24 @@ type MockContainerService_AutoStart_Call struct { | |||
| 62 | 62 | ||
| 63 | 63 | // AutoStart is a helper method to define mock.On call | |
| 64 | 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)} | |
| 65 | + | // - routes []domain.Route | |
| 66 | + | func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}, routes interface{}) *MockContainerService_AutoStart_Call { | |
| 67 | + | return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx, routes)} | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | - | func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context)) *MockContainerService_AutoStart_Call { | |
| 70 | + | func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context, routes []domain.Route)) *MockContainerService_AutoStart_Call { | |
| 70 | 71 | _c.Call.Run(func(args mock.Arguments) { | |
| 71 | 72 | var arg0 context.Context | |
| 72 | 73 | if args[0] != nil { | |
| 73 | 74 | arg0 = args[0].(context.Context) | |
| 74 | 75 | } | |
| 76 | + | var arg1 []domain.Route | |
| 77 | + | if args[1] != nil { | |
| 78 | + | arg1 = args[1].([]domain.Route) | |
| 79 | + | } | |
| 75 | 80 | run( | |
| 76 | 81 | arg0, | |
| 82 | + | arg1, | |
| 77 | 83 | ) | |
| 78 | 84 | }) | |
| 79 | 85 | return _c | |
| @@ -84,7 +90,7 @@ func (_c *MockContainerService_AutoStart_Call) Return(err error) *MockContainerS | |||
| 84 | 90 | return _c | |
| 85 | 91 | } | |
| 86 | 92 | ||
| 87 | - | func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context) error) *MockContainerService_AutoStart_Call { | |
| 93 | + | func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context, routes []domain.Route) error) *MockContainerService_AutoStart_Call { | |
| 88 | 94 | _c.Call.Return(run) | |
| 89 | 95 | return _c | |
| 90 | 96 | } | |
| @@ -39,16 +39,16 @@ func (_m *MockContainerService) EXPECT() *MockContainerService_Expecter { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | // AutoStart provides a mock function for the type MockContainerService | 41 | // AutoStart provides a mock function for the type MockContainerService | ||
| 42 | - | func (_mock *MockContainerService) AutoStart(ctx context.Context) error { | 42 | + | func (_mock *MockContainerService) AutoStart(ctx context.Context, routes []domain.Route) error { |
| 43 | - | ret := _mock.Called(ctx) | 43 | + | ret := _mock.Called(ctx, routes) |
| 44 | 44 | ||||
| 45 | if len(ret) == 0 { | 45 | if len(ret) == 0 { | ||
| 46 | panic("no return value specified for AutoStart") | 46 | panic("no return value specified for AutoStart") | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | var r0 error | 49 | var r0 error | ||
| 50 | - | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | 50 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.Route) error); ok { |
| 51 | - | r0 = returnFunc(ctx) | 51 | + | r0 = returnFunc(ctx, routes) |
| 52 | } else { | 52 | } else { | ||
| 53 | r0 = ret.Error(0) | 53 | r0 = ret.Error(0) | ||
| 54 | } | 54 | } | ||
| @@ -62,18 +62,24 @@ type MockContainerService_AutoStart_Call struct { | |||||
| 62 | 62 | ||||
| 63 | // AutoStart is a helper method to define mock.On call | 63 | // AutoStart is a helper method to define mock.On call | ||
| 64 | // - ctx context.Context | 64 | // - ctx context.Context | ||
| 65 | - | func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}) *MockContainerService_AutoStart_Call { | 65 | + | // - routes []domain.Route |
| 66 | - | return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx)} | 66 | + | func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}, routes interface{}) *MockContainerService_AutoStart_Call { |
| 67 | + | return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx, routes)} | |||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | - | func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context)) *MockContainerService_AutoStart_Call { | 70 | + | func (_c *MockContainerService_AutoStart_Call) Run(run func(ctx context.Context, routes []domain.Route)) *MockContainerService_AutoStart_Call { |
| 70 | _c.Call.Run(func(args mock.Arguments) { | 71 | _c.Call.Run(func(args mock.Arguments) { | ||
| 71 | var arg0 context.Context | 72 | var arg0 context.Context | ||
| 72 | if args[0] != nil { | 73 | if args[0] != nil { | ||
| 73 | arg0 = args[0].(context.Context) | 74 | arg0 = args[0].(context.Context) | ||
| 74 | } | 75 | } | ||
| 76 | + | var arg1 []domain.Route | |||
| 77 | + | if args[1] != nil { | |||
| 78 | + | arg1 = args[1].([]domain.Route) | |||
| 79 | + | } | |||
| 75 | run( | 80 | run( | ||
| 76 | arg0, | 81 | arg0, | ||
| 82 | + | arg1, | |||
| 77 | ) | 83 | ) | ||
| 78 | }) | 84 | }) | ||
| 79 | return _c | 85 | return _c | ||
| @@ -84,7 +90,7 @@ func (_c *MockContainerService_AutoStart_Call) Return(err error) *MockContainerS | |||||
| 84 | return _c | 90 | return _c | ||
| 85 | } | 91 | } | ||
| 86 | 92 | ||||
| 87 | - | func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context) error) *MockContainerService_AutoStart_Call { | 93 | + | func (_c *MockContainerService_AutoStart_Call) RunAndReturn(run func(ctx context.Context, routes []domain.Route) error) *MockContainerService_AutoStart_Call { |
| 88 | _c.Call.Return(run) | 94 | _c.Call.Return(run) | ||
| 89 | return _c | 95 | return _c | ||
| 90 | } | 96 | } | ||
M internal/domain/event.go +6 -0
| @@ -13,6 +13,7 @@ const ( | |||
| 13 | 13 | EventImageDeleted EventType = "image.deleted" | |
| 14 | 14 | EventConfigReload EventType = "config.reload" | |
| 15 | 15 | EventManualReload EventType = "manual.reload" | |
| 16 | + | EventManualDeploy EventType = "manual.deploy" | |
| 16 | 17 | EventContainerStop EventType = "container.stop" | |
| 17 | 18 | EventContainerStart EventType = "container.start" | |
| 18 | 19 | EventContainerHealthCheck EventType = "container.health_check" | |
| @@ -55,6 +56,11 @@ type ConfigReloadPayload struct { | |||
| 55 | 56 | UpdatedRoutes []string | |
| 56 | 57 | } | |
| 57 | 58 | ||
| 59 | + | // ManualDeployPayload contains data for manual.deploy events. | |
| 60 | + | type ManualDeployPayload struct { | |
| 61 | + | Domain string `json:"domain"` | |
| 62 | + | } | |
| 63 | + | ||
| 58 | 64 | // Context keys for domain-level concerns. | |
| 59 | 65 | type contextKey string | |
| 60 | 66 | ||
| @@ -13,6 +13,7 @@ const ( | |||||
| 13 | EventImageDeleted EventType = "image.deleted" | 13 | EventImageDeleted EventType = "image.deleted" | ||
| 14 | EventConfigReload EventType = "config.reload" | 14 | EventConfigReload EventType = "config.reload" | ||
| 15 | EventManualReload EventType = "manual.reload" | 15 | EventManualReload EventType = "manual.reload" | ||
| 16 | + | EventManualDeploy EventType = "manual.deploy" | |||
| 16 | EventContainerStop EventType = "container.stop" | 17 | EventContainerStop EventType = "container.stop" | ||
| 17 | EventContainerStart EventType = "container.start" | 18 | EventContainerStart EventType = "container.start" | ||
| 18 | EventContainerHealthCheck EventType = "container.health_check" | 19 | EventContainerHealthCheck EventType = "container.health_check" | ||
| @@ -55,6 +56,11 @@ type ConfigReloadPayload struct { | |||||
| 55 | UpdatedRoutes []string | 56 | UpdatedRoutes []string | ||
| 56 | } | 57 | } | ||
| 57 | 58 | ||||
| 59 | + | // ManualDeployPayload contains data for manual.deploy events. | |||
| 60 | + | type ManualDeployPayload struct { | |||
| 61 | + | Domain string `json:"domain"` | |||
| 62 | + | } | |||
| 63 | + | ||||
| 58 | // Context keys for domain-level concerns. | 64 | // Context keys for domain-level concerns. | ||
| 59 | type contextKey string | 65 | type contextKey string | ||
| 60 | 66 | ||||
M internal/domain/route.go +7 -0
| @@ -21,3 +21,10 @@ type RouteMatch struct { | |||
| 21 | 21 | Container *Container | |
| 22 | 22 | Target *ProxyTarget | |
| 23 | 23 | } | |
| 24 | + | ||
| 25 | + | // ExternalRoute represents a mapping from a domain to an external (non-container) service. | |
| 26 | + | type ExternalRoute struct { | |
| 27 | + | Domain string // e.g., "reg.bnema.dev" | |
| 28 | + | Host string // e.g., "localhost" | |
| 29 | + | Port int // e.g., 5000 | |
| 30 | + | } | |
| @@ -21,3 +21,10 @@ type RouteMatch struct { | |||||
| 21 | Container *Container | 21 | Container *Container | ||
| 22 | Target *ProxyTarget | 22 | Target *ProxyTarget | ||
| 23 | } | 23 | } | ||
| 24 | + | ||||
| 25 | + | // ExternalRoute represents a mapping from a domain to an external (non-container) service. | |||
| 26 | + | type ExternalRoute struct { | |||
| 27 | + | Domain string // e.g., "reg.bnema.dev" | |||
| 28 | + | Host string // e.g., "localhost" | |||
| 29 | + | Port int // e.g., 5000 | |||
| 30 | + | } | |||
M internal/usecase/config/service.go +15 -0
| @@ -24,6 +24,7 @@ type Config struct { | |||
| 24 | 24 | NetworkIsolation bool | |
| 25 | 25 | NetworkPrefix string | |
| 26 | 26 | Routes map[string]string | |
| 27 | + | ExternalRoutes map[string]string // domain -> "host:port" | |
| 27 | 28 | RegistryAuthEnabled bool | |
| 28 | 29 | RegistryAuthUsername string | |
| 29 | 30 | RegistryAuthPassword string | |
| @@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 66 | 67 | ||
| 67 | 68 | // Load complex nested structures | |
| 68 | 69 | s.config.Routes = loadStringMap(s.viper.Get("routes")) | |
| 70 | + | s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes")) | |
| 69 | 71 | s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | |
| 70 | 72 | s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | |
| 71 | 73 | ||
| @@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config { | |||
| 95 | 97 | VolumePrefix: s.viper.GetString("volumes.prefix"), | |
| 96 | 98 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | |
| 97 | 99 | Routes: make(map[string]string), | |
| 100 | + | ExternalRoutes: make(map[string]string), | |
| 98 | 101 | NetworkGroups: make(map[string][]string), | |
| 99 | 102 | Attachments: make(map[string][]string), | |
| 100 | 103 | } | |
| @@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string { | |||
| 365 | 368 | return result | |
| 366 | 369 | } | |
| 367 | 370 | ||
| 371 | + | // GetExternalRoutes returns all configured external routes. | |
| 372 | + | func (s *Service) GetExternalRoutes() map[string]string { | |
| 373 | + | s.mu.RLock() | |
| 374 | + | defer s.mu.RUnlock() | |
| 375 | + | ||
| 376 | + | result := make(map[string]string) | |
| 377 | + | for k, v := range s.config.ExternalRoutes { | |
| 378 | + | result[k] = v | |
| 379 | + | } | |
| 380 | + | return result | |
| 381 | + | } | |
| 382 | + | ||
| 368 | 383 | // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest". | |
| 369 | 384 | func ExtractDomainFromImageName(imageName string) (string, bool) { | |
| 370 | 385 | parts := strings.Split(imageName, ":") | |
| @@ -24,6 +24,7 @@ type Config struct { | |||||
| 24 | NetworkIsolation bool | 24 | NetworkIsolation bool | ||
| 25 | NetworkPrefix string | 25 | NetworkPrefix string | ||
| 26 | Routes map[string]string | 26 | Routes map[string]string | ||
| 27 | + | ExternalRoutes map[string]string // domain -> "host:port" | |||
| 27 | RegistryAuthEnabled bool | 28 | RegistryAuthEnabled bool | ||
| 28 | RegistryAuthUsername string | 29 | RegistryAuthUsername string | ||
| 29 | RegistryAuthPassword string | 30 | RegistryAuthPassword string | ||
| @@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 66 | 67 | ||||
| 67 | // Load complex nested structures | 68 | // Load complex nested structures | ||
| 68 | s.config.Routes = loadStringMap(s.viper.Get("routes")) | 69 | s.config.Routes = loadStringMap(s.viper.Get("routes")) | ||
| 70 | + | s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes")) | |||
| 69 | s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | 71 | s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | ||
| 70 | s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | 72 | s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | ||
| 71 | 73 | ||||
| @@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config { | |||||
| 95 | VolumePrefix: s.viper.GetString("volumes.prefix"), | 97 | VolumePrefix: s.viper.GetString("volumes.prefix"), | ||
| 96 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | 98 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | ||
| 97 | Routes: make(map[string]string), | 99 | Routes: make(map[string]string), | ||
| 100 | + | ExternalRoutes: make(map[string]string), | |||
| 98 | NetworkGroups: make(map[string][]string), | 101 | NetworkGroups: make(map[string][]string), | ||
| 99 | Attachments: make(map[string][]string), | 102 | Attachments: make(map[string][]string), | ||
| 100 | } | 103 | } | ||
| @@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string { | |||||
| 365 | return result | 368 | return result | ||
| 366 | } | 369 | } | ||
| 367 | 370 | ||||
| 371 | + | // GetExternalRoutes returns all configured external routes. | |||
| 372 | + | func (s *Service) GetExternalRoutes() map[string]string { | |||
| 373 | + | s.mu.RLock() | |||
| 374 | + | defer s.mu.RUnlock() | |||
| 375 | + | ||||
| 376 | + | result := make(map[string]string) | |||
| 377 | + | for k, v := range s.config.ExternalRoutes { | |||
| 378 | + | result[k] = v | |||
| 379 | + | } | |||
| 380 | + | return result | |||
| 381 | + | } | |||
| 382 | + | ||||
| 368 | // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest". | 383 | // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest". | ||
| 369 | func ExtractDomainFromImageName(imageName string) (string, bool) { | 384 | func ExtractDomainFromImageName(imageName string) (string, bool) { | ||
| 370 | parts := strings.Split(imageName, ":") | 385 | parts := strings.Split(imageName, ":") | ||
M internal/usecase/config/service_test.go +34 -0
| @@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) { | |||
| 232 | 232 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"]) | |
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | + | func TestService_GetExternalRoutes(t *testing.T) { | |
| 236 | + | v := viper.New() | |
| 237 | + | v.Set("external_routes", map[string]interface{}{ | |
| 238 | + | "reg.example.com": "localhost:5000", | |
| 239 | + | "cache.example.com": "127.0.0.1:6379", | |
| 240 | + | }) | |
| 241 | + | ||
| 242 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 243 | + | svc := NewService(v, eventBus) | |
| 244 | + | ctx := testContext() | |
| 245 | + | ||
| 246 | + | _ = svc.Load(ctx) | |
| 247 | + | ||
| 248 | + | routes := svc.GetExternalRoutes() | |
| 249 | + | ||
| 250 | + | assert.Len(t, routes, 2) | |
| 251 | + | assert.Equal(t, "localhost:5000", routes["reg.example.com"]) | |
| 252 | + | assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"]) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestService_GetExternalRoutes_Empty(t *testing.T) { | |
| 256 | + | v := viper.New() | |
| 257 | + | ||
| 258 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 259 | + | svc := NewService(v, eventBus) | |
| 260 | + | ctx := testContext() | |
| 261 | + | ||
| 262 | + | _ = svc.Load(ctx) | |
| 263 | + | ||
| 264 | + | routes := svc.GetExternalRoutes() | |
| 265 | + | ||
| 266 | + | assert.Empty(t, routes) | |
| 267 | + | } | |
| 268 | + | ||
| 235 | 269 | func TestExtractDomainFromImageName(t *testing.T) { | |
| 236 | 270 | tests := []struct { | |
| 237 | 271 | name string | |
| @@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) { | |||||
| 232 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"]) | 232 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"]) | ||
| 233 | } | 233 | } | ||
| 234 | 234 | ||||
| 235 | + | func TestService_GetExternalRoutes(t *testing.T) { | |||
| 236 | + | v := viper.New() | |||
| 237 | + | v.Set("external_routes", map[string]interface{}{ | |||
| 238 | + | "reg.example.com": "localhost:5000", | |||
| 239 | + | "cache.example.com": "127.0.0.1:6379", | |||
| 240 | + | }) | |||
| 241 | + | ||||
| 242 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 243 | + | svc := NewService(v, eventBus) | |||
| 244 | + | ctx := testContext() | |||
| 245 | + | ||||
| 246 | + | _ = svc.Load(ctx) | |||
| 247 | + | ||||
| 248 | + | routes := svc.GetExternalRoutes() | |||
| 249 | + | ||||
| 250 | + | assert.Len(t, routes, 2) | |||
| 251 | + | assert.Equal(t, "localhost:5000", routes["reg.example.com"]) | |||
| 252 | + | assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"]) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestService_GetExternalRoutes_Empty(t *testing.T) { | |||
| 256 | + | v := viper.New() | |||
| 257 | + | ||||
| 258 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 259 | + | svc := NewService(v, eventBus) | |||
| 260 | + | ctx := testContext() | |||
| 261 | + | ||||
| 262 | + | _ = svc.Load(ctx) | |||
| 263 | + | ||||
| 264 | + | routes := svc.GetExternalRoutes() | |||
| 265 | + | ||||
| 266 | + | assert.Empty(t, routes) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 235 | func TestExtractDomainFromImageName(t *testing.T) { | 269 | func TestExtractDomainFromImageName(t *testing.T) { | ||
| 236 | tests := []struct { | 270 | tests := []struct { | ||
| 237 | name string | 271 | name string | ||
M internal/usecase/container/events.go +76 -2
| @@ -133,6 +133,12 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error { | |||
| 133 | 133 | ||
| 134 | 134 | log.Info().Msg("processing configuration reload event") | |
| 135 | 135 | ||
| 136 | + | // Sync containers first to ensure we have accurate state | |
| 137 | + | // This removes tracking for containers that were stopped externally | |
| 138 | + | if err := h.containerSvc.SyncContainers(ctx); err != nil { | |
| 139 | + | log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state") | |
| 140 | + | } | |
| 141 | + | ||
| 136 | 142 | currentContainers := h.containerSvc.List(ctx) | |
| 137 | 143 | ||
| 138 | 144 | activeRoutes := make(map[string]*domain.Container) | |
| @@ -159,13 +165,14 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error { | |||
| 159 | 165 | } | |
| 160 | 166 | delete(activeRoutes, route.Domain) | |
| 161 | 167 | } else { | |
| 168 | + | // Route exists in config but no running container (new route or missing container) | |
| 162 | 169 | log.Info(). | |
| 163 | 170 | Str("domain", route.Domain). | |
| 164 | 171 | Str("image", route.Image). | |
| 165 | - | Msg("new route detected, deploying container") | |
| 172 | + | Msg("route missing container, deploying") | |
| 166 | 173 | ||
| 167 | 174 | if _, err := h.containerSvc.Deploy(ctx, route); err != nil { | |
| 168 | - | log.WrapErrWithFields(err, "failed to deploy container for new route", map[string]any{"domain": route.Domain}) | |
| 175 | + | log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain}) | |
| 169 | 176 | } | |
| 170 | 177 | } | |
| 171 | 178 | } | |
| @@ -223,6 +230,12 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error { | |||
| 223 | 230 | ||
| 224 | 231 | log.Info().Msg("processing manual reload event - starting missing containers") | |
| 225 | 232 | ||
| 233 | + | // Sync containers first to ensure we have accurate state | |
| 234 | + | // This removes tracking for containers that were stopped externally | |
| 235 | + | if err := h.containerSvc.SyncContainers(ctx); err != nil { | |
| 236 | + | log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state") | |
| 237 | + | } | |
| 238 | + | ||
| 226 | 239 | routes := h.configSvc.GetRoutes(ctx) | |
| 227 | 240 | startedCount := 0 | |
| 228 | 241 | skippedCount := 0 | |
| @@ -271,3 +284,64 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error { | |||
| 271 | 284 | func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool { | |
| 272 | 285 | return eventType == domain.EventManualReload | |
| 273 | 286 | } | |
| 287 | + | ||
| 288 | + | // ManualDeployHandler handles manual.deploy events for specific routes. | |
| 289 | + | type ManualDeployHandler struct { | |
| 290 | + | containerSvc in.ContainerService | |
| 291 | + | configSvc in.ConfigService | |
| 292 | + | ctx context.Context | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // NewManualDeployHandler creates a new ManualDeployHandler. | |
| 296 | + | func NewManualDeployHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualDeployHandler { | |
| 297 | + | return &ManualDeployHandler{ | |
| 298 | + | containerSvc: containerSvc, | |
| 299 | + | configSvc: configSvc, | |
| 300 | + | ctx: ctx, | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // Handle handles a manual deploy event for a specific route. | |
| 305 | + | func (h *ManualDeployHandler) Handle(event domain.Event) error { | |
| 306 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |
| 307 | + | zerowrap.FieldLayer: "usecase", | |
| 308 | + | zerowrap.FieldHandler: "ManualDeployHandler", | |
| 309 | + | zerowrap.FieldEvent: string(event.Type), | |
| 310 | + | "event_id": event.ID, | |
| 311 | + | }) | |
| 312 | + | log := zerowrap.FromCtx(ctx) | |
| 313 | + | ||
| 314 | + | payload, ok := event.Data.(*domain.ManualDeployPayload) | |
| 315 | + | if !ok || payload == nil || payload.Domain == "" { | |
| 316 | + | return fmt.Errorf("invalid manual deploy payload") | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | log.Info().Str("domain", payload.Domain).Msg("processing manual deploy event") | |
| 320 | + | ||
| 321 | + | // Find the route in configuration | |
| 322 | + | routes := h.configSvc.GetRoutes(ctx) | |
| 323 | + | var targetRoute *domain.Route | |
| 324 | + | for _, r := range routes { | |
| 325 | + | if r.Domain == payload.Domain { | |
| 326 | + | targetRoute = &r | |
| 327 | + | break | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | if targetRoute == nil { | |
| 332 | + | return fmt.Errorf("route not found for domain: %s", payload.Domain) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | // Deploy the container (this will replace any existing container) | |
| 336 | + | if _, err := h.containerSvc.Deploy(ctx, *targetRoute); err != nil { | |
| 337 | + | return log.WrapErr(err, "failed to deploy container") | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | log.Info().Str("domain", payload.Domain).Msg("manual deploy completed successfully") | |
| 341 | + | return nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // CanHandle returns whether this handler can handle the given event type. | |
| 345 | + | func (h *ManualDeployHandler) CanHandle(eventType domain.EventType) bool { | |
| 346 | + | return eventType == domain.EventManualDeploy | |
| 347 | + | } | |
| @@ -133,6 +133,12 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error { | |||||
| 133 | 133 | ||||
| 134 | log.Info().Msg("processing configuration reload event") | 134 | log.Info().Msg("processing configuration reload event") | ||
| 135 | 135 | ||||
| 136 | + | // Sync containers first to ensure we have accurate state | |||
| 137 | + | // This removes tracking for containers that were stopped externally | |||
| 138 | + | if err := h.containerSvc.SyncContainers(ctx); err != nil { | |||
| 139 | + | log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state") | |||
| 140 | + | } | |||
| 141 | + | ||||
| 136 | currentContainers := h.containerSvc.List(ctx) | 142 | currentContainers := h.containerSvc.List(ctx) | ||
| 137 | 143 | ||||
| 138 | activeRoutes := make(map[string]*domain.Container) | 144 | activeRoutes := make(map[string]*domain.Container) | ||
| @@ -159,13 +165,14 @@ func (h *ConfigReloadHandler) Handle(event domain.Event) error { | |||||
| 159 | } | 165 | } | ||
| 160 | delete(activeRoutes, route.Domain) | 166 | delete(activeRoutes, route.Domain) | ||
| 161 | } else { | 167 | } else { | ||
| 168 | + | // Route exists in config but no running container (new route or missing container) | |||
| 162 | log.Info(). | 169 | log.Info(). | ||
| 163 | Str("domain", route.Domain). | 170 | Str("domain", route.Domain). | ||
| 164 | Str("image", route.Image). | 171 | Str("image", route.Image). | ||
| 165 | - | Msg("new route detected, deploying container") | 172 | + | Msg("route missing container, deploying") |
| 166 | 173 | ||||
| 167 | if _, err := h.containerSvc.Deploy(ctx, route); err != nil { | 174 | if _, err := h.containerSvc.Deploy(ctx, route); err != nil { | ||
| 168 | - | log.WrapErrWithFields(err, "failed to deploy container for new route", map[string]any{"domain": route.Domain}) | 175 | + | log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain}) |
| 169 | } | 176 | } | ||
| 170 | } | 177 | } | ||
| 171 | } | 178 | } | ||
| @@ -223,6 +230,12 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error { | |||||
| 223 | 230 | ||||
| 224 | log.Info().Msg("processing manual reload event - starting missing containers") | 231 | log.Info().Msg("processing manual reload event - starting missing containers") | ||
| 225 | 232 | ||||
| 233 | + | // Sync containers first to ensure we have accurate state | |||
| 234 | + | // This removes tracking for containers that were stopped externally | |||
| 235 | + | if err := h.containerSvc.SyncContainers(ctx); err != nil { | |||
| 236 | + | log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state") | |||
| 237 | + | } | |||
| 238 | + | ||||
| 226 | routes := h.configSvc.GetRoutes(ctx) | 239 | routes := h.configSvc.GetRoutes(ctx) | ||
| 227 | startedCount := 0 | 240 | startedCount := 0 | ||
| 228 | skippedCount := 0 | 241 | skippedCount := 0 | ||
| @@ -271,3 +284,64 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error { | |||||
| 271 | func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool { | 284 | func (h *ManualReloadHandler) CanHandle(eventType domain.EventType) bool { | ||
| 272 | return eventType == domain.EventManualReload | 285 | return eventType == domain.EventManualReload | ||
| 273 | } | 286 | } | ||
| 287 | + | ||||
| 288 | + | // ManualDeployHandler handles manual.deploy events for specific routes. | |||
| 289 | + | type ManualDeployHandler struct { | |||
| 290 | + | containerSvc in.ContainerService | |||
| 291 | + | configSvc in.ConfigService | |||
| 292 | + | ctx context.Context | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // NewManualDeployHandler creates a new ManualDeployHandler. | |||
| 296 | + | func NewManualDeployHandler(ctx context.Context, containerSvc in.ContainerService, configSvc in.ConfigService) *ManualDeployHandler { | |||
| 297 | + | return &ManualDeployHandler{ | |||
| 298 | + | containerSvc: containerSvc, | |||
| 299 | + | configSvc: configSvc, | |||
| 300 | + | ctx: ctx, | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // Handle handles a manual deploy event for a specific route. | |||
| 305 | + | func (h *ManualDeployHandler) Handle(event domain.Event) error { | |||
| 306 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |||
| 307 | + | zerowrap.FieldLayer: "usecase", | |||
| 308 | + | zerowrap.FieldHandler: "ManualDeployHandler", | |||
| 309 | + | zerowrap.FieldEvent: string(event.Type), | |||
| 310 | + | "event_id": event.ID, | |||
| 311 | + | }) | |||
| 312 | + | log := zerowrap.FromCtx(ctx) | |||
| 313 | + | ||||
| 314 | + | payload, ok := event.Data.(*domain.ManualDeployPayload) | |||
| 315 | + | if !ok || payload == nil || payload.Domain == "" { | |||
| 316 | + | return fmt.Errorf("invalid manual deploy payload") | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | log.Info().Str("domain", payload.Domain).Msg("processing manual deploy event") | |||
| 320 | + | ||||
| 321 | + | // Find the route in configuration | |||
| 322 | + | routes := h.configSvc.GetRoutes(ctx) | |||
| 323 | + | var targetRoute *domain.Route | |||
| 324 | + | for _, r := range routes { | |||
| 325 | + | if r.Domain == payload.Domain { | |||
| 326 | + | targetRoute = &r | |||
| 327 | + | break | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | if targetRoute == nil { | |||
| 332 | + | return fmt.Errorf("route not found for domain: %s", payload.Domain) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | // Deploy the container (this will replace any existing container) | |||
| 336 | + | if _, err := h.containerSvc.Deploy(ctx, *targetRoute); err != nil { | |||
| 337 | + | return log.WrapErr(err, "failed to deploy container") | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | log.Info().Str("domain", payload.Domain).Msg("manual deploy completed successfully") | |||
| 341 | + | return nil | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // CanHandle returns whether this handler can handle the given event type. | |||
| 345 | + | func (h *ManualDeployHandler) CanHandle(eventType domain.EventType) bool { | |||
| 346 | + | return eventType == domain.EventManualDeploy | |||
| 347 | + | } | |||
M internal/usecase/container/events_test.go +151 -0
| @@ -216,6 +216,9 @@ func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) { | |||
| 216 | 216 | ||
| 217 | 217 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |
| 218 | 218 | ||
| 219 | + | // SyncContainers is called first | |
| 220 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 221 | + | ||
| 219 | 222 | // No existing containers | |
| 220 | 223 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | |
| 221 | 224 | ||
| @@ -245,6 +248,9 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||
| 245 | 248 | ||
| 246 | 249 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |
| 247 | 250 | ||
| 251 | + | // SyncContainers is called first | |
| 252 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 253 | + | ||
| 248 | 254 | // Existing container for a route that's no longer configured | |
| 249 | 255 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 250 | 256 | "removed.example.com": { | |
| @@ -278,6 +284,9 @@ func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) { | |||
| 278 | 284 | ||
| 279 | 285 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |
| 280 | 286 | ||
| 287 | + | // SyncContainers is called first | |
| 288 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 289 | + | ||
| 281 | 290 | // Existing container with old image | |
| 282 | 291 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 283 | 292 | "app.example.com": { | |
| @@ -315,6 +324,9 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) { | |||
| 315 | 324 | ||
| 316 | 325 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |
| 317 | 326 | ||
| 327 | + | // SyncContainers is called first | |
| 328 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 329 | + | ||
| 318 | 330 | // Existing container matches config | |
| 319 | 331 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 320 | 332 | "app.example.com": { | |
| @@ -361,6 +373,9 @@ func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) { | |||
| 361 | 373 | ||
| 362 | 374 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | |
| 363 | 375 | ||
| 376 | + | // SyncContainers is called first | |
| 377 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 378 | + | ||
| 364 | 379 | // Routes in config | |
| 365 | 380 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 366 | 381 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -393,6 +408,9 @@ func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) { | |||
| 393 | 408 | ||
| 394 | 409 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | |
| 395 | 410 | ||
| 411 | + | // SyncContainers is called first | |
| 412 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 413 | + | ||
| 396 | 414 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 397 | 415 | {Domain: "app.example.com", Image: "app:latest"}, | |
| 398 | 416 | }) | |
| @@ -421,6 +439,9 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) { | |||
| 421 | 439 | ||
| 422 | 440 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | |
| 423 | 441 | ||
| 442 | + | // SyncContainers is called first | |
| 443 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 444 | + | ||
| 424 | 445 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 425 | 446 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| 426 | 447 | {Domain: "app2.example.com", Image: "app2:latest"}, | |
| @@ -459,6 +480,9 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T | |||
| 459 | 480 | ||
| 460 | 481 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | |
| 461 | 482 | ||
| 483 | + | // SyncContainers is called first | |
| 484 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 485 | + | ||
| 462 | 486 | // Route with running container | |
| 463 | 487 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 464 | 488 | {Domain: "app.example.com", Image: "app:latest"}, | |
| @@ -477,3 +501,130 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T | |||
| 477 | 501 | ||
| 478 | 502 | assert.NoError(t, err) | |
| 479 | 503 | } | |
| 504 | + | ||
| 505 | + | // ManualDeployHandler tests | |
| 506 | + | ||
| 507 | + | func TestManualDeployHandler_CanHandle(t *testing.T) { | |
| 508 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 509 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 510 | + | ||
| 511 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |
| 512 | + | ||
| 513 | + | assert.True(t, handler.CanHandle(domain.EventManualDeploy)) | |
| 514 | + | assert.False(t, handler.CanHandle(domain.EventImagePushed)) | |
| 515 | + | assert.False(t, handler.CanHandle(domain.EventManualReload)) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) { | |
| 519 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 520 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 521 | + | ||
| 522 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |
| 523 | + | ||
| 524 | + | // Configure routes | |
| 525 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 526 | + | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| 527 | + | {Domain: "app2.example.com", Image: "app2:latest"}, | |
| 528 | + | }) | |
| 529 | + | ||
| 530 | + | // Only the requested route should be deployed | |
| 531 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |
| 532 | + | Domain: "app1.example.com", | |
| 533 | + | Image: "app1:latest", | |
| 534 | + | }).Return(&domain.Container{ID: "container-1"}, nil) | |
| 535 | + | ||
| 536 | + | event := domain.Event{ | |
| 537 | + | ID: "event-123", | |
| 538 | + | Type: domain.EventManualDeploy, | |
| 539 | + | Data: &domain.ManualDeployPayload{Domain: "app1.example.com"}, | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | err := handler.Handle(event) | |
| 543 | + | ||
| 544 | + | assert.NoError(t, err) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) { | |
| 548 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 549 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 550 | + | ||
| 551 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |
| 552 | + | ||
| 553 | + | // No matching routes | |
| 554 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 555 | + | {Domain: "other.example.com", Image: "other:latest"}, | |
| 556 | + | }) | |
| 557 | + | ||
| 558 | + | event := domain.Event{ | |
| 559 | + | ID: "event-123", | |
| 560 | + | Type: domain.EventManualDeploy, | |
| 561 | + | Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"}, | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | err := handler.Handle(event) | |
| 565 | + | ||
| 566 | + | assert.Error(t, err) | |
| 567 | + | assert.Contains(t, err.Error(), "route not found") | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) { | |
| 571 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 572 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 573 | + | ||
| 574 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |
| 575 | + | ||
| 576 | + | // Event with nil payload | |
| 577 | + | event := domain.Event{ | |
| 578 | + | ID: "event-123", | |
| 579 | + | Type: domain.EventManualDeploy, | |
| 580 | + | Data: nil, | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | err := handler.Handle(event) | |
| 584 | + | ||
| 585 | + | assert.Error(t, err) | |
| 586 | + | assert.Contains(t, err.Error(), "invalid manual deploy payload") | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) { | |
| 590 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 591 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 592 | + | ||
| 593 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |
| 594 | + | ||
| 595 | + | // Event with empty domain | |
| 596 | + | event := domain.Event{ | |
| 597 | + | ID: "event-123", | |
| 598 | + | Type: domain.EventManualDeploy, | |
| 599 | + | Data: &domain.ManualDeployPayload{Domain: ""}, | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | err := handler.Handle(event) | |
| 603 | + | ||
| 604 | + | assert.Error(t, err) | |
| 605 | + | assert.Contains(t, err.Error(), "invalid manual deploy payload") | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | func TestManualDeployHandler_Handle_DeployError(t *testing.T) { | |
| 609 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 610 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 611 | + | ||
| 612 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |
| 613 | + | ||
| 614 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 615 | + | {Domain: "app.example.com", Image: "app:latest"}, | |
| 616 | + | }) | |
| 617 | + | ||
| 618 | + | containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed")) | |
| 619 | + | ||
| 620 | + | event := domain.Event{ | |
| 621 | + | ID: "event-123", | |
| 622 | + | Type: domain.EventManualDeploy, | |
| 623 | + | Data: &domain.ManualDeployPayload{Domain: "app.example.com"}, | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | err := handler.Handle(event) | |
| 627 | + | ||
| 628 | + | assert.Error(t, err) | |
| 629 | + | assert.Contains(t, err.Error(), "failed to deploy") | |
| 630 | + | } | |
| @@ -216,6 +216,9 @@ func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) { | |||||
| 216 | 216 | ||||
| 217 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | 217 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 218 | 218 | ||||
| 219 | + | // SyncContainers is called first | |||
| 220 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 221 | + | ||||
| 219 | // No existing containers | 222 | // No existing containers | ||
| 220 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | 223 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | ||
| 221 | 224 | ||||
| @@ -245,6 +248,9 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||||
| 245 | 248 | ||||
| 246 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | 249 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 247 | 250 | ||||
| 251 | + | // SyncContainers is called first | |||
| 252 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 253 | + | ||||
| 248 | // Existing container for a route that's no longer configured | 254 | // Existing container for a route that's no longer configured | ||
| 249 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | 255 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | ||
| 250 | "removed.example.com": { | 256 | "removed.example.com": { | ||
| @@ -278,6 +284,9 @@ func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) { | |||||
| 278 | 284 | ||||
| 279 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | 285 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 280 | 286 | ||||
| 287 | + | // SyncContainers is called first | |||
| 288 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 289 | + | ||||
| 281 | // Existing container with old image | 290 | // Existing container with old image | ||
| 282 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | 291 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | ||
| 283 | "app.example.com": { | 292 | "app.example.com": { | ||
| @@ -315,6 +324,9 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) { | |||||
| 315 | 324 | ||||
| 316 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | 325 | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 317 | 326 | ||||
| 327 | + | // SyncContainers is called first | |||
| 328 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 329 | + | ||||
| 318 | // Existing container matches config | 330 | // Existing container matches config | ||
| 319 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | 331 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | ||
| 320 | "app.example.com": { | 332 | "app.example.com": { | ||
| @@ -361,6 +373,9 @@ func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) { | |||||
| 361 | 373 | ||||
| 362 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | 374 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 363 | 375 | ||||
| 376 | + | // SyncContainers is called first | |||
| 377 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 378 | + | ||||
| 364 | // Routes in config | 379 | // Routes in config | ||
| 365 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | 380 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | ||
| 366 | {Domain: "app1.example.com", Image: "app1:latest"}, | 381 | {Domain: "app1.example.com", Image: "app1:latest"}, | ||
| @@ -393,6 +408,9 @@ func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) { | |||||
| 393 | 408 | ||||
| 394 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | 409 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 395 | 410 | ||||
| 411 | + | // SyncContainers is called first | |||
| 412 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 413 | + | ||||
| 396 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | 414 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | ||
| 397 | {Domain: "app.example.com", Image: "app:latest"}, | 415 | {Domain: "app.example.com", Image: "app:latest"}, | ||
| 398 | }) | 416 | }) | ||
| @@ -421,6 +439,9 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) { | |||||
| 421 | 439 | ||||
| 422 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | 440 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 423 | 441 | ||||
| 442 | + | // SyncContainers is called first | |||
| 443 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 444 | + | ||||
| 424 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | 445 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | ||
| 425 | {Domain: "app1.example.com", Image: "app1:latest"}, | 446 | {Domain: "app1.example.com", Image: "app1:latest"}, | ||
| 426 | {Domain: "app2.example.com", Image: "app2:latest"}, | 447 | {Domain: "app2.example.com", Image: "app2:latest"}, | ||
| @@ -459,6 +480,9 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T | |||||
| 459 | 480 | ||||
| 460 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | 481 | handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc) | ||
| 461 | 482 | ||||
| 483 | + | // SyncContainers is called first | |||
| 484 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 485 | + | ||||
| 462 | // Route with running container | 486 | // Route with running container | ||
| 463 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | 487 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | ||
| 464 | {Domain: "app.example.com", Image: "app:latest"}, | 488 | {Domain: "app.example.com", Image: "app:latest"}, | ||
| @@ -477,3 +501,130 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T | |||||
| 477 | 501 | ||||
| 478 | assert.NoError(t, err) | 502 | assert.NoError(t, err) | ||
| 479 | } | 503 | } | ||
| 504 | + | ||||
| 505 | + | // ManualDeployHandler tests | |||
| 506 | + | ||||
| 507 | + | func TestManualDeployHandler_CanHandle(t *testing.T) { | |||
| 508 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 509 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 510 | + | ||||
| 511 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |||
| 512 | + | ||||
| 513 | + | assert.True(t, handler.CanHandle(domain.EventManualDeploy)) | |||
| 514 | + | assert.False(t, handler.CanHandle(domain.EventImagePushed)) | |||
| 515 | + | assert.False(t, handler.CanHandle(domain.EventManualReload)) | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) { | |||
| 519 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 520 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 521 | + | ||||
| 522 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |||
| 523 | + | ||||
| 524 | + | // Configure routes | |||
| 525 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 526 | + | {Domain: "app1.example.com", Image: "app1:latest"}, | |||
| 527 | + | {Domain: "app2.example.com", Image: "app2:latest"}, | |||
| 528 | + | }) | |||
| 529 | + | ||||
| 530 | + | // Only the requested route should be deployed | |||
| 531 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |||
| 532 | + | Domain: "app1.example.com", | |||
| 533 | + | Image: "app1:latest", | |||
| 534 | + | }).Return(&domain.Container{ID: "container-1"}, nil) | |||
| 535 | + | ||||
| 536 | + | event := domain.Event{ | |||
| 537 | + | ID: "event-123", | |||
| 538 | + | Type: domain.EventManualDeploy, | |||
| 539 | + | Data: &domain.ManualDeployPayload{Domain: "app1.example.com"}, | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | err := handler.Handle(event) | |||
| 543 | + | ||||
| 544 | + | assert.NoError(t, err) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) { | |||
| 548 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 549 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 550 | + | ||||
| 551 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |||
| 552 | + | ||||
| 553 | + | // No matching routes | |||
| 554 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 555 | + | {Domain: "other.example.com", Image: "other:latest"}, | |||
| 556 | + | }) | |||
| 557 | + | ||||
| 558 | + | event := domain.Event{ | |||
| 559 | + | ID: "event-123", | |||
| 560 | + | Type: domain.EventManualDeploy, | |||
| 561 | + | Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"}, | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | err := handler.Handle(event) | |||
| 565 | + | ||||
| 566 | + | assert.Error(t, err) | |||
| 567 | + | assert.Contains(t, err.Error(), "route not found") | |||
| 568 | + | } | |||
| 569 | + | ||||
| 570 | + | func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) { | |||
| 571 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 572 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 573 | + | ||||
| 574 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |||
| 575 | + | ||||
| 576 | + | // Event with nil payload | |||
| 577 | + | event := domain.Event{ | |||
| 578 | + | ID: "event-123", | |||
| 579 | + | Type: domain.EventManualDeploy, | |||
| 580 | + | Data: nil, | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | err := handler.Handle(event) | |||
| 584 | + | ||||
| 585 | + | assert.Error(t, err) | |||
| 586 | + | assert.Contains(t, err.Error(), "invalid manual deploy payload") | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) { | |||
| 590 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 591 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 592 | + | ||||
| 593 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |||
| 594 | + | ||||
| 595 | + | // Event with empty domain | |||
| 596 | + | event := domain.Event{ | |||
| 597 | + | ID: "event-123", | |||
| 598 | + | Type: domain.EventManualDeploy, | |||
| 599 | + | Data: &domain.ManualDeployPayload{Domain: ""}, | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | err := handler.Handle(event) | |||
| 603 | + | ||||
| 604 | + | assert.Error(t, err) | |||
| 605 | + | assert.Contains(t, err.Error(), "invalid manual deploy payload") | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | func TestManualDeployHandler_Handle_DeployError(t *testing.T) { | |||
| 609 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 610 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 611 | + | ||||
| 612 | + | handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc) | |||
| 613 | + | ||||
| 614 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 615 | + | {Domain: "app.example.com", Image: "app:latest"}, | |||
| 616 | + | }) | |||
| 617 | + | ||||
| 618 | + | containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed")) | |||
| 619 | + | ||||
| 620 | + | event := domain.Event{ | |||
| 621 | + | ID: "event-123", | |||
| 622 | + | Type: domain.EventManualDeploy, | |||
| 623 | + | Data: &domain.ManualDeployPayload{Domain: "app.example.com"}, | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | err := handler.Handle(event) | |||
| 627 | + | ||||
| 628 | + | assert.Error(t, err) | |||
| 629 | + | assert.Contains(t, err.Error(), "failed to deploy") | |||
| 630 | + | } | |||
M internal/usecase/container/service.go +49 -2
| @@ -358,8 +358,48 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||
| 358 | 358 | return nil | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | - | // AutoStart starts containers for configured routes. | |
| 362 | - | func (s *Service) AutoStart(_ context.Context) error { | |
| 361 | + | // AutoStart starts containers for the provided routes that aren't running. | |
| 362 | + | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |
| 363 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 364 | + | zerowrap.FieldLayer: "usecase", | |
| 365 | + | zerowrap.FieldUseCase: "AutoStart", | |
| 366 | + | }) | |
| 367 | + | log := zerowrap.FromCtx(ctx) | |
| 368 | + | ||
| 369 | + | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | |
| 370 | + | ||
| 371 | + | var started, skipped, errors int | |
| 372 | + | for _, route := range routes { | |
| 373 | + | // Check if container already exists and is running | |
| 374 | + | if _, exists := s.Get(ctx, route.Domain); exists { | |
| 375 | + | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | |
| 376 | + | skipped++ | |
| 377 | + | continue | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | log.Info(). | |
| 381 | + | Str("domain", route.Domain). | |
| 382 | + | Str("image", route.Image). | |
| 383 | + | Msg("auto-starting container for route") | |
| 384 | + | ||
| 385 | + | if _, err := s.Deploy(ctx, route); err != nil { | |
| 386 | + | log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container") | |
| 387 | + | errors++ | |
| 388 | + | continue | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | started++ | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | log.Info(). | |
| 395 | + | Int("started", started). | |
| 396 | + | Int("skipped", skipped). | |
| 397 | + | Int("errors", errors). | |
| 398 | + | Msg("auto-start completed") | |
| 399 | + | ||
| 400 | + | if errors > 0 { | |
| 401 | + | return fmt.Errorf("auto-start completed with %d errors", errors) | |
| 402 | + | } | |
| 363 | 403 | return nil | |
| 364 | 404 | } | |
| 365 | 405 | ||
| @@ -524,6 +564,13 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||
| 524 | 564 | func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) { | |
| 525 | 565 | log := zerowrap.FromCtx(ctx) | |
| 526 | 566 | ||
| 567 | + | // For internal deploys (image push events), always pull fresh image | |
| 568 | + | // because the same tag may reference new content (e.g., latest tag updated) | |
| 569 | + | if domain.IsInternalDeploy(ctx) { | |
| 570 | + | log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content") | |
| 571 | + | return false, nil | |
| 572 | + | } | |
| 573 | + | ||
| 527 | 574 | localImages, err := s.runtime.ListImages(ctx) | |
| 528 | 575 | if err != nil { | |
| 529 | 576 | log.WrapErr(err, "failed to list local images, will attempt pull") | |
| @@ -358,8 +358,48 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||||
| 358 | return nil | 358 | return nil | ||
| 359 | } | 359 | } | ||
| 360 | 360 | ||||
| 361 | - | // AutoStart starts containers for configured routes. | 361 | + | // AutoStart starts containers for the provided routes that aren't running. |
| 362 | - | func (s *Service) AutoStart(_ context.Context) error { | 362 | + | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { |
| 363 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 364 | + | zerowrap.FieldLayer: "usecase", | |||
| 365 | + | zerowrap.FieldUseCase: "AutoStart", | |||
| 366 | + | }) | |||
| 367 | + | log := zerowrap.FromCtx(ctx) | |||
| 368 | + | ||||
| 369 | + | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | |||
| 370 | + | ||||
| 371 | + | var started, skipped, errors int | |||
| 372 | + | for _, route := range routes { | |||
| 373 | + | // Check if container already exists and is running | |||
| 374 | + | if _, exists := s.Get(ctx, route.Domain); exists { | |||
| 375 | + | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | |||
| 376 | + | skipped++ | |||
| 377 | + | continue | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | log.Info(). | |||
| 381 | + | Str("domain", route.Domain). | |||
| 382 | + | Str("image", route.Image). | |||
| 383 | + | Msg("auto-starting container for route") | |||
| 384 | + | ||||
| 385 | + | if _, err := s.Deploy(ctx, route); err != nil { | |||
| 386 | + | log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container") | |||
| 387 | + | errors++ | |||
| 388 | + | continue | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | started++ | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | log.Info(). | |||
| 395 | + | Int("started", started). | |||
| 396 | + | Int("skipped", skipped). | |||
| 397 | + | Int("errors", errors). | |||
| 398 | + | Msg("auto-start completed") | |||
| 399 | + | ||||
| 400 | + | if errors > 0 { | |||
| 401 | + | return fmt.Errorf("auto-start completed with %d errors", errors) | |||
| 402 | + | } | |||
| 363 | return nil | 403 | return nil | ||
| 364 | } | 404 | } | ||
| 365 | 405 | ||||
| @@ -524,6 +564,13 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||||
| 524 | func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) { | 564 | func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) { | ||
| 525 | log := zerowrap.FromCtx(ctx) | 565 | log := zerowrap.FromCtx(ctx) | ||
| 526 | 566 | ||||
| 567 | + | // For internal deploys (image push events), always pull fresh image | |||
| 568 | + | // because the same tag may reference new content (e.g., latest tag updated) | |||
| 569 | + | if domain.IsInternalDeploy(ctx) { | |||
| 570 | + | log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content") | |||
| 571 | + | return false, nil | |||
| 572 | + | } | |||
| 573 | + | ||||
| 527 | localImages, err := s.runtime.ListImages(ctx) | 574 | localImages, err := s.runtime.ListImages(ctx) | ||
| 528 | if err != nil { | 575 | if err != nil { | ||
| 529 | log.WrapErr(err, "failed to list local images, will attempt pull") | 576 | log.WrapErr(err, "failed to list local images, will attempt pull") | ||
M internal/usecase/container/service_test.go +184 -0
| @@ -711,3 +711,187 @@ func TestRewriteToLocalRegistry(t *testing.T) { | |||
| 711 | 711 | }) | |
| 712 | 712 | } | |
| 713 | 713 | } | |
| 714 | + | ||
| 715 | + | func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |
| 716 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 717 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 718 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 719 | + | ||
| 720 | + | config := Config{ | |
| 721 | + | RegistryAuthEnabled: true, | |
| 722 | + | RegistryDomain: "registry.example.com", | |
| 723 | + | RegistryPort: 5000, | |
| 724 | + | InternalRegistryUsername: "internal", | |
| 725 | + | InternalRegistryPassword: "secret", | |
| 726 | + | ReadinessDelay: 0, | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 730 | + | ||
| 731 | + | // Use internal deploy context (simulating image push event) | |
| 732 | + | ctx := domain.WithInternalDeploy(testContext()) | |
| 733 | + | ||
| 734 | + | route := domain.Route{ | |
| 735 | + | Domain: "test.example.com", | |
| 736 | + | Image: "myapp:latest", | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | // Setup mocks - no orphaned containers | |
| 740 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 741 | + | ||
| 742 | + | // Even though image exists locally, internal deploy should force pull | |
| 743 | + | // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check | |
| 744 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil) | |
| 745 | + | ||
| 746 | + | // Tag image to canonical name | |
| 747 | + | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil) | |
| 748 | + | runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | |
| 749 | + | ||
| 750 | + | // Get exposed ports | |
| 751 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil) | |
| 752 | + | ||
| 753 | + | // Load environment | |
| 754 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 755 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil) | |
| 756 | + | ||
| 757 | + | // Create and start container | |
| 758 | + | createdContainer := &domain.Container{ | |
| 759 | + | ID: "container-123", | |
| 760 | + | Name: "gordon-test.example.com", | |
| 761 | + | Status: "created", | |
| 762 | + | } | |
| 763 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) | |
| 764 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil) | |
| 765 | + | ||
| 766 | + | // Wait for ready | |
| 767 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2) | |
| 768 | + | ||
| 769 | + | // Re-inspect after start | |
| 770 | + | runningContainer := &domain.Container{ | |
| 771 | + | ID: "container-123", | |
| 772 | + | Name: "gordon-test.example.com", | |
| 773 | + | Status: "running", | |
| 774 | + | Ports: []int{8080}, | |
| 775 | + | } | |
| 776 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil) | |
| 777 | + | ||
| 778 | + | // Publish event | |
| 779 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 780 | + | ||
| 781 | + | result, err := svc.Deploy(ctx, route) | |
| 782 | + | ||
| 783 | + | assert.NoError(t, err) | |
| 784 | + | assert.NotNil(t, result) | |
| 785 | + | assert.Equal(t, "container-123", result.ID) | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |
| 789 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 790 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 791 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 792 | + | ||
| 793 | + | config := Config{ | |
| 794 | + | ReadinessDelay: 0, | |
| 795 | + | } | |
| 796 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 797 | + | ctx := testContext() | |
| 798 | + | ||
| 799 | + | routes := []domain.Route{ | |
| 800 | + | {Domain: "app1.example.com", Image: "myapp1:latest"}, | |
| 801 | + | {Domain: "app2.example.com", Image: "myapp2:latest"}, | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | // Setup mocks for first route deployment | |
| 805 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |
| 806 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | |
| 807 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | |
| 808 | + | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | |
| 809 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | |
| 810 | + | ||
| 811 | + | // Create and start containers | |
| 812 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |
| 813 | + | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | |
| 814 | + | }, nil).Once() | |
| 815 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |
| 816 | + | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | |
| 817 | + | }, nil).Once() | |
| 818 | + | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | |
| 819 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4) | |
| 820 | + | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | |
| 821 | + | Status: "running", | |
| 822 | + | }, nil).Times(2) | |
| 823 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2) | |
| 824 | + | ||
| 825 | + | err := svc.AutoStart(ctx, routes) | |
| 826 | + | ||
| 827 | + | assert.NoError(t, err) | |
| 828 | + | assert.Len(t, svc.containers, 2) | |
| 829 | + | } | |
| 830 | + | ||
| 831 | + | func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |
| 832 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 833 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 834 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 835 | + | ||
| 836 | + | config := Config{ | |
| 837 | + | ReadinessDelay: 0, | |
| 838 | + | } | |
| 839 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 840 | + | ctx := testContext() | |
| 841 | + | ||
| 842 | + | // Pre-populate with existing container | |
| 843 | + | svc.containers["app1.example.com"] = &domain.Container{ | |
| 844 | + | ID: "existing-container", | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | routes := []domain.Route{ | |
| 848 | + | {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists | |
| 849 | + | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | |
| 850 | + | } | |
| 851 | + | ||
| 852 | + | // Only deploy for app2 (app1 is skipped) | |
| 853 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |
| 854 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | |
| 855 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once() | |
| 856 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once() | |
| 857 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once() | |
| 858 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |
| 859 | + | ID: "container-2", Status: "created", | |
| 860 | + | }, nil).Once() | |
| 861 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once() | |
| 862 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2) | |
| 863 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | |
| 864 | + | Status: "running", | |
| 865 | + | }, nil).Once() | |
| 866 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once() | |
| 867 | + | ||
| 868 | + | err := svc.AutoStart(ctx, routes) | |
| 869 | + | ||
| 870 | + | assert.NoError(t, err) | |
| 871 | + | assert.Len(t, svc.containers, 2) | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | func TestService_AutoStart_HandlesDeployErrors(t *testing.T) { | |
| 875 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 876 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 877 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 878 | + | ||
| 879 | + | config := Config{} | |
| 880 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 881 | + | ctx := testContext() | |
| 882 | + | ||
| 883 | + | routes := []domain.Route{ | |
| 884 | + | {Domain: "app1.example.com", Image: "myapp1:latest"}, | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | // Setup mocks for failure | |
| 888 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 889 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 890 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found")) | |
| 891 | + | ||
| 892 | + | err := svc.AutoStart(ctx, routes) | |
| 893 | + | ||
| 894 | + | // AutoStart should return error when some deployments fail | |
| 895 | + | assert.Error(t, err) | |
| 896 | + | assert.Contains(t, err.Error(), "auto-start completed with 1 errors") | |
| 897 | + | } | |
| @@ -711,3 +711,187 @@ func TestRewriteToLocalRegistry(t *testing.T) { | |||||
| 711 | }) | 711 | }) | ||
| 712 | } | 712 | } | ||
| 713 | } | 713 | } | ||
| 714 | + | ||||
| 715 | + | func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||
| 716 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 717 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 718 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 719 | + | ||||
| 720 | + | config := Config{ | |||
| 721 | + | RegistryAuthEnabled: true, | |||
| 722 | + | RegistryDomain: "registry.example.com", | |||
| 723 | + | RegistryPort: 5000, | |||
| 724 | + | InternalRegistryUsername: "internal", | |||
| 725 | + | InternalRegistryPassword: "secret", | |||
| 726 | + | ReadinessDelay: 0, | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 730 | + | ||||
| 731 | + | // Use internal deploy context (simulating image push event) | |||
| 732 | + | ctx := domain.WithInternalDeploy(testContext()) | |||
| 733 | + | ||||
| 734 | + | route := domain.Route{ | |||
| 735 | + | Domain: "test.example.com", | |||
| 736 | + | Image: "myapp:latest", | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | // Setup mocks - no orphaned containers | |||
| 740 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 741 | + | ||||
| 742 | + | // Even though image exists locally, internal deploy should force pull | |||
| 743 | + | // Note: ensureLocalImage returns false for internal deploy, skipping the ListImages check | |||
| 744 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil) | |||
| 745 | + | ||||
| 746 | + | // Tag image to canonical name | |||
| 747 | + | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "registry.example.com/myapp:latest").Return(nil) | |||
| 748 | + | runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | |||
| 749 | + | ||||
| 750 | + | // Get exposed ports | |||
| 751 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "registry.example.com/myapp:latest").Return([]int{8080}, nil) | |||
| 752 | + | ||||
| 753 | + | // Load environment | |||
| 754 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 755 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "registry.example.com/myapp:latest").Return([]string{}, nil) | |||
| 756 | + | ||||
| 757 | + | // Create and start container | |||
| 758 | + | createdContainer := &domain.Container{ | |||
| 759 | + | ID: "container-123", | |||
| 760 | + | Name: "gordon-test.example.com", | |||
| 761 | + | Status: "created", | |||
| 762 | + | } | |||
| 763 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) | |||
| 764 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil) | |||
| 765 | + | ||||
| 766 | + | // Wait for ready | |||
| 767 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2) | |||
| 768 | + | ||||
| 769 | + | // Re-inspect after start | |||
| 770 | + | runningContainer := &domain.Container{ | |||
| 771 | + | ID: "container-123", | |||
| 772 | + | Name: "gordon-test.example.com", | |||
| 773 | + | Status: "running", | |||
| 774 | + | Ports: []int{8080}, | |||
| 775 | + | } | |||
| 776 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil) | |||
| 777 | + | ||||
| 778 | + | // Publish event | |||
| 779 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 780 | + | ||||
| 781 | + | result, err := svc.Deploy(ctx, route) | |||
| 782 | + | ||||
| 783 | + | assert.NoError(t, err) | |||
| 784 | + | assert.NotNil(t, result) | |||
| 785 | + | assert.Equal(t, "container-123", result.ID) | |||
| 786 | + | } | |||
| 787 | + | ||||
| 788 | + | func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 789 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 790 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 791 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 792 | + | ||||
| 793 | + | config := Config{ | |||
| 794 | + | ReadinessDelay: 0, | |||
| 795 | + | } | |||
| 796 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 797 | + | ctx := testContext() | |||
| 798 | + | ||||
| 799 | + | routes := []domain.Route{ | |||
| 800 | + | {Domain: "app1.example.com", Image: "myapp1:latest"}, | |||
| 801 | + | {Domain: "app2.example.com", Image: "myapp2:latest"}, | |||
| 802 | + | } | |||
| 803 | + | ||||
| 804 | + | // Setup mocks for first route deployment | |||
| 805 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |||
| 806 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | |||
| 807 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | |||
| 808 | + | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | |||
| 809 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | |||
| 810 | + | ||||
| 811 | + | // Create and start containers | |||
| 812 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |||
| 813 | + | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | |||
| 814 | + | }, nil).Once() | |||
| 815 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |||
| 816 | + | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | |||
| 817 | + | }, nil).Once() | |||
| 818 | + | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | |||
| 819 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4) | |||
| 820 | + | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | |||
| 821 | + | Status: "running", | |||
| 822 | + | }, nil).Times(2) | |||
| 823 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Times(2) | |||
| 824 | + | ||||
| 825 | + | err := svc.AutoStart(ctx, routes) | |||
| 826 | + | ||||
| 827 | + | assert.NoError(t, err) | |||
| 828 | + | assert.Len(t, svc.containers, 2) | |||
| 829 | + | } | |||
| 830 | + | ||||
| 831 | + | func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 832 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 833 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 834 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 835 | + | ||||
| 836 | + | config := Config{ | |||
| 837 | + | ReadinessDelay: 0, | |||
| 838 | + | } | |||
| 839 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 840 | + | ctx := testContext() | |||
| 841 | + | ||||
| 842 | + | // Pre-populate with existing container | |||
| 843 | + | svc.containers["app1.example.com"] = &domain.Container{ | |||
| 844 | + | ID: "existing-container", | |||
| 845 | + | } | |||
| 846 | + | ||||
| 847 | + | routes := []domain.Route{ | |||
| 848 | + | {Domain: "app1.example.com", Image: "myapp1:latest"}, // Already exists | |||
| 849 | + | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | |||
| 850 | + | } | |||
| 851 | + | ||||
| 852 | + | // Only deploy for app2 (app1 is skipped) | |||
| 853 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |||
| 854 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | |||
| 855 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once() | |||
| 856 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "app2.example.com").Return([]string{}, nil).Once() | |||
| 857 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp2:latest").Return([]string{}, nil).Once() | |||
| 858 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |||
| 859 | + | ID: "container-2", Status: "created", | |||
| 860 | + | }, nil).Once() | |||
| 861 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once() | |||
| 862 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2) | |||
| 863 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | |||
| 864 | + | Status: "running", | |||
| 865 | + | }, nil).Once() | |||
| 866 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Once() | |||
| 867 | + | ||||
| 868 | + | err := svc.AutoStart(ctx, routes) | |||
| 869 | + | ||||
| 870 | + | assert.NoError(t, err) | |||
| 871 | + | assert.Len(t, svc.containers, 2) | |||
| 872 | + | } | |||
| 873 | + | ||||
| 874 | + | func TestService_AutoStart_HandlesDeployErrors(t *testing.T) { | |||
| 875 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 876 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 877 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 878 | + | ||||
| 879 | + | config := Config{} | |||
| 880 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 881 | + | ctx := testContext() | |||
| 882 | + | ||||
| 883 | + | routes := []domain.Route{ | |||
| 884 | + | {Domain: "app1.example.com", Image: "myapp1:latest"}, | |||
| 885 | + | } | |||
| 886 | + | ||||
| 887 | + | // Setup mocks for failure | |||
| 888 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 889 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 890 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp1:latest").Return(errors.New("image not found")) | |||
| 891 | + | ||||
| 892 | + | err := svc.AutoStart(ctx, routes) | |||
| 893 | + | ||||
| 894 | + | // AutoStart should return error when some deployments fail | |||
| 895 | + | assert.Error(t, err) | |||
| 896 | + | assert.Contains(t, err.Error(), "auto-start completed with 1 errors") | |||
| 897 | + | } | |||
M internal/usecase/proxy/service.go +31 -0
| @@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 127 | 127 | } | |
| 128 | 128 | s.mu.RUnlock() | |
| 129 | 129 | ||
| 130 | + | // Check if this is an external route | |
| 131 | + | externalRoutes := s.configSvc.GetExternalRoutes() | |
| 132 | + | if targetAddr, ok := externalRoutes[domainName]; ok { | |
| 133 | + | host, portStr, err := net.SplitHostPort(targetAddr) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |
| 136 | + | } | |
| 137 | + | port, err := strconv.Atoi(portStr) | |
| 138 | + | if err != nil { | |
| 139 | + | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | target := &domain.ProxyTarget{ | |
| 143 | + | Host: host, | |
| 144 | + | Port: port, | |
| 145 | + | ContainerID: "", // Not a container | |
| 146 | + | Scheme: "http", | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Cache external route target | |
| 150 | + | s.mu.Lock() | |
| 151 | + | s.targets[domainName] = target | |
| 152 | + | s.mu.Unlock() | |
| 153 | + | ||
| 154 | + | log.Debug(). | |
| 155 | + | Str("host", host). | |
| 156 | + | Int("port", port). | |
| 157 | + | Msg("using external route target") | |
| 158 | + | return target, nil | |
| 159 | + | } | |
| 160 | + | ||
| 130 | 161 | // Get container for this domain | |
| 131 | 162 | container, exists := s.containerSvc.Get(ctx, domainName) | |
| 132 | 163 | if !exists { | |
| @@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 127 | } | 127 | } | ||
| 128 | s.mu.RUnlock() | 128 | s.mu.RUnlock() | ||
| 129 | 129 | ||||
| 130 | + | // Check if this is an external route | |||
| 131 | + | externalRoutes := s.configSvc.GetExternalRoutes() | |||
| 132 | + | if targetAddr, ok := externalRoutes[domainName]; ok { | |||
| 133 | + | host, portStr, err := net.SplitHostPort(targetAddr) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |||
| 136 | + | } | |||
| 137 | + | port, err := strconv.Atoi(portStr) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | target := &domain.ProxyTarget{ | |||
| 143 | + | Host: host, | |||
| 144 | + | Port: port, | |||
| 145 | + | ContainerID: "", // Not a container | |||
| 146 | + | Scheme: "http", | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Cache external route target | |||
| 150 | + | s.mu.Lock() | |||
| 151 | + | s.targets[domainName] = target | |||
| 152 | + | s.mu.Unlock() | |||
| 153 | + | ||||
| 154 | + | log.Debug(). | |||
| 155 | + | Str("host", host). | |||
| 156 | + | Int("port", port). | |||
| 157 | + | Msg("using external route target") | |||
| 158 | + | return target, nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 130 | // Get container for this domain | 161 | // Get container for this domain | ||
| 131 | container, exists := s.containerSvc.Get(ctx, domainName) | 162 | container, exists := s.containerSvc.Get(ctx, domainName) | ||
| 132 | if !exists { | 163 | if !exists { | ||
M internal/usecase/proxy/service_test.go +85 -0
| @@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||
| 55 | 55 | svc := NewService(runtime, containerSvc, configSvc, config) | |
| 56 | 56 | ctx := testContext() | |
| 57 | 57 | ||
| 58 | + | // Mock external routes (empty - no match) | |
| 59 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}) | |
| 58 | 60 | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | |
| 59 | 61 | ||
| 60 | 62 | result, err := svc.GetTarget(ctx, "app.example.com") | |
| @@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||
| 63 | 65 | assert.Nil(t, result) | |
| 64 | 66 | } | |
| 65 | 67 | ||
| 68 | + | func TestService_GetTarget_ExternalRoute(t *testing.T) { | |
| 69 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 70 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 71 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 72 | + | ||
| 73 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 74 | + | ctx := testContext() | |
| 75 | + | ||
| 76 | + | // Mock external routes | |
| 77 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 78 | + | "reg.example.com": "localhost:5000", | |
| 79 | + | }) | |
| 80 | + | ||
| 81 | + | result, err := svc.GetTarget(ctx, "reg.example.com") | |
| 82 | + | ||
| 83 | + | assert.NoError(t, err) | |
| 84 | + | assert.Equal(t, "localhost", result.Host) | |
| 85 | + | assert.Equal(t, 5000, result.Port) | |
| 86 | + | assert.Equal(t, "", result.ContainerID) | |
| 87 | + | assert.Equal(t, "http", result.Scheme) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |
| 91 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 92 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 93 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 94 | + | ||
| 95 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 96 | + | ctx := testContext() | |
| 97 | + | ||
| 98 | + | // First call - should resolve external route | |
| 99 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 100 | + | "reg.example.com": "localhost:5000", | |
| 101 | + | }).Once() | |
| 102 | + | ||
| 103 | + | result1, err := svc.GetTarget(ctx, "reg.example.com") | |
| 104 | + | assert.NoError(t, err) | |
| 105 | + | assert.Equal(t, "localhost", result1.Host) | |
| 106 | + | ||
| 107 | + | // Second call - should return from cache (no mock call needed) | |
| 108 | + | result2, err := svc.GetTarget(ctx, "reg.example.com") | |
| 109 | + | assert.NoError(t, err) | |
| 110 | + | assert.Equal(t, result1, result2) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | |
| 114 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 115 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 116 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 117 | + | ||
| 118 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 119 | + | ctx := testContext() | |
| 120 | + | ||
| 121 | + | // Mock external routes with invalid format (missing port) | |
| 122 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 123 | + | "invalid.example.com": "not-valid-format", | |
| 124 | + | }) | |
| 125 | + | ||
| 126 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |
| 127 | + | ||
| 128 | + | assert.Error(t, err) | |
| 129 | + | assert.Nil(t, result) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) { | |
| 133 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 134 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 135 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 136 | + | ||
| 137 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 138 | + | ctx := testContext() | |
| 139 | + | ||
| 140 | + | // Mock external routes with invalid port | |
| 141 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 142 | + | "invalid.example.com": "localhost:abc", | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |
| 146 | + | ||
| 147 | + | assert.Error(t, err) | |
| 148 | + | assert.Nil(t, result) | |
| 149 | + | } | |
| 150 | + | ||
| 66 | 151 | func TestService_RegisterTarget(t *testing.T) { | |
| 67 | 152 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 68 | 153 | containerSvc := inmocks.NewMockContainerService(t) | |
| @@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||||
| 55 | svc := NewService(runtime, containerSvc, configSvc, config) | 55 | svc := NewService(runtime, containerSvc, configSvc, config) | ||
| 56 | ctx := testContext() | 56 | ctx := testContext() | ||
| 57 | 57 | ||||
| 58 | + | // Mock external routes (empty - no match) | |||
| 59 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}) | |||
| 58 | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | 60 | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | ||
| 59 | 61 | ||||
| 60 | result, err := svc.GetTarget(ctx, "app.example.com") | 62 | result, err := svc.GetTarget(ctx, "app.example.com") | ||
| @@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||||
| 63 | assert.Nil(t, result) | 65 | assert.Nil(t, result) | ||
| 64 | } | 66 | } | ||
| 65 | 67 | ||||
| 68 | + | func TestService_GetTarget_ExternalRoute(t *testing.T) { | |||
| 69 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 70 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 71 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 72 | + | ||||
| 73 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 74 | + | ctx := testContext() | |||
| 75 | + | ||||
| 76 | + | // Mock external routes | |||
| 77 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 78 | + | "reg.example.com": "localhost:5000", | |||
| 79 | + | }) | |||
| 80 | + | ||||
| 81 | + | result, err := svc.GetTarget(ctx, "reg.example.com") | |||
| 82 | + | ||||
| 83 | + | assert.NoError(t, err) | |||
| 84 | + | assert.Equal(t, "localhost", result.Host) | |||
| 85 | + | assert.Equal(t, 5000, result.Port) | |||
| 86 | + | assert.Equal(t, "", result.ContainerID) | |||
| 87 | + | assert.Equal(t, "http", result.Scheme) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||
| 91 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 92 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 93 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 94 | + | ||||
| 95 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 96 | + | ctx := testContext() | |||
| 97 | + | ||||
| 98 | + | // First call - should resolve external route | |||
| 99 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 100 | + | "reg.example.com": "localhost:5000", | |||
| 101 | + | }).Once() | |||
| 102 | + | ||||
| 103 | + | result1, err := svc.GetTarget(ctx, "reg.example.com") | |||
| 104 | + | assert.NoError(t, err) | |||
| 105 | + | assert.Equal(t, "localhost", result1.Host) | |||
| 106 | + | ||||
| 107 | + | // Second call - should return from cache (no mock call needed) | |||
| 108 | + | result2, err := svc.GetTarget(ctx, "reg.example.com") | |||
| 109 | + | assert.NoError(t, err) | |||
| 110 | + | assert.Equal(t, result1, result2) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | |||
| 114 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 115 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 116 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 117 | + | ||||
| 118 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 119 | + | ctx := testContext() | |||
| 120 | + | ||||
| 121 | + | // Mock external routes with invalid format (missing port) | |||
| 122 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 123 | + | "invalid.example.com": "not-valid-format", | |||
| 124 | + | }) | |||
| 125 | + | ||||
| 126 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |||
| 127 | + | ||||
| 128 | + | assert.Error(t, err) | |||
| 129 | + | assert.Nil(t, result) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) { | |||
| 133 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 134 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 135 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 136 | + | ||||
| 137 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 138 | + | ctx := testContext() | |||
| 139 | + | ||||
| 140 | + | // Mock external routes with invalid port | |||
| 141 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 142 | + | "invalid.example.com": "localhost:abc", | |||
| 143 | + | }) | |||
| 144 | + | ||||
| 145 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |||
| 146 | + | ||||
| 147 | + | assert.Error(t, err) | |||
| 148 | + | assert.Nil(t, result) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 66 | func TestService_RegisterTarget(t *testing.T) { | 151 | func TestService_RegisterTarget(t *testing.T) { | ||
| 67 | runtime := outmocks.NewMockContainerRuntime(t) | 152 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 68 | containerSvc := inmocks.NewMockContainerService(t) | 153 | containerSvc := inmocks.NewMockContainerService(t) | ||