summaryrefslogtreecommitdiffstats
path: root/cmd/ship/host
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-08 12:32:59 -0800
committerbndw <ben@bdw.to>2026-02-08 12:32:59 -0800
commita8ad8e934d15d2bf84f942414a89af1d2691adbc (patch)
tree82e6765c9d35968b27ac7ee17f5c201a421dc1d3 /cmd/ship/host
parentaf109c04a3edd4dcd4e7b16242052442fb4a3b24 (diff)
Add git-centric deployment with Docker builds and vanity imports
New deployment model where projects start with a git remote on the VPS. Pushing to the remote triggers automatic docker build and deploy via post-receive hooks. The base domain serves Go vanity imports and git HTTPS cloning via Caddy + fcgiwrap. - Add `ship init <name>` command to create bare repos and .ship/ config - Add `ship deploy <name>` command for manual rebuilds - Extend `ship host init --base-domain` to set up Docker, git user, fcgiwrap, sudoers, and vanity import infrastructure - Add git-app and git-static types alongside existing app and static - Update remove, status, logs, restart, list, and config-update to handle new types
Diffstat (limited to 'cmd/ship/host')
-rw-r--r--cmd/ship/host/init.go131
1 files changed, 131 insertions, 0 deletions
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go
index 27f67af..d7143ff 100644
--- a/cmd/ship/host/init.go
+++ b/cmd/ship/host/init.go
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bdw/ship/internal/ssh" 7 "github.com/bdw/ship/internal/ssh"
8 "github.com/bdw/ship/internal/state" 8 "github.com/bdw/ship/internal/state"
9 "github.com/bdw/ship/internal/templates"
9 "github.com/spf13/cobra" 10 "github.com/spf13/cobra"
10) 11)
11 12
@@ -105,6 +106,14 @@ import /etc/caddy/sites-enabled/*
105 hostState.BaseDomain = baseDomain 106 hostState.BaseDomain = baseDomain
106 fmt.Printf(" Base domain: %s\n", baseDomain) 107 fmt.Printf(" Base domain: %s\n", baseDomain)
107 } 108 }
109
110 // Git-centric deployment setup (gated on base domain)
111 if baseDomain != "" {
112 if err := setupGitDeploy(client, baseDomain, hostState); err != nil {
113 return err
114 }
115 }
116
108 if st.GetDefaultHost() == "" { 117 if st.GetDefaultHost() == "" {
109 st.SetDefaultHost(host) 118 st.SetDefaultHost(host)
110 fmt.Printf(" Set %s as default host\n", host) 119 fmt.Printf(" Set %s as default host\n", host)
@@ -119,6 +128,128 @@ import /etc/caddy/sites-enabled/*
119 fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") 128 fmt.Printf(" ship --binary ./myapp --domain api.example.com\n")
120 fmt.Println(" 2. Deploy a static site:") 129 fmt.Println(" 2. Deploy a static site:")
121 fmt.Printf(" ship --static --dir ./dist --domain example.com\n") 130 fmt.Printf(" ship --static --dir ./dist --domain example.com\n")
131 if baseDomain != "" {
132 fmt.Println(" 3. Initialize a git-deployed app:")
133 fmt.Printf(" ship init myapp\n")
134 }
135 return nil
136}
137
138func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error {
139 fmt.Println("-> Installing Docker...")
140 dockerCommands := []string{
141 "apt-get install -y ca-certificates curl gnupg",
142 "install -m 0755 -d /etc/apt/keyrings",
143 "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
144 "chmod a+r /etc/apt/keyrings/docker.asc",
145 `echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null`,
146 "apt-get update",
147 "apt-get install -y docker-ce docker-ce-cli containerd.io",
148 }
149 for _, cmd := range dockerCommands {
150 if _, err := client.RunSudo(cmd); err != nil {
151 return fmt.Errorf("error installing Docker: %w", err)
152 }
153 }
154 fmt.Println(" Docker installed")
155
156 fmt.Println("-> Installing git and fcgiwrap...")
157 if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil {
158 return fmt.Errorf("error installing git/fcgiwrap: %w", err)
159 }
160 fmt.Println(" git and fcgiwrap installed")
161
162 fmt.Println("-> Creating git user...")
163 // Create git user (ignore error if already exists)
164 client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git")
165 if _, err := client.RunSudo("usermod -aG docker git"); err != nil {
166 return fmt.Errorf("error adding git user to docker group: %w", err)
167 }
168 fmt.Println(" git user created")
169
170 fmt.Println("-> Copying SSH keys to git user...")
171 copyKeysCommands := []string{
172 "mkdir -p /home/git/.ssh",
173 "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys",
174 "chown -R git:git /home/git/.ssh",
175 "chmod 700 /home/git/.ssh",
176 "chmod 600 /home/git/.ssh/authorized_keys",
177 }
178 for _, cmd := range copyKeysCommands {
179 if _, err := client.RunSudo(cmd); err != nil {
180 return fmt.Errorf("error copying SSH keys: %w", err)
181 }
182 }
183 fmt.Println(" SSH keys copied")
184
185 fmt.Println("-> Creating /srv/git...")
186 if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil {
187 return fmt.Errorf("error creating /srv/git: %w", err)
188 }
189 if _, err := client.RunSudo("chown git:git /srv/git"); err != nil {
190 return fmt.Errorf("error setting /srv/git ownership: %w", err)
191 }
192 fmt.Println(" /srv/git created")
193
194 fmt.Println("-> Writing sudoers for git user...")
195 sudoersContent := `git ALL=(ALL) NOPASSWD: /bin/systemctl restart *, /bin/systemctl daemon-reload, /bin/systemctl reload caddy, /bin/systemctl enable *, /bin/cp * /etc/systemd/system/*, /bin/cp * /etc/caddy/sites-enabled/*, /bin/mkdir -p /var/lib/*, /bin/mkdir -p /var/www/*, /bin/chown *
196`
197 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil {
198 return fmt.Errorf("error writing sudoers: %w", err)
199 }
200 if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil {
201 return fmt.Errorf("error setting sudoers permissions: %w", err)
202 }
203 fmt.Println(" sudoers configured")
204
205 fmt.Println("-> Writing vanity import template...")
206 vanityHTML := `<!DOCTYPE html>
207<html><head>
208{{$path := trimPrefix "/" .Req.URL.Path}}
209{{$parts := splitList "/" $path}}
210{{$module := first $parts}}
211<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
212</head>
213<body>go get {{.Host}}/{{$module}}</body>
214</html>
215`
216 if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil {
217 return fmt.Errorf("error creating vanity directory: %w", err)
218 }
219 if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil {
220 return fmt.Errorf("error writing vanity template: %w", err)
221 }
222 fmt.Println(" vanity template written")
223
224 fmt.Println("-> Writing base domain Caddy config...")
225 codeCaddyContent, err := templates.CodeCaddy(map[string]string{
226 "BaseDomain": baseDomain,
227 })
228 if err != nil {
229 return fmt.Errorf("error generating code caddy config: %w", err)
230 }
231 if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil {
232 return fmt.Errorf("error writing code caddy config: %w", err)
233 }
234 fmt.Println(" base domain Caddy config written")
235
236 fmt.Println("-> Starting Docker and fcgiwrap...")
237 if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil {
238 return fmt.Errorf("error enabling services: %w", err)
239 }
240 if _, err := client.RunSudo("systemctl start docker fcgiwrap"); err != nil {
241 return fmt.Errorf("error starting services: %w", err)
242 }
243 fmt.Println(" Docker and fcgiwrap started")
244
245 fmt.Println("-> Reloading Caddy...")
246 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
247 return fmt.Errorf("error reloading Caddy: %w", err)
248 }
249 fmt.Println(" Caddy reloaded")
250
251 hostState.GitSetup = true
252 fmt.Println(" Git deployment setup complete")
122 return nil 253 return nil
123} 254}
124 255