consolidate into main.go
This commit is contained in:
659
deploy.go
659
deploy.go
@@ -1,659 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultServiceDirTemplate = "/root/pb/{service}"
|
||||
defaultEnvFileTemplate = "/root/pb/{service}/.env"
|
||||
totalSetupSteps = 5
|
||||
remoteWindowSize = 10
|
||||
remoteIndent = " "
|
||||
remoteLineColor = "\033[96m"
|
||||
localTimeColor = "\033[92m"
|
||||
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_name"`
|
||||
}
|
||||
|
||||
type deploymentContext struct {
|
||||
serverIP string
|
||||
domain string
|
||||
port int
|
||||
serviceName string
|
||||
version string
|
||||
serviceDir string
|
||||
envFile string
|
||||
unitServiceDir string
|
||||
unitEnvFile 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_name")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
return &deploymentContext{
|
||||
serverIP: serverIP,
|
||||
domain: domain,
|
||||
port: port,
|
||||
serviceName: serviceName,
|
||||
version: version,
|
||||
serviceDir: serviceDir,
|
||||
envFile: envFile,
|
||||
unitServiceDir: unitServiceDir,
|
||||
unitEnvFile: unitEnvFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runSetup() error {
|
||||
ctx, err := buildDeploymentContext()
|
||||
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\n", time.Since(start).Round(time.Millisecond))
|
||||
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.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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDeploy() error {
|
||||
ctx, err := buildDeploymentContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeSSHControlMaster(ctx.serverIP)
|
||||
start := time.Now()
|
||||
|
||||
binaryPath := filepath.Join(ctx.serviceDir, "pocketbase")
|
||||
exists, err := remoteBinaryExists(ctx.serverIP, binaryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
fmt.Println("PocketBase binary missing on remote; running setup")
|
||||
if err := performSetup(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dirs := []string{"pb_public", "pb_migrations", "pb_hooks"}
|
||||
if err := syncLocalDirectories(ctx.serverIP, ctx.serviceDir, dirs); err != nil {
|
||||
return fmt.Errorf("failed to sync local directories: %w", err)
|
||||
}
|
||||
|
||||
if err := runSSHCommand(ctx.serverIP, systemdRestartScript(ctx.serviceName)); err != nil {
|
||||
return fmt.Errorf("systemd restart failed: %w", 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 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 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 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() 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 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]
|
||||
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}"
|
||||
|
||||
[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, serviceName, serviceName)
|
||||
}
|
||||
|
||||
func systemdRestartScript(serviceName string) string {
|
||||
return fmt.Sprintf(`set -euo pipefail
|
||||
systemctl --no-block restart pb@%s
|
||||
`, serviceName)
|
||||
}
|
||||
|
||||
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 syncLocalDirectories(server, remoteBase string, dirs []string) error {
|
||||
var toSync []string
|
||||
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
|
||||
}
|
||||
|
||||
toSync = append(toSync, dir+"/")
|
||||
}
|
||||
|
||||
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 isEnvLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
idx := strings.IndexRune(trimmed, '=')
|
||||
if idx <= 0 {
|
||||
return false
|
||||
}
|
||||
key := trimmed[:idx]
|
||||
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 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()
|
||||
}
|
||||
644
main.go
644
main.go
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,8 +18,10 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
var initServiceNameArg string
|
||||
@@ -593,3 +597,643 @@ func mergeEnv(overrides map[string]string) []string {
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServiceDirTemplate = "/root/pb/{service}"
|
||||
defaultEnvFileTemplate = "/root/pb/{service}/.env"
|
||||
totalSetupSteps = 5
|
||||
remoteWindowSize = 10
|
||||
remoteIndent = " "
|
||||
remoteLineColor = "\033[96m"
|
||||
localTimeColor = "\033[92m"
|
||||
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_name"`
|
||||
}
|
||||
|
||||
type deploymentContext struct {
|
||||
serverIP string
|
||||
domain string
|
||||
port int
|
||||
serviceName string
|
||||
version string
|
||||
serviceDir string
|
||||
envFile string
|
||||
unitServiceDir string
|
||||
unitEnvFile 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_name")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
return &deploymentContext{
|
||||
serverIP: serverIP,
|
||||
domain: domain,
|
||||
port: port,
|
||||
serviceName: serviceName,
|
||||
version: version,
|
||||
serviceDir: serviceDir,
|
||||
envFile: envFile,
|
||||
unitServiceDir: unitServiceDir,
|
||||
unitEnvFile: unitEnvFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runSetup() error {
|
||||
ctx, err := buildDeploymentContext()
|
||||
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\n", time.Since(start).Round(time.Millisecond))
|
||||
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.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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDeploy() error {
|
||||
ctx, err := buildDeploymentContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeSSHControlMaster(ctx.serverIP)
|
||||
start := time.Now()
|
||||
|
||||
binaryPath := filepath.Join(ctx.serviceDir, "pocketbase")
|
||||
exists, err := remoteBinaryExists(ctx.serverIP, binaryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
fmt.Println("PocketBase binary missing on remote; running setup")
|
||||
if err := performSetup(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dirs := []string{"pb_public", "pb_migrations", "pb_hooks"}
|
||||
if err := syncLocalDirectories(ctx.serverIP, ctx.serviceDir, dirs); err != nil {
|
||||
return fmt.Errorf("failed to sync local directories: %w", err)
|
||||
}
|
||||
|
||||
if err := runSSHCommand(ctx.serverIP, systemdRestartScript(ctx.serviceName)); err != nil {
|
||||
return fmt.Errorf("systemd restart failed: %w", 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 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 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 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() 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 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]
|
||||
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}"
|
||||
|
||||
[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, serviceName, serviceName)
|
||||
}
|
||||
|
||||
func systemdRestartScript(serviceName string) string {
|
||||
return fmt.Sprintf(`set -euo pipefail
|
||||
systemctl --no-block restart pb@%s
|
||||
`, serviceName)
|
||||
}
|
||||
|
||||
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 syncLocalDirectories(server, remoteBase string, dirs []string) error {
|
||||
var toSync []string
|
||||
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
|
||||
}
|
||||
|
||||
toSync = append(toSync, dir+"/")
|
||||
}
|
||||
|
||||
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 isEnvLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
idx := strings.IndexRune(trimmed, '=')
|
||||
if idx <= 0 {
|
||||
return false
|
||||
}
|
||||
key := trimmed[:idx]
|
||||
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 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user