feat(traffic): route l4 services from standalone config
- commit
- 3c7cade11a4c6690298e286a9ceb61425b46ef88
- author
- brice · · 2026-06-30 15:51 UTC
- parent
- 8d2c54f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +528 -23
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -41,6 +41,7 @@ type ControlPlane interface { | |||
| 41 | 41 | ||
| 42 | 42 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 43 | 43 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| 44 | + | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | |
| 44 | 45 | Reload(ctx context.Context) error | |
| 45 | 46 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 46 | 47 | GetConfig(ctx context.Context) (*remote.Config, error) | |
| @@ -41,6 +41,7 @@ type ControlPlane interface { | |||||
| 41 | 41 | ||||
| 42 | GetStatus(ctx context.Context) (*remote.Status, error) | 42 | GetStatus(ctx context.Context) (*remote.Status, error) | ||
| 43 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | 43 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | ||
| 44 | + | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | |||
| 44 | Reload(ctx context.Context) error | 45 | Reload(ctx context.Context) error | ||
| 45 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | 46 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | ||
| 46 | GetConfig(ctx context.Context) (*remote.Config, error) | 47 | GetConfig(ctx context.Context) (*remote.Config, error) | ||
M internal/adapters/in/cli/controlplane_local.go +4 -0
| @@ -445,6 +445,10 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes | |||
| 445 | 445 | return &result, nil | |
| 446 | 446 | } | |
| 447 | 447 | ||
| 448 | + | func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) { | |
| 449 | + | return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL") | |
| 450 | + | } | |
| 451 | + | ||
| 448 | 452 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 449 | 453 | if l.configSvc == nil { | |
| 450 | 454 | return nil, fmt.Errorf("local config service unavailable") | |
| @@ -445,6 +445,10 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes | |||||
| 445 | return &result, nil | 445 | return &result, nil | ||
| 446 | } | 446 | } | ||
| 447 | 447 | ||||
| 448 | + | func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) { | |||
| 449 | + | return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL") | |||
| 450 | + | } | |||
| 451 | + | ||||
| 448 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | 452 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | ||
| 449 | if l.configSvc == nil { | 453 | if l.configSvc == nil { | ||
| 450 | return nil, fmt.Errorf("local config service unavailable") | 454 | return nil, fmt.Errorf("local config service unavailable") | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -126,6 +126,10 @@ func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRe | |||
| 126 | 126 | return r.client.GetTLSStatus(ctx) | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | + | func (r *remoteControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | |
| 130 | + | return r.client.GetTrafficStatus(ctx) | |
| 131 | + | } | |
| 132 | + | ||
| 129 | 133 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | |
| 130 | 134 | return r.client.Reload(ctx) | |
| 131 | 135 | } | |
| @@ -126,6 +126,10 @@ func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRe | |||||
| 126 | return r.client.GetTLSStatus(ctx) | 126 | return r.client.GetTLSStatus(ctx) | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | + | func (r *remoteControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | |||
| 130 | + | return r.client.GetTrafficStatus(ctx) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 129 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | 133 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | ||
| 130 | return r.client.Reload(ctx) | 134 | return r.client.Reload(ctx) | ||
| 131 | } | 135 | } | ||
M internal/adapters/in/cli/mocks/mock_control_plane.go +62 -0
| @@ -1532,6 +1532,68 @@ func (_c *MockControlPlane_GetTLSStatus_Call) RunAndReturn(run func(ctx context. | |||
| 1532 | 1532 | return _c | |
| 1533 | 1533 | } | |
| 1534 | 1534 | ||
| 1535 | + | // GetTrafficStatus provides a mock function for the type MockControlPlane | |
| 1536 | + | func (_mock *MockControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | |
| 1537 | + | ret := _mock.Called(ctx) | |
| 1538 | + | ||
| 1539 | + | if len(ret) == 0 { | |
| 1540 | + | panic("no return value specified for GetTrafficStatus") | |
| 1541 | + | } | |
| 1542 | + | ||
| 1543 | + | var r0 *dto.TrafficStatusResponse | |
| 1544 | + | var r1 error | |
| 1545 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.TrafficStatusResponse, error)); ok { | |
| 1546 | + | return returnFunc(ctx) | |
| 1547 | + | } | |
| 1548 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.TrafficStatusResponse); ok { | |
| 1549 | + | r0 = returnFunc(ctx) | |
| 1550 | + | } else { | |
| 1551 | + | if ret.Get(0) != nil { | |
| 1552 | + | r0 = ret.Get(0).(*dto.TrafficStatusResponse) | |
| 1553 | + | } | |
| 1554 | + | } | |
| 1555 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1556 | + | r1 = returnFunc(ctx) | |
| 1557 | + | } else { | |
| 1558 | + | r1 = ret.Error(1) | |
| 1559 | + | } | |
| 1560 | + | return r0, r1 | |
| 1561 | + | } | |
| 1562 | + | ||
| 1563 | + | // MockControlPlane_GetTrafficStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTrafficStatus' | |
| 1564 | + | type MockControlPlane_GetTrafficStatus_Call struct { | |
| 1565 | + | *mock.Call | |
| 1566 | + | } | |
| 1567 | + | ||
| 1568 | + | // GetTrafficStatus is a helper method to define mock.On call | |
| 1569 | + | // - ctx context.Context | |
| 1570 | + | func (_e *MockControlPlane_Expecter) GetTrafficStatus(ctx any) *MockControlPlane_GetTrafficStatus_Call { | |
| 1571 | + | return &MockControlPlane_GetTrafficStatus_Call{Call: _e.mock.On("GetTrafficStatus", ctx)} | |
| 1572 | + | } | |
| 1573 | + | ||
| 1574 | + | func (_c *MockControlPlane_GetTrafficStatus_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetTrafficStatus_Call { | |
| 1575 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1576 | + | var arg0 context.Context | |
| 1577 | + | if args[0] != nil { | |
| 1578 | + | arg0 = args[0].(context.Context) | |
| 1579 | + | } | |
| 1580 | + | run( | |
| 1581 | + | arg0, | |
| 1582 | + | ) | |
| 1583 | + | }) | |
| 1584 | + | return _c | |
| 1585 | + | } | |
| 1586 | + | ||
| 1587 | + | func (_c *MockControlPlane_GetTrafficStatus_Call) Return(trafficStatusResponse *dto.TrafficStatusResponse, err error) *MockControlPlane_GetTrafficStatus_Call { | |
| 1588 | + | _c.Call.Return(trafficStatusResponse, err) | |
| 1589 | + | return _c | |
| 1590 | + | } | |
| 1591 | + | ||
| 1592 | + | func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx context.Context) (*dto.TrafficStatusResponse, error)) *MockControlPlane_GetTrafficStatus_Call { | |
| 1593 | + | _c.Call.Return(run) | |
| 1594 | + | return _c | |
| 1595 | + | } | |
| 1596 | + | ||
| 1535 | 1597 | // ListBackups provides a mock function for the type MockControlPlane | |
| 1536 | 1598 | func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 1537 | 1599 | ret := _mock.Called(ctx, backupDomain) | |
| @@ -1532,6 +1532,68 @@ func (_c *MockControlPlane_GetTLSStatus_Call) RunAndReturn(run func(ctx context. | |||||
| 1532 | return _c | 1532 | return _c | ||
| 1533 | } | 1533 | } | ||
| 1534 | 1534 | ||||
| 1535 | + | // GetTrafficStatus provides a mock function for the type MockControlPlane | |||
| 1536 | + | func (_mock *MockControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | |||
| 1537 | + | ret := _mock.Called(ctx) | |||
| 1538 | + | ||||
| 1539 | + | if len(ret) == 0 { | |||
| 1540 | + | panic("no return value specified for GetTrafficStatus") | |||
| 1541 | + | } | |||
| 1542 | + | ||||
| 1543 | + | var r0 *dto.TrafficStatusResponse | |||
| 1544 | + | var r1 error | |||
| 1545 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.TrafficStatusResponse, error)); ok { | |||
| 1546 | + | return returnFunc(ctx) | |||
| 1547 | + | } | |||
| 1548 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.TrafficStatusResponse); ok { | |||
| 1549 | + | r0 = returnFunc(ctx) | |||
| 1550 | + | } else { | |||
| 1551 | + | if ret.Get(0) != nil { | |||
| 1552 | + | r0 = ret.Get(0).(*dto.TrafficStatusResponse) | |||
| 1553 | + | } | |||
| 1554 | + | } | |||
| 1555 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1556 | + | r1 = returnFunc(ctx) | |||
| 1557 | + | } else { | |||
| 1558 | + | r1 = ret.Error(1) | |||
| 1559 | + | } | |||
| 1560 | + | return r0, r1 | |||
| 1561 | + | } | |||
| 1562 | + | ||||
| 1563 | + | // MockControlPlane_GetTrafficStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTrafficStatus' | |||
| 1564 | + | type MockControlPlane_GetTrafficStatus_Call struct { | |||
| 1565 | + | *mock.Call | |||
| 1566 | + | } | |||
| 1567 | + | ||||
| 1568 | + | // GetTrafficStatus is a helper method to define mock.On call | |||
| 1569 | + | // - ctx context.Context | |||
| 1570 | + | func (_e *MockControlPlane_Expecter) GetTrafficStatus(ctx any) *MockControlPlane_GetTrafficStatus_Call { | |||
| 1571 | + | return &MockControlPlane_GetTrafficStatus_Call{Call: _e.mock.On("GetTrafficStatus", ctx)} | |||
| 1572 | + | } | |||
| 1573 | + | ||||
| 1574 | + | func (_c *MockControlPlane_GetTrafficStatus_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetTrafficStatus_Call { | |||
| 1575 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1576 | + | var arg0 context.Context | |||
| 1577 | + | if args[0] != nil { | |||
| 1578 | + | arg0 = args[0].(context.Context) | |||
| 1579 | + | } | |||
| 1580 | + | run( | |||
| 1581 | + | arg0, | |||
| 1582 | + | ) | |||
| 1583 | + | }) | |||
| 1584 | + | return _c | |||
| 1585 | + | } | |||
| 1586 | + | ||||
| 1587 | + | func (_c *MockControlPlane_GetTrafficStatus_Call) Return(trafficStatusResponse *dto.TrafficStatusResponse, err error) *MockControlPlane_GetTrafficStatus_Call { | |||
| 1588 | + | _c.Call.Return(trafficStatusResponse, err) | |||
| 1589 | + | return _c | |||
| 1590 | + | } | |||
| 1591 | + | ||||
| 1592 | + | func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx context.Context) (*dto.TrafficStatusResponse, error)) *MockControlPlane_GetTrafficStatus_Call { | |||
| 1593 | + | _c.Call.Return(run) | |||
| 1594 | + | return _c | |||
| 1595 | + | } | |||
| 1596 | + | ||||
| 1535 | // ListBackups provides a mock function for the type MockControlPlane | 1597 | // ListBackups provides a mock function for the type MockControlPlane | ||
| 1536 | func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | 1598 | func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | ||
| 1537 | ret := _mock.Called(ctx, backupDomain) | 1599 | ret := _mock.Called(ctx, backupDomain) | ||
M internal/adapters/in/cli/traffic_status.go +11 -11
| @@ -11,28 +11,28 @@ import ( | |||
| 11 | 11 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | var trafficResolveControlPlane = resolveControlPlane | |
| 15 | + | ||
| 14 | 16 | func newTrafficStatusCmd() *cobra.Command { | |
| 15 | 17 | var jsonOut bool | |
| 16 | 18 | cmd := &cobra.Command{ | |
| 17 | 19 | Use: "status", | |
| 18 | - | Short: "Show remote traffic entrypoint, router, and counter status", | |
| 20 | + | Short: "Show traffic entrypoint, router, and counter status", | |
| 19 | 21 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 20 | - | return runTrafficStatus(cmd.Context(), cmd.OutOrStdout(), jsonOut) | |
| 22 | + | handle, err := trafficResolveControlPlane(configPath) | |
| 23 | + | if err != nil { | |
| 24 | + | return err | |
| 25 | + | } | |
| 26 | + | defer handle.close() | |
| 27 | + | return runTrafficStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut) | |
| 21 | 28 | }, | |
| 22 | 29 | } | |
| 23 | 30 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | |
| 24 | 31 | return cmd | |
| 25 | 32 | } | |
| 26 | 33 | ||
| 27 | - | func runTrafficStatus(ctx context.Context, out io.Writer, jsonOut bool) error { | |
| 28 | - | client, isRemote, err := GetRemoteClient() | |
| 29 | - | if err != nil { | |
| 30 | - | return fmt.Errorf("create remote client: %w", err) | |
| 31 | - | } | |
| 32 | - | if !isRemote { | |
| 33 | - | return fmt.Errorf("traffic status currently requires --remote") | |
| 34 | - | } | |
| 35 | - | status, err := client.GetTrafficStatus(ctx) | |
| 34 | + | func runTrafficStatus(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error { | |
| 35 | + | status, err := cp.GetTrafficStatus(ctx) | |
| 36 | 36 | if err != nil { | |
| 37 | 37 | return fmt.Errorf("fetch traffic status: %w", err) | |
| 38 | 38 | } | |
| @@ -11,28 +11,28 @@ import ( | |||||
| 11 | "github.com/bnema/gordon/internal/adapters/dto" | 11 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | var trafficResolveControlPlane = resolveControlPlane | |||
| 15 | + | ||||
| 14 | func newTrafficStatusCmd() *cobra.Command { | 16 | func newTrafficStatusCmd() *cobra.Command { | ||
| 15 | var jsonOut bool | 17 | var jsonOut bool | ||
| 16 | cmd := &cobra.Command{ | 18 | cmd := &cobra.Command{ | ||
| 17 | Use: "status", | 19 | Use: "status", | ||
| 18 | - | Short: "Show remote traffic entrypoint, router, and counter status", | 20 | + | Short: "Show traffic entrypoint, router, and counter status", |
| 19 | RunE: func(cmd *cobra.Command, args []string) error { | 21 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 20 | - | return runTrafficStatus(cmd.Context(), cmd.OutOrStdout(), jsonOut) | 22 | + | handle, err := trafficResolveControlPlane(configPath) |
| 23 | + | if err != nil { | |||
| 24 | + | return err | |||
| 25 | + | } | |||
| 26 | + | defer handle.close() | |||
| 27 | + | return runTrafficStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut) | |||
| 21 | }, | 28 | }, | ||
| 22 | } | 29 | } | ||
| 23 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | 30 | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | ||
| 24 | return cmd | 31 | return cmd | ||
| 25 | } | 32 | } | ||
| 26 | 33 | ||||
| 27 | - | func runTrafficStatus(ctx context.Context, out io.Writer, jsonOut bool) error { | 34 | + | func runTrafficStatus(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error { |
| 28 | - | client, isRemote, err := GetRemoteClient() | 35 | + | status, err := cp.GetTrafficStatus(ctx) |
| 29 | - | if err != nil { | |||
| 30 | - | return fmt.Errorf("create remote client: %w", err) | |||
| 31 | - | } | |||
| 32 | - | if !isRemote { | |||
| 33 | - | return fmt.Errorf("traffic status currently requires --remote") | |||
| 34 | - | } | |||
| 35 | - | status, err := client.GetTrafficStatus(ctx) | |||
| 36 | if err != nil { | 36 | if err != nil { | ||
| 37 | return fmt.Errorf("fetch traffic status: %w", err) | 37 | return fmt.Errorf("fetch traffic status: %w", err) | ||
| 38 | } | 38 | } | ||
M internal/adapters/in/cli/traffic_test.go +54 -0
| @@ -2,13 +2,17 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 6 | 9 | "testing" | |
| 7 | 10 | ||
| 8 | 11 | "github.com/stretchr/testify/assert" | |
| 9 | 12 | "github.com/stretchr/testify/require" | |
| 10 | 13 | ||
| 11 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 12 | 16 | "github.com/bnema/gordon/internal/domain" | |
| 13 | 17 | ) | |
| 14 | 18 | ||
| @@ -68,3 +72,53 @@ func TestTrafficStatusJSONOutput(t *testing.T) { | |||
| 68 | 72 | assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions) | |
| 69 | 73 | assert.Equal(t, int64(1), got.Counters.SmartTCP.SniffTimeout) | |
| 70 | 74 | } | |
| 75 | + | ||
| 76 | + | func TestRunTrafficStatusUsesControlPlane(t *testing.T) { | |
| 77 | + | status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"} | |
| 78 | + | cp := &trafficStatusPlane{status: status} | |
| 79 | + | ||
| 80 | + | var buf bytes.Buffer | |
| 81 | + | require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true)) | |
| 82 | + | ||
| 83 | + | assert.True(t, cp.called) | |
| 84 | + | assert.Contains(t, buf.String(), `"last_reload_status": "ok"`) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | |
| 88 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 89 | + | require.Equal(t, "/admin/traffic/status", r.URL.Path) | |
| 90 | + | _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`)) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | })) | |
| 93 | + | defer srv.Close() | |
| 94 | + | ||
| 95 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |
| 96 | + | var buf bytes.Buffer | |
| 97 | + | require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, false)) | |
| 98 | + | ||
| 99 | + | output := buf.String() | |
| 100 | + | assert.Contains(t, output, "Traffic Status") | |
| 101 | + | assert.Contains(t, output, "Reload") | |
| 102 | + | assert.Contains(t, output, "ok") | |
| 103 | + | assert.Contains(t, output, "udp=3") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) { | |
| 107 | + | _, err := (&localControlPlane{}).GetTrafficStatus(context.Background()) | |
| 108 | + | require.Error(t, err) | |
| 109 | + | assert.Contains(t, err.Error(), "in-process CLI control plane") | |
| 110 | + | assert.Contains(t, err.Error(), "running Gordon daemon") | |
| 111 | + | assert.Contains(t, err.Error(), "--remote") | |
| 112 | + | assert.Contains(t, err.Error(), "GORDON_REMOTE") | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | type trafficStatusPlane struct { | |
| 116 | + | ControlPlane | |
| 117 | + | status *dto.TrafficStatusResponse | |
| 118 | + | called bool | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (p *trafficStatusPlane) GetTrafficStatus(context.Context) (*dto.TrafficStatusResponse, error) { | |
| 122 | + | p.called = true | |
| 123 | + | return p.status, nil | |
| 124 | + | } | |
| @@ -2,13 +2,17 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 6 | "testing" | 9 | "testing" | ||
| 7 | 10 | ||||
| 8 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 10 | 13 | ||||
| 11 | "github.com/bnema/gordon/internal/adapters/dto" | 14 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 12 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| 13 | ) | 17 | ) | ||
| 14 | 18 | ||||
| @@ -68,3 +72,53 @@ func TestTrafficStatusJSONOutput(t *testing.T) { | |||||
| 68 | assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions) | 72 | assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions) | ||
| 69 | assert.Equal(t, int64(1), got.Counters.SmartTCP.SniffTimeout) | 73 | assert.Equal(t, int64(1), got.Counters.SmartTCP.SniffTimeout) | ||
| 70 | } | 74 | } | ||
| 75 | + | ||||
| 76 | + | func TestRunTrafficStatusUsesControlPlane(t *testing.T) { | |||
| 77 | + | status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"} | |||
| 78 | + | cp := &trafficStatusPlane{status: status} | |||
| 79 | + | ||||
| 80 | + | var buf bytes.Buffer | |||
| 81 | + | require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true)) | |||
| 82 | + | ||||
| 83 | + | assert.True(t, cp.called) | |||
| 84 | + | assert.Contains(t, buf.String(), `"last_reload_status": "ok"`) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | |||
| 88 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 89 | + | require.Equal(t, "/admin/traffic/status", r.URL.Path) | |||
| 90 | + | _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`)) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | })) | |||
| 93 | + | defer srv.Close() | |||
| 94 | + | ||||
| 95 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |||
| 96 | + | var buf bytes.Buffer | |||
| 97 | + | require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, false)) | |||
| 98 | + | ||||
| 99 | + | output := buf.String() | |||
| 100 | + | assert.Contains(t, output, "Traffic Status") | |||
| 101 | + | assert.Contains(t, output, "Reload") | |||
| 102 | + | assert.Contains(t, output, "ok") | |||
| 103 | + | assert.Contains(t, output, "udp=3") | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) { | |||
| 107 | + | _, err := (&localControlPlane{}).GetTrafficStatus(context.Background()) | |||
| 108 | + | require.Error(t, err) | |||
| 109 | + | assert.Contains(t, err.Error(), "in-process CLI control plane") | |||
| 110 | + | assert.Contains(t, err.Error(), "running Gordon daemon") | |||
| 111 | + | assert.Contains(t, err.Error(), "--remote") | |||
| 112 | + | assert.Contains(t, err.Error(), "GORDON_REMOTE") | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | type trafficStatusPlane struct { | |||
| 116 | + | ControlPlane | |||
| 117 | + | status *dto.TrafficStatusResponse | |||
| 118 | + | called bool | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (p *trafficStatusPlane) GetTrafficStatus(context.Context) (*dto.TrafficStatusResponse, error) { | |||
| 122 | + | p.called = true | |||
| 123 | + | return p.status, nil | |||
| 124 | + | } | |||
M internal/app/traffic.go +6 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 8 | 8 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 9 | 9 | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |
| 10 | 11 | trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -14,12 +15,17 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana | |||
| 14 | 15 | if manager == nil || configSvc == nil { | |
| 15 | 16 | return nil | |
| 16 | 17 | } | |
| 18 | + | standaloneServices, err := servicecfg.ToDomain(cfg.Services) | |
| 19 | + | if err != nil { | |
| 20 | + | return fmt.Errorf("convert standalone service config: %w", err) | |
| 21 | + | } | |
| 17 | 22 | graph, err := trafficbuilder.Build(trafficbuilder.Input{ | |
| 18 | 23 | EntryPoints: cfg.EntryPoints, | |
| 19 | 24 | Traffic: cfg.Traffic, | |
| 20 | 25 | Routes: configSvc.GetRoutes(ctx), | |
| 21 | 26 | ExternalRoutes: configSvc.GetExternalRoutes(), | |
| 22 | 27 | NetworkServices: cfg.NetworkServices, | |
| 28 | + | Services: standaloneServices, | |
| 23 | 29 | }) | |
| 24 | 30 | if err != nil { | |
| 25 | 31 | return fmt.Errorf("build traffic graph: %w", err) | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | 7 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | ||
| 8 | "github.com/bnema/gordon/internal/boundaries/in" | 8 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 9 | "github.com/bnema/gordon/internal/domain" | 9 | "github.com/bnema/gordon/internal/domain" | ||
| 10 | + | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |||
| 10 | trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic" | 11 | trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -14,12 +15,17 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana | |||||
| 14 | if manager == nil || configSvc == nil { | 15 | if manager == nil || configSvc == nil { | ||
| 15 | return nil | 16 | return nil | ||
| 16 | } | 17 | } | ||
| 18 | + | standaloneServices, err := servicecfg.ToDomain(cfg.Services) | |||
| 19 | + | if err != nil { | |||
| 20 | + | return fmt.Errorf("convert standalone service config: %w", err) | |||
| 21 | + | } | |||
| 17 | graph, err := trafficbuilder.Build(trafficbuilder.Input{ | 22 | graph, err := trafficbuilder.Build(trafficbuilder.Input{ | ||
| 18 | EntryPoints: cfg.EntryPoints, | 23 | EntryPoints: cfg.EntryPoints, | ||
| 19 | Traffic: cfg.Traffic, | 24 | Traffic: cfg.Traffic, | ||
| 20 | Routes: configSvc.GetRoutes(ctx), | 25 | Routes: configSvc.GetRoutes(ctx), | ||
| 21 | ExternalRoutes: configSvc.GetExternalRoutes(), | 26 | ExternalRoutes: configSvc.GetExternalRoutes(), | ||
| 22 | NetworkServices: cfg.NetworkServices, | 27 | NetworkServices: cfg.NetworkServices, | ||
| 28 | + | Services: standaloneServices, | |||
| 23 | }) | 29 | }) | ||
| 24 | if err != nil { | 30 | if err != nil { | ||
| 25 | return fmt.Errorf("build traffic graph: %w", err) | 31 | return fmt.Errorf("build traffic graph: %w", err) | ||
M internal/app/traffic_test.go +37 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 12 | 12 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 13 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |
| 14 | 15 | "github.com/bnema/gordon/internal/usecase/traffic" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -117,6 +118,33 @@ func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy | |||
| 117 | 118 | assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol) | |
| 118 | 119 | } | |
| 119 | 120 | ||
| 121 | + | func TestApplyTrafficRuntimeConfigPassesStandaloneServicesToBuilder(t *testing.T) { | |
| 122 | + | manager := trafficadapter.NewManager() | |
| 123 | + | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | |
| 124 | + | cfg := Config{ | |
| 125 | + | EntryPoints: map[string]traffic.EntryPointConfig{ | |
| 126 | + | "rust": {Address: freeUDPAddress(t), Protocol: domain.EntryPointProtocolUDP}, | |
| 127 | + | }, | |
| 128 | + | Services: []servicecfg.Config{{ | |
| 129 | + | Name: "rust", | |
| 130 | + | Image: "localhost/rust:latest", | |
| 131 | + | Enabled: true, | |
| 132 | + | Ports: []servicecfg.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}}, | |
| 133 | + | }}, | |
| 134 | + | } | |
| 135 | + | cfg.Traffic.UDP.Routers = []traffic.RouterConfig{{Name: "rust-game", EntryPoint: "rust", Service: "service:rust:game"}} | |
| 136 | + | ||
| 137 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 138 | + | configSvc.EXPECT().GetRoutes(context.Background()).Return(nil) | |
| 139 | + | configSvc.EXPECT().GetExternalRoutes().Return(nil) | |
| 140 | + | ||
| 141 | + | require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc)) | |
| 142 | + | status := manager.Status() | |
| 143 | + | require.Len(t, status.EntryPoints, 1) | |
| 144 | + | assert.Equal(t, "rust", status.EntryPoints[0].Name) | |
| 145 | + | assert.True(t, status.EntryPoints[0].Active) | |
| 146 | + | } | |
| 147 | + | ||
| 120 | 148 | func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) { | |
| 121 | 149 | manager := trafficadapter.NewManager() | |
| 122 | 150 | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | |
| @@ -174,3 +202,12 @@ func freeTCPAddress(t *testing.T) string { | |||
| 174 | 202 | require.NoError(t, listener.Close()) | |
| 175 | 203 | return address | |
| 176 | 204 | } | |
| 205 | + | ||
| 206 | + | func freeUDPAddress(t *testing.T) string { | |
| 207 | + | t.Helper() | |
| 208 | + | packetConn, err := net.ListenPacket("udp", "127.0.0.1:0") | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | address := packetConn.LocalAddr().String() | |
| 211 | + | require.NoError(t, packetConn.Close()) | |
| 212 | + | return address | |
| 213 | + | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | 11 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | ||
| 12 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 12 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 13 | "github.com/bnema/gordon/internal/domain" | 13 | "github.com/bnema/gordon/internal/domain" | ||
| 14 | + | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |||
| 14 | "github.com/bnema/gordon/internal/usecase/traffic" | 15 | "github.com/bnema/gordon/internal/usecase/traffic" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| @@ -117,6 +118,33 @@ func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy | |||||
| 117 | assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol) | 118 | assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol) | ||
| 118 | } | 119 | } | ||
| 119 | 120 | ||||
| 121 | + | func TestApplyTrafficRuntimeConfigPassesStandaloneServicesToBuilder(t *testing.T) { | |||
| 122 | + | manager := trafficadapter.NewManager() | |||
| 123 | + | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | |||
| 124 | + | cfg := Config{ | |||
| 125 | + | EntryPoints: map[string]traffic.EntryPointConfig{ | |||
| 126 | + | "rust": {Address: freeUDPAddress(t), Protocol: domain.EntryPointProtocolUDP}, | |||
| 127 | + | }, | |||
| 128 | + | Services: []servicecfg.Config{{ | |||
| 129 | + | Name: "rust", | |||
| 130 | + | Image: "localhost/rust:latest", | |||
| 131 | + | Enabled: true, | |||
| 132 | + | Ports: []servicecfg.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}}, | |||
| 133 | + | }}, | |||
| 134 | + | } | |||
| 135 | + | cfg.Traffic.UDP.Routers = []traffic.RouterConfig{{Name: "rust-game", EntryPoint: "rust", Service: "service:rust:game"}} | |||
| 136 | + | ||||
| 137 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 138 | + | configSvc.EXPECT().GetRoutes(context.Background()).Return(nil) | |||
| 139 | + | configSvc.EXPECT().GetExternalRoutes().Return(nil) | |||
| 140 | + | ||||
| 141 | + | require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc)) | |||
| 142 | + | status := manager.Status() | |||
| 143 | + | require.Len(t, status.EntryPoints, 1) | |||
| 144 | + | assert.Equal(t, "rust", status.EntryPoints[0].Name) | |||
| 145 | + | assert.True(t, status.EntryPoints[0].Active) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 120 | func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) { | 148 | func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) { | ||
| 121 | manager := trafficadapter.NewManager() | 149 | manager := trafficadapter.NewManager() | ||
| 122 | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | 150 | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | ||
| @@ -174,3 +202,12 @@ func freeTCPAddress(t *testing.T) string { | |||||
| 174 | require.NoError(t, listener.Close()) | 202 | require.NoError(t, listener.Close()) | ||
| 175 | return address | 203 | return address | ||
| 176 | } | 204 | } | ||
| 205 | + | ||||
| 206 | + | func freeUDPAddress(t *testing.T) string { | |||
| 207 | + | t.Helper() | |||
| 208 | + | packetConn, err := net.ListenPacket("udp", "127.0.0.1:0") | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | address := packetConn.LocalAddr().String() | |||
| 211 | + | require.NoError(t, packetConn.Close()) | |||
| 212 | + | return address | |||
| 213 | + | } | |||
M internal/domain/service.go +4 -0
| @@ -42,6 +42,7 @@ type StandaloneServicePort struct { | |||
| 42 | 42 | Protocol NetworkProtocol | |
| 43 | 43 | Publish string | |
| 44 | 44 | Private bool | |
| 45 | + | Public bool | |
| 45 | 46 | TrustedCIDRs []string | |
| 46 | 47 | } | |
| 47 | 48 | ||
| @@ -124,6 +125,9 @@ func validateStandaloneServicePorts(s StandaloneService) error { | |||
| 124 | 125 | if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP { | |
| 125 | 126 | return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name) | |
| 126 | 127 | } | |
| 128 | + | if port.Private && port.Public { | |
| 129 | + | return fmt.Errorf("standalone service %q port %q cannot be both private and public", s.Name, name) | |
| 130 | + | } | |
| 127 | 131 | if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil { | |
| 128 | 132 | return err | |
| 129 | 133 | } | |
| @@ -42,6 +42,7 @@ type StandaloneServicePort struct { | |||||
| 42 | Protocol NetworkProtocol | 42 | Protocol NetworkProtocol | ||
| 43 | Publish string | 43 | Publish string | ||
| 44 | Private bool | 44 | Private bool | ||
| 45 | + | Public bool | |||
| 45 | TrustedCIDRs []string | 46 | TrustedCIDRs []string | ||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| @@ -124,6 +125,9 @@ func validateStandaloneServicePorts(s StandaloneService) error { | |||||
| 124 | if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP { | 125 | if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP { | ||
| 125 | return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name) | 126 | return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name) | ||
| 126 | } | 127 | } | ||
| 128 | + | if port.Private && port.Public { | |||
| 129 | + | return fmt.Errorf("standalone service %q port %q cannot be both private and public", s.Name, name) | |||
| 130 | + | } | |||
| 127 | if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil { | 131 | if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil { | ||
| 128 | return err | 132 | return err | ||
| 129 | } | 133 | } | ||
M internal/domain/service_test.go +4 -0
| @@ -43,6 +43,10 @@ func TestStandaloneServiceValidatePorts(t *testing.T) { | |||
| 43 | 43 | badPublish := valid | |
| 44 | 44 | badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}} | |
| 45 | 45 | require.ErrorContains(t, badPublish.Validate(), "valid port") | |
| 46 | + | ||
| 47 | + | conflictingVisibility := valid | |
| 48 | + | conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}} | |
| 49 | + | require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public") | |
| 46 | 50 | } | |
| 47 | 51 | ||
| 48 | 52 | func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) { | |
| @@ -43,6 +43,10 @@ func TestStandaloneServiceValidatePorts(t *testing.T) { | |||||
| 43 | badPublish := valid | 43 | badPublish := valid | ||
| 44 | badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}} | 44 | badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}} | ||
| 45 | require.ErrorContains(t, badPublish.Validate(), "valid port") | 45 | require.ErrorContains(t, badPublish.Validate(), "valid port") | ||
| 46 | + | ||||
| 47 | + | conflictingVisibility := valid | |||
| 48 | + | conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}} | |||
| 49 | + | require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public") | |||
| 46 | } | 50 | } | ||
| 47 | 51 | ||||
| 48 | func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) { | 52 | func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) { | ||
M internal/domain/traffic.go +9 -2
| @@ -173,6 +173,7 @@ const ( | |||
| 173 | 173 | TrafficServiceRefRoute TrafficServiceRefKind = "route" | |
| 174 | 174 | TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route" | |
| 175 | 175 | TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service" | |
| 176 | + | TrafficServiceRefService TrafficServiceRefKind = "service" | |
| 176 | 177 | TrafficServiceRefStatic TrafficServiceRefKind = "static" | |
| 177 | 178 | ) | |
| 178 | 179 | ||
| @@ -200,6 +201,12 @@ func ParseTrafficServiceRef(value string) (TrafficServiceRef, error) { | |||
| 200 | 201 | return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value) | |
| 201 | 202 | } | |
| 202 | 203 | return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil | |
| 204 | + | case TrafficServiceRefService: | |
| 205 | + | name, portName, ok := strings.Cut(rest, ":") | |
| 206 | + | if !ok || name == "" || portName == "" || strings.Contains(portName, ":") { | |
| 207 | + | return TrafficServiceRef{}, fmt.Errorf("invalid service ref %q", value) | |
| 208 | + | } | |
| 209 | + | return TrafficServiceRef{Kind: TrafficServiceRefService, Name: name, PortName: portName}, nil | |
| 203 | 210 | case TrafficServiceRefStatic: | |
| 204 | 211 | return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil | |
| 205 | 212 | default: | |
| @@ -550,11 +557,11 @@ func validateL4RouterService(router TrafficRouter, ref TrafficServiceRef, servic | |||
| 550 | 557 | if !ok { | |
| 551 | 558 | return nil | |
| 552 | 559 | } | |
| 553 | - | if ref.Kind != TrafficServiceRefNetworkService { | |
| 560 | + | if ref.Kind != TrafficServiceRefNetworkService && ref.Kind != TrafficServiceRefService { | |
| 554 | 561 | if ref.Kind == TrafficServiceRefStatic { | |
| 555 | 562 | return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service) | |
| 556 | 563 | } | |
| 557 | - | return fmt.Errorf("l4 router %q requires network_service service ref", router.Name) | |
| 564 | + | return fmt.Errorf("l4 router %q requires network_service or service service ref", router.Name) | |
| 558 | 565 | } | |
| 559 | 566 | if len(service.Backends) != 1 { | |
| 560 | 567 | return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name) | |
| @@ -173,6 +173,7 @@ const ( | |||||
| 173 | TrafficServiceRefRoute TrafficServiceRefKind = "route" | 173 | TrafficServiceRefRoute TrafficServiceRefKind = "route" | ||
| 174 | TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route" | 174 | TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route" | ||
| 175 | TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service" | 175 | TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service" | ||
| 176 | + | TrafficServiceRefService TrafficServiceRefKind = "service" | |||
| 176 | TrafficServiceRefStatic TrafficServiceRefKind = "static" | 177 | TrafficServiceRefStatic TrafficServiceRefKind = "static" | ||
| 177 | ) | 178 | ) | ||
| 178 | 179 | ||||
| @@ -200,6 +201,12 @@ func ParseTrafficServiceRef(value string) (TrafficServiceRef, error) { | |||||
| 200 | return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value) | 201 | return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value) | ||
| 201 | } | 202 | } | ||
| 202 | return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil | 203 | return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil | ||
| 204 | + | case TrafficServiceRefService: | |||
| 205 | + | name, portName, ok := strings.Cut(rest, ":") | |||
| 206 | + | if !ok || name == "" || portName == "" || strings.Contains(portName, ":") { | |||
| 207 | + | return TrafficServiceRef{}, fmt.Errorf("invalid service ref %q", value) | |||
| 208 | + | } | |||
| 209 | + | return TrafficServiceRef{Kind: TrafficServiceRefService, Name: name, PortName: portName}, nil | |||
| 203 | case TrafficServiceRefStatic: | 210 | case TrafficServiceRefStatic: | ||
| 204 | return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil | 211 | return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil | ||
| 205 | default: | 212 | default: | ||
| @@ -550,11 +557,11 @@ func validateL4RouterService(router TrafficRouter, ref TrafficServiceRef, servic | |||||
| 550 | if !ok { | 557 | if !ok { | ||
| 551 | return nil | 558 | return nil | ||
| 552 | } | 559 | } | ||
| 553 | - | if ref.Kind != TrafficServiceRefNetworkService { | 560 | + | if ref.Kind != TrafficServiceRefNetworkService && ref.Kind != TrafficServiceRefService { |
| 554 | if ref.Kind == TrafficServiceRefStatic { | 561 | if ref.Kind == TrafficServiceRefStatic { | ||
| 555 | return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service) | 562 | return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service) | ||
| 556 | } | 563 | } | ||
| 557 | - | return fmt.Errorf("l4 router %q requires network_service service ref", router.Name) | 564 | + | return fmt.Errorf("l4 router %q requires network_service or service service ref", router.Name) |
| 558 | } | 565 | } | ||
| 559 | if len(service.Backends) != 1 { | 566 | if len(service.Backends) != 1 { | ||
| 560 | return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name) | 567 | return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name) | ||
M internal/domain/traffic_test.go +47 -2
| @@ -483,7 +483,7 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) { | |||
| 483 | 483 | name: "l4 router rejects route ref", | |
| 484 | 484 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"}, | |
| 485 | 485 | services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}}, | |
| 486 | - | wantErr: "requires network_service service ref", | |
| 486 | + | wantErr: "requires network_service or service service ref", | |
| 487 | 487 | }, | |
| 488 | 488 | { | |
| 489 | 489 | name: "static ref parses but graph validation rejects unsupported", | |
| @@ -509,6 +509,49 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) { | |||
| 509 | 509 | } | |
| 510 | 510 | } | |
| 511 | 511 | ||
| 512 | + | func TestTrafficGraphValidateL4ServiceRefs(t *testing.T) { | |
| 513 | + | tests := []struct { | |
| 514 | + | name string | |
| 515 | + | routerProtocol RouterProtocol | |
| 516 | + | entryPoint EntryPoint | |
| 517 | + | backend TrafficBackend | |
| 518 | + | }{ | |
| 519 | + | { | |
| 520 | + | name: "tcp allows service ref with tcp backend", | |
| 521 | + | routerProtocol: RouterProtocolTCP, | |
| 522 | + | entryPoint: EntryPoint{Name: "tcp", Address: ":25565", Protocol: EntryPointProtocolTCP}, | |
| 523 | + | backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolTCP}, | |
| 524 | + | }, | |
| 525 | + | { | |
| 526 | + | name: "udp allows service ref with udp backend", | |
| 527 | + | routerProtocol: RouterProtocolUDP, | |
| 528 | + | entryPoint: EntryPoint{Name: "udp", Address: ":25565", Protocol: EntryPointProtocolUDP}, | |
| 529 | + | backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP}, | |
| 530 | + | }, | |
| 531 | + | { | |
| 532 | + | name: "tls passthrough allows service ref with tcp backend", | |
| 533 | + | routerProtocol: RouterProtocolTLSPassthrough, | |
| 534 | + | entryPoint: EntryPoint{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux}, | |
| 535 | + | backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}, | |
| 536 | + | }, | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | for _, tt := range tests { | |
| 540 | + | t.Run(tt.name, func(t *testing.T) { | |
| 541 | + | router := TrafficRouter{Name: "router", EntryPoint: tt.entryPoint.Name, Protocol: tt.routerProtocol, Service: "service:rust:game"} | |
| 542 | + | if tt.routerProtocol == RouterProtocolTLSPassthrough { | |
| 543 | + | router.Rule = TrafficRule{SNI: "rust.example.com"} | |
| 544 | + | } | |
| 545 | + | graph := TrafficGraph{ | |
| 546 | + | EntryPoints: []EntryPoint{tt.entryPoint}, | |
| 547 | + | Routers: []TrafficRouter{router}, | |
| 548 | + | Services: []TrafficService{{Name: "service:rust:game", Backends: []TrafficBackend{tt.backend}}}, | |
| 549 | + | } | |
| 550 | + | require.NoError(t, graph.Validate()) | |
| 551 | + | }) | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | ||
| 512 | 555 | func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) { | |
| 513 | 556 | tests := []struct { | |
| 514 | 557 | name string | |
| @@ -518,6 +561,7 @@ func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) { | |||
| 518 | 561 | {name: "http allows route", service: "route:app.example.com"}, | |
| 519 | 562 | {name: "http allows external_route", service: "external_route:api.example.com"}, | |
| 520 | 563 | {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"}, | |
| 564 | + | {name: "http rejects service", service: "service:app:http", wantErr: "does not support service ref kind service"}, | |
| 521 | 565 | {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"}, | |
| 522 | 566 | } | |
| 523 | 567 | ||
| @@ -575,6 +619,7 @@ func TestParseTrafficServiceRef(t *testing.T) { | |||
| 575 | 619 | {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}}, | |
| 576 | 620 | {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}}, | |
| 577 | 621 | {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}}, | |
| 622 | + | {input: "service:rust:game", want: TrafficServiceRef{Kind: TrafficServiceRefService, Name: "rust", PortName: "game"}}, | |
| 578 | 623 | {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}}, | |
| 579 | 624 | } | |
| 580 | 625 | ||
| @@ -586,7 +631,7 @@ func TestParseTrafficServiceRef(t *testing.T) { | |||
| 586 | 631 | }) | |
| 587 | 632 | } | |
| 588 | 633 | ||
| 589 | - | invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "static:", "unknown:value"} | |
| 634 | + | invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "service:", "service:rust", "service:rust:", "service::game", "service:rust:game:extra", "static:", "unknown:value"} | |
| 590 | 635 | for _, input := range invalid { | |
| 591 | 636 | t.Run("invalid "+input, func(t *testing.T) { | |
| 592 | 637 | _, err := ParseTrafficServiceRef(input) | |
| @@ -483,7 +483,7 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) { | |||||
| 483 | name: "l4 router rejects route ref", | 483 | name: "l4 router rejects route ref", | ||
| 484 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"}, | 484 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"}, | ||
| 485 | services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}}, | 485 | services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}}, | ||
| 486 | - | wantErr: "requires network_service service ref", | 486 | + | wantErr: "requires network_service or service service ref", |
| 487 | }, | 487 | }, | ||
| 488 | { | 488 | { | ||
| 489 | name: "static ref parses but graph validation rejects unsupported", | 489 | name: "static ref parses but graph validation rejects unsupported", | ||
| @@ -509,6 +509,49 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) { | |||||
| 509 | } | 509 | } | ||
| 510 | } | 510 | } | ||
| 511 | 511 | ||||
| 512 | + | func TestTrafficGraphValidateL4ServiceRefs(t *testing.T) { | |||
| 513 | + | tests := []struct { | |||
| 514 | + | name string | |||
| 515 | + | routerProtocol RouterProtocol | |||
| 516 | + | entryPoint EntryPoint | |||
| 517 | + | backend TrafficBackend | |||
| 518 | + | }{ | |||
| 519 | + | { | |||
| 520 | + | name: "tcp allows service ref with tcp backend", | |||
| 521 | + | routerProtocol: RouterProtocolTCP, | |||
| 522 | + | entryPoint: EntryPoint{Name: "tcp", Address: ":25565", Protocol: EntryPointProtocolTCP}, | |||
| 523 | + | backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolTCP}, | |||
| 524 | + | }, | |||
| 525 | + | { | |||
| 526 | + | name: "udp allows service ref with udp backend", | |||
| 527 | + | routerProtocol: RouterProtocolUDP, | |||
| 528 | + | entryPoint: EntryPoint{Name: "udp", Address: ":25565", Protocol: EntryPointProtocolUDP}, | |||
| 529 | + | backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP}, | |||
| 530 | + | }, | |||
| 531 | + | { | |||
| 532 | + | name: "tls passthrough allows service ref with tcp backend", | |||
| 533 | + | routerProtocol: RouterProtocolTLSPassthrough, | |||
| 534 | + | entryPoint: EntryPoint{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux}, | |||
| 535 | + | backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}, | |||
| 536 | + | }, | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | for _, tt := range tests { | |||
| 540 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 541 | + | router := TrafficRouter{Name: "router", EntryPoint: tt.entryPoint.Name, Protocol: tt.routerProtocol, Service: "service:rust:game"} | |||
| 542 | + | if tt.routerProtocol == RouterProtocolTLSPassthrough { | |||
| 543 | + | router.Rule = TrafficRule{SNI: "rust.example.com"} | |||
| 544 | + | } | |||
| 545 | + | graph := TrafficGraph{ | |||
| 546 | + | EntryPoints: []EntryPoint{tt.entryPoint}, | |||
| 547 | + | Routers: []TrafficRouter{router}, | |||
| 548 | + | Services: []TrafficService{{Name: "service:rust:game", Backends: []TrafficBackend{tt.backend}}}, | |||
| 549 | + | } | |||
| 550 | + | require.NoError(t, graph.Validate()) | |||
| 551 | + | }) | |||
| 552 | + | } | |||
| 553 | + | } | |||
| 554 | + | ||||
| 512 | func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) { | 555 | func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) { | ||
| 513 | tests := []struct { | 556 | tests := []struct { | ||
| 514 | name string | 557 | name string | ||
| @@ -518,6 +561,7 @@ func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) { | |||||
| 518 | {name: "http allows route", service: "route:app.example.com"}, | 561 | {name: "http allows route", service: "route:app.example.com"}, | ||
| 519 | {name: "http allows external_route", service: "external_route:api.example.com"}, | 562 | {name: "http allows external_route", service: "external_route:api.example.com"}, | ||
| 520 | {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"}, | 563 | {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"}, | ||
| 564 | + | {name: "http rejects service", service: "service:app:http", wantErr: "does not support service ref kind service"}, | |||
| 521 | {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"}, | 565 | {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"}, | ||
| 522 | } | 566 | } | ||
| 523 | 567 | ||||
| @@ -575,6 +619,7 @@ func TestParseTrafficServiceRef(t *testing.T) { | |||||
| 575 | {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}}, | 619 | {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}}, | ||
| 576 | {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}}, | 620 | {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}}, | ||
| 577 | {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}}, | 621 | {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}}, | ||
| 622 | + | {input: "service:rust:game", want: TrafficServiceRef{Kind: TrafficServiceRefService, Name: "rust", PortName: "game"}}, | |||
| 578 | {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}}, | 623 | {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}}, | ||
| 579 | } | 624 | } | ||
| 580 | 625 | ||||
| @@ -586,7 +631,7 @@ func TestParseTrafficServiceRef(t *testing.T) { | |||||
| 586 | }) | 631 | }) | ||
| 587 | } | 632 | } | ||
| 588 | 633 | ||||
| 589 | - | invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "static:", "unknown:value"} | 634 | + | invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "service:", "service:rust", "service:rust:", "service::game", "service:rust:game:extra", "static:", "unknown:value"} |
| 590 | for _, input := range invalid { | 635 | for _, input := range invalid { | ||
| 591 | t.Run("invalid "+input, func(t *testing.T) { | 636 | t.Run("invalid "+input, func(t *testing.T) { | ||
| 592 | _, err := ParseTrafficServiceRef(input) | 637 | _, err := ParseTrafficServiceRef(input) | ||
M internal/usecase/services/config.go +2 -0
| @@ -27,6 +27,7 @@ type PortConfig struct { | |||
| 27 | 27 | Protocol domain.NetworkProtocol `mapstructure:"protocol"` | |
| 28 | 28 | Publish string `mapstructure:"publish"` | |
| 29 | 29 | Private bool `mapstructure:"private"` | |
| 30 | + | Public bool `mapstructure:"public"` | |
| 30 | 31 | TrustedCIDRs []string `mapstructure:"trusted_cidrs"` | |
| 31 | 32 | } | |
| 32 | 33 | ||
| @@ -141,6 +142,7 @@ func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort { | |||
| 141 | 142 | Protocol: cfg.Protocol, | |
| 142 | 143 | Publish: cfg.Publish, | |
| 143 | 144 | Private: cfg.Private, | |
| 145 | + | Public: cfg.Public, | |
| 144 | 146 | TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...), | |
| 145 | 147 | }) | |
| 146 | 148 | } | |
| @@ -27,6 +27,7 @@ type PortConfig struct { | |||||
| 27 | Protocol domain.NetworkProtocol `mapstructure:"protocol"` | 27 | Protocol domain.NetworkProtocol `mapstructure:"protocol"` | ||
| 28 | Publish string `mapstructure:"publish"` | 28 | Publish string `mapstructure:"publish"` | ||
| 29 | Private bool `mapstructure:"private"` | 29 | Private bool `mapstructure:"private"` | ||
| 30 | + | Public bool `mapstructure:"public"` | |||
| 30 | TrustedCIDRs []string `mapstructure:"trusted_cidrs"` | 31 | TrustedCIDRs []string `mapstructure:"trusted_cidrs"` | ||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| @@ -141,6 +142,7 @@ func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort { | |||||
| 141 | Protocol: cfg.Protocol, | 142 | Protocol: cfg.Protocol, | ||
| 142 | Publish: cfg.Publish, | 143 | Publish: cfg.Publish, | ||
| 143 | Private: cfg.Private, | 144 | Private: cfg.Private, | ||
| 145 | + | Public: cfg.Public, | |||
| 144 | TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...), | 146 | TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...), | ||
| 145 | }) | 147 | }) | ||
| 146 | } | 148 | } | ||
M internal/usecase/services/config_test.go +21 -0
| @@ -41,6 +41,27 @@ func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) { | |||
| 41 | 41 | } | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) { | |
| 45 | + | cfg := Config{ | |
| 46 | + | Name: "game", | |
| 47 | + | Image: "game:latest", | |
| 48 | + | Enabled: true, | |
| 49 | + | Ports: []PortConfig{{ | |
| 50 | + | Name: "rcon", | |
| 51 | + | Container: 28016, | |
| 52 | + | Protocol: domain.NetworkProtocolTCP, | |
| 53 | + | Publish: "127.0.0.1:38016", | |
| 54 | + | Public: true, | |
| 55 | + | }}, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | svc, err := cfg.ToDomain() | |
| 59 | + | ||
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Len(t, svc.Ports, 1) | |
| 62 | + | assert.True(t, svc.Ports[0].Public) | |
| 63 | + | } | |
| 64 | + | ||
| 44 | 65 | func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) { | |
| 45 | 66 | explicit := []domain.StandaloneServiceVolume{ | |
| 46 | 67 | {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false}, | |
| @@ -41,6 +41,27 @@ func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) { | |||||
| 41 | } | 41 | } | ||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | + | func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) { | |||
| 45 | + | cfg := Config{ | |||
| 46 | + | Name: "game", | |||
| 47 | + | Image: "game:latest", | |||
| 48 | + | Enabled: true, | |||
| 49 | + | Ports: []PortConfig{{ | |||
| 50 | + | Name: "rcon", | |||
| 51 | + | Container: 28016, | |||
| 52 | + | Protocol: domain.NetworkProtocolTCP, | |||
| 53 | + | Publish: "127.0.0.1:38016", | |||
| 54 | + | Public: true, | |||
| 55 | + | }}, | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | svc, err := cfg.ToDomain() | |||
| 59 | + | ||||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.Len(t, svc.Ports, 1) | |||
| 62 | + | assert.True(t, svc.Ports[0].Public) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 44 | func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) { | 65 | func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) { | ||
| 45 | explicit := []domain.StandaloneServiceVolume{ | 66 | explicit := []domain.StandaloneServiceVolume{ | ||
| 46 | {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false}, | 67 | {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false}, | ||
M internal/usecase/traffic/builder.go +118 -8
| @@ -20,6 +20,7 @@ type Input struct { | |||
| 20 | 20 | Routes []domain.Route | |
| 21 | 21 | ExternalRoutes map[string]string | |
| 22 | 22 | NetworkServices []NetworkServiceConfig | |
| 23 | + | Services []domain.StandaloneService | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | type EntryPointConfig struct { | |
| @@ -89,6 +90,9 @@ func Build(input Input) (domain.TrafficGraph, error) { | |||
| 89 | 90 | if err := validateNetworkServices(input.NetworkServices); err != nil { | |
| 90 | 91 | return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err) | |
| 91 | 92 | } | |
| 93 | + | if err := validateStandaloneServices(input.Services); err != nil { | |
| 94 | + | return domain.TrafficGraph{}, fmt.Errorf("validate standalone services: %w", err) | |
| 95 | + | } | |
| 92 | 96 | graph.Options = options | |
| 93 | 97 | graph.EntryPoints = b.buildEntryPoints() | |
| 94 | 98 | ||
| @@ -219,7 +223,7 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi | |||
| 219 | 223 | ordered := append([]RouterConfig{}, routers...) | |
| 220 | 224 | sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name }) | |
| 221 | 225 | for _, cfg := range ordered { | |
| 222 | - | service, err := b.resolveService(cfg.Service, backendProtocol) | |
| 226 | + | service, err := b.resolveService(cfg, backendProtocol) | |
| 223 | 227 | if err != nil { | |
| 224 | 228 | return fmt.Errorf("router %q: %w", cfg.Name, err) | |
| 225 | 229 | } | |
| @@ -229,17 +233,24 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi | |||
| 229 | 233 | return nil | |
| 230 | 234 | } | |
| 231 | 235 | ||
| 232 | - | func (b *builder) resolveService(refValue string, protocol domain.NetworkProtocol) (domain.TrafficService, error) { | |
| 233 | - | ref, err := domain.ParseTrafficServiceRef(refValue) | |
| 236 | + | func (b *builder) resolveService(router RouterConfig, protocol domain.NetworkProtocol) (domain.TrafficService, error) { | |
| 237 | + | ref, err := domain.ParseTrafficServiceRef(router.Service) | |
| 234 | 238 | if err != nil { | |
| 235 | 239 | return domain.TrafficService{}, err | |
| 236 | 240 | } | |
| 237 | - | if ref.Kind == domain.TrafficServiceRefStatic { | |
| 238 | - | return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", refValue) | |
| 239 | - | } | |
| 240 | - | if ref.Kind != domain.TrafficServiceRefNetworkService { | |
| 241 | - | return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name>", refValue) | |
| 241 | + | switch ref.Kind { | |
| 242 | + | case domain.TrafficServiceRefNetworkService: | |
| 243 | + | return b.resolveNetworkService(router.Service, ref, protocol) | |
| 244 | + | case domain.TrafficServiceRefService: | |
| 245 | + | return b.resolveStandaloneService(router, ref, protocol) | |
| 246 | + | case domain.TrafficServiceRefStatic: | |
| 247 | + | return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", router.Service) | |
| 248 | + | default: | |
| 249 | + | return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name> or service:<name>:<port-name>", router.Service) | |
| 242 | 250 | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) { | |
| 243 | 254 | ns, ok := b.networkService(ref.Name) | |
| 244 | 255 | if !ok { | |
| 245 | 256 | return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name) | |
| @@ -257,6 +268,47 @@ func (b *builder) resolveService(refValue string, protocol domain.NetworkProtoco | |||
| 257 | 268 | return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil | |
| 258 | 269 | } | |
| 259 | 270 | ||
| 271 | + | func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) { | |
| 272 | + | svc, ok := b.standaloneService(ref.Name) | |
| 273 | + | if !ok { | |
| 274 | + | return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name) | |
| 275 | + | } | |
| 276 | + | port, ok := standaloneServicePort(svc, ref.PortName) | |
| 277 | + | if !ok { | |
| 278 | + | return domain.TrafficService{}, fmt.Errorf("unknown port %q on service %q", ref.PortName, ref.Name) | |
| 279 | + | } | |
| 280 | + | if port.Protocol != protocol { | |
| 281 | + | return domain.TrafficService{}, fmt.Errorf("service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol) | |
| 282 | + | } | |
| 283 | + | if isPrivateStandalonePort(port) { | |
| 284 | + | if err := b.validatePrivateStandalonePortRoute(router, svc, port); err != nil { | |
| 285 | + | return domain.TrafficService{}, err | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | host, backendPort, err := parseBackendAddress(port.Publish) | |
| 289 | + | if err != nil { | |
| 290 | + | return domain.TrafficService{}, fmt.Errorf("service %q port %q publish address %q is invalid: %w", ref.Name, ref.PortName, port.Publish, err) | |
| 291 | + | } | |
| 292 | + | return domain.TrafficService{Name: router.Service, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: host, Port: backendPort, Protocol: port.Protocol}}}, nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func validateStandaloneServices(services []domain.StandaloneService) error { | |
| 296 | + | seen := map[string]struct{}{} | |
| 297 | + | for _, service := range services { | |
| 298 | + | if service.Name == "" { | |
| 299 | + | return fmt.Errorf("standalone service name is required") | |
| 300 | + | } | |
| 301 | + | if _, exists := seen[service.Name]; exists { | |
| 302 | + | return fmt.Errorf("duplicate standalone service %q", service.Name) | |
| 303 | + | } | |
| 304 | + | seen[service.Name] = struct{}{} | |
| 305 | + | if err := service.Validate(); err != nil { | |
| 306 | + | return err | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | return nil | |
| 310 | + | } | |
| 311 | + | ||
| 260 | 312 | func validateNetworkServices(services []NetworkServiceConfig) error { | |
| 261 | 313 | seen := map[string]struct{}{} | |
| 262 | 314 | for _, service := range services { | |
| @@ -312,6 +364,15 @@ func (b *builder) networkService(name string) (NetworkServiceConfig, bool) { | |||
| 312 | 364 | return NetworkServiceConfig{}, false | |
| 313 | 365 | } | |
| 314 | 366 | ||
| 367 | + | func (b *builder) standaloneService(name string) (domain.StandaloneService, bool) { | |
| 368 | + | for _, service := range b.input.Services { | |
| 369 | + | if service.Name == name { | |
| 370 | + | return service, true | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | return domain.StandaloneService{}, false | |
| 374 | + | } | |
| 375 | + | ||
| 315 | 376 | func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) { | |
| 316 | 377 | for _, port := range service.Ports { | |
| 317 | 378 | if port.Name == name { | |
| @@ -321,6 +382,55 @@ func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, | |||
| 321 | 382 | return PortConfig{}, false | |
| 322 | 383 | } | |
| 323 | 384 | ||
| 385 | + | func standaloneServicePort(service domain.StandaloneService, name string) (domain.StandaloneServicePort, bool) { | |
| 386 | + | for _, port := range service.Ports { | |
| 387 | + | if port.Name == name { | |
| 388 | + | return port, true | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | return domain.StandaloneServicePort{}, false | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func isPrivateStandalonePort(port domain.StandaloneServicePort) bool { | |
| 395 | + | if port.Public { | |
| 396 | + | return false | |
| 397 | + | } | |
| 398 | + | return port.Private || strings.EqualFold(port.Name, "rcon") | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func (b *builder) validatePrivateStandalonePortRoute(router RouterConfig, service domain.StandaloneService, port domain.StandaloneServicePort) error { | |
| 402 | + | entryPoint, ok := b.input.EntryPoints[router.EntryPoint] | |
| 403 | + | if !ok { | |
| 404 | + | return fmt.Errorf("private service %q port %q router %q references unknown entrypoint %q", service.Name, port.Name, router.Name, router.EntryPoint) | |
| 405 | + | } | |
| 406 | + | if len(port.TrustedCIDRs) == 0 { | |
| 407 | + | return fmt.Errorf("private service %q port %q router %q requires non-empty service port trusted_cidrs", service.Name, port.Name, router.Name) | |
| 408 | + | } | |
| 409 | + | if len(entryPoint.TrustedCIDRs) == 0 { | |
| 410 | + | return fmt.Errorf("private service %q port %q router %q entrypoint %q requires non-empty trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint) | |
| 411 | + | } | |
| 412 | + | if !sameCIDRSet(port.TrustedCIDRs, entryPoint.TrustedCIDRs) { | |
| 413 | + | return fmt.Errorf("private service %q port %q router %q entrypoint %q trusted_cidrs must exactly match service port trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint) | |
| 414 | + | } | |
| 415 | + | return nil | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func sameCIDRSet(a, b []string) bool { | |
| 419 | + | if len(a) != len(b) { | |
| 420 | + | return false | |
| 421 | + | } | |
| 422 | + | left := append([]string(nil), a...) | |
| 423 | + | right := append([]string(nil), b...) | |
| 424 | + | sort.Strings(left) | |
| 425 | + | sort.Strings(right) | |
| 426 | + | for i := range left { | |
| 427 | + | if left[i] != right[i] { | |
| 428 | + | return false | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | return true | |
| 432 | + | } | |
| 433 | + | ||
| 324 | 434 | func (b *builder) addService(service domain.TrafficService) { | |
| 325 | 435 | if _, exists := b.services[service.Name]; !exists { | |
| 326 | 436 | b.services[service.Name] = service | |
| @@ -20,6 +20,7 @@ type Input struct { | |||||
| 20 | Routes []domain.Route | 20 | Routes []domain.Route | ||
| 21 | ExternalRoutes map[string]string | 21 | ExternalRoutes map[string]string | ||
| 22 | NetworkServices []NetworkServiceConfig | 22 | NetworkServices []NetworkServiceConfig | ||
| 23 | + | Services []domain.StandaloneService | |||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | type EntryPointConfig struct { | 26 | type EntryPointConfig struct { | ||
| @@ -89,6 +90,9 @@ func Build(input Input) (domain.TrafficGraph, error) { | |||||
| 89 | if err := validateNetworkServices(input.NetworkServices); err != nil { | 90 | if err := validateNetworkServices(input.NetworkServices); err != nil { | ||
| 90 | return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err) | 91 | return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err) | ||
| 91 | } | 92 | } | ||
| 93 | + | if err := validateStandaloneServices(input.Services); err != nil { | |||
| 94 | + | return domain.TrafficGraph{}, fmt.Errorf("validate standalone services: %w", err) | |||
| 95 | + | } | |||
| 92 | graph.Options = options | 96 | graph.Options = options | ||
| 93 | graph.EntryPoints = b.buildEntryPoints() | 97 | graph.EntryPoints = b.buildEntryPoints() | ||
| 94 | 98 | ||||
| @@ -219,7 +223,7 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi | |||||
| 219 | ordered := append([]RouterConfig{}, routers...) | 223 | ordered := append([]RouterConfig{}, routers...) | ||
| 220 | sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name }) | 224 | sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name }) | ||
| 221 | for _, cfg := range ordered { | 225 | for _, cfg := range ordered { | ||
| 222 | - | service, err := b.resolveService(cfg.Service, backendProtocol) | 226 | + | service, err := b.resolveService(cfg, backendProtocol) |
| 223 | if err != nil { | 227 | if err != nil { | ||
| 224 | return fmt.Errorf("router %q: %w", cfg.Name, err) | 228 | return fmt.Errorf("router %q: %w", cfg.Name, err) | ||
| 225 | } | 229 | } | ||
| @@ -229,17 +233,24 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi | |||||
| 229 | return nil | 233 | return nil | ||
| 230 | } | 234 | } | ||
| 231 | 235 | ||||
| 232 | - | func (b *builder) resolveService(refValue string, protocol domain.NetworkProtocol) (domain.TrafficService, error) { | 236 | + | func (b *builder) resolveService(router RouterConfig, protocol domain.NetworkProtocol) (domain.TrafficService, error) { |
| 233 | - | ref, err := domain.ParseTrafficServiceRef(refValue) | 237 | + | ref, err := domain.ParseTrafficServiceRef(router.Service) |
| 234 | if err != nil { | 238 | if err != nil { | ||
| 235 | return domain.TrafficService{}, err | 239 | return domain.TrafficService{}, err | ||
| 236 | } | 240 | } | ||
| 237 | - | if ref.Kind == domain.TrafficServiceRefStatic { | 241 | + | switch ref.Kind { |
| 238 | - | return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", refValue) | 242 | + | case domain.TrafficServiceRefNetworkService: |
| 239 | - | } | 243 | + | return b.resolveNetworkService(router.Service, ref, protocol) |
| 240 | - | if ref.Kind != domain.TrafficServiceRefNetworkService { | 244 | + | case domain.TrafficServiceRefService: |
| 241 | - | return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name>", refValue) | 245 | + | return b.resolveStandaloneService(router, ref, protocol) |
| 246 | + | case domain.TrafficServiceRefStatic: | |||
| 247 | + | return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", router.Service) | |||
| 248 | + | default: | |||
| 249 | + | return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name> or service:<name>:<port-name>", router.Service) | |||
| 242 | } | 250 | } | ||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) { | |||
| 243 | ns, ok := b.networkService(ref.Name) | 254 | ns, ok := b.networkService(ref.Name) | ||
| 244 | if !ok { | 255 | if !ok { | ||
| 245 | return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name) | 256 | return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name) | ||
| @@ -257,6 +268,47 @@ func (b *builder) resolveService(refValue string, protocol domain.NetworkProtoco | |||||
| 257 | return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil | 268 | return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil | ||
| 258 | } | 269 | } | ||
| 259 | 270 | ||||
| 271 | + | func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) { | |||
| 272 | + | svc, ok := b.standaloneService(ref.Name) | |||
| 273 | + | if !ok { | |||
| 274 | + | return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name) | |||
| 275 | + | } | |||
| 276 | + | port, ok := standaloneServicePort(svc, ref.PortName) | |||
| 277 | + | if !ok { | |||
| 278 | + | return domain.TrafficService{}, fmt.Errorf("unknown port %q on service %q", ref.PortName, ref.Name) | |||
| 279 | + | } | |||
| 280 | + | if port.Protocol != protocol { | |||
| 281 | + | return domain.TrafficService{}, fmt.Errorf("service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol) | |||
| 282 | + | } | |||
| 283 | + | if isPrivateStandalonePort(port) { | |||
| 284 | + | if err := b.validatePrivateStandalonePortRoute(router, svc, port); err != nil { | |||
| 285 | + | return domain.TrafficService{}, err | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | host, backendPort, err := parseBackendAddress(port.Publish) | |||
| 289 | + | if err != nil { | |||
| 290 | + | return domain.TrafficService{}, fmt.Errorf("service %q port %q publish address %q is invalid: %w", ref.Name, ref.PortName, port.Publish, err) | |||
| 291 | + | } | |||
| 292 | + | return domain.TrafficService{Name: router.Service, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: host, Port: backendPort, Protocol: port.Protocol}}}, nil | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func validateStandaloneServices(services []domain.StandaloneService) error { | |||
| 296 | + | seen := map[string]struct{}{} | |||
| 297 | + | for _, service := range services { | |||
| 298 | + | if service.Name == "" { | |||
| 299 | + | return fmt.Errorf("standalone service name is required") | |||
| 300 | + | } | |||
| 301 | + | if _, exists := seen[service.Name]; exists { | |||
| 302 | + | return fmt.Errorf("duplicate standalone service %q", service.Name) | |||
| 303 | + | } | |||
| 304 | + | seen[service.Name] = struct{}{} | |||
| 305 | + | if err := service.Validate(); err != nil { | |||
| 306 | + | return err | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | return nil | |||
| 310 | + | } | |||
| 311 | + | ||||
| 260 | func validateNetworkServices(services []NetworkServiceConfig) error { | 312 | func validateNetworkServices(services []NetworkServiceConfig) error { | ||
| 261 | seen := map[string]struct{}{} | 313 | seen := map[string]struct{}{} | ||
| 262 | for _, service := range services { | 314 | for _, service := range services { | ||
| @@ -312,6 +364,15 @@ func (b *builder) networkService(name string) (NetworkServiceConfig, bool) { | |||||
| 312 | return NetworkServiceConfig{}, false | 364 | return NetworkServiceConfig{}, false | ||
| 313 | } | 365 | } | ||
| 314 | 366 | ||||
| 367 | + | func (b *builder) standaloneService(name string) (domain.StandaloneService, bool) { | |||
| 368 | + | for _, service := range b.input.Services { | |||
| 369 | + | if service.Name == name { | |||
| 370 | + | return service, true | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | return domain.StandaloneService{}, false | |||
| 374 | + | } | |||
| 375 | + | ||||
| 315 | func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) { | 376 | func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) { | ||
| 316 | for _, port := range service.Ports { | 377 | for _, port := range service.Ports { | ||
| 317 | if port.Name == name { | 378 | if port.Name == name { | ||
| @@ -321,6 +382,55 @@ func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, | |||||
| 321 | return PortConfig{}, false | 382 | return PortConfig{}, false | ||
| 322 | } | 383 | } | ||
| 323 | 384 | ||||
| 385 | + | func standaloneServicePort(service domain.StandaloneService, name string) (domain.StandaloneServicePort, bool) { | |||
| 386 | + | for _, port := range service.Ports { | |||
| 387 | + | if port.Name == name { | |||
| 388 | + | return port, true | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | return domain.StandaloneServicePort{}, false | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func isPrivateStandalonePort(port domain.StandaloneServicePort) bool { | |||
| 395 | + | if port.Public { | |||
| 396 | + | return false | |||
| 397 | + | } | |||
| 398 | + | return port.Private || strings.EqualFold(port.Name, "rcon") | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func (b *builder) validatePrivateStandalonePortRoute(router RouterConfig, service domain.StandaloneService, port domain.StandaloneServicePort) error { | |||
| 402 | + | entryPoint, ok := b.input.EntryPoints[router.EntryPoint] | |||
| 403 | + | if !ok { | |||
| 404 | + | return fmt.Errorf("private service %q port %q router %q references unknown entrypoint %q", service.Name, port.Name, router.Name, router.EntryPoint) | |||
| 405 | + | } | |||
| 406 | + | if len(port.TrustedCIDRs) == 0 { | |||
| 407 | + | return fmt.Errorf("private service %q port %q router %q requires non-empty service port trusted_cidrs", service.Name, port.Name, router.Name) | |||
| 408 | + | } | |||
| 409 | + | if len(entryPoint.TrustedCIDRs) == 0 { | |||
| 410 | + | return fmt.Errorf("private service %q port %q router %q entrypoint %q requires non-empty trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint) | |||
| 411 | + | } | |||
| 412 | + | if !sameCIDRSet(port.TrustedCIDRs, entryPoint.TrustedCIDRs) { | |||
| 413 | + | return fmt.Errorf("private service %q port %q router %q entrypoint %q trusted_cidrs must exactly match service port trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint) | |||
| 414 | + | } | |||
| 415 | + | return nil | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | func sameCIDRSet(a, b []string) bool { | |||
| 419 | + | if len(a) != len(b) { | |||
| 420 | + | return false | |||
| 421 | + | } | |||
| 422 | + | left := append([]string(nil), a...) | |||
| 423 | + | right := append([]string(nil), b...) | |||
| 424 | + | sort.Strings(left) | |||
| 425 | + | sort.Strings(right) | |||
| 426 | + | for i := range left { | |||
| 427 | + | if left[i] != right[i] { | |||
| 428 | + | return false | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 431 | + | return true | |||
| 432 | + | } | |||
| 433 | + | ||||
| 324 | func (b *builder) addService(service domain.TrafficService) { | 434 | func (b *builder) addService(service domain.TrafficService) { | ||
| 325 | if _, exists := b.services[service.Name]; !exists { | 435 | if _, exists := b.services[service.Name]; !exists { | ||
| 326 | b.services[service.Name] = service | 436 | b.services[service.Name] = service | ||
M internal/usecase/traffic/builder_test.go +144 -0
| @@ -207,6 +207,150 @@ func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | |||
| 207 | 207 | }) | |
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | + | func TestBuildResolvesStandaloneServiceBackend(t *testing.T) { | |
| 211 | + | graph, err := Build(Input{ | |
| 212 | + | EntryPoints: map[string]EntryPointConfig{"udp": {Address: ":28015", Protocol: domain.EntryPointProtocolUDP}}, | |
| 213 | + | Traffic: Config{UDP: UDPConfig{Routers: []RouterConfig{{Name: "rust-game", EntryPoint: "udp", Service: "service:rust:game"}}}}, | |
| 214 | + | Services: []domain.StandaloneService{{ | |
| 215 | + | Name: "rust", | |
| 216 | + | Image: "localhost/rust:latest", | |
| 217 | + | Enabled: true, | |
| 218 | + | Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}}, | |
| 219 | + | }}, | |
| 220 | + | }) | |
| 221 | + | require.NoError(t, err) | |
| 222 | + | require.Contains(t, graph.Services, domain.TrafficService{Name: "service:rust:game", Backends: []domain.TrafficBackend{{Name: "rust:game", Host: "127.0.0.1", Port: 38015, Protocol: domain.NetworkProtocolUDP}}}) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) { | |
| 226 | + | base := Input{ | |
| 227 | + | EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}}, | |
| 228 | + | Services: []domain.StandaloneService{{ | |
| 229 | + | Name: "rust", | |
| 230 | + | Image: "localhost/rust:latest", | |
| 231 | + | Enabled: true, | |
| 232 | + | Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}, {Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true, TrustedCIDRs: []string{"100.64.0.0/10"}}}, | |
| 233 | + | }}, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | t.Run("unknown service", func(t *testing.T) { | |
| 237 | + | input := base | |
| 238 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:missing:rcon"}}}} | |
| 239 | + | _, err := Build(input) | |
| 240 | + | require.ErrorContains(t, err, "router \"bad\": unknown service \"missing\"") | |
| 241 | + | }) | |
| 242 | + | ||
| 243 | + | t.Run("unknown port", func(t *testing.T) { | |
| 244 | + | input := base | |
| 245 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:missing"}}}} | |
| 246 | + | _, err := Build(input) | |
| 247 | + | require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"") | |
| 248 | + | }) | |
| 249 | + | ||
| 250 | + | t.Run("protocol mismatch", func(t *testing.T) { | |
| 251 | + | input := base | |
| 252 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}} | |
| 253 | + | _, err := Build(input) | |
| 254 | + | require.ErrorContains(t, err, "service \"rust\" port \"game\" protocol udp does not match router protocol tcp") | |
| 255 | + | }) | |
| 256 | + | ||
| 257 | + | t.Run("missing publish", func(t *testing.T) { | |
| 258 | + | input := base | |
| 259 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}} | |
| 260 | + | input.Services[0].Ports[1].Publish = "" | |
| 261 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}} | |
| 262 | + | _, err := Build(input) | |
| 263 | + | require.ErrorContains(t, err, "service \"rust\" port \"rcon\" publish address") | |
| 264 | + | }) | |
| 265 | + | ||
| 266 | + | t.Run("invalid publish", func(t *testing.T) { | |
| 267 | + | input := base | |
| 268 | + | input.Services[0].Ports[1].Publish = "127.0.0.1:notaport" | |
| 269 | + | _, err := Build(input) | |
| 270 | + | require.ErrorContains(t, err, "publish address") | |
| 271 | + | }) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) { | |
| 275 | + | base := func() Input { | |
| 276 | + | return Input{ | |
| 277 | + | EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}, | |
| 278 | + | Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "rust-rcon", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}, | |
| 279 | + | Services: []domain.StandaloneService{{ | |
| 280 | + | Name: "rust", | |
| 281 | + | Image: "localhost/rust:latest", | |
| 282 | + | Enabled: true, | |
| 283 | + | Ports: []domain.StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", TrustedCIDRs: []string{"100.64.0.0/10"}}}, | |
| 284 | + | }}, | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | t.Run("rcon defaults private and allows exact trusted cidrs", func(t *testing.T) { | |
| 289 | + | _, err := Build(base()) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | }) | |
| 292 | + | ||
| 293 | + | t.Run("private port rejects service port without trusted cidrs", func(t *testing.T) { | |
| 294 | + | input := base() | |
| 295 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |
| 296 | + | _, err := Build(input) | |
| 297 | + | require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs") | |
| 298 | + | }) | |
| 299 | + | ||
| 300 | + | t.Run("rcon rejects both service and entrypoint without trusted cidrs", func(t *testing.T) { | |
| 301 | + | input := base() | |
| 302 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |
| 303 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |
| 304 | + | _, err := Build(input) | |
| 305 | + | require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs") | |
| 306 | + | }) | |
| 307 | + | ||
| 308 | + | t.Run("explicit private non-rcon rejects service port without trusted cidrs", func(t *testing.T) { | |
| 309 | + | input := base() | |
| 310 | + | input.Services[0].Ports[0].Name = "admin" | |
| 311 | + | input.Services[0].Ports[0].Private = true | |
| 312 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |
| 313 | + | input.Traffic.TCP.Routers[0].Service = "service:rust:admin" | |
| 314 | + | _, err := Build(input) | |
| 315 | + | require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs") | |
| 316 | + | }) | |
| 317 | + | ||
| 318 | + | t.Run("private port rejects entrypoint without trusted cidrs", func(t *testing.T) { | |
| 319 | + | input := base() | |
| 320 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |
| 321 | + | _, err := Build(input) | |
| 322 | + | require.ErrorContains(t, err, "entrypoint \"tcp\" requires non-empty trusted_cidrs") | |
| 323 | + | }) | |
| 324 | + | ||
| 325 | + | t.Run("private port rejects non-matching trusted cidrs", func(t *testing.T) { | |
| 326 | + | input := base() | |
| 327 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}} | |
| 328 | + | _, err := Build(input) | |
| 329 | + | require.ErrorContains(t, err, "must exactly match") | |
| 330 | + | }) | |
| 331 | + | ||
| 332 | + | t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) { | |
| 333 | + | input := base() | |
| 334 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |
| 335 | + | input.Services[0].Ports[0].Public = true | |
| 336 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |
| 337 | + | _, err := Build(input) | |
| 338 | + | require.NoError(t, err) | |
| 339 | + | }) | |
| 340 | + | ||
| 341 | + | t.Run("explicit public non-rcon is allowed without trusted cidrs", func(t *testing.T) { | |
| 342 | + | input := base() | |
| 343 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |
| 344 | + | input.Services[0].Ports[0].Name = "admin" | |
| 345 | + | input.Services[0].Ports[0].Private = false | |
| 346 | + | input.Services[0].Ports[0].Public = true | |
| 347 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |
| 348 | + | input.Traffic.TCP.Routers[0].Service = "service:rust:admin" | |
| 349 | + | _, err := Build(input) | |
| 350 | + | require.NoError(t, err) | |
| 351 | + | }) | |
| 352 | + | } | |
| 353 | + | ||
| 210 | 354 | func TestBuildRejectsInvalidServiceRefs(t *testing.T) { | |
| 211 | 355 | base := Input{ | |
| 212 | 356 | EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}}, | |
| @@ -207,6 +207,150 @@ func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | |||||
| 207 | }) | 207 | }) | ||
| 208 | } | 208 | } | ||
| 209 | 209 | ||||
| 210 | + | func TestBuildResolvesStandaloneServiceBackend(t *testing.T) { | |||
| 211 | + | graph, err := Build(Input{ | |||
| 212 | + | EntryPoints: map[string]EntryPointConfig{"udp": {Address: ":28015", Protocol: domain.EntryPointProtocolUDP}}, | |||
| 213 | + | Traffic: Config{UDP: UDPConfig{Routers: []RouterConfig{{Name: "rust-game", EntryPoint: "udp", Service: "service:rust:game"}}}}, | |||
| 214 | + | Services: []domain.StandaloneService{{ | |||
| 215 | + | Name: "rust", | |||
| 216 | + | Image: "localhost/rust:latest", | |||
| 217 | + | Enabled: true, | |||
| 218 | + | Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}}, | |||
| 219 | + | }}, | |||
| 220 | + | }) | |||
| 221 | + | require.NoError(t, err) | |||
| 222 | + | require.Contains(t, graph.Services, domain.TrafficService{Name: "service:rust:game", Backends: []domain.TrafficBackend{{Name: "rust:game", Host: "127.0.0.1", Port: 38015, Protocol: domain.NetworkProtocolUDP}}}) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) { | |||
| 226 | + | base := Input{ | |||
| 227 | + | EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}}, | |||
| 228 | + | Services: []domain.StandaloneService{{ | |||
| 229 | + | Name: "rust", | |||
| 230 | + | Image: "localhost/rust:latest", | |||
| 231 | + | Enabled: true, | |||
| 232 | + | Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}, {Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true, TrustedCIDRs: []string{"100.64.0.0/10"}}}, | |||
| 233 | + | }}, | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | t.Run("unknown service", func(t *testing.T) { | |||
| 237 | + | input := base | |||
| 238 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:missing:rcon"}}}} | |||
| 239 | + | _, err := Build(input) | |||
| 240 | + | require.ErrorContains(t, err, "router \"bad\": unknown service \"missing\"") | |||
| 241 | + | }) | |||
| 242 | + | ||||
| 243 | + | t.Run("unknown port", func(t *testing.T) { | |||
| 244 | + | input := base | |||
| 245 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:missing"}}}} | |||
| 246 | + | _, err := Build(input) | |||
| 247 | + | require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"") | |||
| 248 | + | }) | |||
| 249 | + | ||||
| 250 | + | t.Run("protocol mismatch", func(t *testing.T) { | |||
| 251 | + | input := base | |||
| 252 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}} | |||
| 253 | + | _, err := Build(input) | |||
| 254 | + | require.ErrorContains(t, err, "service \"rust\" port \"game\" protocol udp does not match router protocol tcp") | |||
| 255 | + | }) | |||
| 256 | + | ||||
| 257 | + | t.Run("missing publish", func(t *testing.T) { | |||
| 258 | + | input := base | |||
| 259 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}} | |||
| 260 | + | input.Services[0].Ports[1].Publish = "" | |||
| 261 | + | input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}} | |||
| 262 | + | _, err := Build(input) | |||
| 263 | + | require.ErrorContains(t, err, "service \"rust\" port \"rcon\" publish address") | |||
| 264 | + | }) | |||
| 265 | + | ||||
| 266 | + | t.Run("invalid publish", func(t *testing.T) { | |||
| 267 | + | input := base | |||
| 268 | + | input.Services[0].Ports[1].Publish = "127.0.0.1:notaport" | |||
| 269 | + | _, err := Build(input) | |||
| 270 | + | require.ErrorContains(t, err, "publish address") | |||
| 271 | + | }) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) { | |||
| 275 | + | base := func() Input { | |||
| 276 | + | return Input{ | |||
| 277 | + | EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}, | |||
| 278 | + | Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "rust-rcon", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}, | |||
| 279 | + | Services: []domain.StandaloneService{{ | |||
| 280 | + | Name: "rust", | |||
| 281 | + | Image: "localhost/rust:latest", | |||
| 282 | + | Enabled: true, | |||
| 283 | + | Ports: []domain.StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", TrustedCIDRs: []string{"100.64.0.0/10"}}}, | |||
| 284 | + | }}, | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | t.Run("rcon defaults private and allows exact trusted cidrs", func(t *testing.T) { | |||
| 289 | + | _, err := Build(base()) | |||
| 290 | + | require.NoError(t, err) | |||
| 291 | + | }) | |||
| 292 | + | ||||
| 293 | + | t.Run("private port rejects service port without trusted cidrs", func(t *testing.T) { | |||
| 294 | + | input := base() | |||
| 295 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |||
| 296 | + | _, err := Build(input) | |||
| 297 | + | require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs") | |||
| 298 | + | }) | |||
| 299 | + | ||||
| 300 | + | t.Run("rcon rejects both service and entrypoint without trusted cidrs", func(t *testing.T) { | |||
| 301 | + | input := base() | |||
| 302 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |||
| 303 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |||
| 304 | + | _, err := Build(input) | |||
| 305 | + | require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs") | |||
| 306 | + | }) | |||
| 307 | + | ||||
| 308 | + | t.Run("explicit private non-rcon rejects service port without trusted cidrs", func(t *testing.T) { | |||
| 309 | + | input := base() | |||
| 310 | + | input.Services[0].Ports[0].Name = "admin" | |||
| 311 | + | input.Services[0].Ports[0].Private = true | |||
| 312 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |||
| 313 | + | input.Traffic.TCP.Routers[0].Service = "service:rust:admin" | |||
| 314 | + | _, err := Build(input) | |||
| 315 | + | require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs") | |||
| 316 | + | }) | |||
| 317 | + | ||||
| 318 | + | t.Run("private port rejects entrypoint without trusted cidrs", func(t *testing.T) { | |||
| 319 | + | input := base() | |||
| 320 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |||
| 321 | + | _, err := Build(input) | |||
| 322 | + | require.ErrorContains(t, err, "entrypoint \"tcp\" requires non-empty trusted_cidrs") | |||
| 323 | + | }) | |||
| 324 | + | ||||
| 325 | + | t.Run("private port rejects non-matching trusted cidrs", func(t *testing.T) { | |||
| 326 | + | input := base() | |||
| 327 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}} | |||
| 328 | + | _, err := Build(input) | |||
| 329 | + | require.ErrorContains(t, err, "must exactly match") | |||
| 330 | + | }) | |||
| 331 | + | ||||
| 332 | + | t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) { | |||
| 333 | + | input := base() | |||
| 334 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |||
| 335 | + | input.Services[0].Ports[0].Public = true | |||
| 336 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |||
| 337 | + | _, err := Build(input) | |||
| 338 | + | require.NoError(t, err) | |||
| 339 | + | }) | |||
| 340 | + | ||||
| 341 | + | t.Run("explicit public non-rcon is allowed without trusted cidrs", func(t *testing.T) { | |||
| 342 | + | input := base() | |||
| 343 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |||
| 344 | + | input.Services[0].Ports[0].Name = "admin" | |||
| 345 | + | input.Services[0].Ports[0].Private = false | |||
| 346 | + | input.Services[0].Ports[0].Public = true | |||
| 347 | + | input.Services[0].Ports[0].TrustedCIDRs = nil | |||
| 348 | + | input.Traffic.TCP.Routers[0].Service = "service:rust:admin" | |||
| 349 | + | _, err := Build(input) | |||
| 350 | + | require.NoError(t, err) | |||
| 351 | + | }) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 210 | func TestBuildRejectsInvalidServiceRefs(t *testing.T) { | 354 | func TestBuildRejectsInvalidServiceRefs(t *testing.T) { | ||
| 211 | base := Input{ | 355 | base := Input{ | ||
| 212 | EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}}, | 356 | EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}}, | ||