2050 lines
48 KiB
Go
2050 lines
48 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/mattn/go-runewidth"
|
|
"github.com/pelletier/go-toml/v2"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var (
|
|
initServiceNameArg string
|
|
invocationArgs []string
|
|
)
|
|
|
|
func main() {
|
|
commands := defaultCommands()
|
|
args := os.Args[1:]
|
|
if len(args) > 0 && args[0] == "--" {
|
|
args = args[1:]
|
|
}
|
|
|
|
invocationArgs = args
|
|
|
|
if len(args) == 0 {
|
|
printUsage(commands)
|
|
return
|
|
}
|
|
|
|
if isHelpFlag(args[0]) {
|
|
printUsage(commands)
|
|
return
|
|
}
|
|
|
|
initServiceNameArg = ""
|
|
if args[0] == "init" && len(args) > 1 {
|
|
initServiceNameArg = args[1]
|
|
}
|
|
|
|
if args[0] == "menu" {
|
|
if err := tea.NewProgram(newModel()).Start(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := runCommandByName(commands, args[0]); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
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: "setup", description: "provision the remote server and install PocketBase", action: runSetup},
|
|
{name: "deploy", description: "sync migrations/hooks/static assets (runs setup if needed)", action: runDeploy},
|
|
{name: "status", description: "show status for the remote PocketBase service", action: runStatus},
|
|
{name: "logs", description: "show PocketBase logs", action: runLogs},
|
|
{name: "secrets", description: "manage deployment secrets", action: runSecrets},
|
|
}
|
|
}
|
|
|
|
type commandAction func() error
|
|
|
|
type command struct {
|
|
name string
|
|
description string
|
|
action commandAction
|
|
}
|
|
|
|
func runCommandByName(commands []command, name string) error {
|
|
for _, cmd := range commands {
|
|
if cmd.name == name {
|
|
return cmd.action()
|
|
}
|
|
}
|
|
return fmt.Errorf("unknown command %q", name)
|
|
}
|
|
|
|
func printUsage(commands []command) {
|
|
fmt.Println("Usage: pb <command> [options]")
|
|
fmt.Println()
|
|
fmt.Println("Options:")
|
|
fmt.Println(" -h, --help show this help message")
|
|
fmt.Println()
|
|
fmt.Println("Commands:")
|
|
for _, cmd := range commands {
|
|
fmt.Printf(" %-10s %s\n", cmd.name, cmd.description)
|
|
}
|
|
fmt.Println()
|
|
fmt.Println("Use \"pb menu\" for an interactive helper.")
|
|
}
|
|
|
|
func isHelpFlag(arg string) bool {
|
|
return arg == "-h" || arg == "--help"
|
|
}
|
|
|
|
func newModel() model {
|
|
return model{commands: defaultCommands()}
|
|
}
|
|
|
|
type model struct {
|
|
commands []command
|
|
cursor int
|
|
status string
|
|
running bool
|
|
lastCommand string
|
|
}
|
|
|
|
type commandResultMsg struct {
|
|
name string
|
|
err error
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
case "up", "k":
|
|
if m.running {
|
|
return m, nil
|
|
}
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
} else {
|
|
m.cursor = len(m.commands) - 1
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
if m.running {
|
|
return m, nil
|
|
}
|
|
if m.cursor < len(m.commands)-1 {
|
|
m.cursor++
|
|
} else {
|
|
m.cursor = 0
|
|
}
|
|
return m, nil
|
|
case "enter":
|
|
if m.running {
|
|
return m, nil
|
|
}
|
|
selected := m.commands[m.cursor]
|
|
m.running = true
|
|
m.lastCommand = selected.name
|
|
m.status = fmt.Sprintf("Running %s...", selected.name)
|
|
return m, runCommand(selected)
|
|
}
|
|
case commandResultMsg:
|
|
m.running = false
|
|
if msg.err != nil {
|
|
m.status = fmt.Sprintf("%s failed: %v", msg.name, msg.err)
|
|
} else {
|
|
m.status = fmt.Sprintf("%s finished successfully", msg.name)
|
|
}
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) View() string {
|
|
var b strings.Builder
|
|
b.WriteString("PocketBase deployment helper\n\n")
|
|
for idx, cmd := range m.commands {
|
|
cursor := " "
|
|
if m.cursor == idx {
|
|
cursor = ">"
|
|
}
|
|
b.WriteString(fmt.Sprintf("%s %s\n %s\n", cursor, cmd.name, cmd.description))
|
|
}
|
|
b.WriteString("\n")
|
|
if m.status != "" {
|
|
b.WriteString(fmt.Sprintf("%s\n\n", m.status))
|
|
}
|
|
if m.running {
|
|
b.WriteString("(running... press ctrl+c to abort)\n")
|
|
}
|
|
b.WriteString("↑/↓ to navigate, enter to run, q to quit\n")
|
|
return b.String()
|
|
}
|
|
|
|
func runCommand(cmd command) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return commandResultMsg{name: cmd.name, err: cmd.action()}
|
|
}
|
|
}
|
|
|
|
func runInit() error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pbPath := filepath.Join(cwd, "pb.toml")
|
|
if _, err := os.Stat(pbPath); err == nil {
|
|
return fmt.Errorf("pb.toml already exists, aborting")
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
defaultName := generateServiceName()
|
|
serviceName, err := resolveServiceName(defaultName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := writePBConfig(pbPath, serviceName); err != nil {
|
|
return err
|
|
}
|
|
|
|
targetDir := filepath.Join(cwd)
|
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
binaryPath := filepath.Join(targetDir, pocketbaseBinaryName(runtime.GOOS))
|
|
if err := ensurePocketbaseBinary(defaultPocketbaseVersion, runtime.GOOS, runtime.GOARCH, binaryPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := ensureGitignoreEntries(filepath.Join(cwd, ".gitignore"), []string{"pocketbase", "pb_data", ".env", ".DS_store"}); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("Initialized PocketBase project %q\n", serviceName)
|
|
return nil
|
|
}
|
|
|
|
func runDev() error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
binaryPath := filepath.Join(cwd, pocketbaseBinaryName(runtime.GOOS))
|
|
if err := ensurePocketbaseBinary(defaultPocketbaseVersion, runtime.GOOS, runtime.GOARCH, binaryPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
overrides, err := loadEnv(filepath.Join(cwd, ".env"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd := exec.Command(binaryPath, "serve")
|
|
cmd.Dir = cwd
|
|
cmd.Env = mergeEnv(overrides)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
|
|
fmt.Println("starting PocketBase development server")
|
|
return cmd.Run()
|
|
}
|
|
|
|
const (
|
|
defaultPocketbaseVersion = "0.35.1"
|
|
defaultPocketbaseVolume = "pb_data"
|
|
)
|
|
|
|
func init() {
|
|
rand.Seed(time.Now().UnixNano())
|
|
log.SetFlags(0)
|
|
log.SetPrefix("")
|
|
}
|
|
|
|
func writePBConfig(path, serviceName string) error {
|
|
const tmpl = `[server]
|
|
ip = "127.0.0.1"
|
|
port = 8090
|
|
domain = "example.com"
|
|
|
|
[pocketbase]
|
|
version = "%s"
|
|
service = "%s"
|
|
volume = "%s"
|
|
`
|
|
return os.WriteFile(path, []byte(fmt.Sprintf(tmpl, defaultPocketbaseVersion, serviceName, defaultPocketbaseVolume)), 0o644)
|
|
}
|
|
|
|
func generateServiceName() string {
|
|
adjectives := []string{"sunny", "silent", "cobalt", "rusty", "plush", "brisk", "sage", "glow"}
|
|
nouns := []string{"harbor", "delta", "bloom", "atlas", "ripple", "ember", "lumen", "orbit"}
|
|
adjective := adjectives[rand.Intn(len(adjectives))]
|
|
noun := nouns[rand.Intn(len(nouns))]
|
|
return fmt.Sprintf("%s-%s", adjective, noun)
|
|
}
|
|
|
|
func promptServiceName(defaultName string) (string, error) {
|
|
prompt := fmt.Sprintf("%sService name%s [%s]: ", headerColor, remoteColorReset, defaultName)
|
|
input, err := readEditableLine(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
input = normalizePromptInput(input)
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return defaultName, nil
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func resolveServiceName(defaultName string) (string, error) {
|
|
if override := strings.TrimSpace(initServiceNameArg); override != "" {
|
|
initServiceNameArg = ""
|
|
return override, nil
|
|
}
|
|
return promptServiceName(defaultName)
|
|
}
|
|
|
|
func shouldConfirmServerConfig(ctx *deploymentContext) (bool, error) {
|
|
exists, err := remoteDirExists(ctx.serverIP, ctx.serviceDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: unable to verify remote service at %s: %v\n", ctx.serviceDir, err)
|
|
return true, nil
|
|
}
|
|
return !exists, nil
|
|
}
|
|
|
|
func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bool, error) {
|
|
needsConfirm, err := shouldConfirmServerConfig(ctx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if !needsConfirm {
|
|
return ctx, false, nil
|
|
}
|
|
|
|
if err := confirmServerConfig(ctx.configPath); err != nil {
|
|
return nil, false, err
|
|
}
|
|
newCtx, err := buildDeploymentContext()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return newCtx, true, nil
|
|
}
|
|
|
|
func confirmServerConfig(pbPath string) error {
|
|
cfg, err := loadPBConfig(pbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("Confirm remote server settings (press Enter to keep the current value):\n")
|
|
|
|
ipDefault := cfg.Server.IP
|
|
if ipDefault == "" {
|
|
ipDefault = "127.0.0.1"
|
|
}
|
|
ip, err := promptWithDefault("Server IP", ipDefault)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
portDefault := cfg.Server.Port
|
|
if portDefault <= 0 {
|
|
portDefault = 8090
|
|
}
|
|
port, err := promptPort("Server port", portDefault)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
domainDefault := cfg.Server.Domain
|
|
if domainDefault == "" {
|
|
domainDefault = "example.com"
|
|
}
|
|
domain, err := promptWithDefault("Server domain", domainDefault)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg.Server.IP = ip
|
|
cfg.Server.Port = port
|
|
cfg.Server.Domain = domain
|
|
if err := savePBConfig(pbPath, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func promptWithDefault(label, defaultValue string) (string, error) {
|
|
var suffix string
|
|
if defaultValue != "" {
|
|
suffix = fmt.Sprintf(" [%s]: ", defaultValue)
|
|
} else {
|
|
suffix = ": "
|
|
}
|
|
prompt := fmt.Sprintf("%s%s%s%s", headerColor, label, remoteColorReset, suffix)
|
|
input, err := readEditableLine(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
input = normalizePromptInput(input)
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return defaultValue, nil
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func promptPort(label string, defaultValue int) (int, error) {
|
|
if defaultValue <= 0 {
|
|
defaultValue = 8090
|
|
}
|
|
|
|
for {
|
|
prompt := fmt.Sprintf("%s%s%s [%d]: ", headerColor, label, remoteColorReset, defaultValue)
|
|
input, err := readEditableLine(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
input = normalizePromptInput(input)
|
|
value := strings.TrimSpace(input)
|
|
if value == "" {
|
|
return defaultValue, nil
|
|
}
|
|
port, err := strconv.Atoi(value)
|
|
if err != nil || port <= 0 || port > 65535 {
|
|
fmt.Println(" please enter a valid port between 1 and 65535")
|
|
continue
|
|
}
|
|
return port, nil
|
|
}
|
|
}
|
|
|
|
func normalizePromptInput(raw string) string {
|
|
var buf []rune
|
|
for i := 0; i < len(raw); {
|
|
r, size := utf8.DecodeRuneInString(raw[i:])
|
|
if size == 0 {
|
|
break
|
|
}
|
|
if r == '\r' || r == '\n' {
|
|
break
|
|
}
|
|
if r == 0x1b {
|
|
nextIndex, final := skipEscapeSequence(raw, i)
|
|
if nextIndex <= i {
|
|
i++
|
|
continue
|
|
}
|
|
i = nextIndex
|
|
if final == 0x7f || final == 0x08 {
|
|
buf = deletePreviousWord(buf)
|
|
}
|
|
continue
|
|
}
|
|
if r == 0x17 {
|
|
buf = deletePreviousWord(buf)
|
|
i += size
|
|
continue
|
|
}
|
|
if r == 0x7f || r == 0x08 {
|
|
if len(buf) > 0 {
|
|
buf = buf[:len(buf)-1]
|
|
}
|
|
i += size
|
|
continue
|
|
}
|
|
buf = append(buf, r)
|
|
i += size
|
|
}
|
|
return string(buf)
|
|
}
|
|
|
|
func readEditableLine(prompt string) (string, error) {
|
|
fd := int(os.Stdin.Fd())
|
|
reader := bufio.NewReader(os.Stdin)
|
|
if !term.IsTerminal(fd) {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
return line, nil
|
|
}
|
|
|
|
oldState, err := term.MakeRaw(fd)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer term.Restore(fd, oldState)
|
|
|
|
var buf []rune
|
|
var lastWidth int
|
|
fmt.Print(prompt)
|
|
|
|
for {
|
|
r, _, err := reader.ReadRune()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
switch {
|
|
case r == '\r' || r == '\n':
|
|
fmt.Print("\r\n")
|
|
return string(buf), nil
|
|
case r == 0x03:
|
|
fmt.Fprint(os.Stderr, "\r\n")
|
|
return "", errors.New("input interrupted")
|
|
case r == 0x04:
|
|
fmt.Println()
|
|
return "", io.EOF
|
|
case r == 0x7f || r == 0x08:
|
|
if len(buf) > 0 {
|
|
buf = buf[:len(buf)-1]
|
|
redrawEditableLine(prompt, buf, &lastWidth)
|
|
}
|
|
case r == 0x17:
|
|
buf = deletePreviousWord(buf)
|
|
redrawEditableLine(prompt, buf, &lastWidth)
|
|
case r == 0x1b:
|
|
final, err := readEscapeFinal(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if final == 0x7f || final == 0x08 {
|
|
buf = deletePreviousWord(buf)
|
|
redrawEditableLine(prompt, buf, &lastWidth)
|
|
}
|
|
default:
|
|
if r < 0x20 {
|
|
continue
|
|
}
|
|
buf = append(buf, r)
|
|
redrawEditableLine(prompt, buf, &lastWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
func readEscapeFinal(reader *bufio.Reader) (rune, error) {
|
|
b, err := reader.ReadByte()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if b == '[' || b == 'O' {
|
|
for {
|
|
c, err := reader.ReadByte()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if isCSIFinalByte(rune(c)) {
|
|
return rune(c), nil
|
|
}
|
|
}
|
|
}
|
|
return rune(b), nil
|
|
}
|
|
|
|
func redrawEditableLine(prompt string, buf []rune, lastWidth *int) {
|
|
fmt.Print("\r")
|
|
fmt.Print(prompt)
|
|
text := string(buf)
|
|
fmt.Print(text)
|
|
width := runewidth.StringWidth(text)
|
|
if *lastWidth > width {
|
|
fmt.Print(strings.Repeat(" ", *lastWidth-width))
|
|
fmt.Print("\r")
|
|
fmt.Print(prompt)
|
|
fmt.Print(text)
|
|
}
|
|
*lastWidth = width
|
|
}
|
|
|
|
func skipEscapeSequence(raw string, escIndex int) (int, rune) {
|
|
i := escIndex + 1
|
|
if i >= len(raw) {
|
|
return i, 0
|
|
}
|
|
next, size := utf8.DecodeRuneInString(raw[i:])
|
|
i += size
|
|
if next == '[' || next == 'O' {
|
|
for i < len(raw) {
|
|
code, codeSize := utf8.DecodeRuneInString(raw[i:])
|
|
if codeSize == 0 {
|
|
break
|
|
}
|
|
i += codeSize
|
|
if isCSIFinalByte(code) {
|
|
return i, code
|
|
}
|
|
}
|
|
return i, 0
|
|
}
|
|
return i, next
|
|
}
|
|
|
|
func isCSIFinalByte(r rune) bool {
|
|
return 0x40 <= r && r <= 0x7e
|
|
}
|
|
|
|
func deletePreviousWord(buf []rune) []rune {
|
|
i := len(buf)
|
|
for i > 0 && unicode.IsSpace(buf[i-1]) {
|
|
i--
|
|
}
|
|
for i > 0 && !unicode.IsSpace(buf[i-1]) {
|
|
i--
|
|
}
|
|
return buf[:i]
|
|
}
|
|
|
|
func pocketbaseBinaryName(goos string) string {
|
|
if goos == "windows" {
|
|
return "pocketbase.exe"
|
|
}
|
|
return "pocketbase"
|
|
}
|
|
|
|
func downloadPocketbase(version, goos, goarch, dest string) error {
|
|
url, binaryName, err := releaseAssetURL(version, goos, goarch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status %s while downloading %s", resp.Status, url)
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "pocketbase-*.zip")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
defer tmpFile.Close()
|
|
|
|
label := fmt.Sprintf("Downloading PocketBase v%s (%s/%s)", version, goos, goarch)
|
|
if err := downloadWithProgress(tmpFile, resp.Body, resp.ContentLength, label); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := extractBinary(tmpFile.Name(), binaryName, dest); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.Chmod(dest, 0o755)
|
|
}
|
|
|
|
func ensurePocketbaseBinary(version, goos, goarch, dest string) error {
|
|
if _, err := os.Stat(dest); err == nil {
|
|
return nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("PocketBase binary missing; downloading...")
|
|
return downloadPocketbase(version, goos, goarch, dest)
|
|
}
|
|
|
|
func downloadWithProgress(dst io.Writer, src io.Reader, total int64, label string) error {
|
|
const chunkSize = 32 * 1024
|
|
buf := make([]byte, chunkSize)
|
|
var downloaded int64
|
|
lastPercent := int64(-1)
|
|
|
|
updateProgress := func(force bool) {
|
|
if total > 0 {
|
|
percent := downloaded * 100 / total
|
|
if force || percent != lastPercent {
|
|
fmt.Printf("\r%s %3d%%", label, percent)
|
|
lastPercent = percent
|
|
}
|
|
} else {
|
|
fmt.Printf("\r%s %s downloaded", label, formatBytes(downloaded))
|
|
}
|
|
}
|
|
|
|
for {
|
|
n, err := src.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
|
|
fmt.Println()
|
|
return writeErr
|
|
}
|
|
downloaded += int64(n)
|
|
updateProgress(false)
|
|
}
|
|
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
fmt.Println()
|
|
return err
|
|
}
|
|
}
|
|
|
|
updateProgress(true)
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
func formatBytes(b int64) string {
|
|
const unit = 1024
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := b / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
func releaseAssetURL(version, goos, goarch string) (string, string, error) {
|
|
osName := map[string]string{
|
|
"darwin": "darwin",
|
|
"linux": "linux",
|
|
"windows": "windows",
|
|
}
|
|
archName := map[string]string{
|
|
"amd64": "amd64",
|
|
"386": "386",
|
|
"arm64": "arm64",
|
|
"arm": "armv7",
|
|
}
|
|
|
|
osPart, ok := osName[goos]
|
|
if !ok {
|
|
return "", "", fmt.Errorf("unsupported GOOS: %s", goos)
|
|
}
|
|
archPart, ok := archName[goarch]
|
|
if !ok {
|
|
return "", "", fmt.Errorf("unsupported GOARCH: %s", goarch)
|
|
}
|
|
|
|
filename := fmt.Sprintf("pocketbase_%s_%s_%s.zip", version, osPart, archPart)
|
|
url := fmt.Sprintf("https://github.com/pocketbase/pocketbase/releases/download/v%s/%s", version, filename)
|
|
return url, pocketbaseBinaryName(goos), nil
|
|
}
|
|
|
|
func extractBinary(zipPath, binaryName, dest string) error {
|
|
r, err := zip.OpenReader(zipPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.Close()
|
|
|
|
for _, f := range r.File {
|
|
if filepath.Base(f.Name) != binaryName {
|
|
continue
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
src, err := f.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
|
|
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
if _, err := io.Copy(out, src); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
return fmt.Errorf("binary %s not found in archive", binaryName)
|
|
}
|
|
|
|
func ensureGitignoreEntries(path string, entries []string) error {
|
|
var existing = make(map[string]struct{})
|
|
var original []byte
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
original = data
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed != "" {
|
|
existing[trimmed] = struct{}{}
|
|
}
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
builder := &strings.Builder{}
|
|
if len(original) > 0 {
|
|
builder.Write(original)
|
|
if !strings.HasSuffix(string(original), "\n") {
|
|
builder.WriteByte('\n')
|
|
}
|
|
}
|
|
|
|
var added bool
|
|
for _, entry := range entries {
|
|
if _, seen := existing[entry]; seen {
|
|
continue
|
|
}
|
|
builder.WriteString(entry)
|
|
builder.WriteByte('\n')
|
|
added = true
|
|
}
|
|
|
|
if !added {
|
|
return nil
|
|
}
|
|
|
|
return os.WriteFile(path, []byte(builder.String()), 0o644)
|
|
}
|
|
|
|
func loadEnv(path string) (map[string]string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
env := make(map[string]string)
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if key, value, ok := parseEnvLine(line); ok {
|
|
env[key] = value
|
|
}
|
|
}
|
|
return env, nil
|
|
}
|
|
|
|
func mergeEnv(overrides map[string]string) []string {
|
|
envMap := make(map[string]string)
|
|
for _, kv := range os.Environ() {
|
|
parts := strings.SplitN(kv, "=", 2)
|
|
key := parts[0]
|
|
value := ""
|
|
if len(parts) > 1 {
|
|
value = parts[1]
|
|
}
|
|
envMap[key] = value
|
|
}
|
|
for key, value := range overrides {
|
|
envMap[key] = value
|
|
}
|
|
|
|
keys := make([]string, 0, len(envMap))
|
|
for key := range envMap {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
env := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
env = append(env, fmt.Sprintf("%s=%s", key, envMap[key]))
|
|
}
|
|
return env
|
|
}
|
|
|
|
const (
|
|
defaultServiceDirTemplate = "/root/pb/{service}"
|
|
defaultEnvFileTemplate = "/root/pb/{service}/.env"
|
|
totalSetupSteps = 5
|
|
remoteWindowSize = 10
|
|
remoteIndent = " "
|
|
remoteLineColor = ""
|
|
headerColor = "\033[1;37m"
|
|
localTimeColor = "\033[0;32m"
|
|
statusActiveColor = "\033[92m"
|
|
statusWarnColor = "\033[93m"
|
|
statusFailColor = "\033[91m"
|
|
remoteColorReset = "\033[0m"
|
|
)
|
|
|
|
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"`
|
|
Volume string `toml:"volume"`
|
|
}
|
|
|
|
type deploymentContext struct {
|
|
serverIP string
|
|
domain string
|
|
port int
|
|
serviceName string
|
|
version string
|
|
serviceDir string
|
|
envFile string
|
|
unitServiceDir string
|
|
unitEnvFile string
|
|
volume string
|
|
unitVolume string
|
|
configPath string
|
|
}
|
|
|
|
func buildDeploymentContext() (*deploymentContext, error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
configPath := filepath.Join(cwd, "pb.toml")
|
|
cfg, err := loadPBConfig(configPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
serviceName := cfg.PocketBase.ServiceName
|
|
if serviceName == "" {
|
|
return nil, fmt.Errorf("pb.toml missing [pocketbase].service")
|
|
}
|
|
|
|
serverIP := cfg.Server.IP
|
|
if serverIP == "" {
|
|
return nil, fmt.Errorf("pb.toml missing [server].ip")
|
|
}
|
|
|
|
domain := cfg.Server.Domain
|
|
if domain == "" {
|
|
return nil, fmt.Errorf("pb.toml missing [server].domain")
|
|
}
|
|
|
|
port := cfg.Server.Port
|
|
if port <= 0 {
|
|
return nil, 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")
|
|
volumeTemplate := cfg.PocketBase.Volume
|
|
if volumeTemplate == "" {
|
|
volumeTemplate = defaultPocketbaseVolume
|
|
}
|
|
volume := resolveVolumePath(volumeTemplate, serviceDir, serviceName)
|
|
unitVolume := resolveVolumePath(volumeTemplate, unitServiceDir, "%i")
|
|
|
|
return &deploymentContext{
|
|
serverIP: serverIP,
|
|
domain: domain,
|
|
port: port,
|
|
serviceName: serviceName,
|
|
version: version,
|
|
serviceDir: serviceDir,
|
|
envFile: envFile,
|
|
unitServiceDir: unitServiceDir,
|
|
unitEnvFile: unitEnvFile,
|
|
volume: volume,
|
|
unitVolume: unitVolume,
|
|
configPath: configPath,
|
|
}, nil
|
|
}
|
|
|
|
func runSetup() error {
|
|
ctx, err := buildDeploymentContext()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, _, err = ensureServerConfigConfirmed(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer closeSSHControlMaster(ctx.serverIP)
|
|
start := time.Now()
|
|
|
|
if err := performSetup(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("\nSetup complete; PocketBase should be reachable at https://%s\n", ctx.domain)
|
|
fmt.Printf("Total setup time: %s%s%s\n", localTimeColor, time.Since(start).Round(time.Millisecond), remoteColorReset)
|
|
return nil
|
|
}
|
|
|
|
func performSetup(ctx *deploymentContext) error {
|
|
step := 1
|
|
printStep(step, totalSetupSteps, "validating configuration")
|
|
remoteOS, err := runSSHOutput(ctx.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(ctx.serverIP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
assetName := pocketbaseAsset(ctx.version, "linux", arch)
|
|
assetURL := fmt.Sprintf("https://github.com/pocketbase/pocketbase/releases/download/v%s/%s", ctx.version, assetName)
|
|
|
|
step++
|
|
printStep(step, totalSetupSteps, "configuring firewall")
|
|
if err := runSSHCommand(ctx.serverIP, firewallScript()); err != nil {
|
|
return fmt.Errorf("firewall setup failed: %w", err)
|
|
}
|
|
|
|
step++
|
|
printStep(step, totalSetupSteps, "installing caddy")
|
|
if err := runSSHCommand(ctx.serverIP, caddyScript(ctx.domain, ctx.port, ctx.serviceName)); err != nil {
|
|
return fmt.Errorf("caddy setup failed: %w", err)
|
|
}
|
|
|
|
step++
|
|
printStep(step, totalSetupSteps, "deploying PocketBase binary")
|
|
if err := runSSHCommand(ctx.serverIP, pocketbaseSetupScript(ctx.serviceDir, ctx.envFile, ctx.version, assetURL, ctx.volume, ctx.port)); err != nil {
|
|
return fmt.Errorf("PocketBase setup failed: %w", err)
|
|
}
|
|
|
|
step++
|
|
printStep(step, totalSetupSteps, "configuring systemd service")
|
|
if err := runSSHCommand(ctx.serverIP, systemdScript(ctx.unitServiceDir, ctx.unitEnvFile, ctx.serviceName)); err != nil {
|
|
return fmt.Errorf("systemd setup failed: %w", err)
|
|
}
|
|
|
|
if err := runSSHCommand(ctx.serverIP, systemdOverrideScript(ctx.serviceName, ctx.port, ctx.volume)); err != nil {
|
|
return fmt.Errorf("systemd override failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runDeploy() error {
|
|
ctx, err := buildDeploymentContext()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
closeSSHControlMaster(ctx.serverIP)
|
|
}()
|
|
start := time.Now()
|
|
|
|
binaryPath := filepath.Join(ctx.serviceDir, "pocketbase")
|
|
exists, err := remoteBinaryExists(ctx.serverIP, binaryPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: Unable to contact remote server %s: %v\n", ctx.serverIP, err)
|
|
}
|
|
|
|
if err != nil || !exists {
|
|
fmt.Println("PocketBase binary missing on remote; running setup")
|
|
ctx, prompted, err := ensureServerConfigConfirmed(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if prompted {
|
|
start = time.Now()
|
|
}
|
|
if err := performSetup(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
paths := []string{"pb_public", "pb_migrations", "pb_hooks", "pb.toml"}
|
|
if err := syncLocalPaths(ctx.serverIP, ctx.serviceDir, paths); err != nil {
|
|
return fmt.Errorf("failed to sync local directories: %w", err)
|
|
}
|
|
|
|
if err := runSSHCommand(ctx.serverIP, systemdOverrideScript(ctx.serviceName, ctx.port, ctx.volume)); err != nil {
|
|
return fmt.Errorf("systemd override failed: %w", err)
|
|
}
|
|
|
|
if err := restartPocketBaseService(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("\nDeployment complete; PocketBase should be reachable at https://%s\n", ctx.domain)
|
|
fmt.Printf("Total deployment time: %s%s%s\n", localTimeColor, time.Since(start).Round(time.Millisecond), remoteColorReset)
|
|
return nil
|
|
}
|
|
|
|
func runStatus() error {
|
|
ctx, err := buildDeploymentContext()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeSSHControlMaster(ctx.serverIP)
|
|
|
|
printInfo("Service", fmt.Sprintf("pb@%s", ctx.serviceName), remoteLineColor)
|
|
printInfo("Server", ctx.serverIP, remoteLineColor)
|
|
printInfo("Domain", ctx.domain, remoteLineColor)
|
|
printInfo("Port", fmt.Sprintf("%d", ctx.port), remoteLineColor)
|
|
|
|
props, err := querySystemdProperties(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to query PocketBase service: %w", err)
|
|
}
|
|
|
|
state := strings.TrimSpace(props["ActiveState"])
|
|
if state == "" {
|
|
state = "unknown"
|
|
}
|
|
statusLine := state
|
|
if sub := strings.TrimSpace(props["SubState"]); sub != "" {
|
|
statusLine = fmt.Sprintf("%s (%s)", state, sub)
|
|
}
|
|
printInfo("Status", statusLine, statusColorFor(state))
|
|
|
|
if pid := strings.TrimSpace(props["ExecMainPID"]); pid != "" && pid != "0" {
|
|
printInfo("PID", pid, remoteLineColor)
|
|
}
|
|
|
|
if started := strings.TrimSpace(props["ActiveEnterTimestamp"]); started != "" {
|
|
printInfo("Active since", started, localTimeColor)
|
|
}
|
|
|
|
if uptime := computeUptime(ctx.serverIP, props["ActiveEnterTimestampMonotonic"]); uptime > 0 {
|
|
printInfo("Uptime", formatDuration(uptime), localTimeColor)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func printInfo(label, value, valueColor string) {
|
|
if valueColor == "" {
|
|
valueColor = remoteLineColor
|
|
}
|
|
fmt.Printf("%s%s:%s %s%s%s\n", headerColor, label, remoteColorReset, valueColor, value, remoteColorReset)
|
|
}
|
|
|
|
func statusColorFor(state string) string {
|
|
switch strings.ToLower(state) {
|
|
case "active":
|
|
return statusActiveColor
|
|
case "activating", "deactivating":
|
|
return statusWarnColor
|
|
case "failed", "inactive":
|
|
return statusFailColor
|
|
default:
|
|
return remoteLineColor
|
|
}
|
|
}
|
|
|
|
// querySystemdProperties asks systemd for a few service properties.
|
|
func querySystemdProperties(ctx *deploymentContext) (map[string]string, error) {
|
|
script := fmt.Sprintf(`set -euo pipefail
|
|
systemctl show pb@%s -p ActiveState -p SubState -p ActiveEnterTimestamp -p ActiveEnterTimestampMonotonic -p ExecMainPID
|
|
`, ctx.serviceName)
|
|
output, err := runSSHCollect(ctx.serverIP, script)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseKeyValueLines(output), nil
|
|
}
|
|
|
|
func computeUptime(server, startMicro string) time.Duration {
|
|
startMicro = strings.TrimSpace(startMicro)
|
|
if startMicro == "" {
|
|
return 0
|
|
}
|
|
start, err := strconv.ParseInt(startMicro, 10, 64)
|
|
if err != nil || start <= 0 {
|
|
return 0
|
|
}
|
|
now, err := remoteMonotonicMicro(server)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: unable to compute uptime: %v\n", err)
|
|
return 0
|
|
}
|
|
diff := now - start
|
|
if diff <= 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(diff) * time.Microsecond
|
|
}
|
|
|
|
func remoteMonotonicMicro(server string) (int64, error) {
|
|
output, err := runSSHOutput(server, `awk '{printf "%d", $1*1000000}' /proc/uptime`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return strconv.ParseInt(strings.TrimSpace(output), 10, 64)
|
|
}
|
|
|
|
func parseKeyValueLines(input string) map[string]string {
|
|
props := make(map[string]string)
|
|
scanner := bufio.NewScanner(strings.NewReader(input))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if idx := strings.Index(line, "="); idx > 0 {
|
|
key := strings.TrimSpace(line[:idx])
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
props[key] = value
|
|
}
|
|
}
|
|
return props
|
|
}
|
|
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Second {
|
|
return d.Round(time.Millisecond).String()
|
|
}
|
|
parts := make([]string, 0, 3)
|
|
if hours := d / time.Hour; hours > 0 {
|
|
parts = append(parts, fmt.Sprintf("%dh", hours))
|
|
d -= hours * time.Hour
|
|
}
|
|
if mins := d / time.Minute; mins > 0 {
|
|
parts = append(parts, fmt.Sprintf("%dm", mins))
|
|
d -= mins * time.Minute
|
|
}
|
|
seconds := int64(d / time.Second)
|
|
if seconds > 0 || len(parts) == 0 {
|
|
parts = append(parts, fmt.Sprintf("%ds", seconds))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func runLogs() error {
|
|
ctx, err := buildDeploymentContext()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeSSHControlMaster(ctx.serverIP)
|
|
|
|
logPath := filepath.Join(ctx.serviceDir, fmt.Sprintf("%s.log", ctx.serviceName))
|
|
script := fmt.Sprintf(`set -euo pipefail
|
|
log=%s
|
|
if [ ! -f "$log" ]; then
|
|
echo "log file not found: $log" >&2
|
|
exit 1
|
|
fi
|
|
tail -n 25 -F "$log"
|
|
`, shellQuote(logPath))
|
|
return runSSHRawCommand(ctx.serverIP, script)
|
|
}
|
|
|
|
func runSecrets() error {
|
|
if len(invocationArgs) < 2 {
|
|
return fmt.Errorf("usage: pb secrets <list|set|delete> [arguments]")
|
|
}
|
|
|
|
ctx, err := buildDeploymentContext()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeSSHControlMaster(ctx.serverIP)
|
|
|
|
subcommand := invocationArgs[1]
|
|
args := invocationArgs[2:]
|
|
switch subcommand {
|
|
case "list":
|
|
if len(args) != 0 {
|
|
return fmt.Errorf("usage: pb secrets list")
|
|
}
|
|
return runSecretsList(ctx)
|
|
case "set":
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("usage: pb secrets set KEY=VALUE...")
|
|
}
|
|
return runSecretsSet(ctx, args)
|
|
case "delete":
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("usage: pb secrets delete KEY...")
|
|
}
|
|
return runSecretsDelete(ctx, args)
|
|
default:
|
|
return fmt.Errorf("unknown secrets subcommand %q", subcommand)
|
|
}
|
|
}
|
|
|
|
func runSecretsList(ctx *deploymentContext) error {
|
|
lines, err := loadRemoteEnvLines(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
type secretRow struct {
|
|
key string
|
|
digest string
|
|
}
|
|
|
|
var rows []secretRow
|
|
for _, line := range lines {
|
|
if key, value, ok := parseEnvLine(line); ok {
|
|
rows = append(rows, secretRow{
|
|
key: key,
|
|
digest: secretDigest(value),
|
|
})
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
fmt.Println("no secrets found")
|
|
return nil
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
return rows[i].key < rows[j].key
|
|
})
|
|
nameWidth := len("NAME")
|
|
for _, row := range rows {
|
|
if lw := len(row.key); lw > nameWidth {
|
|
nameWidth = lw
|
|
}
|
|
}
|
|
fmt.Printf("%s%-*s %s%s\n", headerColor, nameWidth, "NAME", "DIGEST", remoteColorReset)
|
|
for _, row := range rows {
|
|
fmt.Printf("%-*s %s\n", nameWidth, row.key, row.digest)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func secretDigest(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
// Return only a prefix so the table stays narrow but still reveals changes.
|
|
const shortBytes = 6
|
|
return fmt.Sprintf("%x", sum[:shortBytes])
|
|
}
|
|
|
|
type envAssignment struct {
|
|
key string
|
|
value string
|
|
}
|
|
|
|
func runSecretsSet(ctx *deploymentContext, pairs []string) error {
|
|
var assignments []envAssignment
|
|
for _, pair := range pairs {
|
|
idx := strings.Index(pair, "=")
|
|
if idx <= 0 {
|
|
return fmt.Errorf("invalid assignment %q, expected KEY=VALUE", pair)
|
|
}
|
|
key := strings.TrimSpace(pair[:idx])
|
|
if key == "" {
|
|
return fmt.Errorf("invalid assignment %q: empty key", pair)
|
|
}
|
|
if !isEnvKey(key) {
|
|
return fmt.Errorf("invalid env key %q", key)
|
|
}
|
|
assignments = append(assignments, envAssignment{key: key, value: pair[idx+1:]})
|
|
}
|
|
|
|
lines, err := loadRemoteEnvLines(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated := applyEnvAssignments(lines, assignments)
|
|
if err := writeRemoteEnvLines(ctx, updated); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := restartPocketBaseService(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("updated %d secrets and restarted PocketBase\n", len(assignments))
|
|
return nil
|
|
}
|
|
|
|
func runSecretsDelete(ctx *deploymentContext, keys []string) error {
|
|
var normalized []string
|
|
for _, key := range keys {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return fmt.Errorf("invalid empty key")
|
|
}
|
|
if !isEnvKey(key) {
|
|
return fmt.Errorf("invalid env key %q", key)
|
|
}
|
|
normalized = append(normalized, key)
|
|
}
|
|
|
|
lines, err := loadRemoteEnvLines(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated := removeEnvKeys(lines, normalized)
|
|
if err := writeRemoteEnvLines(ctx, updated); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := restartPocketBaseService(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("removed %d secrets and restarted PocketBase\n", len(normalized))
|
|
return nil
|
|
}
|
|
|
|
func loadRemoteEnvLines(ctx *deploymentContext) ([]string, error) {
|
|
data, err := readRemoteEnvFile(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data = strings.ReplaceAll(data, "\r\n", "\n")
|
|
data = strings.TrimRightFunc(data, func(r rune) bool {
|
|
return r == '\n' || r == '\r'
|
|
})
|
|
if data == "" {
|
|
return nil, nil
|
|
}
|
|
return strings.Split(data, "\n"), nil
|
|
}
|
|
|
|
func readRemoteEnvFile(ctx *deploymentContext) (string, error) {
|
|
script := fmt.Sprintf(`set -euo pipefail
|
|
if [ -f %s ]; then
|
|
cat %s
|
|
fi
|
|
`, shellQuote(ctx.envFile), shellQuote(ctx.envFile))
|
|
return runSSHCollect(ctx.serverIP, script)
|
|
}
|
|
|
|
func writeRemoteEnvLines(ctx *deploymentContext, lines []string) error {
|
|
content := strings.Join(lines, "\n")
|
|
if content != "" && !strings.HasSuffix(content, "\n") {
|
|
content += "\n"
|
|
}
|
|
dir := filepath.Dir(ctx.envFile)
|
|
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
|
script := fmt.Sprintf(`set -euo pipefail
|
|
mkdir -p %s
|
|
cat <<'EOF' | base64 -d > %s
|
|
%s
|
|
EOF
|
|
`, shellQuote(dir), shellQuote(ctx.envFile), encoded)
|
|
return runSSHCommand(ctx.serverIP, script)
|
|
}
|
|
|
|
func applyEnvAssignments(lines []string, assignments []envAssignment) []string {
|
|
if len(assignments) == 0 {
|
|
return lines
|
|
}
|
|
assignMap := make(map[string]string, len(assignments))
|
|
order := make([]string, 0, len(assignments))
|
|
for _, assign := range assignments {
|
|
if _, seen := assignMap[assign.key]; !seen {
|
|
order = append(order, assign.key)
|
|
}
|
|
assignMap[assign.key] = assign.value
|
|
}
|
|
|
|
inserted := make(map[string]bool, len(assignMap))
|
|
var out []string
|
|
for _, line := range lines {
|
|
if key, _, ok := parseEnvLine(line); ok {
|
|
if value, exists := assignMap[key]; exists {
|
|
if !inserted[key] {
|
|
out = append(out, fmt.Sprintf("%s=%s", key, value))
|
|
inserted[key] = true
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
|
|
for _, key := range order {
|
|
if inserted[key] {
|
|
continue
|
|
}
|
|
out = append(out, fmt.Sprintf("%s=%s", key, assignMap[key]))
|
|
inserted[key] = true
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func removeEnvKeys(lines []string, keys []string) []string {
|
|
if len(keys) == 0 {
|
|
return lines
|
|
}
|
|
targets := make(map[string]struct{}, len(keys))
|
|
for _, key := range keys {
|
|
targets[key] = struct{}{}
|
|
}
|
|
|
|
var out []string
|
|
for _, line := range lines {
|
|
if key, _, ok := parseEnvLine(line); ok {
|
|
if _, remove := targets[key]; remove {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func remoteBinaryExists(server, path string) (bool, error) {
|
|
script := fmt.Sprintf(`if [ -f %q ]; then printf yes; else printf no; fi`, path)
|
|
output, err := runSSHOutput(server, script)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(output) == "yes", nil
|
|
}
|
|
|
|
func remoteDirExists(server, path string) (bool, error) {
|
|
script := fmt.Sprintf(`if [ -d %q ]; then printf yes; else printf no; fi`, path)
|
|
output, err := runSSHOutput(server, script)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(output) == "yes", 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 savePBConfig(path string, cfg *pbToml) error {
|
|
data, err := toml.Marshal(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0o644)
|
|
}
|
|
|
|
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 resolveVolumePath(volumeTemplate, baseDir, serviceName string) string {
|
|
value := renderServiceTemplate(volumeTemplate, serviceName)
|
|
if value == "" {
|
|
return baseDir
|
|
}
|
|
if strings.HasPrefix(value, "/") {
|
|
return value
|
|
}
|
|
return path.Join(baseDir, value)
|
|
}
|
|
|
|
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() 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 80/tcp
|
|
ufw allow 443/tcp
|
|
ufw --force enable
|
|
`)
|
|
}
|
|
|
|
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, volume 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"
|
|
data_dir="%[5]s"
|
|
if [ -n "$data_dir" ]; then
|
|
mkdir -p "$data_dir"
|
|
fi
|
|
env_dir=$(dirname "$env_file")
|
|
mkdir -p "$env_dir"
|
|
if [ ! -f "$env_file" ]; then
|
|
touch "$env_file"
|
|
fi
|
|
`, serviceDir, serviceDir, assetURL, envFile, volume, 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]
|
|
Type = simple
|
|
User = root
|
|
Group = root
|
|
LimitNOFILE = 4096
|
|
Restart = always
|
|
RestartSec = 5s
|
|
StandardOutput = append:%s/%%i.log
|
|
StandardError = append:%s/%%i.log
|
|
WorkingDirectory = %s
|
|
EnvironmentFile = %s
|
|
ExecStart = %s/pocketbase serve --http="127.0.0.1:${PORT}" --dir=${DATA_DIR} --hooksDir=%s/pb_hooks --migrationsDir=%s/pb_migrations --publicDir=%s/pb_public
|
|
|
|
[Install]
|
|
WantedBy = multi-user.target
|
|
EOF
|
|
systemctl daemon-reload
|
|
systemctl --no-block enable --now pb@%s
|
|
systemctl --no-block restart pb@%s
|
|
`, serviceDir, serviceDir, serviceDir, envFile, serviceDir, serviceDir, serviceDir, serviceDir, serviceName, serviceName)
|
|
}
|
|
|
|
func systemdOverrideScript(serviceName string, port int, volume string) string {
|
|
return fmt.Sprintf(`set -euo pipefail
|
|
dir="/etc/systemd/system/pb@%s.service.d"
|
|
mkdir -p "$dir"
|
|
cat <<'EOF' > "$dir/override.conf"
|
|
[Service]
|
|
Environment=PORT=%d
|
|
Environment=DATA_DIR=%s
|
|
EOF
|
|
systemctl daemon-reload
|
|
systemctl --no-block restart pb@%s
|
|
`, serviceName, port, volume, serviceName)
|
|
}
|
|
|
|
func systemdRestartScript(serviceName string) string {
|
|
return fmt.Sprintf(`set -euo pipefail
|
|
systemctl --no-block restart pb@%s
|
|
`, serviceName)
|
|
}
|
|
|
|
func restartPocketBaseService(ctx *deploymentContext) error {
|
|
if err := runSSHCommand(ctx.serverIP, systemdRestartScript(ctx.serviceName)); err != nil {
|
|
return fmt.Errorf("systemd restart failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSSHCommand(server, script string) error {
|
|
remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script))
|
|
cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...)
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- filterEnvStream(stdoutPipe, os.Stdout)
|
|
}()
|
|
|
|
waitErr := cmd.Wait()
|
|
pipeErr := <-done
|
|
if waitErr != nil {
|
|
return waitErr
|
|
}
|
|
return pipeErr
|
|
}
|
|
|
|
func runSSHRawCommand(server, script string) error {
|
|
remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script))
|
|
cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func runSSHOutput(server, script string) (string, error) {
|
|
remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script))
|
|
cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
filtered := stripLeadingEnvLines(out.String())
|
|
return strings.TrimSpace(filtered), nil
|
|
}
|
|
|
|
func runSSHCollect(server, script string) (string, error) {
|
|
remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script))
|
|
cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
func syncLocalPaths(server, remoteBase string, paths []string) error {
|
|
var toSync []string
|
|
for _, path := range paths {
|
|
localPath := filepath.Join(".", path)
|
|
info, err := os.Stat(localPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
fmt.Printf("- %s does not exist locally, skipping\n", path)
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
toSync = append(toSync, path+"/")
|
|
continue
|
|
}
|
|
toSync = append(toSync, path)
|
|
}
|
|
|
|
if len(toSync) == 0 {
|
|
return nil
|
|
}
|
|
|
|
rsyncCmd := rsyncSSHCommand(server)
|
|
remotePath := fmt.Sprintf("root@%s:%s", server, remoteBase)
|
|
args := append([]string{"-e", rsyncCmd, "-avz", "--delete", "--relative"}, toSync...)
|
|
args = append(args, remotePath)
|
|
cmd := exec.Command("rsync", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func rsyncSSHCommand(server string) string {
|
|
args := sshSharedArgs(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()
|
|
}
|
|
|
|
func filterEnvStream(r io.Reader, w io.Writer) error {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
|
skipping := true
|
|
window := newRemoteWindow(w)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if skipping && isEnvLine(line) {
|
|
continue
|
|
}
|
|
if skipping {
|
|
skipping = false
|
|
}
|
|
if err := window.add(line); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type remoteWindow struct {
|
|
w io.Writer
|
|
lines []string
|
|
prevCount int
|
|
}
|
|
|
|
func newRemoteWindow(w io.Writer) *remoteWindow {
|
|
return &remoteWindow{w: w}
|
|
}
|
|
|
|
func (rw *remoteWindow) add(line string) error {
|
|
rw.lines = append(rw.lines, line)
|
|
if len(rw.lines) > remoteWindowSize {
|
|
rw.lines = rw.lines[1:]
|
|
}
|
|
return rw.flush()
|
|
}
|
|
|
|
func (rw *remoteWindow) flush() error {
|
|
if rw.prevCount > 0 {
|
|
if _, err := fmt.Fprintf(rw.w, "\033[%dA", rw.prevCount); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, line := range rw.lines {
|
|
if _, err := fmt.Fprintf(rw.w, "\033[2K\r%s%s%s%s\n", remoteLineColor, remoteIndent, line, remoteColorReset); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
rw.prevCount = len(rw.lines)
|
|
return nil
|
|
}
|
|
|
|
func stripLeadingEnvLines(input string) string {
|
|
scanner := bufio.NewScanner(strings.NewReader(input))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
|
skipping := true
|
|
var builder strings.Builder
|
|
addedLine := false
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if skipping && isEnvLine(line) {
|
|
continue
|
|
}
|
|
if skipping {
|
|
skipping = false
|
|
}
|
|
if addedLine {
|
|
builder.WriteByte('\n')
|
|
}
|
|
builder.WriteString(line)
|
|
addedLine = true
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return input
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func parseEnvLine(line string) (string, string, bool) {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
return "", "", false
|
|
}
|
|
idx := strings.Index(trimmed, "=")
|
|
if idx <= 0 {
|
|
return "", "", false
|
|
}
|
|
key := strings.TrimSpace(trimmed[:idx])
|
|
if !isEnvKey(key) {
|
|
return "", "", false
|
|
}
|
|
value := strings.TrimSpace(trimmed[idx+1:])
|
|
return key, value, true
|
|
}
|
|
|
|
func isEnvKey(key string) bool {
|
|
if key == "" {
|
|
return false
|
|
}
|
|
for i, r := range key {
|
|
if i == 0 {
|
|
if r != '_' && !unicode.IsLetter(r) {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isEnvLine(line string) bool {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
return true
|
|
}
|
|
idx := strings.IndexRune(trimmed, '=')
|
|
if idx <= 0 {
|
|
return false
|
|
}
|
|
return isEnvKey(trimmed[:idx])
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
if value == "" {
|
|
return "''"
|
|
}
|
|
var builder strings.Builder
|
|
builder.WriteByte('\'')
|
|
for _, r := range value {
|
|
if r == '\'' {
|
|
builder.WriteString("'\"'\"'")
|
|
continue
|
|
}
|
|
builder.WriteRune(r)
|
|
}
|
|
builder.WriteByte('\'')
|
|
return builder.String()
|
|
}
|