v2.31.0 e02f654

controlplane_local_test.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"errors"
6	"testing"
7	"time"
8
9	"github.com/bnema/gordon/internal/adapters/dto"
10	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
11	"github.com/bnema/gordon/internal/domain"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15)
16
17type previewContainerService struct {
18	*inmocks.MockContainerService
19	preview func(context.Context, string) (*domain.CleanupReport, error)
20}
21
22func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
23	if s.preview == nil {
24		return nil, nil
25	}
26	return s.preview(ctx, routeDomain)
27}
28
29func TestLocalControlPlane_GetStatus(t *testing.T) {
30	t.Parallel()
31
32	configSvc := inmocks.NewMockConfigService(t)
33	containerSvc := inmocks.NewMockContainerService(t)
34
35	ctx := context.Background()
36	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}})
37	configSvc.EXPECT().GetRegistryDomain().Return("registry.local")
38	configSvc.EXPECT().GetRegistryPort().Return(5000)
39	configSvc.EXPECT().GetServerPort().Return(80)
40	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
41	configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true)
42	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
43		"app.local": {ID: "abc123", Status: "running"},
44	})
45
46	cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
47	status, err := cp.GetStatus(ctx)
48	require.NoError(t, err)
49	require.Equal(t, 1, status.Routes)
50	require.Equal(t, "registry.local", status.RegistryDomain)
51	require.Equal(t, "running", status.ContainerStatus["app.local"])
52}
53
54func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) {
55	t.Parallel()
56
57	configSvc := inmocks.NewMockConfigService(t)
58	containerSvc := inmocks.NewMockContainerService(t)
59
60	ctx := context.Background()
61	route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"}
62
63	require.False(t, domain.IsInternalDeploy(ctx))
64
65	configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil)
66	containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) {
67		require.True(t, domain.IsInternalDeploy(deployCtx))
68		require.Equal(t, *route, deployedRoute)
69		return &domain.Container{ID: "container-1"}, nil
70	})
71
72	cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
73	result, err := cp.Deploy(ctx, "app.local")
74	require.NoError(t, err)
75	require.NotNil(t, result)
76	require.Equal(t, "deployed", result.Status)
77	require.Equal(t, "app.local", result.Domain)
78	require.Equal(t, "container-1", result.ContainerID)
79}
80
81func TestLocalControlPlane_Backups(t *testing.T) {
82	t.Parallel()
83
84	backupSvc := inmocks.NewMockBackupService(t)
85	ctx := context.Background()
86
87	now := time.Now().UTC()
88	jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}}
89	backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil)
90	backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil)
91	backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil)
92	backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil)
93
94	cp := &localControlPlane{backupSvc: backupSvc}
95
96	list, err := cp.ListBackups(ctx, "app.local")
97	require.NoError(t, err)
98	require.Len(t, list, 1)
99
100	status, err := cp.BackupStatus(ctx)
101	require.NoError(t, err)
102	require.Len(t, status, 1)
103
104	run, err := cp.RunBackup(ctx, "app.local", "postgres")
105	require.NoError(t, err)
106	require.NotNil(t, run.Backup)
107	require.Equal(t, "b1", run.Backup.ID)
108
109	dbs, err := cp.DetectDatabases(ctx, "app.local")
110	require.NoError(t, err)
111	require.Len(t, dbs, 1)
112	require.Equal(t, "postgres", dbs[0].Name)
113}
114
115func TestLocalControlPlane_RunVolumeBackupsPreservesPartialJobs(t *testing.T) {
116	t.Parallel()
117
118	volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
119	ctx := context.Background()
120	runErr := errors.New("one volume failed")
121	jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.local", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
122	volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.local", "").Return(jobs, runErr)
123
124	cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
125	result, err := cp.RunVolumeBackups(ctx, "app.local", "")
126
127	require.ErrorIs(t, err, runErr)
128	require.NotNil(t, result)
129	assert.Equal(t, "partial", result.Status)
130	assert.Equal(t, runErr.Error(), result.Error)
131	require.Len(t, result.Backups, 1)
132	assert.Equal(t, "v1", result.Backups[0].ID)
133}
134
135func TestLocalControlPlane_RunVolumeBackupsMissingServiceUsesSentinel(t *testing.T) {
136	t.Parallel()
137
138	cp := &localControlPlane{}
139	result, err := cp.RunVolumeBackups(context.Background(), "app.local", "")
140
141	require.Error(t, err)
142	assert.Nil(t, result)
143	assert.ErrorIs(t, err, domain.ErrVolumeBackupUnavailable)
144}
145
146func TestLocalControlPlane_ListVolumeBackupsWrapsServiceError(t *testing.T) {
147	t.Parallel()
148
149	volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
150	wantErr := errors.New("store unavailable")
151	volumeBackupSvc.EXPECT().ListVolumeBackups(mock.Anything, "app.local").Return(nil, wantErr)
152
153	cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
154	jobs, err := cp.ListVolumeBackups(context.Background(), "app.local")
155
156	require.ErrorIs(t, err, wantErr)
157	assert.Contains(t, err.Error(), "list volume backups")
158	assert.Nil(t, jobs)
159}
160
161func TestLocalControlPlane_ListTags(t *testing.T) {
162	t.Parallel()
163
164	registrySvc := inmocks.NewMockRegistryService(t)
165	registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil)
166
167	cp := &localControlPlane{registrySvc: registrySvc}
168	tags, err := cp.ListTags(context.Background(), "repo/app")
169	require.NoError(t, err)
170	require.Equal(t, []string{"v1.0.0", "latest"}, tags)
171}
172
173func TestLocalControlPlane_RemoveRoutePersistsConfigThenReconcilesRuntime(t *testing.T) {
174	t.Parallel()
175
176	configSvc := inmocks.NewMockConfigService(t)
177	containerSvc := inmocks.NewMockContainerService(t)
178	report := &domain.CleanupReport{
179		Domain: "app.local",
180		RemovedContainers: []domain.CleanupContainer{{
181			ID:   "container-1",
182			Name: "gordon-app.local",
183		}},
184	}
185
186	removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(nil).Once()
187	cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once()
188	mock.InOrder(removeCall, cleanupCall)
189
190	cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
191	err := cp.RemoveRoute(context.Background(), "app.local")
192	require.NoError(t, err)
193}
194
195func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
196	t.Parallel()
197
198	containerSvc := inmocks.NewMockContainerService(t)
199	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
200	containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil)
201
202	cp := &localControlPlane{containerSvc: containerSvc}
203	result, err := cp.Restart(context.Background(), "app.local", true)
204	require.NoError(t, err)
205	require.Equal(t, "app.local", result.Domain)
206}
207
208func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) {
209	t.Parallel()
210
211	containerSvc := inmocks.NewMockContainerService(t)
212	syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
213	listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{
214		Domain:          "app.local",
215		Image:           "repo/app:latest",
216		ContainerID:     "container-1",
217		ContainerStatus: "running",
218	}}).Once()
219	mock.InOrder(syncCall, listCall)
220
221	cp := &localControlPlane{containerSvc: containerSvc}
222	routes, err := cp.ListRoutesWithDetails(context.Background())
223	require.NoError(t, err)
224	require.Len(t, routes, 1)
225	require.Equal(t, "app.local", routes[0].Domain)
226	require.Equal(t, "repo/app:latest", routes[0].Image)
227	require.Equal(t, "container-1", routes[0].ContainerID)
228	require.Equal(t, "running", routes[0].ContainerStatus)
229}
230
231func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) {
232	t.Parallel()
233
234	configSvc := inmocks.NewMockConfigService(t)
235	containerSvc := inmocks.NewMockContainerService(t)
236
237	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
238		Domain: "app.local",
239		Image:  "repo/app:latest",
240	}}).Once()
241	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
242	containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
243
244	cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
245	routes, err := cp.ListRoutesWithDetails(context.Background())
246	require.NoError(t, err)
247	require.Len(t, routes, 1)
248	require.Equal(t, "app.local", routes[0].Domain)
249	require.Equal(t, "repo/app:latest", routes[0].Image)
250	require.Empty(t, routes[0].ContainerID)
251	require.Empty(t, routes[0].ContainerStatus)
252}
253
254func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) {
255	t.Parallel()
256
257	cp := &localControlPlane{}
258	status, err := cp.GetTLSStatus(context.Background())
259	require.NoError(t, err)
260	require.Equal(t, &dto.TLSStatusResponse{
261		ACMEEnabled:     false,
262		SelectionReason: "public TLS service not configured",
263	}, status)
264}
265
266func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
267	t.Parallel()
268
269	logSvc := inmocks.NewMockLogService(t)
270	logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil)
271
272	cp := &localControlPlane{logSvc: logSvc}
273	lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50)
274	require.NoError(t, err)
275	require.Equal(t, []string{"line1", "line2"}, lines)
276}
277
278func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) {
279	ctx := context.Background()
280	volumeSvc := inmocks.NewMockVolumeService(t)
281	containerSvc := &previewContainerService{
282		MockContainerService: inmocks.NewMockContainerService(t),
283		preview: func(context.Context, string) (*domain.CleanupReport, error) {
284			return nil, nil
285		},
286	}
287	cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc}
288
289	report, err := cp.GetRouteCleanupPreview(ctx, "app.local")
290	require.NoError(t, err)
291	assert.Nil(t, report)
292}
293
294func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) {
295	ctx := context.Background()
296	configSvc := inmocks.NewMockConfigService(t)
297	containerSvc := inmocks.NewMockContainerService(t)
298	report := &domain.CleanupReport{Domain: "app.local"}
299
300	configSvc.EXPECT().RemoveRoute(mock.Anything, "app.local").Return(domain.ErrRouteNotFound).Once()
301	containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.local").Return(report, nil).Once()
302
303	cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
304	resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local")
305	require.NoError(t, err)
306	require.NotNil(t, resp)
307	assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup)
308}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close