fix(cli): infer push registry from target Gordon instance (#12)
Bare image names passed to 'gordon images push' use the target's registry_domain, published on /admin/auth/verify (fallback /admin/status). Fully qualified refs must match that registry.
- commit
- 525f93d5d05b3ee2d1cf00f6200f038d1672e425
- author
- brice · · 2026-10-08 07:39 UTC
- committer
- bnema · · 2026-10-08 07:39 UTC
- parent
- d676137
- signature
- unsigned
9 files · +343 -11
M docs/cli/images.md +9 -0
| @@ -57,6 +57,15 @@ It auto-selects only when exactly one remote matches. If multiple remotes match, | |||
| 57 | 57 | or a saved remote cannot be probed safely, the command stops and asks you to use | |
| 58 | 58 | `--remote` explicitly. | |
| 59 | 59 | ||
| 60 | + | The registry comes from the target Gordon instance. A bare image name such as | |
| 61 | + | `myapp` or `myapp:v1` is pushed to the target's `server.registry_domain`, read | |
| 62 | + | from `/admin/auth/verify` (any admin-scoped token, no `admin:status:read` | |
| 63 | + | needed). A fully qualified | |
| 64 | + | reference such as `registry.example.com/myapp` must name that same registry; | |
| 65 | + | otherwise the push is rejected so Gordon credentials are never sent to another | |
| 66 | + | registry. Hosts listed in `server.legacy_registry_domains` are rejected too: use | |
| 67 | + | the canonical registry or a bare name. | |
| 68 | + | ||
| 60 | 69 | Domain-style push targets are retired: push an image name, then | |
| 61 | 70 | `gordon apps apply` the manifest that references the pushed tag. | |
| 62 | 71 | ||
| @@ -57,6 +57,15 @@ It auto-selects only when exactly one remote matches. If multiple remotes match, | |||||
| 57 | or a saved remote cannot be probed safely, the command stops and asks you to use | 57 | or a saved remote cannot be probed safely, the command stops and asks you to use | ||
| 58 | `--remote` explicitly. | 58 | `--remote` explicitly. | ||
| 59 | 59 | ||||
| 60 | + | The registry comes from the target Gordon instance. A bare image name such as | |||
| 61 | + | `myapp` or `myapp:v1` is pushed to the target's `server.registry_domain`, read | |||
| 62 | + | from `/admin/auth/verify` (any admin-scoped token, no `admin:status:read` | |||
| 63 | + | needed). A fully qualified | |||
| 64 | + | reference such as `registry.example.com/myapp` must name that same registry; | |||
| 65 | + | otherwise the push is rejected so Gordon credentials are never sent to another | |||
| 66 | + | registry. Hosts listed in `server.legacy_registry_domains` are rejected too: use | |||
| 67 | + | the canonical registry or a bare name. | |||
| 68 | + | ||||
| 60 | Domain-style push targets are retired: push an image name, then | 69 | Domain-style push targets are retired: push an image name, then | ||
| 61 | `gordon apps apply` the manifest that references the pushed tag. | 70 | `gordon apps apply` the manifest that references the pushed tag. | ||
| 62 | 71 | ||||
M internal/adapters/dto/admin_auth.go +2 -0
| @@ -7,4 +7,6 @@ type AuthVerifyResponse struct { | |||
| 7 | 7 | Scopes []string `json:"scopes,omitempty"` | |
| 8 | 8 | ExpiresAt int64 `json:"expires_at,omitempty"` | |
| 9 | 9 | IssuedAt int64 `json:"issued_at,omitempty"` | |
| 10 | + | // RegistryDomain is the Gordon registry host that push clients target. | |
| 11 | + | RegistryDomain string `json:"registry_domain,omitempty"` | |
| 10 | 12 | } | |
| @@ -7,4 +7,6 @@ type AuthVerifyResponse struct { | |||||
| 7 | Scopes []string `json:"scopes,omitempty"` | 7 | Scopes []string `json:"scopes,omitempty"` | ||
| 8 | ExpiresAt int64 `json:"expires_at,omitempty"` | 8 | ExpiresAt int64 `json:"expires_at,omitempty"` | ||
| 9 | IssuedAt int64 `json:"issued_at,omitempty"` | 9 | IssuedAt int64 `json:"issued_at,omitempty"` | ||
| 10 | + | // RegistryDomain is the Gordon registry host that push clients target. | |||
| 11 | + | RegistryDomain string `json:"registry_domain,omitempty"` | |||
| 10 | } | 12 | } | ||
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -29,6 +29,7 @@ type ControlPlane interface { | |||
| 29 | 29 | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | |
| 30 | 30 | ||
| 31 | 31 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 32 | + | VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) | |
| 32 | 33 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| 33 | 34 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | |
| 34 | 35 | GetCA(ctx context.Context) (*dto.CAResponse, error) | |
| @@ -29,6 +29,7 @@ type ControlPlane interface { | |||||
| 29 | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | 29 | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | ||
| 30 | 30 | ||||
| 31 | GetStatus(ctx context.Context) (*remote.Status, error) | 31 | GetStatus(ctx context.Context) (*remote.Status, error) | ||
| 32 | + | VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) | |||
| 32 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | 33 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | ||
| 33 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | 34 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | ||
| 34 | GetCA(ctx context.Context) (*dto.CAResponse, error) | 35 | GetCA(ctx context.Context) (*dto.CAResponse, error) | ||
M internal/adapters/in/cli/mocks/mock_control_plane.go +62 -0
| @@ -2092,6 +2092,68 @@ func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx con | |||
| 2092 | 2092 | return _c | |
| 2093 | 2093 | } | |
| 2094 | 2094 | ||
| 2095 | + | // VerifyAuth provides a mock function for the type MockControlPlane | |
| 2096 | + | func (_mock *MockControlPlane) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) { | |
| 2097 | + | ret := _mock.Called(ctx) | |
| 2098 | + | ||
| 2099 | + | if len(ret) == 0 { | |
| 2100 | + | panic("no return value specified for VerifyAuth") | |
| 2101 | + | } | |
| 2102 | + | ||
| 2103 | + | var r0 *dto.AuthVerifyResponse | |
| 2104 | + | var r1 error | |
| 2105 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.AuthVerifyResponse, error)); ok { | |
| 2106 | + | return returnFunc(ctx) | |
| 2107 | + | } | |
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.AuthVerifyResponse); ok { | |
| 2109 | + | r0 = returnFunc(ctx) | |
| 2110 | + | } else { | |
| 2111 | + | if ret.Get(0) != nil { | |
| 2112 | + | r0 = ret.Get(0).(*dto.AuthVerifyResponse) | |
| 2113 | + | } | |
| 2114 | + | } | |
| 2115 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 2116 | + | r1 = returnFunc(ctx) | |
| 2117 | + | } else { | |
| 2118 | + | r1 = ret.Error(1) | |
| 2119 | + | } | |
| 2120 | + | return r0, r1 | |
| 2121 | + | } | |
| 2122 | + | ||
| 2123 | + | // MockControlPlane_VerifyAuth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyAuth' | |
| 2124 | + | type MockControlPlane_VerifyAuth_Call struct { | |
| 2125 | + | *mock.Call | |
| 2126 | + | } | |
| 2127 | + | ||
| 2128 | + | // VerifyAuth is a helper method to define mock.On call | |
| 2129 | + | // - ctx context.Context | |
| 2130 | + | func (_e *MockControlPlane_Expecter) VerifyAuth(ctx any) *MockControlPlane_VerifyAuth_Call { | |
| 2131 | + | return &MockControlPlane_VerifyAuth_Call{Call: _e.mock.On("VerifyAuth", ctx)} | |
| 2132 | + | } | |
| 2133 | + | ||
| 2134 | + | func (_c *MockControlPlane_VerifyAuth_Call) Run(run func(ctx context.Context)) *MockControlPlane_VerifyAuth_Call { | |
| 2135 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2136 | + | var arg0 context.Context | |
| 2137 | + | if args[0] != nil { | |
| 2138 | + | arg0 = args[0].(context.Context) | |
| 2139 | + | } | |
| 2140 | + | run( | |
| 2141 | + | arg0, | |
| 2142 | + | ) | |
| 2143 | + | }) | |
| 2144 | + | return _c | |
| 2145 | + | } | |
| 2146 | + | ||
| 2147 | + | func (_c *MockControlPlane_VerifyAuth_Call) Return(authVerifyResponse *dto.AuthVerifyResponse, err error) *MockControlPlane_VerifyAuth_Call { | |
| 2148 | + | _c.Call.Return(authVerifyResponse, err) | |
| 2149 | + | return _c | |
| 2150 | + | } | |
| 2151 | + | ||
| 2152 | + | func (_c *MockControlPlane_VerifyAuth_Call) RunAndReturn(run func(ctx context.Context) (*dto.AuthVerifyResponse, error)) *MockControlPlane_VerifyAuth_Call { | |
| 2153 | + | _c.Call.Return(run) | |
| 2154 | + | return _c | |
| 2155 | + | } | |
| 2156 | + | ||
| 2095 | 2157 | // VolumeBackupStatus provides a mock function for the type MockControlPlane | |
| 2096 | 2158 | func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) { | |
| 2097 | 2159 | ret := _mock.Called(ctx) | |
| @@ -2092,6 +2092,68 @@ func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx con | |||||
| 2092 | return _c | 2092 | return _c | ||
| 2093 | } | 2093 | } | ||
| 2094 | 2094 | ||||
| 2095 | + | // VerifyAuth provides a mock function for the type MockControlPlane | |||
| 2096 | + | func (_mock *MockControlPlane) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) { | |||
| 2097 | + | ret := _mock.Called(ctx) | |||
| 2098 | + | ||||
| 2099 | + | if len(ret) == 0 { | |||
| 2100 | + | panic("no return value specified for VerifyAuth") | |||
| 2101 | + | } | |||
| 2102 | + | ||||
| 2103 | + | var r0 *dto.AuthVerifyResponse | |||
| 2104 | + | var r1 error | |||
| 2105 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.AuthVerifyResponse, error)); ok { | |||
| 2106 | + | return returnFunc(ctx) | |||
| 2107 | + | } | |||
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.AuthVerifyResponse); ok { | |||
| 2109 | + | r0 = returnFunc(ctx) | |||
| 2110 | + | } else { | |||
| 2111 | + | if ret.Get(0) != nil { | |||
| 2112 | + | r0 = ret.Get(0).(*dto.AuthVerifyResponse) | |||
| 2113 | + | } | |||
| 2114 | + | } | |||
| 2115 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 2116 | + | r1 = returnFunc(ctx) | |||
| 2117 | + | } else { | |||
| 2118 | + | r1 = ret.Error(1) | |||
| 2119 | + | } | |||
| 2120 | + | return r0, r1 | |||
| 2121 | + | } | |||
| 2122 | + | ||||
| 2123 | + | // MockControlPlane_VerifyAuth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyAuth' | |||
| 2124 | + | type MockControlPlane_VerifyAuth_Call struct { | |||
| 2125 | + | *mock.Call | |||
| 2126 | + | } | |||
| 2127 | + | ||||
| 2128 | + | // VerifyAuth is a helper method to define mock.On call | |||
| 2129 | + | // - ctx context.Context | |||
| 2130 | + | func (_e *MockControlPlane_Expecter) VerifyAuth(ctx any) *MockControlPlane_VerifyAuth_Call { | |||
| 2131 | + | return &MockControlPlane_VerifyAuth_Call{Call: _e.mock.On("VerifyAuth", ctx)} | |||
| 2132 | + | } | |||
| 2133 | + | ||||
| 2134 | + | func (_c *MockControlPlane_VerifyAuth_Call) Run(run func(ctx context.Context)) *MockControlPlane_VerifyAuth_Call { | |||
| 2135 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2136 | + | var arg0 context.Context | |||
| 2137 | + | if args[0] != nil { | |||
| 2138 | + | arg0 = args[0].(context.Context) | |||
| 2139 | + | } | |||
| 2140 | + | run( | |||
| 2141 | + | arg0, | |||
| 2142 | + | ) | |||
| 2143 | + | }) | |||
| 2144 | + | return _c | |||
| 2145 | + | } | |||
| 2146 | + | ||||
| 2147 | + | func (_c *MockControlPlane_VerifyAuth_Call) Return(authVerifyResponse *dto.AuthVerifyResponse, err error) *MockControlPlane_VerifyAuth_Call { | |||
| 2148 | + | _c.Call.Return(authVerifyResponse, err) | |||
| 2149 | + | return _c | |||
| 2150 | + | } | |||
| 2151 | + | ||||
| 2152 | + | func (_c *MockControlPlane_VerifyAuth_Call) RunAndReturn(run func(ctx context.Context) (*dto.AuthVerifyResponse, error)) *MockControlPlane_VerifyAuth_Call { | |||
| 2153 | + | _c.Call.Return(run) | |||
| 2154 | + | return _c | |||
| 2155 | + | } | |||
| 2156 | + | ||||
| 2095 | // VolumeBackupStatus provides a mock function for the type MockControlPlane | 2157 | // VolumeBackupStatus provides a mock function for the type MockControlPlane | ||
| 2096 | func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) { | 2158 | func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) { | ||
| 2097 | ret := _mock.Called(ctx) | 2159 | ret := _mock.Called(ctx) | ||
M internal/adapters/in/cli/push.go +83 -11
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | + | "net/http" | |
| 9 | 10 | "os" | |
| 10 | 11 | "os/exec" | |
| 11 | 12 | "path/filepath" | |
| @@ -147,10 +148,13 @@ func looksLikeLegacyDomain(arg string) bool { | |||
| 147 | 148 | } | |
| 148 | 149 | ||
| 149 | 150 | // resolveImageTarget determines the registry and image name from the input. | |
| 151 | + | // A bare image name (no registry host) is pushed to the target Gordon's | |
| 152 | + | // registry domain. A fully qualified reference must name that same | |
| 153 | + | // registry, so the Gordon registry credentials never leave the target. | |
| 150 | 154 | // Push transfers OCI content only and never triggers deploys or route | |
| 151 | 155 | // lookups. The deploy target comes from the app manifest applied | |
| 152 | 156 | // separately via `gordon apps apply`. | |
| 153 | - | func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) { | |
| 157 | + | func resolveImageTarget(ctx context.Context, out io.Writer, handle *controlPlaneHandle, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) { | |
| 154 | 158 | if imageArg == "" { | |
| 155 | 159 | imageArg, err = detectImageName(dockerfile) | |
| 156 | 160 | if err != nil { | |
| @@ -164,13 +168,80 @@ func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, i | |||
| 164 | 168 | return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg) | |
| 165 | 169 | } | |
| 166 | 170 | classified := classifyPushArgument(imageArg) | |
| 171 | + | if classified.repository == "" { | |
| 172 | + | return "", "", "", fmt.Errorf("cannot parse image name from %q", imageArg) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | targetRegistry, err := targetRegistryDomain(ctx, handle) | |
| 176 | + | if err != nil { | |
| 177 | + | return "", "", "", err | |
| 178 | + | } | |
| 179 | + | ||
| 167 | 180 | registry, imageName, _ = parseImageRef(classified.sourceRef) | |
| 168 | - | if registry == "" || imageName == "" { | |
| 169 | - | return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg) | |
| 181 | + | if registry == "" { | |
| 182 | + | if err := cliWritef(out, "Registry: %s %s\n", styles.Theme.Bold.Render(targetRegistry), styles.Theme.Muted.Render("(from target)")); err != nil { | |
| 183 | + | return "", "", "", err | |
| 184 | + | } | |
| 185 | + | return targetRegistry, classified.repository, classified.sourceRef, nil | |
| 186 | + | } | |
| 187 | + | if !sameRegistryHost(registry, targetRegistry) { | |
| 188 | + | return "", "", "", fmt.Errorf("image registry %q does not match the target Gordon registry %q; retag the image as %s/%s or select the matching remote", registry, targetRegistry, targetRegistry, imageName) | |
| 170 | 189 | } | |
| 171 | 190 | return registry, imageName, classified.sourceRef, nil | |
| 172 | 191 | } | |
| 173 | 192 | ||
| 193 | + | // targetRegistryDomain asks the target Gordon instance for its registry domain. | |
| 194 | + | // Remote targets publish it on /admin/auth/verify, which any admin-scoped | |
| 195 | + | // token can read, so push needs no admin:status:read scope. The local admin | |
| 196 | + | // socket and servers that predate that field are asked through /admin/status. | |
| 197 | + | func targetRegistryDomain(ctx context.Context, handle *controlPlaneHandle) (string, error) { | |
| 198 | + | if handle.isRemote { | |
| 199 | + | verify, err := handle.plane.VerifyAuth(ctx) | |
| 200 | + | if isForbidden(err) { | |
| 201 | + | return "", fmt.Errorf("failed to read target registry domain: token needs an admin scope (e.g. admin:apps:read): %w", err) | |
| 202 | + | } | |
| 203 | + | if err != nil { | |
| 204 | + | return "", fmt.Errorf("failed to read target registry domain: %w", err) | |
| 205 | + | } | |
| 206 | + | if !verify.Valid { | |
| 207 | + | return "", errors.New("failed to read target registry domain: token is not valid") | |
| 208 | + | } | |
| 209 | + | if registryDomain := strings.TrimSpace(verify.RegistryDomain); registryDomain != "" { | |
| 210 | + | return registryDomain, nil | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | status, err := handle.plane.GetStatus(ctx) | |
| 215 | + | if isForbidden(err) && handle.isRemote { | |
| 216 | + | return "", fmt.Errorf("failed to read target registry domain: the server did not report it on /admin/auth/verify (older server or server.registry_domain unset); upgrade or configure the server, or grant the token admin:status:read: %w", err) | |
| 217 | + | } | |
| 218 | + | if err != nil { | |
| 219 | + | return "", fmt.Errorf("failed to read target registry domain: %w", err) | |
| 220 | + | } | |
| 221 | + | registryDomain := strings.TrimSpace(status.RegistryDomain) | |
| 222 | + | if registryDomain == "" { | |
| 223 | + | return "", errors.New("target Gordon instance does not report a registry domain; set server.registry_domain") | |
| 224 | + | } | |
| 225 | + | return registryDomain, nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func isForbidden(err error) bool { | |
| 229 | + | httpErr, ok := errors.AsType[*remote.HTTPError](err) | |
| 230 | + | return ok && httpErr.StatusCode == http.StatusForbidden | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func sameRegistryHost(a, b string) bool { | |
| 234 | + | normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") } | |
| 235 | + | return normalize(a) == normalize(b) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // isRegistryHost applies Docker's rule for the first path component of an | |
| 239 | + | // image reference: it names a registry only if it contains a dot or a port, | |
| 240 | + | // or is "localhost". | |
| 241 | + | func isRegistryHost(component string) bool { | |
| 242 | + | return strings.ContainsAny(component, ".:") || component == "localhost" | |
| 243 | + | } | |
| 244 | + | ||
| 174 | 245 | // resolveVersion determines and validates the version tag. | |
| 175 | 246 | func resolveVersion(ctx context.Context, tag string) (string, error) { | |
| 176 | 247 | version := determineVersion(ctx, tag) | |
| @@ -189,11 +260,6 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error { | |||
| 189 | 260 | } | |
| 190 | 261 | defer handle.close() | |
| 191 | 262 | ||
| 192 | - | registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile) | |
| 193 | - | if err != nil { | |
| 194 | - | return err | |
| 195 | - | } | |
| 196 | - | ||
| 197 | 263 | version, err := resolveVersion(ctx, req.Tag) | |
| 198 | 264 | if err != nil { | |
| 199 | 265 | return err | |
| @@ -202,6 +268,11 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error { | |||
| 202 | 268 | return err | |
| 203 | 269 | } | |
| 204 | 270 | ||
| 271 | + | registry, imageName, sourceRef, err := resolveImageTarget(ctx, out, handle, req.ImageArg, dockerfile) | |
| 272 | + | if err != nil { | |
| 273 | + | return err | |
| 274 | + | } | |
| 275 | + | ||
| 205 | 276 | img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version} | |
| 206 | 277 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | |
| 207 | 278 | ||
| @@ -574,11 +645,12 @@ func isInteractiveTerminal() bool { | |||
| 574 | 645 | return (info.Mode() & os.ModeCharDevice) != 0 | |
| 575 | 646 | } | |
| 576 | 647 | ||
| 577 | - | // parseImageRef splits "registry/name:tag" into components. | |
| 648 | + | // parseImageRef splits "registry/name:tag" into components. It returns empty | |
| 649 | + | // values when the reference does not start with a registry host. | |
| 578 | 650 | func parseImageRef(image string) (registry, name, tag string) { | |
| 579 | - | // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot" | |
| 651 | + | // Handle "reg.example.com/app:latest" or "reg.example.com/app" | |
| 580 | 652 | parts := strings.SplitN(image, "/", 2) | |
| 581 | - | if len(parts) != 2 { | |
| 653 | + | if len(parts) != 2 || !isRegistryHost(parts[0]) { | |
| 582 | 654 | return "", "", "" | |
| 583 | 655 | } | |
| 584 | 656 | registry = parts[0] | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | + | "net/http" | |||
| 9 | "os" | 10 | "os" | ||
| 10 | "os/exec" | 11 | "os/exec" | ||
| 11 | "path/filepath" | 12 | "path/filepath" | ||
| @@ -147,10 +148,13 @@ func looksLikeLegacyDomain(arg string) bool { | |||||
| 147 | } | 148 | } | ||
| 148 | 149 | ||||
| 149 | // resolveImageTarget determines the registry and image name from the input. | 150 | // resolveImageTarget determines the registry and image name from the input. | ||
| 151 | + | // A bare image name (no registry host) is pushed to the target Gordon's | |||
| 152 | + | // registry domain. A fully qualified reference must name that same | |||
| 153 | + | // registry, so the Gordon registry credentials never leave the target. | |||
| 150 | // Push transfers OCI content only and never triggers deploys or route | 154 | // Push transfers OCI content only and never triggers deploys or route | ||
| 151 | // lookups. The deploy target comes from the app manifest applied | 155 | // lookups. The deploy target comes from the app manifest applied | ||
| 152 | // separately via `gordon apps apply`. | 156 | // separately via `gordon apps apply`. | ||
| 153 | - | func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) { | 157 | + | func resolveImageTarget(ctx context.Context, out io.Writer, handle *controlPlaneHandle, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) { |
| 154 | if imageArg == "" { | 158 | if imageArg == "" { | ||
| 155 | imageArg, err = detectImageName(dockerfile) | 159 | imageArg, err = detectImageName(dockerfile) | ||
| 156 | if err != nil { | 160 | if err != nil { | ||
| @@ -164,13 +168,80 @@ func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, i | |||||
| 164 | return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg) | 168 | return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg) | ||
| 165 | } | 169 | } | ||
| 166 | classified := classifyPushArgument(imageArg) | 170 | classified := classifyPushArgument(imageArg) | ||
| 171 | + | if classified.repository == "" { | |||
| 172 | + | return "", "", "", fmt.Errorf("cannot parse image name from %q", imageArg) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | targetRegistry, err := targetRegistryDomain(ctx, handle) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return "", "", "", err | |||
| 178 | + | } | |||
| 179 | + | ||||
| 167 | registry, imageName, _ = parseImageRef(classified.sourceRef) | 180 | registry, imageName, _ = parseImageRef(classified.sourceRef) | ||
| 168 | - | if registry == "" || imageName == "" { | 181 | + | if registry == "" { |
| 169 | - | return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg) | 182 | + | if err := cliWritef(out, "Registry: %s %s\n", styles.Theme.Bold.Render(targetRegistry), styles.Theme.Muted.Render("(from target)")); err != nil { |
| 183 | + | return "", "", "", err | |||
| 184 | + | } | |||
| 185 | + | return targetRegistry, classified.repository, classified.sourceRef, nil | |||
| 186 | + | } | |||
| 187 | + | if !sameRegistryHost(registry, targetRegistry) { | |||
| 188 | + | return "", "", "", fmt.Errorf("image registry %q does not match the target Gordon registry %q; retag the image as %s/%s or select the matching remote", registry, targetRegistry, targetRegistry, imageName) | |||
| 170 | } | 189 | } | ||
| 171 | return registry, imageName, classified.sourceRef, nil | 190 | return registry, imageName, classified.sourceRef, nil | ||
| 172 | } | 191 | } | ||
| 173 | 192 | ||||
| 193 | + | // targetRegistryDomain asks the target Gordon instance for its registry domain. | |||
| 194 | + | // Remote targets publish it on /admin/auth/verify, which any admin-scoped | |||
| 195 | + | // token can read, so push needs no admin:status:read scope. The local admin | |||
| 196 | + | // socket and servers that predate that field are asked through /admin/status. | |||
| 197 | + | func targetRegistryDomain(ctx context.Context, handle *controlPlaneHandle) (string, error) { | |||
| 198 | + | if handle.isRemote { | |||
| 199 | + | verify, err := handle.plane.VerifyAuth(ctx) | |||
| 200 | + | if isForbidden(err) { | |||
| 201 | + | return "", fmt.Errorf("failed to read target registry domain: token needs an admin scope (e.g. admin:apps:read): %w", err) | |||
| 202 | + | } | |||
| 203 | + | if err != nil { | |||
| 204 | + | return "", fmt.Errorf("failed to read target registry domain: %w", err) | |||
| 205 | + | } | |||
| 206 | + | if !verify.Valid { | |||
| 207 | + | return "", errors.New("failed to read target registry domain: token is not valid") | |||
| 208 | + | } | |||
| 209 | + | if registryDomain := strings.TrimSpace(verify.RegistryDomain); registryDomain != "" { | |||
| 210 | + | return registryDomain, nil | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | status, err := handle.plane.GetStatus(ctx) | |||
| 215 | + | if isForbidden(err) && handle.isRemote { | |||
| 216 | + | return "", fmt.Errorf("failed to read target registry domain: the server did not report it on /admin/auth/verify (older server or server.registry_domain unset); upgrade or configure the server, or grant the token admin:status:read: %w", err) | |||
| 217 | + | } | |||
| 218 | + | if err != nil { | |||
| 219 | + | return "", fmt.Errorf("failed to read target registry domain: %w", err) | |||
| 220 | + | } | |||
| 221 | + | registryDomain := strings.TrimSpace(status.RegistryDomain) | |||
| 222 | + | if registryDomain == "" { | |||
| 223 | + | return "", errors.New("target Gordon instance does not report a registry domain; set server.registry_domain") | |||
| 224 | + | } | |||
| 225 | + | return registryDomain, nil | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func isForbidden(err error) bool { | |||
| 229 | + | httpErr, ok := errors.AsType[*remote.HTTPError](err) | |||
| 230 | + | return ok && httpErr.StatusCode == http.StatusForbidden | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func sameRegistryHost(a, b string) bool { | |||
| 234 | + | normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") } | |||
| 235 | + | return normalize(a) == normalize(b) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // isRegistryHost applies Docker's rule for the first path component of an | |||
| 239 | + | // image reference: it names a registry only if it contains a dot or a port, | |||
| 240 | + | // or is "localhost". | |||
| 241 | + | func isRegistryHost(component string) bool { | |||
| 242 | + | return strings.ContainsAny(component, ".:") || component == "localhost" | |||
| 243 | + | } | |||
| 244 | + | ||||
| 174 | // resolveVersion determines and validates the version tag. | 245 | // resolveVersion determines and validates the version tag. | ||
| 175 | func resolveVersion(ctx context.Context, tag string) (string, error) { | 246 | func resolveVersion(ctx context.Context, tag string) (string, error) { | ||
| 176 | version := determineVersion(ctx, tag) | 247 | version := determineVersion(ctx, tag) | ||
| @@ -189,11 +260,6 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error { | |||||
| 189 | } | 260 | } | ||
| 190 | defer handle.close() | 261 | defer handle.close() | ||
| 191 | 262 | ||||
| 192 | - | registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile) | |||
| 193 | - | if err != nil { | |||
| 194 | - | return err | |||
| 195 | - | } | |||
| 196 | - | ||||
| 197 | version, err := resolveVersion(ctx, req.Tag) | 263 | version, err := resolveVersion(ctx, req.Tag) | ||
| 198 | if err != nil { | 264 | if err != nil { | ||
| 199 | return err | 265 | return err | ||
| @@ -202,6 +268,11 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error { | |||||
| 202 | return err | 268 | return err | ||
| 203 | } | 269 | } | ||
| 204 | 270 | ||||
| 271 | + | registry, imageName, sourceRef, err := resolveImageTarget(ctx, out, handle, req.ImageArg, dockerfile) | |||
| 272 | + | if err != nil { | |||
| 273 | + | return err | |||
| 274 | + | } | |||
| 275 | + | ||||
| 205 | img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version} | 276 | img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version} | ||
| 206 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | 277 | img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version) | ||
| 207 | 278 | ||||
| @@ -574,11 +645,12 @@ func isInteractiveTerminal() bool { | |||||
| 574 | return (info.Mode() & os.ModeCharDevice) != 0 | 645 | return (info.Mode() & os.ModeCharDevice) != 0 | ||
| 575 | } | 646 | } | ||
| 576 | 647 | ||||
| 577 | - | // parseImageRef splits "registry/name:tag" into components. | 648 | + | // parseImageRef splits "registry/name:tag" into components. It returns empty |
| 649 | + | // values when the reference does not start with a registry host. | |||
| 578 | func parseImageRef(image string) (registry, name, tag string) { | 650 | func parseImageRef(image string) (registry, name, tag string) { | ||
| 579 | - | // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot" | 651 | + | // Handle "reg.example.com/app:latest" or "reg.example.com/app" |
| 580 | parts := strings.SplitN(image, "/", 2) | 652 | parts := strings.SplitN(image, "/", 2) | ||
| 581 | - | if len(parts) != 2 { | 653 | + | if len(parts) != 2 || !isRegistryHost(parts[0]) { |
| 582 | return "", "", "" | 654 | return "", "", "" | ||
| 583 | } | 655 | } | ||
| 584 | registry = parts[0] | 656 | registry = parts[0] | ||
A internal/adapters/in/cli/push_registry_target_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 16 | + | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // remoteWithRegistry returns a remote handle whose /auth/verify reports registryDomain. | |
| 21 | + | func remoteWithRegistry(t *testing.T, registryDomain string) *controlPlaneHandle { | |
| 22 | + | t.Helper() | |
| 23 | + | plane := climocks.NewMockControlPlane(t) | |
| 24 | + | plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true, RegistryDomain: registryDomain}, nil).Once() | |
| 25 | + | return &controlPlaneHandle{plane: plane, isRemote: true} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestResolveImageTarget_BareNameUsesTargetRegistry(t *testing.T) { | |
| 29 | + | tests := []struct { | |
| 30 | + | name string | |
| 31 | + | arg string | |
| 32 | + | wantName string | |
| 33 | + | wantSource string | |
| 34 | + | }{ | |
| 35 | + | {"bare name", "myapp", "myapp", "myapp"}, | |
| 36 | + | {"bare name with tag", "myapp:v1.2.3", "myapp", "myapp:v1.2.3"}, | |
| 37 | + | {"namespaced name", "team/myapp:v1", "team/myapp", "team/myapp:v1"}, | |
| 38 | + | {"bare digest", "myapp@sha256:deadbeef", "myapp", "myapp@sha256:deadbeef"}, | |
| 39 | + | } | |
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | var out bytes.Buffer | |
| 43 | + | registry, name, source, err := resolveImageTarget(context.Background(), &out, remoteWithRegistry(t, "reg.example.com"), tt.arg, "Dockerfile") | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.Equal(t, "reg.example.com", registry) | |
| 46 | + | assert.Equal(t, tt.wantName, name) | |
| 47 | + | assert.Equal(t, tt.wantSource, source) | |
| 48 | + | assert.Contains(t, out.String(), "reg.example.com") | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestResolveImageTarget_QualifiedRefMatchingTarget(t *testing.T) { | |
| 54 | + | registry, name, source, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com."), "Reg.Example.com/myapp:v1", "Dockerfile") | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | assert.Equal(t, "Reg.Example.com", registry) | |
| 57 | + | assert.Equal(t, "myapp", name) | |
| 58 | + | assert.Equal(t, "Reg.Example.com/myapp:v1", source) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestResolveImageTarget_RejectsMismatchedRegistry(t *testing.T) { | |
| 62 | + | for _, ref := range []string{"other.example.org/myapp:v1", "reg.example.com:443/myapp:v1"} { | |
| 63 | + | t.Run(ref, func(t *testing.T) { | |
| 64 | + | _, _, _, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com"), ref, "Dockerfile") | |
| 65 | + | require.Error(t, err) | |
| 66 | + | assert.Contains(t, err.Error(), "reg.example.com/myapp") | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestTargetRegistryDomain_RemoteFallsBackToStatusForOlderServers(t *testing.T) { | |
| 72 | + | plane := climocks.NewMockControlPlane(t) | |
| 73 | + | plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once() | |
| 74 | + | plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once() | |
| 75 | + | ||
| 76 | + | got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true}) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | assert.Equal(t, "reg.example.com", got) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestTargetRegistryDomain_LocalSocketUsesStatus(t *testing.T) { | |
| 82 | + | plane := climocks.NewMockControlPlane(t) | |
| 83 | + | plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once() | |
| 84 | + | ||
| 85 | + | got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane}) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | assert.Equal(t, "reg.example.com", got) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestTargetRegistryDomain_Errors(t *testing.T) { | |
| 91 | + | tests := []struct { | |
| 92 | + | name string | |
| 93 | + | setup func(*climocks.MockControlPlane) | |
| 94 | + | wantErr string | |
| 95 | + | }{ | |
| 96 | + | { | |
| 97 | + | name: "verify fails", | |
| 98 | + | setup: func(p *climocks.MockControlPlane) { | |
| 99 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(nil, errors.New("boom")).Once() | |
| 100 | + | }, | |
| 101 | + | wantErr: "boom", | |
| 102 | + | }, | |
| 103 | + | { | |
| 104 | + | name: "token without admin scope", | |
| 105 | + | setup: func(p *climocks.MockControlPlane) { | |
| 106 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once() | |
| 107 | + | }, | |
| 108 | + | wantErr: "admin scope", | |
| 109 | + | }, | |
| 110 | + | { | |
| 111 | + | name: "token invalid", | |
| 112 | + | setup: func(p *climocks.MockControlPlane) { | |
| 113 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: false}, nil).Once() | |
| 114 | + | }, | |
| 115 | + | wantErr: "token is not valid", | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "older server denies status", | |
| 119 | + | setup: func(p *climocks.MockControlPlane) { | |
| 120 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once() | |
| 121 | + | p.EXPECT().GetStatus(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once() | |
| 122 | + | }, | |
| 123 | + | wantErr: "admin:status:read", | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: "no registry domain configured", | |
| 127 | + | setup: func(p *climocks.MockControlPlane) { | |
| 128 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once() | |
| 129 | + | p.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{}, nil).Once() | |
| 130 | + | }, | |
| 131 | + | wantErr: "registry_domain", | |
| 132 | + | }, | |
| 133 | + | } | |
| 134 | + | for _, tt := range tests { | |
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |
| 136 | + | plane := climocks.NewMockControlPlane(t) | |
| 137 | + | tt.setup(plane) | |
| 138 | + | _, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true}) | |
| 139 | + | require.ErrorContains(t, err, tt.wantErr) | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 16 | + | climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |||
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // remoteWithRegistry returns a remote handle whose /auth/verify reports registryDomain. | |||
| 21 | + | func remoteWithRegistry(t *testing.T, registryDomain string) *controlPlaneHandle { | |||
| 22 | + | t.Helper() | |||
| 23 | + | plane := climocks.NewMockControlPlane(t) | |||
| 24 | + | plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true, RegistryDomain: registryDomain}, nil).Once() | |||
| 25 | + | return &controlPlaneHandle{plane: plane, isRemote: true} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestResolveImageTarget_BareNameUsesTargetRegistry(t *testing.T) { | |||
| 29 | + | tests := []struct { | |||
| 30 | + | name string | |||
| 31 | + | arg string | |||
| 32 | + | wantName string | |||
| 33 | + | wantSource string | |||
| 34 | + | }{ | |||
| 35 | + | {"bare name", "myapp", "myapp", "myapp"}, | |||
| 36 | + | {"bare name with tag", "myapp:v1.2.3", "myapp", "myapp:v1.2.3"}, | |||
| 37 | + | {"namespaced name", "team/myapp:v1", "team/myapp", "team/myapp:v1"}, | |||
| 38 | + | {"bare digest", "myapp@sha256:deadbeef", "myapp", "myapp@sha256:deadbeef"}, | |||
| 39 | + | } | |||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 42 | + | var out bytes.Buffer | |||
| 43 | + | registry, name, source, err := resolveImageTarget(context.Background(), &out, remoteWithRegistry(t, "reg.example.com"), tt.arg, "Dockerfile") | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | assert.Equal(t, "reg.example.com", registry) | |||
| 46 | + | assert.Equal(t, tt.wantName, name) | |||
| 47 | + | assert.Equal(t, tt.wantSource, source) | |||
| 48 | + | assert.Contains(t, out.String(), "reg.example.com") | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestResolveImageTarget_QualifiedRefMatchingTarget(t *testing.T) { | |||
| 54 | + | registry, name, source, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com."), "Reg.Example.com/myapp:v1", "Dockerfile") | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | assert.Equal(t, "Reg.Example.com", registry) | |||
| 57 | + | assert.Equal(t, "myapp", name) | |||
| 58 | + | assert.Equal(t, "Reg.Example.com/myapp:v1", source) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestResolveImageTarget_RejectsMismatchedRegistry(t *testing.T) { | |||
| 62 | + | for _, ref := range []string{"other.example.org/myapp:v1", "reg.example.com:443/myapp:v1"} { | |||
| 63 | + | t.Run(ref, func(t *testing.T) { | |||
| 64 | + | _, _, _, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com"), ref, "Dockerfile") | |||
| 65 | + | require.Error(t, err) | |||
| 66 | + | assert.Contains(t, err.Error(), "reg.example.com/myapp") | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestTargetRegistryDomain_RemoteFallsBackToStatusForOlderServers(t *testing.T) { | |||
| 72 | + | plane := climocks.NewMockControlPlane(t) | |||
| 73 | + | plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once() | |||
| 74 | + | plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once() | |||
| 75 | + | ||||
| 76 | + | got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true}) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | assert.Equal(t, "reg.example.com", got) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestTargetRegistryDomain_LocalSocketUsesStatus(t *testing.T) { | |||
| 82 | + | plane := climocks.NewMockControlPlane(t) | |||
| 83 | + | plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once() | |||
| 84 | + | ||||
| 85 | + | got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane}) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | assert.Equal(t, "reg.example.com", got) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestTargetRegistryDomain_Errors(t *testing.T) { | |||
| 91 | + | tests := []struct { | |||
| 92 | + | name string | |||
| 93 | + | setup func(*climocks.MockControlPlane) | |||
| 94 | + | wantErr string | |||
| 95 | + | }{ | |||
| 96 | + | { | |||
| 97 | + | name: "verify fails", | |||
| 98 | + | setup: func(p *climocks.MockControlPlane) { | |||
| 99 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(nil, errors.New("boom")).Once() | |||
| 100 | + | }, | |||
| 101 | + | wantErr: "boom", | |||
| 102 | + | }, | |||
| 103 | + | { | |||
| 104 | + | name: "token without admin scope", | |||
| 105 | + | setup: func(p *climocks.MockControlPlane) { | |||
| 106 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once() | |||
| 107 | + | }, | |||
| 108 | + | wantErr: "admin scope", | |||
| 109 | + | }, | |||
| 110 | + | { | |||
| 111 | + | name: "token invalid", | |||
| 112 | + | setup: func(p *climocks.MockControlPlane) { | |||
| 113 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: false}, nil).Once() | |||
| 114 | + | }, | |||
| 115 | + | wantErr: "token is not valid", | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "older server denies status", | |||
| 119 | + | setup: func(p *climocks.MockControlPlane) { | |||
| 120 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once() | |||
| 121 | + | p.EXPECT().GetStatus(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once() | |||
| 122 | + | }, | |||
| 123 | + | wantErr: "admin:status:read", | |||
| 124 | + | }, | |||
| 125 | + | { | |||
| 126 | + | name: "no registry domain configured", | |||
| 127 | + | setup: func(p *climocks.MockControlPlane) { | |||
| 128 | + | p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once() | |||
| 129 | + | p.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{}, nil).Once() | |||
| 130 | + | }, | |||
| 131 | + | wantErr: "registry_domain", | |||
| 132 | + | }, | |||
| 133 | + | } | |||
| 134 | + | for _, tt := range tests { | |||
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 136 | + | plane := climocks.NewMockControlPlane(t) | |||
| 137 | + | tt.setup(plane) | |||
| 138 | + | _, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true}) | |||
| 139 | + | require.ErrorContains(t, err, tt.wantErr) | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | } | |||
M internal/adapters/in/cli/push_test.go +4 -0
| @@ -55,6 +55,10 @@ func TestParseImageRef(t *testing.T) { | |||
| 55 | 55 | {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"}, | |
| 56 | 56 | {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"}, | |
| 57 | 57 | {"no slash", "myapp:v1.0.0", "", "", ""}, | |
| 58 | + | {"namespace without registry host", "team/myapp:v1", "", "", ""}, | |
| 59 | + | {"localhost", "localhost/myapp:v1", "localhost", "myapp", "v1"}, | |
| 60 | + | {"registry with port", "localhost:5000/myapp:v1", "localhost:5000", "myapp", "v1"}, | |
| 61 | + | {"registry with namespace", "reg.example.com/team/myapp", "reg.example.com", "team/myapp", "latest"}, | |
| 58 | 62 | } | |
| 59 | 63 | ||
| 60 | 64 | for _, tt := range tests { | |
| @@ -55,6 +55,10 @@ func TestParseImageRef(t *testing.T) { | |||||
| 55 | {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"}, | 55 | {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"}, | ||
| 56 | {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"}, | 56 | {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"}, | ||
| 57 | {"no slash", "myapp:v1.0.0", "", "", ""}, | 57 | {"no slash", "myapp:v1.0.0", "", "", ""}, | ||
| 58 | + | {"namespace without registry host", "team/myapp:v1", "", "", ""}, | |||
| 59 | + | {"localhost", "localhost/myapp:v1", "localhost", "myapp", "v1"}, | |||
| 60 | + | {"registry with port", "localhost:5000/myapp:v1", "localhost:5000", "myapp", "v1"}, | |||
| 61 | + | {"registry with namespace", "reg.example.com/team/myapp", "reg.example.com", "team/myapp", "latest"}, | |||
| 58 | } | 62 | } | ||
| 59 | 63 | ||||
| 60 | for _, tt := range tests { | 64 | for _, tt := range tests { | ||
M internal/adapters/in/http/admin/handler.go +5 -0
| @@ -932,6 +932,11 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) { | |||
| 932 | 932 | ExpiresAt: status.ExpiresAt, | |
| 933 | 933 | IssuedAt: status.IssuedAt, | |
| 934 | 934 | } | |
| 935 | + | if status.Valid && h.configSvc != nil { | |
| 936 | + | // Push clients need the registry host but not admin:status:read, | |
| 937 | + | // so it is published to every admin-scoped token here. | |
| 938 | + | response.RegistryDomain = h.configSvc.GetRegistryDomain() | |
| 939 | + | } | |
| 935 | 940 | ||
| 936 | 941 | h.sendJSON(w, http.StatusOK, response) | |
| 937 | 942 | } | |
| @@ -932,6 +932,11 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) { | |||||
| 932 | ExpiresAt: status.ExpiresAt, | 932 | ExpiresAt: status.ExpiresAt, | ||
| 933 | IssuedAt: status.IssuedAt, | 933 | IssuedAt: status.IssuedAt, | ||
| 934 | } | 934 | } | ||
| 935 | + | if status.Valid && h.configSvc != nil { | |||
| 936 | + | // Push clients need the registry host but not admin:status:read, | |||
| 937 | + | // so it is published to every admin-scoped token here. | |||
| 938 | + | response.RegistryDomain = h.configSvc.GetRegistryDomain() | |||
| 939 | + | } | |||
| 935 | 940 | ||||
| 936 | h.sendJSON(w, http.StatusOK, response) | 941 | h.sendJSON(w, http.StatusOK, response) | ||
| 937 | } | 942 | } | ||
M internal/adapters/in/http/admin/handler_test.go +35 -0
| @@ -208,6 +208,41 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 208 | 208 | } | |
| 209 | 209 | } | |
| 210 | 210 | ||
| 211 | + | func TestHandler_AuthVerify_PublishesRegistryDomainForValidTokens(t *testing.T) { | |
| 212 | + | tests := []struct { | |
| 213 | + | name string | |
| 214 | + | valid bool | |
| 215 | + | wantDomain string | |
| 216 | + | }{ | |
| 217 | + | {name: "valid token without status scope", valid: true, wantDomain: "registry.example.com"}, | |
| 218 | + | {name: "invalid token", valid: false, wantDomain: ""}, | |
| 219 | + | } | |
| 220 | + | for _, tt := range tests { | |
| 221 | + | t.Run(tt.name, func(t *testing.T) { | |
| 222 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 223 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 224 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 225 | + | d.ConfigSvc = configSvc | |
| 226 | + | d.AuthSvc = authSvc | |
| 227 | + | }) | |
| 228 | + | authSvc.EXPECT().GetAuthStatus(mock.Anything).Return(&domain.AuthStatus{Valid: tt.valid, Subject: "ci"}, nil).Once() | |
| 229 | + | if tt.valid { | |
| 230 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once() | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | req := httptest.NewRequest(http.MethodGet, "/admin/auth/verify", nil) | |
| 234 | + | req = req.WithContext(ctxWithScopes("push", "pull", "admin:apps:read", "admin:apps:write")) | |
| 235 | + | rec := httptest.NewRecorder() | |
| 236 | + | handler.ServeHTTP(rec, req) | |
| 237 | + | ||
| 238 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 239 | + | var resp dto.AuthVerifyResponse | |
| 240 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) | |
| 241 | + | assert.Equal(t, tt.wantDomain, resp.RegistryDomain) | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 211 | 246 | // Config endpoint tests | |
| 212 | 247 | ||
| 213 | 248 | func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) { | |
| @@ -208,6 +208,41 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||||
| 208 | } | 208 | } | ||
| 209 | } | 209 | } | ||
| 210 | 210 | ||||
| 211 | + | func TestHandler_AuthVerify_PublishesRegistryDomainForValidTokens(t *testing.T) { | |||
| 212 | + | tests := []struct { | |||
| 213 | + | name string | |||
| 214 | + | valid bool | |||
| 215 | + | wantDomain string | |||
| 216 | + | }{ | |||
| 217 | + | {name: "valid token without status scope", valid: true, wantDomain: "registry.example.com"}, | |||
| 218 | + | {name: "invalid token", valid: false, wantDomain: ""}, | |||
| 219 | + | } | |||
| 220 | + | for _, tt := range tests { | |||
| 221 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 222 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 223 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 224 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 225 | + | d.ConfigSvc = configSvc | |||
| 226 | + | d.AuthSvc = authSvc | |||
| 227 | + | }) | |||
| 228 | + | authSvc.EXPECT().GetAuthStatus(mock.Anything).Return(&domain.AuthStatus{Valid: tt.valid, Subject: "ci"}, nil).Once() | |||
| 229 | + | if tt.valid { | |||
| 230 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once() | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | req := httptest.NewRequest(http.MethodGet, "/admin/auth/verify", nil) | |||
| 234 | + | req = req.WithContext(ctxWithScopes("push", "pull", "admin:apps:read", "admin:apps:write")) | |||
| 235 | + | rec := httptest.NewRecorder() | |||
| 236 | + | handler.ServeHTTP(rec, req) | |||
| 237 | + | ||||
| 238 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 239 | + | var resp dto.AuthVerifyResponse | |||
| 240 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) | |||
| 241 | + | assert.Equal(t, tt.wantDomain, resp.RegistryDomain) | |||
| 242 | + | }) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 211 | // Config endpoint tests | 246 | // Config endpoint tests | ||
| 212 | 247 | ||||
| 213 | func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) { | 248 | func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) { | ||