diff --git a/deploy.go b/deploy.go new file mode 100644 index 0000000..0c90d91 --- /dev/null +++ b/deploy.go @@ -0,0 +1,337 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +const ( + defaultServiceDirTemplate = "/root/pb/{service}" + defaultEnvFileTemplate = "/root/pb/{service}/.env" + totalDeploySteps = 7 +) + +type pbToml struct { + Server serverConfig `toml:"server"` + PocketBase pocketBaseConfig `toml:"pocketbase"` + Deployment deploymentConfig `toml:"deployment"` +} + +type serverConfig struct { + IP string `toml:"ip"` + Port int `toml:"port"` + Domain string `toml:"domain"` +} + +type pocketBaseConfig struct { + Version string `toml:"version"` + ServiceName string `toml:"service_name"` +} + +type deploymentConfig struct { + ServiceDir string `toml:"service_dir"` + EnvironmentFile string `toml:"environment_file"` +} + +func runDeploy() error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + configPath := filepath.Join(cwd, "pb.toml") + cfg, err := loadPBConfig(configPath) + if err != nil { + return err + } + + serviceName := cfg.PocketBase.ServiceName + if serviceName == "" { + return fmt.Errorf("pb.toml missing [pocketbase].service_name") + } + + serverIP := cfg.Server.IP + if serverIP == "" { + return fmt.Errorf("pb.toml missing [server].ip") + } + + domain := cfg.Server.Domain + if domain == "" { + return fmt.Errorf("pb.toml missing [server].domain") + } + + port := cfg.Server.Port + if port <= 0 { + return fmt.Errorf("pb.toml server.port must be greater than zero") + } + + version := cfg.PocketBase.Version + if version == "" { + version = defaultPocketbaseVersion + } + + serviceDirTemplate := cfg.Deployment.ServiceDir + if serviceDirTemplate == "" { + serviceDirTemplate = defaultServiceDirTemplate + } + + envFileTemplate := cfg.Deployment.EnvironmentFile + if envFileTemplate == "" { + envFileTemplate = defaultEnvFileTemplate + } + + serviceDir := renderServiceTemplate(serviceDirTemplate, serviceName) + envFile := renderServiceTemplate(envFileTemplate, serviceName) + unitServiceDir := renderServiceTemplate(serviceDirTemplate, "%i") + unitEnvFile := renderServiceTemplate(envFileTemplate, "%i") + + step := 1 + printStep(step, totalDeploySteps, "validating configuration") + step++ + printStep(step, totalDeploySteps, "probing remote host") + remoteOS, err := runSSHOutput(serverIP, "uname -s") + if err != nil { + return fmt.Errorf("failed to determine remote OS: %w", err) + } + if !strings.EqualFold(remoteOS, "linux") { + return fmt.Errorf("unsupported remote OS %q", remoteOS) + } + + machineArch, err := runSSHOutput(serverIP, "uname -m") + if err != nil { + return fmt.Errorf("failed to determine remote architecture: %w", err) + } + arch, err := translateMachineArch(machineArch) + if err != nil { + return err + } + + assetName := pocketbaseAsset(version, "linux", arch) + assetURL := fmt.Sprintf("https://github.com/pocketbase/pocketbase/releases/download/v%s/%s", version, assetName) + + step++ + printStep(step, totalDeploySteps, "configuring firewall") + if err := runSSHCommand(serverIP, firewallScript(port)); err != nil { + return fmt.Errorf("firewall setup failed: %w", err) + } + + step++ + printStep(step, totalDeploySteps, "installing caddy") + if err := runSSHCommand(serverIP, caddyScript(domain, port, serviceName)); err != nil { + return fmt.Errorf("caddy setup failed: %w", err) + } + + step++ + printStep(step, totalDeploySteps, "deploying PocketBase binary") + if err := runSSHCommand(serverIP, pocketbaseSetupScript(serviceDir, envFile, version, assetURL, port)); err != nil { + return fmt.Errorf("PocketBase setup failed: %w", err) + } + + step++ + printStep(step, totalDeploySteps, "syncing migrations/hooks/static assets") + if err := syncLocalDirectories(serverIP, serviceDir, []string{"pb_migrations", "pb_hooks", "pb_public"}); err != nil { + return fmt.Errorf("asset sync failed: %w", err) + } + + step++ + printStep(step, totalDeploySteps, "configuring systemd service") + if err := runSSHCommand(serverIP, systemdScript(unitServiceDir, unitEnvFile, serviceName)); err != nil { + return fmt.Errorf("systemd setup failed: %w", err) + } + + fmt.Printf("\nDeployment complete; PocketBase should be reachable at https://%s\n", domain) + return nil +} + +func loadPBConfig(path string) (*pbToml, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg pbToml + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse pb.toml: %w", err) + } + return &cfg, nil +} + +func printStep(idx, total int, message string) { + fmt.Printf("\nStep %d/%d: %s\n", idx, total, message) +} + +func renderServiceTemplate(tpl, serviceName string) string { + return strings.ReplaceAll(tpl, "{service}", serviceName) +} + +func translateMachineArch(value string) (string, error) { + machine := strings.TrimSpace(strings.ToLower(value)) + switch machine { + case "x86_64", "amd64": + return "amd64", nil + case "i386", "i486", "i586", "i686": + return "386", nil + case "armv7l": + return "armv7", nil + case "armv6l": + return "arm", nil + case "aarch64", "arm64": + return "arm64", nil + } + return "", fmt.Errorf("unsupported remote architecture %q", value) +} + +func pocketbaseAsset(version, osName, arch string) string { + return fmt.Sprintf("pocketbase_%s_%s_%s.zip", version, osName, arch) +} + +func firewallScript(port int) string { + return fmt.Sprintf(`set -euo pipefail +if ! command -v ufw >/dev/null; then + apt-get update -y + apt-get install -y ufw +fi +ufw allow OpenSSH +ufw allow %d/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw --force enable +`, port) +} + +func caddyScript(domain string, port int, serviceName string) string { + return fmt.Sprintf(`set -euo pipefail +if ! command -v caddy >/dev/null; then + apt-get update -y + apt-get install -y curl gnupg2 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list + apt-get update -y + apt-get install -y caddy +fi +mkdir -p /etc/caddy/sites +cat <<'EOF' > /etc/caddy/Caddyfile +import /etc/caddy/sites/*.caddy +EOF +cat <<'EOF' > /etc/caddy/sites/pb-%s.caddy +%s { + request_body { + max_size 10MB + } + encode gzip + reverse_proxy 127.0.0.1:%d { + transport http { + read_timeout 360s + } + } +} +EOF +systemctl daemon-reload +systemctl enable --now caddy.service +systemctl reload-or-restart caddy.service +`, serviceName, domain, port) +} + +func pocketbaseSetupScript(serviceDir, envFile, version, assetURL string, port int) string { + return fmt.Sprintf(`set -euo pipefail +service_dir="%s" +mkdir -p "$service_dir" +apt-get update -y +apt-get install -y curl unzip +binary="%s/pocketbase" +if [ ! -x "$binary" ]; then + tmp=$(mktemp) + curl -fsSL -o "$tmp" "%s" + unzip -p "$tmp" pocketbase > "$binary" + chmod +x "$binary" + rm -f "$tmp" +fi +env_file="%s" +current_port="" +if [ -f "$env_file" ]; then + current_port=$(grep '^PORT=' "$env_file" | head -n 1 | cut -d= -f2) +fi +if [ "$current_port" != "%d" ]; then + cat <<'EOF' > "$env_file" +PORT=%d +EOF +fi +`, serviceDir, serviceDir, assetURL, envFile, port, port) +} + +func systemdScript(serviceDir, envFile, serviceName string) string { + return fmt.Sprintf(`set -euo pipefail +cat <<'EOF' > /etc/systemd/system/pb@.service +[Unit] +Description=PocketBase instance %%i +After=network.target + +[Service] +User=root +Group=root +WorkingDirectory=%s +EnvironmentFile=%s +ExecStart=%s/pocketbase serve --http="127.0.0.1:${PORT}" +Restart=on-failure +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload +systemctl enable --now pb@%s +systemctl restart pb@%s +`, serviceDir, envFile, serviceDir, serviceName, serviceName) +} + +func runSSHCommand(server, script string) error { + cmd := exec.Command("ssh", "-o", "BatchMode=yes", fmt.Sprintf("root@%s", server), "bash", "-lc", script) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runSSHOutput(server, script string) (string, error) { + cmd := exec.Command("ssh", "-o", "BatchMode=yes", fmt.Sprintf("root@%s", server), "bash", "-lc", script) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil +} + +func syncLocalDirectories(server, remoteBase string, dirs []string) error { + for _, dir := range dirs { + localPath := filepath.Join(".", dir) + info, err := os.Stat(localPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Printf("- %s does not exist locally, skipping\n", dir) + continue + } + return err + } + if !info.IsDir() { + fmt.Printf("- %s exists but is not a directory, skipping\n", dir) + continue + } + + remotePath := fmt.Sprintf("root@%s:%s/%s", server, remoteBase, dir) + cmd := exec.Command("rsync", "-az", "--delete", localPath+"/", remotePath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + } + return nil +} diff --git a/go.mod b/go.mod index 0b53995..7e6b729 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module pb go 1.23.1 -require github.com/charmbracelet/bubbletea v0.26.1 +require ( + github.com/charmbracelet/bubbletea v0.26.1 + github.com/pelletier/go-toml/v2 v2.2.4 +) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/go.sum b/go.sum index 76b7b79..2f31bc6 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= diff --git a/main.go b/main.go index 140f530..018ff66 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,7 @@ func defaultCommands() []command { return []command{ {name: "init", description: "start a new PocketBase project", action: runInit}, {name: "dev", description: "run the PocketBase binary locally", action: runDev}, - {name: "deploy", description: "deploy the PocketBase project", action: placeholderAction("deploy")}, + {name: "deploy", description: "deploy the PocketBase project", action: runDeploy}, {name: "logs", description: "show PocketBase logs", action: placeholderAction("logs")}, {name: "secrets", description: "manage deployment secrets", action: placeholderAction("secrets")}, } @@ -284,6 +284,10 @@ domain = "example.com" [pocketbase] version = "%s" service_name = "%s" + +[deployment] +service_dir = "/root/pb/{service}" +environment_file = "/root/pb/{service}/.env" ` return os.WriteFile(path, []byte(fmt.Sprintf(tmpl, defaultPocketbaseVersion, serviceName)), 0o644) }