Compare commits
10 Commits
63b0bbd165
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
78c0d0184e
|
|||
|
03ecba3892
|
|||
|
6d4a3f6cce
|
|||
|
e0bf545881
|
|||
|
67fce39d1f
|
|||
|
f86cfec0a7
|
|||
|
d211493e3c
|
|||
|
095a92dfc2
|
|||
|
4e2b2046ae
|
|||
|
b942757ade
|
36
.github/workflows/release.yml
vendored
Normal file
36
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
goreleaser:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: "~> v2"
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
30
.goreleaser.yaml
Normal file
30
.goreleaser.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
project_name: pb
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: pb
|
||||||
|
main: .
|
||||||
|
binary: pb
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: pb
|
||||||
|
builds:
|
||||||
|
- pb
|
||||||
|
format: tar.gz
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Nick Goodall
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
10
README.md
10
README.md
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
A simple and _fast_ PocketBase deployment tool.
|
A simple and _fast_ PocketBase deployment tool.
|
||||||
|
|
||||||
|
## ⚠️ Fair Warning
|
||||||
|
|
||||||
|
**This is a personal project.** I built it to manage and deploy some side-quests using PocketBase: [Milkstonks](https://milkstonks.com), [ringing.guide](https://ringing.guide), [belfry.world](https://belfry.world) and [waterways.watch](https://waterways.watch).
|
||||||
|
|
||||||
|
Use at your own peril.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### `init`
|
### `init`
|
||||||
@@ -10,9 +16,9 @@ Start a new PocketBase project (optionally provide a service name via `pb init <
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
ip = '127.0.0.1'
|
|
||||||
port = 8090
|
port = 8090
|
||||||
domain = 'example.com'
|
# ip = '127.0.0.1'
|
||||||
|
# domain = 'example.com'
|
||||||
|
|
||||||
[pocketbase]
|
[pocketbase]
|
||||||
version = '0.35.1'
|
version = '0.35.1'
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -4,7 +4,9 @@ go 1.23.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbletea v0.26.1
|
github.com/charmbracelet/bubbletea v0.26.1
|
||||||
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
|
golang.org/x/term v0.19.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -13,7 +15,6 @@ require (
|
|||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
github.com/mattn/go-isatty v0.0.18 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
@@ -21,6 +22,5 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.6 // indirect
|
github.com/rivo/uniseg v0.4.6 // indirect
|
||||||
golang.org/x/sync v0.7.0 // indirect
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.19.0 // indirect
|
golang.org/x/sys v0.19.0 // indirect
|
||||||
golang.org/x/term v0.19.0 // indirect
|
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/text v0.3.8 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
86
main.go
86
main.go
@@ -250,10 +250,26 @@ func runInit() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ensureGitignoreEntries(filepath.Join(cwd, ".gitignore"), []string{"pocketbase", "pb_data", ".env", ".DS_store"}); err != nil {
|
if err := ensureGitignoreEntries(filepath.Join(cwd, ".gitignore"), []string{"pocketbase", "pb_data", ".env", ".DS_Store"}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, dir := range []string{"pb_public", "pb_migrations", "pb_hooks"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(cwd, dir), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envPath := filepath.Join(cwd, ".env")
|
||||||
|
if _, err := os.Stat(envPath); err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(envPath, []byte("# PocketBase env overrides\n"), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Initialized PocketBase project %q\n", serviceName)
|
fmt.Printf("Initialized PocketBase project %q\n", serviceName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -326,9 +342,9 @@ func init() {
|
|||||||
|
|
||||||
func writePBConfig(path, serviceName string) error {
|
func writePBConfig(path, serviceName string) error {
|
||||||
const tmpl = `[server]
|
const tmpl = `[server]
|
||||||
ip = "127.0.0.1"
|
|
||||||
port = 8090
|
port = 8090
|
||||||
domain = "example.com"
|
# ip = "127.0.0.1"
|
||||||
|
# domain = "example.com"
|
||||||
|
|
||||||
[pocketbase]
|
[pocketbase]
|
||||||
version = "%s"
|
version = "%s"
|
||||||
@@ -369,12 +385,16 @@ func resolveServiceName(defaultName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldConfirmServerConfig(ctx *deploymentContext) (bool, error) {
|
func shouldConfirmServerConfig(ctx *deploymentContext) (bool, error) {
|
||||||
exists, err := remoteDirExists(ctx.serverIP, ctx.serviceDir)
|
if strings.TrimSpace(ctx.serverIP) == "" {
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: unable to verify remote service at %s: %v\n", ctx.serviceDir, err)
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
return !exists, nil
|
if strings.TrimSpace(ctx.domain) == "" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if ctx.port <= 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bool, error) {
|
func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bool, error) {
|
||||||
@@ -389,7 +409,7 @@ func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bo
|
|||||||
if err := confirmServerConfig(ctx.configPath); err != nil {
|
if err := confirmServerConfig(ctx.configPath); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
newCtx, err := buildDeploymentContext()
|
newCtx, err := buildDeploymentContext(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -969,7 +989,7 @@ type deploymentContext struct {
|
|||||||
configPath string
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDeploymentContext() (*deploymentContext, error) {
|
func buildDeploymentContext(requireServer bool) (*deploymentContext, error) {
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -987,17 +1007,17 @@ func buildDeploymentContext() (*deploymentContext, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverIP := cfg.Server.IP
|
serverIP := cfg.Server.IP
|
||||||
if serverIP == "" {
|
if requireServer && serverIP == "" {
|
||||||
return nil, fmt.Errorf("pb.toml missing [server].ip")
|
return nil, fmt.Errorf("pb.toml missing [server].ip")
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := cfg.Server.Domain
|
domain := cfg.Server.Domain
|
||||||
if domain == "" {
|
if requireServer && domain == "" {
|
||||||
return nil, fmt.Errorf("pb.toml missing [server].domain")
|
return nil, fmt.Errorf("pb.toml missing [server].domain")
|
||||||
}
|
}
|
||||||
|
|
||||||
port := cfg.Server.Port
|
port := cfg.Server.Port
|
||||||
if port <= 0 {
|
if requireServer && port <= 0 {
|
||||||
return nil, fmt.Errorf("pb.toml server.port must be greater than zero")
|
return nil, fmt.Errorf("pb.toml server.port must be greater than zero")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,7 +1054,7 @@ func buildDeploymentContext() (*deploymentContext, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSetup() error {
|
func runSetup() error {
|
||||||
ctx, err := buildDeploymentContext()
|
ctx, err := buildDeploymentContext(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1113,14 +1133,22 @@ func performSetup(ctx *deploymentContext, restart bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runDeploy() error {
|
func runDeploy() error {
|
||||||
ctx, err := buildDeploymentContext()
|
ctx, err := buildDeploymentContext(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
ctx, prompted, err := ensureServerConfigConfirmed(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prompted {
|
||||||
|
start = time.Now()
|
||||||
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
closeSSHControlMaster(ctx.serverIP)
|
closeSSHControlMaster(ctx.serverIP)
|
||||||
}()
|
}()
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
binaryPath := filepath.Join(ctx.serviceDir, "pocketbase")
|
binaryPath := filepath.Join(ctx.serviceDir, "pocketbase")
|
||||||
exists, err := remoteBinaryExists(ctx.serverIP, binaryPath)
|
exists, err := remoteBinaryExists(ctx.serverIP, binaryPath)
|
||||||
@@ -1130,13 +1158,6 @@ func runDeploy() error {
|
|||||||
|
|
||||||
if err != nil || !exists {
|
if err != nil || !exists {
|
||||||
fmt.Println("PocketBase binary missing on remote; running setup")
|
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, false); err != nil {
|
if err := performSetup(ctx, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1161,16 +1182,16 @@ func runDeploy() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runStatus() error {
|
func runStatus() error {
|
||||||
ctx, err := buildDeploymentContext()
|
ctx, err := buildDeploymentContext(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer closeSSHControlMaster(ctx.serverIP)
|
defer closeSSHControlMaster(ctx.serverIP)
|
||||||
|
|
||||||
printInfo("Service", fmt.Sprintf("pb@%s", ctx.serviceName), remoteLineColor)
|
printInfo("Service", fmt.Sprintf("pb@%s", ctx.serviceName), "")
|
||||||
printInfo("Server", ctx.serverIP, remoteLineColor)
|
printInfo("Server", ctx.serverIP, "")
|
||||||
printInfo("Domain", ctx.domain, remoteLineColor)
|
printInfo("Domain", ctx.domain, "")
|
||||||
printInfo("Port", fmt.Sprintf("%d", ctx.port), remoteLineColor)
|
printInfo("Port", fmt.Sprintf("%d", ctx.port), "")
|
||||||
|
|
||||||
props, err := querySystemdProperties(ctx)
|
props, err := querySystemdProperties(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1188,7 +1209,7 @@ func runStatus() error {
|
|||||||
printInfo("Status", statusLine, statusColorFor(state))
|
printInfo("Status", statusLine, statusColorFor(state))
|
||||||
|
|
||||||
if pid := strings.TrimSpace(props["ExecMainPID"]); pid != "" && pid != "0" {
|
if pid := strings.TrimSpace(props["ExecMainPID"]); pid != "" && pid != "0" {
|
||||||
printInfo("PID", pid, remoteLineColor)
|
printInfo("PID", pid, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
if memCurrent, ok := parseSystemdBytes(props["MemoryCurrent"]); ok {
|
if memCurrent, ok := parseSystemdBytes(props["MemoryCurrent"]); ok {
|
||||||
@@ -1196,7 +1217,7 @@ func runStatus() error {
|
|||||||
if memPeak, ok := parseSystemdBytes(props["MemoryPeak"]); ok {
|
if memPeak, ok := parseSystemdBytes(props["MemoryPeak"]); ok {
|
||||||
memLine = fmt.Sprintf("%s (peak: %s)", memLine, formatBytes(memPeak))
|
memLine = fmt.Sprintf("%s (peak: %s)", memLine, formatBytes(memPeak))
|
||||||
}
|
}
|
||||||
printInfo("Memory", memLine, remoteLineColor)
|
printInfo("Memory", memLine, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
if started := strings.TrimSpace(props["ActiveEnterTimestamp"]); started != "" {
|
if started := strings.TrimSpace(props["ActiveEnterTimestamp"]); started != "" {
|
||||||
@@ -1211,9 +1232,6 @@ func runStatus() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printInfo(label, value, valueColor string) {
|
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)
|
fmt.Printf("%s%s:%s %s%s%s\n", headerColor, label, remoteColorReset, valueColor, value, remoteColorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,7 +1344,7 @@ func formatDuration(d time.Duration) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runLogs() error {
|
func runLogs() error {
|
||||||
ctx, err := buildDeploymentContext()
|
ctx, err := buildDeploymentContext(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1349,7 +1367,7 @@ func runSecrets() error {
|
|||||||
return fmt.Errorf("usage: pb secrets <list|set|delete> [arguments]")
|
return fmt.Errorf("usage: pb secrets <list|set|delete> [arguments]")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, err := buildDeploymentContext()
|
ctx, err := buildDeploymentContext(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
296
main_test.go
Normal file
296
main_test.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseEnvLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
wantKey string
|
||||||
|
wantValue string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{name: "simple", line: "FOO=bar", wantKey: "FOO", wantValue: "bar", wantOK: true},
|
||||||
|
{name: "trims", line: " FOO = bar ", wantKey: "FOO", wantValue: "bar", wantOK: true},
|
||||||
|
{name: "comment", line: "# comment", wantOK: false},
|
||||||
|
{name: "empty", line: "", wantOK: false},
|
||||||
|
{name: "no equals", line: "FOO", wantOK: false},
|
||||||
|
{name: "invalid key digit", line: "1FOO=bar", wantOK: false},
|
||||||
|
{name: "invalid key dash", line: "FOO-BAR=1", wantOK: false},
|
||||||
|
{name: "underscore ok", line: "_FOO=bar", wantKey: "_FOO", wantValue: "bar", wantOK: true},
|
||||||
|
{name: "digit ok after", line: "FOO1=2", wantKey: "FOO1", wantValue: "2", wantOK: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
key, value, ok := parseEnvLine(tt.line)
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Fatalf("ok=%v want %v", ok, tt.wantOK)
|
||||||
|
}
|
||||||
|
if key != tt.wantKey || value != tt.wantValue {
|
||||||
|
t.Fatalf("got key=%q value=%q want key=%q value=%q", key, value, tt.wantKey, tt.wantValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEnvKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{key: "A", want: true},
|
||||||
|
{key: "_A", want: true},
|
||||||
|
{key: "A1", want: true},
|
||||||
|
{key: "A_B2", want: true},
|
||||||
|
{key: "", want: false},
|
||||||
|
{key: "1A", want: false},
|
||||||
|
{key: "A-B", want: false},
|
||||||
|
{key: "A B", want: false},
|
||||||
|
{key: "A.B", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
if got := isEnvKey(tt.key); got != tt.want {
|
||||||
|
t.Fatalf("got %v want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEnvLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
line string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{line: "", want: true},
|
||||||
|
{line: " ", want: true},
|
||||||
|
{line: "FOO=bar", want: true},
|
||||||
|
{line: "FOO = bar", want: false},
|
||||||
|
{line: "1FOO=bar", want: false},
|
||||||
|
{line: "NO_EQUALS", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.line, func(t *testing.T) {
|
||||||
|
if got := isEnvLine(tt.line); got != tt.want {
|
||||||
|
t.Fatalf("got %v want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEnvAssignments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
"# comment",
|
||||||
|
"FOO=old",
|
||||||
|
"BAR=keep",
|
||||||
|
"INVALID LINE",
|
||||||
|
"BAZ=gone",
|
||||||
|
"FOO=duplicate",
|
||||||
|
}
|
||||||
|
assignments := []envAssignment{
|
||||||
|
{key: "FOO", value: "new"},
|
||||||
|
{key: "BAZ", value: "zzz"},
|
||||||
|
{key: "NEWKEY", value: "123"},
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"# comment",
|
||||||
|
"FOO=new",
|
||||||
|
"BAR=keep",
|
||||||
|
"INVALID LINE",
|
||||||
|
"BAZ=zzz",
|
||||||
|
"NEWKEY=123",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := applyEnvAssignments(lines, assignments)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveEnvKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
"KEEP=1",
|
||||||
|
"DROP=2",
|
||||||
|
"# comment",
|
||||||
|
"INVALID LINE",
|
||||||
|
"DROP=3",
|
||||||
|
"ALSO=4",
|
||||||
|
}
|
||||||
|
keys := []string{"DROP", "MISSING"}
|
||||||
|
want := []string{
|
||||||
|
"KEEP=1",
|
||||||
|
"# comment",
|
||||||
|
"INVALID LINE",
|
||||||
|
"ALSO=4",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := removeEnvKeys(lines, keys)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellQuote(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", input: "", want: "''"},
|
||||||
|
{name: "simple", input: "abc", want: "'abc'"},
|
||||||
|
{name: "spaces", input: "a b", want: "'a b'"},
|
||||||
|
{name: "single quote", input: "a'b", want: "'a'\"'\"'b'"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := shellQuote(tt.input); got != tt.want {
|
||||||
|
t.Fatalf("got %q want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveVolumePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
template string
|
||||||
|
baseDir string
|
||||||
|
service string
|
||||||
|
wantResult string
|
||||||
|
}{
|
||||||
|
{name: "empty uses base", template: "", baseDir: "/srv/pb", service: "svc", wantResult: "/srv/pb"},
|
||||||
|
{name: "absolute path", template: "/data/{service}", baseDir: "/srv/pb", service: "svc", wantResult: "/data/svc"},
|
||||||
|
{name: "relative path", template: "data/{service}", baseDir: "/srv/pb", service: "svc", wantResult: "/srv/pb/data/svc"},
|
||||||
|
{name: "simple relative", template: "custom", baseDir: "/srv/pb", service: "svc", wantResult: "/srv/pb/custom"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := resolveVolumePath(tt.template, tt.baseDir, tt.service); got != tt.wantResult {
|
||||||
|
t.Fatalf("got %q want %q", got, tt.wantResult)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslateMachineArch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "x86_64", input: " x86_64 ", want: "amd64"},
|
||||||
|
{name: "amd64", input: "amd64", want: "amd64"},
|
||||||
|
{name: "i686", input: "i686", want: "386"},
|
||||||
|
{name: "armv7l", input: "armv7l", want: "armv7"},
|
||||||
|
{name: "armv6l", input: "armv6l", want: "arm"},
|
||||||
|
{name: "aarch64", input: "aarch64", want: "arm64"},
|
||||||
|
{name: "arm64", input: "arm64", want: "arm64"},
|
||||||
|
{name: "unknown", input: "mips", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := translateMachineArch(tt.input)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("got %q want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdScript(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
serviceDir := "/srv/pb"
|
||||||
|
envFile := "/etc/pb/env"
|
||||||
|
serviceName := "demo"
|
||||||
|
script := systemdScript(serviceDir, envFile, serviceName)
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"/etc/systemd/system/pb@.service",
|
||||||
|
"StandardOutput = append:" + serviceDir + "/%i.log",
|
||||||
|
"StandardError = append:" + serviceDir + "/%i.log",
|
||||||
|
"WorkingDirectory = " + serviceDir,
|
||||||
|
"EnvironmentFile = " + envFile,
|
||||||
|
"ExecStart = " + serviceDir + "/pocketbase serve --http=127.0.0.1:${PORT} --hooksWatch=false --dir=${DATA_DIR} --hooksDir=" + serviceDir + "/pb_hooks --migrationsDir=" + serviceDir + "/pb_migrations --publicDir=" + serviceDir + "/pb_public",
|
||||||
|
"systemctl --no-block enable pb@" + serviceName,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, snippet := range required {
|
||||||
|
if !strings.Contains(script, snippet) {
|
||||||
|
t.Fatalf("script missing %q", snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdOverrideScript(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
serviceName := "demo"
|
||||||
|
port := 8090
|
||||||
|
volume := "/srv/pb/data"
|
||||||
|
script := systemdOverrideScript(serviceName, port, volume)
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"/etc/systemd/system/pb@" + serviceName + ".service.d",
|
||||||
|
"Environment=PORT=8090",
|
||||||
|
"Environment=DATA_DIR=" + volume,
|
||||||
|
}
|
||||||
|
for _, snippet := range required {
|
||||||
|
if !strings.Contains(script, snippet) {
|
||||||
|
t.Fatalf("script missing %q", snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdRestartScript(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
serviceName := "demo"
|
||||||
|
script := systemdRestartScript(serviceName)
|
||||||
|
expected := "systemctl --no-block restart pb@" + serviceName
|
||||||
|
if !strings.Contains(script, expected) {
|
||||||
|
t.Fatalf("script missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user