summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PROGRESS.md7
-rw-r--r--cmd/ship/commands_v2.go344
-rw-r--r--cmd/ship/root_v2.go48
3 files changed, 351 insertions, 48 deletions
diff --git a/PROGRESS.md b/PROGRESS.md
index 5096cf2..77ef8ad 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -21,11 +21,16 @@ Tracking rebuilding ship for agent-first JSON interface.
21- [x] Port allocation (server-side) 21- [x] Port allocation (server-side)
22 22
23## Upcoming 23## Upcoming
24- [ ] `ship list/status/logs/remove` implementations
25- [ ] Wire up v2 commands in main.go (feature flag or replace) 24- [ ] Wire up v2 commands in main.go (feature flag or replace)
26- [ ] Testing with real deploys 25- [ ] Testing with real deploys
27 26
28## Completed Recently 27## Completed Recently
28- [x] `ship list` - enumerate all deploys from /etc/ship/ports and /var/www
29- [x] `ship status NAME` - show deploy status, port, type, TTL
30- [x] `ship logs NAME` - show journalctl logs (or Caddy logs for static)
31- [x] `ship remove NAME` - full cleanup of all deploy artifacts
32
33## Completed Recently
29- [x] TTL cleanup timer (server-side systemd timer) 34- [x] TTL cleanup timer (server-side systemd timer)
30- [x] `ship host init` with JSON output 35- [x] `ship host init` with JSON output
31- [x] Docker + Caddy installation 36- [x] Docker + Caddy installation
diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go
new file mode 100644
index 0000000..26ee1d3
--- /dev/null
+++ b/cmd/ship/commands_v2.go
@@ -0,0 +1,344 @@
1package main
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7 "time"
8
9 "github.com/bdw/ship/internal/output"
10 "github.com/bdw/ship/internal/ssh"
11 "github.com/bdw/ship/internal/state"
12 "github.com/spf13/cobra"
13)
14
15// listV2Cmd lists all deployments
16var listV2Cmd = &cobra.Command{
17 Use: "list",
18 Short: "List all deployments",
19 RunE: runListV2,
20}
21
22func runListV2(cmd *cobra.Command, args []string) error {
23 st, err := state.Load()
24 if err != nil {
25 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
26 }
27
28 hostName := hostFlag
29 if hostName == "" {
30 hostName = st.DefaultHost
31 }
32 if hostName == "" {
33 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
34 }
35
36 hostConfig := st.GetHost(hostName)
37
38 client, err := ssh.Connect(hostName)
39 if err != nil {
40 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
41 }
42 defer client.Close()
43
44 var deploys []output.DeployInfo
45
46 // Get all deployed services by checking /etc/ship/ports and /var/www
47 // Check ports (apps and docker)
48 portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true")
49 for _, name := range strings.Fields(portsOut) {
50 if name == "" {
51 continue
52 }
53
54 info := output.DeployInfo{
55 Name: name,
56 URL: fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain),
57 }
58
59 // Check if it's docker or binary
60 dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name))
61 if strings.Contains(dockerOut, "docker") {
62 info.Type = "docker"
63 } else {
64 info.Type = "binary"
65 }
66
67 // Check if running
68 statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name))
69 info.Running = strings.TrimSpace(statusOut) == "active"
70
71 // Check TTL
72 ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name))
73 if ttlOut != "" {
74 if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil {
75 info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339)
76 }
77 }
78
79 deploys = append(deploys, info)
80 }
81
82 // Check static sites in /var/www
83 wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true")
84 for _, name := range strings.Fields(wwwOut) {
85 if name == "" || name == "html" {
86 continue
87 }
88
89 // Skip if already in ports (would be an app, not static)
90 found := false
91 for _, d := range deploys {
92 if d.Name == name {
93 found = true
94 break
95 }
96 }
97 if found {
98 continue
99 }
100
101 info := output.DeployInfo{
102 Name: name,
103 URL: fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain),
104 Type: "static",
105 Running: true, // Static sites are always "running"
106 }
107
108 // Check TTL
109 ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name))
110 if ttlOut != "" {
111 if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil {
112 info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339)
113 }
114 }
115
116 deploys = append(deploys, info)
117 }
118
119 output.PrintAndExit(&output.ListResponse{
120 Status: "ok",
121 Deploys: deploys,
122 })
123 return nil
124}
125
126// statusV2Cmd shows status for a single deployment
127var statusV2Cmd = &cobra.Command{
128 Use: "status NAME",
129 Short: "Check status of a deployment",
130 Args: cobra.ExactArgs(1),
131 RunE: runStatusV2,
132}
133
134func runStatusV2(cmd *cobra.Command, args []string) error {
135 name := args[0]
136
137 st, err := state.Load()
138 if err != nil {
139 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
140 }
141
142 hostName := hostFlag
143 if hostName == "" {
144 hostName = st.DefaultHost
145 }
146 if hostName == "" {
147 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
148 }
149
150 hostConfig := st.GetHost(hostName)
151
152 client, err := ssh.Connect(hostName)
153 if err != nil {
154 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
155 }
156 defer client.Close()
157
158 // Check if deployment exists
159 portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name))
160 wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name))
161
162 if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" {
163 output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name))
164 }
165
166 resp := &output.StatusResponse{
167 Status: "ok",
168 Name: name,
169 URL: fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain),
170 }
171
172 // Determine type and get details
173 if portOut != "" {
174 port, _ := strconv.Atoi(strings.TrimSpace(portOut))
175 resp.Port = port
176
177 // Check if docker
178 dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name))
179 if strings.Contains(dockerOut, "docker") {
180 resp.Type = "docker"
181 } else {
182 resp.Type = "binary"
183 }
184
185 // Check if running
186 statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name))
187 resp.Running = strings.TrimSpace(statusOut) == "active"
188 } else {
189 resp.Type = "static"
190 resp.Running = true
191 }
192
193 // Check TTL
194 ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name))
195 if ttlOut != "" {
196 if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil {
197 resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339)
198 }
199 }
200
201 output.PrintAndExit(resp)
202 return nil
203}
204
205// logsV2Cmd shows logs for a deployment
206var logsV2Cmd = &cobra.Command{
207 Use: "logs NAME",
208 Short: "View logs for a deployment",
209 Args: cobra.ExactArgs(1),
210 RunE: runLogsV2,
211}
212
213func init() {
214 logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show")
215}
216
217func runLogsV2(cmd *cobra.Command, args []string) error {
218 name := args[0]
219 lines, _ := cmd.Flags().GetInt("lines")
220
221 st, err := state.Load()
222 if err != nil {
223 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
224 }
225
226 hostName := hostFlag
227 if hostName == "" {
228 hostName = st.DefaultHost
229 }
230 if hostName == "" {
231 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
232 }
233
234 client, err := ssh.Connect(hostName)
235 if err != nil {
236 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
237 }
238 defer client.Close()
239
240 // Check if it's a static site (no logs)
241 portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name))
242 if strings.TrimSpace(portOut) == "" {
243 // Check if static site exists
244 wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name))
245 if strings.TrimSpace(wwwExists) == "" {
246 output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name))
247 }
248 // Static site - check Caddy access logs
249 logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u caddy -n %d --no-pager 2>/dev/null | grep %s || echo 'No logs found'", lines*2, name))
250 if err != nil {
251 output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error()))
252 }
253 logLines := strings.Split(strings.TrimSpace(logsOut), "\n")
254 output.PrintAndExit(&output.LogsResponse{
255 Status: "ok",
256 Name: name,
257 Lines: logLines,
258 })
259 return nil
260 }
261
262 // Get journalctl logs
263 logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines))
264 if err != nil {
265 output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error()))
266 }
267
268 logLines := strings.Split(strings.TrimSpace(logsOut), "\n")
269
270 output.PrintAndExit(&output.LogsResponse{
271 Status: "ok",
272 Name: name,
273 Lines: logLines,
274 })
275 return nil
276}
277
278// removeV2Cmd removes a deployment
279var removeV2Cmd = &cobra.Command{
280 Use: "remove NAME",
281 Short: "Remove a deployment",
282 Args: cobra.ExactArgs(1),
283 RunE: runRemoveV2,
284}
285
286func runRemoveV2(cmd *cobra.Command, args []string) error {
287 name := args[0]
288
289 st, err := state.Load()
290 if err != nil {
291 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
292 }
293
294 hostName := hostFlag
295 if hostName == "" {
296 hostName = st.DefaultHost
297 }
298 if hostName == "" {
299 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
300 }
301
302 client, err := ssh.Connect(hostName)
303 if err != nil {
304 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
305 }
306 defer client.Close()
307
308 // Check if deployment exists
309 portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name))
310 wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name))
311
312 if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" {
313 output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name))
314 }
315
316 // Stop and disable service
317 client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name))
318 client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name))
319
320 // Remove files
321 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
322 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
323 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
324 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
325 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
326 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
327 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name))
328 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name))
329
330 // Remove docker container and image
331 client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name))
332 client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name))
333
334 // Reload services
335 client.RunSudo("systemctl daemon-reload")
336 client.RunSudo("systemctl reload caddy")
337
338 output.PrintAndExit(&output.RemoveResponse{
339 Status: "ok",
340 Name: name,
341 Removed: true,
342 })
343 return nil
344}
diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go
index 9900e83..e886a7e 100644
--- a/cmd/ship/root_v2.go
+++ b/cmd/ship/root_v2.go
@@ -82,53 +82,7 @@ func runDeployV2(cmd *cobra.Command, args []string) error {
82 return nil 82 return nil
83} 83}
84 84
85// Placeholder subcommands - to be implemented 85// Subcommands (list, status, logs, remove) are defined in commands_v2.go
86
87var listV2Cmd = &cobra.Command{
88 Use: "list",
89 Short: "List all deployments",
90 RunE: func(cmd *cobra.Command, args []string) error {
91 // TODO: implement
92 output.PrintAndExit(&output.ListResponse{
93 Status: "ok",
94 Deploys: []output.DeployInfo{},
95 })
96 return nil
97 },
98}
99
100var statusV2Cmd = &cobra.Command{
101 Use: "status NAME",
102 Short: "Check status of a deployment",
103 Args: cobra.ExactArgs(1),
104 RunE: func(cmd *cobra.Command, args []string) error {
105 // TODO: implement
106 output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented"))
107 return nil
108 },
109}
110
111var logsV2Cmd = &cobra.Command{
112 Use: "logs NAME",
113 Short: "View logs for a deployment",
114 Args: cobra.ExactArgs(1),
115 RunE: func(cmd *cobra.Command, args []string) error {
116 // TODO: implement
117 output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented"))
118 return nil
119 },
120}
121
122var removeV2Cmd = &cobra.Command{
123 Use: "remove NAME",
124 Short: "Remove a deployment",
125 Args: cobra.ExactArgs(1),
126 RunE: func(cmd *cobra.Command, args []string) error {
127 // TODO: implement
128 output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented"))
129 return nil
130 },
131}
132 86
133var hostV2Cmd = &cobra.Command{ 87var hostV2Cmd = &cobra.Command{
134 Use: "host", 88 Use: "host",