detached bd898f0

Merge pull request #198 from bnema/feat/auto-infer-remote-target

cli: auto infer remote targets from saved remotes
commit
bd898f09e698b53514f07ebcc7c67b43ae9bcf31
author
bnema · · 2026-05-15 10:03 UTC
committer
GitHub · · 2026-05-15 10:03 UTC
merge of
8d0a092 + 3eb7cb6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev17
21 files · +1096 -218
M docs/cli/attachments.md +3 -0
@@ -3,6 +3,9 @@
33 Manage attachments on local or remote Gordon instances.
44
55 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.
69 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
710
811 ## 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 ## Requirements11 ## 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
168168 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
169169 when you want to bypass your saved configuration.
170170
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+
171190 `gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
172191 nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
173192 one to force a single target.
@@ -180,6 +199,10 @@ gordon routes status
180199 # Single-target override
181200 gordon routes list --remote prod
182201 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
183206 ```
184207
185208 **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 overrides168 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 either191 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 override199 # Single-target override
181 gordon routes list --remote prod200 gordon routes list --remote prod
182 GORDON_REMOTE=prod gordon routes status201 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]
3636 `gordon push` tags the selected image for the Gordon registry, pushes it, and
3737 optionally deploys matching routes.
3838
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+
3945 Image resolution order:
4046 1. `--domain` is the explicit deploy target override for legacy workflows
4147 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, and36 `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 workflows46 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 lookup47 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>
165165 When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166166 `gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
167167
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+
168170 ```bash
169171 gordon remotes use prod
170172 gordon secrets list app.com # Uses prod remote automatically
@@ -245,11 +247,13 @@ token = "eyJ..."
245247 When multiple sources specify remote or token, the CLI uses this priority:
246248
247249 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.
248251
249-**Remote URL:**
252+**Remote target selection:**
250253 1. `--remote` flag
251254 2. `GORDON_REMOTE` environment variable
252255 3. Active remote from `remotes.toml`
256+4. Auto-inferred saved remote for supported target-based commands
253257
254258 **Token:**
255259 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 ```bash170 ```bash
169 gordon remotes use prod171 gordon remotes use prod
170 gordon secrets list app.com # Uses prod remote automatically172 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` flag253 1. `--remote` flag
251 2. `GORDON_REMOTE` environment variable254 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` flag259 1. `--token` flag
M docs/cli/secrets.md +2 -0
@@ -3,6 +3,8 @@
33 Manage secrets on local or remote Gordon instances.
44
55 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.
68 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
79
810 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]
248248 | `--token` | | | Authentication token for remote |
249249
250250 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.
251253 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
252254
253255 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 +100 -25
@@ -52,12 +52,21 @@ Examples:
5252 gordon attachments list backend # List attachments for network group`,
5353 Args: cobra.MaximumNArgs(1),
5454 RunE: func(cmd *cobra.Command, args []string) error {
55- ctx := context.Background()
55+ ctx := cmd.Context()
5656 target := ""
5757 if len(args) > 0 {
5858 target = args[0]
5959 }
6060
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+
6170 client, isRemote := GetRemoteClient()
6271 if isRemote {
6372 return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
@@ -71,6 +80,77 @@ Examples:
7180 return cmd
7281 }
7382
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+ if len(images) == 0 {
90+ _, err := fmt.Fprintf(out, "No attachments configured for '%s'\n", target)
91+ return err
92+ }
93+ if jsonOut {
94+ return writeJSON(out, map[string]any{"target": target, "images": images})
95+ }
96+ return renderAttachmentTargetList(out, fmt.Sprintf("Attachments for %s", target), images)
97+ }
98+
99+ attachments, err := cp.GetAllAttachmentsConfig(ctx)
100+ if err != nil {
101+ return fmt.Errorf("failed to list attachments: %w", err)
102+ }
103+ if len(attachments) == 0 {
104+ return cliWriteLine(out, styles.Theme.Muted.Render("No attachments configured"))
105+ }
106+ if jsonOut {
107+ return writeJSON(out, map[string]any{"attachments": attachments})
108+ }
109+ return renderAllAttachmentsList(out, "Attachments", attachments)
110+}
111+
112+func renderAttachmentTargetList(out io.Writer, title string, images []string) error {
113+ if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
114+ return err
115+ }
116+ if err := cliWriteLine(out, ""); err != nil {
117+ return err
118+ }
119+ for _, img := range images {
120+ if err := cliWritef(out, " %s\n", img); err != nil {
121+ return err
122+ }
123+ }
124+ return nil
125+}
126+
127+func renderAllAttachmentsList(out io.Writer, title string, attachments map[string][]string) error {
128+ targets := make([]string, 0, len(attachments))
129+ for t := range attachments {
130+ targets = append(targets, t)
131+ }
132+ sort.Strings(targets)
133+
134+ if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
135+ return err
136+ }
137+ if err := cliWriteLine(out, ""); err != nil {
138+ return err
139+ }
140+ for _, t := range targets {
141+ images := attachments[t]
142+ if err := cliWritef(out, "%s\n", styles.Theme.Bold.Render(t)); err != nil {
143+ return err
144+ }
145+ for _, img := range images {
146+ if err := cliWritef(out, " %s\n", img); err != nil {
147+ return err
148+ }
149+ }
150+ }
151+ return nil
152+}
153+
74154 // runAttachmentsListRemote lists attachments from a remote Gordon instance.
75155 func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
76156 if target != "" {
@@ -215,16 +295,17 @@ Examples:
215295 gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
216296 Args: cobra.ExactArgs(2),
217297 RunE: func(cmd *cobra.Command, args []string) error {
218- ctx := context.Background()
298+ ctx := cmd.Context()
219299 target := args[0]
220300 image := args[1]
221301
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 {
302+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
303+ if err != nil {
304+ return err
305+ }
306+ defer handle.close()
307+
308+ if !handle.isRemote {
228309 local, err := GetLocalServices(configPath)
229310 if err != nil {
230311 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -238,10 +319,10 @@ Examples:
238319 fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
239320 fmt.Println()
240321 }
322+ }
241323
242- if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
243- return fmt.Errorf("failed to add attachment: %w", err)
244- }
324+ if err := handle.plane.AddAttachment(ctx, target, image); err != nil {
325+ return fmt.Errorf("failed to add attachment: %w", err)
245326 }
246327
247328 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
@@ -266,7 +347,7 @@ Examples:
266347 gordon attachments remove backend redis:7-alpine --force`,
267348 Args: cobra.ExactArgs(2),
268349 RunE: func(cmd *cobra.Command, args []string) error {
269- ctx := context.Background()
350+ ctx := cmd.Context()
270351 target := args[0]
271352 image := args[1]
272353
@@ -285,19 +366,13 @@ Examples:
285366 }
286367 }
287368
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- }
369+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
370+ if err != nil {
371+ return err
372+ }
373+ defer handle.close()
374+ if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil {
375+ return fmt.Errorf("failed to remove attachment: %w", err)
301376 }
302377
303378 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
@@ -52,12 +52,21 @@ Examples:
52 gordon attachments list backend # List attachments for network group`,52 gordon attachments list backend # List attachments for network group`,
53 Args: cobra.MaximumNArgs(1),53 Args: cobra.MaximumNArgs(1),
54 RunE: func(cmd *cobra.Command, args []string) error {54 RunE: func(cmd *cobra.Command, args []string) error {
55- ctx := context.Background()55+ ctx := cmd.Context()
56 target := ""56 target := ""
57 if len(args) > 0 {57 if len(args) > 0 {
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,77 @@ Examples:
71 return cmd80 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+ if len(images) == 0 {
90+ _, err := fmt.Fprintf(out, "No attachments configured for '%s'\n", target)
91+ return err
92+ }
93+ if jsonOut {
94+ return writeJSON(out, map[string]any{"target": target, "images": images})
95+ }
96+ return renderAttachmentTargetList(out, fmt.Sprintf("Attachments for %s", target), images)
97+ }
98+
99+ attachments, err := cp.GetAllAttachmentsConfig(ctx)
100+ if err != nil {
101+ return fmt.Errorf("failed to list attachments: %w", err)
102+ }
103+ if len(attachments) == 0 {
104+ return cliWriteLine(out, styles.Theme.Muted.Render("No attachments configured"))
105+ }
106+ if jsonOut {
107+ return writeJSON(out, map[string]any{"attachments": attachments})
108+ }
109+ return renderAllAttachmentsList(out, "Attachments", attachments)
110+}
111+
112+func renderAttachmentTargetList(out io.Writer, title string, images []string) error {
113+ if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
114+ return err
115+ }
116+ if err := cliWriteLine(out, ""); err != nil {
117+ return err
118+ }
119+ for _, img := range images {
120+ if err := cliWritef(out, " %s\n", img); err != nil {
121+ return err
122+ }
123+ }
124+ return nil
125+}
126+
127+func renderAllAttachmentsList(out io.Writer, title string, attachments map[string][]string) error {
128+ targets := make([]string, 0, len(attachments))
129+ for t := range attachments {
130+ targets = append(targets, t)
131+ }
132+ sort.Strings(targets)
133+
134+ if err := cliWriteLine(out, cliRenderTitle(title)); err != nil {
135+ return err
136+ }
137+ if err := cliWriteLine(out, ""); err != nil {
138+ return err
139+ }
140+ for _, t := range targets {
141+ images := attachments[t]
142+ if err := cliWritef(out, "%s\n", styles.Theme.Bold.Render(t)); err != nil {
143+ return err
144+ }
145+ for _, img := range images {
146+ if err := cliWritef(out, " %s\n", img); err != nil {
147+ return err
148+ }
149+ }
150+ }
151+ return nil
152+}
153+
74 // runAttachmentsListRemote lists attachments from a remote Gordon instance.154 // 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 {155 func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
76 if target != "" {156 if target != "" {
@@ -215,16 +295,17 @@ Examples:
215 gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,295 gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
216 Args: cobra.ExactArgs(2),296 Args: cobra.ExactArgs(2),
217 RunE: func(cmd *cobra.Command, args []string) error {297 RunE: func(cmd *cobra.Command, args []string) error {
218- ctx := context.Background()298+ ctx := cmd.Context()
219 target := args[0]299 target := args[0]
220 image := args[1]300 image := args[1]
221 301
222- client, isRemote := GetRemoteClient()302+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
223- if isRemote {303+ if err != nil {
224- if err := client.AddAttachment(ctx, target, image); err != nil {304+ return err
225- return fmt.Errorf("failed to add attachment: %w", err)305+ }
226- }306+ defer handle.close()
227- } else {307+
308+ if !handle.isRemote {
228 local, err := GetLocalServices(configPath)309 local, err := GetLocalServices(configPath)
229 if err != nil {310 if err != nil {
230 return fmt.Errorf("failed to initialize local services: %w", err)311 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -238,10 +319,10 @@ Examples:
238 fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))319 fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
239 fmt.Println()320 fmt.Println()
240 }321 }
322+ }
241 323
242- if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {324+ if err := handle.plane.AddAttachment(ctx, target, image); err != nil {
243- return fmt.Errorf("failed to add attachment: %w", err)325+ return fmt.Errorf("failed to add attachment: %w", err)
244- }
245 }326 }
246 327
247 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))328 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
@@ -266,7 +347,7 @@ Examples:
266 gordon attachments remove backend redis:7-alpine --force`,347 gordon attachments remove backend redis:7-alpine --force`,
267 Args: cobra.ExactArgs(2),348 Args: cobra.ExactArgs(2),
268 RunE: func(cmd *cobra.Command, args []string) error {349 RunE: func(cmd *cobra.Command, args []string) error {
269- ctx := context.Background()350+ ctx := cmd.Context()
270 target := args[0]351 target := args[0]
271 image := args[1]352 image := args[1]
272 353
@@ -285,19 +366,13 @@ Examples:
285 }366 }
286 }367 }
287 368
288- client, isRemote := GetRemoteClient()369+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
289- if isRemote {370+ if err != nil {
290- if err := client.RemoveAttachment(ctx, target, image); err != nil {371+ return err
291- return fmt.Errorf("failed to remove attachment: %w", err)372+ }
292- }373+ defer handle.close()
293- } else {374+ if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil {
294- local, err := GetLocalServices(configPath)375+ 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 }376 }
302 377
303 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))378 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 {
5757 }
5858
5959 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)
6161 if err != nil {
6262 return err
6363 }
64- defer handle.close()
6564
66- dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
65+ inferredRemote, err := inferRemoteForAttachmentImage(ctx, req.ImageArg)
6766 if err != nil {
6867 return err
6968 }
7069
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+
7184 for _, ba := range req.Build.BuildArgs {
7285 if err := validateBuildArg(ba); err != nil {
7386 return err
@@ -91,7 +104,12 @@ func runAttachmentsPush(ctx context.Context, req attachmentPushRequest, out io.W
91104 }
92105 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
93106
94- imageOps, err := newImageOpsFn()
107+ var imageOps pushImageOps
108+ if inferredRemote != nil {
109+ imageOps, err = newImageOpsForResolvedRemote(inferredRemote)
110+ } else {
111+ imageOps, err = newImageOpsFn()
112+ }
95113 if err != nil {
96114 return err
97115 }
@@ -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 err62 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 err67 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 err86 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 err114 return err
97 }115 }
M internal/adapters/in/cli/backup.go +16 -8
@@ -42,17 +42,25 @@ func newBackupListCmd() *cobra.Command {
4242 Long: cliRenderMuted("List backups for all domains or a specific domain."),
4343 Args: cobra.MaximumNArgs(1),
4444 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-
5145 domainName := ""
5246 if len(args) == 1 {
5347 domainName = args[0]
5448 }
5549
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+
5664 jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
5765 if err != nil {
5866 return fmt.Errorf("failed to list backups: %w", err)
@@ -103,7 +111,7 @@ func newBackupRunCmd() *cobra.Command {
103111 Short: "Run backup now",
104112 Args: cobra.ExactArgs(1),
105113 RunE: func(cmd *cobra.Command, args []string) error {
106- handle, err := resolveControlPlane(configPath)
114+ handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
107115 if err != nil {
108116 return err
109117 }
@@ -145,7 +153,7 @@ func newBackupDetectCmd() *cobra.Command {
145153 Short: "Detect databases for domain",
146154 Args: cobra.ExactArgs(1),
147155 RunE: func(cmd *cobra.Command, args []string) error {
148- handle, err := resolveControlPlane(configPath)
156+ handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
149157 if err != nil {
150158 return err
151159 }
@@ -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 err116 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 err158 return err
151 }159 }
M internal/adapters/in/cli/controlplane_resolver.go +36 -0
@@ -1,8 +1,10 @@
11 package cli
22
33 import (
4+ "context"
45 "fmt"
56
7+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
68 "github.com/bnema/gordon/internal/app"
79 )
810
@@ -31,6 +33,40 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
3133 return resolveLocalControlPlane(configPath)
3234 }
3335
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+
3470 func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
3571 kernel, err := app.NewKernelQuiet(configPath)
3672 if err != nil {
@@ -1,8 +1,10 @@
1 package cli1 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:
3535 gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`,
3636 Args: cobra.ExactArgs(1),
3737 RunE: func(cmd *cobra.Command, args []string) error {
38- handle, err := resolveControlPlane(configPath)
38+ handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
3939 if err != nil {
4040 return err
4141 }
@@ -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 err40 return err
41 }41 }
M internal/adapters/in/cli/images.go +1 -1
@@ -148,7 +148,7 @@ Examples:
148148 Args: cobra.ExactArgs(1),
149149 RunE: func(cmd *cobra.Command, args []string) error {
150150 ctx := cmd.Context()
151- handle, err := resolveControlPlane(configPath)
151+ handle, err := resolveControlPlaneForRepository(ctx, args[0])
152152 if err != nil {
153153 return err
154154 }
@@ -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 err153 return err
154 }154 }
M internal/adapters/in/cli/pin.go +2 -2
@@ -59,7 +59,7 @@ func newPinListCmd() *cobra.Command {
5959 }
6060
6161 func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string) error {
62- handle, err := resolveControlPlane(configPath)
62+ handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
6363 if err != nil {
6464 return fmt.Errorf("failed to resolve control plane: %w", err)
6565 }
@@ -96,7 +96,7 @@ func runPin(ctx context.Context, out, ew io.Writer, pinDomain, targetTag string)
9696 }
9797
9898 func runPinList(ctx context.Context, w io.Writer, pinDomain string, jsonOut bool) error {
99- handle, err := resolveControlPlane(configPath)
99+ handle, err := resolveControlPlaneForRouteDomain(ctx, pinDomain)
100100 if err != nil {
101101 return fmt.Errorf("failed to resolve control plane: %w", err)
102102 }
@@ -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 +69 -51
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "fmt"
8+ "io"
89 "net/http"
910 "os"
1011 "os/exec"
@@ -92,7 +93,7 @@ Examples:
9293 if len(args) > 0 {
9394 imageArg = args[0]
9495 }
95- return runPush(cmd.Context(), pushRequest{
96+ return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
9697 ImageArg: imageArg,
9798 Domain: domainFlag,
9899 Tag: tag,
@@ -225,18 +226,13 @@ func resolveVersion(ctx context.Context, tag string) (string, error) {
225226 return version, nil
226227 }
227228
228-func runPush(ctx context.Context, req pushRequest) error {
229- handle, err := resolveControlPlane(configPath)
229+func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
230+ dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, out, req)
230231 if err != nil {
231232 return err
232233 }
233234 defer handle.close()
234235
235- dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
236- if err != nil {
237- return err
238- }
239-
240236 registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
241237 if err != nil {
242238 return err
@@ -246,57 +242,89 @@ func runPush(ctx context.Context, req pushRequest) error {
246242 if err != nil {
247243 return err
248244 }
249-
250- for _, ba := range req.Build.BuildArgs {
251- if err := validateBuildArg(ba); err != nil {
252- return err
253- }
245+ if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
246+ return err
254247 }
255248
256- img := imagePush{
257- Registry: registry,
258- ImageName: imageName,
259- Version: version,
260- }
249+ img := imagePush{Registry: registry, ImageName: imageName, Version: version}
261250 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
262251
263- imageOps, err := newImageOpsFn()
252+ imageOps, err := newPushImageOps(inferredRemote)
264253 if err != nil {
265254 return err
266255 }
267256
268- fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(img.VersionRef))
257+ if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(img.VersionRef)); err != nil {
258+ return err
259+ }
269260 if version != "latest" {
270- fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(img.LatestRef))
261+ if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(img.LatestRef)); err != nil {
262+ return err
263+ }
264+ }
265+ if err := cliWritef(out, "Domain: %s\n", styles.Theme.Bold.Render(pushDomain)); err != nil {
266+ return err
271267 }
272- fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
273268
274269 skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
270+ build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
271+ if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {
272+ return err
273+ }
275274
276- build := buildConfig{
277- Enabled: req.Build.Enabled,
278- Platform: req.Build.Platform,
279- Dockerfile: dockerfile,
280- BuildArgs: req.Build.BuildArgs,
275+ if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
276+ return err
281277 }
278+ if !skipExplicitDeploy {
279+ return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
280+ }
281+ return nil
282+}
282283
283- if build.Enabled {
284- if err := buildAndPush(ctx, imageOps, build, img); err != nil {
285- return err
284+func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
285+ dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
286+ if err != nil {
287+ return "", nil, nil, err
288+ }
289+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)
290+ if err != nil {
291+ return "", nil, nil, err
292+ }
293+ if inferredRemote != nil {
294+ handle = newRemoteControlPlaneHandle(inferredRemote)
295+ if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
296+ return "", nil, nil, err
286297 }
287- } else {
288- if err := tagAndPush(ctx, imageOps, img); err != nil {
298+ return dockerfile, inferredRemote, handle, nil
299+ }
300+ handle, err = resolveControlPlane(configPath)
301+ if err != nil {
302+ return "", nil, nil, err
303+ }
304+ return dockerfile, nil, handle, nil
305+}
306+
307+func validateBuildArgsList(buildArgs []string) error {
308+ for _, ba := range buildArgs {
309+ if err := validateBuildArg(ba); err != nil {
289310 return err
290311 }
291312 }
313+ return nil
314+}
292315
293- fmt.Println(styles.RenderSuccess("Push complete"))
294-
295- if !skipExplicitDeploy {
296- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
316+func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
317+ if inferredRemote != nil {
318+ return newImageOpsForResolvedRemote(inferredRemote)
297319 }
320+ return newImageOpsFn()
321+}
298322
299- return nil
323+func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {
324+ if build.Enabled {
325+ return buildAndPush(ctx, imageOps, build, img)
326+ }
327+ return tagAndPush(ctx, imageOps, img)
300328 }
301329
302330 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
@@ -345,15 +373,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
345373 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
346374 }
347375
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
376+ routes = filterPreviewRoutes(routes)
357377
358378 if len(routes) == 0 {
359379 return "", "", "", noRouteForImageError(imageArg)
@@ -420,7 +440,7 @@ func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error
420440 labelDomainList = append(labelDomainList, labelDomain)
421441 }
422442 if labelDomains != "" {
423- for _, d := range strings.Split(labelDomains, ",") {
443+ for d := range strings.SplitSeq(labelDomains, ",") {
424444 d = strings.TrimSpace(d)
425445 if d != "" {
426446 labelDomainList = append(labelDomainList, d)
@@ -574,12 +594,10 @@ func splitLabelPairs(content string) []string {
574594
575595 // parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
576596 func parseLabelPair(pair string) (key, value string, ok bool) {
577- idx := strings.Index(pair, "=")
578- if idx == -1 {
597+ key, value, ok = strings.Cut(pair, "=")
598+ if !ok {
579599 return "", "", false
580600 }
581- key = pair[:idx]
582- value = pair[idx+1:]
583601 // Strip surrounding quotes
584602 value = strings.Trim(value, "\"'")
585603 return key, value, true
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "io"
8 "net/http"9 "net/http"
9 "os"10 "os"
10 "os/exec"11 "os/exec"
@@ -92,7 +93,7 @@ Examples:
92 if len(args) > 0 {93 if len(args) > 0 {
93 imageArg = args[0]94 imageArg = args[0]
94 }95 }
95- return runPush(cmd.Context(), pushRequest{96+ return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
96 ImageArg: imageArg,97 ImageArg: imageArg,
97 Domain: domainFlag,98 Domain: domainFlag,
98 Tag: tag,99 Tag: tag,
@@ -225,18 +226,13 @@ func resolveVersion(ctx context.Context, tag string) (string, error) {
225 return version, nil226 return version, nil
226 }227 }
227 228
228-func runPush(ctx context.Context, req pushRequest) error {229+func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
229- handle, err := resolveControlPlane(configPath)230+ dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, out, req)
230 if err != nil {231 if err != nil {
231 return err232 return err
232 }233 }
233 defer handle.close()234 defer handle.close()
234 235
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)236 registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
241 if err != nil {237 if err != nil {
242 return err238 return err
@@ -246,57 +242,89 @@ func runPush(ctx context.Context, req pushRequest) error {
246 if err != nil {242 if err != nil {
247 return err243 return err
248 }244 }
249-245+ if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
250- for _, ba := range req.Build.BuildArgs {246+ return err
251- if err := validateBuildArg(ba); err != nil {
252- return err
253- }
254 }247 }
255 248
256- img := imagePush{249+ 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)250 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
262 251
263- imageOps, err := newImageOpsFn()252+ imageOps, err := newPushImageOps(inferredRemote)
264 if err != nil {253 if err != nil {
265 return err254 return err
266 }255 }
267 256
268- fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(img.VersionRef))257+ if err := cliWritef(out, "Image: %s\n", styles.Theme.Bold.Render(img.VersionRef)); err != nil {
258+ return err
259+ }
269 if version != "latest" {260 if version != "latest" {
270- fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(img.LatestRef))261+ if err := cliWritef(out, "Also: %s\n", styles.Theme.Bold.Render(img.LatestRef)); err != nil {
262+ return err
263+ }
264+ }
265+ if err := cliWritef(out, "Domain: %s\n", styles.Theme.Bold.Render(pushDomain)); err != nil {
266+ return err
271 }267 }
272- fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
273 268
274 skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)269 skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
270+ build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
271+ if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {
272+ return err
273+ }
275 274
276- build := buildConfig{275+ if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
277- Enabled: req.Build.Enabled,276+ return err
278- Platform: req.Build.Platform,
279- Dockerfile: dockerfile,
280- BuildArgs: req.Build.BuildArgs,
281 }277 }
278+ if !skipExplicitDeploy {
279+ return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
280+ }
281+ return nil
282+}
282 283
283- if build.Enabled {284+func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
284- if err := buildAndPush(ctx, imageOps, build, img); err != nil {285+ dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
285- return err286+ if err != nil {
287+ return "", nil, nil, err
288+ }
289+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)
290+ if err != nil {
291+ return "", nil, nil, err
292+ }
293+ if inferredRemote != nil {
294+ handle = newRemoteControlPlaneHandle(inferredRemote)
295+ if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
296+ return "", nil, nil, err
286 }297 }
287- } else {298+ return dockerfile, inferredRemote, handle, nil
288- if err := tagAndPush(ctx, imageOps, img); err != nil {299+ }
300+ handle, err = resolveControlPlane(configPath)
301+ if err != nil {
302+ return "", nil, nil, err
303+ }
304+ return dockerfile, nil, handle, nil
305+}
306+
307+func validateBuildArgsList(buildArgs []string) error {
308+ for _, ba := range buildArgs {
309+ if err := validateBuildArg(ba); err != nil {
289 return err310 return err
290 }311 }
291 }312 }
313+ return nil
314+}
292 315
293- fmt.Println(styles.RenderSuccess("Push complete"))316+func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
294-317+ if inferredRemote != nil {
295- if !skipExplicitDeploy {318+ return newImageOpsForResolvedRemote(inferredRemote)
296- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
297 }319 }
320+ return newImageOpsFn()
321+}
298 322
299- return nil323+func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {
324+ if build.Enabled {
325+ return buildAndPush(ctx, imageOps, build, img)
326+ }
327+ return tagAndPush(ctx, imageOps, img)
300 }328 }
301 329
302 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy330 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
@@ -345,15 +373,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
345 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)373 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
346 }374 }
347 375
348- // Filter out preview routes (domains containing "--") so they don't376+ 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 377
358 if len(routes) == 0 {378 if len(routes) == 0 {
359 return "", "", "", noRouteForImageError(imageArg)379 return "", "", "", noRouteForImageError(imageArg)
@@ -420,7 +440,7 @@ func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error
420 labelDomainList = append(labelDomainList, labelDomain)440 labelDomainList = append(labelDomainList, labelDomain)
421 }441 }
422 if labelDomains != "" {442 if labelDomains != "" {
423- for _, d := range strings.Split(labelDomains, ",") {443+ for d := range strings.SplitSeq(labelDomains, ",") {
424 d = strings.TrimSpace(d)444 d = strings.TrimSpace(d)
425 if d != "" {445 if d != "" {
426 labelDomainList = append(labelDomainList, d)446 labelDomainList = append(labelDomainList, d)
@@ -574,12 +594,10 @@ func splitLabelPairs(content string) []string {
574 594
575 // parseLabelPair parses a single "key=value" or "key=\"value\"" pair.595 // parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
576 func parseLabelPair(pair string) (key, value string, ok bool) {596 func parseLabelPair(pair string) (key, value string, ok bool) {
577- idx := strings.Index(pair, "=")597+ key, value, ok = strings.Cut(pair, "=")
578- if idx == -1 {598+ if !ok {
579 return "", "", false599 return "", "", false
580 }600 }
581- key = pair[:idx]
582- value = pair[idx+1:]
583 // Strip surrounding quotes601 // Strip surrounding quotes
584 value = strings.Trim(value, "\"'")602 value = strings.Trim(value, "\"'")
585 return key, value, true603 return key, value, true
M internal/adapters/in/cli/push_image_ops.go +5 -6
@@ -55,6 +55,10 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
5555 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
5656 func newImageOpsFromFlags() (pushImageOps, error) {
5757 resolved, _ := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
58+ return newImageOpsForResolvedRemote(resolved)
59+}
60+
61+func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps, error) {
5862 insecureTLS := false
5963 if resolved != nil {
6064 insecureTLS = resolved.InsecureTLS
@@ -64,12 +68,7 @@ func newImageOpsFromFlags() (pushImageOps, error) {
6468 return nil, err
6569 }
6670 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)...)
7372 }
7473 return ops, nil
7574 }
@@ -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 := false62 insecureTLS := false
59 if resolved != nil {63 if resolved != nil {
60 insecureTLS = resolved.InsecureTLS64 insecureTLS = resolved.InsecureTLS
@@ -64,12 +68,7 @@ func newImageOpsFromFlags() (pushImageOps, error) {
64 return nil, err68 return nil, err
65 }69 }
66 if resolved != nil && resolved.Token != "" {70 if resolved != nil && resolved.Token != "" {
67- var clientOpts []remote.ClientOption71+ 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, nil73 return ops, nil
75 }74 }
A internal/adapters/in/cli/push_remote_inference_test.go +494 -0
@@ -0,0 +1,494 @@
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/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
22+ assert.Equal(t, "myapp", image)
23+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
24+ })
25+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
26+ assert.Equal(t, "myapp", image)
27+ return nil, http.StatusOK
28+ })
29+
30+ configurePushRemoteInferenceTestEnv(t, `
31+[remotes.prod]
32+url = "`+prod.URL+`"
33+
34+[remotes.staging]
35+url = "`+staging.URL+`"
36+`)
37+
38+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
39+ require.NoError(t, err)
40+ require.NotNil(t, resolved)
41+ assert.Equal(t, "prod", resolved.Name)
42+ assert.Equal(t, prod.URL, resolved.URL)
43+}
44+
45+func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
47+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
48+ })
49+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
50+ return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
51+ })
52+
53+ configurePushRemoteInferenceTestEnv(t, `
54+[remotes.prod]
55+url = "`+prod.URL+`"
56+
57+[remotes.staging]
58+url = "`+staging.URL+`"
59+`)
60+
61+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
62+ require.Error(t, err)
63+ assert.Nil(t, resolved)
64+ assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
65+ assert.Contains(t, err.Error(), "prod")
66+ assert.Contains(t, err.Error(), "staging")
67+}
68+
69+func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
70+ originalRemoteFlag := remoteFlag
71+ t.Cleanup(func() {
72+ remoteFlag = originalRemoteFlag
73+ })
74+
75+ called := false
76+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
77+ called = true
78+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
79+ })
80+
81+ configurePushRemoteInferenceTestEnv(t, `
82+[remotes.prod]
83+url = "`+prod.URL+`"
84+`)
85+ t.Setenv("GORDON_REMOTE", "")
86+ remoteFlag = "prod"
87+
88+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
89+ require.NoError(t, err)
90+ assert.Nil(t, resolved)
91+ assert.False(t, called)
92+}
93+
94+func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
95+ called := false
96+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
97+ called = true
98+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
99+ })
100+
101+ configurePushRemoteInferenceTestEnv(t, `
102+active = "prod"
103+
104+[remotes.prod]
105+url = "`+prod.URL+`"
106+`)
107+
108+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
109+ require.NoError(t, err)
110+ assert.Nil(t, resolved)
111+ assert.False(t, called)
112+}
113+
114+func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116+ return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117+ })
118+
119+ configurePushRemoteInferenceTestEnv(t, `
120+[remotes.prod]
121+url = "`+prod.URL+`"
122+`)
123+
124+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125+ require.NoError(t, err)
126+ assert.Nil(t, resolved)
127+}
128+
129+func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
131+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
132+ })
133+ broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
134+ return nil, http.StatusInternalServerError
135+ })
136+
137+ configurePushRemoteInferenceTestEnv(t, `
138+[remotes.prod]
139+url = "`+prod.URL+`"
140+
141+[remotes.broken]
142+url = "`+broken.URL+`"
143+`)
144+
145+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
146+ require.Error(t, err)
147+ assert.Nil(t, resolved)
148+ assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
149+ assert.Contains(t, err.Error(), "broken")
150+}
151+
152+func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155+ })
156+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157+ return nil, http.StatusNotFound
158+ })
159+
160+ configurePushRemoteInferenceTestEnv(t, `
161+[remotes.prod]
162+url = "`+prod.URL+`"
163+
164+[remotes.staging]
165+url = "`+staging.URL+`"
166+`)
167+
168+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169+ require.NoError(t, err)
170+ require.NotNil(t, resolved)
171+ assert.Equal(t, "prod", resolved.Name)
172+}
173+
174+func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177+ })
178+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180+ })
181+
182+ configurePushRemoteInferenceTestEnv(t, `
183+[remotes.prod]
184+url = "`+prod.URL+`"
185+
186+[remotes.staging]
187+url = "`+staging.URL+`"
188+`)
189+
190+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191+ require.Error(t, err)
192+ assert.Nil(t, resolved)
193+ assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194+}
195+
196+func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
197+ prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
198+ return []string{"app.example.com"}, http.StatusOK
199+ })
200+ staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
201+ return nil, http.StatusOK
202+ })
203+
204+ configurePushRemoteInferenceTestEnv(t, `
205+[remotes.prod]
206+url = "`+prod.URL+`"
207+
208+[remotes.staging]
209+url = "`+staging.URL+`"
210+`)
211+
212+ resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
213+ require.NoError(t, err)
214+ require.NotNil(t, resolved)
215+ assert.Equal(t, "prod", resolved.Name)
216+}
217+
218+func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
219+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
220+ getRoute: func(domainName string) (*domain.Route, int) {
221+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
222+ },
223+ getAttachmentsConfig: func(target string) ([]string, int) {
224+ return nil, http.StatusNotFound
225+ },
226+ })
227+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
228+ getRoute: func(domainName string) (*domain.Route, int) {
229+ return nil, http.StatusNotFound
230+ },
231+ getAttachmentsConfig: func(target string) ([]string, int) {
232+ return nil, http.StatusNotFound
233+ },
234+ })
235+
236+ configurePushRemoteInferenceTestEnv(t, `
237+[remotes.prod]
238+url = "`+prod.URL+`"
239+
240+[remotes.staging]
241+url = "`+staging.URL+`"
242+`)
243+
244+ resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
245+ require.NoError(t, err)
246+ require.NotNil(t, resolved)
247+ assert.Equal(t, "prod", resolved.Name)
248+}
249+
250+func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
251+ prod := newTagsTestServer(t, func(repository string) ([]string, int) {
252+ return []string{"latest", "v1.0.0"}, http.StatusOK
253+ })
254+ staging := newTagsTestServer(t, func(repository string) ([]string, int) {
255+ return []string{"latest"}, http.StatusOK
256+ })
257+
258+ configurePushRemoteInferenceTestEnv(t, `
259+[remotes.prod]
260+url = "`+prod.URL+`"
261+
262+[remotes.staging]
263+url = "`+staging.URL+`"
264+`)
265+
266+ resolved, err := inferRemoteForRepository(context.Background(), "myapp")
267+ require.Error(t, err)
268+ assert.Nil(t, resolved)
269+ assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
270+}
271+
272+func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
273+ t.Helper()
274+
275+ originalRemoteFlag := remoteFlag
276+ originalTokenFlag := tokenFlag
277+ originalInsecureTLSFlag := insecureTLSFlag
278+ t.Cleanup(func() {
279+ remoteFlag = originalRemoteFlag
280+ tokenFlag = originalTokenFlag
281+ insecureTLSFlag = originalInsecureTLSFlag
282+ })
283+
284+ remoteFlag = ""
285+ tokenFlag = ""
286+ insecureTLSFlag = false
287+ t.Setenv("GORDON_REMOTE", "")
288+ t.Setenv("GORDON_TOKEN", "")
289+ t.Setenv("GORDON_INSECURE", "")
290+ t.Setenv("HOME", t.TempDir())
291+
292+ configHome := t.TempDir()
293+ t.Setenv("XDG_CONFIG_HOME", configHome)
294+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
295+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
296+ require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
297+}
298+
299+func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
300+ t.Helper()
301+
302+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303+ if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
304+ http.NotFound(w, r)
305+ return
306+ }
307+
308+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
309+ if !assert.NoError(t, err) {
310+ return
311+ }
312+
313+ routes, status := handler(image)
314+ if status >= http.StatusBadRequest {
315+ w.Header().Set("Content-Type", "application/json")
316+ w.WriteHeader(status)
317+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
318+ return
319+ }
320+ return
321+ }
322+
323+ w.Header().Set("Content-Type", "application/json")
324+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
325+ "image": image,
326+ "routes": routes,
327+ })) {
328+ return
329+ }
330+ }))
331+ t.Cleanup(server.Close)
332+ return server
333+}
334+
335+func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
336+ t.Helper()
337+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
338+}
339+
340+type multiAdminProbeHandlers struct {
341+ getRoute func(domainName string) (*domain.Route, int)
342+ getAttachmentsConfig func(target string) ([]string, int)
343+ listTags func(repository string) ([]string, int)
344+ findAttachmentTarget func(image string) ([]string, int)
345+}
346+
347+func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
348+ t.Helper()
349+
350+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351+ switch {
352+ case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
353+ handleGetRouteProbe(t, w, r, handlers.getRoute)
354+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
355+ handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
356+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
357+ handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
358+ case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
359+ handleListTagsProbe(t, w, r, handlers.listTags)
360+ default:
361+ http.NotFound(w, r)
362+ }
363+ }))
364+ t.Cleanup(server.Close)
365+ return server
366+}
367+
368+func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
369+ t.Helper()
370+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
371+}
372+
373+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
374+ t.Helper()
375+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
376+}
377+
378+func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
379+ t.Helper()
380+ if handler == nil {
381+ http.NotFound(w, r)
382+ return
383+ }
384+ domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
385+ if !assert.NoError(t, err) {
386+ return
387+ }
388+ route, status := handler(domainName)
389+ if status >= http.StatusBadRequest {
390+ w.Header().Set("Content-Type", "application/json")
391+ w.WriteHeader(status)
392+ if status == http.StatusNotFound {
393+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
394+ return
395+ }
396+ return
397+ }
398+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
399+ return
400+ }
401+ return
402+ }
403+ w.Header().Set("Content-Type", "application/json")
404+ if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
405+ return
406+ }
407+}
408+
409+func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
410+ t.Helper()
411+ if handler == nil {
412+ http.NotFound(w, r)
413+ return
414+ }
415+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
416+ if !assert.NoError(t, err) {
417+ return
418+ }
419+ targets, status := handler(image)
420+ if status >= http.StatusBadRequest {
421+ w.Header().Set("Content-Type", "application/json")
422+ w.WriteHeader(status)
423+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
424+ return
425+ }
426+ return
427+ }
428+ w.Header().Set("Content-Type", "application/json")
429+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
430+ return
431+ }
432+}
433+
434+func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
435+ t.Helper()
436+ if handler == nil {
437+ http.NotFound(w, r)
438+ return
439+ }
440+ target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
441+ if !assert.NoError(t, err) {
442+ return
443+ }
444+ images, status := handler(target)
445+ if status >= http.StatusBadRequest {
446+ w.Header().Set("Content-Type", "application/json")
447+ w.WriteHeader(status)
448+ if status == http.StatusNotFound {
449+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
450+ return
451+ }
452+ return
453+ }
454+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
455+ return
456+ }
457+ return
458+ }
459+ w.Header().Set("Content-Type", "application/json")
460+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
461+ return
462+ }
463+}
464+
465+func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
466+ t.Helper()
467+ if handler == nil {
468+ http.NotFound(w, r)
469+ return
470+ }
471+ repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
472+ if !assert.NoError(t, err) {
473+ return
474+ }
475+ tags, status := handler(repository)
476+ if status >= http.StatusBadRequest {
477+ w.Header().Set("Content-Type", "application/json")
478+ w.WriteHeader(status)
479+ if status == http.StatusNotFound {
480+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) {
481+ return
482+ }
483+ return
484+ }
485+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
486+ return
487+ }
488+ return
489+ }
490+ w.Header().Set("Content-Type", "application/json")
491+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) {
492+ return
493+ }
494+}
@@ -0,0 +1,494 @@
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/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
22+ assert.Equal(t, "myapp", image)
23+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
24+ })
25+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
26+ assert.Equal(t, "myapp", image)
27+ return nil, http.StatusOK
28+ })
29+
30+ configurePushRemoteInferenceTestEnv(t, `
31+[remotes.prod]
32+url = "`+prod.URL+`"
33+
34+[remotes.staging]
35+url = "`+staging.URL+`"
36+`)
37+
38+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
39+ require.NoError(t, err)
40+ require.NotNil(t, resolved)
41+ assert.Equal(t, "prod", resolved.Name)
42+ assert.Equal(t, prod.URL, resolved.URL)
43+}
44+
45+func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
47+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
48+ })
49+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
50+ return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
51+ })
52+
53+ configurePushRemoteInferenceTestEnv(t, `
54+[remotes.prod]
55+url = "`+prod.URL+`"
56+
57+[remotes.staging]
58+url = "`+staging.URL+`"
59+`)
60+
61+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
62+ require.Error(t, err)
63+ assert.Nil(t, resolved)
64+ assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
65+ assert.Contains(t, err.Error(), "prod")
66+ assert.Contains(t, err.Error(), "staging")
67+}
68+
69+func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
70+ originalRemoteFlag := remoteFlag
71+ t.Cleanup(func() {
72+ remoteFlag = originalRemoteFlag
73+ })
74+
75+ called := false
76+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
77+ called = true
78+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
79+ })
80+
81+ configurePushRemoteInferenceTestEnv(t, `
82+[remotes.prod]
83+url = "`+prod.URL+`"
84+`)
85+ t.Setenv("GORDON_REMOTE", "")
86+ remoteFlag = "prod"
87+
88+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
89+ require.NoError(t, err)
90+ assert.Nil(t, resolved)
91+ assert.False(t, called)
92+}
93+
94+func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
95+ called := false
96+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
97+ called = true
98+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
99+ })
100+
101+ configurePushRemoteInferenceTestEnv(t, `
102+active = "prod"
103+
104+[remotes.prod]
105+url = "`+prod.URL+`"
106+`)
107+
108+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
109+ require.NoError(t, err)
110+ assert.Nil(t, resolved)
111+ assert.False(t, called)
112+}
113+
114+func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116+ return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117+ })
118+
119+ configurePushRemoteInferenceTestEnv(t, `
120+[remotes.prod]
121+url = "`+prod.URL+`"
122+`)
123+
124+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125+ require.NoError(t, err)
126+ assert.Nil(t, resolved)
127+}
128+
129+func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
131+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
132+ })
133+ broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
134+ return nil, http.StatusInternalServerError
135+ })
136+
137+ configurePushRemoteInferenceTestEnv(t, `
138+[remotes.prod]
139+url = "`+prod.URL+`"
140+
141+[remotes.broken]
142+url = "`+broken.URL+`"
143+`)
144+
145+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
146+ require.Error(t, err)
147+ assert.Nil(t, resolved)
148+ assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
149+ assert.Contains(t, err.Error(), "broken")
150+}
151+
152+func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155+ })
156+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157+ return nil, http.StatusNotFound
158+ })
159+
160+ configurePushRemoteInferenceTestEnv(t, `
161+[remotes.prod]
162+url = "`+prod.URL+`"
163+
164+[remotes.staging]
165+url = "`+staging.URL+`"
166+`)
167+
168+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169+ require.NoError(t, err)
170+ require.NotNil(t, resolved)
171+ assert.Equal(t, "prod", resolved.Name)
172+}
173+
174+func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177+ })
178+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180+ })
181+
182+ configurePushRemoteInferenceTestEnv(t, `
183+[remotes.prod]
184+url = "`+prod.URL+`"
185+
186+[remotes.staging]
187+url = "`+staging.URL+`"
188+`)
189+
190+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191+ require.Error(t, err)
192+ assert.Nil(t, resolved)
193+ assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194+}
195+
196+func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
197+ prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
198+ return []string{"app.example.com"}, http.StatusOK
199+ })
200+ staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
201+ return nil, http.StatusOK
202+ })
203+
204+ configurePushRemoteInferenceTestEnv(t, `
205+[remotes.prod]
206+url = "`+prod.URL+`"
207+
208+[remotes.staging]
209+url = "`+staging.URL+`"
210+`)
211+
212+ resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
213+ require.NoError(t, err)
214+ require.NotNil(t, resolved)
215+ assert.Equal(t, "prod", resolved.Name)
216+}
217+
218+func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
219+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
220+ getRoute: func(domainName string) (*domain.Route, int) {
221+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
222+ },
223+ getAttachmentsConfig: func(target string) ([]string, int) {
224+ return nil, http.StatusNotFound
225+ },
226+ })
227+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
228+ getRoute: func(domainName string) (*domain.Route, int) {
229+ return nil, http.StatusNotFound
230+ },
231+ getAttachmentsConfig: func(target string) ([]string, int) {
232+ return nil, http.StatusNotFound
233+ },
234+ })
235+
236+ configurePushRemoteInferenceTestEnv(t, `
237+[remotes.prod]
238+url = "`+prod.URL+`"
239+
240+[remotes.staging]
241+url = "`+staging.URL+`"
242+`)
243+
244+ resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
245+ require.NoError(t, err)
246+ require.NotNil(t, resolved)
247+ assert.Equal(t, "prod", resolved.Name)
248+}
249+
250+func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
251+ prod := newTagsTestServer(t, func(repository string) ([]string, int) {
252+ return []string{"latest", "v1.0.0"}, http.StatusOK
253+ })
254+ staging := newTagsTestServer(t, func(repository string) ([]string, int) {
255+ return []string{"latest"}, http.StatusOK
256+ })
257+
258+ configurePushRemoteInferenceTestEnv(t, `
259+[remotes.prod]
260+url = "`+prod.URL+`"
261+
262+[remotes.staging]
263+url = "`+staging.URL+`"
264+`)
265+
266+ resolved, err := inferRemoteForRepository(context.Background(), "myapp")
267+ require.Error(t, err)
268+ assert.Nil(t, resolved)
269+ assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
270+}
271+
272+func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
273+ t.Helper()
274+
275+ originalRemoteFlag := remoteFlag
276+ originalTokenFlag := tokenFlag
277+ originalInsecureTLSFlag := insecureTLSFlag
278+ t.Cleanup(func() {
279+ remoteFlag = originalRemoteFlag
280+ tokenFlag = originalTokenFlag
281+ insecureTLSFlag = originalInsecureTLSFlag
282+ })
283+
284+ remoteFlag = ""
285+ tokenFlag = ""
286+ insecureTLSFlag = false
287+ t.Setenv("GORDON_REMOTE", "")
288+ t.Setenv("GORDON_TOKEN", "")
289+ t.Setenv("GORDON_INSECURE", "")
290+ t.Setenv("HOME", t.TempDir())
291+
292+ configHome := t.TempDir()
293+ t.Setenv("XDG_CONFIG_HOME", configHome)
294+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
295+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
296+ require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
297+}
298+
299+func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
300+ t.Helper()
301+
302+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303+ if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
304+ http.NotFound(w, r)
305+ return
306+ }
307+
308+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
309+ if !assert.NoError(t, err) {
310+ return
311+ }
312+
313+ routes, status := handler(image)
314+ if status >= http.StatusBadRequest {
315+ w.Header().Set("Content-Type", "application/json")
316+ w.WriteHeader(status)
317+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
318+ return
319+ }
320+ return
321+ }
322+
323+ w.Header().Set("Content-Type", "application/json")
324+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
325+ "image": image,
326+ "routes": routes,
327+ })) {
328+ return
329+ }
330+ }))
331+ t.Cleanup(server.Close)
332+ return server
333+}
334+
335+func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
336+ t.Helper()
337+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
338+}
339+
340+type multiAdminProbeHandlers struct {
341+ getRoute func(domainName string) (*domain.Route, int)
342+ getAttachmentsConfig func(target string) ([]string, int)
343+ listTags func(repository string) ([]string, int)
344+ findAttachmentTarget func(image string) ([]string, int)
345+}
346+
347+func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
348+ t.Helper()
349+
350+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351+ switch {
352+ case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
353+ handleGetRouteProbe(t, w, r, handlers.getRoute)
354+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
355+ handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
356+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
357+ handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
358+ case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
359+ handleListTagsProbe(t, w, r, handlers.listTags)
360+ default:
361+ http.NotFound(w, r)
362+ }
363+ }))
364+ t.Cleanup(server.Close)
365+ return server
366+}
367+
368+func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
369+ t.Helper()
370+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
371+}
372+
373+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
374+ t.Helper()
375+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
376+}
377+
378+func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
379+ t.Helper()
380+ if handler == nil {
381+ http.NotFound(w, r)
382+ return
383+ }
384+ domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
385+ if !assert.NoError(t, err) {
386+ return
387+ }
388+ route, status := handler(domainName)
389+ if status >= http.StatusBadRequest {
390+ w.Header().Set("Content-Type", "application/json")
391+ w.WriteHeader(status)
392+ if status == http.StatusNotFound {
393+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
394+ return
395+ }
396+ return
397+ }
398+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
399+ return
400+ }
401+ return
402+ }
403+ w.Header().Set("Content-Type", "application/json")
404+ if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
405+ return
406+ }
407+}
408+
409+func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
410+ t.Helper()
411+ if handler == nil {
412+ http.NotFound(w, r)
413+ return
414+ }
415+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
416+ if !assert.NoError(t, err) {
417+ return
418+ }
419+ targets, status := handler(image)
420+ if status >= http.StatusBadRequest {
421+ w.Header().Set("Content-Type", "application/json")
422+ w.WriteHeader(status)
423+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
424+ return
425+ }
426+ return
427+ }
428+ w.Header().Set("Content-Type", "application/json")
429+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
430+ return
431+ }
432+}
433+
434+func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
435+ t.Helper()
436+ if handler == nil {
437+ http.NotFound(w, r)
438+ return
439+ }
440+ target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
441+ if !assert.NoError(t, err) {
442+ return
443+ }
444+ images, status := handler(target)
445+ if status >= http.StatusBadRequest {
446+ w.Header().Set("Content-Type", "application/json")
447+ w.WriteHeader(status)
448+ if status == http.StatusNotFound {
449+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
450+ return
451+ }
452+ return
453+ }
454+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
455+ return
456+ }
457+ return
458+ }
459+ w.Header().Set("Content-Type", "application/json")
460+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
461+ return
462+ }
463+}
464+
465+func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
466+ t.Helper()
467+ if handler == nil {
468+ http.NotFound(w, r)
469+ return
470+ }
471+ repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
472+ if !assert.NoError(t, err) {
473+ return
474+ }
475+ tags, status := handler(repository)
476+ if status >= http.StatusBadRequest {
477+ w.Header().Set("Content-Type", "application/json")
478+ w.WriteHeader(status)
479+ if status == http.StatusNotFound {
480+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) {
481+ return
482+ }
483+ return
484+ }
485+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
486+ return
487+ }
488+ return
489+ }
490+ w.Header().Set("Content-Type", "application/json")
491+ if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) {
492+ return
493+ }
494+}
A internal/adapters/in/cli/remote_inference.go +203 -0
@@ -0,0 +1,203 @@
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+ if httpErr, ok := errors.AsType[*remote.HTTPError](err); ok {
190+ return httpErr.StatusCode == http.StatusNotFound
191+ }
192+ return errors.Is(err, domain.ErrRouteNotFound)
193+}
194+
195+func filterPreviewRoutes(routes []domain.Route) []domain.Route {
196+ filtered := make([]domain.Route, 0, len(routes))
197+ for _, route := range routes {
198+ if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) {
199+ filtered = append(filtered, route)
200+ }
201+ }
202+ return filtered
203+}
@@ -0,0 +1,203 @@
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+ if httpErr, ok := errors.AsType[*remote.HTTPError](err); ok {
190+ return httpErr.StatusCode == http.StatusNotFound
191+ }
192+ return errors.Is(err, domain.ErrRouteNotFound)
193+}
194+
195+func filterPreviewRoutes(routes []domain.Route) []domain.Route {
196+ filtered := make([]domain.Route, 0, len(routes))
197+ for _, route := range routes {
198+ if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) {
199+ filtered = append(filtered, route)
200+ }
201+ }
202+ return filtered
203+}
M internal/adapters/in/cli/restart.go +1 -1
@@ -28,7 +28,7 @@ Examples:
2828 ctx := cmd.Context()
2929 restartDomain := args[0]
3030
31- handle, err := resolveControlPlane(configPath)
31+ handle, err := resolveControlPlaneForRouteDomain(ctx, restartDomain)
3232 if err != nil {
3333 return err
3434 }
@@ -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 err33 return err
34 }34 }
M internal/adapters/in/cli/root.go +60 -25
@@ -5,6 +5,7 @@ package cli
55 import (
66 "context"
77 "fmt"
8+ "io"
89 "os"
910 "os/signal"
1011 "syscall"
@@ -298,7 +299,7 @@ Remote mode:
298299 if len(args) > 0 {
299300 logDomain = args[0]
300301 }
301- return runLogs(logsConfigPath, logDomain, follow, lines)
302+ return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout())
302303 },
303304 }
304305
@@ -310,21 +311,30 @@ Remote mode:
310311 }
311312
312313 // runLogs handles the logs command logic.
313-func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
314+func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
315+ if logDomain != "" {
316+ handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain)
317+ if err != nil {
318+ return err
319+ }
320+ defer handle.close()
321+ return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)
322+ }
323+
314324 client, isRemote := GetRemoteClient()
315325 if isRemote {
316- return runLogsRemote(client, logDomain, follow, lines)
326+ return runLogsRemote(ctx, client, logDomain, follow, lines, out)
317327 }
318- return runLogsLocal(logsConfigPath, logDomain, follow, lines)
328+ return runLogsLocal(logsConfigPath, logDomain, follow, lines, out)
319329 }
320330
321331 // runLogsRemote fetches logs from a remote Gordon instance.
322-func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
323- ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
332+func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error {
333+ ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
324334 defer cancel()
325335
326336 if follow {
327- return streamLogsRemote(ctx, client, logDomain, lines)
337+ return streamLogsRemote(ctx, client, logDomain, lines, out)
328338 }
329339
330340 if logDomain == "" {
@@ -334,7 +344,7 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
334344 return fmt.Errorf("failed to get process logs: %w", err)
335345 }
336346 for _, line := range logLines {
337- if err := cliWriteLine(os.Stdout, line); err != nil {
347+ if err := cliWriteLine(out, line); err != nil {
338348 return err
339349 }
340350 }
@@ -345,7 +355,7 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
345355 return fmt.Errorf("failed to get container logs: %w", err)
346356 }
347357 for _, line := range logLines {
348- if err := cliWriteLine(os.Stdout, line); err != nil {
358+ if err := cliWriteLine(out, line); err != nil {
349359 return err
350360 }
351361 }
@@ -354,7 +364,7 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
354364 }
355365
356366 // streamLogsRemote streams logs from a remote Gordon instance.
357-func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
367+func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int, out io.Writer) error {
358368 var ch <-chan string
359369 var err error
360370
@@ -368,7 +378,7 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
368378 }
369379
370380 for line := range ch {
371- if err := cliWriteLine(os.Stdout, line); err != nil {
381+ if err := cliWriteLine(out, line); err != nil {
372382 return err
373383 }
374384 }
@@ -376,39 +386,64 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
376386 }
377387
378388 // runLogsLocal shows logs from a local Gordon instance.
379-func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
389+func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
380390 if logDomain == "" {
381391 // Process logs - use existing app.ShowLogs
382392 return app.ShowLogs(logsConfigPath, follow, lines)
383393 }
384394
385- // Container logs - use local services
386- return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
395+ return showContainerLogsLocal(out, logsConfigPath, logDomain, follow, lines)
396+}
397+
398+func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int, out io.Writer) error {
399+ ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
400+ defer cancel()
401+
402+ if follow {
403+ ch, err := cp.StreamContainerLogs(ctx, logDomain, lines)
404+ if err != nil {
405+ return fmt.Errorf("failed to stream container logs: %w", err)
406+ }
407+ for line := range ch {
408+ if err := cliWriteLine(out, line); err != nil {
409+ return err
410+ }
411+ }
412+ return nil
413+ }
414+
415+ logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)
416+ if err != nil {
417+ return fmt.Errorf("failed to get container logs: %w", err)
418+ }
419+ for _, line := range logLines {
420+ if err := cliWriteLine(out, line); err != nil {
421+ return err
422+ }
423+ }
424+ return nil
387425 }
388426
389-// showContainerLogsLocal shows container logs using local Docker access.
390-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 {
427+// showContainerLogsLocal is retained for UI adoption coverage and fallback messaging.
428+func showContainerLogsLocal(out io.Writer, _ string, logDomain string, follow bool, lines int) error {
429+ if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
395430 return err
396431 }
397- if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil {
432+ if err := cliWriteLine(out, cliRenderInfo("To view container logs locally, use:")); err != nil {
398433 return err
399434 }
400- if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
435+ if err := cliWritef(out, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
401436 return err
402437 }
403438 if follow {
404- if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
439+ if err := cliWritef(out, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
405440 return err
406441 }
407442 }
408- if err := cliWriteLine(os.Stdout, ""); err != nil {
443+ if err := cliWriteLine(out, ""); err != nil {
409444 return err
410445 }
411- if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
446+ if err := cliWriteLine(out, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
412447 return err
413448 }
414449 return nil
@@ -5,6 +5,7 @@ package cli
5 import (5 import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8+ "io"
8 "os"9 "os"
9 "os/signal"10 "os/signal"
10 "syscall"11 "syscall"
@@ -298,7 +299,7 @@ Remote mode:
298 if len(args) > 0 {299 if len(args) > 0 {
299 logDomain = args[0]300 logDomain = args[0]
300 }301 }
301- return runLogs(logsConfigPath, logDomain, follow, lines)302+ return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout())
302 },303 },
303 }304 }
304 305
@@ -310,21 +311,30 @@ Remote mode:
310 }311 }
311 312
312 // runLogs handles the logs command logic.313 // runLogs handles the logs command logic.
313-func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {314+func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
315+ if logDomain != "" {
316+ handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain)
317+ if err != nil {
318+ return err
319+ }
320+ defer handle.close()
321+ return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)
322+ }
323+
314 client, isRemote := GetRemoteClient()324 client, isRemote := GetRemoteClient()
315 if isRemote {325 if isRemote {
316- return runLogsRemote(client, logDomain, follow, lines)326+ return runLogsRemote(ctx, client, logDomain, follow, lines, out)
317 }327 }
318- return runLogsLocal(logsConfigPath, logDomain, follow, lines)328+ return runLogsLocal(logsConfigPath, logDomain, follow, lines, out)
319 }329 }
320 330
321 // runLogsRemote fetches logs from a remote Gordon instance.331 // runLogsRemote fetches logs from a remote Gordon instance.
322-func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {332+func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error {
323- ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)333+ ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
324 defer cancel()334 defer cancel()
325 335
326 if follow {336 if follow {
327- return streamLogsRemote(ctx, client, logDomain, lines)337+ return streamLogsRemote(ctx, client, logDomain, lines, out)
328 }338 }
329 339
330 if logDomain == "" {340 if logDomain == "" {
@@ -334,7 +344,7 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
334 return fmt.Errorf("failed to get process logs: %w", err)344 return fmt.Errorf("failed to get process logs: %w", err)
335 }345 }
336 for _, line := range logLines {346 for _, line := range logLines {
337- if err := cliWriteLine(os.Stdout, line); err != nil {347+ if err := cliWriteLine(out, line); err != nil {
338 return err348 return err
339 }349 }
340 }350 }
@@ -345,7 +355,7 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
345 return fmt.Errorf("failed to get container logs: %w", err)355 return fmt.Errorf("failed to get container logs: %w", err)
346 }356 }
347 for _, line := range logLines {357 for _, line := range logLines {
348- if err := cliWriteLine(os.Stdout, line); err != nil {358+ if err := cliWriteLine(out, line); err != nil {
349 return err359 return err
350 }360 }
351 }361 }
@@ -354,7 +364,7 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
354 }364 }
355 365
356 // streamLogsRemote streams logs from a remote Gordon instance.366 // streamLogsRemote streams logs from a remote Gordon instance.
357-func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {367+func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int, out io.Writer) error {
358 var ch <-chan string368 var ch <-chan string
359 var err error369 var err error
360 370
@@ -368,7 +378,7 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
368 }378 }
369 379
370 for line := range ch {380 for line := range ch {
371- if err := cliWriteLine(os.Stdout, line); err != nil {381+ if err := cliWriteLine(out, line); err != nil {
372 return err382 return err
373 }383 }
374 }384 }
@@ -376,39 +386,64 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
376 }386 }
377 387
378 // runLogsLocal shows logs from a local Gordon instance.388 // runLogsLocal shows logs from a local Gordon instance.
379-func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {389+func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
380 if logDomain == "" {390 if logDomain == "" {
381 // Process logs - use existing app.ShowLogs391 // Process logs - use existing app.ShowLogs
382 return app.ShowLogs(logsConfigPath, follow, lines)392 return app.ShowLogs(logsConfigPath, follow, lines)
383 }393 }
384 394
385- // Container logs - use local services395+ return showContainerLogsLocal(out, logsConfigPath, logDomain, follow, lines)
386- return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)396+}
397+
398+func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int, out io.Writer) error {
399+ ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
400+ defer cancel()
401+
402+ if follow {
403+ ch, err := cp.StreamContainerLogs(ctx, logDomain, lines)
404+ if err != nil {
405+ return fmt.Errorf("failed to stream container logs: %w", err)
406+ }
407+ for line := range ch {
408+ if err := cliWriteLine(out, line); err != nil {
409+ return err
410+ }
411+ }
412+ return nil
413+ }
414+
415+ logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)
416+ if err != nil {
417+ return fmt.Errorf("failed to get container logs: %w", err)
418+ }
419+ for _, line := range logLines {
420+ if err := cliWriteLine(out, line); err != nil {
421+ return err
422+ }
423+ }
424+ return nil
387 }425 }
388 426
389-// showContainerLogsLocal shows container logs using local Docker access.427+// showContainerLogsLocal is retained for UI adoption coverage and fallback messaging.
390-func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error {428+func showContainerLogsLocal(out io.Writer, _ string, logDomain string, follow bool, lines int) error {
391- // For local container logs, we need Docker access which requires429+ if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
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 {
395 return err430 return err
396 }431 }
397- if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil {432+ if err := cliWriteLine(out, cliRenderInfo("To view container logs locally, use:")); err != nil {
398 return err433 return err
399 }434 }
400- if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil {435+ if err := cliWritef(out, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
401 return err436 return err
402 }437 }
403 if follow {438 if follow {
404- if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {439+ if err := cliWritef(out, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
405 return err440 return err
406 }441 }
407 }442 }
408- if err := cliWriteLine(os.Stdout, ""); err != nil {443+ if err := cliWriteLine(out, ""); err != nil {
409 return err444 return err
410 }445 }
411- if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {446+ if err := cliWriteLine(out, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
412 return err447 return err
413 }448 }
414 return nil449 return nil
M internal/adapters/in/cli/routes.go +9 -15
@@ -988,7 +988,7 @@ Examples:
988988 Args: cobra.ExactArgs(1),
989989 RunE: func(cmd *cobra.Command, args []string) error {
990990 ctx := cmd.Context()
991- handle, err := resolveControlPlane(configPath)
991+ handle, err := resolveControlPlaneForRouteDomain(ctx, args[0])
992992 if err != nil {
993993 return err
994994 }
@@ -1162,7 +1162,7 @@ Examples:
11621162 gordon routes remove app.mydomain.com --force`,
11631163 Args: cobra.ExactArgs(1),
11641164 RunE: func(cmd *cobra.Command, args []string) error {
1165- ctx := context.Background()
1165+ ctx := cmd.Context()
11661166 routeDomain := args[0]
11671167
11681168 // Confirm unless --force
@@ -1180,19 +1180,13 @@ Examples:
11801180 }
11811181 }
11821182
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)
11961190 }
11971191
11981192 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 err993 return err
994 }994 }
@@ -1162,7 +1162,7 @@ Examples:
1162 gordon routes remove app.mydomain.com --force`,1162 gordon routes remove app.mydomain.com --force`,
1163 Args: cobra.ExactArgs(1),1163 Args: cobra.ExactArgs(1),
1164 RunE: func(cmd *cobra.Command, args []string) error {1164 RunE: func(cmd *cobra.Command, args []string) error {
1165- ctx := context.Background()1165+ ctx := cmd.Context()
1166 routeDomain := args[0]1166 routeDomain := args[0]
1167 1167
1168 // Confirm unless --force1168 // Confirm unless --force
@@ -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:
6161
6262 // runSecretsListCmd executes the secrets list command.
6363 func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
64- ctx := context.Background()
64+ ctx := cmd.Context()
6565 secretDomain := args[0]
6666
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)
6874 if err != nil {
6975 return err
7076 }
@@ -98,7 +104,7 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
98104 }
99105
100106 title := fmt.Sprintf("Secrets for %s", secretDomain)
101- if !isRemote {
107+ if !handle.isRemote {
102108 title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
103109 }
104110 fmt.Println(styles.Theme.Title.Render(title))
@@ -118,37 +124,13 @@ func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
118124 return nil
119125 }
120126
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)
133130 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)
149132 }
150-
151- return keys, attachments, isRemote, nil
133+ return result.Keys, result.Attachments, nil
152134 }
153135
154136 // buildSecretsTableRows builds table rows with tree structure for attachments.
@@ -259,7 +241,7 @@ Examples:
259241 gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
260242 Args: cobra.MinimumNArgs(2),
261243 RunE: func(cmd *cobra.Command, args []string) error {
262- ctx := context.Background()
244+ ctx := cmd.Context()
263245 secretDomain := args[0]
264246 pairs := args[1:]
265247
@@ -273,30 +255,18 @@ Examples:
273255 secrets[parts[0]] = parts[1]
274256 }
275257
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)
286266 }
287267 } 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)
300270 }
301271 }
302272
@@ -343,7 +313,7 @@ Examples:
343313 gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
344314 Args: cobra.ExactArgs(2),
345315 RunE: func(cmd *cobra.Command, args []string) error {
346- ctx := context.Background()
316+ ctx := cmd.Context()
347317 secretDomain := args[0]
348318 key := args[1]
349319
@@ -366,30 +336,18 @@ Examples:
366336 }
367337 }
368338
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)
379347 }
380348 } 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)
393351 }
394352 }
395353
@@ -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 err75 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 nil124 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

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close