secrets command
This commit is contained in:
@@ -30,3 +30,9 @@ Syncs `pb_public`, `pb_migrations`, and `pb_hooks`, then restarts the remote Poc
|
||||
Connects to the configured server and streams `/root/pb/{service}/{service}.log` via `tail -n 100 -F`.
|
||||
|
||||
### secrets
|
||||
|
||||
Manage the remote `/root/pb/{service}/.env` file.
|
||||
|
||||
- `pb secrets list` prints every variable name defined in the remote `.env` (comments and empty lines are ignored).
|
||||
- `pb secrets set KEY=VALUE [...]` adds or updates one or more key/value pairs while leaving the other file entries untouched.
|
||||
- `pb secrets delete KEY [...]` removes the named entries from the remote `.env`.
|
||||
|
||||
296
main.go
296
main.go
@@ -5,6 +5,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -24,7 +25,10 @@ import (
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
var initServiceNameArg string
|
||||
var (
|
||||
initServiceNameArg string
|
||||
invocationArgs []string
|
||||
)
|
||||
|
||||
func main() {
|
||||
commands := defaultCommands()
|
||||
@@ -33,6 +37,8 @@ func main() {
|
||||
args = args[1:]
|
||||
}
|
||||
|
||||
invocationArgs = args
|
||||
|
||||
if len(args) == 0 {
|
||||
printUsage(commands)
|
||||
return
|
||||
@@ -67,7 +73,7 @@ func defaultCommands() []command {
|
||||
{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: "logs", description: "show PocketBase logs", action: runLogs},
|
||||
{name: "secrets", description: "manage deployment secrets", action: placeholderAction("secrets")},
|
||||
{name: "secrets", description: "manage deployment secrets", action: runSecrets},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,13 +208,6 @@ func runCommand(cmd command) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func placeholderAction(name string) commandAction {
|
||||
return func() error {
|
||||
fmt.Printf("TODO: implement %s command\n", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func runInit() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -285,6 +284,8 @@ const defaultPocketbaseVersion = "0.35.1"
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
}
|
||||
|
||||
func writePBConfig(path, serviceName string) error {
|
||||
@@ -555,18 +556,10 @@ func loadEnv(path string) (map[string]string, error) {
|
||||
|
||||
env := make(map[string]string)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, "=")
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if key, value, ok := parseEnvLine(line); ok {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
@@ -808,6 +801,226 @@ tail -n 25 -F "$log"
|
||||
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
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for _, line := range lines {
|
||||
if key, _, ok := parseEnvLine(line); ok {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
fmt.Println("no secrets found")
|
||||
return nil
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
fmt.Println(key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Printf("updated %d secrets\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
|
||||
}
|
||||
|
||||
fmt.Printf("removed %d secrets\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)
|
||||
@@ -1040,6 +1253,18 @@ func runSSHOutput(server, script string) (string, error) {
|
||||
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 syncLocalDirectories(server, remoteBase string, dirs []string) error {
|
||||
var toSync []string
|
||||
for _, dir := range dirs {
|
||||
@@ -1197,16 +1422,27 @@ func stripLeadingEnvLines(input string) string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func isEnvLine(line string) bool {
|
||||
func parseEnvLine(line string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
return "", "", false
|
||||
}
|
||||
idx := strings.IndexRune(trimmed, '=')
|
||||
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
|
||||
}
|
||||
key := trimmed[:idx]
|
||||
for i, r := range key {
|
||||
if i == 0 {
|
||||
if r != '_' && !unicode.IsLetter(r) {
|
||||
@@ -1221,6 +1457,18 @@ func isEnvLine(line string) bool {
|
||||
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 "''"
|
||||
|
||||
Reference in New Issue
Block a user