cli: auto infer remote targets from saved remotes
- commit
- 2154bd4d436cd9d43702e2106f60e0e74af1b2b3
- author
- brice · · 2026-05-15 07:08 UTC
- parent
- 8d0a092
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +1003 -187
M docs/cli/attachments.md +3 -0
| @@ -3,6 +3,9 @@ | |||
| 3 | 3 | Manage attachments on local or remote Gordon instances. | |
| 4 | 4 | ||
| 5 | 5 | Remote targeting uses client config or an active remote by default. | |
| 6 | + | When you provide a concrete target (for example `attachments list app.example.com` | |
| 7 | + | or `attachments remove app.example.com postgres:18`) and no remote is selected, | |
| 8 | + | Gordon can also auto-infer a saved remote when exactly one match is found. | |
| 6 | 9 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 7 | 10 | ||
| 8 | 11 | ## Requirements | |
| @@ -3,6 +3,9 @@ | |||||
| 3 | Manage attachments on local or remote Gordon instances. | 3 | Manage attachments on local or remote Gordon instances. | ||
| 4 | 4 | ||||
| 5 | Remote targeting uses client config or an active remote by default. | 5 | Remote targeting uses client config or an active remote by default. | ||
| 6 | + | When you provide a concrete target (for example `attachments list app.example.com` | |||
| 7 | + | or `attachments remove app.example.com postgres:18`) and no remote is selected, | |||
| 8 | + | Gordon can also auto-infer a saved remote when exactly one match is found. | |||
| 6 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | 9 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | ||
| 7 | 10 | ||||
| 8 | ## Requirements | 11 | ## Requirements | ||
M docs/cli/index.md +23 -0
| @@ -168,6 +168,25 @@ The CLI can target remote Gordon instances using client config, an active remote | |||
| 168 | 168 | or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides | |
| 169 | 169 | when you want to bypass your saved configuration. | |
| 170 | 170 | ||
| 171 | + | When no explicit remote is selected and no active remote is configured, Gordon can now | |
| 172 | + | **auto-infer a saved remote** for commands that already have a concrete target. It probes your | |
| 173 | + | saved remotes and uses the remote automatically when exactly one matches. If multiple remotes | |
| 174 | + | match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe | |
| 175 | + | fails, Gordon also stops rather than guessing. | |
| 176 | + | ||
| 177 | + | Auto-inference currently applies to target-based commands such as: | |
| 178 | + | - `gordon push` | |
| 179 | + | - `gordon attachments push` | |
| 180 | + | - `gordon deploy <domain>` | |
| 181 | + | - `gordon restart <domain>` | |
| 182 | + | - `gordon pin <domain>` / `gordon pin list <domain>` | |
| 183 | + | - `gordon routes show <domain>` / `gordon routes remove <domain>` | |
| 184 | + | - `gordon secrets list|set|remove <domain>` | |
| 185 | + | - `gordon attachments list <target>` / `gordon attachments add|remove <target> ...` | |
| 186 | + | - `gordon backups list <domain>` / `gordon backups run <domain>` / `gordon backups detect <domain>` | |
| 187 | + | - `gordon images tags <repository>` | |
| 188 | + | - `gordon logs <domain>` | |
| 189 | + | ||
| 171 | 190 | `gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote` | |
| 172 | 191 | nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either | |
| 173 | 192 | one to force a single target. | |
| @@ -180,6 +199,10 @@ gordon routes status | |||
| 180 | 199 | # Single-target override | |
| 181 | 200 | gordon routes list --remote prod | |
| 182 | 201 | GORDON_REMOTE=prod gordon routes status | |
| 202 | + | ||
| 203 | + | # Auto-inferred single match from saved remotes | |
| 204 | + | gordon push myapp --build | |
| 205 | + | gordon deploy app.example.com | |
| 183 | 206 | ``` | |
| 184 | 207 | ||
| 185 | 208 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | |
| @@ -168,6 +168,25 @@ The CLI can target remote Gordon instances using client config, an active remote | |||||
| 168 | or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides | 168 | or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides | ||
| 169 | when you want to bypass your saved configuration. | 169 | when you want to bypass your saved configuration. | ||
| 170 | 170 | ||||
| 171 | + | When no explicit remote is selected and no active remote is configured, Gordon can now | |||
| 172 | + | **auto-infer a saved remote** for commands that already have a concrete target. It probes your | |||
| 173 | + | saved remotes and uses the remote automatically when exactly one matches. If multiple remotes | |||
| 174 | + | match, Gordon stops with an ambiguity error and asks you to use `--remote`. If any remote probe | |||
| 175 | + | fails, Gordon also stops rather than guessing. | |||
| 176 | + | ||||
| 177 | + | Auto-inference currently applies to target-based commands such as: | |||
| 178 | + | - `gordon push` | |||
| 179 | + | - `gordon attachments push` | |||
| 180 | + | - `gordon deploy <domain>` | |||
| 181 | + | - `gordon restart <domain>` | |||
| 182 | + | - `gordon pin <domain>` / `gordon pin list <domain>` | |||
| 183 | + | - `gordon routes show <domain>` / `gordon routes remove <domain>` | |||
| 184 | + | - `gordon secrets list|set|remove <domain>` | |||
| 185 | + | - `gordon attachments list <target>` / `gordon attachments add|remove <target> ...` | |||
| 186 | + | - `gordon backups list <domain>` / `gordon backups run <domain>` / `gordon backups detect <domain>` | |||
| 187 | + | - `gordon images tags <repository>` | |||
| 188 | + | - `gordon logs <domain>` | |||
| 189 | + | ||||
| 171 | `gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote` | 190 | `gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote` | ||
| 172 | nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either | 191 | nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either | ||
| 173 | one to force a single target. | 192 | one to force a single target. | ||
| @@ -180,6 +199,10 @@ gordon routes status | |||||
| 180 | # Single-target override | 199 | # Single-target override | ||
| 181 | gordon routes list --remote prod | 200 | gordon routes list --remote prod | ||
| 182 | GORDON_REMOTE=prod gordon routes status | 201 | GORDON_REMOTE=prod gordon routes status | ||
| 202 | + | ||||
| 203 | + | # Auto-inferred single match from saved remotes | |||
| 204 | + | gordon push myapp --build | |||
| 205 | + | gordon deploy app.example.com | |||
| 183 | ``` | 206 | ``` | ||
| 184 | 207 | ||||
| 185 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | 208 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | ||
M docs/cli/push.md +6 -0
| @@ -36,6 +36,12 @@ gordon push [image] [options] | |||
| 36 | 36 | `gordon push` tags the selected image for the Gordon registry, pushes it, and | |
| 37 | 37 | optionally deploys matching routes. | |
| 38 | 38 | ||
| 39 | + | If you do not pass `--remote` and no active remote is configured, Gordon tries to | |
| 40 | + | infer the correct saved remote by probing your saved remotes for a matching route. | |
| 41 | + | It auto-selects only when exactly one remote matches. If multiple remotes match, | |
| 42 | + | or a saved remote cannot be probed safely, the command stops and asks you to use | |
| 43 | + | `--remote` explicitly. | |
| 44 | + | ||
| 39 | 45 | Image resolution order: | |
| 40 | 46 | 1. `--domain` is the explicit deploy target override for legacy workflows | |
| 41 | 47 | 2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup | |
| @@ -36,6 +36,12 @@ gordon push [image] [options] | |||||
| 36 | `gordon push` tags the selected image for the Gordon registry, pushes it, and | 36 | `gordon push` tags the selected image for the Gordon registry, pushes it, and | ||
| 37 | optionally deploys matching routes. | 37 | optionally deploys matching routes. | ||
| 38 | 38 | ||||
| 39 | + | If you do not pass `--remote` and no active remote is configured, Gordon tries to | |||
| 40 | + | infer the correct saved remote by probing your saved remotes for a matching route. | |||
| 41 | + | It auto-selects only when exactly one remote matches. If multiple remotes match, | |||
| 42 | + | or a saved remote cannot be probed safely, the command stops and asks you to use | |||
| 43 | + | `--remote` explicitly. | |||
| 44 | + | ||||
| 39 | Image resolution order: | 45 | Image resolution order: | ||
| 40 | 1. `--domain` is the explicit deploy target override for legacy workflows | 46 | 1. `--domain` is the explicit deploy target override for legacy workflows | ||
| 41 | 2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup | 47 | 2. Positional refs resolve routes by image name; tagged refs still use the image name for lookup | ||
M docs/cli/remotes.md +5 -1
| @@ -165,6 +165,8 @@ gordon remotes use <name> | |||
| 165 | 165 | When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`. | |
| 166 | 166 | `gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`. | |
| 167 | 167 | ||
| 168 | + | If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for commands with a concrete target (for example `gordon push myapp`, `gordon deploy app.example.com`, or `gordon images tags myapp`). It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`. | |
| 169 | + | ||
| 168 | 170 | ```bash | |
| 169 | 171 | gordon remotes use prod | |
| 170 | 172 | gordon secrets list app.com # Uses prod remote automatically | |
| @@ -245,11 +247,13 @@ token = "eyJ..." | |||
| 245 | 247 | When multiple sources specify remote or token, the CLI uses this priority: | |
| 246 | 248 | ||
| 247 | 249 | For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists. | |
| 250 | + | Auto-inference is not used for those aggregate views. | |
| 248 | 251 | ||
| 249 | - | **Remote URL:** | |
| 252 | + | **Remote target selection:** | |
| 250 | 253 | 1. `--remote` flag | |
| 251 | 254 | 2. `GORDON_REMOTE` environment variable | |
| 252 | 255 | 3. Active remote from `remotes.toml` | |
| 256 | + | 4. Auto-inferred saved remote for supported target-based commands | |
| 253 | 257 | ||
| 254 | 258 | **Token:** | |
| 255 | 259 | 1. `--token` flag | |
| @@ -165,6 +165,8 @@ gordon remotes use <name> | |||||
| 165 | When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`. | 165 | When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`. | ||
| 166 | `gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`. | 166 | `gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`. | ||
| 167 | 167 | ||||
| 168 | + | If no remote is active and you do not pass `--remote`, Gordon can also auto-infer a saved remote for commands with a concrete target (for example `gordon push myapp`, `gordon deploy app.example.com`, or `gordon images tags myapp`). It only auto-selects when exactly one saved remote matches. Ambiguous matches and probe failures require an explicit `--remote`. | |||
| 169 | + | ||||
| 168 | ```bash | 170 | ```bash | ||
| 169 | gordon remotes use prod | 171 | gordon remotes use prod | ||
| 170 | gordon secrets list app.com # Uses prod remote automatically | 172 | gordon secrets list app.com # Uses prod remote automatically | ||
| @@ -245,11 +247,13 @@ token = "eyJ..." | |||||
| 245 | When multiple sources specify remote or token, the CLI uses this priority: | 247 | When multiple sources specify remote or token, the CLI uses this priority: | ||
| 246 | 248 | ||||
| 247 | For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists. | 249 | For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists. | ||
| 250 | + | Auto-inference is not used for those aggregate views. | |||
| 248 | 251 | ||||
| 249 | - | **Remote URL:** | 252 | + | **Remote target selection:** |
| 250 | 1. `--remote` flag | 253 | 1. `--remote` flag | ||
| 251 | 2. `GORDON_REMOTE` environment variable | 254 | 2. `GORDON_REMOTE` environment variable | ||
| 252 | 3. Active remote from `remotes.toml` | 255 | 3. Active remote from `remotes.toml` | ||
| 256 | + | 4. Auto-inferred saved remote for supported target-based commands | |||
| 253 | 257 | ||||
| 254 | **Token:** | 258 | **Token:** | ||
| 255 | 1. `--token` flag | 259 | 1. `--token` flag | ||
M docs/cli/secrets.md +2 -0
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | Manage secrets on local or remote Gordon instances. | |
| 4 | 4 | ||
| 5 | 5 | Remote targeting uses client config or an active remote by default. | |
| 6 | + | When you provide a concrete domain and no remote is selected, Gordon can also | |
| 7 | + | auto-infer a saved remote when exactly one match is found. | |
| 6 | 8 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 7 | 9 | ||
| 8 | 10 | Storage depends on the secrets backend: | |
| @@ -3,6 +3,8 @@ | |||||
| 3 | Manage secrets on local or remote Gordon instances. | 3 | Manage secrets on local or remote Gordon instances. | ||
| 4 | 4 | ||||
| 5 | Remote targeting uses client config or an active remote by default. | 5 | Remote targeting uses client config or an active remote by default. | ||
| 6 | + | When you provide a concrete domain and no remote is selected, Gordon can also | |||
| 7 | + | auto-infer a saved remote when exactly one match is found. | |||
| 6 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | 8 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | ||
| 7 | 9 | ||||
| 8 | Storage depends on the secrets backend: | 10 | Storage depends on the secrets backend: | ||
M docs/cli/serve.md +2 -0
| @@ -248,6 +248,8 @@ gordon logs [domain] [options] | |||
| 248 | 248 | | `--token` | | | Authentication token for remote | | |
| 249 | 249 | ||
| 250 | 250 | Remote targeting uses client config or an active remote by default. | |
| 251 | + | When you provide a concrete domain and no remote is selected, Gordon can also | |
| 252 | + | auto-infer a saved remote when exactly one match is found. | |
| 251 | 253 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 252 | 254 | ||
| 253 | 255 | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | |
| @@ -248,6 +248,8 @@ gordon logs [domain] [options] | |||||
| 248 | | `--token` | | | Authentication token for remote | | 248 | | `--token` | | | Authentication token for remote | | ||
| 249 | 249 | ||||
| 250 | Remote targeting uses client config or an active remote by default. | 250 | Remote targeting uses client config or an active remote by default. | ||
| 251 | + | When you provide a concrete domain and no remote is selected, Gordon can also | |||
| 252 | + | auto-infer a saved remote when exactly one match is found. | |||
| 251 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | 253 | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | ||
| 252 | 254 | ||||
| 253 | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | 255 | Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs. | ||
M internal/adapters/in/cli/attachments.go +81 -22
| @@ -58,6 +58,15 @@ Examples: | |||
| 58 | 58 | target = args[0] | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | if target != "" { | |
| 62 | + | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) | |
| 63 | + | if err != nil { | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | defer handle.close() | |
| 67 | + | return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout()) | |
| 68 | + | } | |
| 69 | + | ||
| 61 | 70 | client, isRemote := GetRemoteClient() | |
| 62 | 71 | if isRemote { | |
| 63 | 72 | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) | |
| @@ -71,6 +80,61 @@ Examples: | |||
| 71 | 80 | return cmd | |
| 72 | 81 | } | |
| 73 | 82 | ||
| 83 | + | func runAttachmentsList(ctx context.Context, cp ControlPlane, target string, jsonOut bool, out io.Writer) error { | |
| 84 | + | if target != "" { | |
| 85 | + | images, err := cp.GetAttachmentsConfig(ctx, target) | |
| 86 | + | if err != nil { | |
| 87 | + | return fmt.Errorf("failed to get attachments: %w", err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | if len(images) == 0 { | |
| 91 | + | fmt.Printf("No attachments configured for '%s'\n", target) | |
| 92 | + | return nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if jsonOut { | |
| 96 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | |
| 100 | + | fmt.Println() | |
| 101 | + | for _, img := range images { | |
| 102 | + | fmt.Printf(" %s\n", img) | |
| 103 | + | } | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | attachments, err := cp.GetAllAttachmentsConfig(ctx) | |
| 108 | + | if err != nil { | |
| 109 | + | return fmt.Errorf("failed to list attachments: %w", err) | |
| 110 | + | } | |
| 111 | + | if len(attachments) == 0 { | |
| 112 | + | fmt.Println(styles.Theme.Muted.Render("No attachments configured")) | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | if jsonOut { | |
| 116 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | targets := make([]string, 0, len(attachments)) | |
| 120 | + | for t := range attachments { | |
| 121 | + | targets = append(targets, t) | |
| 122 | + | } | |
| 123 | + | sort.Strings(targets) | |
| 124 | + | ||
| 125 | + | fmt.Println(styles.Theme.Title.Render("Attachments")) | |
| 126 | + | fmt.Println() | |
| 127 | + | for _, t := range targets { | |
| 128 | + | images := attachments[t] | |
| 129 | + | fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) | |
| 130 | + | for _, img := range images { | |
| 131 | + | fmt.Printf(" %s\n", img) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | ||
| 74 | 138 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | |
| 75 | 139 | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { | |
| 76 | 140 | if target != "" { | |
| @@ -219,12 +283,13 @@ Examples: | |||
| 219 | 283 | target := args[0] | |
| 220 | 284 | image := args[1] | |
| 221 | 285 | ||
| 222 | - | client, isRemote := GetRemoteClient() | |
| 223 | - | if isRemote { | |
| 224 | - | if err := client.AddAttachment(ctx, target, image); err != nil { | |
| 225 | - | return fmt.Errorf("failed to add attachment: %w", err) | |
| 226 | - | } | |
| 227 | - | } else { | |
| 286 | + | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) | |
| 287 | + | if err != nil { | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | defer handle.close() | |
| 291 | + | ||
| 292 | + | if !handle.isRemote { | |
| 228 | 293 | local, err := GetLocalServices(configPath) | |
| 229 | 294 | if err != nil { | |
| 230 | 295 | return fmt.Errorf("failed to initialize local services: %w", err) | |
| @@ -238,10 +303,10 @@ Examples: | |||
| 238 | 303 | fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true")) | |
| 239 | 304 | fmt.Println() | |
| 240 | 305 | } | |
| 306 | + | } | |
| 241 | 307 | ||
| 242 | - | if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil { | |
| 243 | - | return fmt.Errorf("failed to add attachment: %w", err) | |
| 244 | - | } | |
| 308 | + | if err := handle.plane.AddAttachment(ctx, target, image); err != nil { | |
| 309 | + | return fmt.Errorf("failed to add attachment: %w", err) | |
| 245 | 310 | } | |
| 246 | 311 | ||
| 247 | 312 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image))) | |
| @@ -285,19 +350,13 @@ Examples: | |||
| 285 | 350 | } | |
| 286 | 351 | } | |
| 287 | 352 | ||
| 288 | - | client, isRemote := GetRemoteClient() | |
| 289 | - | if isRemote { | |
| 290 | - | if err := client.RemoveAttachment(ctx, target, image); err != nil { | |
| 291 | - | return fmt.Errorf("failed to remove attachment: %w", err) | |
| 292 | - | } | |
| 293 | - | } else { | |
| 294 | - | local, err := GetLocalServices(configPath) | |
| 295 | - | if err != nil { | |
| 296 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 297 | - | } | |
| 298 | - | if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil { | |
| 299 | - | return fmt.Errorf("failed to remove attachment: %w", err) | |
| 300 | - | } | |
| 353 | + | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) | |
| 354 | + | if err != nil { | |
| 355 | + | return err | |
| 356 | + | } | |
| 357 | + | defer handle.close() | |
| 358 | + | if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil { | |
| 359 | + | return fmt.Errorf("failed to remove attachment: %w", err) | |
| 301 | 360 | } | |
| 302 | 361 | ||
| 303 | 362 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image))) | |
| @@ -58,6 +58,15 @@ Examples: | |||||
| 58 | target = args[0] | 58 | target = args[0] | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | + | if target != "" { | |||
| 62 | + | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) | |||
| 63 | + | if err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | defer handle.close() | |||
| 67 | + | return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout()) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 61 | client, isRemote := GetRemoteClient() | 70 | client, isRemote := GetRemoteClient() | ||
| 62 | if isRemote { | 71 | if isRemote { | ||
| 63 | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) | 72 | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) | ||
| @@ -71,6 +80,61 @@ Examples: | |||||
| 71 | return cmd | 80 | return cmd | ||
| 72 | } | 81 | } | ||
| 73 | 82 | ||||
| 83 | + | func runAttachmentsList(ctx context.Context, cp ControlPlane, target string, jsonOut bool, out io.Writer) error { | |||
| 84 | + | if target != "" { | |||
| 85 | + | images, err := cp.GetAttachmentsConfig(ctx, target) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return fmt.Errorf("failed to get attachments: %w", err) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | if len(images) == 0 { | |||
| 91 | + | fmt.Printf("No attachments configured for '%s'\n", target) | |||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | if jsonOut { | |||
| 96 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | |||
| 100 | + | fmt.Println() | |||
| 101 | + | for _, img := range images { | |||
| 102 | + | fmt.Printf(" %s\n", img) | |||
| 103 | + | } | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | attachments, err := cp.GetAllAttachmentsConfig(ctx) | |||
| 108 | + | if err != nil { | |||
| 109 | + | return fmt.Errorf("failed to list attachments: %w", err) | |||
| 110 | + | } | |||
| 111 | + | if len(attachments) == 0 { | |||
| 112 | + | fmt.Println(styles.Theme.Muted.Render("No attachments configured")) | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | if jsonOut { | |||
| 116 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | targets := make([]string, 0, len(attachments)) | |||
| 120 | + | for t := range attachments { | |||
| 121 | + | targets = append(targets, t) | |||
| 122 | + | } | |||
| 123 | + | sort.Strings(targets) | |||
| 124 | + | ||||
| 125 | + | fmt.Println(styles.Theme.Title.Render("Attachments")) | |||
| 126 | + | fmt.Println() | |||
| 127 | + | for _, t := range targets { | |||
| 128 | + | images := attachments[t] | |||
| 129 | + | fmt.Printf("%s\n", styles.Theme.Bold.Render(t)) | |||
| 130 | + | for _, img := range images { | |||
| 131 | + | fmt.Printf(" %s\n", img) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | ||||
| 74 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | 138 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | ||
| 75 | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { | 139 | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { | ||
| 76 | if target != "" { | 140 | if target != "" { | ||
| @@ -219,12 +283,13 @@ Examples: | |||||
| 219 | target := args[0] | 283 | target := args[0] | ||
| 220 | image := args[1] | 284 | image := args[1] | ||
| 221 | 285 | ||||
| 222 | - | client, isRemote := GetRemoteClient() | 286 | + | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) |
| 223 | - | if isRemote { | 287 | + | if err != nil { |
| 224 | - | if err := client.AddAttachment(ctx, target, image); err != nil { | 288 | + | return err |
| 225 | - | return fmt.Errorf("failed to add attachment: %w", err) | 289 | + | } |
| 226 | - | } | 290 | + | defer handle.close() |
| 227 | - | } else { | 291 | + | |
| 292 | + | if !handle.isRemote { | |||
| 228 | local, err := GetLocalServices(configPath) | 293 | local, err := GetLocalServices(configPath) | ||
| 229 | if err != nil { | 294 | if err != nil { | ||
| 230 | return fmt.Errorf("failed to initialize local services: %w", err) | 295 | return fmt.Errorf("failed to initialize local services: %w", err) | ||
| @@ -238,10 +303,10 @@ Examples: | |||||
| 238 | fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true")) | 303 | fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true")) | ||
| 239 | fmt.Println() | 304 | fmt.Println() | ||
| 240 | } | 305 | } | ||
| 306 | + | } | |||
| 241 | 307 | ||||
| 242 | - | if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil { | 308 | + | if err := handle.plane.AddAttachment(ctx, target, image); err != nil { |
| 243 | - | return fmt.Errorf("failed to add attachment: %w", err) | 309 | + | return fmt.Errorf("failed to add attachment: %w", err) |
| 244 | - | } | |||
| 245 | } | 310 | } | ||
| 246 | 311 | ||||
| 247 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image))) | 312 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image))) | ||
| @@ -285,19 +350,13 @@ Examples: | |||||
| 285 | } | 350 | } | ||
| 286 | } | 351 | } | ||
| 287 | 352 | ||||
| 288 | - | client, isRemote := GetRemoteClient() | 353 | + | handle, err := resolveControlPlaneForAttachmentTarget(ctx, target) |
| 289 | - | if isRemote { | 354 | + | if err != nil { |
| 290 | - | if err := client.RemoveAttachment(ctx, target, image); err != nil { | 355 | + | return err |
| 291 | - | return fmt.Errorf("failed to remove attachment: %w", err) | 356 | + | } |
| 292 | - | } | 357 | + | defer handle.close() |
| 293 | - | } else { | 358 | + | if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil { |
| 294 | - | local, err := GetLocalServices(configPath) | 359 | + | return fmt.Errorf("failed to remove attachment: %w", err) |
| 295 | - | if err != nil { | |||
| 296 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 297 | - | } | |||
| 298 | - | if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil { | |||
| 299 | - | return fmt.Errorf("failed to remove attachment: %w", err) | |||
| 300 | - | } | |||
| 301 | } | 360 | } | ||
| 302 | 361 | ||||
| 303 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image))) | 362 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image))) | ||
M internal/adapters/in/cli/attachments_push.go +22 -4
| @@ -57,17 +57,30 @@ func newAttachmentsPushCmd() *cobra.Command { | |||
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | 59 | func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.Writer) error { | |
| 60 | - | handle, err := resolveControlPlaneFn(configPath) | |
| 60 | + | dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) | |
| 61 | 61 | if err != nil { | |
| 62 | 62 | return err | |
| 63 | 63 | } | |
| 64 | - | defer handle.close() | |
| 65 | 64 | ||
| 66 | - | dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) | |
| 65 | + | inferredRemote, err := inferRemoteForAttachmentImage(ctx, req.ImageArg) | |
| 67 | 66 | if err != nil { | |
| 68 | 67 | return err | |
| 69 | 68 | } | |
| 70 | 69 | ||
| 70 | + | var handle *controlPlaneHandle | |
| 71 | + | if inferredRemote != nil { | |
| 72 | + | handle = newRemoteControlPlaneHandle(inferredRemote) | |
| 73 | + | if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil { | |
| 74 | + | return err | |
| 75 | + | } | |
| 76 | + | } else { | |
| 77 | + | handle, err = resolveControlPlaneFn(configPath) | |
| 78 | + | if err != nil { | |
| 79 | + | return err | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | defer handle.close() | |
| 83 | + | ||
| 71 | 84 | for _, ba := range req.Build.BuildArgs { | |
| 72 | 85 | if err := validateBuildArg(ba); err != nil { | |
| 73 | 86 | return err | |
| @@ -91,7 +104,12 @@ func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.W | |||
| 91 | 104 | } | |
| 92 | 105 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | |
| 93 | 106 | ||
| 94 | - | imageOps, err := newImageOpsFn() | |
| 107 | + | var imageOps pushImageOps | |
| 108 | + | if inferredRemote != nil { | |
| 109 | + | imageOps, err = newImageOpsForResolvedRemote(inferredRemote) | |
| 110 | + | } else { | |
| 111 | + | imageOps, err = newImageOpsFn() | |
| 112 | + | } | |
| 95 | 113 | if err != nil { | |
| 96 | 114 | return err | |
| 97 | 115 | } | |
| @@ -57,17 +57,30 @@ func newAttachmentsPushCmd() *cobra.Command { | |||||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.Writer) error { | 59 | func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.Writer) error { | ||
| 60 | - | handle, err := resolveControlPlaneFn(configPath) | 60 | + | dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) |
| 61 | if err != nil { | 61 | if err != nil { | ||
| 62 | return err | 62 | return err | ||
| 63 | } | 63 | } | ||
| 64 | - | defer handle.close() | |||
| 65 | 64 | ||||
| 66 | - | dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) | 65 | + | inferredRemote, err := inferRemoteForAttachmentImage(ctx, req.ImageArg) |
| 67 | if err != nil { | 66 | if err != nil { | ||
| 68 | return err | 67 | return err | ||
| 69 | } | 68 | } | ||
| 70 | 69 | ||||
| 70 | + | var handle *controlPlaneHandle | |||
| 71 | + | if inferredRemote != nil { | |||
| 72 | + | handle = newRemoteControlPlaneHandle(inferredRemote) | |||
| 73 | + | if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil { | |||
| 74 | + | return err | |||
| 75 | + | } | |||
| 76 | + | } else { | |||
| 77 | + | handle, err = resolveControlPlaneFn(configPath) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return err | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | defer handle.close() | |||
| 83 | + | ||||
| 71 | for _, ba := range req.Build.BuildArgs { | 84 | for _, ba := range req.Build.BuildArgs { | ||
| 72 | if err := validateBuildArg(ba); err != nil { | 85 | if err := validateBuildArg(ba); err != nil { | ||
| 73 | return err | 86 | return err | ||
| @@ -91,7 +104,12 @@ func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.W | |||||
| 91 | } | 104 | } | ||
| 92 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | 105 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | ||
| 93 | 106 | ||||
| 94 | - | imageOps, err := newImageOpsFn() | 107 | + | var imageOps pushImageOps |
| 108 | + | if inferredRemote != nil { | |||
| 109 | + | imageOps, err = newImageOpsForResolvedRemote(inferredRemote) | |||
| 110 | + | } else { | |||
| 111 | + | imageOps, err = newImageOpsFn() | |||
| 112 | + | } | |||
| 95 | if err != nil { | 113 | if err != nil { | ||
| 96 | return err | 114 | return err | ||
| 97 | } | 115 | } | ||
M internal/adapters/in/cli/backup.go +16 -8
| @@ -42,17 +42,25 @@ func newBackupListCmd() *cobra.Command { | |||
| 42 | 42 | Long: cliRenderMuted("List backups for all domains or a specific domain."), | |
| 43 | 43 | Args: cobra.MaximumNArgs(1), | |
| 44 | 44 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 45 | - | handle, err := resolveControlPlane(configPath) | |
| 46 | - | if err != nil { | |
| 47 | - | return err | |
| 48 | - | } | |
| 49 | - | defer handle.close() | |
| 50 | - | ||
| 51 | 45 | domainName := "" | |
| 52 | 46 | if len(args) == 1 { | |
| 53 | 47 | domainName = args[0] | |
| 54 | 48 | } | |
| 55 | 49 | ||
| 50 | + | var ( | |
| 51 | + | handle *controlPlaneHandle | |
| 52 | + | err error | |
| 53 | + | ) | |
| 54 | + | if domainName != "" { | |
| 55 | + | handle, err = resolveControlPlaneForRouteDomain(cmd.Context(), domainName) | |
| 56 | + | } else { | |
| 57 | + | handle, err = resolveControlPlane(configPath) | |
| 58 | + | } | |
| 59 | + | if err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | defer handle.close() | |
| 63 | + | ||
| 56 | 64 | jobs, err := handle.plane.ListBackups(cmd.Context(), domainName) | |
| 57 | 65 | if err != nil { | |
| 58 | 66 | return fmt.Errorf("failed to list backups: %w", err) | |
| @@ -103,7 +111,7 @@ func newBackupRunCmd() *cobra.Command { | |||
| 103 | 111 | Short: "Run backup now", | |
| 104 | 112 | Args: cobra.ExactArgs(1), | |
| 105 | 113 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 106 | - | handle, err := resolveControlPlane(configPath) | |
| 114 | + | handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0]) | |
| 107 | 115 | if err != nil { | |
| 108 | 116 | return err | |
| 109 | 117 | } | |
| @@ -145,7 +153,7 @@ func newBackupDetectCmd() *cobra.Command { | |||
| 145 | 153 | Short: "Detect databases for domain", | |
| 146 | 154 | Args: cobra.ExactArgs(1), | |
| 147 | 155 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 148 | - | handle, err := resolveControlPlane(configPath) | |
| 156 | + | handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0]) | |
| 149 | 157 | if err != nil { | |
| 150 | 158 | return err | |
| 151 | 159 | } | |
| @@ -42,17 +42,25 @@ func newBackupListCmd() *cobra.Command { | |||||
| 42 | Long: cliRenderMuted("List backups for all domains or a specific domain."), | 42 | Long: cliRenderMuted("List backups for all domains or a specific domain."), | ||
| 43 | Args: cobra.MaximumNArgs(1), | 43 | Args: cobra.MaximumNArgs(1), | ||
| 44 | RunE: func(cmd *cobra.Command, args []string) error { | 44 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 45 | - | handle, err := resolveControlPlane(configPath) | |||
| 46 | - | if err != nil { | |||
| 47 | - | return err | |||
| 48 | - | } | |||
| 49 | - | defer handle.close() | |||
| 50 | - | ||||
| 51 | domainName := "" | 45 | domainName := "" | ||
| 52 | if len(args) == 1 { | 46 | if len(args) == 1 { | ||
| 53 | domainName = args[0] | 47 | domainName = args[0] | ||
| 54 | } | 48 | } | ||
| 55 | 49 | ||||
| 50 | + | var ( | |||
| 51 | + | handle *controlPlaneHandle | |||
| 52 | + | err error | |||
| 53 | + | ) | |||
| 54 | + | if domainName != "" { | |||
| 55 | + | handle, err = resolveControlPlaneForRouteDomain(cmd.Context(), domainName) | |||
| 56 | + | } else { | |||
| 57 | + | handle, err = resolveControlPlane(configPath) | |||
| 58 | + | } | |||
| 59 | + | if err != nil { | |||
| 60 | + | return err | |||
| 61 | + | } | |||
| 62 | + | defer handle.close() | |||
| 63 | + | ||||
| 56 | jobs, err := handle.plane.ListBackups(cmd.Context(), domainName) | 64 | jobs, err := handle.plane.ListBackups(cmd.Context(), domainName) | ||
| 57 | if err != nil { | 65 | if err != nil { | ||
| 58 | return fmt.Errorf("failed to list backups: %w", err) | 66 | return fmt.Errorf("failed to list backups: %w", err) | ||
| @@ -103,7 +111,7 @@ func newBackupRunCmd() *cobra.Command { | |||||
| 103 | Short: "Run backup now", | 111 | Short: "Run backup now", | ||
| 104 | Args: cobra.ExactArgs(1), | 112 | Args: cobra.ExactArgs(1), | ||
| 105 | RunE: func(cmd *cobra.Command, args []string) error { | 113 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 106 | - | handle, err := resolveControlPlane(configPath) | 114 | + | handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0]) |
| 107 | if err != nil { | 115 | if err != nil { | ||
| 108 | return err | 116 | return err | ||
| 109 | } | 117 | } | ||
| @@ -145,7 +153,7 @@ func newBackupDetectCmd() *cobra.Command { | |||||
| 145 | Short: "Detect databases for domain", | 153 | Short: "Detect databases for domain", | ||
| 146 | Args: cobra.ExactArgs(1), | 154 | Args: cobra.ExactArgs(1), | ||
| 147 | RunE: func(cmd *cobra.Command, args []string) error { | 155 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 148 | - | handle, err := resolveControlPlane(configPath) | 156 | + | handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0]) |
| 149 | if err != nil { | 157 | if err != nil { | ||
| 150 | return err | 158 | return err | ||
| 151 | } | 159 | } | ||
M internal/adapters/in/cli/controlplane_resolver.go +36 -0
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 6 | 8 | "github.com/bnema/gordon/internal/app" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| @@ -31,6 +33,40 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |||
| 31 | 33 | return resolveLocalControlPlane(configPath) | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 36 | + | func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle { | |
| 37 | + | client := remote.NewClient(target.URL, remoteClientOptions(target.Token, target.InsecureTLS)...) | |
| 38 | + | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) { | |
| 42 | + | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 43 | + | return inferRemoteForRouteDomain(ctx, routeDomain) | |
| 44 | + | }) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) { | |
| 48 | + | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 49 | + | return inferRemoteForAttachmentTarget(ctx, target) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) { | |
| 54 | + | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |
| 55 | + | return inferRemoteForRepository(ctx, repository) | |
| 56 | + | }) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Context) (*remote.ResolvedRemote, error)) (*controlPlaneHandle, error) { | |
| 60 | + | resolved, err := infer(ctx) | |
| 61 | + | if err != nil { | |
| 62 | + | return nil, err | |
| 63 | + | } | |
| 64 | + | if resolved != nil { | |
| 65 | + | return newRemoteControlPlaneHandle(resolved), nil | |
| 66 | + | } | |
| 67 | + | return resolveControlPlane(configPath) | |
| 68 | + | } | |
| 69 | + | ||
| 34 | 70 | func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) { | |
| 35 | 71 | kernel, err := app.NewKernelQuiet(configPath) | |
| 36 | 72 | if err != nil { | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | 6 | ||||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 6 | "github.com/bnema/gordon/internal/app" | 8 | "github.com/bnema/gordon/internal/app" | ||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| @@ -31,6 +33,40 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |||||
| 31 | return resolveLocalControlPlane(configPath) | 33 | return resolveLocalControlPlane(configPath) | ||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| 36 | + | func newRemoteControlPlaneHandle(target *remote.ResolvedRemote) *controlPlaneHandle { | |||
| 37 | + | client := remote.NewClient(target.URL, remoteClientOptions(target.Token, target.InsecureTLS)...) | |||
| 38 | + | return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func resolveControlPlaneForRouteDomain(ctx context.Context, routeDomain string) (*controlPlaneHandle, error) { | |||
| 42 | + | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |||
| 43 | + | return inferRemoteForRouteDomain(ctx, routeDomain) | |||
| 44 | + | }) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func resolveControlPlaneForAttachmentTarget(ctx context.Context, target string) (*controlPlaneHandle, error) { | |||
| 48 | + | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |||
| 49 | + | return inferRemoteForAttachmentTarget(ctx, target) | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func resolveControlPlaneForRepository(ctx context.Context, repository string) (*controlPlaneHandle, error) { | |||
| 54 | + | return resolveControlPlaneWithInference(ctx, func(ctx context.Context) (*remote.ResolvedRemote, error) { | |||
| 55 | + | return inferRemoteForRepository(ctx, repository) | |||
| 56 | + | }) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func resolveControlPlaneWithInference(ctx context.Context, infer func(context.Context) (*remote.ResolvedRemote, error)) (*controlPlaneHandle, error) { | |||
| 60 | + | resolved, err := infer(ctx) | |||
| 61 | + | if err != nil { | |||
| 62 | + | return nil, err | |||
| 63 | + | } | |||
| 64 | + | if resolved != nil { | |||
| 65 | + | return newRemoteControlPlaneHandle(resolved), nil | |||
| 66 | + | } | |||
| 67 | + | return resolveControlPlane(configPath) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 34 | func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) { | 70 | func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) { | ||
| 35 | kernel, err := app.NewKernelQuiet(configPath) | 71 | kernel, err := app.NewKernelQuiet(configPath) | ||
| 36 | if err != nil { | 72 | if err != nil { | ||
M internal/adapters/in/cli/deploy.go +1 -1
| @@ -35,7 +35,7 @@ Examples: | |||
| 35 | 35 | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`, | |
| 36 | 36 | Args: cobra.ExactArgs(1), | |
| 37 | 37 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 38 | - | handle, err := resolveControlPlane(configPath) | |
| 38 | + | handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0]) | |
| 39 | 39 | if err != nil { | |
| 40 | 40 | return err | |
| 41 | 41 | } | |
| @@ -35,7 +35,7 @@ Examples: | |||||
| 35 | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`, | 35 | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`, | ||
| 36 | Args: cobra.ExactArgs(1), | 36 | Args: cobra.ExactArgs(1), | ||
| 37 | RunE: func(cmd *cobra.Command, args []string) error { | 37 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 38 | - | handle, err := resolveControlPlane(configPath) | 38 | + | handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0]) |
| 39 | if err != nil { | 39 | if err != nil { | ||
| 40 | return err | 40 | return err | ||
| 41 | } | 41 | } | ||
M internal/adapters/in/cli/images.go +1 -1
| @@ -148,7 +148,7 @@ Examples: | |||
| 148 | 148 | Args: cobra.ExactArgs(1), | |
| 149 | 149 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 150 | 150 | ctx := cmd.Context() | |
| 151 | - | handle, err := resolveControlPlane(configPath) | |
| 151 | + | handle, err := resolveControlPlaneForRepository(ctx, args[0]) | |
| 152 | 152 | if err != nil { | |
| 153 | 153 | return err | |
| 154 | 154 | } | |
| @@ -148,7 +148,7 @@ Examples: | |||||
| 148 | Args: cobra.ExactArgs(1), | 148 | Args: cobra.ExactArgs(1), | ||
| 149 | RunE: func(cmd *cobra.Command, args []string) error { | 149 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 150 | ctx := cmd.Context() | 150 | ctx := cmd.Context() | ||
| 151 | - | handle, err := resolveControlPlane(configPath) | 151 | + | handle, err := resolveControlPlaneForRepository(ctx, args[0]) |
| 152 | if err != nil { | 152 | if err != nil { | ||
| 153 | return err | 153 | return err | ||
| 154 | } | 154 | } | ||
M internal/adapters/in/cli/pin.go +2 -2
| @@ -59,7 +59,7 @@ func newPinListCmd() *cobra.Command { | |||
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error { | |
| 62 | - | handle, err := resolveControlPlane(configPath) | |
| 62 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain) | |
| 63 | 63 | if err != nil { | |
| 64 | 64 | return fmt.Errorf("failed to resolve control plane: %w", err) | |
| 65 | 65 | } | |
| @@ -96,7 +96,7 @@ func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) | |||
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 98 | func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error { | |
| 99 | - | handle, err := resolveControlPlane(configPath) | |
| 99 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain) | |
| 100 | 100 | if err != nil { | |
| 101 | 101 | return fmt.Errorf("failed to resolve control plane: %w", err) | |
| 102 | 102 | } | |
| @@ -59,7 +59,7 @@ func newPinListCmd() *cobra.Command { | |||||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error { | 61 | func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error { | ||
| 62 | - | handle, err := resolveControlPlane(configPath) | 62 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain) |
| 63 | if err != nil { | 63 | if err != nil { | ||
| 64 | return fmt.Errorf("failed to resolve control plane: %w", err) | 64 | return fmt.Errorf("failed to resolve control plane: %w", err) | ||
| 65 | } | 65 | } | ||
| @@ -96,7 +96,7 @@ func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) | |||||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error { | 98 | func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error { | ||
| 99 | - | handle, err := resolveControlPlane(configPath) | 99 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain) |
| 100 | if err != nil { | 100 | if err != nil { | ||
| 101 | return fmt.Errorf("failed to resolve control plane: %w", err) | 101 | return fmt.Errorf("failed to resolve control plane: %w", err) | ||
| 102 | } | 102 | } | ||
M internal/adapters/in/cli/push.go +51 -42
| @@ -226,17 +226,12 @@ func resolveVersion(ctx context.Context, tag string) (string, error) { | |||
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | 228 | func runPush(ctx context.Context, req pushRequest) error { | |
| 229 | - | handle, err := resolveControlPlane(configPath) | |
| 229 | + | dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, req) | |
| 230 | 230 | if err != nil { | |
| 231 | 231 | return err | |
| 232 | 232 | } | |
| 233 | 233 | defer handle.close() | |
| 234 | 234 | ||
| 235 | - | dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) | |
| 236 | - | if err != nil { | |
| 237 | - | return err | |
| 238 | - | } | |
| 239 | - | ||
| 240 | 235 | registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile) | |
| 241 | 236 | if err != nil { | |
| 242 | 237 | return err | |
| @@ -246,21 +241,14 @@ func runPush(ctx context.Context, req pushRequest) error { | |||
| 246 | 241 | if err != nil { | |
| 247 | 242 | return err | |
| 248 | 243 | } | |
| 249 | - | ||
| 250 | - | for _, ba := range req.Build.BuildArgs { | |
| 251 | - | if err := validateBuildArg(ba); err != nil { | |
| 252 | - | return err | |
| 253 | - | } | |
| 244 | + | if err := validateBuildArgsList(req.Build.BuildArgs); err != nil { | |
| 245 | + | return err | |
| 254 | 246 | } | |
| 255 | 247 | ||
| 256 | - | img := imagePush{ | |
| 257 | - | Registry: registry, | |
| 258 | - | ImageName: imageName, | |
| 259 | - | Version: version, | |
| 260 | - | } | |
| 248 | + | img := imagePush{Registry: registry, ImageName: imageName, Version: version} | |
| 261 | 249 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | |
| 262 | 250 | ||
| 263 | - | imageOps, err := newImageOpsFn() | |
| 251 | + | imageOps, err := newPushImageOps(inferredRemote) | |
| 264 | 252 | if err != nil { | |
| 265 | 253 | return err | |
| 266 | 254 | } | |
| @@ -272,31 +260,60 @@ func runPush(ctx context.Context, req pushRequest) error { | |||
| 272 | 260 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | |
| 273 | 261 | ||
| 274 | 262 | skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy) | |
| 263 | + | build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs} | |
| 264 | + | if err := pushResolvedImage(ctx, imageOps, build, img); err != nil { | |
| 265 | + | return err | |
| 266 | + | } | |
| 275 | 267 | ||
| 276 | - | build := buildConfig{ | |
| 277 | - | Enabled: req.Build.Enabled, | |
| 278 | - | Platform: req.Build.Platform, | |
| 279 | - | Dockerfile: dockerfile, | |
| 280 | - | BuildArgs: req.Build.BuildArgs, | |
| 268 | + | fmt.Println(styles.RenderSuccess("Push complete")) | |
| 269 | + | if !skipExplicitDeploy { | |
| 270 | + | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) | |
| 281 | 271 | } | |
| 272 | + | return nil | |
| 273 | + | } | |
| 282 | 274 | ||
| 283 | - | if build.Enabled { | |
| 284 | - | if err := buildAndPush(ctx, imageOps, build, img); err != nil { | |
| 285 | - | return err | |
| 286 | - | } | |
| 287 | - | } else { | |
| 288 | - | if err := tagAndPush(ctx, imageOps, img); err != nil { | |
| 275 | + | func resolvePushTarget(ctx context.Context, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) { | |
| 276 | + | dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) | |
| 277 | + | if err != nil { | |
| 278 | + | return "", nil, nil, err | |
| 279 | + | } | |
| 280 | + | inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile) | |
| 281 | + | if err != nil { | |
| 282 | + | return "", nil, nil, err | |
| 283 | + | } | |
| 284 | + | if inferredRemote != nil { | |
| 285 | + | handle = newRemoteControlPlaneHandle(inferredRemote) | |
| 286 | + | fmt.Printf("Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")) | |
| 287 | + | return dockerfile, inferredRemote, handle, nil | |
| 288 | + | } | |
| 289 | + | handle, err = resolveControlPlane(configPath) | |
| 290 | + | if err != nil { | |
| 291 | + | return "", nil, nil, err | |
| 292 | + | } | |
| 293 | + | return dockerfile, nil, handle, nil | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func validateBuildArgsList(buildArgs []string) error { | |
| 297 | + | for _, ba := range buildArgs { | |
| 298 | + | if err := validateBuildArg(ba); err != nil { | |
| 289 | 299 | return err | |
| 290 | 300 | } | |
| 291 | 301 | } | |
| 302 | + | return nil | |
| 303 | + | } | |
| 292 | 304 | ||
| 293 | - | fmt.Println(styles.RenderSuccess("Push complete")) | |
| 294 | - | ||
| 295 | - | if !skipExplicitDeploy { | |
| 296 | - | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) | |
| 305 | + | func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) { | |
| 306 | + | if inferredRemote != nil { | |
| 307 | + | return newImageOpsForResolvedRemote(inferredRemote) | |
| 297 | 308 | } | |
| 309 | + | return newImageOpsFn() | |
| 310 | + | } | |
| 298 | 311 | ||
| 299 | - | return nil | |
| 312 | + | func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error { | |
| 313 | + | if build.Enabled { | |
| 314 | + | return buildAndPush(ctx, imageOps, build, img) | |
| 315 | + | } | |
| 316 | + | return tagAndPush(ctx, imageOps, img) | |
| 300 | 317 | } | |
| 301 | 318 | ||
| 302 | 319 | // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy | |
| @@ -345,15 +362,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||
| 345 | 362 | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | |
| 346 | 363 | } | |
| 347 | 364 | ||
| 348 | - | // Filter out preview routes (domains containing "--") so they don't | |
| 349 | - | // pollute the selection when pushing the base image. | |
| 350 | - | filtered := routes[:0] | |
| 351 | - | for _, r := range routes { | |
| 352 | - | if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) { | |
| 353 | - | filtered = append(filtered, r) | |
| 354 | - | } | |
| 355 | - | } | |
| 356 | - | routes = filtered | |
| 365 | + | routes = filterPreviewRoutes(routes) | |
| 357 | 366 | ||
| 358 | 367 | if len(routes) == 0 { | |
| 359 | 368 | return "", "", "", noRouteForImageError(imageArg) | |
| @@ -226,17 +226,12 @@ func resolveVersion(ctx context.Context, tag string) (string, error) { | |||||
| 226 | } | 226 | } | ||
| 227 | 227 | ||||
| 228 | func runPush(ctx context.Context, req pushRequest) error { | 228 | func runPush(ctx context.Context, req pushRequest) error { | ||
| 229 | - | handle, err := resolveControlPlane(configPath) | 229 | + | dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, req) |
| 230 | if err != nil { | 230 | if err != nil { | ||
| 231 | return err | 231 | return err | ||
| 232 | } | 232 | } | ||
| 233 | defer handle.close() | 233 | defer handle.close() | ||
| 234 | 234 | ||||
| 235 | - | dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) | |||
| 236 | - | if err != nil { | |||
| 237 | - | return err | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile) | 235 | registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile) | ||
| 241 | if err != nil { | 236 | if err != nil { | ||
| 242 | return err | 237 | return err | ||
| @@ -246,21 +241,14 @@ func runPush(ctx context.Context, req pushRequest) error { | |||||
| 246 | if err != nil { | 241 | if err != nil { | ||
| 247 | return err | 242 | return err | ||
| 248 | } | 243 | } | ||
| 249 | - | 244 | + | if err := validateBuildArgsList(req.Build.BuildArgs); err != nil { | |
| 250 | - | for _, ba := range req.Build.BuildArgs { | 245 | + | return err |
| 251 | - | if err := validateBuildArg(ba); err != nil { | |||
| 252 | - | return err | |||
| 253 | - | } | |||
| 254 | } | 246 | } | ||
| 255 | 247 | ||||
| 256 | - | img := imagePush{ | 248 | + | img := imagePush{Registry: registry, ImageName: imageName, Version: version} |
| 257 | - | Registry: registry, | |||
| 258 | - | ImageName: imageName, | |||
| 259 | - | Version: version, | |||
| 260 | - | } | |||
| 261 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | 249 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | ||
| 262 | 250 | ||||
| 263 | - | imageOps, err := newImageOpsFn() | 251 | + | imageOps, err := newPushImageOps(inferredRemote) |
| 264 | if err != nil { | 252 | if err != nil { | ||
| 265 | return err | 253 | return err | ||
| 266 | } | 254 | } | ||
| @@ -272,31 +260,60 @@ func runPush(ctx context.Context, req pushRequest) error { | |||||
| 272 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | 260 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | ||
| 273 | 261 | ||||
| 274 | skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy) | 262 | skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy) | ||
| 263 | + | build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs} | |||
| 264 | + | if err := pushResolvedImage(ctx, imageOps, build, img); err != nil { | |||
| 265 | + | return err | |||
| 266 | + | } | |||
| 275 | 267 | ||||
| 276 | - | build := buildConfig{ | 268 | + | fmt.Println(styles.RenderSuccess("Push complete")) |
| 277 | - | Enabled: req.Build.Enabled, | 269 | + | if !skipExplicitDeploy { |
| 278 | - | Platform: req.Build.Platform, | 270 | + | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) |
| 279 | - | Dockerfile: dockerfile, | |||
| 280 | - | BuildArgs: req.Build.BuildArgs, | |||
| 281 | } | 271 | } | ||
| 272 | + | return nil | |||
| 273 | + | } | |||
| 282 | 274 | ||||
| 283 | - | if build.Enabled { | 275 | + | func resolvePushTarget(ctx context.Context, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) { |
| 284 | - | if err := buildAndPush(ctx, imageOps, build, img); err != nil { | 276 | + | dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled) |
| 285 | - | return err | 277 | + | if err != nil { |
| 286 | - | } | 278 | + | return "", nil, nil, err |
| 287 | - | } else { | 279 | + | } |
| 288 | - | if err := tagAndPush(ctx, imageOps, img); err != nil { | 280 | + | inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile) |
| 281 | + | if err != nil { | |||
| 282 | + | return "", nil, nil, err | |||
| 283 | + | } | |||
| 284 | + | if inferredRemote != nil { | |||
| 285 | + | handle = newRemoteControlPlaneHandle(inferredRemote) | |||
| 286 | + | fmt.Printf("Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")) | |||
| 287 | + | return dockerfile, inferredRemote, handle, nil | |||
| 288 | + | } | |||
| 289 | + | handle, err = resolveControlPlane(configPath) | |||
| 290 | + | if err != nil { | |||
| 291 | + | return "", nil, nil, err | |||
| 292 | + | } | |||
| 293 | + | return dockerfile, nil, handle, nil | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func validateBuildArgsList(buildArgs []string) error { | |||
| 297 | + | for _, ba := range buildArgs { | |||
| 298 | + | if err := validateBuildArg(ba); err != nil { | |||
| 289 | return err | 299 | return err | ||
| 290 | } | 300 | } | ||
| 291 | } | 301 | } | ||
| 302 | + | return nil | |||
| 303 | + | } | |||
| 292 | 304 | ||||
| 293 | - | fmt.Println(styles.RenderSuccess("Push complete")) | 305 | + | func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) { |
| 294 | - | 306 | + | if inferredRemote != nil { | |
| 295 | - | if !skipExplicitDeploy { | 307 | + | return newImageOpsForResolvedRemote(inferredRemote) |
| 296 | - | return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm) | |||
| 297 | } | 308 | } | ||
| 309 | + | return newImageOpsFn() | |||
| 310 | + | } | |||
| 298 | 311 | ||||
| 299 | - | return nil | 312 | + | func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error { |
| 313 | + | if build.Enabled { | |||
| 314 | + | return buildAndPush(ctx, imageOps, build, img) | |||
| 315 | + | } | |||
| 316 | + | return tagAndPush(ctx, imageOps, img) | |||
| 300 | } | 317 | } | ||
| 301 | 318 | ||||
| 302 | // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy | 319 | // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy | ||
| @@ -345,15 +362,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||||
| 345 | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | 362 | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | ||
| 346 | } | 363 | } | ||
| 347 | 364 | ||||
| 348 | - | // Filter out preview routes (domains containing "--") so they don't | 365 | + | routes = filterPreviewRoutes(routes) |
| 349 | - | // pollute the selection when pushing the base image. | |||
| 350 | - | filtered := routes[:0] | |||
| 351 | - | for _, r := range routes { | |||
| 352 | - | if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) { | |||
| 353 | - | filtered = append(filtered, r) | |||
| 354 | - | } | |||
| 355 | - | } | |||
| 356 | - | routes = filtered | |||
| 357 | 366 | ||||
| 358 | if len(routes) == 0 { | 367 | if len(routes) == 0 { | ||
| 359 | return "", "", "", noRouteForImageError(imageArg) | 368 | return "", "", "", noRouteForImageError(imageArg) | ||
M internal/adapters/in/cli/push_image_ops.go +5 -6
| @@ -55,6 +55,10 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e | |||
| 55 | 55 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | |
| 56 | 56 | func newImageOpsFromFlags() (pushImageOps, error) { | |
| 57 | 57 | resolved, _ := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 58 | + | return newImageOpsForResolvedRemote(resolved) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps, error) { | |
| 58 | 62 | insecureTLS := false | |
| 59 | 63 | if resolved != nil { | |
| 60 | 64 | insecureTLS = resolved.InsecureTLS | |
| @@ -64,12 +68,7 @@ func newImageOpsFromFlags() (pushImageOps, error) { | |||
| 64 | 68 | return nil, err | |
| 65 | 69 | } | |
| 66 | 70 | if resolved != nil && resolved.Token != "" { | |
| 67 | - | var clientOpts []remote.ClientOption | |
| 68 | - | clientOpts = append(clientOpts, remote.WithToken(resolved.Token)) | |
| 69 | - | if resolved.InsecureTLS { | |
| 70 | - | clientOpts = append(clientOpts, remote.WithInsecureTLS(true)) | |
| 71 | - | } | |
| 72 | - | ops.remoteClient = remote.NewClient(resolved.URL, clientOpts...) | |
| 71 | + | ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | |
| 73 | 72 | } | |
| 74 | 73 | return ops, nil | |
| 75 | 74 | } | |
| @@ -55,6 +55,10 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e | |||||
| 55 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | 55 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | ||
| 56 | func newImageOpsFromFlags() (pushImageOps, error) { | 56 | func newImageOpsFromFlags() (pushImageOps, error) { | ||
| 57 | resolved, _ := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag) | 57 | resolved, _ := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag) | ||
| 58 | + | return newImageOpsForResolvedRemote(resolved) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps, error) { | |||
| 58 | insecureTLS := false | 62 | insecureTLS := false | ||
| 59 | if resolved != nil { | 63 | if resolved != nil { | ||
| 60 | insecureTLS = resolved.InsecureTLS | 64 | insecureTLS = resolved.InsecureTLS | ||
| @@ -64,12 +68,7 @@ func newImageOpsFromFlags() (pushImageOps, error) { | |||||
| 64 | return nil, err | 68 | return nil, err | ||
| 65 | } | 69 | } | ||
| 66 | if resolved != nil && resolved.Token != "" { | 70 | if resolved != nil && resolved.Token != "" { | ||
| 67 | - | var clientOpts []remote.ClientOption | 71 | + | ops.remoteClient = remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) |
| 68 | - | clientOpts = append(clientOpts, remote.WithToken(resolved.Token)) | |||
| 69 | - | if resolved.InsecureTLS { | |||
| 70 | - | clientOpts = append(clientOpts, remote.WithInsecureTLS(true)) | |||
| 71 | - | } | |||
| 72 | - | ops.remoteClient = remote.NewClient(resolved.URL, clientOpts...) | |||
| 73 | } | 72 | } | ||
| 74 | return ops, nil | 73 | return ops, nil | ||
| 75 | } | 74 | } | ||
A internal/adapters/in/cli/push_remote_inference_test.go +457 -0
| @@ -0,0 +1,457 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "net/url" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) { | |
| 20 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 21 | + | assert.Equal(t, "myapp", image) | |
| 22 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 23 | + | }) | |
| 24 | + | staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 25 | + | assert.Equal(t, "myapp", image) | |
| 26 | + | return nil, http.StatusOK | |
| 27 | + | }) | |
| 28 | + | ||
| 29 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 30 | + | [remotes.prod] | |
| 31 | + | url = "`+prod.URL+`" | |
| 32 | + | ||
| 33 | + | [remotes.staging] | |
| 34 | + | url = "`+staging.URL+`" | |
| 35 | + | `) | |
| 36 | + | ||
| 37 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.NotNil(t, resolved) | |
| 40 | + | assert.Equal(t, "prod", resolved.Name) | |
| 41 | + | assert.Equal(t, prod.URL, resolved.URL) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |
| 45 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 46 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 47 | + | }) | |
| 48 | + | staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 49 | + | return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 50 | + | }) | |
| 51 | + | ||
| 52 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 53 | + | [remotes.prod] | |
| 54 | + | url = "`+prod.URL+`" | |
| 55 | + | ||
| 56 | + | [remotes.staging] | |
| 57 | + | url = "`+staging.URL+`" | |
| 58 | + | `) | |
| 59 | + | ||
| 60 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |
| 61 | + | require.Error(t, err) | |
| 62 | + | assert.Nil(t, resolved) | |
| 63 | + | assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`) | |
| 64 | + | assert.Contains(t, err.Error(), "prod") | |
| 65 | + | assert.Contains(t, err.Error(), "staging") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) { | |
| 69 | + | originalRemoteFlag := remoteFlag | |
| 70 | + | t.Cleanup(func() { | |
| 71 | + | remoteFlag = originalRemoteFlag | |
| 72 | + | }) | |
| 73 | + | ||
| 74 | + | called := false | |
| 75 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 76 | + | called = true | |
| 77 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 78 | + | }) | |
| 79 | + | ||
| 80 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 81 | + | [remotes.prod] | |
| 82 | + | url = "`+prod.URL+`" | |
| 83 | + | `) | |
| 84 | + | t.Setenv("GORDON_REMOTE", "") | |
| 85 | + | remoteFlag = "prod" | |
| 86 | + | ||
| 87 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | assert.Nil(t, resolved) | |
| 90 | + | assert.False(t, called) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) { | |
| 94 | + | called := false | |
| 95 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 96 | + | called = true | |
| 97 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 98 | + | }) | |
| 99 | + | ||
| 100 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 101 | + | active = "prod" | |
| 102 | + | ||
| 103 | + | [remotes.prod] | |
| 104 | + | url = "`+prod.URL+`" | |
| 105 | + | `) | |
| 106 | + | ||
| 107 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | assert.Nil(t, resolved) | |
| 110 | + | assert.False(t, called) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) { | |
| 114 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 115 | + | return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 116 | + | }) | |
| 117 | + | ||
| 118 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 119 | + | [remotes.prod] | |
| 120 | + | url = "`+prod.URL+`" | |
| 121 | + | `) | |
| 122 | + | ||
| 123 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | assert.Nil(t, resolved) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) { | |
| 129 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 130 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |
| 131 | + | }) | |
| 132 | + | broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |
| 133 | + | return nil, http.StatusInternalServerError | |
| 134 | + | }) | |
| 135 | + | ||
| 136 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 137 | + | [remotes.prod] | |
| 138 | + | url = "`+prod.URL+`" | |
| 139 | + | ||
| 140 | + | [remotes.broken] | |
| 141 | + | url = "`+broken.URL+`" | |
| 142 | + | `) | |
| 143 | + | ||
| 144 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |
| 145 | + | require.Error(t, err) | |
| 146 | + | assert.Nil(t, resolved) | |
| 147 | + | assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`) | |
| 148 | + | assert.Contains(t, err.Error(), "broken") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) { | |
| 152 | + | prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |
| 153 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |
| 154 | + | }) | |
| 155 | + | staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |
| 156 | + | return nil, http.StatusNotFound | |
| 157 | + | }) | |
| 158 | + | ||
| 159 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 160 | + | [remotes.prod] | |
| 161 | + | url = "`+prod.URL+`" | |
| 162 | + | ||
| 163 | + | [remotes.staging] | |
| 164 | + | url = "`+staging.URL+`" | |
| 165 | + | `) | |
| 166 | + | ||
| 167 | + | resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com") | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | require.NotNil(t, resolved) | |
| 170 | + | assert.Equal(t, "prod", resolved.Name) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |
| 174 | + | prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |
| 175 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |
| 176 | + | }) | |
| 177 | + | staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |
| 178 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |
| 179 | + | }) | |
| 180 | + | ||
| 181 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 182 | + | [remotes.prod] | |
| 183 | + | url = "`+prod.URL+`" | |
| 184 | + | ||
| 185 | + | [remotes.staging] | |
| 186 | + | url = "`+staging.URL+`" | |
| 187 | + | `) | |
| 188 | + | ||
| 189 | + | resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com") | |
| 190 | + | require.Error(t, err) | |
| 191 | + | assert.Nil(t, resolved) | |
| 192 | + | assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) { | |
| 196 | + | prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) { | |
| 197 | + | return []string{"app.example.com"}, http.StatusOK | |
| 198 | + | }) | |
| 199 | + | staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) { | |
| 200 | + | return nil, http.StatusOK | |
| 201 | + | }) | |
| 202 | + | ||
| 203 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 204 | + | [remotes.prod] | |
| 205 | + | url = "`+prod.URL+`" | |
| 206 | + | ||
| 207 | + | [remotes.staging] | |
| 208 | + | url = "`+staging.URL+`" | |
| 209 | + | `) | |
| 210 | + | ||
| 211 | + | resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18") | |
| 212 | + | require.NoError(t, err) | |
| 213 | + | require.NotNil(t, resolved) | |
| 214 | + | assert.Equal(t, "prod", resolved.Name) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) { | |
| 218 | + | prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{ | |
| 219 | + | getRoute: func(domainName string) (*domain.Route, int) { | |
| 220 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |
| 221 | + | }, | |
| 222 | + | getAttachmentsConfig: func(target string) ([]string, int) { | |
| 223 | + | return nil, http.StatusNotFound | |
| 224 | + | }, | |
| 225 | + | }) | |
| 226 | + | staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{ | |
| 227 | + | getRoute: func(domainName string) (*domain.Route, int) { | |
| 228 | + | return nil, http.StatusNotFound | |
| 229 | + | }, | |
| 230 | + | getAttachmentsConfig: func(target string) ([]string, int) { | |
| 231 | + | return nil, http.StatusNotFound | |
| 232 | + | }, | |
| 233 | + | }) | |
| 234 | + | ||
| 235 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 236 | + | [remotes.prod] | |
| 237 | + | url = "`+prod.URL+`" | |
| 238 | + | ||
| 239 | + | [remotes.staging] | |
| 240 | + | url = "`+staging.URL+`" | |
| 241 | + | `) | |
| 242 | + | ||
| 243 | + | resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com") | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | require.NotNil(t, resolved) | |
| 246 | + | assert.Equal(t, "prod", resolved.Name) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |
| 250 | + | prod := newTagsTestServer(t, func(repository string) ([]string, int) { | |
| 251 | + | return []string{"latest", "v1.0.0"}, http.StatusOK | |
| 252 | + | }) | |
| 253 | + | staging := newTagsTestServer(t, func(repository string) ([]string, int) { | |
| 254 | + | return []string{"latest"}, http.StatusOK | |
| 255 | + | }) | |
| 256 | + | ||
| 257 | + | configurePushRemoteInferenceTestEnv(t, ` | |
| 258 | + | [remotes.prod] | |
| 259 | + | url = "`+prod.URL+`" | |
| 260 | + | ||
| 261 | + | [remotes.staging] | |
| 262 | + | url = "`+staging.URL+`" | |
| 263 | + | `) | |
| 264 | + | ||
| 265 | + | resolved, err := inferRemoteForRepository(context.Background(), "myapp") | |
| 266 | + | require.Error(t, err) | |
| 267 | + | assert.Nil(t, resolved) | |
| 268 | + | assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) { | |
| 272 | + | t.Helper() | |
| 273 | + | ||
| 274 | + | originalRemoteFlag := remoteFlag | |
| 275 | + | originalTokenFlag := tokenFlag | |
| 276 | + | originalInsecureTLSFlag := insecureTLSFlag | |
| 277 | + | t.Cleanup(func() { | |
| 278 | + | remoteFlag = originalRemoteFlag | |
| 279 | + | tokenFlag = originalTokenFlag | |
| 280 | + | insecureTLSFlag = originalInsecureTLSFlag | |
| 281 | + | }) | |
| 282 | + | ||
| 283 | + | remoteFlag = "" | |
| 284 | + | tokenFlag = "" | |
| 285 | + | insecureTLSFlag = false | |
| 286 | + | t.Setenv("GORDON_REMOTE", "") | |
| 287 | + | t.Setenv("GORDON_TOKEN", "") | |
| 288 | + | t.Setenv("GORDON_INSECURE", "") | |
| 289 | + | t.Setenv("HOME", t.TempDir()) | |
| 290 | + | ||
| 291 | + | configHome := t.TempDir() | |
| 292 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |
| 293 | + | configPath := filepath.Join(configHome, "gordon", "remotes.toml") | |
| 294 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) | |
| 295 | + | require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600)) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server { | |
| 299 | + | t.Helper() | |
| 300 | + | ||
| 301 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 302 | + | if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") { | |
| 303 | + | http.NotFound(w, r) | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/")) | |
| 308 | + | require.NoError(t, err) | |
| 309 | + | ||
| 310 | + | routes, status := handler(image) | |
| 311 | + | if status >= http.StatusBadRequest { | |
| 312 | + | w.Header().Set("Content-Type", "application/json") | |
| 313 | + | w.WriteHeader(status) | |
| 314 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |
| 315 | + | return | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | w.Header().Set("Content-Type", "application/json") | |
| 319 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ | |
| 320 | + | "image": image, | |
| 321 | + | "routes": routes, | |
| 322 | + | })) | |
| 323 | + | })) | |
| 324 | + | t.Cleanup(server.Close) | |
| 325 | + | return server | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server { | |
| 329 | + | t.Helper() | |
| 330 | + | return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler}) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | type multiAdminProbeHandlers struct { | |
| 334 | + | getRoute func(domainName string) (*domain.Route, int) | |
| 335 | + | getAttachmentsConfig func(target string) ([]string, int) | |
| 336 | + | listTags func(repository string) ([]string, int) | |
| 337 | + | findAttachmentTarget func(image string) ([]string, int) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server { | |
| 341 | + | t.Helper() | |
| 342 | + | ||
| 343 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 344 | + | switch { | |
| 345 | + | case strings.HasPrefix(r.URL.Path, "/admin/routes/"): | |
| 346 | + | handleGetRouteProbe(t, w, r, handlers.getRoute) | |
| 347 | + | case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"): | |
| 348 | + | handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget) | |
| 349 | + | case strings.HasPrefix(r.URL.Path, "/admin/attachments/"): | |
| 350 | + | handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig) | |
| 351 | + | case strings.HasPrefix(r.URL.Path, "/admin/tags/"): | |
| 352 | + | handleListTagsProbe(t, w, r, handlers.listTags) | |
| 353 | + | default: | |
| 354 | + | http.NotFound(w, r) | |
| 355 | + | } | |
| 356 | + | })) | |
| 357 | + | t.Cleanup(server.Close) | |
| 358 | + | return server | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server { | |
| 362 | + | t.Helper() | |
| 363 | + | return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler}) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server { | |
| 367 | + | t.Helper() | |
| 368 | + | return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler}) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) { | |
| 372 | + | t.Helper() | |
| 373 | + | if handler == nil { | |
| 374 | + | http.NotFound(w, r) | |
| 375 | + | return | |
| 376 | + | } | |
| 377 | + | domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/")) | |
| 378 | + | require.NoError(t, err) | |
| 379 | + | route, status := handler(domainName) | |
| 380 | + | if status >= http.StatusBadRequest { | |
| 381 | + | w.Header().Set("Content-Type", "application/json") | |
| 382 | + | w.WriteHeader(status) | |
| 383 | + | if status == http.StatusNotFound { | |
| 384 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) | |
| 385 | + | return | |
| 386 | + | } | |
| 387 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |
| 388 | + | return | |
| 389 | + | } | |
| 390 | + | w.Header().Set("Content-Type", "application/json") | |
| 391 | + | require.NoError(t, json.NewEncoder(w).Encode(route)) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) { | |
| 395 | + | t.Helper() | |
| 396 | + | if handler == nil { | |
| 397 | + | http.NotFound(w, r) | |
| 398 | + | return | |
| 399 | + | } | |
| 400 | + | image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/")) | |
| 401 | + | require.NoError(t, err) | |
| 402 | + | targets, status := handler(image) | |
| 403 | + | if status >= http.StatusBadRequest { | |
| 404 | + | w.Header().Set("Content-Type", "application/json") | |
| 405 | + | w.WriteHeader(status) | |
| 406 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |
| 407 | + | return | |
| 408 | + | } | |
| 409 | + | w.Header().Set("Content-Type", "application/json") | |
| 410 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) { | |
| 414 | + | t.Helper() | |
| 415 | + | if handler == nil { | |
| 416 | + | http.NotFound(w, r) | |
| 417 | + | return | |
| 418 | + | } | |
| 419 | + | target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/")) | |
| 420 | + | require.NoError(t, err) | |
| 421 | + | images, status := handler(target) | |
| 422 | + | if status >= http.StatusBadRequest { | |
| 423 | + | w.Header().Set("Content-Type", "application/json") | |
| 424 | + | w.WriteHeader(status) | |
| 425 | + | if status == http.StatusNotFound { | |
| 426 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) | |
| 427 | + | return | |
| 428 | + | } | |
| 429 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |
| 430 | + | return | |
| 431 | + | } | |
| 432 | + | w.Header().Set("Content-Type", "application/json") | |
| 433 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) { | |
| 437 | + | t.Helper() | |
| 438 | + | if handler == nil { | |
| 439 | + | http.NotFound(w, r) | |
| 440 | + | return | |
| 441 | + | } | |
| 442 | + | repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/")) | |
| 443 | + | require.NoError(t, err) | |
| 444 | + | tags, status := handler(repository) | |
| 445 | + | if status >= http.StatusBadRequest { | |
| 446 | + | w.Header().Set("Content-Type", "application/json") | |
| 447 | + | w.WriteHeader(status) | |
| 448 | + | if status == http.StatusNotFound { | |
| 449 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) | |
| 450 | + | return | |
| 451 | + | } | |
| 452 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |
| 453 | + | return | |
| 454 | + | } | |
| 455 | + | w.Header().Set("Content-Type", "application/json") | |
| 456 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) | |
| 457 | + | } | |
| @@ -0,0 +1,457 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "net/url" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) { | |||
| 20 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 21 | + | assert.Equal(t, "myapp", image) | |||
| 22 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |||
| 23 | + | }) | |||
| 24 | + | staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 25 | + | assert.Equal(t, "myapp", image) | |||
| 26 | + | return nil, http.StatusOK | |||
| 27 | + | }) | |||
| 28 | + | ||||
| 29 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 30 | + | [remotes.prod] | |||
| 31 | + | url = "`+prod.URL+`" | |||
| 32 | + | ||||
| 33 | + | [remotes.staging] | |||
| 34 | + | url = "`+staging.URL+`" | |||
| 35 | + | `) | |||
| 36 | + | ||||
| 37 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | require.NotNil(t, resolved) | |||
| 40 | + | assert.Equal(t, "prod", resolved.Name) | |||
| 41 | + | assert.Equal(t, prod.URL, resolved.URL) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |||
| 45 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 46 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |||
| 47 | + | }) | |||
| 48 | + | staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 49 | + | return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |||
| 50 | + | }) | |||
| 51 | + | ||||
| 52 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 53 | + | [remotes.prod] | |||
| 54 | + | url = "`+prod.URL+`" | |||
| 55 | + | ||||
| 56 | + | [remotes.staging] | |||
| 57 | + | url = "`+staging.URL+`" | |||
| 58 | + | `) | |||
| 59 | + | ||||
| 60 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |||
| 61 | + | require.Error(t, err) | |||
| 62 | + | assert.Nil(t, resolved) | |||
| 63 | + | assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`) | |||
| 64 | + | assert.Contains(t, err.Error(), "prod") | |||
| 65 | + | assert.Contains(t, err.Error(), "staging") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) { | |||
| 69 | + | originalRemoteFlag := remoteFlag | |||
| 70 | + | t.Cleanup(func() { | |||
| 71 | + | remoteFlag = originalRemoteFlag | |||
| 72 | + | }) | |||
| 73 | + | ||||
| 74 | + | called := false | |||
| 75 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 76 | + | called = true | |||
| 77 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |||
| 78 | + | }) | |||
| 79 | + | ||||
| 80 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 81 | + | [remotes.prod] | |||
| 82 | + | url = "`+prod.URL+`" | |||
| 83 | + | `) | |||
| 84 | + | t.Setenv("GORDON_REMOTE", "") | |||
| 85 | + | remoteFlag = "prod" | |||
| 86 | + | ||||
| 87 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | assert.Nil(t, resolved) | |||
| 90 | + | assert.False(t, called) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) { | |||
| 94 | + | called := false | |||
| 95 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 96 | + | called = true | |||
| 97 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |||
| 98 | + | }) | |||
| 99 | + | ||||
| 100 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 101 | + | active = "prod" | |||
| 102 | + | ||||
| 103 | + | [remotes.prod] | |||
| 104 | + | url = "`+prod.URL+`" | |||
| 105 | + | `) | |||
| 106 | + | ||||
| 107 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | assert.Nil(t, resolved) | |||
| 110 | + | assert.False(t, called) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) { | |||
| 114 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 115 | + | return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |||
| 116 | + | }) | |||
| 117 | + | ||||
| 118 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 119 | + | [remotes.prod] | |||
| 120 | + | url = "`+prod.URL+`" | |||
| 121 | + | `) | |||
| 122 | + | ||||
| 123 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | assert.Nil(t, resolved) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) { | |||
| 129 | + | prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 130 | + | return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK | |||
| 131 | + | }) | |||
| 132 | + | broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) { | |||
| 133 | + | return nil, http.StatusInternalServerError | |||
| 134 | + | }) | |||
| 135 | + | ||||
| 136 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 137 | + | [remotes.prod] | |||
| 138 | + | url = "`+prod.URL+`" | |||
| 139 | + | ||||
| 140 | + | [remotes.broken] | |||
| 141 | + | url = "`+broken.URL+`" | |||
| 142 | + | `) | |||
| 143 | + | ||||
| 144 | + | resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile") | |||
| 145 | + | require.Error(t, err) | |||
| 146 | + | assert.Nil(t, resolved) | |||
| 147 | + | assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`) | |||
| 148 | + | assert.Contains(t, err.Error(), "broken") | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) { | |||
| 152 | + | prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |||
| 153 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |||
| 154 | + | }) | |||
| 155 | + | staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |||
| 156 | + | return nil, http.StatusNotFound | |||
| 157 | + | }) | |||
| 158 | + | ||||
| 159 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 160 | + | [remotes.prod] | |||
| 161 | + | url = "`+prod.URL+`" | |||
| 162 | + | ||||
| 163 | + | [remotes.staging] | |||
| 164 | + | url = "`+staging.URL+`" | |||
| 165 | + | `) | |||
| 166 | + | ||||
| 167 | + | resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com") | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | require.NotNil(t, resolved) | |||
| 170 | + | assert.Equal(t, "prod", resolved.Name) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |||
| 174 | + | prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |||
| 175 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |||
| 176 | + | }) | |||
| 177 | + | staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) { | |||
| 178 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |||
| 179 | + | }) | |||
| 180 | + | ||||
| 181 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 182 | + | [remotes.prod] | |||
| 183 | + | url = "`+prod.URL+`" | |||
| 184 | + | ||||
| 185 | + | [remotes.staging] | |||
| 186 | + | url = "`+staging.URL+`" | |||
| 187 | + | `) | |||
| 188 | + | ||||
| 189 | + | resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com") | |||
| 190 | + | require.Error(t, err) | |||
| 191 | + | assert.Nil(t, resolved) | |||
| 192 | + | assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) { | |||
| 196 | + | prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) { | |||
| 197 | + | return []string{"app.example.com"}, http.StatusOK | |||
| 198 | + | }) | |||
| 199 | + | staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) { | |||
| 200 | + | return nil, http.StatusOK | |||
| 201 | + | }) | |||
| 202 | + | ||||
| 203 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 204 | + | [remotes.prod] | |||
| 205 | + | url = "`+prod.URL+`" | |||
| 206 | + | ||||
| 207 | + | [remotes.staging] | |||
| 208 | + | url = "`+staging.URL+`" | |||
| 209 | + | `) | |||
| 210 | + | ||||
| 211 | + | resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18") | |||
| 212 | + | require.NoError(t, err) | |||
| 213 | + | require.NotNil(t, resolved) | |||
| 214 | + | assert.Equal(t, "prod", resolved.Name) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) { | |||
| 218 | + | prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{ | |||
| 219 | + | getRoute: func(domainName string) (*domain.Route, int) { | |||
| 220 | + | return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK | |||
| 221 | + | }, | |||
| 222 | + | getAttachmentsConfig: func(target string) ([]string, int) { | |||
| 223 | + | return nil, http.StatusNotFound | |||
| 224 | + | }, | |||
| 225 | + | }) | |||
| 226 | + | staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{ | |||
| 227 | + | getRoute: func(domainName string) (*domain.Route, int) { | |||
| 228 | + | return nil, http.StatusNotFound | |||
| 229 | + | }, | |||
| 230 | + | getAttachmentsConfig: func(target string) ([]string, int) { | |||
| 231 | + | return nil, http.StatusNotFound | |||
| 232 | + | }, | |||
| 233 | + | }) | |||
| 234 | + | ||||
| 235 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 236 | + | [remotes.prod] | |||
| 237 | + | url = "`+prod.URL+`" | |||
| 238 | + | ||||
| 239 | + | [remotes.staging] | |||
| 240 | + | url = "`+staging.URL+`" | |||
| 241 | + | `) | |||
| 242 | + | ||||
| 243 | + | resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com") | |||
| 244 | + | require.NoError(t, err) | |||
| 245 | + | require.NotNil(t, resolved) | |||
| 246 | + | assert.Equal(t, "prod", resolved.Name) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) { | |||
| 250 | + | prod := newTagsTestServer(t, func(repository string) ([]string, int) { | |||
| 251 | + | return []string{"latest", "v1.0.0"}, http.StatusOK | |||
| 252 | + | }) | |||
| 253 | + | staging := newTagsTestServer(t, func(repository string) ([]string, int) { | |||
| 254 | + | return []string{"latest"}, http.StatusOK | |||
| 255 | + | }) | |||
| 256 | + | ||||
| 257 | + | configurePushRemoteInferenceTestEnv(t, ` | |||
| 258 | + | [remotes.prod] | |||
| 259 | + | url = "`+prod.URL+`" | |||
| 260 | + | ||||
| 261 | + | [remotes.staging] | |||
| 262 | + | url = "`+staging.URL+`" | |||
| 263 | + | `) | |||
| 264 | + | ||||
| 265 | + | resolved, err := inferRemoteForRepository(context.Background(), "myapp") | |||
| 266 | + | require.Error(t, err) | |||
| 267 | + | assert.Nil(t, resolved) | |||
| 268 | + | assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) { | |||
| 272 | + | t.Helper() | |||
| 273 | + | ||||
| 274 | + | originalRemoteFlag := remoteFlag | |||
| 275 | + | originalTokenFlag := tokenFlag | |||
| 276 | + | originalInsecureTLSFlag := insecureTLSFlag | |||
| 277 | + | t.Cleanup(func() { | |||
| 278 | + | remoteFlag = originalRemoteFlag | |||
| 279 | + | tokenFlag = originalTokenFlag | |||
| 280 | + | insecureTLSFlag = originalInsecureTLSFlag | |||
| 281 | + | }) | |||
| 282 | + | ||||
| 283 | + | remoteFlag = "" | |||
| 284 | + | tokenFlag = "" | |||
| 285 | + | insecureTLSFlag = false | |||
| 286 | + | t.Setenv("GORDON_REMOTE", "") | |||
| 287 | + | t.Setenv("GORDON_TOKEN", "") | |||
| 288 | + | t.Setenv("GORDON_INSECURE", "") | |||
| 289 | + | t.Setenv("HOME", t.TempDir()) | |||
| 290 | + | ||||
| 291 | + | configHome := t.TempDir() | |||
| 292 | + | t.Setenv("XDG_CONFIG_HOME", configHome) | |||
| 293 | + | configPath := filepath.Join(configHome, "gordon", "remotes.toml") | |||
| 294 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) | |||
| 295 | + | require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600)) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server { | |||
| 299 | + | t.Helper() | |||
| 300 | + | ||||
| 301 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 302 | + | if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") { | |||
| 303 | + | http.NotFound(w, r) | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/")) | |||
| 308 | + | require.NoError(t, err) | |||
| 309 | + | ||||
| 310 | + | routes, status := handler(image) | |||
| 311 | + | if status >= http.StatusBadRequest { | |||
| 312 | + | w.Header().Set("Content-Type", "application/json") | |||
| 313 | + | w.WriteHeader(status) | |||
| 314 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |||
| 315 | + | return | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | w.Header().Set("Content-Type", "application/json") | |||
| 319 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ | |||
| 320 | + | "image": image, | |||
| 321 | + | "routes": routes, | |||
| 322 | + | })) | |||
| 323 | + | })) | |||
| 324 | + | t.Cleanup(server.Close) | |||
| 325 | + | return server | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server { | |||
| 329 | + | t.Helper() | |||
| 330 | + | return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler}) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | type multiAdminProbeHandlers struct { | |||
| 334 | + | getRoute func(domainName string) (*domain.Route, int) | |||
| 335 | + | getAttachmentsConfig func(target string) ([]string, int) | |||
| 336 | + | listTags func(repository string) ([]string, int) | |||
| 337 | + | findAttachmentTarget func(image string) ([]string, int) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server { | |||
| 341 | + | t.Helper() | |||
| 342 | + | ||||
| 343 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 344 | + | switch { | |||
| 345 | + | case strings.HasPrefix(r.URL.Path, "/admin/routes/"): | |||
| 346 | + | handleGetRouteProbe(t, w, r, handlers.getRoute) | |||
| 347 | + | case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"): | |||
| 348 | + | handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget) | |||
| 349 | + | case strings.HasPrefix(r.URL.Path, "/admin/attachments/"): | |||
| 350 | + | handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig) | |||
| 351 | + | case strings.HasPrefix(r.URL.Path, "/admin/tags/"): | |||
| 352 | + | handleListTagsProbe(t, w, r, handlers.listTags) | |||
| 353 | + | default: | |||
| 354 | + | http.NotFound(w, r) | |||
| 355 | + | } | |||
| 356 | + | })) | |||
| 357 | + | t.Cleanup(server.Close) | |||
| 358 | + | return server | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server { | |||
| 362 | + | t.Helper() | |||
| 363 | + | return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler}) | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server { | |||
| 367 | + | t.Helper() | |||
| 368 | + | return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler}) | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) { | |||
| 372 | + | t.Helper() | |||
| 373 | + | if handler == nil { | |||
| 374 | + | http.NotFound(w, r) | |||
| 375 | + | return | |||
| 376 | + | } | |||
| 377 | + | domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/")) | |||
| 378 | + | require.NoError(t, err) | |||
| 379 | + | route, status := handler(domainName) | |||
| 380 | + | if status >= http.StatusBadRequest { | |||
| 381 | + | w.Header().Set("Content-Type", "application/json") | |||
| 382 | + | w.WriteHeader(status) | |||
| 383 | + | if status == http.StatusNotFound { | |||
| 384 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) | |||
| 385 | + | return | |||
| 386 | + | } | |||
| 387 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |||
| 388 | + | return | |||
| 389 | + | } | |||
| 390 | + | w.Header().Set("Content-Type", "application/json") | |||
| 391 | + | require.NoError(t, json.NewEncoder(w).Encode(route)) | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) { | |||
| 395 | + | t.Helper() | |||
| 396 | + | if handler == nil { | |||
| 397 | + | http.NotFound(w, r) | |||
| 398 | + | return | |||
| 399 | + | } | |||
| 400 | + | image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/")) | |||
| 401 | + | require.NoError(t, err) | |||
| 402 | + | targets, status := handler(image) | |||
| 403 | + | if status >= http.StatusBadRequest { | |||
| 404 | + | w.Header().Set("Content-Type", "application/json") | |||
| 405 | + | w.WriteHeader(status) | |||
| 406 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |||
| 407 | + | return | |||
| 408 | + | } | |||
| 409 | + | w.Header().Set("Content-Type", "application/json") | |||
| 410 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) { | |||
| 414 | + | t.Helper() | |||
| 415 | + | if handler == nil { | |||
| 416 | + | http.NotFound(w, r) | |||
| 417 | + | return | |||
| 418 | + | } | |||
| 419 | + | target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/")) | |||
| 420 | + | require.NoError(t, err) | |||
| 421 | + | images, status := handler(target) | |||
| 422 | + | if status >= http.StatusBadRequest { | |||
| 423 | + | w.Header().Set("Content-Type", "application/json") | |||
| 424 | + | w.WriteHeader(status) | |||
| 425 | + | if status == http.StatusNotFound { | |||
| 426 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) | |||
| 427 | + | return | |||
| 428 | + | } | |||
| 429 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |||
| 430 | + | return | |||
| 431 | + | } | |||
| 432 | + | w.Header().Set("Content-Type", "application/json") | |||
| 433 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) { | |||
| 437 | + | t.Helper() | |||
| 438 | + | if handler == nil { | |||
| 439 | + | http.NotFound(w, r) | |||
| 440 | + | return | |||
| 441 | + | } | |||
| 442 | + | repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/")) | |||
| 443 | + | require.NoError(t, err) | |||
| 444 | + | tags, status := handler(repository) | |||
| 445 | + | if status >= http.StatusBadRequest { | |||
| 446 | + | w.Header().Set("Content-Type", "application/json") | |||
| 447 | + | w.WriteHeader(status) | |||
| 448 | + | if status == http.StatusNotFound { | |||
| 449 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) | |||
| 450 | + | return | |||
| 451 | + | } | |||
| 452 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) | |||
| 453 | + | return | |||
| 454 | + | } | |||
| 455 | + | w.Header().Set("Content-Type", "application/json") | |||
| 456 | + | require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) | |||
| 457 | + | } | |||
A internal/adapters/in/cli/remote_inference.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | "github.com/bnema/gordon/pkg/validation" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error) | |
| 16 | + | ||
| 17 | + | func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile string) (*remote.ResolvedRemote, error) { | |
| 18 | + | if domainFlag != "" { | |
| 19 | + | return inferRemoteForRouteDomain(ctx, domainFlag) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | if imageArg == "" { | |
| 23 | + | detected, err := detectImageName(dockerfile) | |
| 24 | + | if err != nil { | |
| 25 | + | return nil, err | |
| 26 | + | } | |
| 27 | + | imageArg = detected | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | if looksLikeLegacyDomain(imageArg) { | |
| 31 | + | lookupImage := imageArg | |
| 32 | + | domainLookupArg := imageArg | |
| 33 | + | if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg { | |
| 34 | + | domainLookupArg = parsedImage | |
| 35 | + | if !strings.HasPrefix(ref, "sha256:") { | |
| 36 | + | lookupImage = parsedImage | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | resolved, err := inferRemoteForImage(ctx, lookupImage) | |
| 41 | + | if err != nil || resolved != nil { | |
| 42 | + | return resolved, err | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | return inferRemoteForRouteDomain(ctx, domainLookupArg) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | classified := classifyPushArgument(imageArg) | |
| 49 | + | return inferRemoteForImage(ctx, classified.lookupImage) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) { | |
| 53 | + | return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 54 | + | routes, err := client.FindRoutesByImage(ctx, imageName) | |
| 55 | + | if err != nil { | |
| 56 | + | return false, err | |
| 57 | + | } | |
| 58 | + | return len(filterPreviewRoutes(routes)) > 0, nil | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) { | |
| 63 | + | return inferSavedRemote(ctx, "route", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 64 | + | _, err := client.GetRoute(ctx, routeDomain) | |
| 65 | + | if err != nil { | |
| 66 | + | if isRemoteNotFoundError(err) { | |
| 67 | + | return false, nil | |
| 68 | + | } | |
| 69 | + | return false, err | |
| 70 | + | } | |
| 71 | + | return true, nil | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) { | |
| 76 | + | return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 77 | + | targets, err := client.FindAttachmentTargetsByImage(ctx, imageName) | |
| 78 | + | if err != nil { | |
| 79 | + | return false, err | |
| 80 | + | } | |
| 81 | + | return len(targets) > 0, nil | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func inferRemoteForAttachmentTarget(ctx context.Context, target string) (*remote.ResolvedRemote, error) { | |
| 86 | + | return inferSavedRemote(ctx, "attachment target", target, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 87 | + | images, err := client.GetAttachmentsConfig(ctx, target) | |
| 88 | + | if err == nil { | |
| 89 | + | return len(images) > 0, nil | |
| 90 | + | } | |
| 91 | + | if !isRemoteNotFoundError(err) { | |
| 92 | + | return false, err | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | _, err = client.GetRoute(ctx, target) | |
| 96 | + | if err != nil { | |
| 97 | + | if isRemoteNotFoundError(err) { | |
| 98 | + | return false, nil | |
| 99 | + | } | |
| 100 | + | return false, err | |
| 101 | + | } | |
| 102 | + | return true, nil | |
| 103 | + | }) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func inferRemoteForRepository(ctx context.Context, repository string) (*remote.ResolvedRemote, error) { | |
| 107 | + | return inferSavedRemote(ctx, "repository", repository, func(ctx context.Context, client *remote.Client) (bool, error) { | |
| 108 | + | tags, err := client.ListTags(ctx, repository) | |
| 109 | + | if err != nil { | |
| 110 | + | if isRemoteNotFoundError(err) { | |
| 111 | + | return false, nil | |
| 112 | + | } | |
| 113 | + | return false, err | |
| 114 | + | } | |
| 115 | + | return len(tags) > 0, nil | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func inferSavedRemote(ctx context.Context, targetKind, target string, probe remoteInferenceProbe) (*remote.ResolvedRemote, error) { | |
| 120 | + | remotes, ok := loadInferenceCandidateRemotes() | |
| 121 | + | if !ok { | |
| 122 | + | return nil, nil | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | names := sortedRemoteNames(remotes) | |
| 126 | + | matches := make([]*remote.ResolvedRemote, 0, len(names)) | |
| 127 | + | probeFailures := make([]string, 0) | |
| 128 | + | ||
| 129 | + | for _, name := range names { | |
| 130 | + | entry := remotes[name] | |
| 131 | + | matched, err := probe(ctx, newRoutesTargetClient(name, entry)) | |
| 132 | + | if err != nil { | |
| 133 | + | probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err)) | |
| 134 | + | continue | |
| 135 | + | } | |
| 136 | + | if matched { | |
| 137 | + | matches = append(matches, resolvedRemoteFromEntry(name, entry)) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | if len(probeFailures) > 0 { | |
| 142 | + | return nil, fmt.Errorf("could not safely infer remote for %s %q because probing failed for: %s; use --remote", targetKind, target, strings.Join(probeFailures, ", ")) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | if len(matches) > 1 { | |
| 146 | + | names := make([]string, 0, len(matches)) | |
| 147 | + | for _, match := range matches { | |
| 148 | + | names = append(names, match.DisplayName()) | |
| 149 | + | } | |
| 150 | + | return nil, fmt.Errorf("multiple saved remotes match %s %q: %s; use --remote", targetKind, target, strings.Join(names, ", ")) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | if len(matches) == 1 { | |
| 154 | + | return matches[0], nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | return nil, nil | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) { | |
| 161 | + | if _, ok := resolveRoutesExplicitTarget(); ok { | |
| 162 | + | return nil, false | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | remotes, err := remote.LoadRemotes("") | |
| 166 | + | if err != nil || remotes == nil { | |
| 167 | + | return nil, false | |
| 168 | + | } | |
| 169 | + | if remotes.Active != "" { | |
| 170 | + | return nil, false | |
| 171 | + | } | |
| 172 | + | if len(remotes.Remotes) == 0 { | |
| 173 | + | return nil, false | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | return remotes.Remotes, true | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func resolvedRemoteFromEntry(name string, entry remote.RemoteEntry) *remote.ResolvedRemote { | |
| 180 | + | return &remote.ResolvedRemote{ | |
| 181 | + | Name: name, | |
| 182 | + | URL: entry.URL, | |
| 183 | + | Token: resolveRoutesTokenForTarget(name, entry), | |
| 184 | + | InsecureTLS: resolveRoutesInsecureForTarget(name, entry), | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func isRemoteNotFoundError(err error) bool { | |
| 189 | + | var httpErr *remote.HTTPError | |
| 190 | + | if errors.As(err, &httpErr) { | |
| 191 | + | return httpErr.StatusCode == http.StatusNotFound | |
| 192 | + | } | |
| 193 | + | return errors.Is(err, domain.ErrRouteNotFound) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func filterPreviewRoutes(routes []domain.Route) []domain.Route { | |
| 197 | + | filtered := routes[:0] | |
| 198 | + | for _, route := range routes { | |
| 199 | + | if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) { | |
| 200 | + | filtered = append(filtered, route) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | return filtered | |
| 204 | + | } | |
| @@ -0,0 +1,204 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | "github.com/bnema/gordon/pkg/validation" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error) | |||
| 16 | + | ||||
| 17 | + | func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile string) (*remote.ResolvedRemote, error) { | |||
| 18 | + | if domainFlag != "" { | |||
| 19 | + | return inferRemoteForRouteDomain(ctx, domainFlag) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | if imageArg == "" { | |||
| 23 | + | detected, err := detectImageName(dockerfile) | |||
| 24 | + | if err != nil { | |||
| 25 | + | return nil, err | |||
| 26 | + | } | |||
| 27 | + | imageArg = detected | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | if looksLikeLegacyDomain(imageArg) { | |||
| 31 | + | lookupImage := imageArg | |||
| 32 | + | domainLookupArg := imageArg | |||
| 33 | + | if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg { | |||
| 34 | + | domainLookupArg = parsedImage | |||
| 35 | + | if !strings.HasPrefix(ref, "sha256:") { | |||
| 36 | + | lookupImage = parsedImage | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | resolved, err := inferRemoteForImage(ctx, lookupImage) | |||
| 41 | + | if err != nil || resolved != nil { | |||
| 42 | + | return resolved, err | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | return inferRemoteForRouteDomain(ctx, domainLookupArg) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | classified := classifyPushArgument(imageArg) | |||
| 49 | + | return inferRemoteForImage(ctx, classified.lookupImage) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) { | |||
| 53 | + | return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) { | |||
| 54 | + | routes, err := client.FindRoutesByImage(ctx, imageName) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return false, err | |||
| 57 | + | } | |||
| 58 | + | return len(filterPreviewRoutes(routes)) > 0, nil | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) { | |||
| 63 | + | return inferSavedRemote(ctx, "route", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) { | |||
| 64 | + | _, err := client.GetRoute(ctx, routeDomain) | |||
| 65 | + | if err != nil { | |||
| 66 | + | if isRemoteNotFoundError(err) { | |||
| 67 | + | return false, nil | |||
| 68 | + | } | |||
| 69 | + | return false, err | |||
| 70 | + | } | |||
| 71 | + | return true, nil | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) { | |||
| 76 | + | return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) { | |||
| 77 | + | targets, err := client.FindAttachmentTargetsByImage(ctx, imageName) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return false, err | |||
| 80 | + | } | |||
| 81 | + | return len(targets) > 0, nil | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func inferRemoteForAttachmentTarget(ctx context.Context, target string) (*remote.ResolvedRemote, error) { | |||
| 86 | + | return inferSavedRemote(ctx, "attachment target", target, func(ctx context.Context, client *remote.Client) (bool, error) { | |||
| 87 | + | images, err := client.GetAttachmentsConfig(ctx, target) | |||
| 88 | + | if err == nil { | |||
| 89 | + | return len(images) > 0, nil | |||
| 90 | + | } | |||
| 91 | + | if !isRemoteNotFoundError(err) { | |||
| 92 | + | return false, err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | _, err = client.GetRoute(ctx, target) | |||
| 96 | + | if err != nil { | |||
| 97 | + | if isRemoteNotFoundError(err) { | |||
| 98 | + | return false, nil | |||
| 99 | + | } | |||
| 100 | + | return false, err | |||
| 101 | + | } | |||
| 102 | + | return true, nil | |||
| 103 | + | }) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func inferRemoteForRepository(ctx context.Context, repository string) (*remote.ResolvedRemote, error) { | |||
| 107 | + | return inferSavedRemote(ctx, "repository", repository, func(ctx context.Context, client *remote.Client) (bool, error) { | |||
| 108 | + | tags, err := client.ListTags(ctx, repository) | |||
| 109 | + | if err != nil { | |||
| 110 | + | if isRemoteNotFoundError(err) { | |||
| 111 | + | return false, nil | |||
| 112 | + | } | |||
| 113 | + | return false, err | |||
| 114 | + | } | |||
| 115 | + | return len(tags) > 0, nil | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func inferSavedRemote(ctx context.Context, targetKind, target string, probe remoteInferenceProbe) (*remote.ResolvedRemote, error) { | |||
| 120 | + | remotes, ok := loadInferenceCandidateRemotes() | |||
| 121 | + | if !ok { | |||
| 122 | + | return nil, nil | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | names := sortedRemoteNames(remotes) | |||
| 126 | + | matches := make([]*remote.ResolvedRemote, 0, len(names)) | |||
| 127 | + | probeFailures := make([]string, 0) | |||
| 128 | + | ||||
| 129 | + | for _, name := range names { | |||
| 130 | + | entry := remotes[name] | |||
| 131 | + | matched, err := probe(ctx, newRoutesTargetClient(name, entry)) | |||
| 132 | + | if err != nil { | |||
| 133 | + | probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err)) | |||
| 134 | + | continue | |||
| 135 | + | } | |||
| 136 | + | if matched { | |||
| 137 | + | matches = append(matches, resolvedRemoteFromEntry(name, entry)) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | if len(probeFailures) > 0 { | |||
| 142 | + | return nil, fmt.Errorf("could not safely infer remote for %s %q because probing failed for: %s; use --remote", targetKind, target, strings.Join(probeFailures, ", ")) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | if len(matches) > 1 { | |||
| 146 | + | names := make([]string, 0, len(matches)) | |||
| 147 | + | for _, match := range matches { | |||
| 148 | + | names = append(names, match.DisplayName()) | |||
| 149 | + | } | |||
| 150 | + | return nil, fmt.Errorf("multiple saved remotes match %s %q: %s; use --remote", targetKind, target, strings.Join(names, ", ")) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | if len(matches) == 1 { | |||
| 154 | + | return matches[0], nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | return nil, nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) { | |||
| 161 | + | if _, ok := resolveRoutesExplicitTarget(); ok { | |||
| 162 | + | return nil, false | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | remotes, err := remote.LoadRemotes("") | |||
| 166 | + | if err != nil || remotes == nil { | |||
| 167 | + | return nil, false | |||
| 168 | + | } | |||
| 169 | + | if remotes.Active != "" { | |||
| 170 | + | return nil, false | |||
| 171 | + | } | |||
| 172 | + | if len(remotes.Remotes) == 0 { | |||
| 173 | + | return nil, false | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | return remotes.Remotes, true | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func resolvedRemoteFromEntry(name string, entry remote.RemoteEntry) *remote.ResolvedRemote { | |||
| 180 | + | return &remote.ResolvedRemote{ | |||
| 181 | + | Name: name, | |||
| 182 | + | URL: entry.URL, | |||
| 183 | + | Token: resolveRoutesTokenForTarget(name, entry), | |||
| 184 | + | InsecureTLS: resolveRoutesInsecureForTarget(name, entry), | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func isRemoteNotFoundError(err error) bool { | |||
| 189 | + | var httpErr *remote.HTTPError | |||
| 190 | + | if errors.As(err, &httpErr) { | |||
| 191 | + | return httpErr.StatusCode == http.StatusNotFound | |||
| 192 | + | } | |||
| 193 | + | return errors.Is(err, domain.ErrRouteNotFound) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func filterPreviewRoutes(routes []domain.Route) []domain.Route { | |||
| 197 | + | filtered := routes[:0] | |||
| 198 | + | for _, route := range routes { | |||
| 199 | + | if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) { | |||
| 200 | + | filtered = append(filtered, route) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | return filtered | |||
| 204 | + | } | |||
M internal/adapters/in/cli/restart.go +1 -1
| @@ -28,7 +28,7 @@ Examples: | |||
| 28 | 28 | ctx := cmd.Context() | |
| 29 | 29 | restartDomain := args[0] | |
| 30 | 30 | ||
| 31 | - | handle, err := resolveControlPlane(configPath) | |
| 31 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, restartDomain) | |
| 32 | 32 | if err != nil { | |
| 33 | 33 | return err | |
| 34 | 34 | } | |
| @@ -28,7 +28,7 @@ Examples: | |||||
| 28 | ctx := cmd.Context() | 28 | ctx := cmd.Context() | ||
| 29 | restartDomain := args[0] | 29 | restartDomain := args[0] | ||
| 30 | 30 | ||||
| 31 | - | handle, err := resolveControlPlane(configPath) | 31 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, restartDomain) |
| 32 | if err != nil { | 32 | if err != nil { | ||
| 33 | return err | 33 | return err | ||
| 34 | } | 34 | } | ||
M internal/adapters/in/cli/root.go +41 -7
| @@ -298,7 +298,7 @@ Remote mode: | |||
| 298 | 298 | if len(args) > 0 { | |
| 299 | 299 | logDomain = args[0] | |
| 300 | 300 | } | |
| 301 | - | return runLogs(logsConfigPath, logDomain, follow, lines) | |
| 301 | + | return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines) | |
| 302 | 302 | }, | |
| 303 | 303 | } | |
| 304 | 304 | ||
| @@ -310,7 +310,16 @@ Remote mode: | |||
| 310 | 310 | } | |
| 311 | 311 | ||
| 312 | 312 | // runLogs handles the logs command logic. | |
| 313 | - | func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error { | |
| 313 | + | func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int) error { | |
| 314 | + | if logDomain != "" { | |
| 315 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain) | |
| 316 | + | if err != nil { | |
| 317 | + | return err | |
| 318 | + | } | |
| 319 | + | defer handle.close() | |
| 320 | + | return runContainerLogs(ctx, handle.plane, logDomain, follow, lines) | |
| 321 | + | } | |
| 322 | + | ||
| 314 | 323 | client, isRemote := GetRemoteClient() | |
| 315 | 324 | if isRemote { | |
| 316 | 325 | return runLogsRemote(client, logDomain, follow, lines) | |
| @@ -382,15 +391,40 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro | |||
| 382 | 391 | return app.ShowLogs(logsConfigPath, follow, lines) | |
| 383 | 392 | } | |
| 384 | 393 | ||
| 385 | - | // Container logs - use local services | |
| 386 | 394 | return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines) | |
| 387 | 395 | } | |
| 388 | 396 | ||
| 389 | - | // showContainerLogsLocal shows container logs using local Docker access. | |
| 397 | + | func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int) error { | |
| 398 | + | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 399 | + | defer cancel() | |
| 400 | + | ||
| 401 | + | if follow { | |
| 402 | + | ch, err := cp.StreamContainerLogs(ctx, logDomain, lines) | |
| 403 | + | if err != nil { | |
| 404 | + | return fmt.Errorf("failed to stream container logs: %w", err) | |
| 405 | + | } | |
| 406 | + | for line := range ch { | |
| 407 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 408 | + | return err | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | return nil | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | logLines, err := cp.GetContainerLogs(ctx, logDomain, lines) | |
| 415 | + | if err != nil { | |
| 416 | + | return fmt.Errorf("failed to get container logs: %w", err) | |
| 417 | + | } | |
| 418 | + | for _, line := range logLines { | |
| 419 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 420 | + | return err | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | return nil | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // showContainerLogsLocal is retained for UI adoption coverage and fallback messaging. | |
| 390 | 427 | func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error { | |
| 391 | - | // For local container logs, we need Docker access which requires | |
| 392 | - | // the runtime to be initialized. For now, suggest using remote mode | |
| 393 | - | // or direct docker logs command. | |
| 394 | 428 | if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { | |
| 395 | 429 | return err | |
| 396 | 430 | } | |
| @@ -298,7 +298,7 @@ Remote mode: | |||||
| 298 | if len(args) > 0 { | 298 | if len(args) > 0 { | ||
| 299 | logDomain = args[0] | 299 | logDomain = args[0] | ||
| 300 | } | 300 | } | ||
| 301 | - | return runLogs(logsConfigPath, logDomain, follow, lines) | 301 | + | return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines) |
| 302 | }, | 302 | }, | ||
| 303 | } | 303 | } | ||
| 304 | 304 | ||||
| @@ -310,7 +310,16 @@ Remote mode: | |||||
| 310 | } | 310 | } | ||
| 311 | 311 | ||||
| 312 | // runLogs handles the logs command logic. | 312 | // runLogs handles the logs command logic. | ||
| 313 | - | func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error { | 313 | + | func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int) error { |
| 314 | + | if logDomain != "" { | |||
| 315 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain) | |||
| 316 | + | if err != nil { | |||
| 317 | + | return err | |||
| 318 | + | } | |||
| 319 | + | defer handle.close() | |||
| 320 | + | return runContainerLogs(ctx, handle.plane, logDomain, follow, lines) | |||
| 321 | + | } | |||
| 322 | + | ||||
| 314 | client, isRemote := GetRemoteClient() | 323 | client, isRemote := GetRemoteClient() | ||
| 315 | if isRemote { | 324 | if isRemote { | ||
| 316 | return runLogsRemote(client, logDomain, follow, lines) | 325 | return runLogsRemote(client, logDomain, follow, lines) | ||
| @@ -382,15 +391,40 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro | |||||
| 382 | return app.ShowLogs(logsConfigPath, follow, lines) | 391 | return app.ShowLogs(logsConfigPath, follow, lines) | ||
| 383 | } | 392 | } | ||
| 384 | 393 | ||||
| 385 | - | // Container logs - use local services | |||
| 386 | return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines) | 394 | return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines) | ||
| 387 | } | 395 | } | ||
| 388 | 396 | ||||
| 389 | - | // showContainerLogsLocal shows container logs using local Docker access. | 397 | + | func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int) error { |
| 398 | + | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |||
| 399 | + | defer cancel() | |||
| 400 | + | ||||
| 401 | + | if follow { | |||
| 402 | + | ch, err := cp.StreamContainerLogs(ctx, logDomain, lines) | |||
| 403 | + | if err != nil { | |||
| 404 | + | return fmt.Errorf("failed to stream container logs: %w", err) | |||
| 405 | + | } | |||
| 406 | + | for line := range ch { | |||
| 407 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |||
| 408 | + | return err | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | return nil | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | logLines, err := cp.GetContainerLogs(ctx, logDomain, lines) | |||
| 415 | + | if err != nil { | |||
| 416 | + | return fmt.Errorf("failed to get container logs: %w", err) | |||
| 417 | + | } | |||
| 418 | + | for _, line := range logLines { | |||
| 419 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |||
| 420 | + | return err | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | return nil | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // showContainerLogsLocal is retained for UI adoption coverage and fallback messaging. | |||
| 390 | func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error { | 427 | func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error { | ||
| 391 | - | // For local container logs, we need Docker access which requires | |||
| 392 | - | // the runtime to be initialized. For now, suggest using remote mode | |||
| 393 | - | // or direct docker logs command. | |||
| 394 | if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { | 428 | if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { | ||
| 395 | return err | 429 | return err | ||
| 396 | } | 430 | } | ||
M internal/adapters/in/cli/routes.go +8 -14
| @@ -988,7 +988,7 @@ Examples: | |||
| 988 | 988 | Args: cobra.ExactArgs(1), | |
| 989 | 989 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 990 | 990 | ctx := cmd.Context() | |
| 991 | - | handle, err := resolveControlPlane(configPath) | |
| 991 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, args[0]) | |
| 992 | 992 | if err != nil { | |
| 993 | 993 | return err | |
| 994 | 994 | } | |
| @@ -1180,19 +1180,13 @@ Examples: | |||
| 1180 | 1180 | } | |
| 1181 | 1181 | } | |
| 1182 | 1182 | ||
| 1183 | - | client, isRemote := GetRemoteClient() | |
| 1184 | - | if isRemote { | |
| 1185 | - | if err := client.RemoveRoute(ctx, routeDomain); err != nil { | |
| 1186 | - | return fmt.Errorf("failed to remove route: %w", err) | |
| 1187 | - | } | |
| 1188 | - | } else { | |
| 1189 | - | local, err := GetLocalServices(configPath) | |
| 1190 | - | if err != nil { | |
| 1191 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 1192 | - | } | |
| 1193 | - | if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil { | |
| 1194 | - | return fmt.Errorf("failed to remove route: %w", err) | |
| 1195 | - | } | |
| 1183 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain) | |
| 1184 | + | if err != nil { | |
| 1185 | + | return err | |
| 1186 | + | } | |
| 1187 | + | defer handle.close() | |
| 1188 | + | if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil { | |
| 1189 | + | return fmt.Errorf("failed to remove route: %w", err) | |
| 1196 | 1190 | } | |
| 1197 | 1191 | ||
| 1198 | 1192 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))) | |
| @@ -988,7 +988,7 @@ Examples: | |||||
| 988 | Args: cobra.ExactArgs(1), | 988 | Args: cobra.ExactArgs(1), | ||
| 989 | RunE: func(cmd *cobra.Command, args []string) error { | 989 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 990 | ctx := cmd.Context() | 990 | ctx := cmd.Context() | ||
| 991 | - | handle, err := resolveControlPlane(configPath) | 991 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, args[0]) |
| 992 | if err != nil { | 992 | if err != nil { | ||
| 993 | return err | 993 | return err | ||
| 994 | } | 994 | } | ||
| @@ -1180,19 +1180,13 @@ Examples: | |||||
| 1180 | } | 1180 | } | ||
| 1181 | } | 1181 | } | ||
| 1182 | 1182 | ||||
| 1183 | - | client, isRemote := GetRemoteClient() | 1183 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain) |
| 1184 | - | if isRemote { | 1184 | + | if err != nil { |
| 1185 | - | if err := client.RemoveRoute(ctx, routeDomain); err != nil { | 1185 | + | return err |
| 1186 | - | return fmt.Errorf("failed to remove route: %w", err) | 1186 | + | } |
| 1187 | - | } | 1187 | + | defer handle.close() |
| 1188 | - | } else { | 1188 | + | if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil { |
| 1189 | - | local, err := GetLocalServices(configPath) | 1189 | + | return fmt.Errorf("failed to remove route: %w", err) |
| 1190 | - | if err != nil { | |||
| 1191 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 1192 | - | } | |||
| 1193 | - | if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil { | |||
| 1194 | - | return fmt.Errorf("failed to remove route: %w", err) | |||
| 1195 | - | } | |||
| 1196 | } | 1190 | } | ||
| 1197 | 1191 | ||||
| 1198 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))) | 1192 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))) | ||
M internal/adapters/in/cli/secrets.go +36 -78
| @@ -61,10 +61,16 @@ Examples: | |||
| 61 | 61 | ||
| 62 | 62 | // runSecretsListCmd executes the secrets list command. | |
| 63 | 63 | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |
| 64 | - | ctx := context.Background() | |
| 64 | + | ctx := cmd.Context() | |
| 65 | 65 | secretDomain := args[0] | |
| 66 | 66 | ||
| 67 | - | keys, attachments, isRemote, err := fetchSecretsWithAttachments(ctx, secretDomain) | |
| 67 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) | |
| 68 | + | if err != nil { | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | defer handle.close() | |
| 72 | + | ||
| 73 | + | keys, attachments, err := fetchSecretsWithAttachments(ctx, handle.plane, secretDomain) | |
| 68 | 74 | if err != nil { | |
| 69 | 75 | return err | |
| 70 | 76 | } | |
| @@ -98,7 +104,7 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||
| 98 | 104 | } | |
| 99 | 105 | ||
| 100 | 106 | title := fmt.Sprintf("Secrets for %s", secretDomain) | |
| 101 | - | if !isRemote { | |
| 107 | + | if !handle.isRemote { | |
| 102 | 108 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | |
| 103 | 109 | } | |
| 104 | 110 | fmt.Println(styles.Theme.Title.Render(title)) | |
| @@ -118,37 +124,13 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||
| 118 | 124 | return nil | |
| 119 | 125 | } | |
| 120 | 126 | ||
| 121 | - | // fetchSecretsWithAttachments retrieves secrets from remote or local source. | |
| 122 | - | func fetchSecretsWithAttachments(ctx context.Context, secretDomain string) ([]string, []remote.AttachmentSecrets, bool, error) { | |
| 123 | - | client, isRemote := GetRemoteClient() | |
| 124 | - | if isRemote { | |
| 125 | - | result, err := client.ListSecretsWithAttachments(ctx, secretDomain) | |
| 126 | - | if err != nil { | |
| 127 | - | return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err) | |
| 128 | - | } | |
| 129 | - | return result.Keys, result.Attachments, isRemote, nil | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | local, err := GetLocalServices(configPath) | |
| 127 | + | // fetchSecretsWithAttachments retrieves secrets from the selected control plane. | |
| 128 | + | func fetchSecretsWithAttachments(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, []remote.AttachmentSecrets, error) { | |
| 129 | + | result, err := cp.ListSecretsWithAttachments(ctx, secretDomain) | |
| 133 | 130 | if err != nil { | |
| 134 | - | return nil, nil, isRemote, fmt.Errorf("failed to initialize local services: %w", err) | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | keys, domainAttachments, err := local.GetSecretService().ListKeysWithAttachments(ctx, secretDomain) | |
| 138 | - | if err != nil { | |
| 139 | - | return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // Convert to remote.AttachmentSecrets format | |
| 143 | - | var attachments []remote.AttachmentSecrets | |
| 144 | - | for _, att := range domainAttachments { | |
| 145 | - | attachments = append(attachments, remote.AttachmentSecrets{ | |
| 146 | - | Service: att.Service, | |
| 147 | - | Keys: att.Keys, | |
| 148 | - | }) | |
| 131 | + | return nil, nil, fmt.Errorf("failed to list secrets: %w", err) | |
| 149 | 132 | } | |
| 150 | - | ||
| 151 | - | return keys, attachments, isRemote, nil | |
| 133 | + | return result.Keys, result.Attachments, nil | |
| 152 | 134 | } | |
| 153 | 135 | ||
| 154 | 136 | // buildSecretsTableRows builds table rows with tree structure for attachments. | |
| @@ -259,7 +241,7 @@ Examples: | |||
| 259 | 241 | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | |
| 260 | 242 | Args: cobra.MinimumNArgs(2), | |
| 261 | 243 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 262 | - | ctx := context.Background() | |
| 244 | + | ctx := cmd.Context() | |
| 263 | 245 | secretDomain := args[0] | |
| 264 | 246 | pairs := args[1:] | |
| 265 | 247 | ||
| @@ -273,30 +255,18 @@ Examples: | |||
| 273 | 255 | secrets[parts[0]] = parts[1] | |
| 274 | 256 | } | |
| 275 | 257 | ||
| 276 | - | client, isRemote := GetRemoteClient() | |
| 277 | - | if isRemote { | |
| 278 | - | if attachment != "" { | |
| 279 | - | if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { | |
| 280 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 281 | - | } | |
| 282 | - | } else { | |
| 283 | - | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 284 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 285 | - | } | |
| 258 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) | |
| 259 | + | if err != nil { | |
| 260 | + | return err | |
| 261 | + | } | |
| 262 | + | defer handle.close() | |
| 263 | + | if attachment != "" { | |
| 264 | + | if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { | |
| 265 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 286 | 266 | } | |
| 287 | 267 | } else { | |
| 288 | - | local, err := GetLocalServices(configPath) | |
| 289 | - | if err != nil { | |
| 290 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 291 | - | } | |
| 292 | - | if attachment != "" { | |
| 293 | - | if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil { | |
| 294 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 295 | - | } | |
| 296 | - | } else { | |
| 297 | - | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | |
| 298 | - | return fmt.Errorf("failed to set secrets: %w", err) | |
| 299 | - | } | |
| 268 | + | if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil { | |
| 269 | + | return fmt.Errorf("failed to set secrets: %w", err) | |
| 300 | 270 | } | |
| 301 | 271 | } | |
| 302 | 272 | ||
| @@ -343,7 +313,7 @@ Examples: | |||
| 343 | 313 | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | |
| 344 | 314 | Args: cobra.ExactArgs(2), | |
| 345 | 315 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 346 | - | ctx := context.Background() | |
| 316 | + | ctx := cmd.Context() | |
| 347 | 317 | secretDomain := args[0] | |
| 348 | 318 | key := args[1] | |
| 349 | 319 | ||
| @@ -366,30 +336,18 @@ Examples: | |||
| 366 | 336 | } | |
| 367 | 337 | } | |
| 368 | 338 | ||
| 369 | - | client, isRemote := GetRemoteClient() | |
| 370 | - | if isRemote { | |
| 371 | - | if attachment != "" { | |
| 372 | - | if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { | |
| 373 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 374 | - | } | |
| 375 | - | } else { | |
| 376 | - | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 377 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 378 | - | } | |
| 339 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) | |
| 340 | + | if err != nil { | |
| 341 | + | return err | |
| 342 | + | } | |
| 343 | + | defer handle.close() | |
| 344 | + | if attachment != "" { | |
| 345 | + | if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { | |
| 346 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 379 | 347 | } | |
| 380 | 348 | } else { | |
| 381 | - | local, err := GetLocalServices(configPath) | |
| 382 | - | if err != nil { | |
| 383 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |
| 384 | - | } | |
| 385 | - | if attachment != "" { | |
| 386 | - | if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil { | |
| 387 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 388 | - | } | |
| 389 | - | } else { | |
| 390 | - | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | |
| 391 | - | return fmt.Errorf("failed to remove secret: %w", err) | |
| 392 | - | } | |
| 349 | + | if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil { | |
| 350 | + | return fmt.Errorf("failed to remove secret: %w", err) | |
| 393 | 351 | } | |
| 394 | 352 | } | |
| 395 | 353 | ||
| @@ -61,10 +61,16 @@ Examples: | |||||
| 61 | 61 | ||||
| 62 | // runSecretsListCmd executes the secrets list command. | 62 | // runSecretsListCmd executes the secrets list command. | ||
| 63 | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | 63 | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | ||
| 64 | - | ctx := context.Background() | 64 | + | ctx := cmd.Context() |
| 65 | secretDomain := args[0] | 65 | secretDomain := args[0] | ||
| 66 | 66 | ||||
| 67 | - | keys, attachments, isRemote, err := fetchSecretsWithAttachments(ctx, secretDomain) | 67 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) |
| 68 | + | if err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | defer handle.close() | |||
| 72 | + | ||||
| 73 | + | keys, attachments, err := fetchSecretsWithAttachments(ctx, handle.plane, secretDomain) | |||
| 68 | if err != nil { | 74 | if err != nil { | ||
| 69 | return err | 75 | return err | ||
| 70 | } | 76 | } | ||
| @@ -98,7 +104,7 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||||
| 98 | } | 104 | } | ||
| 99 | 105 | ||||
| 100 | title := fmt.Sprintf("Secrets for %s", secretDomain) | 106 | title := fmt.Sprintf("Secrets for %s", secretDomain) | ||
| 101 | - | if !isRemote { | 107 | + | if !handle.isRemote { |
| 102 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | 108 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | ||
| 103 | } | 109 | } | ||
| 104 | fmt.Println(styles.Theme.Title.Render(title)) | 110 | fmt.Println(styles.Theme.Title.Render(title)) | ||
| @@ -118,37 +124,13 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |||||
| 118 | return nil | 124 | return nil | ||
| 119 | } | 125 | } | ||
| 120 | 126 | ||||
| 121 | - | // fetchSecretsWithAttachments retrieves secrets from remote or local source. | 127 | + | // fetchSecretsWithAttachments retrieves secrets from the selected control plane. |
| 122 | - | func fetchSecretsWithAttachments(ctx context.Context, secretDomain string) ([]string, []remote.AttachmentSecrets, bool, error) { | 128 | + | func fetchSecretsWithAttachments(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, []remote.AttachmentSecrets, error) { |
| 123 | - | client, isRemote := GetRemoteClient() | 129 | + | result, err := cp.ListSecretsWithAttachments(ctx, secretDomain) |
| 124 | - | if isRemote { | |||
| 125 | - | result, err := client.ListSecretsWithAttachments(ctx, secretDomain) | |||
| 126 | - | if err != nil { | |||
| 127 | - | return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err) | |||
| 128 | - | } | |||
| 129 | - | return result.Keys, result.Attachments, isRemote, nil | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | local, err := GetLocalServices(configPath) | |||
| 133 | if err != nil { | 130 | if err != nil { | ||
| 134 | - | return nil, nil, isRemote, fmt.Errorf("failed to initialize local services: %w", err) | 131 | + | return nil, nil, fmt.Errorf("failed to list secrets: %w", err) |
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | keys, domainAttachments, err := local.GetSecretService().ListKeysWithAttachments(ctx, secretDomain) | |||
| 138 | - | if err != nil { | |||
| 139 | - | return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // Convert to remote.AttachmentSecrets format | |||
| 143 | - | var attachments []remote.AttachmentSecrets | |||
| 144 | - | for _, att := range domainAttachments { | |||
| 145 | - | attachments = append(attachments, remote.AttachmentSecrets{ | |||
| 146 | - | Service: att.Service, | |||
| 147 | - | Keys: att.Keys, | |||
| 148 | - | }) | |||
| 149 | } | 132 | } | ||
| 150 | - | 133 | + | return result.Keys, result.Attachments, nil | |
| 151 | - | return keys, attachments, isRemote, nil | |||
| 152 | } | 134 | } | ||
| 153 | 135 | ||||
| 154 | // buildSecretsTableRows builds table rows with tree structure for attachments. | 136 | // buildSecretsTableRows builds table rows with tree structure for attachments. | ||
| @@ -259,7 +241,7 @@ Examples: | |||||
| 259 | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | 241 | gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`, | ||
| 260 | Args: cobra.MinimumNArgs(2), | 242 | Args: cobra.MinimumNArgs(2), | ||
| 261 | RunE: func(cmd *cobra.Command, args []string) error { | 243 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 262 | - | ctx := context.Background() | 244 | + | ctx := cmd.Context() |
| 263 | secretDomain := args[0] | 245 | secretDomain := args[0] | ||
| 264 | pairs := args[1:] | 246 | pairs := args[1:] | ||
| 265 | 247 | ||||
| @@ -273,30 +255,18 @@ Examples: | |||||
| 273 | secrets[parts[0]] = parts[1] | 255 | secrets[parts[0]] = parts[1] | ||
| 274 | } | 256 | } | ||
| 275 | 257 | ||||
| 276 | - | client, isRemote := GetRemoteClient() | 258 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) |
| 277 | - | if isRemote { | 259 | + | if err != nil { |
| 278 | - | if attachment != "" { | 260 | + | return err |
| 279 | - | if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { | 261 | + | } |
| 280 | - | return fmt.Errorf("failed to set secrets: %w", err) | 262 | + | defer handle.close() |
| 281 | - | } | 263 | + | if attachment != "" { |
| 282 | - | } else { | 264 | + | if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil { |
| 283 | - | if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil { | 265 | + | return fmt.Errorf("failed to set secrets: %w", err) |
| 284 | - | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 285 | - | } | |||
| 286 | } | 266 | } | ||
| 287 | } else { | 267 | } else { | ||
| 288 | - | local, err := GetLocalServices(configPath) | 268 | + | if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil { |
| 289 | - | if err != nil { | 269 | + | return fmt.Errorf("failed to set secrets: %w", err) |
| 290 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 291 | - | } | |||
| 292 | - | if attachment != "" { | |||
| 293 | - | if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil { | |||
| 294 | - | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 295 | - | } | |||
| 296 | - | } else { | |||
| 297 | - | if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil { | |||
| 298 | - | return fmt.Errorf("failed to set secrets: %w", err) | |||
| 299 | - | } | |||
| 300 | } | 270 | } | ||
| 301 | } | 271 | } | ||
| 302 | 272 | ||||
| @@ -343,7 +313,7 @@ Examples: | |||||
| 343 | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | 313 | gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`, | ||
| 344 | Args: cobra.ExactArgs(2), | 314 | Args: cobra.ExactArgs(2), | ||
| 345 | RunE: func(cmd *cobra.Command, args []string) error { | 315 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 346 | - | ctx := context.Background() | 316 | + | ctx := cmd.Context() |
| 347 | secretDomain := args[0] | 317 | secretDomain := args[0] | ||
| 348 | key := args[1] | 318 | key := args[1] | ||
| 349 | 319 | ||||
| @@ -366,30 +336,18 @@ Examples: | |||||
| 366 | } | 336 | } | ||
| 367 | } | 337 | } | ||
| 368 | 338 | ||||
| 369 | - | client, isRemote := GetRemoteClient() | 339 | + | handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain) |
| 370 | - | if isRemote { | 340 | + | if err != nil { |
| 371 | - | if attachment != "" { | 341 | + | return err |
| 372 | - | if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { | 342 | + | } |
| 373 | - | return fmt.Errorf("failed to remove secret: %w", err) | 343 | + | defer handle.close() |
| 374 | - | } | 344 | + | if attachment != "" { |
| 375 | - | } else { | 345 | + | if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil { |
| 376 | - | if err := client.DeleteSecret(ctx, secretDomain, key); err != nil { | 346 | + | return fmt.Errorf("failed to remove secret: %w", err) |
| 377 | - | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 378 | - | } | |||
| 379 | } | 347 | } | ||
| 380 | } else { | 348 | } else { | ||
| 381 | - | local, err := GetLocalServices(configPath) | 349 | + | if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil { |
| 382 | - | if err != nil { | 350 | + | return fmt.Errorf("failed to remove secret: %w", err) |
| 383 | - | return fmt.Errorf("failed to initialize local services: %w", err) | |||
| 384 | - | } | |||
| 385 | - | if attachment != "" { | |||
| 386 | - | if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil { | |||
| 387 | - | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 388 | - | } | |||
| 389 | - | } else { | |||
| 390 | - | if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil { | |||
| 391 | - | return fmt.Errorf("failed to remove secret: %w", err) | |||
| 392 | - | } | |||
| 393 | } | 351 | } | ||
| 394 | } | 352 | } | ||
| 395 | 353 | ||||