From a8ad8e934d15d2bf84f942414a89af1d2691adbc Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 8 Feb 2026 12:32:59 -0800 Subject: 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 ` command to create bare repos and .ship/ config - Add `ship deploy ` 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 --- cmd/ship/init.go | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 cmd/ship/init.go (limited to 'cmd/ship/init.go') diff --git a/cmd/ship/init.go b/cmd/ship/init.go new file mode 100644 index 0000000..167347d --- /dev/null +++ b/cmd/ship/init.go @@ -0,0 +1,247 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init ", + Short: "Initialize a git-deployed project", + Long: `Create a bare git repo on the VPS and generate local .ship/ config files. + +Pushing to the remote triggers an automatic docker build and deploy (for apps) +or a static file checkout (for static sites). + +Examples: + # Initialize an app (Docker-based) + ship init myapp + + # Initialize with a custom domain + ship init myapp --domain custom.example.com + + # Initialize a static site + ship init mysite --static`, + Args: cobra.ExactArgs(1), + RunE: runInit, +} + +func init() { + initCmd.Flags().Bool("static", false, "Initialize as static site") + initCmd.Flags().String("domain", "", "Custom domain (default: name.basedomain)") +} + +func runInit(cmd *cobra.Command, args []string) error { + name := args[0] + static, _ := cmd.Flags().GetBool("static") + domain, _ := cmd.Flags().GetString("domain") + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host := hostFlag + if host == "" { + host = st.GetDefaultHost() + } + if host == "" { + return fmt.Errorf("--host is required") + } + + hostState := st.GetHost(host) + if !hostState.GitSetup { + return fmt.Errorf("git deployment not set up on %s (run 'ship host init --base-domain example.com' first)", host) + } + + // Check if app already exists + if _, err := st.GetApp(host, name); err == nil { + return fmt.Errorf("app %s already exists", name) + } + + // Resolve domain + if domain == "" && hostState.BaseDomain != "" { + domain = name + "." + hostState.BaseDomain + } + if domain == "" { + return fmt.Errorf("--domain required (or configure base domain)") + } + + appType := "git-app" + if static { + appType = "git-static" + } + + // Allocate port for apps + port := 0 + if !static { + port = st.AllocatePort(host) + } + + fmt.Printf("Initializing %s: %s\n", appType, name) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + // Create bare repo + fmt.Println("-> Creating bare git repo...") + repo := fmt.Sprintf("/srv/git/%s.git", name) + if _, err := client.RunSudo(fmt.Sprintf("sudo -u git git init --bare %s", repo)); err != nil { + return fmt.Errorf("error creating bare repo: %w", err) + } + + if static { + // Create web root + fmt.Println("-> Creating web root...") + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p /var/www/%s", name)); err != nil { + return fmt.Errorf("error creating web root: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown git:git /var/www/%s", name)); err != nil { + return fmt.Errorf("error setting web root ownership: %w", err) + } + + // Write post-receive hook + fmt.Println("-> Writing post-receive hook...") + hookContent, err := templates.PostReceiveHookStatic(map[string]string{ + "Name": name, + }) + if err != nil { + return fmt.Errorf("error generating hook: %w", err) + } + hookPath := fmt.Sprintf("%s/hooks/post-receive", repo) + if err := client.WriteSudoFile(hookPath, hookContent); err != nil { + return fmt.Errorf("error writing hook: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil { + return fmt.Errorf("error making hook executable: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil { + return fmt.Errorf("error setting hook ownership: %w", err) + } + } else { + // Create directories for app + fmt.Println("-> Creating app directories...") + dirs := []string{ + fmt.Sprintf("/var/lib/%s/data", name), + fmt.Sprintf("/var/lib/%s/src", name), + } + for _, dir := range dirs { + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { + return fmt.Errorf("error creating directory %s: %w", dir, err) + } + } + if _, err := client.RunSudo(fmt.Sprintf("chown -R git:git /var/lib/%s", name)); err != nil { + return fmt.Errorf("error setting directory ownership: %w", err) + } + + // Create env file + fmt.Println("-> Creating environment file...") + envContent := fmt.Sprintf("PORT=%d\nDATA_DIR=/data\n", port) + envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) + if err := client.WriteSudoFile(envPath, envContent); err != nil { + return fmt.Errorf("error creating env file: %w", err) + } + + // Write post-receive hook + fmt.Println("-> Writing post-receive hook...") + hookContent, err := templates.PostReceiveHook(map[string]string{ + "Name": name, + }) + if err != nil { + return fmt.Errorf("error generating hook: %w", err) + } + hookPath := fmt.Sprintf("%s/hooks/post-receive", repo) + if err := client.WriteSudoFile(hookPath, hookContent); err != nil { + return fmt.Errorf("error writing hook: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil { + return fmt.Errorf("error making hook executable: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil { + return fmt.Errorf("error setting hook ownership: %w", err) + } + } + + // Save state + st.AddApp(host, name, &state.App{ + Type: appType, + Domain: domain, + Port: port, + Repo: repo, + }) + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + // Generate local .ship/ files + fmt.Println("-> Generating local .ship/ config...") + if err := os.MkdirAll(".ship", 0755); err != nil { + return fmt.Errorf("error creating .ship directory: %w", err) + } + + if static { + caddyContent, err := templates.DefaultStaticCaddy(map[string]string{ + "Domain": domain, + "Name": name, + }) + if err != nil { + return fmt.Errorf("error generating Caddyfile: %w", err) + } + if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { + return fmt.Errorf("error writing Caddyfile: %w", err) + } + } else { + caddyContent, err := templates.DefaultAppCaddy(map[string]string{ + "Domain": domain, + "Port": strconv.Itoa(port), + }) + if err != nil { + return fmt.Errorf("error generating Caddyfile: %w", err) + } + if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { + return fmt.Errorf("error writing Caddyfile: %w", err) + } + + serviceContent, err := templates.DockerService(map[string]string{ + "Name": name, + "Port": strconv.Itoa(port), + }) + if err != nil { + return fmt.Errorf("error generating service file: %w", err) + } + if err := os.WriteFile(filepath.Join(".ship", "service"), []byte(serviceContent), 0644); err != nil { + return fmt.Errorf("error writing service file: %w", err) + } + } + + // Resolve SSH hostname for git remote URL + sshHost := host + + fmt.Printf("\nProject initialized: %s\n", name) + fmt.Println("\nGenerated:") + fmt.Println(" .ship/Caddyfile — Caddy config (edit to customize routing)") + if !static { + fmt.Println(" .ship/service — systemd unit (edit to customize resources, ports)") + } + fmt.Println("\nNext steps:") + if static { + fmt.Println(" git add .ship/") + } else { + fmt.Println(" git add .ship/ Dockerfile") + } + fmt.Println(" git commit -m \"initial deploy\"") + fmt.Printf(" git remote add ship git@%s:%s\n", sshHost, repo) + fmt.Println(" git push ship main") + + return nil +} -- cgit v1.2.3