summaryrefslogtreecommitdiffstats
path: root/cmd/deploy/deploy.go
diff options
context:
space:
mode:
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}