Skip to content

Commit 69c4760

Browse files
committed
feat: add public plan library for system-agent
Signed-off-by: Carlos Salas <carlos.salas@suse.com>
1 parent 4d8e0e6 commit 69c4760

6 files changed

Lines changed: 282 additions & 0 deletions

File tree

pkg/plan/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/rancher/rancher/pkg/plan
2+
3+
go 1.25.0
4+
5+
toolchain go1.25.8

pkg/plan/go.sum

Whitespace-only changes.

pkg/plan/plan.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package plan
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/json"
6+
"fmt"
7+
)
8+
9+
// Parse decodes a JSON-encoded plan into a Plan struct and returns an error if the JSON is invalid.
10+
// This is used to extract Plan Secret data into a structured format.
11+
func Parse(raw []byte) (Plan, error) {
12+
var plan Plan
13+
if err := json.Unmarshal(raw, &plan); err != nil {
14+
return Plan{}, fmt.Errorf("failed to parse plan: %w", err)
15+
}
16+
17+
return plan, nil
18+
}
19+
20+
// Checksum computes the sha-256 checksum of the plan bytes.
21+
// Used for backward compatibility with orchestrators that compare applied-checksum.
22+
func Checksum(raw []byte) string {
23+
h := sha256.New()
24+
h.Write(raw)
25+
26+
return fmt.Sprintf("%x", h.Sum(nil))
27+
}

pkg/plan/plan_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package plan
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestParse(t *testing.T) {
11+
t.Run("valid plan json", func(t *testing.T) {
12+
raw := `{"files":[{"path":"/tmp/test","content":"aGVsbG8gd29ybGQ="}],"instructions":[{"name":"setup","command":"/bin/sh"}]}`
13+
14+
p, err := Parse([]byte(raw))
15+
if err != nil {
16+
t.Fatalf("unexpected error: %v", err)
17+
}
18+
19+
if len(p.Files) != 1 || p.Files[0].Path != "/tmp/test" {
20+
t.Errorf("unexpected files: %+v", p.Files)
21+
}
22+
23+
if len(p.OneTimeInstructions) != 1 || p.OneTimeInstructions[0].Name != "setup" {
24+
t.Errorf("unexpected instructions: %+v", p.OneTimeInstructions)
25+
}
26+
})
27+
28+
t.Run("invalid plan json", func(t *testing.T) {
29+
_, err := Parse([]byte(`{not valid json`))
30+
if err == nil {
31+
t.Fatal("expected an error")
32+
}
33+
34+
if !strings.Contains(err.Error(), "failed to parse plan") {
35+
t.Errorf("unexpected error message: %v", err)
36+
}
37+
})
38+
}
39+
40+
func TestPlanEncodeParse(t *testing.T) {
41+
original := Plan{
42+
Files: []File{
43+
{
44+
Content: "aGVsbG8gd29ybGQ=",
45+
UID: -1,
46+
GID: -1,
47+
Path: "/etc/myapp/config.yaml",
48+
Permissions: "0644",
49+
},
50+
{
51+
Path: "/etc/myapp/",
52+
Directory: true,
53+
},
54+
},
55+
OneTimeInstructions: []OneTimeInstruction{
56+
{
57+
CommonInstruction: CommonInstruction{
58+
Name: "install",
59+
Command: "/bin/sh",
60+
Args: []string{"-c", "echo hello"},
61+
},
62+
SaveOutput: true,
63+
},
64+
},
65+
PeriodicInstructions: []PeriodicInstruction{
66+
{
67+
CommonInstruction: CommonInstruction{
68+
Name: "healthcheck",
69+
Command: "/bin/sh",
70+
Args: []string{"-c", "echo ok"},
71+
},
72+
PeriodSeconds: 60,
73+
SaveStderrOutput: true,
74+
},
75+
},
76+
}
77+
78+
data, err := json.Marshal(original)
79+
if err != nil {
80+
t.Fatalf("marshal: %v", err)
81+
}
82+
83+
var restored Plan
84+
restored, err = Parse([]byte(data))
85+
if err != nil {
86+
t.Fatalf("parse: %v", err)
87+
}
88+
89+
if !reflect.DeepEqual(original, restored) {
90+
t.Errorf("plan changed after encode/parse\nwant: %+v\n got: %+v", original, restored)
91+
}
92+
}
93+
94+
func TestJSONKeys(t *testing.T) {
95+
tests := []struct {
96+
name string
97+
input string
98+
validate func(t *testing.T, p Plan)
99+
}{
100+
{
101+
name: "files",
102+
input: `{"files":[{"path":"/tmp/test","content":"aGVsbG8gd29ybGQ="}]}`,
103+
validate: func(t *testing.T, p Plan) {
104+
if len(p.Files) != 1 || p.Files[0].Path != "/tmp/test" {
105+
t.Errorf("got %+v", p.Files)
106+
}
107+
},
108+
},
109+
{
110+
name: "instructions",
111+
input: `{"instructions":[{"name":"test","command":"/bin/sh"}]}`,
112+
validate: func(t *testing.T, p Plan) {
113+
if len(p.OneTimeInstructions) != 1 || p.OneTimeInstructions[0].Name != "test" {
114+
t.Errorf("got %+v", p.OneTimeInstructions)
115+
}
116+
},
117+
},
118+
{
119+
name: "periodicInstructions",
120+
input: `{"periodicInstructions":[{"name":"cron","periodSeconds":300}]}`,
121+
validate: func(t *testing.T, p Plan) {
122+
if len(p.PeriodicInstructions) != 1 || p.PeriodicInstructions[0].PeriodSeconds != 300 {
123+
t.Errorf("got %+v", p.PeriodicInstructions)
124+
}
125+
},
126+
},
127+
{
128+
name: "probes / httpGet",
129+
input: `{"probes":{"web":{"httpGet":{"url":"http://localhost/health","insecure":true}}}}`,
130+
validate: func(t *testing.T, p Plan) {
131+
probe, ok := p.Probes["web"]
132+
if !ok {
133+
t.Fatal("probes.web missing")
134+
}
135+
if probe.HTTPGetAction.URL != "http://localhost/health" || !probe.HTTPGetAction.Insecure {
136+
t.Errorf("got %+v", probe.HTTPGetAction)
137+
}
138+
},
139+
},
140+
}
141+
142+
for _, tt := range tests {
143+
t.Run(tt.name, func(t *testing.T) {
144+
var p Plan
145+
146+
p, err := Parse([]byte(tt.input))
147+
if err != nil {
148+
t.Fatalf("parse: %v", err)
149+
}
150+
151+
tt.validate(t, p)
152+
})
153+
}
154+
}
155+
156+
func TestChecksum(t *testing.T) {
157+
input := []byte(`{"files":[]}`)
158+
159+
// verify idempotency by calling Checksum() twice with the same input
160+
output1, output2 := Checksum(input), Checksum(input)
161+
if output1 != output2 {
162+
t.Error("checksum result is not idempotent")
163+
}
164+
}

pkg/plan/probe.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package plan
2+
3+
// HTTPGetAction describes an HTTP GET request used by a Probe.
4+
type HTTPGetAction struct {
5+
URL string `json:"url,omitempty"`
6+
Insecure bool `json:"insecure,omitempty"`
7+
ClientCert string `json:"clientCert,omitempty"`
8+
ClientKey string `json:"clientKey,omitempty"`
9+
CACert string `json:"caCert,omitempty"`
10+
}
11+
12+
// Probe describes a health check to be performed against the node.
13+
type Probe struct {
14+
Name string `json:"name,omitempty"`
15+
InitialDelaySeconds int `json:"initialDelaySeconds,omitempty"` // default 0
16+
TimeoutSeconds int `json:"timeoutSeconds,omitempty"` // default 1
17+
SuccessThreshold int `json:"successThreshold,omitempty"` // default 1
18+
FailureThreshold int `json:"failureThreshold,omitempty"` // default 3
19+
HTTPGetAction HTTPGetAction `json:"httpGet,omitempty"`
20+
}
21+
22+
// ProbeStatus represents the current health status of a probe as reported by the agent.
23+
type ProbeStatus struct {
24+
Healthy bool `json:"healthy,omitempty"`
25+
SuccessCount int `json:"successCount,omitempty"`
26+
FailureCount int `json:"failureCount,omitempty"`
27+
}

pkg/plan/types.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package plan
2+
3+
// Plan represents the basic unit of work performed by the system-agent.
4+
type Plan struct {
5+
Files []File `json:"files,omitempty"`
6+
OneTimeInstructions []OneTimeInstruction `json:"instructions,omitempty"`
7+
Probes map[string]Probe `json:"probes,omitempty"`
8+
PeriodicInstructions []PeriodicInstruction `json:"periodicInstructions,omitempty"`
9+
}
10+
11+
// File represents a file to be written on the node by the system-agent.
12+
// Path is the absolute path on the node (e.g. /etc/kubernetes/ssl/ca.pem).
13+
// Content is base64-encoded. If Directory is true, a directory is created, not a file.
14+
type File struct {
15+
Content string `json:"content,omitempty"`
16+
Directory bool `json:"directory,omitempty"`
17+
UID int `json:"uid,omitempty"`
18+
GID int `json:"gid,omitempty"`
19+
Path string `json:"path,omitempty"`
20+
Permissions string `json:"permissions,omitempty"` // internally, the string will be converted to a uint32 to satisfy os.FileMode
21+
Action string `json:"action,omitempty"`
22+
}
23+
24+
// CommonInstruction holds fields shared by all instruction types.
25+
type CommonInstruction struct {
26+
Name string `json:"name,omitempty"`
27+
Image string `json:"image,omitempty"`
28+
Env []string `json:"env,omitempty"`
29+
Args []string `json:"args,omitempty"`
30+
Command string `json:"command,omitempty"`
31+
}
32+
33+
// OneTimeInstruction is an instruction that is executed exactly once.
34+
type OneTimeInstruction struct {
35+
CommonInstruction
36+
SaveOutput bool `json:"saveOutput,omitempty"`
37+
}
38+
39+
// PeriodicInstruction is an instruction that is executed on a recurring schedule.
40+
type PeriodicInstruction struct {
41+
CommonInstruction
42+
PeriodSeconds int `json:"periodSeconds,omitempty"` // default 600, i.e. 10 minutes
43+
SaveStderrOutput bool `json:"saveStderrOutput,omitempty"`
44+
}
45+
46+
// PeriodicInstructionOutput holds the result of a periodic instruction execution.
47+
// The Stdout and Stderr fields are gzip+base64 encoded byte slices.
48+
type PeriodicInstructionOutput struct {
49+
Name string `json:"name"`
50+
Stdout []byte `json:"stdout"`
51+
Stderr []byte `json:"stderr"`
52+
ExitCode int `json:"exitCode"`
53+
// LastSuccessfulRunTime is a time.UnixDate formatted string of the last successful run.
54+
LastSuccessfulRunTime string `json:"lastSuccessfulRunTime"`
55+
// Failures is the number of consecutive times this instruction has failed.
56+
Failures int `json:"failures"`
57+
// LastFailedRunTime is a time.UnixDate formatted string of when the instruction started failing.
58+
LastFailedRunTime string `json:"lastFailedRunTime"`
59+
}

0 commit comments

Comments
 (0)