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/deploy.go | 56 ++++----- cmd/ship/deploy_cmd.go | 138 ++++++++++++++++++++++ cmd/ship/host/init.go | 131 +++++++++++++++++++++ cmd/ship/init.go | 247 ++++++++++++++++++++++++++++++++++++++++ cmd/ship/list.go | 2 +- cmd/ship/logs.go | 2 +- cmd/ship/remove.go | 27 ++++- cmd/ship/restart.go | 2 +- cmd/ship/root.go | 2 + cmd/ship/status.go | 2 +- internal/state/state.go | 8 +- internal/templates/templates.go | 168 +++++++++++++++++++++++++++ 12 files changed, 750 insertions(+), 35 deletions(-) create mode 100644 cmd/ship/deploy_cmd.go create mode 100644 cmd/ship/init.go diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go index 24eed1e..9ac754c 100644 --- a/cmd/ship/deploy.go +++ b/cmd/ship/deploy.go @@ -415,7 +415,7 @@ func updateAppConfig(st *state.State, opts DeployOptions) error { return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) } - if existingApp.Type != "app" { + if existingApp.Type != "app" && existingApp.Type != "git-app" { return fmt.Errorf("%s is a static site, not an app", opts.Name) } @@ -441,33 +441,37 @@ func updateAppConfig(st *state.State, opts DeployOptions) error { return fmt.Errorf("error creating env file: %w", err) } - // Regenerate systemd unit - fmt.Println("-> Updating systemd service...") - workDir := fmt.Sprintf("/var/lib/%s", opts.Name) - binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) - serviceContent, err := templates.SystemdService(map[string]string{ - "Name": opts.Name, - "User": opts.Name, - "WorkDir": workDir, - "BinaryPath": binaryDest, - "Port": strconv.Itoa(existingApp.Port), - "EnvFile": envFilePath, - "Args": opts.Args, - "Memory": opts.Memory, - "CPU": opts.CPU, - }) - if err != nil { - return fmt.Errorf("error generating systemd unit: %w", err) - } + // For git-app, the systemd unit comes from .ship/service in the repo, + // so we only update the env file and restart. + if existingApp.Type != "git-app" { + // Regenerate systemd unit + fmt.Println("-> Updating systemd service...") + workDir := fmt.Sprintf("/var/lib/%s", opts.Name) + binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": opts.Name, + "User": opts.Name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(existingApp.Port), + "EnvFile": envFilePath, + "Args": opts.Args, + "Memory": opts.Memory, + "CPU": opts.CPU, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) - if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { - return fmt.Errorf("error creating systemd unit: %w", err) - } + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) + if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { + return fmt.Errorf("error creating systemd unit: %w", err) + } - fmt.Println("-> Reloading systemd...") - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return fmt.Errorf("error reloading systemd: %w", err) + fmt.Println("-> Reloading systemd...") + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return fmt.Errorf("error reloading systemd: %w", err) + } } fmt.Println("-> Restarting service...") diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go new file mode 100644 index 0000000..ca5c54d --- /dev/null +++ b/cmd/ship/deploy_cmd.go @@ -0,0 +1,138 @@ +package main + +import ( + "fmt" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var deployGitCmd = &cobra.Command{ + Use: "deploy ", + Short: "Manually rebuild and deploy a git-deployed app", + Long: `Trigger a manual rebuild from the latest code in the git repo. + +This runs the same steps as the post-receive hook: checkout code, +install .ship/ configs, docker build (for apps), and restart. + +Examples: + ship deploy myapp`, + Args: cobra.ExactArgs(1), + RunE: runDeployGit, +} + +func runDeployGit(cmd *cobra.Command, args []string) error { + name := args[0] + + 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") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + if app.Type != "git-app" && app.Type != "git-static" { + return fmt.Errorf("%s is not a git-deployed app (type: %s)", name, app.Type) + } + + fmt.Printf("Deploying %s...\n", name) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + if app.Type == "git-app" { + if err := deployGitApp(client, name); err != nil { + return err + } + } else { + if err := deployGitStatic(client, name); err != nil { + return err + } + } + + fmt.Println("\nDeploy complete!") + return nil +} + +func deployGitApp(client *ssh.Client, name string) error { + repo := fmt.Sprintf("/srv/git/%s.git", name) + src := fmt.Sprintf("/var/lib/%s/src", name) + + fmt.Println("-> Checking out code...") + if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", src, repo)); err != nil { + return fmt.Errorf("error checking out code: %w", err) + } + + // Install deployment config from repo + serviceSrc := fmt.Sprintf("%s/.ship/service", src) + serviceDst := fmt.Sprintf("/etc/systemd/system/%s.service", name) + fmt.Println("-> Installing systemd unit...") + if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", serviceSrc, serviceDst)); err != nil { + fmt.Printf(" Warning: no .ship/service found, skipping\n") + } else { + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return fmt.Errorf("error reloading systemd: %w", err) + } + } + + caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", src) + caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + fmt.Println("-> Installing Caddy config...") + if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { + fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") + } else { + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + } + + fmt.Println("-> Building Docker image...") + if err := client.RunSudoStream(fmt.Sprintf("docker build -t %s:latest %s", name, src)); err != nil { + return fmt.Errorf("error building Docker image: %w", err) + } + + fmt.Println("-> Restarting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return fmt.Errorf("error restarting service: %w", err) + } + + return nil +} + +func deployGitStatic(client *ssh.Client, name string) error { + repo := fmt.Sprintf("/srv/git/%s.git", name) + webroot := fmt.Sprintf("/var/www/%s", name) + + fmt.Println("-> Deploying static site...") + if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", webroot, repo)); err != nil { + return fmt.Errorf("error checking out code: %w", err) + } + + caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", webroot) + caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + fmt.Println("-> Installing Caddy config...") + if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { + fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") + } else { + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + } + + return nil +} 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 ( "github.com/bdw/ship/internal/ssh" "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" "github.com/spf13/cobra" ) @@ -105,6 +106,14 @@ import /etc/caddy/sites-enabled/* hostState.BaseDomain = baseDomain fmt.Printf(" Base domain: %s\n", baseDomain) } + + // Git-centric deployment setup (gated on base domain) + if baseDomain != "" { + if err := setupGitDeploy(client, baseDomain, hostState); err != nil { + return err + } + } + if st.GetDefaultHost() == "" { st.SetDefaultHost(host) fmt.Printf(" Set %s as default host\n", host) @@ -119,6 +128,128 @@ import /etc/caddy/sites-enabled/* fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") fmt.Println(" 2. Deploy a static site:") fmt.Printf(" ship --static --dir ./dist --domain example.com\n") + if baseDomain != "" { + fmt.Println(" 3. Initialize a git-deployed app:") + fmt.Printf(" ship init myapp\n") + } + return nil +} + +func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error { + fmt.Println("-> Installing Docker...") + dockerCommands := []string{ + "apt-get install -y ca-certificates curl gnupg", + "install -m 0755 -d /etc/apt/keyrings", + "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", + "chmod a+r /etc/apt/keyrings/docker.asc", + `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`, + "apt-get update", + "apt-get install -y docker-ce docker-ce-cli containerd.io", + } + for _, cmd := range dockerCommands { + if _, err := client.RunSudo(cmd); err != nil { + return fmt.Errorf("error installing Docker: %w", err) + } + } + fmt.Println(" Docker installed") + + fmt.Println("-> Installing git and fcgiwrap...") + if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { + return fmt.Errorf("error installing git/fcgiwrap: %w", err) + } + fmt.Println(" git and fcgiwrap installed") + + fmt.Println("-> Creating git user...") + // Create git user (ignore error if already exists) + client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") + if _, err := client.RunSudo("usermod -aG docker git"); err != nil { + return fmt.Errorf("error adding git user to docker group: %w", err) + } + fmt.Println(" git user created") + + fmt.Println("-> Copying SSH keys to git user...") + copyKeysCommands := []string{ + "mkdir -p /home/git/.ssh", + "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", + "chown -R git:git /home/git/.ssh", + "chmod 700 /home/git/.ssh", + "chmod 600 /home/git/.ssh/authorized_keys", + } + for _, cmd := range copyKeysCommands { + if _, err := client.RunSudo(cmd); err != nil { + return fmt.Errorf("error copying SSH keys: %w", err) + } + } + fmt.Println(" SSH keys copied") + + fmt.Println("-> Creating /srv/git...") + if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil { + return fmt.Errorf("error creating /srv/git: %w", err) + } + if _, err := client.RunSudo("chown git:git /srv/git"); err != nil { + return fmt.Errorf("error setting /srv/git ownership: %w", err) + } + fmt.Println(" /srv/git created") + + fmt.Println("-> Writing sudoers for git user...") + 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 * +` + if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { + return fmt.Errorf("error writing sudoers: %w", err) + } + if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil { + return fmt.Errorf("error setting sudoers permissions: %w", err) + } + fmt.Println(" sudoers configured") + + fmt.Println("-> Writing vanity import template...") + vanityHTML := ` + +{{$path := trimPrefix "/" .Req.URL.Path}} +{{$parts := splitList "/" $path}} +{{$module := first $parts}} + + +go get {{.Host}}/{{$module}} + +` + if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil { + return fmt.Errorf("error creating vanity directory: %w", err) + } + if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil { + return fmt.Errorf("error writing vanity template: %w", err) + } + fmt.Println(" vanity template written") + + fmt.Println("-> Writing base domain Caddy config...") + codeCaddyContent, err := templates.CodeCaddy(map[string]string{ + "BaseDomain": baseDomain, + }) + if err != nil { + return fmt.Errorf("error generating code caddy config: %w", err) + } + if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil { + return fmt.Errorf("error writing code caddy config: %w", err) + } + fmt.Println(" base domain Caddy config written") + + fmt.Println("-> Starting Docker and fcgiwrap...") + if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { + return fmt.Errorf("error enabling services: %w", err) + } + if _, err := client.RunSudo("systemctl start docker fcgiwrap"); err != nil { + return fmt.Errorf("error starting services: %w", err) + } + fmt.Println(" Docker and fcgiwrap started") + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + fmt.Println(" Caddy reloaded") + + hostState.GitSetup = true + fmt.Println(" Git deployment setup complete") return nil } 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 +} diff --git a/cmd/ship/list.go b/cmd/ship/list.go index 404ca68..a10b2ca 100644 --- a/cmd/ship/list.go +++ b/cmd/ship/list.go @@ -40,7 +40,7 @@ func runList(cmd *cobra.Command, args []string) error { fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") for name, app := range apps { port := "" - if app.Type == "app" { + if app.Type == "app" || app.Type == "git-app" { port = fmt.Sprintf(":%d", app.Port) } domain := app.Domain diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go index 1932c18..4d85fe1 100644 --- a/cmd/ship/logs.go +++ b/cmd/ship/logs.go @@ -44,7 +44,7 @@ func runLogs(cmd *cobra.Command, args []string) error { return err } - if app.Type != "app" { + if app.Type != "app" && app.Type != "git-app" { return fmt.Errorf("logs are only available for apps, not static sites") } diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go index 922eb8f..8380e87 100644 --- a/cmd/ship/remove.go +++ b/cmd/ship/remove.go @@ -46,7 +46,8 @@ func runRemove(cmd *cobra.Command, args []string) error { } defer client.Close() - if app.Type == "app" { + switch app.Type { + case "app": fmt.Println("-> Stopping service...") client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) @@ -58,7 +59,29 @@ func runRemove(cmd *cobra.Command, args []string) error { client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) client.RunSudo(fmt.Sprintf("userdel %s", name)) - } else { + + case "git-app": + fmt.Println("-> Stopping service...") + client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) + client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) + + client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) + client.RunSudo("systemctl daemon-reload") + + fmt.Println("-> Removing Docker image...") + client.RunSudo(fmt.Sprintf("docker rmi %s:latest", name)) + + fmt.Println("-> Removing files...") + client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) + client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) + client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) + + case "git-static": + fmt.Println("-> Removing files...") + client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) + client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) + + default: // "static" fmt.Println("-> Removing files...") client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) } diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go index 2c74c62..404624a 100644 --- a/cmd/ship/restart.go +++ b/cmd/ship/restart.go @@ -37,7 +37,7 @@ func runRestart(cmd *cobra.Command, args []string) error { return err } - if app.Type != "app" { + if app.Type != "app" && app.Type != "git-app" { return fmt.Errorf("restart is only available for apps, not static sites") } diff --git a/cmd/ship/root.go b/cmd/ship/root.go index 837fd4c..93280f5 100644 --- a/cmd/ship/root.go +++ b/cmd/ship/root.go @@ -88,6 +88,8 @@ func init() { rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(restartCmd) rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(deployGitCmd) rootCmd.AddCommand(env.Cmd) rootCmd.AddCommand(host.Cmd) rootCmd.AddCommand(uiCmd) diff --git a/cmd/ship/status.go b/cmd/ship/status.go index 03c548b..536ec8c 100644 --- a/cmd/ship/status.go +++ b/cmd/ship/status.go @@ -37,7 +37,7 @@ func runStatus(cmd *cobra.Command, args []string) error { return err } - if app.Type != "app" { + if app.Type != "app" && app.Type != "git-app" { return fmt.Errorf("status is only available for apps, not static sites") } diff --git a/internal/state/state.go b/internal/state/state.go index 38657cb..324fd34 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -17,15 +17,17 @@ type State struct { type Host struct { NextPort int `json:"next_port"` BaseDomain string `json:"base_domain,omitempty"` + GitSetup bool `json:"git_setup,omitempty"` Apps map[string]*App `json:"apps"` } // App represents a deployed application or static site type App struct { - Type string `json:"type"` // "app" or "static" + Type string `json:"type"` // "app", "static", "git-app", or "git-static" Domain string `json:"domain"` - Port int `json:"port,omitempty"` // only for type="app" - Env map[string]string `json:"env,omitempty"` // only for type="app" + Port int `json:"port,omitempty"` // only for type="app" or "git-app" + Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git" + Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app" Args string `json:"args,omitempty"` // only for type="app" Files []string `json:"files,omitempty"` // only for type="app" Memory string `json:"memory,omitempty"` // only for type="app" diff --git a/internal/templates/templates.go b/internal/templates/templates.go index ce1cbe5..8615117 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -86,3 +86,171 @@ func StaticCaddy(data map[string]string) (string, error) { return buf.String(), nil } + +var postReceiveHookTemplate = `#!/bin/bash +set -euo pipefail + +REPO=/srv/git/{{.Name}}.git +SRC=/var/lib/{{.Name}}/src +NAME={{.Name}} + +while read oldrev newrev refname; do + branch=$(git rev-parse --symbolic --abbrev-ref "$refname") + [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; } +done + +echo "==> Checking out code..." +git --work-tree="$SRC" --git-dir="$REPO" checkout -f main + +cd "$SRC" + +# Install deployment config from repo +if [ -f .ship/service ]; then + echo "==> Installing systemd unit..." + sudo cp .ship/service /etc/systemd/system/${NAME}.service + sudo systemctl daemon-reload +fi +if [ -f .ship/Caddyfile ]; then + echo "==> Installing Caddy config..." + sudo cp .ship/Caddyfile /etc/caddy/sites-enabled/${NAME}.caddy + sudo systemctl reload caddy +fi + +echo "==> Building Docker image..." +docker build -t ${NAME}:latest . + +echo "==> Restarting service..." +sudo systemctl restart ${NAME} + +echo "==> Deploy complete!" +` + +var postReceiveHookStaticTemplate = `#!/bin/bash +set -euo pipefail + +REPO=/srv/git/{{.Name}}.git +WEBROOT=/var/www/{{.Name}} +NAME={{.Name}} + +while read oldrev newrev refname; do + branch=$(git rev-parse --symbolic --abbrev-ref "$refname") + [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; } +done + +echo "==> Deploying static site..." +git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main + +if [ -f "$WEBROOT/.ship/Caddyfile" ]; then + echo "==> Installing Caddy config..." + sudo cp "$WEBROOT/.ship/Caddyfile" /etc/caddy/sites-enabled/${NAME}.caddy + sudo systemctl reload caddy +fi + +echo "==> Deploy complete!" +` + +var codeCaddyTemplate = `{{.BaseDomain}} { + @goget query go-get=1 + handle @goget { + root * /opt/ship/vanity + templates + rewrite * /index.html + file_server + } + + @git path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack|git-receive-pack)$" + handle @git { + reverse_proxy unix//run/fcgiwrap.socket { + transport fastcgi { + env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend + env GIT_PROJECT_ROOT /srv/git + env GIT_HTTP_EXPORT_ALL 1 + env REQUEST_METHOD {method} + env QUERY_STRING {query} + env PATH_INFO {path} + } + } + } + + handle { + respond "not found" 404 + } +} +` + +var dockerServiceTemplate = `[Unit] +Description={{.Name}} +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +ExecStartPre=-/usr/bin/docker rm -f {{.Name}} +ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ + -p 127.0.0.1:{{.Port}}:{{.Port}} \ + --env-file /etc/ship/env/{{.Name}}.env \ + -v /var/lib/{{.Name}}/data:/data \ + {{.Name}}:latest +ExecStop=/usr/bin/docker stop -t 10 {{.Name}} +Restart=always +RestartSec=5s + +[Install] +WantedBy=multi-user.target +` + +var defaultAppCaddyTemplate = `{{.Domain}} { + reverse_proxy 127.0.0.1:{{.Port}} +} +` + +var defaultStaticCaddyTemplate = `{{.Domain}} { + root * /var/www/{{.Name}} + file_server + encode gzip +} +` + +// PostReceiveHook generates a post-receive hook for git-app repos +func PostReceiveHook(data map[string]string) (string, error) { + return renderTemplate("post-receive", postReceiveHookTemplate, data) +} + +// PostReceiveHookStatic generates a post-receive hook for git-static repos +func PostReceiveHookStatic(data map[string]string) (string, error) { + return renderTemplate("post-receive-static", postReceiveHookStaticTemplate, data) +} + +// CodeCaddy generates the base domain Caddy config for vanity imports + git HTTP +func CodeCaddy(data map[string]string) (string, error) { + return renderTemplate("code-caddy", codeCaddyTemplate, data) +} + +// DockerService generates a systemd unit for a Docker-based app +func DockerService(data map[string]string) (string, error) { + return renderTemplate("docker-service", dockerServiceTemplate, data) +} + +// DefaultAppCaddy generates a default Caddyfile for a git-app +func DefaultAppCaddy(data map[string]string) (string, error) { + return renderTemplate("default-app-caddy", defaultAppCaddyTemplate, data) +} + +// DefaultStaticCaddy generates a default Caddyfile for a git-static site +func DefaultStaticCaddy(data map[string]string) (string, error) { + return renderTemplate("default-static-caddy", defaultStaticCaddyTemplate, data) +} + +func renderTemplate(name, tmplStr string, data map[string]string) (string, error) { + tmpl, err := template.New(name).Parse(tmplStr) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} -- cgit v1.2.3