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`.
|
Connects to the configured server and streams `/root/pb/{service}/{service}.log` via `tail -n 100 -F`.
|
||||||
|
|
||||||
### secrets
|
### 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"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -24,7 +25,10 @@ import (
|
|||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var initServiceNameArg string
|
var (
|
||||||
|
initServiceNameArg string
|
||||||
|
invocationArgs []string
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
commands := defaultCommands()
|
commands := defaultCommands()
|
||||||
@@ -33,6 +37,8 @@ func main() {
|
|||||||
args = args[1:]
|
args = args[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invocationArgs = args
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
printUsage(commands)
|
printUsage(commands)
|
||||||
return
|
return
|
||||||
@@ -67,7 +73,7 @@ func defaultCommands() []command {
|
|||||||
{name: "setup", description: "provision the remote server and install PocketBase", action: runSetup},
|
{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: "deploy", description: "sync migrations/hooks/static assets (runs setup if needed)", action: runDeploy},
|
||||||
{name: "logs", description: "show PocketBase logs", action: runLogs},
|
{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 {
|
func runInit() error {
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -285,6 +284,8 @@ const defaultPocketbaseVersion = "0.35.1"
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rand.Seed(time.Now().UnixNano())
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
log.SetFlags(0)
|
||||||
|
log.SetPrefix("")
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePBConfig(path, serviceName string) error {
|
func writePBConfig(path, serviceName string) error {
|
||||||
@@ -555,17 +556,9 @@ func loadEnv(path string) (map[string]string, error) {
|
|||||||
|
|
||||||
env := make(map[string]string)
|
env := make(map[string]string)
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
if key, value, ok := parseEnvLine(line); ok {
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
env[key] = value
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
idx := strings.Index(line, "=")
|
|
||||||
if idx == -1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(line[:idx])
|
|
||||||
value := strings.TrimSpace(line[idx+1:])
|
|
||||||
env[key] = value
|
|
||||||
}
|
}
|
||||||
return env, nil
|
return env, nil
|
||||||
}
|
}
|
||||||
@@ -808,6 +801,226 @@ tail -n 25 -F "$log"
|
|||||||
return runSSHRawCommand(ctx.serverIP, script)
|
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) {
|
func remoteBinaryExists(server, path string) (bool, error) {
|
||||||
script := fmt.Sprintf(`if [ -f %q ]; then printf yes; else printf no; fi`, path)
|
script := fmt.Sprintf(`if [ -f %q ]; then printf yes; else printf no; fi`, path)
|
||||||
output, err := runSSHOutput(server, script)
|
output, err := runSSHOutput(server, script)
|
||||||
@@ -1040,6 +1253,18 @@ func runSSHOutput(server, script string) (string, error) {
|
|||||||
return strings.TrimSpace(filtered), nil
|
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 {
|
func syncLocalDirectories(server, remoteBase string, dirs []string) error {
|
||||||
var toSync []string
|
var toSync []string
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
@@ -1197,16 +1422,27 @@ func stripLeadingEnvLines(input string) string {
|
|||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEnvLine(line string) bool {
|
func parseEnvLine(line string) (string, string, bool) {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if trimmed == "" {
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
return true
|
return "", "", false
|
||||||
}
|
}
|
||||||
idx := strings.IndexRune(trimmed, '=')
|
idx := strings.Index(trimmed, "=")
|
||||||
if idx <= 0 {
|
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
|
return false
|
||||||
}
|
}
|
||||||
key := trimmed[:idx]
|
|
||||||
for i, r := range key {
|
for i, r := range key {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
if r != '_' && !unicode.IsLetter(r) {
|
if r != '_' && !unicode.IsLetter(r) {
|
||||||
@@ -1221,6 +1457,18 @@ func isEnvLine(line string) bool {
|
|||||||
return true
|
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 {
|
func shellQuote(value string) string {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return "''"
|
return "''"
|
||||||
|
|||||||
Reference in New Issue
Block a user