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