feat(proxy): add external routes for non-containerized services
Add support for proxying to external (non-container) services via a new [external_routes] configuration section. This allows Gordon to reverse proxy to services running directly on the host or network, such as Docker registries, database admin tools, or legacy applications. Configuration example: [external_routes] "reg.bnema.dev" = "localhost:5000"
- commit
- db9a8a3f8de30d318a6be6cdc044e68b22961a02
- author
- brice · · 2026-01-14 22:09 UTC
- committer
- brice · · 2026-01-14 19:55 UTC
- parent
- 683bc85
- signature
- unsigned
10 files · +320 -0
A docs/config/external-routes.md +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | # External Routes Configuration | |
| 2 | + | ||
| 3 | + | External routes allow proxying to non-containerized services running on the host or network. | |
| 4 | + | ||
| 5 | + | ## Configuration | |
| 6 | + | ||
| 7 | + | ```toml | |
| 8 | + | [external_routes] | |
| 9 | + | "service.mydomain.com" = "localhost:5000" | |
| 10 | + | "cache.mydomain.com" = "192.168.1.100:6379" | |
| 11 | + | ``` | |
| 12 | + | ||
| 13 | + | ## Syntax | |
| 14 | + | ||
| 15 | + | ```toml | |
| 16 | + | [external_routes] | |
| 17 | + | "<domain>" = "<host>:<port>" | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | | Component | Description | | |
| 21 | + | |-----------|-------------| | |
| 22 | + | | `domain` | Fully qualified domain name | | |
| 23 | + | | `host` | Target hostname or IP address | | |
| 24 | + | | `port` | Target port number | | |
| 25 | + | ||
| 26 | + | ## Use Cases | |
| 27 | + | ||
| 28 | + | ### Docker Registry | |
| 29 | + | ||
| 30 | + | Proxy to a Docker registry running outside of Gordon: | |
| 31 | + | ||
| 32 | + | ```toml | |
| 33 | + | [external_routes] | |
| 34 | + | "registry.mydomain.com" = "localhost:5000" | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | ### Database Admin Tools | |
| 38 | + | ||
| 39 | + | Proxy to database admin interfaces: | |
| 40 | + | ||
| 41 | + | ```toml | |
| 42 | + | [external_routes] | |
| 43 | + | "pgadmin.mydomain.com" = "localhost:5050" | |
| 44 | + | "redis-commander.mydomain.com" = "localhost:8081" | |
| 45 | + | ``` | |
| 46 | + | ||
| 47 | + | ### Legacy Services | |
| 48 | + | ||
| 49 | + | Proxy to services that can't be containerized: | |
| 50 | + | ||
| 51 | + | ```toml | |
| 52 | + | [external_routes] | |
| 53 | + | "legacy-api.mydomain.com" = "192.168.1.50:8080" | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | ## How It Works | |
| 57 | + | ||
| 58 | + | 1. Request arrives for `service.mydomain.com` | |
| 59 | + | 2. Gordon checks if domain matches an external route | |
| 60 | + | 3. If matched, proxies directly to the configured `host:port` | |
| 61 | + | 4. No container lookup is performed | |
| 62 | + | ||
| 63 | + | ``` | |
| 64 | + | Client ─> Gordon Proxy ─> External Service | |
| 65 | + | (port 80) (configured host:port) | |
| 66 | + | ``` | |
| 67 | + | ||
| 68 | + | ## Limitations | |
| 69 | + | ||
| 70 | + | - HTTP only (no HTTPS upstream) | |
| 71 | + | - No health checks | |
| 72 | + | - No load balancing | |
| 73 | + | ||
| 74 | + | ## Hot Reload | |
| 75 | + | ||
| 76 | + | External routes reload automatically when the config file changes: | |
| 77 | + | ||
| 78 | + | ```bash | |
| 79 | + | # Edit config | |
| 80 | + | vim ~/.config/gordon/gordon.toml | |
| 81 | + | ||
| 82 | + | # Add external route | |
| 83 | + | [external_routes] | |
| 84 | + | "newservice.mydomain.com" = "localhost:9000" | |
| 85 | + | ||
| 86 | + | # Save - Gordon reloads automatically | |
| 87 | + | ``` | |
| 88 | + | ||
| 89 | + | ## Related | |
| 90 | + | ||
| 91 | + | - [Routes Configuration](./routes.md) | |
| 92 | + | - [Configuration Overview](./index.md) | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | # External Routes Configuration | |||
| 2 | + | ||||
| 3 | + | External routes allow proxying to non-containerized services running on the host or network. | |||
| 4 | + | ||||
| 5 | + | ## Configuration | |||
| 6 | + | ||||
| 7 | + | ```toml | |||
| 8 | + | [external_routes] | |||
| 9 | + | "service.mydomain.com" = "localhost:5000" | |||
| 10 | + | "cache.mydomain.com" = "192.168.1.100:6379" | |||
| 11 | + | ``` | |||
| 12 | + | ||||
| 13 | + | ## Syntax | |||
| 14 | + | ||||
| 15 | + | ```toml | |||
| 16 | + | [external_routes] | |||
| 17 | + | "<domain>" = "<host>:<port>" | |||
| 18 | + | ``` | |||
| 19 | + | ||||
| 20 | + | | Component | Description | | |||
| 21 | + | |-----------|-------------| | |||
| 22 | + | | `domain` | Fully qualified domain name | | |||
| 23 | + | | `host` | Target hostname or IP address | | |||
| 24 | + | | `port` | Target port number | | |||
| 25 | + | ||||
| 26 | + | ## Use Cases | |||
| 27 | + | ||||
| 28 | + | ### Docker Registry | |||
| 29 | + | ||||
| 30 | + | Proxy to a Docker registry running outside of Gordon: | |||
| 31 | + | ||||
| 32 | + | ```toml | |||
| 33 | + | [external_routes] | |||
| 34 | + | "registry.mydomain.com" = "localhost:5000" | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | ### Database Admin Tools | |||
| 38 | + | ||||
| 39 | + | Proxy to database admin interfaces: | |||
| 40 | + | ||||
| 41 | + | ```toml | |||
| 42 | + | [external_routes] | |||
| 43 | + | "pgadmin.mydomain.com" = "localhost:5050" | |||
| 44 | + | "redis-commander.mydomain.com" = "localhost:8081" | |||
| 45 | + | ``` | |||
| 46 | + | ||||
| 47 | + | ### Legacy Services | |||
| 48 | + | ||||
| 49 | + | Proxy to services that can't be containerized: | |||
| 50 | + | ||||
| 51 | + | ```toml | |||
| 52 | + | [external_routes] | |||
| 53 | + | "legacy-api.mydomain.com" = "192.168.1.50:8080" | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | ## How It Works | |||
| 57 | + | ||||
| 58 | + | 1. Request arrives for `service.mydomain.com` | |||
| 59 | + | 2. Gordon checks if domain matches an external route | |||
| 60 | + | 3. If matched, proxies directly to the configured `host:port` | |||
| 61 | + | 4. No container lookup is performed | |||
| 62 | + | ||||
| 63 | + | ``` | |||
| 64 | + | Client ─> Gordon Proxy ─> External Service | |||
| 65 | + | (port 80) (configured host:port) | |||
| 66 | + | ``` | |||
| 67 | + | ||||
| 68 | + | ## Limitations | |||
| 69 | + | ||||
| 70 | + | - HTTP only (no HTTPS upstream) | |||
| 71 | + | - No health checks | |||
| 72 | + | - No load balancing | |||
| 73 | + | ||||
| 74 | + | ## Hot Reload | |||
| 75 | + | ||||
| 76 | + | External routes reload automatically when the config file changes: | |||
| 77 | + | ||||
| 78 | + | ```bash | |||
| 79 | + | # Edit config | |||
| 80 | + | vim ~/.config/gordon/gordon.toml | |||
| 81 | + | ||||
| 82 | + | # Add external route | |||
| 83 | + | [external_routes] | |||
| 84 | + | "newservice.mydomain.com" = "localhost:9000" | |||
| 85 | + | ||||
| 86 | + | # Save - Gordon reloads automatically | |||
| 87 | + | ``` | |||
| 88 | + | ||||
| 89 | + | ## Related | |||
| 90 | + | ||||
| 91 | + | - [Routes Configuration](./routes.md) | |||
| 92 | + | - [Configuration Overview](./index.md) | |||
M docs/config/index.md +2 -0
| @@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names | |||
| 115 | 115 | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | |
| 116 | 116 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | |
| 117 | 117 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | |
| 118 | + | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| 118 | 119 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 119 | 120 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 120 | 121 | ||
| @@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 173 | 174 | ||
| 174 | 175 | - [Server Configuration](./server.md) | |
| 175 | 176 | - [Routes Configuration](./routes.md) | |
| 177 | + | - [External Routes](./external-routes.md) | |
| 176 | 178 | - [Registry Authentication](./registry-auth.md) | |
| @@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names | |||||
| 115 | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | 115 | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | ||
| 116 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | 116 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | ||
| 117 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | 117 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | ||
| 118 | + | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |||
| 118 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | 119 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | ||
| 119 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | 120 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | ||
| 120 | 121 | ||||
| @@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||||
| 173 | 174 | ||||
| 174 | - [Server Configuration](./server.md) | 175 | - [Server Configuration](./server.md) | ||
| 175 | - [Routes Configuration](./routes.md) | 176 | - [Routes Configuration](./routes.md) | ||
| 177 | + | - [External Routes](./external-routes.md) | |||
| 176 | - [Registry Authentication](./registry-auth.md) | 178 | - [Registry Authentication](./registry-auth.md) | ||
M docs/config/routes.md +5 -0
| @@ -194,8 +194,13 @@ Add to `/etc/hosts`: | |||
| 194 | 194 | "portal.acme-corp.com" = "saas-app:v2.1.0" | |
| 195 | 195 | ``` | |
| 196 | 196 | ||
| 197 | + | ## External Services | |
| 198 | + | ||
| 199 | + | For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md). | |
| 200 | + | ||
| 197 | 201 | ## Related | |
| 198 | 202 | ||
| 199 | 203 | - [Configuration Overview](./index.md) | |
| 200 | 204 | - [Auto Route](./auto-route.md) | |
| 201 | 205 | - [Attachments](./attachments.md) | |
| 206 | + | - [External Routes](./external-routes.md) | |
| @@ -194,8 +194,13 @@ Add to `/etc/hosts`: | |||||
| 194 | "portal.acme-corp.com" = "saas-app:v2.1.0" | 194 | "portal.acme-corp.com" = "saas-app:v2.1.0" | ||
| 195 | ``` | 195 | ``` | ||
| 196 | 196 | ||||
| 197 | + | ## External Services | |||
| 198 | + | ||||
| 199 | + | For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md). | |||
| 200 | + | ||||
| 197 | ## Related | 201 | ## Related | ||
| 198 | 202 | ||||
| 199 | - [Configuration Overview](./index.md) | 203 | - [Configuration Overview](./index.md) | ||
| 200 | - [Auto Route](./auto-route.md) | 204 | - [Auto Route](./auto-route.md) | ||
| 201 | - [Attachments](./attachments.md) | 205 | - [Attachments](./attachments.md) | ||
| 206 | + | - [External Routes](./external-routes.md) | |||
M internal/boundaries/in/config.go +3 -0
| @@ -47,4 +47,7 @@ type ConfigService interface { | |||
| 47 | 47 | ||
| 48 | 48 | // GetNetworkPrefix returns the prefix for created networks. | |
| 49 | 49 | GetNetworkPrefix() string | |
| 50 | + | ||
| 51 | + | // GetExternalRoutes returns all configured external routes. | |
| 52 | + | GetExternalRoutes() map[string]string | |
| 50 | 53 | } | |
| @@ -47,4 +47,7 @@ type ConfigService interface { | |||||
| 47 | 47 | ||||
| 48 | // GetNetworkPrefix returns the prefix for created networks. | 48 | // GetNetworkPrefix returns the prefix for created networks. | ||
| 49 | GetNetworkPrefix() string | 49 | GetNetworkPrefix() string | ||
| 50 | + | ||||
| 51 | + | // GetExternalRoutes returns all configured external routes. | |||
| 52 | + | GetExternalRoutes() map[string]string | |||
| 50 | } | 53 | } | ||
M internal/boundaries/in/mocks/mock_config_service.go +46 -0
| @@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo | |||
| 139 | 139 | return _c | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | // GetExternalRoutes provides a mock function for the type MockConfigService | |
| 143 | + | func (_mock *MockConfigService) GetExternalRoutes() map[string]string { | |
| 144 | + | ret := _mock.Called() | |
| 145 | + | ||
| 146 | + | if len(ret) == 0 { | |
| 147 | + | panic("no return value specified for GetExternalRoutes") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | var r0 map[string]string | |
| 151 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |
| 152 | + | r0 = returnFunc() | |
| 153 | + | } else { | |
| 154 | + | if ret.Get(0) != nil { | |
| 155 | + | r0 = ret.Get(0).(map[string]string) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | return r0 | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |
| 162 | + | type MockConfigService_GetExternalRoutes_Call struct { | |
| 163 | + | *mock.Call | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // GetExternalRoutes is a helper method to define mock.On call | |
| 167 | + | func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call { | |
| 168 | + | return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call { | |
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 173 | + | run() | |
| 174 | + | }) | |
| 175 | + | return _c | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call { | |
| 179 | + | _c.Call.Return(stringToString) | |
| 180 | + | return _c | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call { | |
| 184 | + | _c.Call.Return(run) | |
| 185 | + | return _c | |
| 186 | + | } | |
| 187 | + | ||
| 142 | 188 | // GetNetworkPrefix provides a mock function for the type MockConfigService | |
| 143 | 189 | func (_mock *MockConfigService) GetNetworkPrefix() string { | |
| 144 | 190 | ret := _mock.Called() | |
| @@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo | |||||
| 139 | return _c | 139 | return _c | ||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | + | // GetExternalRoutes provides a mock function for the type MockConfigService | |||
| 143 | + | func (_mock *MockConfigService) GetExternalRoutes() map[string]string { | |||
| 144 | + | ret := _mock.Called() | |||
| 145 | + | ||||
| 146 | + | if len(ret) == 0 { | |||
| 147 | + | panic("no return value specified for GetExternalRoutes") | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | var r0 map[string]string | |||
| 151 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |||
| 152 | + | r0 = returnFunc() | |||
| 153 | + | } else { | |||
| 154 | + | if ret.Get(0) != nil { | |||
| 155 | + | r0 = ret.Get(0).(map[string]string) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | return r0 | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |||
| 162 | + | type MockConfigService_GetExternalRoutes_Call struct { | |||
| 163 | + | *mock.Call | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // GetExternalRoutes is a helper method to define mock.On call | |||
| 167 | + | func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call { | |||
| 168 | + | return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call { | |||
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 173 | + | run() | |||
| 174 | + | }) | |||
| 175 | + | return _c | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call { | |||
| 179 | + | _c.Call.Return(stringToString) | |||
| 180 | + | return _c | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call { | |||
| 184 | + | _c.Call.Return(run) | |||
| 185 | + | return _c | |||
| 186 | + | } | |||
| 187 | + | ||||
| 142 | // GetNetworkPrefix provides a mock function for the type MockConfigService | 188 | // GetNetworkPrefix provides a mock function for the type MockConfigService | ||
| 143 | func (_mock *MockConfigService) GetNetworkPrefix() string { | 189 | func (_mock *MockConfigService) GetNetworkPrefix() string { | ||
| 144 | ret := _mock.Called() | 190 | ret := _mock.Called() | ||
M internal/domain/route.go +7 -0
| @@ -21,3 +21,10 @@ type RouteMatch struct { | |||
| 21 | 21 | Container *Container | |
| 22 | 22 | Target *ProxyTarget | |
| 23 | 23 | } | |
| 24 | + | ||
| 25 | + | // ExternalRoute represents a mapping from a domain to an external (non-container) service. | |
| 26 | + | type ExternalRoute struct { | |
| 27 | + | Domain string // e.g., "reg.bnema.dev" | |
| 28 | + | Host string // e.g., "localhost" | |
| 29 | + | Port int // e.g., 5000 | |
| 30 | + | } | |
| @@ -21,3 +21,10 @@ type RouteMatch struct { | |||||
| 21 | Container *Container | 21 | Container *Container | ||
| 22 | Target *ProxyTarget | 22 | Target *ProxyTarget | ||
| 23 | } | 23 | } | ||
| 24 | + | ||||
| 25 | + | // ExternalRoute represents a mapping from a domain to an external (non-container) service. | |||
| 26 | + | type ExternalRoute struct { | |||
| 27 | + | Domain string // e.g., "reg.bnema.dev" | |||
| 28 | + | Host string // e.g., "localhost" | |||
| 29 | + | Port int // e.g., 5000 | |||
| 30 | + | } | |||
M internal/usecase/config/service.go +15 -0
| @@ -24,6 +24,7 @@ type Config struct { | |||
| 24 | 24 | NetworkIsolation bool | |
| 25 | 25 | NetworkPrefix string | |
| 26 | 26 | Routes map[string]string | |
| 27 | + | ExternalRoutes map[string]string // domain -> "host:port" | |
| 27 | 28 | RegistryAuthEnabled bool | |
| 28 | 29 | RegistryAuthUsername string | |
| 29 | 30 | RegistryAuthPassword string | |
| @@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 66 | 67 | ||
| 67 | 68 | // Load complex nested structures | |
| 68 | 69 | s.config.Routes = loadStringMap(s.viper.Get("routes")) | |
| 70 | + | s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes")) | |
| 69 | 71 | s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | |
| 70 | 72 | s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | |
| 71 | 73 | ||
| @@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config { | |||
| 95 | 97 | VolumePrefix: s.viper.GetString("volumes.prefix"), | |
| 96 | 98 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | |
| 97 | 99 | Routes: make(map[string]string), | |
| 100 | + | ExternalRoutes: make(map[string]string), | |
| 98 | 101 | NetworkGroups: make(map[string][]string), | |
| 99 | 102 | Attachments: make(map[string][]string), | |
| 100 | 103 | } | |
| @@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string { | |||
| 365 | 368 | return result | |
| 366 | 369 | } | |
| 367 | 370 | ||
| 371 | + | // GetExternalRoutes returns all configured external routes. | |
| 372 | + | func (s *Service) GetExternalRoutes() map[string]string { | |
| 373 | + | s.mu.RLock() | |
| 374 | + | defer s.mu.RUnlock() | |
| 375 | + | ||
| 376 | + | result := make(map[string]string) | |
| 377 | + | for k, v := range s.config.ExternalRoutes { | |
| 378 | + | result[k] = v | |
| 379 | + | } | |
| 380 | + | return result | |
| 381 | + | } | |
| 382 | + | ||
| 368 | 383 | // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest". | |
| 369 | 384 | func ExtractDomainFromImageName(imageName string) (string, bool) { | |
| 370 | 385 | parts := strings.Split(imageName, ":") | |
| @@ -24,6 +24,7 @@ type Config struct { | |||||
| 24 | NetworkIsolation bool | 24 | NetworkIsolation bool | ||
| 25 | NetworkPrefix string | 25 | NetworkPrefix string | ||
| 26 | Routes map[string]string | 26 | Routes map[string]string | ||
| 27 | + | ExternalRoutes map[string]string // domain -> "host:port" | |||
| 27 | RegistryAuthEnabled bool | 28 | RegistryAuthEnabled bool | ||
| 28 | RegistryAuthUsername string | 29 | RegistryAuthUsername string | ||
| 29 | RegistryAuthPassword string | 30 | RegistryAuthPassword string | ||
| @@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 66 | 67 | ||||
| 67 | // Load complex nested structures | 68 | // Load complex nested structures | ||
| 68 | s.config.Routes = loadStringMap(s.viper.Get("routes")) | 69 | s.config.Routes = loadStringMap(s.viper.Get("routes")) | ||
| 70 | + | s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes")) | |||
| 69 | s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | 71 | s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups")) | ||
| 70 | s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | 72 | s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments")) | ||
| 71 | 73 | ||||
| @@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config { | |||||
| 95 | VolumePrefix: s.viper.GetString("volumes.prefix"), | 97 | VolumePrefix: s.viper.GetString("volumes.prefix"), | ||
| 96 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | 98 | VolumePreserve: s.viper.GetBool("volumes.preserve"), | ||
| 97 | Routes: make(map[string]string), | 99 | Routes: make(map[string]string), | ||
| 100 | + | ExternalRoutes: make(map[string]string), | |||
| 98 | NetworkGroups: make(map[string][]string), | 101 | NetworkGroups: make(map[string][]string), | ||
| 99 | Attachments: make(map[string][]string), | 102 | Attachments: make(map[string][]string), | ||
| 100 | } | 103 | } | ||
| @@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string { | |||||
| 365 | return result | 368 | return result | ||
| 366 | } | 369 | } | ||
| 367 | 370 | ||||
| 371 | + | // GetExternalRoutes returns all configured external routes. | |||
| 372 | + | func (s *Service) GetExternalRoutes() map[string]string { | |||
| 373 | + | s.mu.RLock() | |||
| 374 | + | defer s.mu.RUnlock() | |||
| 375 | + | ||||
| 376 | + | result := make(map[string]string) | |||
| 377 | + | for k, v := range s.config.ExternalRoutes { | |||
| 378 | + | result[k] = v | |||
| 379 | + | } | |||
| 380 | + | return result | |||
| 381 | + | } | |||
| 382 | + | ||||
| 368 | // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest". | 383 | // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest". | ||
| 369 | func ExtractDomainFromImageName(imageName string) (string, bool) { | 384 | func ExtractDomainFromImageName(imageName string) (string, bool) { | ||
| 370 | parts := strings.Split(imageName, ":") | 385 | parts := strings.Split(imageName, ":") | ||
M internal/usecase/config/service_test.go +34 -0
| @@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) { | |||
| 232 | 232 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"]) | |
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | + | func TestService_GetExternalRoutes(t *testing.T) { | |
| 236 | + | v := viper.New() | |
| 237 | + | v.Set("external_routes", map[string]interface{}{ | |
| 238 | + | "reg.example.com": "localhost:5000", | |
| 239 | + | "cache.example.com": "127.0.0.1:6379", | |
| 240 | + | }) | |
| 241 | + | ||
| 242 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 243 | + | svc := NewService(v, eventBus) | |
| 244 | + | ctx := testContext() | |
| 245 | + | ||
| 246 | + | _ = svc.Load(ctx) | |
| 247 | + | ||
| 248 | + | routes := svc.GetExternalRoutes() | |
| 249 | + | ||
| 250 | + | assert.Len(t, routes, 2) | |
| 251 | + | assert.Equal(t, "localhost:5000", routes["reg.example.com"]) | |
| 252 | + | assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"]) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestService_GetExternalRoutes_Empty(t *testing.T) { | |
| 256 | + | v := viper.New() | |
| 257 | + | ||
| 258 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 259 | + | svc := NewService(v, eventBus) | |
| 260 | + | ctx := testContext() | |
| 261 | + | ||
| 262 | + | _ = svc.Load(ctx) | |
| 263 | + | ||
| 264 | + | routes := svc.GetExternalRoutes() | |
| 265 | + | ||
| 266 | + | assert.Empty(t, routes) | |
| 267 | + | } | |
| 268 | + | ||
| 235 | 269 | func TestExtractDomainFromImageName(t *testing.T) { | |
| 236 | 270 | tests := []struct { | |
| 237 | 271 | name string | |
| @@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) { | |||||
| 232 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"]) | 232 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"]) | ||
| 233 | } | 233 | } | ||
| 234 | 234 | ||||
| 235 | + | func TestService_GetExternalRoutes(t *testing.T) { | |||
| 236 | + | v := viper.New() | |||
| 237 | + | v.Set("external_routes", map[string]interface{}{ | |||
| 238 | + | "reg.example.com": "localhost:5000", | |||
| 239 | + | "cache.example.com": "127.0.0.1:6379", | |||
| 240 | + | }) | |||
| 241 | + | ||||
| 242 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 243 | + | svc := NewService(v, eventBus) | |||
| 244 | + | ctx := testContext() | |||
| 245 | + | ||||
| 246 | + | _ = svc.Load(ctx) | |||
| 247 | + | ||||
| 248 | + | routes := svc.GetExternalRoutes() | |||
| 249 | + | ||||
| 250 | + | assert.Len(t, routes, 2) | |||
| 251 | + | assert.Equal(t, "localhost:5000", routes["reg.example.com"]) | |||
| 252 | + | assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"]) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestService_GetExternalRoutes_Empty(t *testing.T) { | |||
| 256 | + | v := viper.New() | |||
| 257 | + | ||||
| 258 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 259 | + | svc := NewService(v, eventBus) | |||
| 260 | + | ctx := testContext() | |||
| 261 | + | ||||
| 262 | + | _ = svc.Load(ctx) | |||
| 263 | + | ||||
| 264 | + | routes := svc.GetExternalRoutes() | |||
| 265 | + | ||||
| 266 | + | assert.Empty(t, routes) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 235 | func TestExtractDomainFromImageName(t *testing.T) { | 269 | func TestExtractDomainFromImageName(t *testing.T) { | ||
| 236 | tests := []struct { | 270 | tests := []struct { | ||
| 237 | name string | 271 | name string | ||
M internal/usecase/proxy/service.go +31 -0
| @@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 127 | 127 | } | |
| 128 | 128 | s.mu.RUnlock() | |
| 129 | 129 | ||
| 130 | + | // Check if this is an external route | |
| 131 | + | externalRoutes := s.configSvc.GetExternalRoutes() | |
| 132 | + | if targetAddr, ok := externalRoutes[domainName]; ok { | |
| 133 | + | host, portStr, err := net.SplitHostPort(targetAddr) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |
| 136 | + | } | |
| 137 | + | port, err := strconv.Atoi(portStr) | |
| 138 | + | if err != nil { | |
| 139 | + | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | target := &domain.ProxyTarget{ | |
| 143 | + | Host: host, | |
| 144 | + | Port: port, | |
| 145 | + | ContainerID: "", // Not a container | |
| 146 | + | Scheme: "http", | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Cache external route target | |
| 150 | + | s.mu.Lock() | |
| 151 | + | s.targets[domainName] = target | |
| 152 | + | s.mu.Unlock() | |
| 153 | + | ||
| 154 | + | log.Debug(). | |
| 155 | + | Str("host", host). | |
| 156 | + | Int("port", port). | |
| 157 | + | Msg("using external route target") | |
| 158 | + | return target, nil | |
| 159 | + | } | |
| 160 | + | ||
| 130 | 161 | // Get container for this domain | |
| 131 | 162 | container, exists := s.containerSvc.Get(ctx, domainName) | |
| 132 | 163 | if !exists { | |
| @@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 127 | } | 127 | } | ||
| 128 | s.mu.RUnlock() | 128 | s.mu.RUnlock() | ||
| 129 | 129 | ||||
| 130 | + | // Check if this is an external route | |||
| 131 | + | externalRoutes := s.configSvc.GetExternalRoutes() | |||
| 132 | + | if targetAddr, ok := externalRoutes[domainName]; ok { | |||
| 133 | + | host, portStr, err := net.SplitHostPort(targetAddr) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |||
| 136 | + | } | |||
| 137 | + | port, err := strconv.Atoi(portStr) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | target := &domain.ProxyTarget{ | |||
| 143 | + | Host: host, | |||
| 144 | + | Port: port, | |||
| 145 | + | ContainerID: "", // Not a container | |||
| 146 | + | Scheme: "http", | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Cache external route target | |||
| 150 | + | s.mu.Lock() | |||
| 151 | + | s.targets[domainName] = target | |||
| 152 | + | s.mu.Unlock() | |||
| 153 | + | ||||
| 154 | + | log.Debug(). | |||
| 155 | + | Str("host", host). | |||
| 156 | + | Int("port", port). | |||
| 157 | + | Msg("using external route target") | |||
| 158 | + | return target, nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 130 | // Get container for this domain | 161 | // Get container for this domain | ||
| 131 | container, exists := s.containerSvc.Get(ctx, domainName) | 162 | container, exists := s.containerSvc.Get(ctx, domainName) | ||
| 132 | if !exists { | 163 | if !exists { | ||
M internal/usecase/proxy/service_test.go +85 -0
| @@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||
| 55 | 55 | svc := NewService(runtime, containerSvc, configSvc, config) | |
| 56 | 56 | ctx := testContext() | |
| 57 | 57 | ||
| 58 | + | // Mock external routes (empty - no match) | |
| 59 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}) | |
| 58 | 60 | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | |
| 59 | 61 | ||
| 60 | 62 | result, err := svc.GetTarget(ctx, "app.example.com") | |
| @@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||
| 63 | 65 | assert.Nil(t, result) | |
| 64 | 66 | } | |
| 65 | 67 | ||
| 68 | + | func TestService_GetTarget_ExternalRoute(t *testing.T) { | |
| 69 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 70 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 71 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 72 | + | ||
| 73 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 74 | + | ctx := testContext() | |
| 75 | + | ||
| 76 | + | // Mock external routes | |
| 77 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 78 | + | "reg.example.com": "localhost:5000", | |
| 79 | + | }) | |
| 80 | + | ||
| 81 | + | result, err := svc.GetTarget(ctx, "reg.example.com") | |
| 82 | + | ||
| 83 | + | assert.NoError(t, err) | |
| 84 | + | assert.Equal(t, "localhost", result.Host) | |
| 85 | + | assert.Equal(t, 5000, result.Port) | |
| 86 | + | assert.Equal(t, "", result.ContainerID) | |
| 87 | + | assert.Equal(t, "http", result.Scheme) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |
| 91 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 92 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 93 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 94 | + | ||
| 95 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 96 | + | ctx := testContext() | |
| 97 | + | ||
| 98 | + | // First call - should resolve external route | |
| 99 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 100 | + | "reg.example.com": "localhost:5000", | |
| 101 | + | }).Once() | |
| 102 | + | ||
| 103 | + | result1, err := svc.GetTarget(ctx, "reg.example.com") | |
| 104 | + | assert.NoError(t, err) | |
| 105 | + | assert.Equal(t, "localhost", result1.Host) | |
| 106 | + | ||
| 107 | + | // Second call - should return from cache (no mock call needed) | |
| 108 | + | result2, err := svc.GetTarget(ctx, "reg.example.com") | |
| 109 | + | assert.NoError(t, err) | |
| 110 | + | assert.Equal(t, result1, result2) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | |
| 114 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 115 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 116 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 117 | + | ||
| 118 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 119 | + | ctx := testContext() | |
| 120 | + | ||
| 121 | + | // Mock external routes with invalid format (missing port) | |
| 122 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 123 | + | "invalid.example.com": "not-valid-format", | |
| 124 | + | }) | |
| 125 | + | ||
| 126 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |
| 127 | + | ||
| 128 | + | assert.Error(t, err) | |
| 129 | + | assert.Nil(t, result) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) { | |
| 133 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 134 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 135 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 136 | + | ||
| 137 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 138 | + | ctx := testContext() | |
| 139 | + | ||
| 140 | + | // Mock external routes with invalid port | |
| 141 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 142 | + | "invalid.example.com": "localhost:abc", | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |
| 146 | + | ||
| 147 | + | assert.Error(t, err) | |
| 148 | + | assert.Nil(t, result) | |
| 149 | + | } | |
| 150 | + | ||
| 66 | 151 | func TestService_RegisterTarget(t *testing.T) { | |
| 67 | 152 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 68 | 153 | containerSvc := inmocks.NewMockContainerService(t) | |
| @@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||||
| 55 | svc := NewService(runtime, containerSvc, configSvc, config) | 55 | svc := NewService(runtime, containerSvc, configSvc, config) | ||
| 56 | ctx := testContext() | 56 | ctx := testContext() | ||
| 57 | 57 | ||||
| 58 | + | // Mock external routes (empty - no match) | |||
| 59 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}) | |||
| 58 | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | 60 | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | ||
| 59 | 61 | ||||
| 60 | result, err := svc.GetTarget(ctx, "app.example.com") | 62 | result, err := svc.GetTarget(ctx, "app.example.com") | ||
| @@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) { | |||||
| 63 | assert.Nil(t, result) | 65 | assert.Nil(t, result) | ||
| 64 | } | 66 | } | ||
| 65 | 67 | ||||
| 68 | + | func TestService_GetTarget_ExternalRoute(t *testing.T) { | |||
| 69 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 70 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 71 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 72 | + | ||||
| 73 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 74 | + | ctx := testContext() | |||
| 75 | + | ||||
| 76 | + | // Mock external routes | |||
| 77 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 78 | + | "reg.example.com": "localhost:5000", | |||
| 79 | + | }) | |||
| 80 | + | ||||
| 81 | + | result, err := svc.GetTarget(ctx, "reg.example.com") | |||
| 82 | + | ||||
| 83 | + | assert.NoError(t, err) | |||
| 84 | + | assert.Equal(t, "localhost", result.Host) | |||
| 85 | + | assert.Equal(t, 5000, result.Port) | |||
| 86 | + | assert.Equal(t, "", result.ContainerID) | |||
| 87 | + | assert.Equal(t, "http", result.Scheme) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||
| 91 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 92 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 93 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 94 | + | ||||
| 95 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 96 | + | ctx := testContext() | |||
| 97 | + | ||||
| 98 | + | // First call - should resolve external route | |||
| 99 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 100 | + | "reg.example.com": "localhost:5000", | |||
| 101 | + | }).Once() | |||
| 102 | + | ||||
| 103 | + | result1, err := svc.GetTarget(ctx, "reg.example.com") | |||
| 104 | + | assert.NoError(t, err) | |||
| 105 | + | assert.Equal(t, "localhost", result1.Host) | |||
| 106 | + | ||||
| 107 | + | // Second call - should return from cache (no mock call needed) | |||
| 108 | + | result2, err := svc.GetTarget(ctx, "reg.example.com") | |||
| 109 | + | assert.NoError(t, err) | |||
| 110 | + | assert.Equal(t, result1, result2) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | |||
| 114 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 115 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 116 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 117 | + | ||||
| 118 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 119 | + | ctx := testContext() | |||
| 120 | + | ||||
| 121 | + | // Mock external routes with invalid format (missing port) | |||
| 122 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 123 | + | "invalid.example.com": "not-valid-format", | |||
| 124 | + | }) | |||
| 125 | + | ||||
| 126 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |||
| 127 | + | ||||
| 128 | + | assert.Error(t, err) | |||
| 129 | + | assert.Nil(t, result) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) { | |||
| 133 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 134 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 135 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 136 | + | ||||
| 137 | + | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |||
| 138 | + | ctx := testContext() | |||
| 139 | + | ||||
| 140 | + | // Mock external routes with invalid port | |||
| 141 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |||
| 142 | + | "invalid.example.com": "localhost:abc", | |||
| 143 | + | }) | |||
| 144 | + | ||||
| 145 | + | result, err := svc.GetTarget(ctx, "invalid.example.com") | |||
| 146 | + | ||||
| 147 | + | assert.Error(t, err) | |||
| 148 | + | assert.Nil(t, result) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 66 | func TestService_RegisterTarget(t *testing.T) { | 151 | func TestService_RegisterTarget(t *testing.T) { | ||
| 67 | runtime := outmocks.NewMockContainerRuntime(t) | 152 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 68 | containerSvc := inmocks.NewMockContainerService(t) | 153 | containerSvc := inmocks.NewMockContainerService(t) | ||