main 8d5c184

apps_local_integration_test.go RawHistoryFolder

1package cli
2
3import (
4	"bytes"
5	"context"
6	"net/http"
7	"path/filepath"
8	"testing"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15
16	"github.com/bnema/gordon/internal/adapters/dto"
17	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
18	adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
19	"github.com/bnema/gordon/internal/adapters/localadmin"
20	in "github.com/bnema/gordon/internal/boundaries/in"
21	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
22	"github.com/bnema/gordon/internal/domain"
23)
24
25// validLocalAppManifest is a minimal manifest accepted by the admin apply
26// surface (one service, one HTTP host).
27const validLocalAppManifest = `
28name = "blog"
29[services.web]
30image = "img:1"
31[[services.web.http]]
32host = "blog.example.com"
33port = 8080
34`
35
36// TestSharedCommandLocalAdminComposition_Status executes a shared command
37// through socket discovery, the Unix transport, and the local authority.
38func TestSharedCommandLocalAdminComposition_Status(t *testing.T) {
39	xdg := t.TempDir()
40	t.Setenv("XDG_RUNTIME_DIR", xdg)
41	withRemoteTarget(t, "")
42
43	configSvc := inmocks.NewMockConfigService(t)
44	configSvc.EXPECT().GetRegistryDomain().Return("registry.example.test").Once()
45	configSvc.EXPECT().GetRegistryPort().Return(5000).Once()
46	configSvc.EXPECT().GetServerPort().Return(8080).Once()
47	configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true).Once()
48	appSvc := inmocks.NewMockAppService(t)
49	appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{{App: "blog", Active: "rev-1", Converged: true}}, nil).Once()
50
51	handler := adminhttp.NewHandler(adminhttp.HandlerDeps{ConfigSvc: configSvc, AppSvc: appSvc, Log: zerowrap.Default()})
52	shutdown := startLocalAuthority(t, filepath.Join(xdg, "gordon"), handler.LocalAuthority())
53	defer shutdown()
54	stubLocalAppClient(t, remote.NewLocalClient)
55
56	cmd := newStatusCmd()
57	var out bytes.Buffer
58	cmd.SetOut(&out)
59	require.NoError(t, cmd.ExecuteContext(context.Background()))
60	assert.Contains(t, out.String(), "registry.example.test")
61	assert.Contains(t, out.String(), "blog")
62}
63
64// TestAppLocalAdminComposition_EndToEnd composes the real local authority,
65// Unix listener, discovery, transport, and app control plane.
66func TestAppLocalAdminComposition_EndToEnd(t *testing.T) {
67	xdg := t.TempDir()
68	t.Setenv("XDG_RUNTIME_DIR", xdg)
69	withRemoteTarget(t, "")
70
71	appSvc := inmocks.NewMockAppService(t)
72	appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
73		&in.AppApplyResult{App: "blog", ResultingRevision: "rev-2", Pending: true, IntentID: "apply-1"},
74		nil, nil,
75	).Once()
76	appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{
77		{App: "blog", Desired: "rev-2", Active: "rev-1"},
78	}, nil).Once()
79	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
80		App:             "blog",
81		DesiredRevision: "rev-2",
82		DesiredStatus:   "pending",
83		Services:        map[string]in.AppServiceView{"web": {Container: "ctr-web"}},
84	}, nil).Once()
85	appSvc.EXPECT().Diff(mock.Anything, "blog").Return(domain.AppDiff{}, nil).Once()
86	appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&domain.AppOperation{
87		Op:      "apply",
88		App:     "blog",
89		Outcome: "succeeded",
90	}, nil).Once()
91
92	handler := adminhttp.NewHandler(adminhttp.HandlerDeps{AppSvc: appSvc, Log: zerowrap.Default()})
93	dir := filepath.Join(xdg, "gordon")
94	shutdown := startLocalAuthority(t, dir, handler.LocalAuthority())
95
96	// The CLI resolves the socket through real discovery and the Unix transport.
97	stubLocalAppClient(t, remote.NewLocalClient)
98
99	handle, err := resolveAppPlane()
100	require.NoError(t, err)
101	defer handle.close()
102	plane := handle.plane
103
104	ctx := context.Background()
105
106	apply, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: validLocalAppManifest})
107	require.NoError(t, err)
108	assert.Equal(t, "rev-2", apply.ResultingRevision)
109
110	apps, err := plane.ListApps(ctx)
111	require.NoError(t, err)
112	require.Len(t, apps, 1)
113	assert.Equal(t, "blog", apps[0].App)
114
115	show, err := plane.ShowApp(ctx, "blog")
116	require.NoError(t, err)
117	assert.Equal(t, "ctr-web", show.Active.Services["web"].Container)
118
119	diff, err := plane.DiffApp(ctx, "blog")
120	require.NoError(t, err)
121	assert.Equal(t, "blog", diff.App)
122
123	// Idempotent-mutation recovery path: re-query the same key.
124	op, err := plane.OperationByKey(ctx, "blog", "key-1")
125	require.NoError(t, err)
126	assert.Equal(t, "succeeded", op.Outcome)
127
128	// Shutdown removes only the daemon-owned socket; restart recreates it.
129	shutdown()
130	assert.NoFileExists(t, localadmin.SocketPath(dir))
131
132	restartShutdown := startLocalAuthority(t, dir, handler.LocalAuthority())
133	require.FileExists(t, localadmin.SocketPath(dir))
134	restartShutdown()
135	assert.NoFileExists(t, localadmin.SocketPath(dir))
136}
137
138// startLocalAuthority serves the given handler on an owner-only Unix socket
139// and returns a shutdown function that stops the server and removes the
140// socket, mirroring daemon lifecycle.
141func startLocalAuthority(t *testing.T, dir string, handler http.Handler) func() {
142	t.Helper()
143	ln, info, err := localadmin.Listen(dir)
144	require.NoError(t, err)
145
146	srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
147	go func() { _ = srv.Serve(ln) }()
148
149	return func() {
150		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
151		defer cancel()
152		_ = srv.Shutdown(ctx)
153		require.NoError(t, localadmin.RemoveOwnedSocket(localadmin.SocketPath(dir), info))
154	}
155}

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