aboutsummaryrefslogtreecommitdiffstats
path: root/cmd/ship/deploy_v2.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/ship/deploy_v2.go')
-rw-r--r--cmd/ship/deploy_v2.go210
1 files changed, 210 insertions, 0 deletions
diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go
new file mode 100644
index 0000000..7d498b2
--- /dev/null
+++ b/cmd/ship/deploy_v2.go
@@ -0,0 +1,210 @@
1package main
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "fmt"
7 "regexp"
8 "strings"
9 "time"
10
11 "github.com/bdw/ship/internal/detect"
12 "github.com/bdw/ship/internal/output"
13 "github.com/bdw/ship/internal/state"
14)
15
16// deployV2 implements the new agent-first deploy interface.
17// Usage: ship [PATH] [FLAGS]
18// PATH defaults to "." if not provided.
19func deployV2(path string, opts deployV2Options) {
20 start := time.Now()
21
22 // Validate name if provided
23 if opts.Name != "" {
24 if err := validateNameV2(opts.Name); err != nil {
25 output.PrintAndExit(err)
26 }
27 }
28
29 // Parse TTL if provided
30 var ttlDuration time.Duration
31 if opts.TTL != "" {
32 var err error
33 ttlDuration, err = parseTTL(opts.TTL)
34 if err != nil {
35 output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error()))
36 }
37 }
38
39 // Get host configuration
40 st, err := state.Load()
41 if err != nil {
42 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error()))
43 }
44
45 hostName := opts.Host
46 if hostName == "" {
47 hostName = st.DefaultHost
48 }
49 if hostName == "" {
50 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init"))
51 }
52
53 hostConfig := st.GetHost(hostName)
54 if hostConfig.BaseDomain == "" {
55 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName)))
56 }
57
58 // Auto-detect project type
59 result := detect.Detect(path)
60 if result.Error != nil {
61 output.PrintAndExit(result.Error)
62 }
63
64 // Generate name if not provided
65 name := opts.Name
66 if name == "" {
67 name = generateName()
68 }
69
70 // Build URL: use custom domain if provided, otherwise use subdomain
71 var url string
72 if opts.Domain != "" {
73 url = fmt.Sprintf("https://%s", opts.Domain)
74 } else {
75 url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain)
76 }
77
78 // Build deploy context
79 ctx := &deployContext{
80 SSHHost: hostName,
81 HostConfig: hostConfig,
82 Name: name,
83 Path: result.Path,
84 URL: url,
85 Opts: opts,
86 }
87
88 // Deploy based on type
89 var deployErr *output.ErrorResponse
90 switch result.Type {
91 case detect.TypeStatic:
92 deployErr = deployStaticV2(ctx)
93 case detect.TypeDocker:
94 deployErr = deployDockerV2(ctx)
95 case detect.TypeBinary:
96 deployErr = deployBinaryV2(ctx)
97 }
98
99 if deployErr != nil {
100 deployErr.Name = name
101 deployErr.URL = url
102 output.PrintAndExit(deployErr)
103 }
104
105 // Set TTL if specified
106 if ttlDuration > 0 {
107 if err := setTTLV2(ctx, ttlDuration); err != nil {
108 // Non-fatal, deploy succeeded
109 // TODO: log warning
110 }
111 }
112
113 // Health check
114 var healthResult *output.HealthResult
115 if opts.Health != "" || result.Type == detect.TypeStatic {
116 endpoint := opts.Health
117 if endpoint == "" {
118 endpoint = "/"
119 }
120 healthResult, deployErr = runHealthCheck(url, endpoint)
121 if deployErr != nil {
122 deployErr.Name = name
123 deployErr.URL = url
124 output.PrintAndExit(deployErr)
125 }
126 }
127
128 // Build response
129 resp := &output.DeployResponse{
130 Status: "ok",
131 Name: name,
132 URL: url,
133 Type: string(result.Type),
134 TookMs: time.Since(start).Milliseconds(),
135 Health: healthResult,
136 }
137
138 if ttlDuration > 0 {
139 resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339)
140 }
141
142 output.PrintAndExit(resp)
143}
144
145type deployV2Options struct {
146 Name string
147 Host string
148 Domain string
149 Health string
150 TTL string
151 Env []string
152 EnvFile string
153 ContainerPort int // Port the container listens on (default 80 for Docker)
154 Pretty bool
155}
156
157// deployContext holds all info needed for a deploy
158type deployContext struct {
159 SSHHost string // SSH connection string (config alias or user@host)
160 HostConfig *state.Host // Host configuration
161 Name string // Deploy name
162 Path string // Local path to deploy
163 URL string // Full URL after deploy
164 Opts deployV2Options
165}
166
167// validateNameV2 checks if name matches allowed pattern
168func validateNameV2(name string) *output.ErrorResponse {
169 // Must be lowercase alphanumeric with hyphens, 1-63 chars
170 pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`)
171 if !pattern.MatchString(name) {
172 return output.Err(output.ErrInvalidName,
173 "name must be lowercase alphanumeric with hyphens, 1-63 characters")
174 }
175 return nil
176}
177
178// generateName creates a random deploy name
179func generateName() string {
180 bytes := make([]byte, 3)
181 rand.Read(bytes)
182 return "ship-" + hex.EncodeToString(bytes)
183}
184
185// parseTTL converts duration strings like "1h", "7d" to time.Duration
186func parseTTL(s string) (time.Duration, error) {
187 s = strings.TrimSpace(s)
188 if s == "" {
189 return 0, nil
190 }
191
192 // Handle days specially (not supported by time.ParseDuration)
193 if strings.HasSuffix(s, "d") {
194 days := strings.TrimSuffix(s, "d")
195 var d int
196 _, err := fmt.Sscanf(days, "%d", &d)
197 if err != nil {
198 return 0, fmt.Errorf("invalid TTL: %s", s)
199 }
200 return time.Duration(d) * 24 * time.Hour, nil
201 }
202
203 d, err := time.ParseDuration(s)
204 if err != nil {
205 return 0, fmt.Errorf("invalid TTL: %s", s)
206 }
207 return d, nil
208}
209
210// Deploy implementations are in deploy_impl_v2.go