391 lines
9.9 KiB
Go
391 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
)
|
|
|
|
const (
|
|
defaultServiceDirTemplate = "/root/pb/{service}"
|
|
defaultEnvFileTemplate = "/root/pb/{service}/.env"
|
|
totalDeploySteps = 6
|
|
)
|
|
|
|
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")
|
|
}
|
|
defer closeSSHControlMaster(serverIP)
|
|
|
|
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")
|
|
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)
|
|
}
|
|
|
|
arch, err := detectRemoteArch(serverIP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("arch; %s", arch)
|
|
|
|
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("Step %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 detectRemoteArch(server string) (string, error) {
|
|
probes := []string{
|
|
"uname -m",
|
|
"arch",
|
|
}
|
|
var lastErr error
|
|
for _, probe := range probes {
|
|
output, err := runSSHOutput(server, probe)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("%s failed: %w", probe, err)
|
|
continue
|
|
}
|
|
|
|
arch, err := translateMachineArch(output)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("%s -> %w", probe, err)
|
|
continue
|
|
}
|
|
return arch, nil
|
|
}
|
|
if lastErr != nil {
|
|
return "", fmt.Errorf("failed to determine remote architecture: %w", lastErr)
|
|
}
|
|
return "", fmt.Errorf("failed to determine remote architecture")
|
|
}
|
|
|
|
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", "--noprofile", "--norc", "-c", 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", "--noprofile", "--norc", "-c", 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)
|
|
rsyncCmd := rsyncSSHCommand(server)
|
|
cmd := exec.Command("rsync", "-e", rsyncCmd, "-az", "--delete", localPath+"/", remotePath)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rsyncSSHCommand(server string) string {
|
|
args := append([]string(nil), sshSharedArgs(server)...)
|
|
args = append(args, fmt.Sprintf("root@%s", server))
|
|
return fmt.Sprintf("ssh %s", strings.Join(args, " "))
|
|
}
|
|
|
|
func sshArgs(server string) []string {
|
|
args := append([]string(nil), sshSharedArgs(server)...)
|
|
return append(args, fmt.Sprintf("root@%s", server))
|
|
}
|
|
|
|
func sshSharedArgs(server string) []string {
|
|
return []string{
|
|
"-o", "BatchMode=yes",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ControlMaster=auto",
|
|
"-o", fmt.Sprintf("ControlPath=%s", sshControlPath(server)),
|
|
"-o", "ControlPersist=5m",
|
|
}
|
|
}
|
|
|
|
func sshControlPath(server string) string {
|
|
sum := sha1.Sum([]byte(server))
|
|
return filepath.Join(sshControlDir(), fmt.Sprintf("pb-ssh-%x.sock", sum))
|
|
}
|
|
|
|
func sshControlDir() string {
|
|
if dir := os.Getenv("PB_SSH_CONTROL_DIR"); dir != "" {
|
|
return dir
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
return os.TempDir()
|
|
}
|
|
return "/tmp"
|
|
}
|
|
|
|
func closeSSHControlMaster(server string) {
|
|
args := append([]string(nil), sshSharedArgs(server)...)
|
|
args = append(args, "-O", "exit", fmt.Sprintf("root@%s", server))
|
|
_ = exec.Command("ssh", args...).Run()
|
|
}
|