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"` } 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"` } 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 } serviceDir := renderServiceTemplate(defaultServiceDirTemplate, serviceName) envFile := renderServiceTemplate(defaultEnvFileTemplate, serviceName) unitServiceDir := renderServiceTemplate(defaultServiceDirTemplate, "%i") unitEnvFile := renderServiceTemplate(defaultEnvFileTemplate, "%i") step := 1 printStep(step, totalDeploySteps, "validating configuration") step++ printStep(step, totalDeploySteps, "probing remote host") remoteOS, err := runSSHOutput(serverIP, "/bin/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, "/bin/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", append(sshArgs(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", append(sshArgs(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", "-e", "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", "-az", "--delete", localPath+"/", remotePath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return err } } return nil } func sshArgs(server string) []string { return []string{ "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", fmt.Sprintf("root@%s", server), } }