Merge pull request #57 from bnema/feat/attachment-secrets-cli
feat(secrets): add attachment secrets CLI support
19 files · +1913 -76
M .gitignore +2 -1
| @@ -1,3 +1,4 @@ | |||
| 1 | 1 | gordon | |
| 2 | 2 | .env | |
| 3 | - | dist/⏎̸ | |
| 3 | + | dist/ | |
| 4 | + | docs/plans/⏎̸ | |
| @@ -1,3 +1,4 @@ | |||||
| 1 | gordon | 1 | gordon | ||
| 2 | .env | 2 | .env | ||
| 3 | - | dist/⏎̸ | 3 | + | dist/ |
| 4 | + | docs/plans/⏎̸ | |||
M docs/cli/secrets.md +41 -8
| @@ -23,7 +23,7 @@ Storage depends on the secrets backend: | |||
| 23 | 23 | ||
| 24 | 24 | ## gordon secrets list | |
| 25 | 25 | ||
| 26 | - | List all secrets for a specific domain. | |
| 26 | + | List all secrets for a specific domain. When attachment secrets are present, they are displayed in a tree view below the domain secrets. | |
| 27 | 27 | ||
| 28 | 28 | ```bash | |
| 29 | 29 | gordon secrets list <domain> | |
| @@ -55,11 +55,16 @@ gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TO | |||
| 55 | 55 | ### Output | |
| 56 | 56 | ||
| 57 | 57 | ``` | |
| 58 | - | Secret Value (masked) | |
| 59 | - | -------------------------------------------------------------------------------- | |
| 60 | - | DATABASE_URL postgres://... | |
| 58 | + | Secrets for app.mydomain.com | |
| 59 | + | ||
| 60 | + | Key Value | |
| 61 | + | DATABASE_URL **** | |
| 61 | 62 | API_KEY **** | |
| 62 | - | JWT_SECRET **** | |
| 63 | + | ├─ [postgres] | |
| 64 | + | │ ├─ POSTGRES_USER **** | |
| 65 | + | │ └─ POSTGRES_PASSWORD **** | |
| 66 | + | └─ [redis] | |
| 67 | + | └─ REDIS_PASSWORD **** | |
| 63 | 68 | ``` | |
| 64 | 69 | ||
| 65 | 70 | --- | |
| @@ -69,7 +74,7 @@ JWT_SECRET **** | |||
| 69 | 74 | Set a secret value for a domain. | |
| 70 | 75 | ||
| 71 | 76 | ```bash | |
| 72 | - | gordon secrets set <domain> <key> <value> | |
| 77 | + | gordon secrets set <domain> <KEY=value>... | |
| 73 | 78 | gordon secrets set myapp.local DATABASE_URL "postgres://..." | |
| 74 | 79 | ``` | |
| 75 | 80 | ||
| @@ -85,6 +90,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..." | |||
| 85 | 90 | ||
| 86 | 91 | | Option | Description | | |
| 87 | 92 | |--------|-------------| | |
| 93 | + | | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) | | |
| 88 | 94 | | `--remote` | Remote Gordon URL | | |
| 89 | 95 | | `--token` | Authentication token for remote | | |
| 90 | 96 | ||
| @@ -97,6 +103,13 @@ gordon secrets set myapp.local API_KEY "your-api-key" | |||
| 97 | 103 | ||
| 98 | 104 | # Remote (override) | |
| 99 | 105 | gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN | |
| 106 | + | ||
| 107 | + | # Set attachment secrets | |
| 108 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 109 | + | gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=mysecret | |
| 110 | + | ||
| 111 | + | # Multiple secrets at once | |
| 112 | + | gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |
| 100 | 113 | ``` | |
| 101 | 114 | ||
| 102 | 115 | --- | |
| @@ -120,6 +133,7 @@ gordon secrets remove <domain> <key> | |||
| 120 | 133 | ||
| 121 | 134 | | Option | Description | | |
| 122 | 135 | |--------|-------------| | |
| 136 | + | | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) | | |
| 123 | 137 | | `--remote` | Remote Gordon URL | | |
| 124 | 138 | | `--token` | Authentication token for remote | | |
| 125 | 139 | ||
| @@ -131,6 +145,9 @@ gordon secrets remove myapp.local DATABASE_URL | |||
| 131 | 145 | ||
| 132 | 146 | # Remote (override) | |
| 133 | 147 | gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | |
| 148 | + | ||
| 149 | + | # Remove attachment secret | |
| 150 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |
| 134 | 151 | ``` | |
| 135 | 152 | ||
| 136 | 153 | --- | |
| @@ -166,7 +183,7 @@ gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL" | |||
| 166 | 183 | gordon secrets set myapp.example.com API_KEY "$API_KEY" | |
| 167 | 184 | ||
| 168 | 185 | # Deploy | |
| 169 | - | gordon routes deploy myapp.example.com | |
| 186 | + | gordon deploy myapp.example.com | |
| 170 | 187 | ``` | |
| 171 | 188 | ||
| 172 | 189 | ### Rotating Secrets | |
| @@ -179,7 +196,23 @@ NEW_JWT_SECRET=$(openssl rand -base64 32) | |||
| 179 | 196 | gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET" | |
| 180 | 197 | ||
| 181 | 198 | # Redeploy to pick up new secret | |
| 182 | - | gordon routes deploy myapp.local | |
| 199 | + | gordon deploy myapp.local | |
| 200 | + | ``` | |
| 201 | + | ||
| 202 | + | ### Attachment Secrets | |
| 203 | + | ||
| 204 | + | ```bash | |
| 205 | + | # Configure database credentials | |
| 206 | + | gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |
| 207 | + | ||
| 208 | + | # Configure cache credentials | |
| 209 | + | gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=cache-secret | |
| 210 | + | ||
| 211 | + | # Verify | |
| 212 | + | gordon secrets list app.mydomain.com | |
| 213 | + | ||
| 214 | + | # Redeploy to pick up new secrets | |
| 215 | + | gordon deploy app.mydomain.com | |
| 183 | 216 | ``` | |
| 184 | 217 | ||
| 185 | 218 | ## Related | |
| @@ -23,7 +23,7 @@ Storage depends on the secrets backend: | |||||
| 23 | 23 | ||||
| 24 | ## gordon secrets list | 24 | ## gordon secrets list | ||
| 25 | 25 | ||||
| 26 | - | List all secrets for a specific domain. | 26 | + | List all secrets for a specific domain. When attachment secrets are present, they are displayed in a tree view below the domain secrets. |
| 27 | 27 | ||||
| 28 | ```bash | 28 | ```bash | ||
| 29 | gordon secrets list <domain> | 29 | gordon secrets list <domain> | ||
| @@ -55,11 +55,16 @@ gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TO | |||||
| 55 | ### Output | 55 | ### Output | ||
| 56 | 56 | ||||
| 57 | ``` | 57 | ``` | ||
| 58 | - | Secret Value (masked) | 58 | + | Secrets for app.mydomain.com |
| 59 | - | -------------------------------------------------------------------------------- | 59 | + | |
| 60 | - | DATABASE_URL postgres://... | 60 | + | Key Value |
| 61 | + | DATABASE_URL **** | |||
| 61 | API_KEY **** | 62 | API_KEY **** | ||
| 62 | - | JWT_SECRET **** | 63 | + | ├─ [postgres] |
| 64 | + | │ ├─ POSTGRES_USER **** | |||
| 65 | + | │ └─ POSTGRES_PASSWORD **** | |||
| 66 | + | └─ [redis] | |||
| 67 | + | └─ REDIS_PASSWORD **** | |||
| 63 | ``` | 68 | ``` | ||
| 64 | 69 | ||||
| 65 | --- | 70 | --- | ||
| @@ -69,7 +74,7 @@ JWT_SECRET **** | |||||
| 69 | Set a secret value for a domain. | 74 | Set a secret value for a domain. | ||
| 70 | 75 | ||||
| 71 | ```bash | 76 | ```bash | ||
| 72 | - | gordon secrets set <domain> <key> <value> | 77 | + | gordon secrets set <domain> <KEY=value>... |
| 73 | gordon secrets set myapp.local DATABASE_URL "postgres://..." | 78 | gordon secrets set myapp.local DATABASE_URL "postgres://..." | ||
| 74 | ``` | 79 | ``` | ||
| 75 | 80 | ||||
| @@ -85,6 +90,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..." | |||||
| 85 | 90 | ||||
| 86 | | Option | Description | | 91 | | Option | Description | | ||
| 87 | |--------|-------------| | 92 | |--------|-------------| | ||
| 93 | + | | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) | | |||
| 88 | | `--remote` | Remote Gordon URL | | 94 | | `--remote` | Remote Gordon URL | | ||
| 89 | | `--token` | Authentication token for remote | | 95 | | `--token` | Authentication token for remote | | ||
| 90 | 96 | ||||
| @@ -97,6 +103,13 @@ gordon secrets set myapp.local API_KEY "your-api-key" | |||||
| 97 | 103 | ||||
| 98 | # Remote (override) | 104 | # Remote (override) | ||
| 99 | gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN | 105 | gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN | ||
| 106 | + | ||||
| 107 | + | # Set attachment secrets | |||
| 108 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |||
| 109 | + | gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=mysecret | |||
| 110 | + | ||||
| 111 | + | # Multiple secrets at once | |||
| 112 | + | gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |||
| 100 | ``` | 113 | ``` | ||
| 101 | 114 | ||||
| 102 | --- | 115 | --- | ||
| @@ -120,6 +133,7 @@ gordon secrets remove <domain> <key> | |||||
| 120 | 133 | ||||
| 121 | | Option | Description | | 134 | | Option | Description | | ||
| 122 | |--------|-------------| | 135 | |--------|-------------| | ||
| 136 | + | | `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) | | |||
| 123 | | `--remote` | Remote Gordon URL | | 137 | | `--remote` | Remote Gordon URL | | ||
| 124 | | `--token` | Authentication token for remote | | 138 | | `--token` | Authentication token for remote | | ||
| 125 | 139 | ||||
| @@ -131,6 +145,9 @@ gordon secrets remove myapp.local DATABASE_URL | |||||
| 131 | 145 | ||||
| 132 | # Remote (override) | 146 | # Remote (override) | ||
| 133 | gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | 147 | gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | ||
| 148 | + | ||||
| 149 | + | # Remove attachment secret | |||
| 150 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |||
| 134 | ``` | 151 | ``` | ||
| 135 | 152 | ||||
| 136 | --- | 153 | --- | ||
| @@ -166,7 +183,7 @@ gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL" | |||||
| 166 | gordon secrets set myapp.example.com API_KEY "$API_KEY" | 183 | gordon secrets set myapp.example.com API_KEY "$API_KEY" | ||
| 167 | 184 | ||||
| 168 | # Deploy | 185 | # Deploy | ||
| 169 | - | gordon routes deploy myapp.example.com | 186 | + | gordon deploy myapp.example.com |
| 170 | ``` | 187 | ``` | ||
| 171 | 188 | ||||
| 172 | ### Rotating Secrets | 189 | ### Rotating Secrets | ||
| @@ -179,7 +196,23 @@ NEW_JWT_SECRET=$(openssl rand -base64 32) | |||||
| 179 | gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET" | 196 | gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET" | ||
| 180 | 197 | ||||
| 181 | # Redeploy to pick up new secret | 198 | # Redeploy to pick up new secret | ||
| 182 | - | gordon routes deploy myapp.local | 199 | + | gordon deploy myapp.local |
| 200 | + | ``` | |||
| 201 | + | ||||
| 202 | + | ### Attachment Secrets | |||
| 203 | + | ||||
| 204 | + | ```bash | |||
| 205 | + | # Configure database credentials | |||
| 206 | + | gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |||
| 207 | + | ||||
| 208 | + | # Configure cache credentials | |||
| 209 | + | gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=cache-secret | |||
| 210 | + | ||||
| 211 | + | # Verify | |||
| 212 | + | gordon secrets list app.mydomain.com | |||
| 213 | + | ||||
| 214 | + | # Redeploy to pick up new secrets | |||
| 215 | + | gordon deploy app.mydomain.com | |||
| 183 | ``` | 216 | ``` | ||
| 184 | 217 | ||||
| 185 | ## Related | 218 | ## Related | ||
M docs/config/attachments.md +39 -1
| @@ -80,9 +80,21 @@ FROM postgres:18 | |||
| 80 | 80 | VOLUME ["/var/lib/postgresql/data"] | |
| 81 | 81 | ENV POSTGRES_DB=myapp | |
| 82 | 82 | ENV POSTGRES_USER=app | |
| 83 | - | ENV POSTGRES_PASSWORD=secret | |
| 83 | + | # Password is injected via attachment secrets — do NOT hardcode here | |
| 84 | 84 | ``` | |
| 85 | 85 | ||
| 86 | + | Configure credentials via attachment secrets instead of hardcoding them: | |
| 87 | + | ||
| 88 | + | ```bash | |
| 89 | + | # Set database password securely | |
| 90 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 91 | + | ||
| 92 | + | # Verify | |
| 93 | + | gordon secrets list app.mydomain.com | |
| 94 | + | ``` | |
| 95 | + | ||
| 96 | + | This works with all secrets backends (pass, sops, unsafe). See [Secrets Configuration](./secrets.md) for backend details. | |
| 97 | + | ||
| 86 | 98 | Build and push to Gordon: | |
| 87 | 99 | ```bash | |
| 88 | 100 | docker build -f postgres.Dockerfile -t my-postgres:latest . | |
| @@ -95,6 +107,32 @@ Use in attachments: | |||
| 95 | 107 | "app.mydomain.com" = ["my-postgres:latest"] | |
| 96 | 108 | ``` | |
| 97 | 109 | ||
| 110 | + | ## Attachment Secrets | |
| 111 | + | ||
| 112 | + | Inject environment variables into attachment containers using the `--attachment` flag on secrets commands: | |
| 113 | + | ||
| 114 | + | ```bash | |
| 115 | + | # Set secrets for the postgres attachment | |
| 116 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |
| 117 | + | ||
| 118 | + | # Set secrets for the redis attachment | |
| 119 | + | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=cache-secret | |
| 120 | + | ||
| 121 | + | # View all secrets (domain + attachments) in a tree view | |
| 122 | + | gordon secrets list app.mydomain.com | |
| 123 | + | ||
| 124 | + | # Remove an attachment secret | |
| 125 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |
| 126 | + | ``` | |
| 127 | + | ||
| 128 | + | The `--attachment` flag takes the service name (the image name before the colon in your attachments config). For example, if your config has `"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]`, the service names are `postgres` and `redis`. | |
| 129 | + | ||
| 130 | + | Storage depends on your secrets backend: | |
| 131 | + | - **pass**: `gordon/env/attachments/<container-name>/<KEY>` | |
| 132 | + | - **sops/unsafe**: `gordon-<container-name>.env` files | |
| 133 | + | ||
| 134 | + | See [Secrets Configuration](./secrets.md) and [Secrets Commands](../cli/secrets.md) for details. | |
| 135 | + | ||
| 98 | 136 | ## Shared Attachments with Network Groups | |
| 99 | 137 | ||
| 100 | 138 | Share services between multiple apps using network groups: | |
| @@ -80,9 +80,21 @@ FROM postgres:18 | |||||
| 80 | VOLUME ["/var/lib/postgresql/data"] | 80 | VOLUME ["/var/lib/postgresql/data"] | ||
| 81 | ENV POSTGRES_DB=myapp | 81 | ENV POSTGRES_DB=myapp | ||
| 82 | ENV POSTGRES_USER=app | 82 | ENV POSTGRES_USER=app | ||
| 83 | - | ENV POSTGRES_PASSWORD=secret | 83 | + | # Password is injected via attachment secrets — do NOT hardcode here |
| 84 | ``` | 84 | ``` | ||
| 85 | 85 | ||||
| 86 | + | Configure credentials via attachment secrets instead of hardcoding them: | |||
| 87 | + | ||||
| 88 | + | ```bash | |||
| 89 | + | # Set database password securely | |||
| 90 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |||
| 91 | + | ||||
| 92 | + | # Verify | |||
| 93 | + | gordon secrets list app.mydomain.com | |||
| 94 | + | ``` | |||
| 95 | + | ||||
| 96 | + | This works with all secrets backends (pass, sops, unsafe). See [Secrets Configuration](./secrets.md) for backend details. | |||
| 97 | + | ||||
| 86 | Build and push to Gordon: | 98 | Build and push to Gordon: | ||
| 87 | ```bash | 99 | ```bash | ||
| 88 | docker build -f postgres.Dockerfile -t my-postgres:latest . | 100 | docker build -f postgres.Dockerfile -t my-postgres:latest . | ||
| @@ -95,6 +107,32 @@ Use in attachments: | |||||
| 95 | "app.mydomain.com" = ["my-postgres:latest"] | 107 | "app.mydomain.com" = ["my-postgres:latest"] | ||
| 96 | ``` | 108 | ``` | ||
| 97 | 109 | ||||
| 110 | + | ## Attachment Secrets | |||
| 111 | + | ||||
| 112 | + | Inject environment variables into attachment containers using the `--attachment` flag on secrets commands: | |||
| 113 | + | ||||
| 114 | + | ```bash | |||
| 115 | + | # Set secrets for the postgres attachment | |||
| 116 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret | |||
| 117 | + | ||||
| 118 | + | # Set secrets for the redis attachment | |||
| 119 | + | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=cache-secret | |||
| 120 | + | ||||
| 121 | + | # View all secrets (domain + attachments) in a tree view | |||
| 122 | + | gordon secrets list app.mydomain.com | |||
| 123 | + | ||||
| 124 | + | # Remove an attachment secret | |||
| 125 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD | |||
| 126 | + | ``` | |||
| 127 | + | ||||
| 128 | + | The `--attachment` flag takes the service name (the image name before the colon in your attachments config). For example, if your config has `"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]`, the service names are `postgres` and `redis`. | |||
| 129 | + | ||||
| 130 | + | Storage depends on your secrets backend: | |||
| 131 | + | - **pass**: `gordon/env/attachments/<container-name>/<KEY>` | |||
| 132 | + | - **sops/unsafe**: `gordon-<container-name>.env` files | |||
| 133 | + | ||||
| 134 | + | See [Secrets Configuration](./secrets.md) and [Secrets Commands](../cli/secrets.md) for details. | |||
| 135 | + | ||||
| 98 | ## Shared Attachments with Network Groups | 136 | ## Shared Attachments with Network Groups | ||
| 99 | 137 | ||||
| 100 | Share services between multiple apps using network groups: | 138 | Share services between multiple apps using network groups: | ||
M docs/config/secrets.md +12 -0
| @@ -57,6 +57,8 @@ token_secret = "gordon/auth/token_secret" | |||
| 57 | 57 | **Route secrets storage:** | |
| 58 | 58 | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | |
| 59 | 59 | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | |
| 60 | + | - Attachment secrets are stored under `gordon/env/attachments/<container-name>/<KEY>` with a `.keys` manifest | |
| 61 | + | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |
| 60 | 62 | ||
| 61 | 63 | ### SOPS | |
| 62 | 64 | ||
| @@ -98,6 +100,11 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||
| 98 | 100 | - Domain secrets stay in `.env` files | |
| 99 | 101 | - Use `${sops:...}` syntax to resolve encrypted values | |
| 100 | 102 | ||
| 103 | + | **Attachment secrets storage:** | |
| 104 | + | - Attachment secrets are stored in `gordon-<container-name>.env` files alongside domain env files | |
| 105 | + | - Use `${sops:...}` syntax inside attachment env files to resolve encrypted values | |
| 106 | + | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |
| 107 | + | ||
| 101 | 108 | ### Unsafe (Development Only) | |
| 102 | 109 | ||
| 103 | 110 | Stores secrets as plain text files: | |
| @@ -116,6 +123,11 @@ secrets_backend = "unsafe" | |||
| 116 | 123 | │ └── token_secret | |
| 117 | 124 | ``` | |
| 118 | 125 | ||
| 126 | + | **Attachment secrets:** | |
| 127 | + | - Stored as `gordon-<container-name>.env` files in the env directory | |
| 128 | + | - Example: `gordon-app__mydomain__com-postgres.env` | |
| 129 | + | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |
| 130 | + | ||
| 119 | 131 | **Usage:** | |
| 120 | 132 | ```bash | |
| 121 | 133 | # Create secret | |
| @@ -57,6 +57,8 @@ token_secret = "gordon/auth/token_secret" | |||||
| 57 | **Route secrets storage:** | 57 | **Route secrets storage:** | ||
| 58 | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | 58 | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | ||
| 59 | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | 59 | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | ||
| 60 | + | - Attachment secrets are stored under `gordon/env/attachments/<container-name>/<KEY>` with a `.keys` manifest | |||
| 61 | + | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |||
| 60 | 62 | ||||
| 61 | ### SOPS | 63 | ### SOPS | ||
| 62 | 64 | ||||
| @@ -98,6 +100,11 @@ DB_PASSWORD=${sops:secrets.yaml:database.password} | |||||
| 98 | - Domain secrets stay in `.env` files | 100 | - Domain secrets stay in `.env` files | ||
| 99 | - Use `${sops:...}` syntax to resolve encrypted values | 101 | - Use `${sops:...}` syntax to resolve encrypted values | ||
| 100 | 102 | ||||
| 103 | + | **Attachment secrets storage:** | |||
| 104 | + | - Attachment secrets are stored in `gordon-<container-name>.env` files alongside domain env files | |||
| 105 | + | - Use `${sops:...}` syntax inside attachment env files to resolve encrypted values | |||
| 106 | + | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |||
| 107 | + | ||||
| 101 | ### Unsafe (Development Only) | 108 | ### Unsafe (Development Only) | ||
| 102 | 109 | ||||
| 103 | Stores secrets as plain text files: | 110 | Stores secrets as plain text files: | ||
| @@ -116,6 +123,11 @@ secrets_backend = "unsafe" | |||||
| 116 | │ └── token_secret | 123 | │ └── token_secret | ||
| 117 | ``` | 124 | ``` | ||
| 118 | 125 | ||||
| 126 | + | **Attachment secrets:** | |||
| 127 | + | - Stored as `gordon-<container-name>.env` files in the env directory | |||
| 128 | + | - Example: `gordon-app__mydomain__com-postgres.env` | |||
| 129 | + | - Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them | |||
| 130 | + | ||||
| 119 | **Usage:** | 131 | **Usage:** | ||
| 120 | ```bash | 132 | ```bash | ||
| 121 | # Create secret | 133 | # Create secret | ||
A docs/plans/2026-02-06-attachment-secrets.md +893 -0
| @@ -0,0 +1,893 @@ | |||
| 1 | + | # Attachment Secrets CLI Support — Implementation Plan | |
| 2 | + | ||
| 3 | + | > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. | |
| 4 | + | ||
| 5 | + | **Goal:** Allow `gordon secrets set` and `gordon secrets remove` to target attachment containers via an `--attachment` flag, closing issue #56. | |
| 6 | + | ||
| 7 | + | **Architecture:** The storage layer (`DomainSecretStore.SetAttachment`) and file format (`gordon-{container}.env`) already exist. The gap is a missing `DeleteAttachment` in the store, missing attachment methods on the `SecretService` use case, a missing admin API endpoint for attachment secrets, and missing `--attachment` flag on CLI commands. We thread through all layers: storage → use case → admin API → remote client → CLI. | |
| 8 | + | ||
| 9 | + | **Tech Stack:** Go, cobra (CLI), mockery (mock generation), httptest | |
| 10 | + | ||
| 11 | + | --- | |
| 12 | + | ||
| 13 | + | ### Task 1: Add `DeleteAttachment` to storage interface and implementation | |
| 14 | + | ||
| 15 | + | The `DomainSecretStore` has `SetAttachment` but no `DeleteAttachment`. We need it to remove individual keys from attachment env files. | |
| 16 | + | ||
| 17 | + | **Files:** | |
| 18 | + | - Modify: `internal/boundaries/out/domain_secrets.go:13-36` | |
| 19 | + | - Modify: `internal/adapters/out/domainsecrets/store.go` (add method after `SetAttachment`) | |
| 20 | + | - Test: `internal/adapters/out/domainsecrets/store_test.go` | |
| 21 | + | ||
| 22 | + | **Step 1: Write the failing test** | |
| 23 | + | ||
| 24 | + | Add to `store_test.go`, near the existing `SetAttachment` tests (~line 229): | |
| 25 | + | ||
| 26 | + | ```go | |
| 27 | + | func TestFileStore_DeleteAttachment(t *testing.T) { | |
| 28 | + | dir := t.TempDir() | |
| 29 | + | store := NewFileStore(dir, testLogger()) | |
| 30 | + | containerName := "app__example__com-postgres" | |
| 31 | + | ||
| 32 | + | // Seed with two secrets | |
| 33 | + | err := store.SetAttachment(containerName, map[string]string{ | |
| 34 | + | "POSTGRES_USER": "admin", | |
| 35 | + | "POSTGRES_PASSWORD": "secret", | |
| 36 | + | }) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | ||
| 39 | + | t.Run("deletes single key", func(t *testing.T) { | |
| 40 | + | err := store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | ||
| 43 | + | secrets, err := store.GetAllAttachment(containerName) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.Equal(t, map[string]string{"POSTGRES_USER": "admin"}, secrets) | |
| 46 | + | }) | |
| 47 | + | ||
| 48 | + | t.Run("deleting nonexistent key is no-op", func(t *testing.T) { | |
| 49 | + | err := store.DeleteAttachment(containerName, "NONEXISTENT") | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | ``` | |
| 54 | + | ||
| 55 | + | **Step 2: Run test to verify it fails** | |
| 56 | + | ||
| 57 | + | Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v` | |
| 58 | + | Expected: FAIL — `store.DeleteAttachment` undefined | |
| 59 | + | ||
| 60 | + | **Step 3: Add to interface and implement** | |
| 61 | + | ||
| 62 | + | In `internal/boundaries/out/domain_secrets.go`, add to the `DomainSecretStore` interface: | |
| 63 | + | ||
| 64 | + | ```go | |
| 65 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 66 | + | DeleteAttachment(containerName, key string) error | |
| 67 | + | ``` | |
| 68 | + | ||
| 69 | + | In `internal/adapters/out/domainsecrets/store.go`, add after the `SetAttachment` method (~line 177): | |
| 70 | + | ||
| 71 | + | ```go | |
| 72 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 73 | + | func (s *FileStore) DeleteAttachment(containerName, key string) error { | |
| 74 | + | // Validate container name first | |
| 75 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |
| 76 | + | return err | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Read existing secrets | |
| 80 | + | existing, err := s.GetAllAttachment(containerName) | |
| 81 | + | if err != nil { | |
| 82 | + | return err | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Remove the key | |
| 86 | + | delete(existing, key) | |
| 87 | + | ||
| 88 | + | // Write back atomically | |
| 89 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |
| 90 | + | } | |
| 91 | + | ``` | |
| 92 | + | ||
| 93 | + | **Step 4: Run test to verify it passes** | |
| 94 | + | ||
| 95 | + | Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v` | |
| 96 | + | Expected: PASS | |
| 97 | + | ||
| 98 | + | **Step 5: Regenerate mocks** | |
| 99 | + | ||
| 100 | + | Run: `mockery` | |
| 101 | + | Expected: Regenerates `internal/boundaries/out/mocks/mock_domain_secret_store.go` with new `DeleteAttachment` method. | |
| 102 | + | ||
| 103 | + | **Step 6: Run go fmt and full test suite** | |
| 104 | + | ||
| 105 | + | Run: `go fmt ./... && go test ./... -count=1` | |
| 106 | + | Expected: ALL PASS | |
| 107 | + | ||
| 108 | + | **Step 7: Commit** | |
| 109 | + | ||
| 110 | + | ```bash | |
| 111 | + | git add internal/boundaries/out/domain_secrets.go internal/adapters/out/domainsecrets/store.go internal/adapters/out/domainsecrets/store_test.go internal/boundaries/out/mocks/mock_domain_secret_store.go | |
| 112 | + | git commit -m "feat(secrets): add DeleteAttachment to storage layer | |
| 113 | + | ||
| 114 | + | Add ability to remove individual keys from attachment env files, | |
| 115 | + | matching the existing SetAttachment capability." | |
| 116 | + | ``` | |
| 117 | + | ||
| 118 | + | --- | |
| 119 | + | ||
| 120 | + | ### Task 2: Add attachment methods to SecretService interface and use case | |
| 121 | + | ||
| 122 | + | The use case layer needs to resolve `(domain, service)` → container name and call through to the store. | |
| 123 | + | ||
| 124 | + | **Files:** | |
| 125 | + | - Modify: `internal/boundaries/in/secrets.go:11-31` | |
| 126 | + | - Modify: `internal/usecase/secrets/service.go` | |
| 127 | + | - Test: `internal/usecase/secrets/service_test.go` (create if needed) | |
| 128 | + | ||
| 129 | + | **Step 1: Write the failing test** | |
| 130 | + | ||
| 131 | + | Create or add to `internal/usecase/secrets/service_test.go`: | |
| 132 | + | ||
| 133 | + | ```go | |
| 134 | + | package secrets | |
| 135 | + | ||
| 136 | + | import ( | |
| 137 | + | "context" | |
| 138 | + | "testing" | |
| 139 | + | ||
| 140 | + | "github.com/bnema/zerowrap" | |
| 141 | + | "github.com/stretchr/testify/assert" | |
| 142 | + | "github.com/stretchr/testify/mock" | |
| 143 | + | ||
| 144 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 145 | + | ) | |
| 146 | + | ||
| 147 | + | func testLogger() zerowrap.Logger { | |
| 148 | + | return zerowrap.Default() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestService_SetAttachment(t *testing.T) { | |
| 152 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 153 | + | svc := NewService(store, testLogger()) | |
| 154 | + | ||
| 155 | + | secrets := map[string]string{"POSTGRES_PASSWORD": "secret"} | |
| 156 | + | ||
| 157 | + | // domain "app.example.com" + service "postgres" → container name "app__example__com-postgres" | |
| 158 | + | store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil) | |
| 159 | + | ||
| 160 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets) | |
| 161 | + | assert.NoError(t, err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestService_DeleteAttachment(t *testing.T) { | |
| 165 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 166 | + | svc := NewService(store, testLogger()) | |
| 167 | + | ||
| 168 | + | store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil) | |
| 169 | + | ||
| 170 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY") | |
| 171 | + | assert.NoError(t, err) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestService_SetAttachment_InvalidDomain(t *testing.T) { | |
| 175 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 176 | + | svc := NewService(store, testLogger()) | |
| 177 | + | ||
| 178 | + | err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"K": "V"}) | |
| 179 | + | assert.Error(t, err) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestService_SetAttachment_EmptyService(t *testing.T) { | |
| 183 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 184 | + | svc := NewService(store, testLogger()) | |
| 185 | + | ||
| 186 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"K": "V"}) | |
| 187 | + | assert.ErrorIs(t, err, ErrServiceEmpty) | |
| 188 | + | } | |
| 189 | + | ``` | |
| 190 | + | ||
| 191 | + | **Step 2: Run test to verify it fails** | |
| 192 | + | ||
| 193 | + | Run: `go test ./internal/usecase/secrets/... -count=1 -run "TestService_SetAttachment|TestService_DeleteAttachment" -v` | |
| 194 | + | Expected: FAIL — `svc.SetAttachment` undefined | |
| 195 | + | ||
| 196 | + | **Step 3: Add to interface and implement** | |
| 197 | + | ||
| 198 | + | In `internal/boundaries/in/secrets.go`, add to the `SecretService` interface: | |
| 199 | + | ||
| 200 | + | ```go | |
| 201 | + | // SetAttachment sets or updates secrets for an attachment container. | |
| 202 | + | // Resolves the container name from domain + service name. | |
| 203 | + | SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error | |
| 204 | + | ||
| 205 | + | // DeleteAttachment removes a secret key from an attachment container. | |
| 206 | + | // Resolves the container name from domain + service name. | |
| 207 | + | DeleteAttachment(ctx context.Context, domain, service, key string) error | |
| 208 | + | ``` | |
| 209 | + | ||
| 210 | + | In `internal/usecase/secrets/service.go`, add the error variable and methods: | |
| 211 | + | ||
| 212 | + | ```go | |
| 213 | + | var ErrServiceEmpty = errors.New("service name cannot be empty") | |
| 214 | + | ``` | |
| 215 | + | ||
| 216 | + | Add the import for the `domain` package: | |
| 217 | + | ||
| 218 | + | ```go | |
| 219 | + | import ( | |
| 220 | + | // ... existing imports ... | |
| 221 | + | "github.com/bnema/gordon/internal/domain" | |
| 222 | + | ) | |
| 223 | + | ``` | |
| 224 | + | ||
| 225 | + | Add the methods: | |
| 226 | + | ||
| 227 | + | ```go | |
| 228 | + | // SetAttachment sets or updates secrets for an attachment container. | |
| 229 | + | func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error { | |
| 230 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 231 | + | zerowrap.FieldLayer: "usecase", | |
| 232 | + | zerowrap.FieldUseCase: "SetAttachment", | |
| 233 | + | "domain": domainName, | |
| 234 | + | "service": service, | |
| 235 | + | }) | |
| 236 | + | log := zerowrap.FromCtx(ctx) | |
| 237 | + | ||
| 238 | + | if err := ValidateDomain(domainName); err != nil { | |
| 239 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 240 | + | return err | |
| 241 | + | } | |
| 242 | + | if service == "" { | |
| 243 | + | return ErrServiceEmpty | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | containerName := resolveContainerName(domainName, service) | |
| 247 | + | if err := s.store.SetAttachment(containerName, secrets); err != nil { | |
| 248 | + | log.Error().Err(err).Str("container", containerName).Msg("failed to set attachment secrets") | |
| 249 | + | return err | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | log.Info().Str("container", containerName).Int("count", len(secrets)).Msg("attachment secrets set") | |
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // DeleteAttachment removes a secret key from an attachment container. | |
| 257 | + | func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error { | |
| 258 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 259 | + | zerowrap.FieldLayer: "usecase", | |
| 260 | + | zerowrap.FieldUseCase: "DeleteAttachment", | |
| 261 | + | "domain": domainName, | |
| 262 | + | "service": service, | |
| 263 | + | "key": key, | |
| 264 | + | }) | |
| 265 | + | log := zerowrap.FromCtx(ctx) | |
| 266 | + | ||
| 267 | + | if err := ValidateDomain(domainName); err != nil { | |
| 268 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 269 | + | return err | |
| 270 | + | } | |
| 271 | + | if service == "" { | |
| 272 | + | return ErrServiceEmpty | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | containerName := resolveContainerName(domainName, service) | |
| 276 | + | if err := s.store.DeleteAttachment(containerName, key); err != nil { | |
| 277 | + | log.Error().Err(err).Str("container", containerName).Msg("failed to delete attachment secret") | |
| 278 | + | return err | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | log.Info().Str("container", containerName).Msg("attachment secret deleted") | |
| 282 | + | return nil | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // resolveContainerName builds the attachment container name from domain and service. | |
| 286 | + | // Must match the naming convention in container service's deployAttachedService(). | |
| 287 | + | func resolveContainerName(domainName, service string) string { | |
| 288 | + | return domain.SanitizeDomainForContainer(domainName) + "-" + service | |
| 289 | + | } | |
| 290 | + | ``` | |
| 291 | + | ||
| 292 | + | **Step 4: Run test to verify it passes** | |
| 293 | + | ||
| 294 | + | Run: `go test ./internal/usecase/secrets/... -count=1 -v` | |
| 295 | + | Expected: ALL PASS | |
| 296 | + | ||
| 297 | + | **Step 5: Regenerate mocks** | |
| 298 | + | ||
| 299 | + | Run: `mockery` | |
| 300 | + | Expected: Regenerates `internal/boundaries/in/mocks/mock_secret_service.go` with new methods. | |
| 301 | + | ||
| 302 | + | **Step 6: Run go fmt and full test suite** | |
| 303 | + | ||
| 304 | + | Run: `go fmt ./... && go test ./... -count=1` | |
| 305 | + | Expected: ALL PASS | |
| 306 | + | ||
| 307 | + | **Step 7: Commit** | |
| 308 | + | ||
| 309 | + | ```bash | |
| 310 | + | git add internal/boundaries/in/secrets.go internal/usecase/secrets/service.go internal/usecase/secrets/service_test.go internal/boundaries/in/mocks/mock_secret_service.go | |
| 311 | + | git commit -m "feat(secrets): add SetAttachment/DeleteAttachment to use case layer | |
| 312 | + | ||
| 313 | + | Resolves (domain, service) to container name and delegates to | |
| 314 | + | the storage layer. Matches container naming from deployAttachedService()." | |
| 315 | + | ``` | |
| 316 | + | ||
| 317 | + | --- | |
| 318 | + | ||
| 319 | + | ### Task 3: Add admin API endpoint for attachment secrets | |
| 320 | + | ||
| 321 | + | Add `/admin/secrets/{domain}/attachments/{service}` routes for POST (set) and DELETE (with key). | |
| 322 | + | ||
| 323 | + | **Files:** | |
| 324 | + | - Modify: `internal/adapters/in/http/admin/handler.go:446-518` | |
| 325 | + | - Test: `internal/adapters/in/http/admin/handler_test.go` | |
| 326 | + | ||
| 327 | + | **Step 1: Write the failing tests** | |
| 328 | + | ||
| 329 | + | Add to `handler_test.go`: | |
| 330 | + | ||
| 331 | + | ```go | |
| 332 | + | func TestHandler_AttachmentSecretsPost(t *testing.T) { | |
| 333 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 334 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 335 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 336 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 337 | + | ||
| 338 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 339 | + | ||
| 340 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil) | |
| 341 | + | ||
| 342 | + | body := `{"POSTGRES_PASSWORD": "secret"}` | |
| 343 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/postgres", bytes.NewBufferString(body)) | |
| 344 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| 345 | + | rec := httptest.NewRecorder() | |
| 346 | + | ||
| 347 | + | handler.ServeHTTP(rec, req) | |
| 348 | + | ||
| 349 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |
| 353 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 354 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 355 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 356 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 357 | + | ||
| 358 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 359 | + | ||
| 360 | + | secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "postgres", "OLD_KEY").Return(nil) | |
| 361 | + | ||
| 362 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/postgres/OLD_KEY", nil) | |
| 363 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| 364 | + | rec := httptest.NewRecorder() | |
| 365 | + | ||
| 366 | + | handler.ServeHTTP(rec, req) | |
| 367 | + | ||
| 368 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 369 | + | } | |
| 370 | + | ``` | |
| 371 | + | ||
| 372 | + | **Step 2: Run tests to verify they fail** | |
| 373 | + | ||
| 374 | + | Run: `go test ./internal/adapters/in/http/admin/... -count=1 -run "TestHandler_AttachmentSecrets" -v` | |
| 375 | + | Expected: FAIL — routes not recognized, 400 or 404 | |
| 376 | + | ||
| 377 | + | **Step 3: Implement the endpoint** | |
| 378 | + | ||
| 379 | + | In `handler.go`, modify the `handleSecrets` function. Replace the path parsing section (lines 451-462) to detect the `/attachments/` sub-path: | |
| 380 | + | ||
| 381 | + | ```go | |
| 382 | + | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | |
| 383 | + | ctx := r.Context() | |
| 384 | + | log := zerowrap.FromCtx(ctx) | |
| 385 | + | ||
| 386 | + | // Parse path: /secrets/{domain}, /secrets/{domain}/{key}, | |
| 387 | + | // or /secrets/{domain}/attachments/{service}[/{key}] | |
| 388 | + | trimmed := strings.TrimPrefix(path, "/secrets/") | |
| 389 | + | parts := strings.Split(trimmed, "/") | |
| 390 | + | if len(parts) == 0 || parts[0] == "" { | |
| 391 | + | h.sendError(w, http.StatusBadRequest, "domain required") | |
| 392 | + | return | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | secretDomain := parts[0] | |
| 396 | + | ||
| 397 | + | // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}] | |
| 398 | + | if len(parts) >= 3 && parts[1] == "attachments" { | |
| 399 | + | service := parts[2] | |
| 400 | + | attachmentKey := "" | |
| 401 | + | if len(parts) > 3 { | |
| 402 | + | attachmentKey = parts[3] | |
| 403 | + | } | |
| 404 | + | h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey) | |
| 405 | + | return | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | secretKey := "" | |
| 409 | + | if len(parts) > 1 { | |
| 410 | + | secretKey = parts[1] | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | switch r.Method { | |
| 414 | + | case http.MethodGet: | |
| 415 | + | h.handleSecretsGet(w, r, secretDomain) | |
| 416 | + | ||
| 417 | + | case http.MethodPost: | |
| 418 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 419 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 420 | + | return | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 424 | + | ||
| 425 | + | var data map[string]string | |
| 426 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |
| 427 | + | log.Warn().Err(err).Msg("invalid secrets JSON") | |
| 428 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 429 | + | return | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |
| 433 | + | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |
| 434 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 435 | + | return | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | |
| 439 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |
| 440 | + | ||
| 441 | + | case http.MethodDelete: | |
| 442 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 443 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 444 | + | return | |
| 445 | + | } | |
| 446 | + | if secretKey == "" { | |
| 447 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |
| 448 | + | return | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |
| 452 | + | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |
| 453 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 454 | + | return | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |
| 458 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |
| 459 | + | ||
| 460 | + | default: | |
| 461 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | ``` | |
| 465 | + | ||
| 466 | + | Add the new handler method: | |
| 467 | + | ||
| 468 | + | ```go | |
| 469 | + | // handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}]. | |
| 470 | + | func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) { | |
| 471 | + | ctx := r.Context() | |
| 472 | + | log := zerowrap.FromCtx(ctx) | |
| 473 | + | ||
| 474 | + | switch r.Method { | |
| 475 | + | case http.MethodPost: | |
| 476 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 477 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 478 | + | return | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 482 | + | ||
| 483 | + | var data map[string]string | |
| 484 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |
| 485 | + | log.Warn().Err(err).Msg("invalid attachment secrets JSON") | |
| 486 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 487 | + | return | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil { | |
| 491 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets") | |
| 492 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |
| 493 | + | return | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set") | |
| 497 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |
| 498 | + | ||
| 499 | + | case http.MethodDelete: | |
| 500 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 501 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 502 | + | return | |
| 503 | + | } | |
| 504 | + | if key == "" { | |
| 505 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |
| 506 | + | return | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil { | |
| 510 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret") | |
| 511 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |
| 512 | + | return | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted") | |
| 516 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |
| 517 | + | ||
| 518 | + | default: | |
| 519 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | ``` | |
| 523 | + | ||
| 524 | + | **Step 4: Run tests to verify they pass** | |
| 525 | + | ||
| 526 | + | Run: `go test ./internal/adapters/in/http/admin/... -count=1 -v` | |
| 527 | + | Expected: ALL PASS (both new and existing tests) | |
| 528 | + | ||
| 529 | + | **Step 5: Run go fmt** | |
| 530 | + | ||
| 531 | + | Run: `go fmt ./internal/adapters/in/http/admin/...` | |
| 532 | + | ||
| 533 | + | **Step 6: Commit** | |
| 534 | + | ||
| 535 | + | ```bash | |
| 536 | + | git add internal/adapters/in/http/admin/handler.go internal/adapters/in/http/admin/handler_test.go | |
| 537 | + | git commit -m "feat(api): add attachment secrets endpoints | |
| 538 | + | ||
| 539 | + | POST /admin/secrets/{domain}/attachments/{service} to set secrets. | |
| 540 | + | DELETE /admin/secrets/{domain}/attachments/{service}/{key} to remove." | |
| 541 | + | ``` | |
| 542 | + | ||
| 543 | + | --- | |
| 544 | + | ||
| 545 | + | ### Task 4: Add remote client methods | |
| 546 | + | ||
| 547 | + | **Files:** | |
| 548 | + | - Modify: `internal/adapters/in/cli/remote/client.go` (after `DeleteSecret` ~line 296) | |
| 549 | + | ||
| 550 | + | **Step 1: Implement the client methods** | |
| 551 | + | ||
| 552 | + | No separate test file exists for the remote client (it's tested via integration). Add the methods: | |
| 553 | + | ||
| 554 | + | ```go | |
| 555 | + | // SetAttachmentSecrets sets secrets for an attachment container. | |
| 556 | + | func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error { | |
| 557 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) | |
| 558 | + | resp, err := c.request(ctx, http.MethodPost, path, secrets) | |
| 559 | + | if err != nil { | |
| 560 | + | return err | |
| 561 | + | } | |
| 562 | + | return parseResponse(resp, nil) | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // DeleteAttachmentSecret removes a secret from an attachment container. | |
| 566 | + | func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error { | |
| 567 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key) | |
| 568 | + | resp, err := c.request(ctx, http.MethodDelete, path, nil) | |
| 569 | + | if err != nil { | |
| 570 | + | return err | |
| 571 | + | } | |
| 572 | + | return parseResponse(resp, nil) | |
| 573 | + | } | |
| 574 | + | ``` | |
| 575 | + | ||
| 576 | + | **Step 2: Run go fmt and verify build** | |
| 577 | + | ||
| 578 | + | Run: `go fmt ./internal/adapters/in/cli/remote/... && go build ./...` | |
| 579 | + | Expected: Clean build | |
| 580 | + | ||
| 581 | + | **Step 3: Commit** | |
| 582 | + | ||
| 583 | + | ```bash | |
| 584 | + | git add internal/adapters/in/cli/remote/client.go | |
| 585 | + | git commit -m "feat(client): add attachment secrets API methods" | |
| 586 | + | ``` | |
| 587 | + | ||
| 588 | + | --- | |
| 589 | + | ||
| 590 | + | ### Task 5: Add `--attachment` flag to `secrets set` and `secrets remove` CLI commands | |
| 591 | + | ||
| 592 | + | **Files:** | |
| 593 | + | - Modify: `internal/adapters/in/cli/secrets.go:214-270` (set command) | |
| 594 | + | - Modify: `internal/adapters/in/cli/secrets.go:272-327` (remove command) | |
| 595 | + | ||
| 596 | + | **Step 1: Write the failing test for flag parsing** | |
| 597 | + | ||
| 598 | + | Add to `internal/adapters/in/cli/secrets_test.go` (create file): | |
| 599 | + | ||
| 600 | + | ```go | |
| 601 | + | package cli | |
| 602 | + | ||
| 603 | + | import ( | |
| 604 | + | "testing" | |
| 605 | + | ||
| 606 | + | "github.com/stretchr/testify/assert" | |
| 607 | + | ) | |
| 608 | + | ||
| 609 | + | func TestResolveContainerName(t *testing.T) { | |
| 610 | + | tests := []struct { | |
| 611 | + | domain string | |
| 612 | + | service string | |
| 613 | + | want string | |
| 614 | + | }{ | |
| 615 | + | {"app.example.com", "postgres", "app__example__com-postgres"}, | |
| 616 | + | {"git.example.com", "redis", "git__example__com-redis"}, | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | for _, tt := range tests { | |
| 620 | + | t.Run(tt.domain+"/"+tt.service, func(t *testing.T) { | |
| 621 | + | got := resolveAttachmentContainerName(tt.domain, tt.service) | |
| 622 | + | assert.Equal(t, tt.want, got) | |
| 623 | + | }) | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | ``` | |
| 627 | + | ||
| 628 | + | **Step 2: Run test to verify it fails** | |
| 629 | + | ||
| 630 | + | Run: `go test ./internal/adapters/in/cli/... -count=1 -run TestResolveContainerName -v` | |
| 631 | + | Expected: FAIL — `resolveAttachmentContainerName` undefined | |
| 632 | + | ||
| 633 | + | **Step 3: Implement** | |
| 634 | + | ||
| 635 | + | In `secrets.go`, add the helper: | |
| 636 | + | ||
| 637 | + | ```go | |
| 638 | + | // resolveAttachmentContainerName builds the container name from domain and service. | |
| 639 | + | // Must match the naming in usecase/container deployAttachedService() and usecase/secrets resolveContainerName(). | |
| 640 | + | func resolveAttachmentContainerName(domainName, service string) string { | |
| 641 | + | return domain.SanitizeDomainForContainer(domainName) + "-" + service | |
| 642 | + | } | |
| 643 | + | ``` | |
| 644 | + | ||
| 645 | + | Add the import for the domain package at the top of `secrets.go`: | |
| 646 | + | ||
| 647 | + | ```go | |
| 648 | + | import ( | |
| 649 | + | // ... existing imports ... | |
| 650 | + | "github.com/bnema/gordon/internal/domain" | |
| 651 | + | ) | |
| 652 | + | ``` | |
| 653 | + | ||
| 654 | + | Now modify `newSecretsSetCmd()` to add the `--attachment` flag. Replace the function: | |
| 655 | + | ||
| 656 | + | ```go | |
| 657 | + | func newSecretsSetCmd() *cobra.Command { | |
| 658 | + | var attachment string | |
| 659 | + | ||
| 660 | + | cmd := &cobra.Command{ | |
| 661 | + | Use: "set <domain> <KEY=value>...", | |
| 662 | + | Short: "Set secrets for a domain or attachment", | |
| 663 | + | Long: `Set one or more secrets for a domain or an attachment container. | |
| 664 | + | ||
| 665 | + | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | |
| 666 | + | ||
| 667 | + | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |
| 668 | + | of the main domain container. | |
| 669 | + | ||
| 670 | + | Examples: | |
| 671 | + | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | |
| 672 | + | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | |
| 673 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 674 | + | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | |
| 675 | + | Args: cobra.MinimumNArgs(2), | |
| 676 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 677 | + | ctx := context.Background() | |
| 678 | + | secretDomain := args[0] | |
| 679 | + | pairs := args[1:] | |
| 680 | + | ||
| 681 | + | // Parse KEY=value pairs | |
| 682 | + | secrets := make(map[string]string) | |
| 683 | + | for _, pair := range pairs { | |
| 684 | + | parts := strings.SplitN(pair, "=", 2) | |
| 685 | + | if len(parts) != 2 { | |
| 686 | + | fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair))) | |
| 687 | + | return nil | |
| 688 | + | } | |
| 689 | + | secrets[parts[0]] = parts[1] | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | client, isRemote := GetRemoteClient() | |
| 693 | + | if isRemote { | |
| 694 | + | if attachment != "" { | |
| 695 | + | if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { | |
| 696 | + | return fmt.Errorf("failed to set attachment secrets: %w", err) | |
| 697 | + | } | |
| 698 | + | } else { | |
| 699 | + | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 700 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 701 | + | } | |
| 702 | + | } | |
| 703 | + | } else { | |
| 704 | + | local, err := GetLocalServices(configPath) | |
| 705 | + | if err != nil { | |
| 706 | + | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 707 | + | } | |
| 708 | + | if attachment != "" { | |
| 709 | + | if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil { | |
| 710 | + | return fmt.Errorf("failed to set attachment secrets: %w", err) | |
| 711 | + | } | |
| 712 | + | } else { | |
| 713 | + | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | |
| 714 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 715 | + | } | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | target := secretDomain | |
| 720 | + | if attachment != "" { | |
| 721 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | if len(secrets) == 1 { | |
| 725 | + | for key := range secrets { | |
| 726 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |
| 727 | + | } | |
| 728 | + | } else { | |
| 729 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | return nil | |
| 733 | + | }, | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 737 | + | ||
| 738 | + | return cmd | |
| 739 | + | } | |
| 740 | + | ``` | |
| 741 | + | ||
| 742 | + | Now modify `newSecretsRemoveCmd()` similarly: | |
| 743 | + | ||
| 744 | + | ```go | |
| 745 | + | func newSecretsRemoveCmd() *cobra.Command { | |
| 746 | + | var ( | |
| 747 | + | force bool | |
| 748 | + | attachment string | |
| 749 | + | ) | |
| 750 | + | ||
| 751 | + | cmd := &cobra.Command{ | |
| 752 | + | Use: "remove <domain> <key>", | |
| 753 | + | Short: "Remove a secret", | |
| 754 | + | Long: `Remove a secret from a domain or attachment container. | |
| 755 | + | ||
| 756 | + | Use --attachment to target an attachment service instead of the main domain. | |
| 757 | + | ||
| 758 | + | Examples: | |
| 759 | + | gordon secrets remove app.mydomain.com OLD_API_KEY | |
| 760 | + | gordon secrets remove app.mydomain.com OLD_API_KEY --force | |
| 761 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | |
| 762 | + | Args: cobra.ExactArgs(2), | |
| 763 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 764 | + | ctx := context.Background() | |
| 765 | + | secretDomain := args[0] | |
| 766 | + | key := args[1] | |
| 767 | + | ||
| 768 | + | target := secretDomain | |
| 769 | + | if attachment != "" { | |
| 770 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 771 | + | } | |
| 772 | + | ||
| 773 | + | // Confirm unless --force | |
| 774 | + | if !force { | |
| 775 | + | confirmed, err := components.RunConfirm( | |
| 776 | + | fmt.Sprintf("Remove secret '%s' from %s?", key, target), | |
| 777 | + | ) | |
| 778 | + | if err != nil { | |
| 779 | + | return err | |
| 780 | + | } | |
| 781 | + | if !confirmed { | |
| 782 | + | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |
| 783 | + | return nil | |
| 784 | + | } | |
| 785 | + | } | |
| 786 | + | ||
| 787 | + | client, isRemote := GetRemoteClient() | |
| 788 | + | if isRemote { | |
| 789 | + | if attachment != "" { | |
| 790 | + | if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { | |
| 791 | + | return fmt.Errorf("failed to remove attachment secret: %w", err) | |
| 792 | + | } | |
| 793 | + | } else { | |
| 794 | + | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 795 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 796 | + | } | |
| 797 | + | } | |
| 798 | + | } else { | |
| 799 | + | local, err := GetLocalServices(configPath) | |
| 800 | + | if err != nil { | |
| 801 | + | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 802 | + | } | |
| 803 | + | if attachment != "" { | |
| 804 | + | if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil { | |
| 805 | + | return fmt.Errorf("failed to remove attachment secret: %w", err) | |
| 806 | + | } | |
| 807 | + | } else { | |
| 808 | + | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | |
| 809 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 810 | + | } | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s from %s", key, target))) | |
| 815 | + | return nil | |
| 816 | + | }, | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |
| 820 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 821 | + | ||
| 822 | + | return cmd | |
| 823 | + | } | |
| 824 | + | ``` | |
| 825 | + | ||
| 826 | + | **Step 4: Run tests to verify they pass** | |
| 827 | + | ||
| 828 | + | Run: `go test ./internal/adapters/in/cli/... -count=1 -v` | |
| 829 | + | Expected: ALL PASS | |
| 830 | + | ||
| 831 | + | **Step 5: Run go fmt and full suite** | |
| 832 | + | ||
| 833 | + | Run: `go fmt ./... && go test ./... -count=1` | |
| 834 | + | Expected: ALL PASS | |
| 835 | + | ||
| 836 | + | **Step 6: Commit** | |
| 837 | + | ||
| 838 | + | ```bash | |
| 839 | + | git add internal/adapters/in/cli/secrets.go internal/adapters/in/cli/secrets_test.go | |
| 840 | + | git commit -m "feat(cli): add --attachment flag to secrets set/remove | |
| 841 | + | ||
| 842 | + | Closes #56. Users can now configure attachment secrets via: | |
| 843 | + | gordon secrets set <domain> --attachment <service> KEY=value | |
| 844 | + | gordon secrets remove <domain> --attachment <service> KEY" | |
| 845 | + | ``` | |
| 846 | + | ||
| 847 | + | --- | |
| 848 | + | ||
| 849 | + | ### Task 6: Update secrets command description and verify end-to-end | |
| 850 | + | ||
| 851 | + | **Files:** | |
| 852 | + | - Modify: `internal/adapters/in/cli/secrets.go:16-33` (parent command description) | |
| 853 | + | ||
| 854 | + | **Step 1: Update parent command long description** | |
| 855 | + | ||
| 856 | + | ```go | |
| 857 | + | func newSecretsCmd() *cobra.Command { | |
| 858 | + | cmd := &cobra.Command{ | |
| 859 | + | Use: "secrets", | |
| 860 | + | Short: "Manage secrets", | |
| 861 | + | Long: `Manage secrets (environment variables) for routes and attachments. | |
| 862 | + | ||
| 863 | + | Secrets are stored per-domain and injected into containers as environment variables. | |
| 864 | + | Use --attachment to target attachment containers (databases, caches, etc.). | |
| 865 | + | ||
| 866 | + | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |
| 867 | + | these commands operate on the remote server.`, | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | cmd.AddCommand(newSecretsListCmd()) | |
| 871 | + | cmd.AddCommand(newSecretsSetCmd()) | |
| 872 | + | cmd.AddCommand(newSecretsRemoveCmd()) | |
| 873 | + | ||
| 874 | + | return cmd | |
| 875 | + | } | |
| 876 | + | ``` | |
| 877 | + | ||
| 878 | + | **Step 2: Run full test suite** | |
| 879 | + | ||
| 880 | + | Run: `go test ./... -count=1` | |
| 881 | + | Expected: ALL PASS | |
| 882 | + | ||
| 883 | + | **Step 3: Run go vet and build** | |
| 884 | + | ||
| 885 | + | Run: `go vet ./... && go build ./...` | |
| 886 | + | Expected: Clean | |
| 887 | + | ||
| 888 | + | **Step 4: Commit** | |
| 889 | + | ||
| 890 | + | ```bash | |
| 891 | + | git add internal/adapters/in/cli/secrets.go | |
| 892 | + | git commit -m "docs(cli): update secrets command description for attachments" | |
| 893 | + | ``` | |
| @@ -0,0 +1,893 @@ | |||||
| 1 | + | # Attachment Secrets CLI Support — Implementation Plan | |||
| 2 | + | ||||
| 3 | + | > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. | |||
| 4 | + | ||||
| 5 | + | **Goal:** Allow `gordon secrets set` and `gordon secrets remove` to target attachment containers via an `--attachment` flag, closing issue #56. | |||
| 6 | + | ||||
| 7 | + | **Architecture:** The storage layer (`DomainSecretStore.SetAttachment`) and file format (`gordon-{container}.env`) already exist. The gap is a missing `DeleteAttachment` in the store, missing attachment methods on the `SecretService` use case, a missing admin API endpoint for attachment secrets, and missing `--attachment` flag on CLI commands. We thread through all layers: storage → use case → admin API → remote client → CLI. | |||
| 8 | + | ||||
| 9 | + | **Tech Stack:** Go, cobra (CLI), mockery (mock generation), httptest | |||
| 10 | + | ||||
| 11 | + | --- | |||
| 12 | + | ||||
| 13 | + | ### Task 1: Add `DeleteAttachment` to storage interface and implementation | |||
| 14 | + | ||||
| 15 | + | The `DomainSecretStore` has `SetAttachment` but no `DeleteAttachment`. We need it to remove individual keys from attachment env files. | |||
| 16 | + | ||||
| 17 | + | **Files:** | |||
| 18 | + | - Modify: `internal/boundaries/out/domain_secrets.go:13-36` | |||
| 19 | + | - Modify: `internal/adapters/out/domainsecrets/store.go` (add method after `SetAttachment`) | |||
| 20 | + | - Test: `internal/adapters/out/domainsecrets/store_test.go` | |||
| 21 | + | ||||
| 22 | + | **Step 1: Write the failing test** | |||
| 23 | + | ||||
| 24 | + | Add to `store_test.go`, near the existing `SetAttachment` tests (~line 229): | |||
| 25 | + | ||||
| 26 | + | ```go | |||
| 27 | + | func TestFileStore_DeleteAttachment(t *testing.T) { | |||
| 28 | + | dir := t.TempDir() | |||
| 29 | + | store := NewFileStore(dir, testLogger()) | |||
| 30 | + | containerName := "app__example__com-postgres" | |||
| 31 | + | ||||
| 32 | + | // Seed with two secrets | |||
| 33 | + | err := store.SetAttachment(containerName, map[string]string{ | |||
| 34 | + | "POSTGRES_USER": "admin", | |||
| 35 | + | "POSTGRES_PASSWORD": "secret", | |||
| 36 | + | }) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | ||||
| 39 | + | t.Run("deletes single key", func(t *testing.T) { | |||
| 40 | + | err := store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | ||||
| 43 | + | secrets, err := store.GetAllAttachment(containerName) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | assert.Equal(t, map[string]string{"POSTGRES_USER": "admin"}, secrets) | |||
| 46 | + | }) | |||
| 47 | + | ||||
| 48 | + | t.Run("deleting nonexistent key is no-op", func(t *testing.T) { | |||
| 49 | + | err := store.DeleteAttachment(containerName, "NONEXISTENT") | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | ``` | |||
| 54 | + | ||||
| 55 | + | **Step 2: Run test to verify it fails** | |||
| 56 | + | ||||
| 57 | + | Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v` | |||
| 58 | + | Expected: FAIL — `store.DeleteAttachment` undefined | |||
| 59 | + | ||||
| 60 | + | **Step 3: Add to interface and implement** | |||
| 61 | + | ||||
| 62 | + | In `internal/boundaries/out/domain_secrets.go`, add to the `DomainSecretStore` interface: | |||
| 63 | + | ||||
| 64 | + | ```go | |||
| 65 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 66 | + | DeleteAttachment(containerName, key string) error | |||
| 67 | + | ``` | |||
| 68 | + | ||||
| 69 | + | In `internal/adapters/out/domainsecrets/store.go`, add after the `SetAttachment` method (~line 177): | |||
| 70 | + | ||||
| 71 | + | ```go | |||
| 72 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 73 | + | func (s *FileStore) DeleteAttachment(containerName, key string) error { | |||
| 74 | + | // Validate container name first | |||
| 75 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |||
| 76 | + | return err | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Read existing secrets | |||
| 80 | + | existing, err := s.GetAllAttachment(containerName) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return err | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Remove the key | |||
| 86 | + | delete(existing, key) | |||
| 87 | + | ||||
| 88 | + | // Write back atomically | |||
| 89 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |||
| 90 | + | } | |||
| 91 | + | ``` | |||
| 92 | + | ||||
| 93 | + | **Step 4: Run test to verify it passes** | |||
| 94 | + | ||||
| 95 | + | Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v` | |||
| 96 | + | Expected: PASS | |||
| 97 | + | ||||
| 98 | + | **Step 5: Regenerate mocks** | |||
| 99 | + | ||||
| 100 | + | Run: `mockery` | |||
| 101 | + | Expected: Regenerates `internal/boundaries/out/mocks/mock_domain_secret_store.go` with new `DeleteAttachment` method. | |||
| 102 | + | ||||
| 103 | + | **Step 6: Run go fmt and full test suite** | |||
| 104 | + | ||||
| 105 | + | Run: `go fmt ./... && go test ./... -count=1` | |||
| 106 | + | Expected: ALL PASS | |||
| 107 | + | ||||
| 108 | + | **Step 7: Commit** | |||
| 109 | + | ||||
| 110 | + | ```bash | |||
| 111 | + | git add internal/boundaries/out/domain_secrets.go internal/adapters/out/domainsecrets/store.go internal/adapters/out/domainsecrets/store_test.go internal/boundaries/out/mocks/mock_domain_secret_store.go | |||
| 112 | + | git commit -m "feat(secrets): add DeleteAttachment to storage layer | |||
| 113 | + | ||||
| 114 | + | Add ability to remove individual keys from attachment env files, | |||
| 115 | + | matching the existing SetAttachment capability." | |||
| 116 | + | ``` | |||
| 117 | + | ||||
| 118 | + | --- | |||
| 119 | + | ||||
| 120 | + | ### Task 2: Add attachment methods to SecretService interface and use case | |||
| 121 | + | ||||
| 122 | + | The use case layer needs to resolve `(domain, service)` → container name and call through to the store. | |||
| 123 | + | ||||
| 124 | + | **Files:** | |||
| 125 | + | - Modify: `internal/boundaries/in/secrets.go:11-31` | |||
| 126 | + | - Modify: `internal/usecase/secrets/service.go` | |||
| 127 | + | - Test: `internal/usecase/secrets/service_test.go` (create if needed) | |||
| 128 | + | ||||
| 129 | + | **Step 1: Write the failing test** | |||
| 130 | + | ||||
| 131 | + | Create or add to `internal/usecase/secrets/service_test.go`: | |||
| 132 | + | ||||
| 133 | + | ```go | |||
| 134 | + | package secrets | |||
| 135 | + | ||||
| 136 | + | import ( | |||
| 137 | + | "context" | |||
| 138 | + | "testing" | |||
| 139 | + | ||||
| 140 | + | "github.com/bnema/zerowrap" | |||
| 141 | + | "github.com/stretchr/testify/assert" | |||
| 142 | + | "github.com/stretchr/testify/mock" | |||
| 143 | + | ||||
| 144 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 145 | + | ) | |||
| 146 | + | ||||
| 147 | + | func testLogger() zerowrap.Logger { | |||
| 148 | + | return zerowrap.Default() | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestService_SetAttachment(t *testing.T) { | |||
| 152 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 153 | + | svc := NewService(store, testLogger()) | |||
| 154 | + | ||||
| 155 | + | secrets := map[string]string{"POSTGRES_PASSWORD": "secret"} | |||
| 156 | + | ||||
| 157 | + | // domain "app.example.com" + service "postgres" → container name "app__example__com-postgres" | |||
| 158 | + | store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil) | |||
| 159 | + | ||||
| 160 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets) | |||
| 161 | + | assert.NoError(t, err) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestService_DeleteAttachment(t *testing.T) { | |||
| 165 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 166 | + | svc := NewService(store, testLogger()) | |||
| 167 | + | ||||
| 168 | + | store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil) | |||
| 169 | + | ||||
| 170 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY") | |||
| 171 | + | assert.NoError(t, err) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestService_SetAttachment_InvalidDomain(t *testing.T) { | |||
| 175 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 176 | + | svc := NewService(store, testLogger()) | |||
| 177 | + | ||||
| 178 | + | err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"K": "V"}) | |||
| 179 | + | assert.Error(t, err) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestService_SetAttachment_EmptyService(t *testing.T) { | |||
| 183 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 184 | + | svc := NewService(store, testLogger()) | |||
| 185 | + | ||||
| 186 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"K": "V"}) | |||
| 187 | + | assert.ErrorIs(t, err, ErrServiceEmpty) | |||
| 188 | + | } | |||
| 189 | + | ``` | |||
| 190 | + | ||||
| 191 | + | **Step 2: Run test to verify it fails** | |||
| 192 | + | ||||
| 193 | + | Run: `go test ./internal/usecase/secrets/... -count=1 -run "TestService_SetAttachment|TestService_DeleteAttachment" -v` | |||
| 194 | + | Expected: FAIL — `svc.SetAttachment` undefined | |||
| 195 | + | ||||
| 196 | + | **Step 3: Add to interface and implement** | |||
| 197 | + | ||||
| 198 | + | In `internal/boundaries/in/secrets.go`, add to the `SecretService` interface: | |||
| 199 | + | ||||
| 200 | + | ```go | |||
| 201 | + | // SetAttachment sets or updates secrets for an attachment container. | |||
| 202 | + | // Resolves the container name from domain + service name. | |||
| 203 | + | SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error | |||
| 204 | + | ||||
| 205 | + | // DeleteAttachment removes a secret key from an attachment container. | |||
| 206 | + | // Resolves the container name from domain + service name. | |||
| 207 | + | DeleteAttachment(ctx context.Context, domain, service, key string) error | |||
| 208 | + | ``` | |||
| 209 | + | ||||
| 210 | + | In `internal/usecase/secrets/service.go`, add the error variable and methods: | |||
| 211 | + | ||||
| 212 | + | ```go | |||
| 213 | + | var ErrServiceEmpty = errors.New("service name cannot be empty") | |||
| 214 | + | ``` | |||
| 215 | + | ||||
| 216 | + | Add the import for the `domain` package: | |||
| 217 | + | ||||
| 218 | + | ```go | |||
| 219 | + | import ( | |||
| 220 | + | // ... existing imports ... | |||
| 221 | + | "github.com/bnema/gordon/internal/domain" | |||
| 222 | + | ) | |||
| 223 | + | ``` | |||
| 224 | + | ||||
| 225 | + | Add the methods: | |||
| 226 | + | ||||
| 227 | + | ```go | |||
| 228 | + | // SetAttachment sets or updates secrets for an attachment container. | |||
| 229 | + | func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error { | |||
| 230 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 231 | + | zerowrap.FieldLayer: "usecase", | |||
| 232 | + | zerowrap.FieldUseCase: "SetAttachment", | |||
| 233 | + | "domain": domainName, | |||
| 234 | + | "service": service, | |||
| 235 | + | }) | |||
| 236 | + | log := zerowrap.FromCtx(ctx) | |||
| 237 | + | ||||
| 238 | + | if err := ValidateDomain(domainName); err != nil { | |||
| 239 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 240 | + | return err | |||
| 241 | + | } | |||
| 242 | + | if service == "" { | |||
| 243 | + | return ErrServiceEmpty | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | containerName := resolveContainerName(domainName, service) | |||
| 247 | + | if err := s.store.SetAttachment(containerName, secrets); err != nil { | |||
| 248 | + | log.Error().Err(err).Str("container", containerName).Msg("failed to set attachment secrets") | |||
| 249 | + | return err | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | log.Info().Str("container", containerName).Int("count", len(secrets)).Msg("attachment secrets set") | |||
| 253 | + | return nil | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // DeleteAttachment removes a secret key from an attachment container. | |||
| 257 | + | func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error { | |||
| 258 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 259 | + | zerowrap.FieldLayer: "usecase", | |||
| 260 | + | zerowrap.FieldUseCase: "DeleteAttachment", | |||
| 261 | + | "domain": domainName, | |||
| 262 | + | "service": service, | |||
| 263 | + | "key": key, | |||
| 264 | + | }) | |||
| 265 | + | log := zerowrap.FromCtx(ctx) | |||
| 266 | + | ||||
| 267 | + | if err := ValidateDomain(domainName); err != nil { | |||
| 268 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 269 | + | return err | |||
| 270 | + | } | |||
| 271 | + | if service == "" { | |||
| 272 | + | return ErrServiceEmpty | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | containerName := resolveContainerName(domainName, service) | |||
| 276 | + | if err := s.store.DeleteAttachment(containerName, key); err != nil { | |||
| 277 | + | log.Error().Err(err).Str("container", containerName).Msg("failed to delete attachment secret") | |||
| 278 | + | return err | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | log.Info().Str("container", containerName).Msg("attachment secret deleted") | |||
| 282 | + | return nil | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // resolveContainerName builds the attachment container name from domain and service. | |||
| 286 | + | // Must match the naming convention in container service's deployAttachedService(). | |||
| 287 | + | func resolveContainerName(domainName, service string) string { | |||
| 288 | + | return domain.SanitizeDomainForContainer(domainName) + "-" + service | |||
| 289 | + | } | |||
| 290 | + | ``` | |||
| 291 | + | ||||
| 292 | + | **Step 4: Run test to verify it passes** | |||
| 293 | + | ||||
| 294 | + | Run: `go test ./internal/usecase/secrets/... -count=1 -v` | |||
| 295 | + | Expected: ALL PASS | |||
| 296 | + | ||||
| 297 | + | **Step 5: Regenerate mocks** | |||
| 298 | + | ||||
| 299 | + | Run: `mockery` | |||
| 300 | + | Expected: Regenerates `internal/boundaries/in/mocks/mock_secret_service.go` with new methods. | |||
| 301 | + | ||||
| 302 | + | **Step 6: Run go fmt and full test suite** | |||
| 303 | + | ||||
| 304 | + | Run: `go fmt ./... && go test ./... -count=1` | |||
| 305 | + | Expected: ALL PASS | |||
| 306 | + | ||||
| 307 | + | **Step 7: Commit** | |||
| 308 | + | ||||
| 309 | + | ```bash | |||
| 310 | + | git add internal/boundaries/in/secrets.go internal/usecase/secrets/service.go internal/usecase/secrets/service_test.go internal/boundaries/in/mocks/mock_secret_service.go | |||
| 311 | + | git commit -m "feat(secrets): add SetAttachment/DeleteAttachment to use case layer | |||
| 312 | + | ||||
| 313 | + | Resolves (domain, service) to container name and delegates to | |||
| 314 | + | the storage layer. Matches container naming from deployAttachedService()." | |||
| 315 | + | ``` | |||
| 316 | + | ||||
| 317 | + | --- | |||
| 318 | + | ||||
| 319 | + | ### Task 3: Add admin API endpoint for attachment secrets | |||
| 320 | + | ||||
| 321 | + | Add `/admin/secrets/{domain}/attachments/{service}` routes for POST (set) and DELETE (with key). | |||
| 322 | + | ||||
| 323 | + | **Files:** | |||
| 324 | + | - Modify: `internal/adapters/in/http/admin/handler.go:446-518` | |||
| 325 | + | - Test: `internal/adapters/in/http/admin/handler_test.go` | |||
| 326 | + | ||||
| 327 | + | **Step 1: Write the failing tests** | |||
| 328 | + | ||||
| 329 | + | Add to `handler_test.go`: | |||
| 330 | + | ||||
| 331 | + | ```go | |||
| 332 | + | func TestHandler_AttachmentSecretsPost(t *testing.T) { | |||
| 333 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 334 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 335 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 336 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 337 | + | ||||
| 338 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 339 | + | ||||
| 340 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil) | |||
| 341 | + | ||||
| 342 | + | body := `{"POSTGRES_PASSWORD": "secret"}` | |||
| 343 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/postgres", bytes.NewBufferString(body)) | |||
| 344 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |||
| 345 | + | rec := httptest.NewRecorder() | |||
| 346 | + | ||||
| 347 | + | handler.ServeHTTP(rec, req) | |||
| 348 | + | ||||
| 349 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |||
| 353 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 354 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 355 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 356 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 357 | + | ||||
| 358 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 359 | + | ||||
| 360 | + | secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "postgres", "OLD_KEY").Return(nil) | |||
| 361 | + | ||||
| 362 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/postgres/OLD_KEY", nil) | |||
| 363 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |||
| 364 | + | rec := httptest.NewRecorder() | |||
| 365 | + | ||||
| 366 | + | handler.ServeHTTP(rec, req) | |||
| 367 | + | ||||
| 368 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 369 | + | } | |||
| 370 | + | ``` | |||
| 371 | + | ||||
| 372 | + | **Step 2: Run tests to verify they fail** | |||
| 373 | + | ||||
| 374 | + | Run: `go test ./internal/adapters/in/http/admin/... -count=1 -run "TestHandler_AttachmentSecrets" -v` | |||
| 375 | + | Expected: FAIL — routes not recognized, 400 or 404 | |||
| 376 | + | ||||
| 377 | + | **Step 3: Implement the endpoint** | |||
| 378 | + | ||||
| 379 | + | In `handler.go`, modify the `handleSecrets` function. Replace the path parsing section (lines 451-462) to detect the `/attachments/` sub-path: | |||
| 380 | + | ||||
| 381 | + | ```go | |||
| 382 | + | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | |||
| 383 | + | ctx := r.Context() | |||
| 384 | + | log := zerowrap.FromCtx(ctx) | |||
| 385 | + | ||||
| 386 | + | // Parse path: /secrets/{domain}, /secrets/{domain}/{key}, | |||
| 387 | + | // or /secrets/{domain}/attachments/{service}[/{key}] | |||
| 388 | + | trimmed := strings.TrimPrefix(path, "/secrets/") | |||
| 389 | + | parts := strings.Split(trimmed, "/") | |||
| 390 | + | if len(parts) == 0 || parts[0] == "" { | |||
| 391 | + | h.sendError(w, http.StatusBadRequest, "domain required") | |||
| 392 | + | return | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | secretDomain := parts[0] | |||
| 396 | + | ||||
| 397 | + | // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}] | |||
| 398 | + | if len(parts) >= 3 && parts[1] == "attachments" { | |||
| 399 | + | service := parts[2] | |||
| 400 | + | attachmentKey := "" | |||
| 401 | + | if len(parts) > 3 { | |||
| 402 | + | attachmentKey = parts[3] | |||
| 403 | + | } | |||
| 404 | + | h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey) | |||
| 405 | + | return | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | secretKey := "" | |||
| 409 | + | if len(parts) > 1 { | |||
| 410 | + | secretKey = parts[1] | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | switch r.Method { | |||
| 414 | + | case http.MethodGet: | |||
| 415 | + | h.handleSecretsGet(w, r, secretDomain) | |||
| 416 | + | ||||
| 417 | + | case http.MethodPost: | |||
| 418 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 419 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 420 | + | return | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 424 | + | ||||
| 425 | + | var data map[string]string | |||
| 426 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |||
| 427 | + | log.Warn().Err(err).Msg("invalid secrets JSON") | |||
| 428 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 429 | + | return | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |||
| 433 | + | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |||
| 434 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 435 | + | return | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | |||
| 439 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |||
| 440 | + | ||||
| 441 | + | case http.MethodDelete: | |||
| 442 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 443 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 444 | + | return | |||
| 445 | + | } | |||
| 446 | + | if secretKey == "" { | |||
| 447 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |||
| 448 | + | return | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |||
| 452 | + | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |||
| 453 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 454 | + | return | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |||
| 458 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |||
| 459 | + | ||||
| 460 | + | default: | |||
| 461 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 462 | + | } | |||
| 463 | + | } | |||
| 464 | + | ``` | |||
| 465 | + | ||||
| 466 | + | Add the new handler method: | |||
| 467 | + | ||||
| 468 | + | ```go | |||
| 469 | + | // handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}]. | |||
| 470 | + | func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) { | |||
| 471 | + | ctx := r.Context() | |||
| 472 | + | log := zerowrap.FromCtx(ctx) | |||
| 473 | + | ||||
| 474 | + | switch r.Method { | |||
| 475 | + | case http.MethodPost: | |||
| 476 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 477 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 478 | + | return | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 482 | + | ||||
| 483 | + | var data map[string]string | |||
| 484 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |||
| 485 | + | log.Warn().Err(err).Msg("invalid attachment secrets JSON") | |||
| 486 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 487 | + | return | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil { | |||
| 491 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets") | |||
| 492 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 493 | + | return | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set") | |||
| 497 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |||
| 498 | + | ||||
| 499 | + | case http.MethodDelete: | |||
| 500 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 501 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 502 | + | return | |||
| 503 | + | } | |||
| 504 | + | if key == "" { | |||
| 505 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |||
| 506 | + | return | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil { | |||
| 510 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret") | |||
| 511 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 512 | + | return | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted") | |||
| 516 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |||
| 517 | + | ||||
| 518 | + | default: | |||
| 519 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 520 | + | } | |||
| 521 | + | } | |||
| 522 | + | ``` | |||
| 523 | + | ||||
| 524 | + | **Step 4: Run tests to verify they pass** | |||
| 525 | + | ||||
| 526 | + | Run: `go test ./internal/adapters/in/http/admin/... -count=1 -v` | |||
| 527 | + | Expected: ALL PASS (both new and existing tests) | |||
| 528 | + | ||||
| 529 | + | **Step 5: Run go fmt** | |||
| 530 | + | ||||
| 531 | + | Run: `go fmt ./internal/adapters/in/http/admin/...` | |||
| 532 | + | ||||
| 533 | + | **Step 6: Commit** | |||
| 534 | + | ||||
| 535 | + | ```bash | |||
| 536 | + | git add internal/adapters/in/http/admin/handler.go internal/adapters/in/http/admin/handler_test.go | |||
| 537 | + | git commit -m "feat(api): add attachment secrets endpoints | |||
| 538 | + | ||||
| 539 | + | POST /admin/secrets/{domain}/attachments/{service} to set secrets. | |||
| 540 | + | DELETE /admin/secrets/{domain}/attachments/{service}/{key} to remove." | |||
| 541 | + | ``` | |||
| 542 | + | ||||
| 543 | + | --- | |||
| 544 | + | ||||
| 545 | + | ### Task 4: Add remote client methods | |||
| 546 | + | ||||
| 547 | + | **Files:** | |||
| 548 | + | - Modify: `internal/adapters/in/cli/remote/client.go` (after `DeleteSecret` ~line 296) | |||
| 549 | + | ||||
| 550 | + | **Step 1: Implement the client methods** | |||
| 551 | + | ||||
| 552 | + | No separate test file exists for the remote client (it's tested via integration). Add the methods: | |||
| 553 | + | ||||
| 554 | + | ```go | |||
| 555 | + | // SetAttachmentSecrets sets secrets for an attachment container. | |||
| 556 | + | func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error { | |||
| 557 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) | |||
| 558 | + | resp, err := c.request(ctx, http.MethodPost, path, secrets) | |||
| 559 | + | if err != nil { | |||
| 560 | + | return err | |||
| 561 | + | } | |||
| 562 | + | return parseResponse(resp, nil) | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | // DeleteAttachmentSecret removes a secret from an attachment container. | |||
| 566 | + | func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error { | |||
| 567 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key) | |||
| 568 | + | resp, err := c.request(ctx, http.MethodDelete, path, nil) | |||
| 569 | + | if err != nil { | |||
| 570 | + | return err | |||
| 571 | + | } | |||
| 572 | + | return parseResponse(resp, nil) | |||
| 573 | + | } | |||
| 574 | + | ``` | |||
| 575 | + | ||||
| 576 | + | **Step 2: Run go fmt and verify build** | |||
| 577 | + | ||||
| 578 | + | Run: `go fmt ./internal/adapters/in/cli/remote/... && go build ./...` | |||
| 579 | + | Expected: Clean build | |||
| 580 | + | ||||
| 581 | + | **Step 3: Commit** | |||
| 582 | + | ||||
| 583 | + | ```bash | |||
| 584 | + | git add internal/adapters/in/cli/remote/client.go | |||
| 585 | + | git commit -m "feat(client): add attachment secrets API methods" | |||
| 586 | + | ``` | |||
| 587 | + | ||||
| 588 | + | --- | |||
| 589 | + | ||||
| 590 | + | ### Task 5: Add `--attachment` flag to `secrets set` and `secrets remove` CLI commands | |||
| 591 | + | ||||
| 592 | + | **Files:** | |||
| 593 | + | - Modify: `internal/adapters/in/cli/secrets.go:214-270` (set command) | |||
| 594 | + | - Modify: `internal/adapters/in/cli/secrets.go:272-327` (remove command) | |||
| 595 | + | ||||
| 596 | + | **Step 1: Write the failing test for flag parsing** | |||
| 597 | + | ||||
| 598 | + | Add to `internal/adapters/in/cli/secrets_test.go` (create file): | |||
| 599 | + | ||||
| 600 | + | ```go | |||
| 601 | + | package cli | |||
| 602 | + | ||||
| 603 | + | import ( | |||
| 604 | + | "testing" | |||
| 605 | + | ||||
| 606 | + | "github.com/stretchr/testify/assert" | |||
| 607 | + | ) | |||
| 608 | + | ||||
| 609 | + | func TestResolveContainerName(t *testing.T) { | |||
| 610 | + | tests := []struct { | |||
| 611 | + | domain string | |||
| 612 | + | service string | |||
| 613 | + | want string | |||
| 614 | + | }{ | |||
| 615 | + | {"app.example.com", "postgres", "app__example__com-postgres"}, | |||
| 616 | + | {"git.example.com", "redis", "git__example__com-redis"}, | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | for _, tt := range tests { | |||
| 620 | + | t.Run(tt.domain+"/"+tt.service, func(t *testing.T) { | |||
| 621 | + | got := resolveAttachmentContainerName(tt.domain, tt.service) | |||
| 622 | + | assert.Equal(t, tt.want, got) | |||
| 623 | + | }) | |||
| 624 | + | } | |||
| 625 | + | } | |||
| 626 | + | ``` | |||
| 627 | + | ||||
| 628 | + | **Step 2: Run test to verify it fails** | |||
| 629 | + | ||||
| 630 | + | Run: `go test ./internal/adapters/in/cli/... -count=1 -run TestResolveContainerName -v` | |||
| 631 | + | Expected: FAIL — `resolveAttachmentContainerName` undefined | |||
| 632 | + | ||||
| 633 | + | **Step 3: Implement** | |||
| 634 | + | ||||
| 635 | + | In `secrets.go`, add the helper: | |||
| 636 | + | ||||
| 637 | + | ```go | |||
| 638 | + | // resolveAttachmentContainerName builds the container name from domain and service. | |||
| 639 | + | // Must match the naming in usecase/container deployAttachedService() and usecase/secrets resolveContainerName(). | |||
| 640 | + | func resolveAttachmentContainerName(domainName, service string) string { | |||
| 641 | + | return domain.SanitizeDomainForContainer(domainName) + "-" + service | |||
| 642 | + | } | |||
| 643 | + | ``` | |||
| 644 | + | ||||
| 645 | + | Add the import for the domain package at the top of `secrets.go`: | |||
| 646 | + | ||||
| 647 | + | ```go | |||
| 648 | + | import ( | |||
| 649 | + | // ... existing imports ... | |||
| 650 | + | "github.com/bnema/gordon/internal/domain" | |||
| 651 | + | ) | |||
| 652 | + | ``` | |||
| 653 | + | ||||
| 654 | + | Now modify `newSecretsSetCmd()` to add the `--attachment` flag. Replace the function: | |||
| 655 | + | ||||
| 656 | + | ```go | |||
| 657 | + | func newSecretsSetCmd() *cobra.Command { | |||
| 658 | + | var attachment string | |||
| 659 | + | ||||
| 660 | + | cmd := &cobra.Command{ | |||
| 661 | + | Use: "set <domain> <KEY=value>...", | |||
| 662 | + | Short: "Set secrets for a domain or attachment", | |||
| 663 | + | Long: `Set one or more secrets for a domain or an attachment container. | |||
| 664 | + | ||||
| 665 | + | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | |||
| 666 | + | ||||
| 667 | + | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |||
| 668 | + | of the main domain container. | |||
| 669 | + | ||||
| 670 | + | Examples: | |||
| 671 | + | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | |||
| 672 | + | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | |||
| 673 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |||
| 674 | + | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | |||
| 675 | + | Args: cobra.MinimumNArgs(2), | |||
| 676 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 677 | + | ctx := context.Background() | |||
| 678 | + | secretDomain := args[0] | |||
| 679 | + | pairs := args[1:] | |||
| 680 | + | ||||
| 681 | + | // Parse KEY=value pairs | |||
| 682 | + | secrets := make(map[string]string) | |||
| 683 | + | for _, pair := range pairs { | |||
| 684 | + | parts := strings.SplitN(pair, "=", 2) | |||
| 685 | + | if len(parts) != 2 { | |||
| 686 | + | fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair))) | |||
| 687 | + | return nil | |||
| 688 | + | } | |||
| 689 | + | secrets[parts[0]] = parts[1] | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | client, isRemote := GetRemoteClient() | |||
| 693 | + | if isRemote { | |||
| 694 | + | if attachment != "" { | |||
| 695 | + | if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { | |||
| 696 | + | return fmt.Errorf("failed to set attachment secrets: %w", err) | |||
| 697 | + | } | |||
| 698 | + | } else { | |||
| 699 | + | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | |||
| 700 | + | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 701 | + | } | |||
| 702 | + | } | |||
| 703 | + | } else { | |||
| 704 | + | local, err := GetLocalServices(configPath) | |||
| 705 | + | if err != nil { | |||
| 706 | + | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 707 | + | } | |||
| 708 | + | if attachment != "" { | |||
| 709 | + | if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil { | |||
| 710 | + | return fmt.Errorf("failed to set attachment secrets: %w", err) | |||
| 711 | + | } | |||
| 712 | + | } else { | |||
| 713 | + | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | |||
| 714 | + | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 715 | + | } | |||
| 716 | + | } | |||
| 717 | + | } | |||
| 718 | + | ||||
| 719 | + | target := secretDomain | |||
| 720 | + | if attachment != "" { | |||
| 721 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | if len(secrets) == 1 { | |||
| 725 | + | for key := range secrets { | |||
| 726 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |||
| 727 | + | } | |||
| 728 | + | } else { | |||
| 729 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |||
| 730 | + | } | |||
| 731 | + | ||||
| 732 | + | return nil | |||
| 733 | + | }, | |||
| 734 | + | } | |||
| 735 | + | ||||
| 736 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |||
| 737 | + | ||||
| 738 | + | return cmd | |||
| 739 | + | } | |||
| 740 | + | ``` | |||
| 741 | + | ||||
| 742 | + | Now modify `newSecretsRemoveCmd()` similarly: | |||
| 743 | + | ||||
| 744 | + | ```go | |||
| 745 | + | func newSecretsRemoveCmd() *cobra.Command { | |||
| 746 | + | var ( | |||
| 747 | + | force bool | |||
| 748 | + | attachment string | |||
| 749 | + | ) | |||
| 750 | + | ||||
| 751 | + | cmd := &cobra.Command{ | |||
| 752 | + | Use: "remove <domain> <key>", | |||
| 753 | + | Short: "Remove a secret", | |||
| 754 | + | Long: `Remove a secret from a domain or attachment container. | |||
| 755 | + | ||||
| 756 | + | Use --attachment to target an attachment service instead of the main domain. | |||
| 757 | + | ||||
| 758 | + | Examples: | |||
| 759 | + | gordon secrets remove app.mydomain.com OLD_API_KEY | |||
| 760 | + | gordon secrets remove app.mydomain.com OLD_API_KEY --force | |||
| 761 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | |||
| 762 | + | Args: cobra.ExactArgs(2), | |||
| 763 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 764 | + | ctx := context.Background() | |||
| 765 | + | secretDomain := args[0] | |||
| 766 | + | key := args[1] | |||
| 767 | + | ||||
| 768 | + | target := secretDomain | |||
| 769 | + | if attachment != "" { | |||
| 770 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |||
| 771 | + | } | |||
| 772 | + | ||||
| 773 | + | // Confirm unless --force | |||
| 774 | + | if !force { | |||
| 775 | + | confirmed, err := components.RunConfirm( | |||
| 776 | + | fmt.Sprintf("Remove secret '%s' from %s?", key, target), | |||
| 777 | + | ) | |||
| 778 | + | if err != nil { | |||
| 779 | + | return err | |||
| 780 | + | } | |||
| 781 | + | if !confirmed { | |||
| 782 | + | fmt.Println(styles.Theme.Muted.Render("Cancelled")) | |||
| 783 | + | return nil | |||
| 784 | + | } | |||
| 785 | + | } | |||
| 786 | + | ||||
| 787 | + | client, isRemote := GetRemoteClient() | |||
| 788 | + | if isRemote { | |||
| 789 | + | if attachment != "" { | |||
| 790 | + | if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { | |||
| 791 | + | return fmt.Errorf("failed to remove attachment secret: %w", err) | |||
| 792 | + | } | |||
| 793 | + | } else { | |||
| 794 | + | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | |||
| 795 | + | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 796 | + | } | |||
| 797 | + | } | |||
| 798 | + | } else { | |||
| 799 | + | local, err := GetLocalServices(configPath) | |||
| 800 | + | if err != nil { | |||
| 801 | + | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 802 | + | } | |||
| 803 | + | if attachment != "" { | |||
| 804 | + | if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil { | |||
| 805 | + | return fmt.Errorf("failed to remove attachment secret: %w", err) | |||
| 806 | + | } | |||
| 807 | + | } else { | |||
| 808 | + | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | |||
| 809 | + | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 810 | + | } | |||
| 811 | + | } | |||
| 812 | + | } | |||
| 813 | + | ||||
| 814 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s from %s", key, target))) | |||
| 815 | + | return nil | |||
| 816 | + | }, | |||
| 817 | + | } | |||
| 818 | + | ||||
| 819 | + | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |||
| 820 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |||
| 821 | + | ||||
| 822 | + | return cmd | |||
| 823 | + | } | |||
| 824 | + | ``` | |||
| 825 | + | ||||
| 826 | + | **Step 4: Run tests to verify they pass** | |||
| 827 | + | ||||
| 828 | + | Run: `go test ./internal/adapters/in/cli/... -count=1 -v` | |||
| 829 | + | Expected: ALL PASS | |||
| 830 | + | ||||
| 831 | + | **Step 5: Run go fmt and full suite** | |||
| 832 | + | ||||
| 833 | + | Run: `go fmt ./... && go test ./... -count=1` | |||
| 834 | + | Expected: ALL PASS | |||
| 835 | + | ||||
| 836 | + | **Step 6: Commit** | |||
| 837 | + | ||||
| 838 | + | ```bash | |||
| 839 | + | git add internal/adapters/in/cli/secrets.go internal/adapters/in/cli/secrets_test.go | |||
| 840 | + | git commit -m "feat(cli): add --attachment flag to secrets set/remove | |||
| 841 | + | ||||
| 842 | + | Closes #56. Users can now configure attachment secrets via: | |||
| 843 | + | gordon secrets set <domain> --attachment <service> KEY=value | |||
| 844 | + | gordon secrets remove <domain> --attachment <service> KEY" | |||
| 845 | + | ``` | |||
| 846 | + | ||||
| 847 | + | --- | |||
| 848 | + | ||||
| 849 | + | ### Task 6: Update secrets command description and verify end-to-end | |||
| 850 | + | ||||
| 851 | + | **Files:** | |||
| 852 | + | - Modify: `internal/adapters/in/cli/secrets.go:16-33` (parent command description) | |||
| 853 | + | ||||
| 854 | + | **Step 1: Update parent command long description** | |||
| 855 | + | ||||
| 856 | + | ```go | |||
| 857 | + | func newSecretsCmd() *cobra.Command { | |||
| 858 | + | cmd := &cobra.Command{ | |||
| 859 | + | Use: "secrets", | |||
| 860 | + | Short: "Manage secrets", | |||
| 861 | + | Long: `Manage secrets (environment variables) for routes and attachments. | |||
| 862 | + | ||||
| 863 | + | Secrets are stored per-domain and injected into containers as environment variables. | |||
| 864 | + | Use --attachment to target attachment containers (databases, caches, etc.). | |||
| 865 | + | ||||
| 866 | + | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |||
| 867 | + | these commands operate on the remote server.`, | |||
| 868 | + | } | |||
| 869 | + | ||||
| 870 | + | cmd.AddCommand(newSecretsListCmd()) | |||
| 871 | + | cmd.AddCommand(newSecretsSetCmd()) | |||
| 872 | + | cmd.AddCommand(newSecretsRemoveCmd()) | |||
| 873 | + | ||||
| 874 | + | return cmd | |||
| 875 | + | } | |||
| 876 | + | ``` | |||
| 877 | + | ||||
| 878 | + | **Step 2: Run full test suite** | |||
| 879 | + | ||||
| 880 | + | Run: `go test ./... -count=1` | |||
| 881 | + | Expected: ALL PASS | |||
| 882 | + | ||||
| 883 | + | **Step 3: Run go vet and build** | |||
| 884 | + | ||||
| 885 | + | Run: `go vet ./... && go build ./...` | |||
| 886 | + | Expected: Clean | |||
| 887 | + | ||||
| 888 | + | **Step 4: Commit** | |||
| 889 | + | ||||
| 890 | + | ```bash | |||
| 891 | + | git add internal/adapters/in/cli/secrets.go | |||
| 892 | + | git commit -m "docs(cli): update secrets command description for attachments" | |||
| 893 | + | ``` | |||
M internal/adapters/in/cli/remote/client.go +20 -0
| @@ -295,6 +295,26 @@ func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) err | |||
| 295 | 295 | return parseResponse(resp, nil) | |
| 296 | 296 | } | |
| 297 | 297 | ||
| 298 | + | // SetAttachmentSecrets sets secrets for an attachment container. | |
| 299 | + | func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error { | |
| 300 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) | |
| 301 | + | resp, err := c.request(ctx, http.MethodPost, path, secrets) | |
| 302 | + | if err != nil { | |
| 303 | + | return err | |
| 304 | + | } | |
| 305 | + | return parseResponse(resp, nil) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // DeleteAttachmentSecret removes a secret from an attachment container. | |
| 309 | + | func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error { | |
| 310 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key) | |
| 311 | + | resp, err := c.request(ctx, http.MethodDelete, path, nil) | |
| 312 | + | if err != nil { | |
| 313 | + | return err | |
| 314 | + | } | |
| 315 | + | return parseResponse(resp, nil) | |
| 316 | + | } | |
| 317 | + | ||
| 298 | 318 | // Status API | |
| 299 | 319 | ||
| 300 | 320 | // Status represents the Gordon server status. | |
| @@ -295,6 +295,26 @@ func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) err | |||||
| 295 | return parseResponse(resp, nil) | 295 | return parseResponse(resp, nil) | ||
| 296 | } | 296 | } | ||
| 297 | 297 | ||||
| 298 | + | // SetAttachmentSecrets sets secrets for an attachment container. | |||
| 299 | + | func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error { | |||
| 300 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) | |||
| 301 | + | resp, err := c.request(ctx, http.MethodPost, path, secrets) | |||
| 302 | + | if err != nil { | |||
| 303 | + | return err | |||
| 304 | + | } | |||
| 305 | + | return parseResponse(resp, nil) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // DeleteAttachmentSecret removes a secret from an attachment container. | |||
| 309 | + | func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error { | |||
| 310 | + | path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key) | |||
| 311 | + | resp, err := c.request(ctx, http.MethodDelete, path, nil) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return err | |||
| 314 | + | } | |||
| 315 | + | return parseResponse(resp, nil) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 298 | // Status API | 318 | // Status API | ||
| 299 | 319 | ||||
| 300 | // Status represents the Gordon server status. | 320 | // Status represents the Gordon server status. | ||
M internal/adapters/in/cli/secrets.go +74 -22
| @@ -17,9 +17,10 @@ func newSecretsCmd() *cobra.Command { | |||
| 17 | 17 | cmd := &cobra.Command{ | |
| 18 | 18 | Use: "secrets", | |
| 19 | 19 | Short: "Manage secrets", | |
| 20 | - | Long: `Manage secrets (environment variables) for routes. | |
| 20 | + | Long: `Manage secrets (environment variables) for routes and attachments. | |
| 21 | 21 | ||
| 22 | 22 | Secrets are stored per-domain and injected into containers as environment variables. | |
| 23 | + | Use --attachment to target attachment containers (databases, caches, etc.). | |
| 23 | 24 | ||
| 24 | 25 | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | |
| 25 | 26 | these commands operate on the remote server.`, | |
| @@ -213,17 +214,23 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string { | |||
| 213 | 214 | ||
| 214 | 215 | // newSecretsSetCmd creates the secrets set command. | |
| 215 | 216 | func newSecretsSetCmd() *cobra.Command { | |
| 216 | - | return &cobra.Command{ | |
| 217 | + | var attachment string | |
| 218 | + | ||
| 219 | + | cmd := &cobra.Command{ | |
| 217 | 220 | Use: "set <domain> <KEY=value>...", | |
| 218 | - | Short: "Set secrets for a domain", | |
| 219 | - | Long: `Set one or more secrets for a domain. | |
| 221 | + | Short: "Set secrets for a domain or attachment", | |
| 222 | + | Long: `Set one or more secrets for a domain or an attachment container. | |
| 220 | 223 | ||
| 221 | 224 | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | |
| 222 | 225 | ||
| 226 | + | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |
| 227 | + | of the main domain container. | |
| 228 | + | ||
| 223 | 229 | Examples: | |
| 224 | 230 | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | |
| 225 | 231 | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | |
| 226 | - | gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`, | |
| 232 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret | |
| 233 | + | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | |
| 227 | 234 | Args: cobra.MinimumNArgs(2), | |
| 228 | 235 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 229 | 236 | ctx := context.Background() | |
| @@ -235,62 +242,94 @@ Examples: | |||
| 235 | 242 | for _, pair := range pairs { | |
| 236 | 243 | parts := strings.SplitN(pair, "=", 2) | |
| 237 | 244 | if len(parts) != 2 { | |
| 238 | - | fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair))) | |
| 239 | - | return nil | |
| 245 | + | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) | |
| 240 | 246 | } | |
| 241 | 247 | secrets[parts[0]] = parts[1] | |
| 242 | 248 | } | |
| 243 | 249 | ||
| 244 | 250 | client, isRemote := GetRemoteClient() | |
| 245 | 251 | if isRemote { | |
| 246 | - | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 247 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 252 | + | if attachment != "" { | |
| 253 | + | if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { | |
| 254 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 255 | + | } | |
| 256 | + | } else { | |
| 257 | + | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 258 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 259 | + | } | |
| 248 | 260 | } | |
| 249 | 261 | } else { | |
| 250 | 262 | local, err := GetLocalServices(configPath) | |
| 251 | 263 | if err != nil { | |
| 252 | 264 | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 253 | 265 | } | |
| 254 | - | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | |
| 255 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 266 | + | if attachment != "" { | |
| 267 | + | if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil { | |
| 268 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 269 | + | } | |
| 270 | + | } else { | |
| 271 | + | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | |
| 272 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 273 | + | } | |
| 256 | 274 | } | |
| 257 | 275 | } | |
| 258 | 276 | ||
| 277 | + | target := secretDomain | |
| 278 | + | if attachment != "" { | |
| 279 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 280 | + | } | |
| 281 | + | ||
| 259 | 282 | if len(secrets) == 1 { | |
| 260 | 283 | for key := range secrets { | |
| 261 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key))) | |
| 284 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) | |
| 262 | 285 | } | |
| 263 | 286 | } else { | |
| 264 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain))) | |
| 287 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) | |
| 265 | 288 | } | |
| 266 | 289 | ||
| 267 | 290 | return nil | |
| 268 | 291 | }, | |
| 269 | 292 | } | |
| 293 | + | ||
| 294 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 295 | + | ||
| 296 | + | return cmd | |
| 270 | 297 | } | |
| 271 | 298 | ||
| 272 | 299 | // newSecretsRemoveCmd creates the secrets remove command. | |
| 273 | 300 | func newSecretsRemoveCmd() *cobra.Command { | |
| 274 | - | var force bool | |
| 301 | + | var ( | |
| 302 | + | force bool | |
| 303 | + | attachment string | |
| 304 | + | ) | |
| 275 | 305 | ||
| 276 | 306 | cmd := &cobra.Command{ | |
| 277 | 307 | Use: "remove <domain> <key>", | |
| 278 | 308 | Short: "Remove a secret", | |
| 279 | - | Long: `Remove a secret from a domain. | |
| 309 | + | Long: `Remove a secret from a domain or an attachment container. | |
| 310 | + | ||
| 311 | + | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |
| 312 | + | of the main domain container. | |
| 280 | 313 | ||
| 281 | 314 | Examples: | |
| 282 | 315 | gordon secrets remove app.mydomain.com OLD_API_KEY | |
| 283 | - | gordon secrets remove app.mydomain.com OLD_API_KEY --force`, | |
| 316 | + | gordon secrets remove app.mydomain.com OLD_API_KEY --force | |
| 317 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | |
| 284 | 318 | Args: cobra.ExactArgs(2), | |
| 285 | 319 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 286 | 320 | ctx := context.Background() | |
| 287 | 321 | secretDomain := args[0] | |
| 288 | 322 | key := args[1] | |
| 289 | 323 | ||
| 324 | + | target := secretDomain | |
| 325 | + | if attachment != "" { | |
| 326 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |
| 327 | + | } | |
| 328 | + | ||
| 290 | 329 | // Confirm unless --force | |
| 291 | 330 | if !force { | |
| 292 | 331 | confirmed, err := components.RunConfirm( | |
| 293 | - | fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain), | |
| 332 | + | fmt.Sprintf("Remove secret '%s' from %s?", key, target), | |
| 294 | 333 | ) | |
| 295 | 334 | if err != nil { | |
| 296 | 335 | return err | |
| @@ -303,25 +342,38 @@ Examples: | |||
| 303 | 342 | ||
| 304 | 343 | client, isRemote := GetRemoteClient() | |
| 305 | 344 | if isRemote { | |
| 306 | - | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 307 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 345 | + | if attachment != "" { | |
| 346 | + | if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { | |
| 347 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 348 | + | } | |
| 349 | + | } else { | |
| 350 | + | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 351 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 352 | + | } | |
| 308 | 353 | } | |
| 309 | 354 | } else { | |
| 310 | 355 | local, err := GetLocalServices(configPath) | |
| 311 | 356 | if err != nil { | |
| 312 | 357 | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 313 | 358 | } | |
| 314 | - | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | |
| 315 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 359 | + | if attachment != "" { | |
| 360 | + | if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil { | |
| 361 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 362 | + | } | |
| 363 | + | } else { | |
| 364 | + | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | |
| 365 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 366 | + | } | |
| 316 | 367 | } | |
| 317 | 368 | } | |
| 318 | 369 | ||
| 319 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key))) | |
| 370 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key))) | |
| 320 | 371 | return nil | |
| 321 | 372 | }, | |
| 322 | 373 | } | |
| 323 | 374 | ||
| 324 | 375 | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | |
| 376 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |
| 325 | 377 | ||
| 326 | 378 | return cmd | |
| 327 | 379 | } | |
| @@ -17,9 +17,10 @@ func newSecretsCmd() *cobra.Command { | |||||
| 17 | cmd := &cobra.Command{ | 17 | cmd := &cobra.Command{ | ||
| 18 | Use: "secrets", | 18 | Use: "secrets", | ||
| 19 | Short: "Manage secrets", | 19 | Short: "Manage secrets", | ||
| 20 | - | Long: `Manage secrets (environment variables) for routes. | 20 | + | Long: `Manage secrets (environment variables) for routes and attachments. |
| 21 | 21 | ||||
| 22 | Secrets are stored per-domain and injected into containers as environment variables. | 22 | Secrets are stored per-domain and injected into containers as environment variables. | ||
| 23 | + | Use --attachment to target attachment containers (databases, caches, etc.). | |||
| 23 | 24 | ||||
| 24 | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | 25 | When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var), | ||
| 25 | these commands operate on the remote server.`, | 26 | these commands operate on the remote server.`, | ||
| @@ -213,17 +214,23 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string { | |||||
| 213 | 214 | ||||
| 214 | // newSecretsSetCmd creates the secrets set command. | 215 | // newSecretsSetCmd creates the secrets set command. | ||
| 215 | func newSecretsSetCmd() *cobra.Command { | 216 | func newSecretsSetCmd() *cobra.Command { | ||
| 216 | - | return &cobra.Command{ | 217 | + | var attachment string |
| 218 | + | ||||
| 219 | + | cmd := &cobra.Command{ | |||
| 217 | Use: "set <domain> <KEY=value>...", | 220 | Use: "set <domain> <KEY=value>...", | ||
| 218 | - | Short: "Set secrets for a domain", | 221 | + | Short: "Set secrets for a domain or attachment", |
| 219 | - | Long: `Set one or more secrets for a domain. | 222 | + | Long: `Set one or more secrets for a domain or an attachment container. |
| 220 | 223 | ||||
| 221 | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | 224 | Secrets are specified as KEY=value pairs. Multiple secrets can be set at once. | ||
| 222 | 225 | ||||
| 226 | + | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |||
| 227 | + | of the main domain container. | |||
| 228 | + | ||||
| 223 | Examples: | 229 | Examples: | ||
| 224 | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | 230 | gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db | ||
| 225 | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | 231 | gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false | ||
| 226 | - | gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`, | 232 | + | gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret |
| 233 | + | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | |||
| 227 | Args: cobra.MinimumNArgs(2), | 234 | Args: cobra.MinimumNArgs(2), | ||
| 228 | RunE: func(cmd *cobra.Command, args []string) error { | 235 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 229 | ctx := context.Background() | 236 | ctx := context.Background() | ||
| @@ -235,62 +242,94 @@ Examples: | |||||
| 235 | for _, pair := range pairs { | 242 | for _, pair := range pairs { | ||
| 236 | parts := strings.SplitN(pair, "=", 2) | 243 | parts := strings.SplitN(pair, "=", 2) | ||
| 237 | if len(parts) != 2 { | 244 | if len(parts) != 2 { | ||
| 238 | - | fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair))) | 245 | + | return fmt.Errorf("invalid format: %s (expected KEY=value)", pair) |
| 239 | - | return nil | |||
| 240 | } | 246 | } | ||
| 241 | secrets[parts[0]] = parts[1] | 247 | secrets[parts[0]] = parts[1] | ||
| 242 | } | 248 | } | ||
| 243 | 249 | ||||
| 244 | client, isRemote := GetRemoteClient() | 250 | client, isRemote := GetRemoteClient() | ||
| 245 | if isRemote { | 251 | if isRemote { | ||
| 246 | - | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | 252 | + | if attachment != "" { |
| 247 | - | return fmt.Errorf("failed to set secrets: %w", err) | 253 | + | if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { |
| 254 | + | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 255 | + | } | |||
| 256 | + | } else { | |||
| 257 | + | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | |||
| 258 | + | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 259 | + | } | |||
| 248 | } | 260 | } | ||
| 249 | } else { | 261 | } else { | ||
| 250 | local, err := GetLocalServices(configPath) | 262 | local, err := GetLocalServices(configPath) | ||
| 251 | if err != nil { | 263 | if err != nil { | ||
| 252 | return fmt.Errorf("failed to initialize local services: %w", err) | 264 | return fmt.Errorf("failed to initialize local services: %w", err) | ||
| 253 | } | 265 | } | ||
| 254 | - | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | 266 | + | if attachment != "" { |
| 255 | - | return fmt.Errorf("failed to set secrets: %w", err) | 267 | + | if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil { |
| 268 | + | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 269 | + | } | |||
| 270 | + | } else { | |||
| 271 | + | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | |||
| 272 | + | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 273 | + | } | |||
| 256 | } | 274 | } | ||
| 257 | } | 275 | } | ||
| 258 | 276 | ||||
| 277 | + | target := secretDomain | |||
| 278 | + | if attachment != "" { | |||
| 279 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 259 | if len(secrets) == 1 { | 282 | if len(secrets) == 1 { | ||
| 260 | for key := range secrets { | 283 | for key := range secrets { | ||
| 261 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key))) | 284 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target))) |
| 262 | } | 285 | } | ||
| 263 | } else { | 286 | } else { | ||
| 264 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain))) | 287 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target))) |
| 265 | } | 288 | } | ||
| 266 | 289 | ||||
| 267 | return nil | 290 | return nil | ||
| 268 | }, | 291 | }, | ||
| 269 | } | 292 | } | ||
| 293 | + | ||||
| 294 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |||
| 295 | + | ||||
| 296 | + | return cmd | |||
| 270 | } | 297 | } | ||
| 271 | 298 | ||||
| 272 | // newSecretsRemoveCmd creates the secrets remove command. | 299 | // newSecretsRemoveCmd creates the secrets remove command. | ||
| 273 | func newSecretsRemoveCmd() *cobra.Command { | 300 | func newSecretsRemoveCmd() *cobra.Command { | ||
| 274 | - | var force bool | 301 | + | var ( |
| 302 | + | force bool | |||
| 303 | + | attachment string | |||
| 304 | + | ) | |||
| 275 | 305 | ||||
| 276 | cmd := &cobra.Command{ | 306 | cmd := &cobra.Command{ | ||
| 277 | Use: "remove <domain> <key>", | 307 | Use: "remove <domain> <key>", | ||
| 278 | Short: "Remove a secret", | 308 | Short: "Remove a secret", | ||
| 279 | - | Long: `Remove a secret from a domain. | 309 | + | Long: `Remove a secret from a domain or an attachment container. |
| 310 | + | ||||
| 311 | + | Use --attachment to target an attachment service (e.g., postgres, redis) instead | |||
| 312 | + | of the main domain container. | |||
| 280 | 313 | ||||
| 281 | Examples: | 314 | Examples: | ||
| 282 | gordon secrets remove app.mydomain.com OLD_API_KEY | 315 | gordon secrets remove app.mydomain.com OLD_API_KEY | ||
| 283 | - | gordon secrets remove app.mydomain.com OLD_API_KEY --force`, | 316 | + | gordon secrets remove app.mydomain.com OLD_API_KEY --force |
| 317 | + | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | |||
| 284 | Args: cobra.ExactArgs(2), | 318 | Args: cobra.ExactArgs(2), | ||
| 285 | RunE: func(cmd *cobra.Command, args []string) error { | 319 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 286 | ctx := context.Background() | 320 | ctx := context.Background() | ||
| 287 | secretDomain := args[0] | 321 | secretDomain := args[0] | ||
| 288 | key := args[1] | 322 | key := args[1] | ||
| 289 | 323 | ||||
| 324 | + | target := secretDomain | |||
| 325 | + | if attachment != "" { | |||
| 326 | + | target = fmt.Sprintf("%s [%s]", secretDomain, attachment) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 290 | // Confirm unless --force | 329 | // Confirm unless --force | ||
| 291 | if !force { | 330 | if !force { | ||
| 292 | confirmed, err := components.RunConfirm( | 331 | confirmed, err := components.RunConfirm( | ||
| 293 | - | fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain), | 332 | + | fmt.Sprintf("Remove secret '%s' from %s?", key, target), |
| 294 | ) | 333 | ) | ||
| 295 | if err != nil { | 334 | if err != nil { | ||
| 296 | return err | 335 | return err | ||
| @@ -303,25 +342,38 @@ Examples: | |||||
| 303 | 342 | ||||
| 304 | client, isRemote := GetRemoteClient() | 343 | client, isRemote := GetRemoteClient() | ||
| 305 | if isRemote { | 344 | if isRemote { | ||
| 306 | - | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | 345 | + | if attachment != "" { |
| 307 | - | return fmt.Errorf("failed to remove secret: %w", err) | 346 | + | if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { |
| 347 | + | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 348 | + | } | |||
| 349 | + | } else { | |||
| 350 | + | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | |||
| 351 | + | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 352 | + | } | |||
| 308 | } | 353 | } | ||
| 309 | } else { | 354 | } else { | ||
| 310 | local, err := GetLocalServices(configPath) | 355 | local, err := GetLocalServices(configPath) | ||
| 311 | if err != nil { | 356 | if err != nil { | ||
| 312 | return fmt.Errorf("failed to initialize local services: %w", err) | 357 | return fmt.Errorf("failed to initialize local services: %w", err) | ||
| 313 | } | 358 | } | ||
| 314 | - | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | 359 | + | if attachment != "" { |
| 315 | - | return fmt.Errorf("failed to remove secret: %w", err) | 360 | + | if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil { |
| 361 | + | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 362 | + | } | |||
| 363 | + | } else { | |||
| 364 | + | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | |||
| 365 | + | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 366 | + | } | |||
| 316 | } | 367 | } | ||
| 317 | } | 368 | } | ||
| 318 | 369 | ||||
| 319 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key))) | 370 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key))) |
| 320 | return nil | 371 | return nil | ||
| 321 | }, | 372 | }, | ||
| 322 | } | 373 | } | ||
| 323 | 374 | ||||
| 324 | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | 375 | cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") | ||
| 376 | + | cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)") | |||
| 325 | 377 | ||||
| 326 | return cmd | 378 | return cmd | ||
| 327 | } | 379 | } | ||
M internal/adapters/in/http/admin/handler.go +134 -44
| @@ -445,9 +445,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | |||
| 445 | 445 | ||
| 446 | 446 | // handleSecrets handles /admin/secrets endpoints. | |
| 447 | 447 | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | |
| 448 | - | ctx := r.Context() | |
| 449 | - | log := zerowrap.FromCtx(ctx) | |
| 450 | - | ||
| 451 | 448 | // Parse path: /secrets/{domain} or /secrets/{domain}/{key} | |
| 452 | 449 | parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/") | |
| 453 | 450 | if len(parts) == 0 || parts[0] == "" { | |
| @@ -456,6 +453,35 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||
| 456 | 453 | } | |
| 457 | 454 | ||
| 458 | 455 | secretDomain := parts[0] | |
| 456 | + | ||
| 457 | + | // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}] | |
| 458 | + | if len(parts) >= 2 && parts[1] == "attachments" { | |
| 459 | + | switch r.Method { | |
| 460 | + | case http.MethodPost: | |
| 461 | + | // Expected path: /secrets/{domain}/attachments/{service} | |
| 462 | + | if len(parts) != 3 { | |
| 463 | + | h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}") | |
| 464 | + | return | |
| 465 | + | } | |
| 466 | + | service := parts[2] | |
| 467 | + | h.handleAttachmentSecrets(w, r, secretDomain, service, "") | |
| 468 | + | return | |
| 469 | + | case http.MethodDelete: | |
| 470 | + | // Expected path: /secrets/{domain}/attachments/{service}/{key} | |
| 471 | + | if len(parts) != 4 { | |
| 472 | + | h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}") | |
| 473 | + | return | |
| 474 | + | } | |
| 475 | + | service := parts[2] | |
| 476 | + | attachmentKey := parts[3] | |
| 477 | + | h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey) | |
| 478 | + | return | |
| 479 | + | default: | |
| 480 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 481 | + | return | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | ||
| 459 | 485 | secretKey := "" | |
| 460 | 486 | if len(parts) > 1 { | |
| 461 | 487 | secretKey = parts[1] | |
| @@ -464,57 +490,66 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||
| 464 | 490 | switch r.Method { | |
| 465 | 491 | case http.MethodGet: | |
| 466 | 492 | h.handleSecretsGet(w, r, secretDomain) | |
| 467 | - | ||
| 468 | 493 | case http.MethodPost: | |
| 469 | - | // Check write permission | |
| 470 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 471 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 472 | - | return | |
| 473 | - | } | |
| 494 | + | h.handleSecretsPost(w, r, secretDomain) | |
| 495 | + | case http.MethodDelete: | |
| 496 | + | h.handleSecretsDelete(w, r, secretDomain, secretKey) | |
| 497 | + | default: | |
| 498 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 499 | + | } | |
| 500 | + | } | |
| 474 | 501 | ||
| 475 | - | // Limit request body size | |
| 476 | - | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 502 | + | // handleSecretsPost handles POST /admin/secrets/{domain} - set secrets. | |
| 503 | + | func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) { | |
| 504 | + | ctx := r.Context() | |
| 505 | + | log := zerowrap.FromCtx(ctx) | |
| 477 | 506 | ||
| 478 | - | // Set secret(s) | |
| 479 | - | var data map[string]string | |
| 480 | - | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |
| 481 | - | log.Warn().Err(err).Msg("invalid secrets JSON") | |
| 482 | - | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 483 | - | return | |
| 484 | - | } | |
| 507 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 508 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 509 | + | return | |
| 510 | + | } | |
| 485 | 511 | ||
| 486 | - | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |
| 487 | - | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |
| 488 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 489 | - | return | |
| 490 | - | } | |
| 512 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 491 | 513 | ||
| 492 | - | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | |
| 493 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |
| 514 | + | var data map[string]string | |
| 515 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |
| 516 | + | log.Warn().Err(err).Msg("invalid secrets JSON") | |
| 517 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 518 | + | return | |
| 519 | + | } | |
| 494 | 520 | ||
| 495 | - | case http.MethodDelete: | |
| 496 | - | // Check write permission | |
| 497 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 498 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 499 | - | return | |
| 500 | - | } | |
| 501 | - | if secretKey == "" { | |
| 502 | - | h.sendError(w, http.StatusBadRequest, "key required in path") | |
| 503 | - | return | |
| 504 | - | } | |
| 521 | + | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |
| 522 | + | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |
| 523 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 524 | + | return | |
| 525 | + | } | |
| 505 | 526 | ||
| 506 | - | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |
| 507 | - | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |
| 508 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 509 | - | return | |
| 510 | - | } | |
| 527 | + | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | |
| 528 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |
| 529 | + | } | |
| 511 | 530 | ||
| 512 | - | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |
| 513 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |
| 531 | + | // handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret. | |
| 532 | + | func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) { | |
| 533 | + | ctx := r.Context() | |
| 534 | + | log := zerowrap.FromCtx(ctx) | |
| 514 | 535 | ||
| 515 | - | default: | |
| 516 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 536 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 537 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 538 | + | return | |
| 539 | + | } | |
| 540 | + | if secretKey == "" { | |
| 541 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |
| 542 | + | return | |
| 517 | 543 | } | |
| 544 | + | ||
| 545 | + | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |
| 546 | + | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |
| 547 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 548 | + | return | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |
| 552 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |
| 518 | 553 | } | |
| 519 | 554 | ||
| 520 | 555 | // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets. | |
| @@ -552,6 +587,61 @@ func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secre | |||
| 552 | 587 | }) | |
| 553 | 588 | } | |
| 554 | 589 | ||
| 590 | + | // handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints. | |
| 591 | + | func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) { | |
| 592 | + | ctx := r.Context() | |
| 593 | + | log := zerowrap.FromCtx(ctx) | |
| 594 | + | ||
| 595 | + | switch r.Method { | |
| 596 | + | case http.MethodPost: | |
| 597 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 598 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 599 | + | return | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 603 | + | ||
| 604 | + | var data map[string]string | |
| 605 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |
| 606 | + | log.Warn().Err(err).Msg("invalid attachment secrets JSON") | |
| 607 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 608 | + | return | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil { | |
| 612 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets") | |
| 613 | + | h.sendError(w, http.StatusBadRequest, "invalid domain or service") | |
| 614 | + | return | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set") | |
| 618 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |
| 619 | + | ||
| 620 | + | case http.MethodDelete: | |
| 621 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 622 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 623 | + | return | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | if key == "" { | |
| 627 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |
| 628 | + | return | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil { | |
| 632 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret") | |
| 633 | + | h.sendError(w, http.StatusBadRequest, "invalid domain or service") | |
| 634 | + | return | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted") | |
| 638 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |
| 639 | + | ||
| 640 | + | default: | |
| 641 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 642 | + | } | |
| 643 | + | } | |
| 644 | + | ||
| 555 | 645 | // handleHealth handles /admin/health endpoint. | |
| 556 | 646 | // Returns detailed health status for all routes including HTTP probe results. | |
| 557 | 647 | func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { | |
| @@ -445,9 +445,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | |||||
| 445 | 445 | ||||
| 446 | // handleSecrets handles /admin/secrets endpoints. | 446 | // handleSecrets handles /admin/secrets endpoints. | ||
| 447 | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | 447 | func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) { | ||
| 448 | - | ctx := r.Context() | |||
| 449 | - | log := zerowrap.FromCtx(ctx) | |||
| 450 | - | ||||
| 451 | // Parse path: /secrets/{domain} or /secrets/{domain}/{key} | 448 | // Parse path: /secrets/{domain} or /secrets/{domain}/{key} | ||
| 452 | parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/") | 449 | parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/") | ||
| 453 | if len(parts) == 0 || parts[0] == "" { | 450 | if len(parts) == 0 || parts[0] == "" { | ||
| @@ -456,6 +453,35 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||||
| 456 | } | 453 | } | ||
| 457 | 454 | ||||
| 458 | secretDomain := parts[0] | 455 | secretDomain := parts[0] | ||
| 456 | + | ||||
| 457 | + | // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}] | |||
| 458 | + | if len(parts) >= 2 && parts[1] == "attachments" { | |||
| 459 | + | switch r.Method { | |||
| 460 | + | case http.MethodPost: | |||
| 461 | + | // Expected path: /secrets/{domain}/attachments/{service} | |||
| 462 | + | if len(parts) != 3 { | |||
| 463 | + | h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}") | |||
| 464 | + | return | |||
| 465 | + | } | |||
| 466 | + | service := parts[2] | |||
| 467 | + | h.handleAttachmentSecrets(w, r, secretDomain, service, "") | |||
| 468 | + | return | |||
| 469 | + | case http.MethodDelete: | |||
| 470 | + | // Expected path: /secrets/{domain}/attachments/{service}/{key} | |||
| 471 | + | if len(parts) != 4 { | |||
| 472 | + | h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}") | |||
| 473 | + | return | |||
| 474 | + | } | |||
| 475 | + | service := parts[2] | |||
| 476 | + | attachmentKey := parts[3] | |||
| 477 | + | h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey) | |||
| 478 | + | return | |||
| 479 | + | default: | |||
| 480 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 481 | + | return | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | ||||
| 459 | secretKey := "" | 485 | secretKey := "" | ||
| 460 | if len(parts) > 1 { | 486 | if len(parts) > 1 { | ||
| 461 | secretKey = parts[1] | 487 | secretKey = parts[1] | ||
| @@ -464,57 +490,66 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||||
| 464 | switch r.Method { | 490 | switch r.Method { | ||
| 465 | case http.MethodGet: | 491 | case http.MethodGet: | ||
| 466 | h.handleSecretsGet(w, r, secretDomain) | 492 | h.handleSecretsGet(w, r, secretDomain) | ||
| 467 | - | ||||
| 468 | case http.MethodPost: | 493 | case http.MethodPost: | ||
| 469 | - | // Check write permission | 494 | + | h.handleSecretsPost(w, r, secretDomain) |
| 470 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | 495 | + | case http.MethodDelete: |
| 471 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | 496 | + | h.handleSecretsDelete(w, r, secretDomain, secretKey) |
| 472 | - | return | 497 | + | default: |
| 473 | - | } | 498 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") |
| 499 | + | } | |||
| 500 | + | } | |||
| 474 | 501 | ||||
| 475 | - | // Limit request body size | 502 | + | // handleSecretsPost handles POST /admin/secrets/{domain} - set secrets. |
| 476 | - | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | 503 | + | func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) { |
| 504 | + | ctx := r.Context() | |||
| 505 | + | log := zerowrap.FromCtx(ctx) | |||
| 477 | 506 | ||||
| 478 | - | // Set secret(s) | 507 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { |
| 479 | - | var data map[string]string | 508 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") |
| 480 | - | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | 509 | + | return |
| 481 | - | log.Warn().Err(err).Msg("invalid secrets JSON") | 510 | + | } |
| 482 | - | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 483 | - | return | |||
| 484 | - | } | |||
| 485 | 511 | ||||
| 486 | - | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | 512 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) |
| 487 | - | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |||
| 488 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 489 | - | return | |||
| 490 | - | } | |||
| 491 | 513 | ||||
| 492 | - | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") | 514 | + | var data map[string]string |
| 493 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | 515 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { |
| 516 | + | log.Warn().Err(err).Msg("invalid secrets JSON") | |||
| 517 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 518 | + | return | |||
| 519 | + | } | |||
| 494 | 520 | ||||
| 495 | - | case http.MethodDelete: | 521 | + | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { |
| 496 | - | // Check write permission | 522 | + | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") |
| 497 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | 523 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") |
| 498 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | 524 | + | return |
| 499 | - | return | 525 | + | } |
| 500 | - | } | |||
| 501 | - | if secretKey == "" { | |||
| 502 | - | h.sendError(w, http.StatusBadRequest, "key required in path") | |||
| 503 | - | return | |||
| 504 | - | } | |||
| 505 | 526 | ||||
| 506 | - | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | 527 | + | log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set") |
| 507 | - | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | 528 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) |
| 508 | - | h.sendError(w, http.StatusBadRequest, "invalid domain") | 529 | + | } |
| 509 | - | return | |||
| 510 | - | } | |||
| 511 | 530 | ||||
| 512 | - | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | 531 | + | // handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret. |
| 513 | - | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | 532 | + | func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) { |
| 533 | + | ctx := r.Context() | |||
| 534 | + | log := zerowrap.FromCtx(ctx) | |||
| 514 | 535 | ||||
| 515 | - | default: | 536 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { |
| 516 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | 537 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") |
| 538 | + | return | |||
| 539 | + | } | |||
| 540 | + | if secretKey == "" { | |||
| 541 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |||
| 542 | + | return | |||
| 517 | } | 543 | } | ||
| 544 | + | ||||
| 545 | + | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |||
| 546 | + | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |||
| 547 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 548 | + | return | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted") | |||
| 552 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |||
| 518 | } | 553 | } | ||
| 519 | 554 | ||||
| 520 | // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets. | 555 | // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets. | ||
| @@ -552,6 +587,61 @@ func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secre | |||||
| 552 | }) | 587 | }) | ||
| 553 | } | 588 | } | ||
| 554 | 589 | ||||
| 590 | + | // handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints. | |||
| 591 | + | func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) { | |||
| 592 | + | ctx := r.Context() | |||
| 593 | + | log := zerowrap.FromCtx(ctx) | |||
| 594 | + | ||||
| 595 | + | switch r.Method { | |||
| 596 | + | case http.MethodPost: | |||
| 597 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 598 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 599 | + | return | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 603 | + | ||||
| 604 | + | var data map[string]string | |||
| 605 | + | if err := json.NewDecoder(r.Body).Decode(&data); err != nil { | |||
| 606 | + | log.Warn().Err(err).Msg("invalid attachment secrets JSON") | |||
| 607 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 608 | + | return | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil { | |||
| 612 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets") | |||
| 613 | + | h.sendError(w, http.StatusBadRequest, "invalid domain or service") | |||
| 614 | + | return | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set") | |||
| 618 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"}) | |||
| 619 | + | ||||
| 620 | + | case http.MethodDelete: | |||
| 621 | + | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 622 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 623 | + | return | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | if key == "" { | |||
| 627 | + | h.sendError(w, http.StatusBadRequest, "key required in path") | |||
| 628 | + | return | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil { | |||
| 632 | + | log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret") | |||
| 633 | + | h.sendError(w, http.StatusBadRequest, "invalid domain or service") | |||
| 634 | + | return | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted") | |||
| 638 | + | h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"}) | |||
| 639 | + | ||||
| 640 | + | default: | |||
| 641 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 642 | + | } | |||
| 643 | + | } | |||
| 644 | + | ||||
| 555 | // handleHealth handles /admin/health endpoint. | 645 | // handleHealth handles /admin/health endpoint. | ||
| 556 | // Returns detailed health status for all routes including HTTP probe results. | 646 | // Returns detailed health status for all routes including HTTP probe results. | ||
| 557 | func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { | 647 | func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { | ||
M internal/adapters/in/http/admin/handler_test.go +125 -0
| @@ -389,6 +389,59 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 389 | 389 | } | |
| 390 | 390 | } | |
| 391 | 391 | ||
| 392 | + | // Attachment secrets endpoint tests | |
| 393 | + | ||
| 394 | + | func TestHandler_AttachmentSecretsPost(t *testing.T) { | |
| 395 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 396 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 397 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 398 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 399 | + | ||
| 400 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 401 | + | ||
| 402 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil) | |
| 403 | + | ||
| 404 | + | body := `{"REDIS_URL": "redis://localhost:6379"}` | |
| 405 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | |
| 406 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| 407 | + | rec := httptest.NewRecorder() | |
| 408 | + | ||
| 409 | + | handler.ServeHTTP(rec, req) | |
| 410 | + | ||
| 411 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 412 | + | ||
| 413 | + | var response struct { | |
| 414 | + | Status string `json:"status"` | |
| 415 | + | } | |
| 416 | + | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 417 | + | assert.Equal(t, "updated", response.Status) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |
| 421 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 422 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 423 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 424 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 425 | + | ||
| 426 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 427 | + | ||
| 428 | + | secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil) | |
| 429 | + | ||
| 430 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | |
| 431 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| 432 | + | rec := httptest.NewRecorder() | |
| 433 | + | ||
| 434 | + | handler.ServeHTTP(rec, req) | |
| 435 | + | ||
| 436 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 437 | + | ||
| 438 | + | var response struct { | |
| 439 | + | Status string `json:"status"` | |
| 440 | + | } | |
| 441 | + | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 442 | + | assert.Equal(t, "deleted", response.Status) | |
| 443 | + | } | |
| 444 | + | ||
| 392 | 445 | // Status endpoint tests | |
| 393 | 446 | ||
| 394 | 447 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | |
| @@ -1040,6 +1093,78 @@ func TestHandler_Tags_Error(t *testing.T) { | |||
| 1040 | 1093 | assert.NotContains(t, rec.Body.String(), "registry error") | |
| 1041 | 1094 | } | |
| 1042 | 1095 | ||
| 1096 | + | func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) { | |
| 1097 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1098 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1099 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1100 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1101 | + | ||
| 1102 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 1103 | + | ||
| 1104 | + | body := `{"REDIS_URL": "redis://localhost:6379"}` | |
| 1105 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | |
| 1106 | + | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| 1107 | + | rec := httptest.NewRecorder() | |
| 1108 | + | ||
| 1109 | + | handler.ServeHTTP(rec, req) | |
| 1110 | + | ||
| 1111 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 1112 | + | } | |
| 1113 | + | ||
| 1114 | + | func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) { | |
| 1115 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1116 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1117 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1118 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1119 | + | ||
| 1120 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 1121 | + | ||
| 1122 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | |
| 1123 | + | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| 1124 | + | rec := httptest.NewRecorder() | |
| 1125 | + | ||
| 1126 | + | handler.ServeHTTP(rec, req) | |
| 1127 | + | ||
| 1128 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 1129 | + | } | |
| 1130 | + | ||
| 1131 | + | func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) { | |
| 1132 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1133 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1134 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1135 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1136 | + | ||
| 1137 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 1138 | + | ||
| 1139 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) | |
| 1140 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| 1141 | + | rec := httptest.NewRecorder() | |
| 1142 | + | ||
| 1143 | + | handler.ServeHTTP(rec, req) | |
| 1144 | + | ||
| 1145 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 1146 | + | assert.Contains(t, rec.Body.String(), "invalid attachment path") | |
| 1147 | + | } | |
| 1148 | + | ||
| 1149 | + | func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) { | |
| 1150 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1151 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1152 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1153 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1154 | + | ||
| 1155 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |
| 1156 | + | ||
| 1157 | + | body := `{not valid json` | |
| 1158 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | |
| 1159 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| 1160 | + | rec := httptest.NewRecorder() | |
| 1161 | + | ||
| 1162 | + | handler.ServeHTTP(rec, req) | |
| 1163 | + | ||
| 1164 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 1165 | + | assert.Contains(t, rec.Body.String(), "invalid JSON") | |
| 1166 | + | } | |
| 1167 | + | ||
| 1043 | 1168 | func TestHandler_NotFound(t *testing.T) { | |
| 1044 | 1169 | configSvc := inmocks.NewMockConfigService(t) | |
| 1045 | 1170 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -389,6 +389,59 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||||
| 389 | } | 389 | } | ||
| 390 | } | 390 | } | ||
| 391 | 391 | ||||
| 392 | + | // Attachment secrets endpoint tests | |||
| 393 | + | ||||
| 394 | + | func TestHandler_AttachmentSecretsPost(t *testing.T) { | |||
| 395 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 396 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 397 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 398 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 399 | + | ||||
| 400 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 401 | + | ||||
| 402 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil) | |||
| 403 | + | ||||
| 404 | + | body := `{"REDIS_URL": "redis://localhost:6379"}` | |||
| 405 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | |||
| 406 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |||
| 407 | + | rec := httptest.NewRecorder() | |||
| 408 | + | ||||
| 409 | + | handler.ServeHTTP(rec, req) | |||
| 410 | + | ||||
| 411 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 412 | + | ||||
| 413 | + | var response struct { | |||
| 414 | + | Status string `json:"status"` | |||
| 415 | + | } | |||
| 416 | + | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |||
| 417 | + | assert.Equal(t, "updated", response.Status) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |||
| 421 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 422 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 423 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 424 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 425 | + | ||||
| 426 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 427 | + | ||||
| 428 | + | secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil) | |||
| 429 | + | ||||
| 430 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | |||
| 431 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |||
| 432 | + | rec := httptest.NewRecorder() | |||
| 433 | + | ||||
| 434 | + | handler.ServeHTTP(rec, req) | |||
| 435 | + | ||||
| 436 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 437 | + | ||||
| 438 | + | var response struct { | |||
| 439 | + | Status string `json:"status"` | |||
| 440 | + | } | |||
| 441 | + | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |||
| 442 | + | assert.Equal(t, "deleted", response.Status) | |||
| 443 | + | } | |||
| 444 | + | ||||
| 392 | // Status endpoint tests | 445 | // Status endpoint tests | ||
| 393 | 446 | ||||
| 394 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | 447 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | ||
| @@ -1040,6 +1093,78 @@ func TestHandler_Tags_Error(t *testing.T) { | |||||
| 1040 | assert.NotContains(t, rec.Body.String(), "registry error") | 1093 | assert.NotContains(t, rec.Body.String(), "registry error") | ||
| 1041 | } | 1094 | } | ||
| 1042 | 1095 | ||||
| 1096 | + | func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 1097 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1098 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1099 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1100 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1101 | + | ||||
| 1102 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 1103 | + | ||||
| 1104 | + | body := `{"REDIS_URL": "redis://localhost:6379"}` | |||
| 1105 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | |||
| 1106 | + | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |||
| 1107 | + | rec := httptest.NewRecorder() | |||
| 1108 | + | ||||
| 1109 | + | handler.ServeHTTP(rec, req) | |||
| 1110 | + | ||||
| 1111 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 1112 | + | } | |||
| 1113 | + | ||||
| 1114 | + | func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 1115 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1116 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1117 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1118 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1119 | + | ||||
| 1120 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 1121 | + | ||||
| 1122 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | |||
| 1123 | + | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |||
| 1124 | + | rec := httptest.NewRecorder() | |||
| 1125 | + | ||||
| 1126 | + | handler.ServeHTTP(rec, req) | |||
| 1127 | + | ||||
| 1128 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 1129 | + | } | |||
| 1130 | + | ||||
| 1131 | + | func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) { | |||
| 1132 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1133 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1134 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1135 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1136 | + | ||||
| 1137 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 1138 | + | ||||
| 1139 | + | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) | |||
| 1140 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |||
| 1141 | + | rec := httptest.NewRecorder() | |||
| 1142 | + | ||||
| 1143 | + | handler.ServeHTTP(rec, req) | |||
| 1144 | + | ||||
| 1145 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 1146 | + | assert.Contains(t, rec.Body.String(), "invalid attachment path") | |||
| 1147 | + | } | |||
| 1148 | + | ||||
| 1149 | + | func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) { | |||
| 1150 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1151 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1152 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1153 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1154 | + | ||||
| 1155 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger()) | |||
| 1156 | + | ||||
| 1157 | + | body := `{not valid json` | |||
| 1158 | + | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | |||
| 1159 | + | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |||
| 1160 | + | rec := httptest.NewRecorder() | |||
| 1161 | + | ||||
| 1162 | + | handler.ServeHTTP(rec, req) | |||
| 1163 | + | ||||
| 1164 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 1165 | + | assert.Contains(t, rec.Body.String(), "invalid JSON") | |||
| 1166 | + | } | |||
| 1167 | + | ||||
| 1043 | func TestHandler_NotFound(t *testing.T) { | 1168 | func TestHandler_NotFound(t *testing.T) { | ||
| 1044 | configSvc := inmocks.NewMockConfigService(t) | 1169 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1045 | authSvc := inmocks.NewMockAuthService(t) | 1170 | authSvc := inmocks.NewMockAuthService(t) | ||
M internal/adapters/out/domainsecrets/pass_store.go +45 -0
| @@ -299,6 +299,51 @@ func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]st | |||
| 299 | 299 | return nil | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 303 | + | func (s *PassStore) DeleteAttachment(containerName, key string) error { | |
| 304 | + | if _, err := s.attachmentPath(containerName); err != nil { | |
| 305 | + | return err | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 309 | + | if err != nil { | |
| 310 | + | return err | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 314 | + | defer rmCancel() | |
| 315 | + | if err := s.passRemove(rmCtx, keyPath); err != nil { | |
| 316 | + | return err | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | keys, err := s.listAttachmentKeys(containerName) | |
| 320 | + | if err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | updated := make([]string, 0, len(keys)) | |
| 325 | + | for _, existingKey := range keys { | |
| 326 | + | if existingKey == key { | |
| 327 | + | continue | |
| 328 | + | } | |
| 329 | + | updated = append(updated, existingKey) | |
| 330 | + | } | |
| 331 | + | sort.Strings(updated) | |
| 332 | + | ||
| 333 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 334 | + | if err != nil { | |
| 335 | + | return err | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 339 | + | defer insertCancel() | |
| 340 | + | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |
| 341 | + | return fmt.Errorf("failed to update attachment manifest: %w", err) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 302 | 347 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 303 | 348 | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 304 | 349 | keys, err := s.listAttachmentKeys(containerName) | |
| @@ -299,6 +299,51 @@ func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]st | |||||
| 299 | return nil | 299 | return nil | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 303 | + | func (s *PassStore) DeleteAttachment(containerName, key string) error { | |||
| 304 | + | if _, err := s.attachmentPath(containerName); err != nil { | |||
| 305 | + | return err | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return err | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 314 | + | defer rmCancel() | |||
| 315 | + | if err := s.passRemove(rmCtx, keyPath); err != nil { | |||
| 316 | + | return err | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | keys, err := s.listAttachmentKeys(containerName) | |||
| 320 | + | if err != nil { | |||
| 321 | + | return err | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | updated := make([]string, 0, len(keys)) | |||
| 325 | + | for _, existingKey := range keys { | |||
| 326 | + | if existingKey == key { | |||
| 327 | + | continue | |||
| 328 | + | } | |||
| 329 | + | updated = append(updated, existingKey) | |||
| 330 | + | } | |||
| 331 | + | sort.Strings(updated) | |||
| 332 | + | ||||
| 333 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 334 | + | if err != nil { | |||
| 335 | + | return err | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 339 | + | defer insertCancel() | |||
| 340 | + | if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil { | |||
| 341 | + | return fmt.Errorf("failed to update attachment manifest: %w", err) | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | return nil | |||
| 345 | + | } | |||
| 346 | + | ||||
| 302 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | 347 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | ||
| 303 | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | 348 | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | ||
| 304 | keys, err := s.listAttachmentKeys(containerName) | 349 | keys, err := s.listAttachmentKeys(containerName) | ||
M internal/adapters/out/domainsecrets/pass_store_test.go +31 -0
| @@ -126,6 +126,37 @@ func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) { | |||
| 126 | 126 | assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | + | func TestPassStore_DeleteAttachment(t *testing.T) { | |
| 130 | + | requirePass(t) | |
| 131 | + | ||
| 132 | + | store, err := NewPassStore(testLogger()) | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | ||
| 135 | + | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |
| 136 | + | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |
| 137 | + | defer CleanupPassAttachment(t, containerName, keys) | |
| 138 | + | ||
| 139 | + | // Set 2 secrets | |
| 140 | + | secretsMap := map[string]string{ | |
| 141 | + | "POSTGRES_USER": "gitea", | |
| 142 | + | "POSTGRES_PASSWORD": "secret123", | |
| 143 | + | } | |
| 144 | + | err = store.SetAttachment(containerName, secretsMap) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | ||
| 147 | + | // Delete 1 | |
| 148 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | ||
| 151 | + | // Verify remaining via GetAllAttachment | |
| 152 | + | values, err := store.GetAllAttachment(containerName) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | assert.Len(t, values, 1) | |
| 155 | + | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |
| 156 | + | _, exists := values["POSTGRES_PASSWORD"] | |
| 157 | + | assert.False(t, exists) | |
| 158 | + | } | |
| 159 | + | ||
| 129 | 160 | func TestParsePassListOutput(t *testing.T) { | |
| 130 | 161 | tests := []struct { | |
| 131 | 162 | name string | |
| @@ -126,6 +126,37 @@ func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) { | |||||
| 126 | assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) | 126 | assert.Equal(t, "redis123", values["REDIS_PASSWORD"]) | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | + | func TestPassStore_DeleteAttachment(t *testing.T) { | |||
| 130 | + | requirePass(t) | |||
| 131 | + | ||||
| 132 | + | store, err := NewPassStore(testLogger()) | |||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | ||||
| 135 | + | containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano()) | |||
| 136 | + | keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"} | |||
| 137 | + | defer CleanupPassAttachment(t, containerName, keys) | |||
| 138 | + | ||||
| 139 | + | // Set 2 secrets | |||
| 140 | + | secretsMap := map[string]string{ | |||
| 141 | + | "POSTGRES_USER": "gitea", | |||
| 142 | + | "POSTGRES_PASSWORD": "secret123", | |||
| 143 | + | } | |||
| 144 | + | err = store.SetAttachment(containerName, secretsMap) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | ||||
| 147 | + | // Delete 1 | |||
| 148 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | ||||
| 151 | + | // Verify remaining via GetAllAttachment | |||
| 152 | + | values, err := store.GetAllAttachment(containerName) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | assert.Len(t, values, 1) | |||
| 155 | + | assert.Equal(t, "gitea", values["POSTGRES_USER"]) | |||
| 156 | + | _, exists := values["POSTGRES_PASSWORD"] | |||
| 157 | + | assert.False(t, exists) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 129 | func TestParsePassListOutput(t *testing.T) { | 160 | func TestParsePassListOutput(t *testing.T) { | ||
| 130 | tests := []struct { | 161 | tests := []struct { | ||
| 131 | name string | 162 | name string | ||
M internal/adapters/out/domainsecrets/store.go +20 -0
| @@ -176,6 +176,26 @@ func (s *FileStore) SetAttachment(containerName string, secrets map[string]strin | |||
| 176 | 176 | return s.writeAttachmentSecretsAtomic(containerName, existing) | |
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 180 | + | func (s *FileStore) DeleteAttachment(containerName, key string) error { | |
| 181 | + | // Validate container name first | |
| 182 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |
| 183 | + | return err | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Read existing secrets | |
| 187 | + | existing, err := s.GetAllAttachment(containerName) | |
| 188 | + | if err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Remove the key (no-op if not present) | |
| 193 | + | delete(existing, key) | |
| 194 | + | ||
| 195 | + | // Write back atomically | |
| 196 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |
| 197 | + | } | |
| 198 | + | ||
| 179 | 199 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 180 | 200 | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 181 | 201 | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| @@ -176,6 +176,26 @@ func (s *FileStore) SetAttachment(containerName string, secrets map[string]strin | |||||
| 176 | return s.writeAttachmentSecretsAtomic(containerName, existing) | 176 | return s.writeAttachmentSecretsAtomic(containerName, existing) | ||
| 177 | } | 177 | } | ||
| 178 | 178 | ||||
| 179 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 180 | + | func (s *FileStore) DeleteAttachment(containerName, key string) error { | |||
| 181 | + | // Validate container name first | |||
| 182 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |||
| 183 | + | return err | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Read existing secrets | |||
| 187 | + | existing, err := s.GetAllAttachment(containerName) | |||
| 188 | + | if err != nil { | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // Remove the key (no-op if not present) | |||
| 193 | + | delete(existing, key) | |||
| 194 | + | ||||
| 195 | + | // Write back atomically | |||
| 196 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 179 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | 199 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | ||
| 180 | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | 200 | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | ||
| 181 | envFile, err := s.validateAttachmentEnvFilePath(containerName) | 201 | envFile, err := s.validateAttachmentEnvFilePath(containerName) | ||
M internal/adapters/out/domainsecrets/store_test.go +40 -0
| @@ -336,6 +336,46 @@ func TestFileStore_ValidDomainOperations(t *testing.T) { | |||
| 336 | 336 | assert.False(t, exists) | |
| 337 | 337 | } | |
| 338 | 338 | ||
| 339 | + | func TestFileStore_DeleteAttachment(t *testing.T) { | |
| 340 | + | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 341 | + | require.NoError(t, err) | |
| 342 | + | defer os.RemoveAll(tmpDir) | |
| 343 | + | ||
| 344 | + | store, err := NewFileStore(tmpDir, testLogger()) | |
| 345 | + | require.NoError(t, err) | |
| 346 | + | ||
| 347 | + | containerName := "gitea-postgres" | |
| 348 | + | ||
| 349 | + | // Seed with two secrets | |
| 350 | + | err = store.SetAttachment(containerName, map[string]string{ | |
| 351 | + | "POSTGRES_USER": "gitea", | |
| 352 | + | "POSTGRES_PASSWORD": "secret123", | |
| 353 | + | }) | |
| 354 | + | require.NoError(t, err) | |
| 355 | + | ||
| 356 | + | // Delete one key | |
| 357 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 358 | + | require.NoError(t, err) | |
| 359 | + | ||
| 360 | + | // Verify remaining | |
| 361 | + | secrets, err := store.GetAllAttachment(containerName) | |
| 362 | + | require.NoError(t, err) | |
| 363 | + | assert.Len(t, secrets, 1) | |
| 364 | + | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |
| 365 | + | _, exists := secrets["POSTGRES_PASSWORD"] | |
| 366 | + | assert.False(t, exists) | |
| 367 | + | ||
| 368 | + | // Delete nonexistent key - should be no-op | |
| 369 | + | err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY") | |
| 370 | + | assert.NoError(t, err) | |
| 371 | + | ||
| 372 | + | // Verify secrets unchanged after no-op delete | |
| 373 | + | secrets, err = store.GetAllAttachment(containerName) | |
| 374 | + | require.NoError(t, err) | |
| 375 | + | assert.Len(t, secrets, 1) | |
| 376 | + | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |
| 377 | + | } | |
| 378 | + | ||
| 339 | 379 | func TestGetEnvFilePath(t *testing.T) { | |
| 340 | 380 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |
| 341 | 381 | require.NoError(t, err) | |
| @@ -336,6 +336,46 @@ func TestFileStore_ValidDomainOperations(t *testing.T) { | |||||
| 336 | assert.False(t, exists) | 336 | assert.False(t, exists) | ||
| 337 | } | 337 | } | ||
| 338 | 338 | ||||
| 339 | + | func TestFileStore_DeleteAttachment(t *testing.T) { | |||
| 340 | + | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | |||
| 341 | + | require.NoError(t, err) | |||
| 342 | + | defer os.RemoveAll(tmpDir) | |||
| 343 | + | ||||
| 344 | + | store, err := NewFileStore(tmpDir, testLogger()) | |||
| 345 | + | require.NoError(t, err) | |||
| 346 | + | ||||
| 347 | + | containerName := "gitea-postgres" | |||
| 348 | + | ||||
| 349 | + | // Seed with two secrets | |||
| 350 | + | err = store.SetAttachment(containerName, map[string]string{ | |||
| 351 | + | "POSTGRES_USER": "gitea", | |||
| 352 | + | "POSTGRES_PASSWORD": "secret123", | |||
| 353 | + | }) | |||
| 354 | + | require.NoError(t, err) | |||
| 355 | + | ||||
| 356 | + | // Delete one key | |||
| 357 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 358 | + | require.NoError(t, err) | |||
| 359 | + | ||||
| 360 | + | // Verify remaining | |||
| 361 | + | secrets, err := store.GetAllAttachment(containerName) | |||
| 362 | + | require.NoError(t, err) | |||
| 363 | + | assert.Len(t, secrets, 1) | |||
| 364 | + | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |||
| 365 | + | _, exists := secrets["POSTGRES_PASSWORD"] | |||
| 366 | + | assert.False(t, exists) | |||
| 367 | + | ||||
| 368 | + | // Delete nonexistent key - should be no-op | |||
| 369 | + | err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY") | |||
| 370 | + | assert.NoError(t, err) | |||
| 371 | + | ||||
| 372 | + | // Verify secrets unchanged after no-op delete | |||
| 373 | + | secrets, err = store.GetAllAttachment(containerName) | |||
| 374 | + | require.NoError(t, err) | |||
| 375 | + | assert.Len(t, secrets, 1) | |||
| 376 | + | assert.Equal(t, "gitea", secrets["POSTGRES_USER"]) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 339 | func TestGetEnvFilePath(t *testing.T) { | 379 | func TestGetEnvFilePath(t *testing.T) { | ||
| 340 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | 380 | tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*") | ||
| 341 | require.NoError(t, err) | 381 | require.NoError(t, err) | ||
M internal/boundaries/in/mocks/mock_secret_service.go +138 -0
| @@ -101,6 +101,75 @@ func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Conte | |||
| 101 | 101 | return _c | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | // DeleteAttachment provides a mock function for the type MockSecretService | |
| 105 | + | func (_mock *MockSecretService) DeleteAttachment(ctx context.Context, domain string, service string, key string) error { | |
| 106 | + | ret := _mock.Called(ctx, domain, service, key) | |
| 107 | + | ||
| 108 | + | if len(ret) == 0 { | |
| 109 | + | panic("no return value specified for DeleteAttachment") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | var r0 error | |
| 113 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { | |
| 114 | + | r0 = returnFunc(ctx, domain, service, key) | |
| 115 | + | } else { | |
| 116 | + | r0 = ret.Error(0) | |
| 117 | + | } | |
| 118 | + | return r0 | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // MockSecretService_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment' | |
| 122 | + | type MockSecretService_DeleteAttachment_Call struct { | |
| 123 | + | *mock.Call | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // DeleteAttachment is a helper method to define mock.On call | |
| 127 | + | // - ctx context.Context | |
| 128 | + | // - domain string | |
| 129 | + | // - service string | |
| 130 | + | // - key string | |
| 131 | + | func (_e *MockSecretService_Expecter) DeleteAttachment(ctx interface{}, domain interface{}, service interface{}, key interface{}) *MockSecretService_DeleteAttachment_Call { | |
| 132 | + | return &MockSecretService_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", ctx, domain, service, key)} | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (_c *MockSecretService_DeleteAttachment_Call) Run(run func(ctx context.Context, domain string, service string, key string)) *MockSecretService_DeleteAttachment_Call { | |
| 136 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 137 | + | var arg0 context.Context | |
| 138 | + | if args[0] != nil { | |
| 139 | + | arg0 = args[0].(context.Context) | |
| 140 | + | } | |
| 141 | + | var arg1 string | |
| 142 | + | if args[1] != nil { | |
| 143 | + | arg1 = args[1].(string) | |
| 144 | + | } | |
| 145 | + | var arg2 string | |
| 146 | + | if args[2] != nil { | |
| 147 | + | arg2 = args[2].(string) | |
| 148 | + | } | |
| 149 | + | var arg3 string | |
| 150 | + | if args[3] != nil { | |
| 151 | + | arg3 = args[3].(string) | |
| 152 | + | } | |
| 153 | + | run( | |
| 154 | + | arg0, | |
| 155 | + | arg1, | |
| 156 | + | arg2, | |
| 157 | + | arg3, | |
| 158 | + | ) | |
| 159 | + | }) | |
| 160 | + | return _c | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (_c *MockSecretService_DeleteAttachment_Call) Return(err error) *MockSecretService_DeleteAttachment_Call { | |
| 164 | + | _c.Call.Return(err) | |
| 165 | + | return _c | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (_c *MockSecretService_DeleteAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, key string) error) *MockSecretService_DeleteAttachment_Call { | |
| 169 | + | _c.Call.Return(run) | |
| 170 | + | return _c | |
| 171 | + | } | |
| 172 | + | ||
| 104 | 173 | // GetAll provides a mock function for the type MockSecretService | |
| 105 | 174 | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |
| 106 | 175 | ret := _mock.Called(ctx, domain) | |
| @@ -375,3 +444,72 @@ func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, | |||
| 375 | 444 | _c.Call.Return(run) | |
| 376 | 445 | return _c | |
| 377 | 446 | } | |
| 447 | + | ||
| 448 | + | // SetAttachment provides a mock function for the type MockSecretService | |
| 449 | + | func (_mock *MockSecretService) SetAttachment(ctx context.Context, domain string, service string, secrets map[string]string) error { | |
| 450 | + | ret := _mock.Called(ctx, domain, service, secrets) | |
| 451 | + | ||
| 452 | + | if len(ret) == 0 { | |
| 453 | + | panic("no return value specified for SetAttachment") | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | var r0 error | |
| 457 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) error); ok { | |
| 458 | + | r0 = returnFunc(ctx, domain, service, secrets) | |
| 459 | + | } else { | |
| 460 | + | r0 = ret.Error(0) | |
| 461 | + | } | |
| 462 | + | return r0 | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | // MockSecretService_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment' | |
| 466 | + | type MockSecretService_SetAttachment_Call struct { | |
| 467 | + | *mock.Call | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // SetAttachment is a helper method to define mock.On call | |
| 471 | + | // - ctx context.Context | |
| 472 | + | // - domain string | |
| 473 | + | // - service string | |
| 474 | + | // - secrets map[string]string | |
| 475 | + | func (_e *MockSecretService_Expecter) SetAttachment(ctx interface{}, domain interface{}, service interface{}, secrets interface{}) *MockSecretService_SetAttachment_Call { | |
| 476 | + | return &MockSecretService_SetAttachment_Call{Call: _e.mock.On("SetAttachment", ctx, domain, service, secrets)} | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | func (_c *MockSecretService_SetAttachment_Call) Run(run func(ctx context.Context, domain string, service string, secrets map[string]string)) *MockSecretService_SetAttachment_Call { | |
| 480 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 481 | + | var arg0 context.Context | |
| 482 | + | if args[0] != nil { | |
| 483 | + | arg0 = args[0].(context.Context) | |
| 484 | + | } | |
| 485 | + | var arg1 string | |
| 486 | + | if args[1] != nil { | |
| 487 | + | arg1 = args[1].(string) | |
| 488 | + | } | |
| 489 | + | var arg2 string | |
| 490 | + | if args[2] != nil { | |
| 491 | + | arg2 = args[2].(string) | |
| 492 | + | } | |
| 493 | + | var arg3 map[string]string | |
| 494 | + | if args[3] != nil { | |
| 495 | + | arg3 = args[3].(map[string]string) | |
| 496 | + | } | |
| 497 | + | run( | |
| 498 | + | arg0, | |
| 499 | + | arg1, | |
| 500 | + | arg2, | |
| 501 | + | arg3, | |
| 502 | + | ) | |
| 503 | + | }) | |
| 504 | + | return _c | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func (_c *MockSecretService_SetAttachment_Call) Return(err error) *MockSecretService_SetAttachment_Call { | |
| 508 | + | _c.Call.Return(err) | |
| 509 | + | return _c | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func (_c *MockSecretService_SetAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, secrets map[string]string) error) *MockSecretService_SetAttachment_Call { | |
| 513 | + | _c.Call.Return(run) | |
| 514 | + | return _c | |
| 515 | + | } | |
| @@ -101,6 +101,75 @@ func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Conte | |||||
| 101 | return _c | 101 | return _c | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | + | // DeleteAttachment provides a mock function for the type MockSecretService | |||
| 105 | + | func (_mock *MockSecretService) DeleteAttachment(ctx context.Context, domain string, service string, key string) error { | |||
| 106 | + | ret := _mock.Called(ctx, domain, service, key) | |||
| 107 | + | ||||
| 108 | + | if len(ret) == 0 { | |||
| 109 | + | panic("no return value specified for DeleteAttachment") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | var r0 error | |||
| 113 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { | |||
| 114 | + | r0 = returnFunc(ctx, domain, service, key) | |||
| 115 | + | } else { | |||
| 116 | + | r0 = ret.Error(0) | |||
| 117 | + | } | |||
| 118 | + | return r0 | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // MockSecretService_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment' | |||
| 122 | + | type MockSecretService_DeleteAttachment_Call struct { | |||
| 123 | + | *mock.Call | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // DeleteAttachment is a helper method to define mock.On call | |||
| 127 | + | // - ctx context.Context | |||
| 128 | + | // - domain string | |||
| 129 | + | // - service string | |||
| 130 | + | // - key string | |||
| 131 | + | func (_e *MockSecretService_Expecter) DeleteAttachment(ctx interface{}, domain interface{}, service interface{}, key interface{}) *MockSecretService_DeleteAttachment_Call { | |||
| 132 | + | return &MockSecretService_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", ctx, domain, service, key)} | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (_c *MockSecretService_DeleteAttachment_Call) Run(run func(ctx context.Context, domain string, service string, key string)) *MockSecretService_DeleteAttachment_Call { | |||
| 136 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 137 | + | var arg0 context.Context | |||
| 138 | + | if args[0] != nil { | |||
| 139 | + | arg0 = args[0].(context.Context) | |||
| 140 | + | } | |||
| 141 | + | var arg1 string | |||
| 142 | + | if args[1] != nil { | |||
| 143 | + | arg1 = args[1].(string) | |||
| 144 | + | } | |||
| 145 | + | var arg2 string | |||
| 146 | + | if args[2] != nil { | |||
| 147 | + | arg2 = args[2].(string) | |||
| 148 | + | } | |||
| 149 | + | var arg3 string | |||
| 150 | + | if args[3] != nil { | |||
| 151 | + | arg3 = args[3].(string) | |||
| 152 | + | } | |||
| 153 | + | run( | |||
| 154 | + | arg0, | |||
| 155 | + | arg1, | |||
| 156 | + | arg2, | |||
| 157 | + | arg3, | |||
| 158 | + | ) | |||
| 159 | + | }) | |||
| 160 | + | return _c | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (_c *MockSecretService_DeleteAttachment_Call) Return(err error) *MockSecretService_DeleteAttachment_Call { | |||
| 164 | + | _c.Call.Return(err) | |||
| 165 | + | return _c | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (_c *MockSecretService_DeleteAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, key string) error) *MockSecretService_DeleteAttachment_Call { | |||
| 169 | + | _c.Call.Return(run) | |||
| 170 | + | return _c | |||
| 171 | + | } | |||
| 172 | + | ||||
| 104 | // GetAll provides a mock function for the type MockSecretService | 173 | // GetAll provides a mock function for the type MockSecretService | ||
| 105 | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | 174 | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | ||
| 106 | ret := _mock.Called(ctx, domain) | 175 | ret := _mock.Called(ctx, domain) | ||
| @@ -375,3 +444,72 @@ func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, | |||||
| 375 | _c.Call.Return(run) | 444 | _c.Call.Return(run) | ||
| 376 | return _c | 445 | return _c | ||
| 377 | } | 446 | } | ||
| 447 | + | ||||
| 448 | + | // SetAttachment provides a mock function for the type MockSecretService | |||
| 449 | + | func (_mock *MockSecretService) SetAttachment(ctx context.Context, domain string, service string, secrets map[string]string) error { | |||
| 450 | + | ret := _mock.Called(ctx, domain, service, secrets) | |||
| 451 | + | ||||
| 452 | + | if len(ret) == 0 { | |||
| 453 | + | panic("no return value specified for SetAttachment") | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | var r0 error | |||
| 457 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) error); ok { | |||
| 458 | + | r0 = returnFunc(ctx, domain, service, secrets) | |||
| 459 | + | } else { | |||
| 460 | + | r0 = ret.Error(0) | |||
| 461 | + | } | |||
| 462 | + | return r0 | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | // MockSecretService_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment' | |||
| 466 | + | type MockSecretService_SetAttachment_Call struct { | |||
| 467 | + | *mock.Call | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // SetAttachment is a helper method to define mock.On call | |||
| 471 | + | // - ctx context.Context | |||
| 472 | + | // - domain string | |||
| 473 | + | // - service string | |||
| 474 | + | // - secrets map[string]string | |||
| 475 | + | func (_e *MockSecretService_Expecter) SetAttachment(ctx interface{}, domain interface{}, service interface{}, secrets interface{}) *MockSecretService_SetAttachment_Call { | |||
| 476 | + | return &MockSecretService_SetAttachment_Call{Call: _e.mock.On("SetAttachment", ctx, domain, service, secrets)} | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | func (_c *MockSecretService_SetAttachment_Call) Run(run func(ctx context.Context, domain string, service string, secrets map[string]string)) *MockSecretService_SetAttachment_Call { | |||
| 480 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 481 | + | var arg0 context.Context | |||
| 482 | + | if args[0] != nil { | |||
| 483 | + | arg0 = args[0].(context.Context) | |||
| 484 | + | } | |||
| 485 | + | var arg1 string | |||
| 486 | + | if args[1] != nil { | |||
| 487 | + | arg1 = args[1].(string) | |||
| 488 | + | } | |||
| 489 | + | var arg2 string | |||
| 490 | + | if args[2] != nil { | |||
| 491 | + | arg2 = args[2].(string) | |||
| 492 | + | } | |||
| 493 | + | var arg3 map[string]string | |||
| 494 | + | if args[3] != nil { | |||
| 495 | + | arg3 = args[3].(map[string]string) | |||
| 496 | + | } | |||
| 497 | + | run( | |||
| 498 | + | arg0, | |||
| 499 | + | arg1, | |||
| 500 | + | arg2, | |||
| 501 | + | arg3, | |||
| 502 | + | ) | |||
| 503 | + | }) | |||
| 504 | + | return _c | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func (_c *MockSecretService_SetAttachment_Call) Return(err error) *MockSecretService_SetAttachment_Call { | |||
| 508 | + | _c.Call.Return(err) | |||
| 509 | + | return _c | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | func (_c *MockSecretService_SetAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, secrets map[string]string) error) *MockSecretService_SetAttachment_Call { | |||
| 513 | + | _c.Call.Return(run) | |||
| 514 | + | return _c | |||
| 515 | + | } | |||
M internal/boundaries/in/secrets.go +8 -0
| @@ -28,4 +28,12 @@ type SecretService interface { | |||
| 28 | 28 | // Delete removes a specific secret key from a domain. | |
| 29 | 29 | // Returns an error if the domain is invalid. | |
| 30 | 30 | Delete(ctx context.Context, domain, key string) error | |
| 31 | + | ||
| 32 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 33 | + | // The container name is derived from the domain and service name. | |
| 34 | + | SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error | |
| 35 | + | ||
| 36 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 37 | + | // The container name is derived from the domain and service name. | |
| 38 | + | DeleteAttachment(ctx context.Context, domain, service, key string) error | |
| 31 | 39 | } | |
| @@ -28,4 +28,12 @@ type SecretService interface { | |||||
| 28 | // Delete removes a specific secret key from a domain. | 28 | // Delete removes a specific secret key from a domain. | ||
| 29 | // Returns an error if the domain is invalid. | 29 | // Returns an error if the domain is invalid. | ||
| 30 | Delete(ctx context.Context, domain, key string) error | 30 | Delete(ctx context.Context, domain, key string) error | ||
| 31 | + | ||||
| 32 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 33 | + | // The container name is derived from the domain and service name. | |||
| 34 | + | SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error | |||
| 35 | + | ||||
| 36 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 37 | + | // The container name is derived from the domain and service name. | |||
| 38 | + | DeleteAttachment(ctx context.Context, domain, service, key string) error | |||
| 31 | } | 39 | } | ||
M internal/boundaries/out/domain_secrets.go +3 -0
| @@ -29,6 +29,9 @@ type DomainSecretStore interface { | |||
| 29 | 29 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 30 | 30 | GetAllAttachment(containerName string) (map[string]string, error) | |
| 31 | 31 | ||
| 32 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 33 | + | DeleteAttachment(containerName, key string) error | |
| 34 | + | ||
| 32 | 35 | // ListAttachmentKeys finds and returns secret keys for attachment containers | |
| 33 | 36 | // associated with the given domain. Returns a list of AttachmentSecrets, one | |
| 34 | 37 | // for each attachment that has secrets configured. | |
| @@ -29,6 +29,9 @@ type DomainSecretStore interface { | |||||
| 29 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | 29 | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | ||
| 30 | GetAllAttachment(containerName string) (map[string]string, error) | 30 | GetAllAttachment(containerName string) (map[string]string, error) | ||
| 31 | 31 | ||||
| 32 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 33 | + | DeleteAttachment(containerName, key string) error | |||
| 34 | + | ||||
| 32 | // ListAttachmentKeys finds and returns secret keys for attachment containers | 35 | // ListAttachmentKeys finds and returns secret keys for attachment containers | ||
| 33 | // associated with the given domain. Returns a list of AttachmentSecrets, one | 36 | // associated with the given domain. Returns a list of AttachmentSecrets, one | ||
| 34 | // for each attachment that has secrets configured. | 37 | // for each attachment that has secrets configured. | ||
M internal/boundaries/out/mocks/mock_domain_secret_store.go +57 -0
| @@ -93,6 +93,63 @@ func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string | |||
| 93 | 93 | return _c | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | // DeleteAttachment provides a mock function for the type MockDomainSecretStore | |
| 97 | + | func (_mock *MockDomainSecretStore) DeleteAttachment(containerName string, key string) error { | |
| 98 | + | ret := _mock.Called(containerName, key) | |
| 99 | + | ||
| 100 | + | if len(ret) == 0 { | |
| 101 | + | panic("no return value specified for DeleteAttachment") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | var r0 error | |
| 105 | + | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |
| 106 | + | r0 = returnFunc(containerName, key) | |
| 107 | + | } else { | |
| 108 | + | r0 = ret.Error(0) | |
| 109 | + | } | |
| 110 | + | return r0 | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // MockDomainSecretStore_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment' | |
| 114 | + | type MockDomainSecretStore_DeleteAttachment_Call struct { | |
| 115 | + | *mock.Call | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // DeleteAttachment is a helper method to define mock.On call | |
| 119 | + | // - containerName string | |
| 120 | + | // - key string | |
| 121 | + | func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName interface{}, key interface{}) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 122 | + | return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)} | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Run(run func(containerName string, key string)) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 126 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 127 | + | var arg0 string | |
| 128 | + | if args[0] != nil { | |
| 129 | + | arg0 = args[0].(string) | |
| 130 | + | } | |
| 131 | + | var arg1 string | |
| 132 | + | if args[1] != nil { | |
| 133 | + | arg1 = args[1].(string) | |
| 134 | + | } | |
| 135 | + | run( | |
| 136 | + | arg0, | |
| 137 | + | arg1, | |
| 138 | + | ) | |
| 139 | + | }) | |
| 140 | + | return _c | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Return(err error) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 144 | + | _c.Call.Return(err) | |
| 145 | + | return _c | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (_c *MockDomainSecretStore_DeleteAttachment_Call) RunAndReturn(run func(containerName string, key string) error) *MockDomainSecretStore_DeleteAttachment_Call { | |
| 149 | + | _c.Call.Return(run) | |
| 150 | + | return _c | |
| 151 | + | } | |
| 152 | + | ||
| 96 | 153 | // GetAll provides a mock function for the type MockDomainSecretStore | |
| 97 | 154 | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | |
| 98 | 155 | ret := _mock.Called(domain) | |
| @@ -93,6 +93,63 @@ func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string | |||||
| 93 | return _c | 93 | return _c | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | // DeleteAttachment provides a mock function for the type MockDomainSecretStore | |||
| 97 | + | func (_mock *MockDomainSecretStore) DeleteAttachment(containerName string, key string) error { | |||
| 98 | + | ret := _mock.Called(containerName, key) | |||
| 99 | + | ||||
| 100 | + | if len(ret) == 0 { | |||
| 101 | + | panic("no return value specified for DeleteAttachment") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | var r0 error | |||
| 105 | + | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |||
| 106 | + | r0 = returnFunc(containerName, key) | |||
| 107 | + | } else { | |||
| 108 | + | r0 = ret.Error(0) | |||
| 109 | + | } | |||
| 110 | + | return r0 | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // MockDomainSecretStore_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment' | |||
| 114 | + | type MockDomainSecretStore_DeleteAttachment_Call struct { | |||
| 115 | + | *mock.Call | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // DeleteAttachment is a helper method to define mock.On call | |||
| 119 | + | // - containerName string | |||
| 120 | + | // - key string | |||
| 121 | + | func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName interface{}, key interface{}) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 122 | + | return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)} | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Run(run func(containerName string, key string)) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 126 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 127 | + | var arg0 string | |||
| 128 | + | if args[0] != nil { | |||
| 129 | + | arg0 = args[0].(string) | |||
| 130 | + | } | |||
| 131 | + | var arg1 string | |||
| 132 | + | if args[1] != nil { | |||
| 133 | + | arg1 = args[1].(string) | |||
| 134 | + | } | |||
| 135 | + | run( | |||
| 136 | + | arg0, | |||
| 137 | + | arg1, | |||
| 138 | + | ) | |||
| 139 | + | }) | |||
| 140 | + | return _c | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (_c *MockDomainSecretStore_DeleteAttachment_Call) Return(err error) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 144 | + | _c.Call.Return(err) | |||
| 145 | + | return _c | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (_c *MockDomainSecretStore_DeleteAttachment_Call) RunAndReturn(run func(containerName string, key string) error) *MockDomainSecretStore_DeleteAttachment_Call { | |||
| 149 | + | _c.Call.Return(run) | |||
| 150 | + | return _c | |||
| 151 | + | } | |||
| 152 | + | ||||
| 96 | // GetAll provides a mock function for the type MockDomainSecretStore | 153 | // GetAll provides a mock function for the type MockDomainSecretStore | ||
| 97 | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | 154 | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | ||
| 98 | ret := _mock.Called(domain) | 155 | ret := _mock.Called(domain) | ||
M internal/usecase/secrets/service.go +111 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/zerowrap" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // Domain validation errors. | |
| @@ -17,6 +18,8 @@ var ( | |||
| 17 | 18 | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | |
| 18 | 19 | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | |
| 19 | 20 | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | |
| 21 | + | ErrServiceEmpty = errors.New("service name cannot be empty") | |
| 22 | + | ErrInvalidServiceName = errors.New("invalid service name: must start with a letter, contain only lowercase letters, numbers, and hyphens, be at most 63 characters, and not end with a hyphen") | |
| 20 | 23 | ) | |
| 21 | 24 | ||
| 22 | 25 | // Service implements the SecretService interface. | |
| @@ -165,6 +168,114 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||
| 165 | 168 | return nil | |
| 166 | 169 | } | |
| 167 | 170 | ||
| 171 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 172 | + | func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error { | |
| 173 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 174 | + | zerowrap.FieldLayer: "usecase", | |
| 175 | + | zerowrap.FieldUseCase: "SetAttachment", | |
| 176 | + | "domain": domainName, | |
| 177 | + | "service": service, | |
| 178 | + | }) | |
| 179 | + | log := zerowrap.FromCtx(ctx) | |
| 180 | + | ||
| 181 | + | if err := ValidateDomain(domainName); err != nil { | |
| 182 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 183 | + | return err | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | if err := validateServiceName(service); err != nil { | |
| 187 | + | log.Warn().Err(err).Msg("service name validation failed") | |
| 188 | + | return err | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | containerName := resolveContainerName(domainName, service) | |
| 192 | + | ||
| 193 | + | if err := s.store.SetAttachment(containerName, secrets); err != nil { | |
| 194 | + | log.Error().Err(err).Msg("failed to set attachment secrets") | |
| 195 | + | return err | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |
| 203 | + | func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error { | |
| 204 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 205 | + | zerowrap.FieldLayer: "usecase", | |
| 206 | + | zerowrap.FieldUseCase: "DeleteAttachment", | |
| 207 | + | "domain": domainName, | |
| 208 | + | "service": service, | |
| 209 | + | "key": key, | |
| 210 | + | }) | |
| 211 | + | log := zerowrap.FromCtx(ctx) | |
| 212 | + | ||
| 213 | + | if err := ValidateDomain(domainName); err != nil { | |
| 214 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 215 | + | return err | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | if err := validateServiceName(service); err != nil { | |
| 219 | + | log.Warn().Err(err).Msg("service name validation failed") | |
| 220 | + | return err | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | containerName := resolveContainerName(domainName, service) | |
| 224 | + | ||
| 225 | + | if err := s.store.DeleteAttachment(containerName, key); err != nil { | |
| 226 | + | log.Error().Err(err).Msg("failed to delete attachment secret") | |
| 227 | + | return err | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | log.Info().Str("container", containerName).Msg("attachment secret deleted") | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // validateServiceName checks that a service name is safe and follows Docker-style conventions. | |
| 235 | + | func validateServiceName(service string) error { | |
| 236 | + | if service == "" { | |
| 237 | + | return ErrServiceEmpty | |
| 238 | + | } | |
| 239 | + | // DNS labels are limited to 63 characters (RFC 1035) | |
| 240 | + | if len(service) > 63 { | |
| 241 | + | return ErrInvalidServiceName | |
| 242 | + | } | |
| 243 | + | // Docker-style: lowercase letter start, then lowercase alphanumeric + hyphens | |
| 244 | + | for i, c := range service { | |
| 245 | + | if i == 0 { | |
| 246 | + | if c < 'a' || c > 'z' { | |
| 247 | + | return ErrInvalidServiceName | |
| 248 | + | } | |
| 249 | + | continue | |
| 250 | + | } | |
| 251 | + | if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { | |
| 252 | + | return ErrInvalidServiceName | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | // Trailing hyphen is not allowed in DNS labels | |
| 256 | + | if service[len(service)-1] == '-' { | |
| 257 | + | return ErrInvalidServiceName | |
| 258 | + | } | |
| 259 | + | return nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // resolveContainerName builds the container name from a domain and service. | |
| 263 | + | // Ensures the total name does not exceed Docker's 255-character limit. | |
| 264 | + | func resolveContainerName(domainName, service string) string { | |
| 265 | + | sanitized := domain.SanitizeDomainForContainer(domainName) | |
| 266 | + | name := "gordon-" + sanitized + "-" + service | |
| 267 | + | ||
| 268 | + | // Docker container names are limited to 255 characters | |
| 269 | + | if len(name) > 255 { | |
| 270 | + | // Truncate the domain part to fit, keeping room for "gordon-", "-", and service name | |
| 271 | + | maxDomainLen := 255 - 7 - 1 - len(service) // 7 for "gordon-", 1 for "-" | |
| 272 | + | if maxDomainLen > 0 { | |
| 273 | + | name = "gordon-" + sanitized[:maxDomainLen] + "-" + service | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | return name | |
| 277 | + | } | |
| 278 | + | ||
| 168 | 279 | // ValidateDomain validates that a domain is safe to use for secret storage. | |
| 169 | 280 | // This prevents path traversal attacks and ensures the domain is valid. | |
| 170 | 281 | func ValidateDomain(domain string) error { | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/gordon/internal/boundaries/out" | 11 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | // Domain validation errors. | 15 | // Domain validation errors. | ||
| @@ -17,6 +18,8 @@ var ( | |||||
| 17 | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | 18 | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | ||
| 18 | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | 19 | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | ||
| 19 | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | 20 | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | ||
| 21 | + | ErrServiceEmpty = errors.New("service name cannot be empty") | |||
| 22 | + | ErrInvalidServiceName = errors.New("invalid service name: must start with a letter, contain only lowercase letters, numbers, and hyphens, be at most 63 characters, and not end with a hyphen") | |||
| 20 | ) | 23 | ) | ||
| 21 | 24 | ||||
| 22 | // Service implements the SecretService interface. | 25 | // Service implements the SecretService interface. | ||
| @@ -165,6 +168,114 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||||
| 165 | return nil | 168 | return nil | ||
| 166 | } | 169 | } | ||
| 167 | 170 | ||||
| 171 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 172 | + | func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error { | |||
| 173 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 174 | + | zerowrap.FieldLayer: "usecase", | |||
| 175 | + | zerowrap.FieldUseCase: "SetAttachment", | |||
| 176 | + | "domain": domainName, | |||
| 177 | + | "service": service, | |||
| 178 | + | }) | |||
| 179 | + | log := zerowrap.FromCtx(ctx) | |||
| 180 | + | ||||
| 181 | + | if err := ValidateDomain(domainName); err != nil { | |||
| 182 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 183 | + | return err | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | if err := validateServiceName(service); err != nil { | |||
| 187 | + | log.Warn().Err(err).Msg("service name validation failed") | |||
| 188 | + | return err | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | containerName := resolveContainerName(domainName, service) | |||
| 192 | + | ||||
| 193 | + | if err := s.store.SetAttachment(containerName, secrets); err != nil { | |||
| 194 | + | log.Error().Err(err).Msg("failed to set attachment secrets") | |||
| 195 | + | return err | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | |||
| 199 | + | return nil | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // DeleteAttachment removes a specific secret key from an attachment container. | |||
| 203 | + | func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error { | |||
| 204 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 205 | + | zerowrap.FieldLayer: "usecase", | |||
| 206 | + | zerowrap.FieldUseCase: "DeleteAttachment", | |||
| 207 | + | "domain": domainName, | |||
| 208 | + | "service": service, | |||
| 209 | + | "key": key, | |||
| 210 | + | }) | |||
| 211 | + | log := zerowrap.FromCtx(ctx) | |||
| 212 | + | ||||
| 213 | + | if err := ValidateDomain(domainName); err != nil { | |||
| 214 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 215 | + | return err | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | if err := validateServiceName(service); err != nil { | |||
| 219 | + | log.Warn().Err(err).Msg("service name validation failed") | |||
| 220 | + | return err | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | containerName := resolveContainerName(domainName, service) | |||
| 224 | + | ||||
| 225 | + | if err := s.store.DeleteAttachment(containerName, key); err != nil { | |||
| 226 | + | log.Error().Err(err).Msg("failed to delete attachment secret") | |||
| 227 | + | return err | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | log.Info().Str("container", containerName).Msg("attachment secret deleted") | |||
| 231 | + | return nil | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // validateServiceName checks that a service name is safe and follows Docker-style conventions. | |||
| 235 | + | func validateServiceName(service string) error { | |||
| 236 | + | if service == "" { | |||
| 237 | + | return ErrServiceEmpty | |||
| 238 | + | } | |||
| 239 | + | // DNS labels are limited to 63 characters (RFC 1035) | |||
| 240 | + | if len(service) > 63 { | |||
| 241 | + | return ErrInvalidServiceName | |||
| 242 | + | } | |||
| 243 | + | // Docker-style: lowercase letter start, then lowercase alphanumeric + hyphens | |||
| 244 | + | for i, c := range service { | |||
| 245 | + | if i == 0 { | |||
| 246 | + | if c < 'a' || c > 'z' { | |||
| 247 | + | return ErrInvalidServiceName | |||
| 248 | + | } | |||
| 249 | + | continue | |||
| 250 | + | } | |||
| 251 | + | if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { | |||
| 252 | + | return ErrInvalidServiceName | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | // Trailing hyphen is not allowed in DNS labels | |||
| 256 | + | if service[len(service)-1] == '-' { | |||
| 257 | + | return ErrInvalidServiceName | |||
| 258 | + | } | |||
| 259 | + | return nil | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // resolveContainerName builds the container name from a domain and service. | |||
| 263 | + | // Ensures the total name does not exceed Docker's 255-character limit. | |||
| 264 | + | func resolveContainerName(domainName, service string) string { | |||
| 265 | + | sanitized := domain.SanitizeDomainForContainer(domainName) | |||
| 266 | + | name := "gordon-" + sanitized + "-" + service | |||
| 267 | + | ||||
| 268 | + | // Docker container names are limited to 255 characters | |||
| 269 | + | if len(name) > 255 { | |||
| 270 | + | // Truncate the domain part to fit, keeping room for "gordon-", "-", and service name | |||
| 271 | + | maxDomainLen := 255 - 7 - 1 - len(service) // 7 for "gordon-", 1 for "-" | |||
| 272 | + | if maxDomainLen > 0 { | |||
| 273 | + | name = "gordon-" + sanitized[:maxDomainLen] + "-" + service | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | return name | |||
| 277 | + | } | |||
| 278 | + | ||||
| 168 | // ValidateDomain validates that a domain is safe to use for secret storage. | 279 | // ValidateDomain validates that a domain is safe to use for secret storage. | ||
| 169 | // This prevents path traversal attacks and ensures the domain is valid. | 280 | // This prevents path traversal attacks and ensures the domain is valid. | ||
| 170 | func ValidateDomain(domain string) error { | 281 | func ValidateDomain(domain string) error { | ||
M internal/usecase/secrets/service_test.go +120 -0
| @@ -459,3 +459,123 @@ func TestService_ValidationHappensBeforeStore(t *testing.T) { | |||
| 459 | 459 | // Verify no store methods were called (mockery would fail if unexpected calls happened) | |
| 460 | 460 | mock.AssertExpectationsForObjects(t, store) | |
| 461 | 461 | } | |
| 462 | + | ||
| 463 | + | func TestService_SetAttachment(t *testing.T) { | |
| 464 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 465 | + | svc := NewService(store, testLogger()) | |
| 466 | + | ||
| 467 | + | secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"} | |
| 468 | + | store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil) | |
| 469 | + | ||
| 470 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets) | |
| 471 | + | assert.NoError(t, err) | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func TestService_DeleteAttachment(t *testing.T) { | |
| 475 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 476 | + | svc := NewService(store, testLogger()) | |
| 477 | + | ||
| 478 | + | store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil) | |
| 479 | + | ||
| 480 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY") | |
| 481 | + | assert.NoError(t, err) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func TestService_SetAttachment_InvalidDomain(t *testing.T) { | |
| 485 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 486 | + | svc := NewService(store, testLogger()) | |
| 487 | + | ||
| 488 | + | err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"}) | |
| 489 | + | assert.ErrorIs(t, err, ErrDomainEmpty) | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func TestService_SetAttachment_EmptyService(t *testing.T) { | |
| 493 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 494 | + | svc := NewService(store, testLogger()) | |
| 495 | + | ||
| 496 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"}) | |
| 497 | + | assert.ErrorIs(t, err, ErrServiceEmpty) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func TestService_DeleteAttachment_InvalidDomain(t *testing.T) { | |
| 501 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 502 | + | svc := NewService(store, testLogger()) | |
| 503 | + | ||
| 504 | + | err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY") | |
| 505 | + | assert.ErrorIs(t, err, ErrDomainEmpty) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | func TestService_DeleteAttachment_EmptyService(t *testing.T) { | |
| 509 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 510 | + | svc := NewService(store, testLogger()) | |
| 511 | + | ||
| 512 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY") | |
| 513 | + | assert.ErrorIs(t, err, ErrServiceEmpty) | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | func TestService_SetAttachment_InvalidServiceName(t *testing.T) { | |
| 517 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 518 | + | svc := NewService(store, testLogger()) | |
| 519 | + | ||
| 520 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"}) | |
| 521 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) { | |
| 525 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 526 | + | svc := NewService(store, testLogger()) | |
| 527 | + | ||
| 528 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY") | |
| 529 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) { | |
| 533 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 534 | + | svc := NewService(store, testLogger()) | |
| 535 | + | ||
| 536 | + | // Service name exceeds DNS label limit of 63 characters | |
| 537 | + | longService := strings.Repeat("a", 64) | |
| 538 | + | err := svc.SetAttachment(context.Background(), "app.example.com", longService, map[string]string{"K": "V"}) | |
| 539 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |
| 543 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 544 | + | svc := NewService(store, testLogger()) | |
| 545 | + | ||
| 546 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"}) | |
| 547 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) { | |
| 551 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 552 | + | svc := NewService(store, testLogger()) | |
| 553 | + | ||
| 554 | + | // Service name exceeds DNS label limit of 63 characters | |
| 555 | + | longService := strings.Repeat("a", 64) | |
| 556 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", longService, "SOME_KEY") | |
| 557 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |
| 561 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 562 | + | svc := NewService(store, testLogger()) | |
| 563 | + | ||
| 564 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY") | |
| 565 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) { | |
| 569 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 570 | + | svc := NewService(store, testLogger()) | |
| 571 | + | ||
| 572 | + | // With invalid service name, store methods should never be called | |
| 573 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"}) | |
| 574 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 575 | + | ||
| 576 | + | err = svc.DeleteAttachment(context.Background(), "app.example.com", "../evil", "key") | |
| 577 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| 578 | + | ||
| 579 | + | // Verify no store methods were called | |
| 580 | + | mock.AssertExpectationsForObjects(t, store) | |
| 581 | + | } | |
| @@ -459,3 +459,123 @@ func TestService_ValidationHappensBeforeStore(t *testing.T) { | |||||
| 459 | // Verify no store methods were called (mockery would fail if unexpected calls happened) | 459 | // Verify no store methods were called (mockery would fail if unexpected calls happened) | ||
| 460 | mock.AssertExpectationsForObjects(t, store) | 460 | mock.AssertExpectationsForObjects(t, store) | ||
| 461 | } | 461 | } | ||
| 462 | + | ||||
| 463 | + | func TestService_SetAttachment(t *testing.T) { | |||
| 464 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 465 | + | svc := NewService(store, testLogger()) | |||
| 466 | + | ||||
| 467 | + | secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"} | |||
| 468 | + | store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil) | |||
| 469 | + | ||||
| 470 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets) | |||
| 471 | + | assert.NoError(t, err) | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | func TestService_DeleteAttachment(t *testing.T) { | |||
| 475 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 476 | + | svc := NewService(store, testLogger()) | |||
| 477 | + | ||||
| 478 | + | store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil) | |||
| 479 | + | ||||
| 480 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY") | |||
| 481 | + | assert.NoError(t, err) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | func TestService_SetAttachment_InvalidDomain(t *testing.T) { | |||
| 485 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 486 | + | svc := NewService(store, testLogger()) | |||
| 487 | + | ||||
| 488 | + | err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"}) | |||
| 489 | + | assert.ErrorIs(t, err, ErrDomainEmpty) | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func TestService_SetAttachment_EmptyService(t *testing.T) { | |||
| 493 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 494 | + | svc := NewService(store, testLogger()) | |||
| 495 | + | ||||
| 496 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"}) | |||
| 497 | + | assert.ErrorIs(t, err, ErrServiceEmpty) | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func TestService_DeleteAttachment_InvalidDomain(t *testing.T) { | |||
| 501 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 502 | + | svc := NewService(store, testLogger()) | |||
| 503 | + | ||||
| 504 | + | err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY") | |||
| 505 | + | assert.ErrorIs(t, err, ErrDomainEmpty) | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | func TestService_DeleteAttachment_EmptyService(t *testing.T) { | |||
| 509 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 510 | + | svc := NewService(store, testLogger()) | |||
| 511 | + | ||||
| 512 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY") | |||
| 513 | + | assert.ErrorIs(t, err, ErrServiceEmpty) | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | func TestService_SetAttachment_InvalidServiceName(t *testing.T) { | |||
| 517 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 518 | + | svc := NewService(store, testLogger()) | |||
| 519 | + | ||||
| 520 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"}) | |||
| 521 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) { | |||
| 525 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 526 | + | svc := NewService(store, testLogger()) | |||
| 527 | + | ||||
| 528 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY") | |||
| 529 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) { | |||
| 533 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 534 | + | svc := NewService(store, testLogger()) | |||
| 535 | + | ||||
| 536 | + | // Service name exceeds DNS label limit of 63 characters | |||
| 537 | + | longService := strings.Repeat("a", 64) | |||
| 538 | + | err := svc.SetAttachment(context.Background(), "app.example.com", longService, map[string]string{"K": "V"}) | |||
| 539 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |||
| 543 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 544 | + | svc := NewService(store, testLogger()) | |||
| 545 | + | ||||
| 546 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"}) | |||
| 547 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) { | |||
| 551 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 552 | + | svc := NewService(store, testLogger()) | |||
| 553 | + | ||||
| 554 | + | // Service name exceeds DNS label limit of 63 characters | |||
| 555 | + | longService := strings.Repeat("a", 64) | |||
| 556 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", longService, "SOME_KEY") | |||
| 557 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |||
| 561 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 562 | + | svc := NewService(store, testLogger()) | |||
| 563 | + | ||||
| 564 | + | err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY") | |||
| 565 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) { | |||
| 569 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 570 | + | svc := NewService(store, testLogger()) | |||
| 571 | + | ||||
| 572 | + | // With invalid service name, store methods should never be called | |||
| 573 | + | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"}) | |||
| 574 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 575 | + | ||||
| 576 | + | err = svc.DeleteAttachment(context.Background(), "app.example.com", "../evil", "key") | |||
| 577 | + | assert.ErrorIs(t, err, ErrInvalidServiceName) | |||
| 578 | + | ||||
| 579 | + | // Verify no store methods were called | |||
| 580 | + | mock.AssertExpectationsForObjects(t, store) | |||
| 581 | + | } | |||