summaryrefslogtreecommitdiffstats
path: root/cmd/deploy/deploy.go
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-01-23 20:54:46 -0800
committerbndw <ben@bdw.to>2026-01-23 20:54:46 -0800
commit98b9af372025595e8a4255538e2836e019311474 (patch)
tree0a26fa5a8a19ea8565da6d63e1f19c21fc170d12 /cmd/deploy/deploy.go
parent7fcb9dfa87310e91b527829ece9989decb6fda64 (diff)
Add deploy command and fix static site naming
Static sites now default to using the domain as the name instead of the source directory basename, preventing conflicts when multiple sites use the same directory name (e.g., dist). Also fixes .gitignore to not exclude cmd/deploy/ directory.
Diffstat (limited to 'cmd/deploy/deploy.go')
-rw-r--r--cmd/deploy/deploy.go494
1 files changed, 494 insertions, 0 deletions
diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go
new file mode 100644
index 0000000..2b3ab4a
--- /dev/null
+++ b/cmd/deploy/deploy.go
@@ -0,0 +1,494 @@
1package main
2
3import (
4 "bufio"
5 "flag"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11
12 "github.com/bdw/deploy/internal/config"
13 "github.com/bdw/deploy/internal/ssh"
14 "github.com/bdw/deploy/internal/state"
15 "github.com/bdw/deploy/internal/templates"
16)
17
18type envFlags []string
19
20func (e *envFlags) String() string {
21 return strings.Join(*e, ",")
22}
23
24func (e *envFlags) Set(value string) error {
25 *e = append(*e, value)
26 return nil
27}
28
29type fileFlags []string
30
31func (f *fileFlags) String() string {
32 return strings.Join(*f, ",")
33}
34
35func (f *fileFlags) Set(value string) error {
36 *f = append(*f, value)
37 return nil
38}
39
40func runDeploy(args []string) {
41 fs := flag.NewFlagSet("deploy", flag.ExitOnError)
42 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
43 domain := fs.String("domain", "", "Domain name (required)")
44 name := fs.String("name", "", "App name (default: inferred from binary or directory)")
45 binary := fs.String("binary", "", "Path to Go binary (for app deployment)")
46 static := fs.Bool("static", false, "Deploy as static site")
47 dir := fs.String("dir", ".", "Directory to deploy (for static sites)")
48 port := fs.Int("port", 0, "Port override (default: auto-allocate)")
49 var envVars envFlags
50 fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)")
51 envFile := fs.String("env-file", "", "Path to .env file")
52 binaryArgs := fs.String("args", "", "Arguments to pass to binary")
53 var files fileFlags
54 fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)")
55
56 fs.Parse(args)
57
58 // Get host from flag or config
59 if *host == "" {
60 cfg, err := config.Load()
61 if err != nil {
62 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
63 os.Exit(1)
64 }
65 *host = cfg.Host
66 }
67
68 if *host == "" || *domain == "" {
69 fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n")
70 fs.Usage()
71 os.Exit(1)
72 }
73
74 if *static {
75 deployStatic(*host, *domain, *name, *dir)
76 } else {
77 deployApp(*host, *domain, *name, *binary, *port, envVars, *envFile, *binaryArgs, files)
78 }
79}
80
81func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) {
82 // Determine app name
83 if name == "" {
84 if binaryPath != "" {
85 name = filepath.Base(binaryPath)
86 } else {
87 // Try to find a binary in current directory
88 cwd, _ := os.Getwd()
89 name = filepath.Base(cwd)
90 }
91 }
92
93 // Find binary if not specified
94 if binaryPath == "" {
95 // Look for binary with same name as directory
96 if _, err := os.Stat(name); err == nil {
97 binaryPath = name
98 } else {
99 fmt.Fprintf(os.Stderr, "Error: --binary is required (could not find binary in current directory)\n")
100 os.Exit(1)
101 }
102 }
103
104 // Verify binary exists
105 if _, err := os.Stat(binaryPath); err != nil {
106 fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath)
107 os.Exit(1)
108 }
109
110 fmt.Printf("Deploying app: %s\n", name)
111 fmt.Printf(" Domain: %s\n", domain)
112 fmt.Printf(" Binary: %s\n", binaryPath)
113
114 // Load state
115 st, err := state.Load()
116 if err != nil {
117 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
118 os.Exit(1)
119 }
120
121 // Check if app already exists (update) or new deployment
122 existingApp, _ := st.GetApp(host, name)
123 var port int
124 if existingApp != nil {
125 port = existingApp.Port
126 fmt.Printf(" Updating existing deployment (port %d)\n", port)
127 } else {
128 if portOverride > 0 {
129 port = portOverride
130 } else {
131 port = st.AllocatePort(host)
132 }
133 fmt.Printf(" Allocated port: %d\n", port)
134 }
135
136 // Parse environment variables
137 env := make(map[string]string)
138 if existingApp != nil {
139 // Preserve existing env vars
140 for k, v := range existingApp.Env {
141 env[k] = v
142 }
143 // Preserve existing args if not provided
144 if args == "" && existingApp.Args != "" {
145 args = existingApp.Args
146 }
147 // Preserve existing files if not provided
148 if len(files) == 0 && len(existingApp.Files) > 0 {
149 files = existingApp.Files
150 }
151 }
152
153 // Add/override from flags
154 for _, e := range envVars {
155 parts := strings.SplitN(e, "=", 2)
156 if len(parts) == 2 {
157 env[parts[0]] = parts[1]
158 }
159 }
160
161 // Add/override from file
162 if envFile != "" {
163 fileEnv, err := parseEnvFile(envFile)
164 if err != nil {
165 fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err)
166 os.Exit(1)
167 }
168 for k, v := range fileEnv {
169 env[k] = v
170 }
171 }
172
173 // Always set PORT
174 env["PORT"] = strconv.Itoa(port)
175
176 // Connect to VPS
177 client, err := ssh.Connect(host)
178 if err != nil {
179 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
180 os.Exit(1)
181 }
182 defer client.Close()
183
184 // Upload binary
185 fmt.Println("→ Uploading binary...")
186 remoteTmpPath := fmt.Sprintf("/tmp/%s", name)
187 if err := client.Upload(binaryPath, remoteTmpPath); err != nil {
188 fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err)
189 os.Exit(1)
190 }
191
192 // Create user (ignore error if already exists)
193 fmt.Println("→ Creating system user...")
194 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name))
195
196 // Create working directory
197 fmt.Println("→ Setting up directories...")
198 workDir := fmt.Sprintf("/var/lib/%s", name)
199 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
200 fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err)
201 os.Exit(1)
202 }
203 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil {
204 fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err)
205 os.Exit(1)
206 }
207
208 // Move binary to /usr/local/bin
209 fmt.Println("→ Installing binary...")
210 binaryDest := fmt.Sprintf("/usr/local/bin/%s", name)
211 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
212 fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err)
213 os.Exit(1)
214 }
215 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
216 fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err)
217 os.Exit(1)
218 }
219
220 // Upload config files to working directory
221 if len(files) > 0 {
222 fmt.Println("→ Uploading config files...")
223 for _, file := range files {
224 // Verify file exists locally
225 if _, err := os.Stat(file); err != nil {
226 fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file)
227 os.Exit(1)
228 }
229
230 // Determine remote path (preserve filename)
231 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
232 remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file))
233
234 // Upload to tmp first
235 if err := client.Upload(file, remoteTmpPath); err != nil {
236 fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err)
237 os.Exit(1)
238 }
239
240 // Move to working directory
241 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil {
242 fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err)
243 os.Exit(1)
244 }
245
246 // Set ownership
247 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil {
248 fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err)
249 os.Exit(1)
250 }
251
252 fmt.Printf(" Uploaded: %s\n", file)
253 }
254 }
255
256 // Create env file
257 fmt.Println("→ Creating environment file...")
258 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
259 envContent := ""
260 for k, v := range env {
261 envContent += fmt.Sprintf("%s=%s\n", k, v)
262 }
263 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
264 fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err)
265 os.Exit(1)
266 }
267 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
268 fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err)
269 os.Exit(1)
270 }
271 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil {
272 fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err)
273 os.Exit(1)
274 }
275
276 // Generate systemd unit
277 fmt.Println("→ Creating systemd service...")
278 serviceContent, err := templates.SystemdService(map[string]string{
279 "Name": name,
280 "User": name,
281 "WorkDir": workDir,
282 "BinaryPath": binaryDest,
283 "Port": strconv.Itoa(port),
284 "EnvFile": envFilePath,
285 "Args": args,
286 })
287 if err != nil {
288 fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err)
289 os.Exit(1)
290 }
291
292 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
293 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
294 fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err)
295 os.Exit(1)
296 }
297
298 // Generate Caddy config
299 fmt.Println("→ Configuring Caddy...")
300 caddyContent, err := templates.AppCaddy(map[string]string{
301 "Domain": domain,
302 "Port": strconv.Itoa(port),
303 })
304 if err != nil {
305 fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err)
306 os.Exit(1)
307 }
308
309 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
310 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
311 fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err)
312 os.Exit(1)
313 }
314
315 // Reload systemd
316 fmt.Println("→ Reloading systemd...")
317 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
318 fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err)
319 os.Exit(1)
320 }
321
322 // Enable and start service
323 fmt.Println("→ Starting service...")
324 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil {
325 fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err)
326 os.Exit(1)
327 }
328 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
329 fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err)
330 os.Exit(1)
331 }
332
333 // Reload Caddy
334 fmt.Println("→ Reloading Caddy...")
335 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
336 fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err)
337 os.Exit(1)
338 }
339
340 // Update state
341 st.AddApp(host, name, &state.App{
342 Type: "app",
343 Domain: domain,
344 Port: port,
345 Env: env,
346 Args: args,
347 Files: files,
348 })
349 if err := st.Save(); err != nil {
350 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
351 os.Exit(1)
352 }
353
354 fmt.Printf("\n✓ App deployed successfully!\n")
355 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
356}
357
358func deployStatic(host, domain, name, dir string) {
359 // Determine site name (default to domain to avoid conflicts)
360 if name == "" {
361 name = domain
362 }
363
364 // Verify directory exists
365 if _, err := os.Stat(dir); err != nil {
366 fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir)
367 os.Exit(1)
368 }
369
370 fmt.Printf("Deploying static site: %s\n", name)
371 fmt.Printf(" Domain: %s\n", domain)
372 fmt.Printf(" Directory: %s\n", dir)
373
374 // Load state
375 st, err := state.Load()
376 if err != nil {
377 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
378 os.Exit(1)
379 }
380
381 // Connect to VPS
382 client, err := ssh.Connect(host)
383 if err != nil {
384 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
385 os.Exit(1)
386 }
387 defer client.Close()
388
389 // Create remote directory
390 remoteDir := fmt.Sprintf("/var/www/%s", name)
391 fmt.Println("→ Creating remote directory...")
392 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
393 fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err)
394 os.Exit(1)
395 }
396
397 // Get current user for temporary ownership during upload
398 currentUser, err := client.Run("whoami")
399 if err != nil {
400 fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err)
401 os.Exit(1)
402 }
403 currentUser = strings.TrimSpace(currentUser)
404
405 // Set ownership to current user for upload
406 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
407 fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err)
408 os.Exit(1)
409 }
410
411 // Upload files
412 fmt.Println("→ Uploading files...")
413 if err := client.UploadDir(dir, remoteDir); err != nil {
414 fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err)
415 os.Exit(1)
416 }
417
418 // Set ownership and permissions
419 fmt.Println("→ Setting permissions...")
420 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
421 fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err)
422 os.Exit(1)
423 }
424 // Make files readable by all (755 for dirs, 644 for files)
425 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
426 fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err)
427 os.Exit(1)
428 }
429 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
430 fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err)
431 os.Exit(1)
432 }
433
434 // Generate Caddy config
435 fmt.Println("→ Configuring Caddy...")
436 caddyContent, err := templates.StaticCaddy(map[string]string{
437 "Domain": domain,
438 "RootDir": remoteDir,
439 })
440 if err != nil {
441 fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err)
442 os.Exit(1)
443 }
444
445 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
446 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
447 fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err)
448 os.Exit(1)
449 }
450
451 // Reload Caddy
452 fmt.Println("→ Reloading Caddy...")
453 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
454 fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err)
455 os.Exit(1)
456 }
457
458 // Update state
459 st.AddApp(host, name, &state.App{
460 Type: "static",
461 Domain: domain,
462 })
463 if err := st.Save(); err != nil {
464 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
465 os.Exit(1)
466 }
467
468 fmt.Printf("\n✓ Static site deployed successfully!\n")
469 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
470}
471
472func parseEnvFile(path string) (map[string]string, error) {
473 file, err := os.Open(path)
474 if err != nil {
475 return nil, err
476 }
477 defer file.Close()
478
479 env := make(map[string]string)
480 scanner := bufio.NewScanner(file)
481 for scanner.Scan() {
482 line := strings.TrimSpace(scanner.Text())
483 if line == "" || strings.HasPrefix(line, "#") {
484 continue
485 }
486
487 parts := strings.SplitN(line, "=", 2)
488 if len(parts) == 2 {
489 env[parts[0]] = parts[1]
490 }
491 }
492
493 return env, scanner.Err()
494}