detached 2154bd4

cli: auto infer remote targets from saved remotes

commit
2154bd4d436cd9d43702e2106f60e0e74af1b2b3
author
brice · · 2026-05-15 07:08 UTC
parent
8d0a092
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +1003 -187
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 +81 -22
@@ -58,6 +58,15 @@ Examples:
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,61 @@ 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+
90+ if len(images) == 0 {
91+ fmt.Printf("No attachments configured for '%s'\n", target)
92+ return nil
93+ }
94+
95+ if jsonOut {
96+ return writeJSON(out, map[string]any{"target": target, "images": images})
97+ }
98+
99+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
100+ fmt.Println()
101+ for _, img := range images {
102+ fmt.Printf(" %s\n", img)
103+ }
104+ return nil
105+ }
106+
107+ attachments, err := cp.GetAllAttachmentsConfig(ctx)
108+ if err != nil {
109+ return fmt.Errorf("failed to list attachments: %w", err)
110+ }
111+ if len(attachments) == 0 {
112+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
113+ return nil
114+ }
115+ if jsonOut {
116+ return writeJSON(out, map[string]any{"attachments": attachments})
117+ }
118+
119+ targets := make([]string, 0, len(attachments))
120+ for t := range attachments {
121+ targets = append(targets, t)
122+ }
123+ sort.Strings(targets)
124+
125+ fmt.Println(styles.Theme.Title.Render("Attachments"))
126+ fmt.Println()
127+ for _, t := range targets {
128+ images := attachments[t]
129+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
130+ for _, img := range images {
131+ fmt.Printf(" %s\n", img)
132+ }
133+ }
134+
135+ return nil
136+}
137+
74138 // runAttachmentsListRemote lists attachments from a remote Gordon instance.
75139 func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
76140 if target != "" {
@@ -219,12 +283,13 @@ Examples:
219283 target := args[0]
220284 image := args[1]
221285
222- client, isRemote := GetRemoteClient()
223- if isRemote {
224- if err := client.AddAttachment(ctx, target, image); err != nil {
225- return fmt.Errorf("failed to add attachment: %w", err)
226- }
227- } else {
286+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
287+ if err != nil {
288+ return err
289+ }
290+ defer handle.close()
291+
292+ if !handle.isRemote {
228293 local, err := GetLocalServices(configPath)
229294 if err != nil {
230295 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -238,10 +303,10 @@ Examples:
238303 fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
239304 fmt.Println()
240305 }
306+ }
241307
242- if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
243- return fmt.Errorf("failed to add attachment: %w", err)
244- }
308+ if err := handle.plane.AddAttachment(ctx, target, image); err != nil {
309+ return fmt.Errorf("failed to add attachment: %w", err)
245310 }
246311
247312 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
@@ -285,19 +350,13 @@ Examples:
285350 }
286351 }
287352
288- client, isRemote := GetRemoteClient()
289- if isRemote {
290- if err := client.RemoveAttachment(ctx, target, image); err != nil {
291- return fmt.Errorf("failed to remove attachment: %w", err)
292- }
293- } else {
294- local, err := GetLocalServices(configPath)
295- if err != nil {
296- return fmt.Errorf("failed to initialize local services: %w", err)
297- }
298- if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
299- return fmt.Errorf("failed to remove attachment: %w", err)
300- }
353+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
354+ if err != nil {
355+ return err
356+ }
357+ defer handle.close()
358+ if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil {
359+ return fmt.Errorf("failed to remove attachment: %w", err)
301360 }
302361
303362 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
@@ -58,6 +58,15 @@ Examples:
58 target = args[0]58 target = args[0]
59 }59 }
60 60
61+ if target != "" {
62+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
63+ if err != nil {
64+ return err
65+ }
66+ defer handle.close()
67+ return runAttachmentsList(ctx, handle.plane, target, jsonOut, cmd.OutOrStdout())
68+ }
69+
61 client, isRemote := GetRemoteClient()70 client, isRemote := GetRemoteClient()
62 if isRemote {71 if isRemote {
63 return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())72 return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
@@ -71,6 +80,61 @@ Examples:
71 return 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+
90+ if len(images) == 0 {
91+ fmt.Printf("No attachments configured for '%s'\n", target)
92+ return nil
93+ }
94+
95+ if jsonOut {
96+ return writeJSON(out, map[string]any{"target": target, "images": images})
97+ }
98+
99+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
100+ fmt.Println()
101+ for _, img := range images {
102+ fmt.Printf(" %s\n", img)
103+ }
104+ return nil
105+ }
106+
107+ attachments, err := cp.GetAllAttachmentsConfig(ctx)
108+ if err != nil {
109+ return fmt.Errorf("failed to list attachments: %w", err)
110+ }
111+ if len(attachments) == 0 {
112+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
113+ return nil
114+ }
115+ if jsonOut {
116+ return writeJSON(out, map[string]any{"attachments": attachments})
117+ }
118+
119+ targets := make([]string, 0, len(attachments))
120+ for t := range attachments {
121+ targets = append(targets, t)
122+ }
123+ sort.Strings(targets)
124+
125+ fmt.Println(styles.Theme.Title.Render("Attachments"))
126+ fmt.Println()
127+ for _, t := range targets {
128+ images := attachments[t]
129+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
130+ for _, img := range images {
131+ fmt.Printf(" %s\n", img)
132+ }
133+ }
134+
135+ return nil
136+}
137+
74 // runAttachmentsListRemote lists attachments from a remote Gordon instance.138 // runAttachmentsListRemote lists attachments from a remote Gordon instance.
75 func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {139 func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
76 if target != "" {140 if target != "" {
@@ -219,12 +283,13 @@ Examples:
219 target := args[0]283 target := args[0]
220 image := args[1]284 image := args[1]
221 285
222- client, isRemote := GetRemoteClient()286+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
223- if isRemote {287+ if err != nil {
224- if err := client.AddAttachment(ctx, target, image); err != nil {288+ return err
225- return fmt.Errorf("failed to add attachment: %w", err)289+ }
226- }290+ defer handle.close()
227- } else {291+
292+ if !handle.isRemote {
228 local, err := GetLocalServices(configPath)293 local, err := GetLocalServices(configPath)
229 if err != nil {294 if err != nil {
230 return fmt.Errorf("failed to initialize local services: %w", err)295 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -238,10 +303,10 @@ Examples:
238 fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))303 fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
239 fmt.Println()304 fmt.Println()
240 }305 }
306+ }
241 307
242- if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {308+ if err := handle.plane.AddAttachment(ctx, target, image); err != nil {
243- return fmt.Errorf("failed to add attachment: %w", err)309+ return fmt.Errorf("failed to add attachment: %w", err)
244- }
245 }310 }
246 311
247 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))312 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
@@ -285,19 +350,13 @@ Examples:
285 }350 }
286 }351 }
287 352
288- client, isRemote := GetRemoteClient()353+ handle, err := resolveControlPlaneForAttachmentTarget(ctx, target)
289- if isRemote {354+ if err != nil {
290- if err := client.RemoveAttachment(ctx, target, image); err != nil {355+ return err
291- return fmt.Errorf("failed to remove attachment: %w", err)356+ }
292- }357+ defer handle.close()
293- } else {358+ if err := handle.plane.RemoveAttachment(ctx, target, image); err != nil {
294- local, err := GetLocalServices(configPath)359+ return fmt.Errorf("failed to remove attachment: %w", err)
295- if err != nil {
296- return fmt.Errorf("failed to initialize local services: %w", err)
297- }
298- if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
299- return fmt.Errorf("failed to remove attachment: %w", err)
300- }
301 }360 }
302 361
303 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))362 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
M internal/adapters/in/cli/attachments_push.go +22 -4
@@ -57,17 +57,30 @@ func newAttachmentsPushCmd() *cobra.Command {
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 +51 -42
@@ -226,17 +226,12 @@ func resolveVersion(ctx context.Context, tag string) (string, error) {
226226 }
227227
228228 func runPush(ctx context.Context, req pushRequest) error {
229- handle, err := resolveControlPlane(configPath)
229+ dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, req)
230230 if err != nil {
231231 return err
232232 }
233233 defer handle.close()
234234
235- dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
236- if err != nil {
237- return err
238- }
239-
240235 registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
241236 if err != nil {
242237 return err
@@ -246,21 +241,14 @@ func runPush(ctx context.Context, req pushRequest) error {
246241 if err != nil {
247242 return err
248243 }
249-
250- for _, ba := range req.Build.BuildArgs {
251- if err := validateBuildArg(ba); err != nil {
252- return err
253- }
244+ if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
245+ return err
254246 }
255247
256- img := imagePush{
257- Registry: registry,
258- ImageName: imageName,
259- Version: version,
260- }
248+ img := imagePush{Registry: registry, ImageName: imageName, Version: version}
261249 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
262250
263- imageOps, err := newImageOpsFn()
251+ imageOps, err := newPushImageOps(inferredRemote)
264252 if err != nil {
265253 return err
266254 }
@@ -272,31 +260,60 @@ func runPush(ctx context.Context, req pushRequest) error {
272260 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
273261
274262 skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
263+ build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
264+ if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {
265+ return err
266+ }
275267
276- build := buildConfig{
277- Enabled: req.Build.Enabled,
278- Platform: req.Build.Platform,
279- Dockerfile: dockerfile,
280- BuildArgs: req.Build.BuildArgs,
268+ fmt.Println(styles.RenderSuccess("Push complete"))
269+ if !skipExplicitDeploy {
270+ return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
281271 }
272+ return nil
273+}
282274
283- if build.Enabled {
284- if err := buildAndPush(ctx, imageOps, build, img); err != nil {
285- return err
286- }
287- } else {
288- if err := tagAndPush(ctx, imageOps, img); err != nil {
275+func resolvePushTarget(ctx context.Context, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
276+ dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
277+ if err != nil {
278+ return "", nil, nil, err
279+ }
280+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)
281+ if err != nil {
282+ return "", nil, nil, err
283+ }
284+ if inferredRemote != nil {
285+ handle = newRemoteControlPlaneHandle(inferredRemote)
286+ fmt.Printf("Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)"))
287+ return dockerfile, inferredRemote, handle, nil
288+ }
289+ handle, err = resolveControlPlane(configPath)
290+ if err != nil {
291+ return "", nil, nil, err
292+ }
293+ return dockerfile, nil, handle, nil
294+}
295+
296+func validateBuildArgsList(buildArgs []string) error {
297+ for _, ba := range buildArgs {
298+ if err := validateBuildArg(ba); err != nil {
289299 return err
290300 }
291301 }
302+ return nil
303+}
292304
293- fmt.Println(styles.RenderSuccess("Push complete"))
294-
295- if !skipExplicitDeploy {
296- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
305+func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
306+ if inferredRemote != nil {
307+ return newImageOpsForResolvedRemote(inferredRemote)
297308 }
309+ return newImageOpsFn()
310+}
298311
299- return nil
312+func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {
313+ if build.Enabled {
314+ return buildAndPush(ctx, imageOps, build, img)
315+ }
316+ return tagAndPush(ctx, imageOps, img)
300317 }
301318
302319 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
@@ -345,15 +362,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
345362 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
346363 }
347364
348- // Filter out preview routes (domains containing "--") so they don't
349- // pollute the selection when pushing the base image.
350- filtered := routes[:0]
351- for _, r := range routes {
352- if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
353- filtered = append(filtered, r)
354- }
355- }
356- routes = filtered
365+ routes = filterPreviewRoutes(routes)
357366
358367 if len(routes) == 0 {
359368 return "", "", "", noRouteForImageError(imageArg)
@@ -226,17 +226,12 @@ func resolveVersion(ctx context.Context, tag string) (string, error) {
226 }226 }
227 227
228 func runPush(ctx context.Context, req pushRequest) error {228 func runPush(ctx context.Context, req pushRequest) error {
229- handle, err := resolveControlPlane(configPath)229+ dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, req)
230 if err != nil {230 if err != nil {
231 return err231 return err
232 }232 }
233 defer handle.close()233 defer handle.close()
234 234
235- dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
236- if err != nil {
237- return err
238- }
239-
240 registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)235 registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
241 if err != nil {236 if err != nil {
242 return err237 return err
@@ -246,21 +241,14 @@ func runPush(ctx context.Context, req pushRequest) error {
246 if err != nil {241 if err != nil {
247 return err242 return err
248 }243 }
249-244+ if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
250- for _, ba := range req.Build.BuildArgs {245+ return err
251- if err := validateBuildArg(ba); err != nil {
252- return err
253- }
254 }246 }
255 247
256- img := imagePush{248+ img := imagePush{Registry: registry, ImageName: imageName, Version: version}
257- Registry: registry,
258- ImageName: imageName,
259- Version: version,
260- }
261 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)249 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
262 250
263- imageOps, err := newImageOpsFn()251+ imageOps, err := newPushImageOps(inferredRemote)
264 if err != nil {252 if err != nil {
265 return err253 return err
266 }254 }
@@ -272,31 +260,60 @@ func runPush(ctx context.Context, req pushRequest) error {
272 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))260 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
273 261
274 skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)262 skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
263+ build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
264+ if err := pushResolvedImage(ctx, imageOps, build, img); err != nil {
265+ return err
266+ }
275 267
276- build := buildConfig{268+ fmt.Println(styles.RenderSuccess("Push complete"))
277- Enabled: req.Build.Enabled,269+ if !skipExplicitDeploy {
278- Platform: req.Build.Platform,270+ return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
279- Dockerfile: dockerfile,
280- BuildArgs: req.Build.BuildArgs,
281 }271 }
272+ return nil
273+}
282 274
283- if build.Enabled {275+func resolvePushTarget(ctx context.Context, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
284- if err := buildAndPush(ctx, imageOps, build, img); err != nil {276+ dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
285- return err277+ if err != nil {
286- }278+ return "", nil, nil, err
287- } else {279+ }
288- if err := tagAndPush(ctx, imageOps, img); err != nil {280+ inferredRemote, err = inferPushRemote(ctx, req.ImageArg, req.Domain, dockerfile)
281+ if err != nil {
282+ return "", nil, nil, err
283+ }
284+ if inferredRemote != nil {
285+ handle = newRemoteControlPlaneHandle(inferredRemote)
286+ fmt.Printf("Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)"))
287+ return dockerfile, inferredRemote, handle, nil
288+ }
289+ handle, err = resolveControlPlane(configPath)
290+ if err != nil {
291+ return "", nil, nil, err
292+ }
293+ return dockerfile, nil, handle, nil
294+}
295+
296+func validateBuildArgsList(buildArgs []string) error {
297+ for _, ba := range buildArgs {
298+ if err := validateBuildArg(ba); err != nil {
289 return err299 return err
290 }300 }
291 }301 }
302+ return nil
303+}
292 304
293- fmt.Println(styles.RenderSuccess("Push complete"))305+func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
294-306+ if inferredRemote != nil {
295- if !skipExplicitDeploy {307+ return newImageOpsForResolvedRemote(inferredRemote)
296- return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
297 }308 }
309+ return newImageOpsFn()
310+}
298 311
299- return nil312+func pushResolvedImage(ctx context.Context, imageOps pushImageOps, build buildConfig, img imagePush) error {
313+ if build.Enabled {
314+ return buildAndPush(ctx, imageOps, build, img)
315+ }
316+ return tagAndPush(ctx, imageOps, img)
300 }317 }
301 318
302 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy319 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
@@ -345,15 +362,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
345 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)362 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
346 }363 }
347 364
348- // Filter out preview routes (domains containing "--") so they don't365+ routes = filterPreviewRoutes(routes)
349- // pollute the selection when pushing the base image.
350- filtered := routes[:0]
351- for _, r := range routes {
352- if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
353- filtered = append(filtered, r)
354- }
355- }
356- routes = filtered
357 366
358 if len(routes) == 0 {367 if len(routes) == 0 {
359 return "", "", "", noRouteForImageError(imageArg)368 return "", "", "", noRouteForImageError(imageArg)
M internal/adapters/in/cli/push_image_ops.go +5 -6
@@ -55,6 +55,10 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
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 +457 -0
@@ -0,0 +1,457 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
20+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
21+ assert.Equal(t, "myapp", image)
22+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
23+ })
24+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
25+ assert.Equal(t, "myapp", image)
26+ return nil, http.StatusOK
27+ })
28+
29+ configurePushRemoteInferenceTestEnv(t, `
30+[remotes.prod]
31+url = "`+prod.URL+`"
32+
33+[remotes.staging]
34+url = "`+staging.URL+`"
35+`)
36+
37+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
38+ require.NoError(t, err)
39+ require.NotNil(t, resolved)
40+ assert.Equal(t, "prod", resolved.Name)
41+ assert.Equal(t, prod.URL, resolved.URL)
42+}
43+
44+func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
45+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
46+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
47+ })
48+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
49+ return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
50+ })
51+
52+ configurePushRemoteInferenceTestEnv(t, `
53+[remotes.prod]
54+url = "`+prod.URL+`"
55+
56+[remotes.staging]
57+url = "`+staging.URL+`"
58+`)
59+
60+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
61+ require.Error(t, err)
62+ assert.Nil(t, resolved)
63+ assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
64+ assert.Contains(t, err.Error(), "prod")
65+ assert.Contains(t, err.Error(), "staging")
66+}
67+
68+func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
69+ originalRemoteFlag := remoteFlag
70+ t.Cleanup(func() {
71+ remoteFlag = originalRemoteFlag
72+ })
73+
74+ called := false
75+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
76+ called = true
77+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
78+ })
79+
80+ configurePushRemoteInferenceTestEnv(t, `
81+[remotes.prod]
82+url = "`+prod.URL+`"
83+`)
84+ t.Setenv("GORDON_REMOTE", "")
85+ remoteFlag = "prod"
86+
87+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
88+ require.NoError(t, err)
89+ assert.Nil(t, resolved)
90+ assert.False(t, called)
91+}
92+
93+func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
94+ called := false
95+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
96+ called = true
97+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
98+ })
99+
100+ configurePushRemoteInferenceTestEnv(t, `
101+active = "prod"
102+
103+[remotes.prod]
104+url = "`+prod.URL+`"
105+`)
106+
107+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
108+ require.NoError(t, err)
109+ assert.Nil(t, resolved)
110+ assert.False(t, called)
111+}
112+
113+func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
114+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
115+ return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
116+ })
117+
118+ configurePushRemoteInferenceTestEnv(t, `
119+[remotes.prod]
120+url = "`+prod.URL+`"
121+`)
122+
123+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
124+ require.NoError(t, err)
125+ assert.Nil(t, resolved)
126+}
127+
128+func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
129+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
130+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
131+ })
132+ broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
133+ return nil, http.StatusInternalServerError
134+ })
135+
136+ configurePushRemoteInferenceTestEnv(t, `
137+[remotes.prod]
138+url = "`+prod.URL+`"
139+
140+[remotes.broken]
141+url = "`+broken.URL+`"
142+`)
143+
144+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
145+ require.Error(t, err)
146+ assert.Nil(t, resolved)
147+ assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
148+ assert.Contains(t, err.Error(), "broken")
149+}
150+
151+func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
152+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
153+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
154+ })
155+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
156+ return nil, http.StatusNotFound
157+ })
158+
159+ configurePushRemoteInferenceTestEnv(t, `
160+[remotes.prod]
161+url = "`+prod.URL+`"
162+
163+[remotes.staging]
164+url = "`+staging.URL+`"
165+`)
166+
167+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
168+ require.NoError(t, err)
169+ require.NotNil(t, resolved)
170+ assert.Equal(t, "prod", resolved.Name)
171+}
172+
173+func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
174+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
175+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
176+ })
177+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
178+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
179+ })
180+
181+ configurePushRemoteInferenceTestEnv(t, `
182+[remotes.prod]
183+url = "`+prod.URL+`"
184+
185+[remotes.staging]
186+url = "`+staging.URL+`"
187+`)
188+
189+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
190+ require.Error(t, err)
191+ assert.Nil(t, resolved)
192+ assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
193+}
194+
195+func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
196+ prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
197+ return []string{"app.example.com"}, http.StatusOK
198+ })
199+ staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
200+ return nil, http.StatusOK
201+ })
202+
203+ configurePushRemoteInferenceTestEnv(t, `
204+[remotes.prod]
205+url = "`+prod.URL+`"
206+
207+[remotes.staging]
208+url = "`+staging.URL+`"
209+`)
210+
211+ resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
212+ require.NoError(t, err)
213+ require.NotNil(t, resolved)
214+ assert.Equal(t, "prod", resolved.Name)
215+}
216+
217+func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
218+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
219+ getRoute: func(domainName string) (*domain.Route, int) {
220+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
221+ },
222+ getAttachmentsConfig: func(target string) ([]string, int) {
223+ return nil, http.StatusNotFound
224+ },
225+ })
226+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
227+ getRoute: func(domainName string) (*domain.Route, int) {
228+ return nil, http.StatusNotFound
229+ },
230+ getAttachmentsConfig: func(target string) ([]string, int) {
231+ return nil, http.StatusNotFound
232+ },
233+ })
234+
235+ configurePushRemoteInferenceTestEnv(t, `
236+[remotes.prod]
237+url = "`+prod.URL+`"
238+
239+[remotes.staging]
240+url = "`+staging.URL+`"
241+`)
242+
243+ resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
244+ require.NoError(t, err)
245+ require.NotNil(t, resolved)
246+ assert.Equal(t, "prod", resolved.Name)
247+}
248+
249+func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
250+ prod := newTagsTestServer(t, func(repository string) ([]string, int) {
251+ return []string{"latest", "v1.0.0"}, http.StatusOK
252+ })
253+ staging := newTagsTestServer(t, func(repository string) ([]string, int) {
254+ return []string{"latest"}, http.StatusOK
255+ })
256+
257+ configurePushRemoteInferenceTestEnv(t, `
258+[remotes.prod]
259+url = "`+prod.URL+`"
260+
261+[remotes.staging]
262+url = "`+staging.URL+`"
263+`)
264+
265+ resolved, err := inferRemoteForRepository(context.Background(), "myapp")
266+ require.Error(t, err)
267+ assert.Nil(t, resolved)
268+ assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
269+}
270+
271+func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
272+ t.Helper()
273+
274+ originalRemoteFlag := remoteFlag
275+ originalTokenFlag := tokenFlag
276+ originalInsecureTLSFlag := insecureTLSFlag
277+ t.Cleanup(func() {
278+ remoteFlag = originalRemoteFlag
279+ tokenFlag = originalTokenFlag
280+ insecureTLSFlag = originalInsecureTLSFlag
281+ })
282+
283+ remoteFlag = ""
284+ tokenFlag = ""
285+ insecureTLSFlag = false
286+ t.Setenv("GORDON_REMOTE", "")
287+ t.Setenv("GORDON_TOKEN", "")
288+ t.Setenv("GORDON_INSECURE", "")
289+ t.Setenv("HOME", t.TempDir())
290+
291+ configHome := t.TempDir()
292+ t.Setenv("XDG_CONFIG_HOME", configHome)
293+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
294+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
295+ require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
296+}
297+
298+func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
299+ t.Helper()
300+
301+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
302+ if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
303+ http.NotFound(w, r)
304+ return
305+ }
306+
307+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
308+ require.NoError(t, err)
309+
310+ routes, status := handler(image)
311+ if status >= http.StatusBadRequest {
312+ w.Header().Set("Content-Type", "application/json")
313+ w.WriteHeader(status)
314+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
315+ return
316+ }
317+
318+ w.Header().Set("Content-Type", "application/json")
319+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
320+ "image": image,
321+ "routes": routes,
322+ }))
323+ }))
324+ t.Cleanup(server.Close)
325+ return server
326+}
327+
328+func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
329+ t.Helper()
330+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
331+}
332+
333+type multiAdminProbeHandlers struct {
334+ getRoute func(domainName string) (*domain.Route, int)
335+ getAttachmentsConfig func(target string) ([]string, int)
336+ listTags func(repository string) ([]string, int)
337+ findAttachmentTarget func(image string) ([]string, int)
338+}
339+
340+func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
341+ t.Helper()
342+
343+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
344+ switch {
345+ case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
346+ handleGetRouteProbe(t, w, r, handlers.getRoute)
347+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
348+ handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
349+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
350+ handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
351+ case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
352+ handleListTagsProbe(t, w, r, handlers.listTags)
353+ default:
354+ http.NotFound(w, r)
355+ }
356+ }))
357+ t.Cleanup(server.Close)
358+ return server
359+}
360+
361+func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
362+ t.Helper()
363+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
364+}
365+
366+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
367+ t.Helper()
368+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
369+}
370+
371+func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
372+ t.Helper()
373+ if handler == nil {
374+ http.NotFound(w, r)
375+ return
376+ }
377+ domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
378+ require.NoError(t, err)
379+ route, status := handler(domainName)
380+ if status >= http.StatusBadRequest {
381+ w.Header().Set("Content-Type", "application/json")
382+ w.WriteHeader(status)
383+ if status == http.StatusNotFound {
384+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()}))
385+ return
386+ }
387+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
388+ return
389+ }
390+ w.Header().Set("Content-Type", "application/json")
391+ require.NoError(t, json.NewEncoder(w).Encode(route))
392+}
393+
394+func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
395+ t.Helper()
396+ if handler == nil {
397+ http.NotFound(w, r)
398+ return
399+ }
400+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
401+ require.NoError(t, err)
402+ targets, status := handler(image)
403+ if status >= http.StatusBadRequest {
404+ w.Header().Set("Content-Type", "application/json")
405+ w.WriteHeader(status)
406+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
407+ return
408+ }
409+ w.Header().Set("Content-Type", "application/json")
410+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets}))
411+}
412+
413+func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
414+ t.Helper()
415+ if handler == nil {
416+ http.NotFound(w, r)
417+ return
418+ }
419+ target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
420+ require.NoError(t, err)
421+ images, status := handler(target)
422+ if status >= http.StatusBadRequest {
423+ w.Header().Set("Content-Type", "application/json")
424+ w.WriteHeader(status)
425+ if status == http.StatusNotFound {
426+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"}))
427+ return
428+ }
429+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
430+ return
431+ }
432+ w.Header().Set("Content-Type", "application/json")
433+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images}))
434+}
435+
436+func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
437+ t.Helper()
438+ if handler == nil {
439+ http.NotFound(w, r)
440+ return
441+ }
442+ repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
443+ require.NoError(t, err)
444+ tags, status := handler(repository)
445+ if status >= http.StatusBadRequest {
446+ w.Header().Set("Content-Type", "application/json")
447+ w.WriteHeader(status)
448+ if status == http.StatusNotFound {
449+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"}))
450+ return
451+ }
452+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
453+ return
454+ }
455+ w.Header().Set("Content-Type", "application/json")
456+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags}))
457+}
@@ -0,0 +1,457 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
20+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
21+ assert.Equal(t, "myapp", image)
22+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
23+ })
24+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
25+ assert.Equal(t, "myapp", image)
26+ return nil, http.StatusOK
27+ })
28+
29+ configurePushRemoteInferenceTestEnv(t, `
30+[remotes.prod]
31+url = "`+prod.URL+`"
32+
33+[remotes.staging]
34+url = "`+staging.URL+`"
35+`)
36+
37+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
38+ require.NoError(t, err)
39+ require.NotNil(t, resolved)
40+ assert.Equal(t, "prod", resolved.Name)
41+ assert.Equal(t, prod.URL, resolved.URL)
42+}
43+
44+func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
45+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
46+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
47+ })
48+ staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
49+ return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
50+ })
51+
52+ configurePushRemoteInferenceTestEnv(t, `
53+[remotes.prod]
54+url = "`+prod.URL+`"
55+
56+[remotes.staging]
57+url = "`+staging.URL+`"
58+`)
59+
60+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
61+ require.Error(t, err)
62+ assert.Nil(t, resolved)
63+ assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
64+ assert.Contains(t, err.Error(), "prod")
65+ assert.Contains(t, err.Error(), "staging")
66+}
67+
68+func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
69+ originalRemoteFlag := remoteFlag
70+ t.Cleanup(func() {
71+ remoteFlag = originalRemoteFlag
72+ })
73+
74+ called := false
75+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
76+ called = true
77+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
78+ })
79+
80+ configurePushRemoteInferenceTestEnv(t, `
81+[remotes.prod]
82+url = "`+prod.URL+`"
83+`)
84+ t.Setenv("GORDON_REMOTE", "")
85+ remoteFlag = "prod"
86+
87+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
88+ require.NoError(t, err)
89+ assert.Nil(t, resolved)
90+ assert.False(t, called)
91+}
92+
93+func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
94+ called := false
95+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
96+ called = true
97+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
98+ })
99+
100+ configurePushRemoteInferenceTestEnv(t, `
101+active = "prod"
102+
103+[remotes.prod]
104+url = "`+prod.URL+`"
105+`)
106+
107+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
108+ require.NoError(t, err)
109+ assert.Nil(t, resolved)
110+ assert.False(t, called)
111+}
112+
113+func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
114+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
115+ return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
116+ })
117+
118+ configurePushRemoteInferenceTestEnv(t, `
119+[remotes.prod]
120+url = "`+prod.URL+`"
121+`)
122+
123+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
124+ require.NoError(t, err)
125+ assert.Nil(t, resolved)
126+}
127+
128+func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
129+ prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
130+ return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
131+ })
132+ broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
133+ return nil, http.StatusInternalServerError
134+ })
135+
136+ configurePushRemoteInferenceTestEnv(t, `
137+[remotes.prod]
138+url = "`+prod.URL+`"
139+
140+[remotes.broken]
141+url = "`+broken.URL+`"
142+`)
143+
144+ resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
145+ require.Error(t, err)
146+ assert.Nil(t, resolved)
147+ assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
148+ assert.Contains(t, err.Error(), "broken")
149+}
150+
151+func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
152+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
153+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
154+ })
155+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
156+ return nil, http.StatusNotFound
157+ })
158+
159+ configurePushRemoteInferenceTestEnv(t, `
160+[remotes.prod]
161+url = "`+prod.URL+`"
162+
163+[remotes.staging]
164+url = "`+staging.URL+`"
165+`)
166+
167+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
168+ require.NoError(t, err)
169+ require.NotNil(t, resolved)
170+ assert.Equal(t, "prod", resolved.Name)
171+}
172+
173+func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
174+ prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
175+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
176+ })
177+ staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
178+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
179+ })
180+
181+ configurePushRemoteInferenceTestEnv(t, `
182+[remotes.prod]
183+url = "`+prod.URL+`"
184+
185+[remotes.staging]
186+url = "`+staging.URL+`"
187+`)
188+
189+ resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
190+ require.Error(t, err)
191+ assert.Nil(t, resolved)
192+ assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
193+}
194+
195+func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
196+ prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
197+ return []string{"app.example.com"}, http.StatusOK
198+ })
199+ staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
200+ return nil, http.StatusOK
201+ })
202+
203+ configurePushRemoteInferenceTestEnv(t, `
204+[remotes.prod]
205+url = "`+prod.URL+`"
206+
207+[remotes.staging]
208+url = "`+staging.URL+`"
209+`)
210+
211+ resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
212+ require.NoError(t, err)
213+ require.NotNil(t, resolved)
214+ assert.Equal(t, "prod", resolved.Name)
215+}
216+
217+func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
218+ prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
219+ getRoute: func(domainName string) (*domain.Route, int) {
220+ return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
221+ },
222+ getAttachmentsConfig: func(target string) ([]string, int) {
223+ return nil, http.StatusNotFound
224+ },
225+ })
226+ staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
227+ getRoute: func(domainName string) (*domain.Route, int) {
228+ return nil, http.StatusNotFound
229+ },
230+ getAttachmentsConfig: func(target string) ([]string, int) {
231+ return nil, http.StatusNotFound
232+ },
233+ })
234+
235+ configurePushRemoteInferenceTestEnv(t, `
236+[remotes.prod]
237+url = "`+prod.URL+`"
238+
239+[remotes.staging]
240+url = "`+staging.URL+`"
241+`)
242+
243+ resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
244+ require.NoError(t, err)
245+ require.NotNil(t, resolved)
246+ assert.Equal(t, "prod", resolved.Name)
247+}
248+
249+func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
250+ prod := newTagsTestServer(t, func(repository string) ([]string, int) {
251+ return []string{"latest", "v1.0.0"}, http.StatusOK
252+ })
253+ staging := newTagsTestServer(t, func(repository string) ([]string, int) {
254+ return []string{"latest"}, http.StatusOK
255+ })
256+
257+ configurePushRemoteInferenceTestEnv(t, `
258+[remotes.prod]
259+url = "`+prod.URL+`"
260+
261+[remotes.staging]
262+url = "`+staging.URL+`"
263+`)
264+
265+ resolved, err := inferRemoteForRepository(context.Background(), "myapp")
266+ require.Error(t, err)
267+ assert.Nil(t, resolved)
268+ assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
269+}
270+
271+func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
272+ t.Helper()
273+
274+ originalRemoteFlag := remoteFlag
275+ originalTokenFlag := tokenFlag
276+ originalInsecureTLSFlag := insecureTLSFlag
277+ t.Cleanup(func() {
278+ remoteFlag = originalRemoteFlag
279+ tokenFlag = originalTokenFlag
280+ insecureTLSFlag = originalInsecureTLSFlag
281+ })
282+
283+ remoteFlag = ""
284+ tokenFlag = ""
285+ insecureTLSFlag = false
286+ t.Setenv("GORDON_REMOTE", "")
287+ t.Setenv("GORDON_TOKEN", "")
288+ t.Setenv("GORDON_INSECURE", "")
289+ t.Setenv("HOME", t.TempDir())
290+
291+ configHome := t.TempDir()
292+ t.Setenv("XDG_CONFIG_HOME", configHome)
293+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
294+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
295+ require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
296+}
297+
298+func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
299+ t.Helper()
300+
301+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
302+ if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
303+ http.NotFound(w, r)
304+ return
305+ }
306+
307+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
308+ require.NoError(t, err)
309+
310+ routes, status := handler(image)
311+ if status >= http.StatusBadRequest {
312+ w.Header().Set("Content-Type", "application/json")
313+ w.WriteHeader(status)
314+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
315+ return
316+ }
317+
318+ w.Header().Set("Content-Type", "application/json")
319+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
320+ "image": image,
321+ "routes": routes,
322+ }))
323+ }))
324+ t.Cleanup(server.Close)
325+ return server
326+}
327+
328+func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
329+ t.Helper()
330+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
331+}
332+
333+type multiAdminProbeHandlers struct {
334+ getRoute func(domainName string) (*domain.Route, int)
335+ getAttachmentsConfig func(target string) ([]string, int)
336+ listTags func(repository string) ([]string, int)
337+ findAttachmentTarget func(image string) ([]string, int)
338+}
339+
340+func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
341+ t.Helper()
342+
343+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
344+ switch {
345+ case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
346+ handleGetRouteProbe(t, w, r, handlers.getRoute)
347+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
348+ handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
349+ case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
350+ handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
351+ case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
352+ handleListTagsProbe(t, w, r, handlers.listTags)
353+ default:
354+ http.NotFound(w, r)
355+ }
356+ }))
357+ t.Cleanup(server.Close)
358+ return server
359+}
360+
361+func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
362+ t.Helper()
363+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
364+}
365+
366+func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
367+ t.Helper()
368+ return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
369+}
370+
371+func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
372+ t.Helper()
373+ if handler == nil {
374+ http.NotFound(w, r)
375+ return
376+ }
377+ domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
378+ require.NoError(t, err)
379+ route, status := handler(domainName)
380+ if status >= http.StatusBadRequest {
381+ w.Header().Set("Content-Type", "application/json")
382+ w.WriteHeader(status)
383+ if status == http.StatusNotFound {
384+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()}))
385+ return
386+ }
387+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
388+ return
389+ }
390+ w.Header().Set("Content-Type", "application/json")
391+ require.NoError(t, json.NewEncoder(w).Encode(route))
392+}
393+
394+func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
395+ t.Helper()
396+ if handler == nil {
397+ http.NotFound(w, r)
398+ return
399+ }
400+ image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
401+ require.NoError(t, err)
402+ targets, status := handler(image)
403+ if status >= http.StatusBadRequest {
404+ w.Header().Set("Content-Type", "application/json")
405+ w.WriteHeader(status)
406+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
407+ return
408+ }
409+ w.Header().Set("Content-Type", "application/json")
410+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets}))
411+}
412+
413+func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
414+ t.Helper()
415+ if handler == nil {
416+ http.NotFound(w, r)
417+ return
418+ }
419+ target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
420+ require.NoError(t, err)
421+ images, status := handler(target)
422+ if status >= http.StatusBadRequest {
423+ w.Header().Set("Content-Type", "application/json")
424+ w.WriteHeader(status)
425+ if status == http.StatusNotFound {
426+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"}))
427+ return
428+ }
429+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
430+ return
431+ }
432+ w.Header().Set("Content-Type", "application/json")
433+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images}))
434+}
435+
436+func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
437+ t.Helper()
438+ if handler == nil {
439+ http.NotFound(w, r)
440+ return
441+ }
442+ repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
443+ require.NoError(t, err)
444+ tags, status := handler(repository)
445+ if status >= http.StatusBadRequest {
446+ w.Header().Set("Content-Type", "application/json")
447+ w.WriteHeader(status)
448+ if status == http.StatusNotFound {
449+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"}))
450+ return
451+ }
452+ require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"}))
453+ return
454+ }
455+ w.Header().Set("Content-Type", "application/json")
456+ require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags}))
457+}
A internal/adapters/in/cli/remote_inference.go +204 -0
@@ -0,0 +1,204 @@
1+package cli
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "strings"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/gordon/pkg/validation"
13+)
14+
15+type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error)
16+
17+func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile string) (*remote.ResolvedRemote, error) {
18+ if domainFlag != "" {
19+ return inferRemoteForRouteDomain(ctx, domainFlag)
20+ }
21+
22+ if imageArg == "" {
23+ detected, err := detectImageName(dockerfile)
24+ if err != nil {
25+ return nil, err
26+ }
27+ imageArg = detected
28+ }
29+
30+ if looksLikeLegacyDomain(imageArg) {
31+ lookupImage := imageArg
32+ domainLookupArg := imageArg
33+ if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
34+ domainLookupArg = parsedImage
35+ if !strings.HasPrefix(ref, "sha256:") {
36+ lookupImage = parsedImage
37+ }
38+ }
39+
40+ resolved, err := inferRemoteForImage(ctx, lookupImage)
41+ if err != nil || resolved != nil {
42+ return resolved, err
43+ }
44+
45+ return inferRemoteForRouteDomain(ctx, domainLookupArg)
46+ }
47+
48+ classified := classifyPushArgument(imageArg)
49+ return inferRemoteForImage(ctx, classified.lookupImage)
50+}
51+
52+func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
53+ return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
54+ routes, err := client.FindRoutesByImage(ctx, imageName)
55+ if err != nil {
56+ return false, err
57+ }
58+ return len(filterPreviewRoutes(routes)) > 0, nil
59+ })
60+}
61+
62+func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) {
63+ return inferSavedRemote(ctx, "route", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) {
64+ _, err := client.GetRoute(ctx, routeDomain)
65+ if err != nil {
66+ if isRemoteNotFoundError(err) {
67+ return false, nil
68+ }
69+ return false, err
70+ }
71+ return true, nil
72+ })
73+}
74+
75+func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
76+ return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
77+ targets, err := client.FindAttachmentTargetsByImage(ctx, imageName)
78+ if err != nil {
79+ return false, err
80+ }
81+ return len(targets) > 0, nil
82+ })
83+}
84+
85+func inferRemoteForAttachmentTarget(ctx context.Context, target string) (*remote.ResolvedRemote, error) {
86+ return inferSavedRemote(ctx, "attachment target", target, func(ctx context.Context, client *remote.Client) (bool, error) {
87+ images, err := client.GetAttachmentsConfig(ctx, target)
88+ if err == nil {
89+ return len(images) > 0, nil
90+ }
91+ if !isRemoteNotFoundError(err) {
92+ return false, err
93+ }
94+
95+ _, err = client.GetRoute(ctx, target)
96+ if err != nil {
97+ if isRemoteNotFoundError(err) {
98+ return false, nil
99+ }
100+ return false, err
101+ }
102+ return true, nil
103+ })
104+}
105+
106+func inferRemoteForRepository(ctx context.Context, repository string) (*remote.ResolvedRemote, error) {
107+ return inferSavedRemote(ctx, "repository", repository, func(ctx context.Context, client *remote.Client) (bool, error) {
108+ tags, err := client.ListTags(ctx, repository)
109+ if err != nil {
110+ if isRemoteNotFoundError(err) {
111+ return false, nil
112+ }
113+ return false, err
114+ }
115+ return len(tags) > 0, nil
116+ })
117+}
118+
119+func inferSavedRemote(ctx context.Context, targetKind, target string, probe remoteInferenceProbe) (*remote.ResolvedRemote, error) {
120+ remotes, ok := loadInferenceCandidateRemotes()
121+ if !ok {
122+ return nil, nil
123+ }
124+
125+ names := sortedRemoteNames(remotes)
126+ matches := make([]*remote.ResolvedRemote, 0, len(names))
127+ probeFailures := make([]string, 0)
128+
129+ for _, name := range names {
130+ entry := remotes[name]
131+ matched, err := probe(ctx, newRoutesTargetClient(name, entry))
132+ if err != nil {
133+ probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err))
134+ continue
135+ }
136+ if matched {
137+ matches = append(matches, resolvedRemoteFromEntry(name, entry))
138+ }
139+ }
140+
141+ if len(probeFailures) > 0 {
142+ return nil, fmt.Errorf("could not safely infer remote for %s %q because probing failed for: %s; use --remote", targetKind, target, strings.Join(probeFailures, ", "))
143+ }
144+
145+ if len(matches) > 1 {
146+ names := make([]string, 0, len(matches))
147+ for _, match := range matches {
148+ names = append(names, match.DisplayName())
149+ }
150+ return nil, fmt.Errorf("multiple saved remotes match %s %q: %s; use --remote", targetKind, target, strings.Join(names, ", "))
151+ }
152+
153+ if len(matches) == 1 {
154+ return matches[0], nil
155+ }
156+
157+ return nil, nil
158+}
159+
160+func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) {
161+ if _, ok := resolveRoutesExplicitTarget(); ok {
162+ return nil, false
163+ }
164+
165+ remotes, err := remote.LoadRemotes("")
166+ if err != nil || remotes == nil {
167+ return nil, false
168+ }
169+ if remotes.Active != "" {
170+ return nil, false
171+ }
172+ if len(remotes.Remotes) == 0 {
173+ return nil, false
174+ }
175+
176+ return remotes.Remotes, true
177+}
178+
179+func resolvedRemoteFromEntry(name string, entry remote.RemoteEntry) *remote.ResolvedRemote {
180+ return &remote.ResolvedRemote{
181+ Name: name,
182+ URL: entry.URL,
183+ Token: resolveRoutesTokenForTarget(name, entry),
184+ InsecureTLS: resolveRoutesInsecureForTarget(name, entry),
185+ }
186+}
187+
188+func isRemoteNotFoundError(err error) bool {
189+ var httpErr *remote.HTTPError
190+ if errors.As(err, &httpErr) {
191+ return httpErr.StatusCode == http.StatusNotFound
192+ }
193+ return errors.Is(err, domain.ErrRouteNotFound)
194+}
195+
196+func filterPreviewRoutes(routes []domain.Route) []domain.Route {
197+ filtered := routes[:0]
198+ for _, route := range routes {
199+ if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) {
200+ filtered = append(filtered, route)
201+ }
202+ }
203+ return filtered
204+}
@@ -0,0 +1,204 @@
1+package cli
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "strings"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/gordon/pkg/validation"
13+)
14+
15+type remoteInferenceProbe func(context.Context, *remote.Client) (bool, error)
16+
17+func inferPushRemote(ctx context.Context, imageArg, domainFlag, dockerfile string) (*remote.ResolvedRemote, error) {
18+ if domainFlag != "" {
19+ return inferRemoteForRouteDomain(ctx, domainFlag)
20+ }
21+
22+ if imageArg == "" {
23+ detected, err := detectImageName(dockerfile)
24+ if err != nil {
25+ return nil, err
26+ }
27+ imageArg = detected
28+ }
29+
30+ if looksLikeLegacyDomain(imageArg) {
31+ lookupImage := imageArg
32+ domainLookupArg := imageArg
33+ if parsedImage, ref := validation.ParseImageReference(imageArg); parsedImage != imageArg {
34+ domainLookupArg = parsedImage
35+ if !strings.HasPrefix(ref, "sha256:") {
36+ lookupImage = parsedImage
37+ }
38+ }
39+
40+ resolved, err := inferRemoteForImage(ctx, lookupImage)
41+ if err != nil || resolved != nil {
42+ return resolved, err
43+ }
44+
45+ return inferRemoteForRouteDomain(ctx, domainLookupArg)
46+ }
47+
48+ classified := classifyPushArgument(imageArg)
49+ return inferRemoteForImage(ctx, classified.lookupImage)
50+}
51+
52+func inferRemoteForImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
53+ return inferSavedRemote(ctx, "image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
54+ routes, err := client.FindRoutesByImage(ctx, imageName)
55+ if err != nil {
56+ return false, err
57+ }
58+ return len(filterPreviewRoutes(routes)) > 0, nil
59+ })
60+}
61+
62+func inferRemoteForRouteDomain(ctx context.Context, routeDomain string) (*remote.ResolvedRemote, error) {
63+ return inferSavedRemote(ctx, "route", routeDomain, func(ctx context.Context, client *remote.Client) (bool, error) {
64+ _, err := client.GetRoute(ctx, routeDomain)
65+ if err != nil {
66+ if isRemoteNotFoundError(err) {
67+ return false, nil
68+ }
69+ return false, err
70+ }
71+ return true, nil
72+ })
73+}
74+
75+func inferRemoteForAttachmentImage(ctx context.Context, imageName string) (*remote.ResolvedRemote, error) {
76+ return inferSavedRemote(ctx, "attachment image", imageName, func(ctx context.Context, client *remote.Client) (bool, error) {
77+ targets, err := client.FindAttachmentTargetsByImage(ctx, imageName)
78+ if err != nil {
79+ return false, err
80+ }
81+ return len(targets) > 0, nil
82+ })
83+}
84+
85+func inferRemoteForAttachmentTarget(ctx context.Context, target string) (*remote.ResolvedRemote, error) {
86+ return inferSavedRemote(ctx, "attachment target", target, func(ctx context.Context, client *remote.Client) (bool, error) {
87+ images, err := client.GetAttachmentsConfig(ctx, target)
88+ if err == nil {
89+ return len(images) > 0, nil
90+ }
91+ if !isRemoteNotFoundError(err) {
92+ return false, err
93+ }
94+
95+ _, err = client.GetRoute(ctx, target)
96+ if err != nil {
97+ if isRemoteNotFoundError(err) {
98+ return false, nil
99+ }
100+ return false, err
101+ }
102+ return true, nil
103+ })
104+}
105+
106+func inferRemoteForRepository(ctx context.Context, repository string) (*remote.ResolvedRemote, error) {
107+ return inferSavedRemote(ctx, "repository", repository, func(ctx context.Context, client *remote.Client) (bool, error) {
108+ tags, err := client.ListTags(ctx, repository)
109+ if err != nil {
110+ if isRemoteNotFoundError(err) {
111+ return false, nil
112+ }
113+ return false, err
114+ }
115+ return len(tags) > 0, nil
116+ })
117+}
118+
119+func inferSavedRemote(ctx context.Context, targetKind, target string, probe remoteInferenceProbe) (*remote.ResolvedRemote, error) {
120+ remotes, ok := loadInferenceCandidateRemotes()
121+ if !ok {
122+ return nil, nil
123+ }
124+
125+ names := sortedRemoteNames(remotes)
126+ matches := make([]*remote.ResolvedRemote, 0, len(names))
127+ probeFailures := make([]string, 0)
128+
129+ for _, name := range names {
130+ entry := remotes[name]
131+ matched, err := probe(ctx, newRoutesTargetClient(name, entry))
132+ if err != nil {
133+ probeFailures = append(probeFailures, fmt.Sprintf("%s (%v)", name, err))
134+ continue
135+ }
136+ if matched {
137+ matches = append(matches, resolvedRemoteFromEntry(name, entry))
138+ }
139+ }
140+
141+ if len(probeFailures) > 0 {
142+ return nil, fmt.Errorf("could not safely infer remote for %s %q because probing failed for: %s; use --remote", targetKind, target, strings.Join(probeFailures, ", "))
143+ }
144+
145+ if len(matches) > 1 {
146+ names := make([]string, 0, len(matches))
147+ for _, match := range matches {
148+ names = append(names, match.DisplayName())
149+ }
150+ return nil, fmt.Errorf("multiple saved remotes match %s %q: %s; use --remote", targetKind, target, strings.Join(names, ", "))
151+ }
152+
153+ if len(matches) == 1 {
154+ return matches[0], nil
155+ }
156+
157+ return nil, nil
158+}
159+
160+func loadInferenceCandidateRemotes() (map[string]remote.RemoteEntry, bool) {
161+ if _, ok := resolveRoutesExplicitTarget(); ok {
162+ return nil, false
163+ }
164+
165+ remotes, err := remote.LoadRemotes("")
166+ if err != nil || remotes == nil {
167+ return nil, false
168+ }
169+ if remotes.Active != "" {
170+ return nil, false
171+ }
172+ if len(remotes.Remotes) == 0 {
173+ return nil, false
174+ }
175+
176+ return remotes.Remotes, true
177+}
178+
179+func resolvedRemoteFromEntry(name string, entry remote.RemoteEntry) *remote.ResolvedRemote {
180+ return &remote.ResolvedRemote{
181+ Name: name,
182+ URL: entry.URL,
183+ Token: resolveRoutesTokenForTarget(name, entry),
184+ InsecureTLS: resolveRoutesInsecureForTarget(name, entry),
185+ }
186+}
187+
188+func isRemoteNotFoundError(err error) bool {
189+ var httpErr *remote.HTTPError
190+ if errors.As(err, &httpErr) {
191+ return httpErr.StatusCode == http.StatusNotFound
192+ }
193+ return errors.Is(err, domain.ErrRouteNotFound)
194+}
195+
196+func filterPreviewRoutes(routes []domain.Route) []domain.Route {
197+ filtered := routes[:0]
198+ for _, route := range routes {
199+ if !strings.Contains(route.Domain, domain.DefaultPreviewSeparator) {
200+ filtered = append(filtered, route)
201+ }
202+ }
203+ return filtered
204+}
M internal/adapters/in/cli/restart.go +1 -1
@@ -28,7 +28,7 @@ Examples:
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 +41 -7
@@ -298,7 +298,7 @@ Remote mode:
298298 if len(args) > 0 {
299299 logDomain = args[0]
300300 }
301- return runLogs(logsConfigPath, logDomain, follow, lines)
301+ return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines)
302302 },
303303 }
304304
@@ -310,7 +310,16 @@ Remote mode:
310310 }
311311
312312 // runLogs handles the logs command logic.
313-func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
313+func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int) error {
314+ if logDomain != "" {
315+ handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain)
316+ if err != nil {
317+ return err
318+ }
319+ defer handle.close()
320+ return runContainerLogs(ctx, handle.plane, logDomain, follow, lines)
321+ }
322+
314323 client, isRemote := GetRemoteClient()
315324 if isRemote {
316325 return runLogsRemote(client, logDomain, follow, lines)
@@ -382,15 +391,40 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro
382391 return app.ShowLogs(logsConfigPath, follow, lines)
383392 }
384393
385- // Container logs - use local services
386394 return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
387395 }
388396
389-// showContainerLogsLocal shows container logs using local Docker access.
397+func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int) error {
398+ ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
399+ defer cancel()
400+
401+ if follow {
402+ ch, err := cp.StreamContainerLogs(ctx, logDomain, lines)
403+ if err != nil {
404+ return fmt.Errorf("failed to stream container logs: %w", err)
405+ }
406+ for line := range ch {
407+ if err := cliWriteLine(os.Stdout, line); err != nil {
408+ return err
409+ }
410+ }
411+ return nil
412+ }
413+
414+ logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)
415+ if err != nil {
416+ return fmt.Errorf("failed to get container logs: %w", err)
417+ }
418+ for _, line := range logLines {
419+ if err := cliWriteLine(os.Stdout, line); err != nil {
420+ return err
421+ }
422+ }
423+ return nil
424+}
425+
426+// showContainerLogsLocal is retained for UI adoption coverage and fallback messaging.
390427 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.
394428 if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
395429 return err
396430 }
@@ -298,7 +298,7 @@ Remote mode:
298 if len(args) > 0 {298 if len(args) > 0 {
299 logDomain = args[0]299 logDomain = args[0]
300 }300 }
301- return runLogs(logsConfigPath, logDomain, follow, lines)301+ return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines)
302 },302 },
303 }303 }
304 304
@@ -310,7 +310,16 @@ Remote mode:
310 }310 }
311 311
312 // runLogs handles the logs command logic.312 // runLogs handles the logs command logic.
313-func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {313+func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int) error {
314+ if logDomain != "" {
315+ handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain)
316+ if err != nil {
317+ return err
318+ }
319+ defer handle.close()
320+ return runContainerLogs(ctx, handle.plane, logDomain, follow, lines)
321+ }
322+
314 client, isRemote := GetRemoteClient()323 client, isRemote := GetRemoteClient()
315 if isRemote {324 if isRemote {
316 return runLogsRemote(client, logDomain, follow, lines)325 return runLogsRemote(client, logDomain, follow, lines)
@@ -382,15 +391,40 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro
382 return app.ShowLogs(logsConfigPath, follow, lines)391 return app.ShowLogs(logsConfigPath, follow, lines)
383 }392 }
384 393
385- // Container logs - use local services
386 return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)394 return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
387 }395 }
388 396
389-// showContainerLogsLocal shows container logs using local Docker access.397+func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int) error {
398+ ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
399+ defer cancel()
400+
401+ if follow {
402+ ch, err := cp.StreamContainerLogs(ctx, logDomain, lines)
403+ if err != nil {
404+ return fmt.Errorf("failed to stream container logs: %w", err)
405+ }
406+ for line := range ch {
407+ if err := cliWriteLine(os.Stdout, line); err != nil {
408+ return err
409+ }
410+ }
411+ return nil
412+ }
413+
414+ logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)
415+ if err != nil {
416+ return fmt.Errorf("failed to get container logs: %w", err)
417+ }
418+ for _, line := range logLines {
419+ if err := cliWriteLine(os.Stdout, line); err != nil {
420+ return err
421+ }
422+ }
423+ return nil
424+}
425+
426+// showContainerLogsLocal is retained for UI adoption coverage and fallback messaging.
390 func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error {427 func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error {
391- // For local container logs, we need Docker access which requires
392- // the runtime to be initialized. For now, suggest using remote mode
393- // or direct docker logs command.
394 if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {428 if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
395 return err429 return err
396 }430 }
M internal/adapters/in/cli/routes.go +8 -14
@@ -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 }
@@ -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 }
@@ -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