Skip to content

Commit fb93602

Browse files
committed
feat(cli): consolidate command surface and operation plans
Flatten Cobra command entrypoints, add structured plan and confirmation contracts, harden pgBackRest/PITR/log/fork/do workflows, and move command glue into cli/internal helpers.
1 parent 9eb70db commit fb93602

84 files changed

Lines changed: 11943 additions & 5782 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cli/do/commands.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package do
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
// RunPlaybooks runs a sequence of ansible playbook commands.
11+
func RunPlaybooks(inventory string, commands [][]string) error {
12+
for _, command := range commands {
13+
if err := RunPlaybook(inventory, command); err != nil {
14+
return err
15+
}
16+
}
17+
return nil
18+
}
19+
20+
func BuildPgsqlAddCommands(cluster string, ips []string) ([][]string, error) {
21+
if cluster == "" {
22+
return nil, fmt.Errorf("pgsql cluster is required")
23+
}
24+
if len(ips) == 0 {
25+
return [][]string{{"pgsql.yml", "-l", cluster}}, nil
26+
}
27+
target, exists, err := buildPgsqlInstancePatterns(cluster, ips)
28+
if err != nil {
29+
return nil, err
30+
}
31+
return [][]string{
32+
{"pgsql.yml", "-l", target},
33+
{"pgsql.yml", "-l", exists, "-t", "pg_service"},
34+
}, nil
35+
}
36+
37+
func BuildPgsqlRmCommands(cluster string, ips []string, uninstall bool) ([][]string, error) {
38+
if cluster == "" {
39+
return nil, fmt.Errorf("pgsql cluster is required")
40+
}
41+
selector := cluster
42+
if len(ips) > 0 {
43+
target, _, err := buildPgsqlInstancePatterns(cluster, ips)
44+
if err != nil {
45+
return nil, err
46+
}
47+
selector = target
48+
}
49+
command := []string{"pgsql-rm.yml", "-l", selector}
50+
if uninstall {
51+
command = append(command, "-e", "pg_rm_pkg=true")
52+
}
53+
return [][]string{command}, nil
54+
}
55+
56+
func BuildNodeAddCommand(selectors []string) ([]string, error) {
57+
selector, err := joinSelectors(selectors)
58+
if err != nil {
59+
return nil, err
60+
}
61+
return []string{"node.yml", "-l", selector}, nil
62+
}
63+
64+
func BuildNodeRmCommand(selectors []string) ([]string, error) {
65+
selector, err := joinSelectors(selectors)
66+
if err != nil {
67+
return nil, err
68+
}
69+
return []string{"node-rm.yml", "-l", selector}, nil
70+
}
71+
72+
func BuildNodeRepoCommand(args []string) ([]string, error) {
73+
if len(args) > 2 {
74+
return nil, fmt.Errorf("node-repo accepts at most selector and module")
75+
}
76+
command := []string{"node.yml", "-t", "node_repo"}
77+
if len(args) >= 1 && args[0] != "" {
78+
command = append(command, "-l", args[0])
79+
}
80+
if len(args) == 2 && args[1] != "" {
81+
command = append(command, "-e", fmt.Sprintf("node_repo_modules=%s", args[1]))
82+
}
83+
return command, nil
84+
}
85+
86+
func BuildRedisAddCommands(selector string, ports []string) ([][]string, error) {
87+
if selector == "" {
88+
return nil, fmt.Errorf("redis selector is required")
89+
}
90+
if len(ports) == 0 {
91+
return [][]string{{"redis.yml", "-l", selector}}, nil
92+
}
93+
if !isIPv4(selector) {
94+
return nil, fmt.Errorf("redis instance operations require an IP selector")
95+
}
96+
commands := make([][]string, 0, len(ports))
97+
for _, port := range ports {
98+
if err := validateRedisPort(port); err != nil {
99+
return nil, err
100+
}
101+
commands = append(commands, []string{"redis.yml", "-l", selector, "-e", fmt.Sprintf("redis_port=%s", port)})
102+
}
103+
return commands, nil
104+
}
105+
106+
func BuildRedisRmCommands(selector string, ports []string, uninstall bool) ([][]string, error) {
107+
if selector == "" {
108+
return nil, fmt.Errorf("redis selector is required")
109+
}
110+
if len(ports) == 0 {
111+
command := []string{"redis-rm.yml", "-l", selector}
112+
if uninstall {
113+
command = append(command, "-e", "redis_rm_pkg=true")
114+
}
115+
return [][]string{command}, nil
116+
}
117+
if !isIPv4(selector) {
118+
return nil, fmt.Errorf("redis instance operations require an IP selector")
119+
}
120+
if uninstall {
121+
return nil, fmt.Errorf("redis package uninstall is only supported for node or cluster removal")
122+
}
123+
commands := make([][]string, 0, len(ports))
124+
for _, port := range ports {
125+
if err := validateRedisPort(port); err != nil {
126+
return nil, err
127+
}
128+
command := []string{"redis-rm.yml", "-l", selector, "-e", fmt.Sprintf("redis_port=%s", port)}
129+
commands = append(commands, command)
130+
}
131+
return commands, nil
132+
}
133+
134+
func buildPgsqlInstancePatterns(cluster string, ips []string) (string, string, error) {
135+
target := "&" + cluster
136+
existing := cluster
137+
for _, ip := range ips {
138+
if !isIPv4(ip) {
139+
return "", "", fmt.Errorf("invalid ip address: %s", ip)
140+
}
141+
target = ip + "," + target
142+
existing += ",!" + ip
143+
}
144+
return target, existing, nil
145+
}
146+
147+
func joinSelectors(selectors []string) (string, error) {
148+
if len(selectors) == 0 {
149+
return "", fmt.Errorf("selector is required")
150+
}
151+
for _, selector := range selectors {
152+
if selector == "" {
153+
return "", fmt.Errorf("selector cannot be empty")
154+
}
155+
}
156+
return strings.Join(selectors, ","), nil
157+
}
158+
159+
func validateRedisPort(port string) error {
160+
n, err := strconv.Atoi(port)
161+
if err != nil || n < 1024 || n > 65535 {
162+
return fmt.Errorf("invalid redis port: %s", port)
163+
}
164+
return nil
165+
}
166+
167+
func isIPv4(value string) bool {
168+
ip := net.ParseIP(value)
169+
return ip != nil && ip.To4() != nil
170+
}

cli/do/do_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package do
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestBuildPgsqlAddCommandsAppendsReplicaAndRefreshesService(t *testing.T) {
9+
commands, err := BuildPgsqlAddCommands("pg-test", []string{"10.10.10.12", "10.10.10.13"})
10+
if err != nil {
11+
t.Fatalf("BuildPgsqlAddCommands() error = %v", err)
12+
}
13+
14+
want := [][]string{
15+
{"pgsql.yml", "-l", "10.10.10.13,10.10.10.12,&pg-test"},
16+
{"pgsql.yml", "-l", "pg-test,!10.10.10.12,!10.10.10.13", "-t", "pg_service"},
17+
}
18+
if !reflect.DeepEqual(commands, want) {
19+
t.Fatalf("BuildPgsqlAddCommands() = %#v, want %#v", commands, want)
20+
}
21+
}
22+
23+
func TestBuildPgsqlRmCommandsRemovesReplicaInsteadOfCluster(t *testing.T) {
24+
commands, err := BuildPgsqlRmCommands("pg-test", []string{"10.10.10.13"}, true)
25+
if err != nil {
26+
t.Fatalf("BuildPgsqlRmCommands() error = %v", err)
27+
}
28+
29+
want := [][]string{
30+
{"pgsql-rm.yml", "-l", "10.10.10.13,&pg-test", "-e", "pg_rm_pkg=true"},
31+
}
32+
if !reflect.DeepEqual(commands, want) {
33+
t.Fatalf("BuildPgsqlRmCommands() = %#v, want %#v", commands, want)
34+
}
35+
}
36+
37+
func TestBuildNodeCommandsAcceptMultipleSelectors(t *testing.T) {
38+
addCommand, err := BuildNodeAddCommand([]string{"10.10.10.10", "10.10.10.11"})
39+
if err != nil {
40+
t.Fatalf("BuildNodeAddCommand() error = %v", err)
41+
}
42+
if want := []string{"node.yml", "-l", "10.10.10.10,10.10.10.11"}; !reflect.DeepEqual(addCommand, want) {
43+
t.Fatalf("BuildNodeAddCommand() = %#v, want %#v", addCommand, want)
44+
}
45+
46+
rmCommand, err := BuildNodeRmCommand([]string{"pg-test", "10.10.10.11"})
47+
if err != nil {
48+
t.Fatalf("BuildNodeRmCommand() error = %v", err)
49+
}
50+
if want := []string{"node-rm.yml", "-l", "pg-test,10.10.10.11"}; !reflect.DeepEqual(rmCommand, want) {
51+
t.Fatalf("BuildNodeRmCommand() = %#v, want %#v", rmCommand, want)
52+
}
53+
}
54+
55+
func TestBuildNodeRepoCommandRejectsTooManyArgs(t *testing.T) {
56+
if _, err := BuildNodeRepoCommand([]string{"pg-test", "node", "extra"}); err == nil {
57+
t.Fatal("BuildNodeRepoCommand() should reject more than two arguments")
58+
}
59+
}
60+
61+
func TestBuildRedisCommandsValidatePortsAndUninstall(t *testing.T) {
62+
if _, err := BuildRedisAddCommands("redis-test", []string{"6379"}); err == nil {
63+
t.Fatal("BuildRedisAddCommands() should require an IP selector when ports are specified")
64+
}
65+
if _, err := BuildRedisAddCommands("10.10.10.10", []string{"80"}); err == nil {
66+
t.Fatal("BuildRedisAddCommands() should reject invalid redis port")
67+
}
68+
69+
commands, err := BuildRedisRmCommands("10.10.10.10", nil, true)
70+
if err != nil {
71+
t.Fatalf("BuildRedisRmCommands() error = %v", err)
72+
}
73+
want := [][]string{{"redis-rm.yml", "-l", "10.10.10.10", "-e", "redis_rm_pkg=true"}}
74+
if !reflect.DeepEqual(commands, want) {
75+
t.Fatalf("BuildRedisRmCommands() = %#v, want %#v", commands, want)
76+
}
77+
78+
if _, err := BuildRedisRmCommands("10.10.10.10", []string{"6379"}, true); err == nil {
79+
t.Fatal("BuildRedisRmCommands() should reject --uninstall for per-port removal")
80+
}
81+
}

cli/patroni/config_plan.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package patroni
2+
3+
import (
4+
"strings"
5+
6+
"pig/internal/output"
7+
)
8+
9+
// BuildConfigPlan returns a side-effect-free primitive plan for Patroni DCS config changes.
10+
func BuildConfigPlan(action string, kvPairs []string) *output.Plan {
11+
scope := "patroni"
12+
setFlag := "-s"
13+
if action == "pg" {
14+
scope = "postgresql.parameters"
15+
setFlag = "-p"
16+
}
17+
18+
normalized := normalizeConfigPairs(kvPairs)
19+
pairDetail := strings.Join(normalized, ", ")
20+
if pairDetail == "" {
21+
pairDetail = "no key=value pairs provided"
22+
}
23+
24+
return &output.Plan{
25+
Command: buildConfigCommand(action, normalized),
26+
Boundary: "pt:dcs-config",
27+
Confirmation: "recommended",
28+
Actions: []output.Action{
29+
{Step: 1, Description: "Validate key=value pairs for Patroni dynamic configuration"},
30+
{Step: 2, Description: "Apply DCS config changes with patronictl edit-config --force"},
31+
{Step: 3, Description: "Report whether PostgreSQL reload or restart should be considered"},
32+
},
33+
Affects: []output.Resource{
34+
{Type: "dcs_config", Name: scope, Impact: "update", Detail: pairDetail},
35+
},
36+
Expected: "Patroni dynamic configuration is updated in DCS; members apply changes according to Patroni/PostgreSQL rules",
37+
Risks: []string{
38+
"DCS configuration mistakes can affect every cluster member.",
39+
"Some PostgreSQL parameters require reload or restart before they take effect.",
40+
},
41+
Preconditions: []output.Check{
42+
{Name: "config pairs", Status: "planned", Detail: pairDetail},
43+
{Name: "patroni config", Status: "required", Detail: DefaultConfigPath},
44+
{Name: "patronictl command", Status: "planned", Detail: "edit-config --force " + setFlag},
45+
},
46+
Verifications: []output.Check{
47+
{Name: "show config", Status: "manual", Detail: "pig pt config show"},
48+
{Name: "member state", Status: "manual", Detail: "pig pt list"},
49+
},
50+
NextActions: []output.NextAction{
51+
{Command: "pig pt reload", Reason: "reload PostgreSQL configuration after DCS parameter changes", Required: false},
52+
{Command: "pig pt restart --pending", Reason: "restart members only if Patroni marks pending restart", Required: false},
53+
{Command: "pig pt config show", Reason: "verify DCS config after change", Required: false},
54+
},
55+
}
56+
}
57+
58+
func buildConfigCommand(action string, kvPairs []string) string {
59+
parts := []string{"pig", "pt", "config", action}
60+
parts = append(parts, kvPairs...)
61+
parts = append(parts, "--plan")
62+
return strings.Join(parts, " ")
63+
}
64+
65+
func normalizeConfigPairs(kvPairs []string) []string {
66+
if len(kvPairs) == 0 {
67+
return nil
68+
}
69+
pairs := make([]string, 0, len(kvPairs))
70+
for _, pair := range kvPairs {
71+
pair = strings.TrimSpace(pair)
72+
if pair != "" {
73+
pairs = append(pairs, pair)
74+
}
75+
}
76+
return pairs
77+
}

0 commit comments

Comments
 (0)