Files
pb/deploy.go
2026-01-13 12:19:13 +00:00

330 lines
8.5 KiB
Go

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),
}
}