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}