diff --git a/pkg/plan/go.mod b/pkg/plan/go.mod new file mode 100644 index 00000000000..60afa135d63 --- /dev/null +++ b/pkg/plan/go.mod @@ -0,0 +1,5 @@ +module github.com/rancher/rancher/pkg/plan + +go 1.25.0 + +toolchain go1.25.8 diff --git a/pkg/plan/go.sum b/pkg/plan/go.sum new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pkg/plan/plan.go b/pkg/plan/plan.go new file mode 100644 index 00000000000..010cddbfb04 --- /dev/null +++ b/pkg/plan/plan.go @@ -0,0 +1,27 @@ +package plan + +import ( + "crypto/sha256" + "encoding/json" + "fmt" +) + +// Parse decodes a JSON-encoded plan into a Plan struct and returns an error if the JSON is invalid. +// This is used to extract Plan Secret data into a structured format. +func Parse(raw []byte) (Plan, error) { + var plan Plan + if err := json.Unmarshal(raw, &plan); err != nil { + return Plan{}, fmt.Errorf("failed to parse plan: %w", err) + } + + return plan, nil +} + +// Checksum computes the sha-256 checksum of the plan bytes. +// Used for backward compatibility with orchestrators that compare applied-checksum. +func Checksum(raw []byte) string { + h := sha256.New() + h.Write(raw) + + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/pkg/plan/plan_test.go b/pkg/plan/plan_test.go new file mode 100644 index 00000000000..75c2da0d428 --- /dev/null +++ b/pkg/plan/plan_test.go @@ -0,0 +1,164 @@ +package plan + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestParse(t *testing.T) { + t.Run("valid plan json", func(t *testing.T) { + raw := `{"files":[{"path":"/tmp/test","content":"aGVsbG8gd29ybGQ="}],"instructions":[{"name":"setup","command":"/bin/sh"}]}` + + p, err := Parse([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(p.Files) != 1 || p.Files[0].Path != "/tmp/test" { + t.Errorf("unexpected files: %+v", p.Files) + } + + if len(p.OneTimeInstructions) != 1 || p.OneTimeInstructions[0].Name != "setup" { + t.Errorf("unexpected instructions: %+v", p.OneTimeInstructions) + } + }) + + t.Run("invalid plan json", func(t *testing.T) { + _, err := Parse([]byte(`{not valid json`)) + if err == nil { + t.Fatal("expected an error") + } + + if !strings.Contains(err.Error(), "failed to parse plan") { + t.Errorf("unexpected error message: %v", err) + } + }) +} + +func TestMarshalParse(t *testing.T) { + original := Plan{ + Files: []File{ + { + Content: "aGVsbG8gd29ybGQ=", + UID: -1, + GID: -1, + Path: "/etc/myapp/config.yaml", + Permissions: "0644", + }, + { + Path: "/etc/myapp/", + Directory: true, + }, + }, + OneTimeInstructions: []OneTimeInstruction{ + { + CommonInstruction: CommonInstruction{ + Name: "install", + Command: "/bin/sh", + Args: []string{"-c", "echo hello"}, + }, + SaveOutput: true, + }, + }, + PeriodicInstructions: []PeriodicInstruction{ + { + CommonInstruction: CommonInstruction{ + Name: "healthcheck", + Command: "/bin/sh", + Args: []string{"-c", "echo ok"}, + }, + PeriodSeconds: 60, + SaveStderrOutput: true, + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var restored Plan + restored, err = Parse([]byte(data)) + if err != nil { + t.Fatalf("parse: %v", err) + } + + if !reflect.DeepEqual(original, restored) { + t.Errorf("plan changed after encode/parse\nwant: %+v\n got: %+v", original, restored) + } +} + +func TestParseJSONKeys(t *testing.T) { + tests := []struct { + name string + input string + validate func(t *testing.T, p Plan) + }{ + { + name: "files", + input: `{"files":[{"path":"/tmp/test","content":"aGVsbG8gd29ybGQ="}]}`, + validate: func(t *testing.T, p Plan) { + if len(p.Files) != 1 || p.Files[0].Path != "/tmp/test" { + t.Errorf("got %+v", p.Files) + } + }, + }, + { + name: "instructions", + input: `{"instructions":[{"name":"test","command":"/bin/sh"}]}`, + validate: func(t *testing.T, p Plan) { + if len(p.OneTimeInstructions) != 1 || p.OneTimeInstructions[0].Name != "test" { + t.Errorf("got %+v", p.OneTimeInstructions) + } + }, + }, + { + name: "periodicInstructions", + input: `{"periodicInstructions":[{"name":"cron","periodSeconds":300}]}`, + validate: func(t *testing.T, p Plan) { + if len(p.PeriodicInstructions) != 1 || p.PeriodicInstructions[0].PeriodSeconds != 300 { + t.Errorf("got %+v", p.PeriodicInstructions) + } + }, + }, + { + name: "probes / httpGet", + input: `{"probes":{"web":{"httpGet":{"url":"http://localhost/health","insecure":true}}}}`, + validate: func(t *testing.T, p Plan) { + probe, ok := p.Probes["web"] + if !ok { + t.Fatal("probes.web missing") + } + if probe.HTTPGetAction.URL != "http://localhost/health" || !probe.HTTPGetAction.Insecure { + t.Errorf("got %+v", probe.HTTPGetAction) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var p Plan + + p, err := Parse([]byte(tt.input)) + if err != nil { + t.Fatalf("parse: %v", err) + } + + tt.validate(t, p) + }) + } +} + +func TestChecksum(t *testing.T) { + input := []byte(`{"files":[]}`) + + // verify idempotency by calling Checksum() twice with the same input + output1, output2 := Checksum(input), Checksum(input) + if output1 != output2 { + t.Error("checksum result is not idempotent") + } +} diff --git a/pkg/plan/probe.go b/pkg/plan/probe.go new file mode 100644 index 00000000000..4976ed91a48 --- /dev/null +++ b/pkg/plan/probe.go @@ -0,0 +1,27 @@ +package plan + +// Probe describes a health check to be performed against the node. +type Probe struct { + Name string `json:"name,omitempty"` + InitialDelaySeconds int `json:"initialDelaySeconds,omitempty"` // default 0 + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` // default 1 + SuccessThreshold int `json:"successThreshold,omitempty"` // default 1 + FailureThreshold int `json:"failureThreshold,omitempty"` // default 3 + HTTPGetAction HTTPGetAction `json:"httpGet,omitempty"` +} + +// HTTPGetAction describes an HTTP GET request used by a Probe. +type HTTPGetAction struct { + URL string `json:"url,omitempty"` + Insecure bool `json:"insecure,omitempty"` + ClientCert string `json:"clientCert,omitempty"` + ClientKey string `json:"clientKey,omitempty"` + CACert string `json:"caCert,omitempty"` +} + +// ProbeStatus represents the current health status of a probe as reported by the agent. +type ProbeStatus struct { + Healthy bool `json:"healthy,omitempty"` + SuccessCount int `json:"successCount,omitempty"` + FailureCount int `json:"failureCount,omitempty"` +} diff --git a/pkg/plan/types.go b/pkg/plan/types.go new file mode 100644 index 00000000000..7c6971b0d40 --- /dev/null +++ b/pkg/plan/types.go @@ -0,0 +1,59 @@ +package plan + +// Plan represents the basic unit of work performed by the system-agent. +type Plan struct { + Files []File `json:"files,omitempty"` + OneTimeInstructions []OneTimeInstruction `json:"instructions,omitempty"` + Probes map[string]Probe `json:"probes,omitempty"` + PeriodicInstructions []PeriodicInstruction `json:"periodicInstructions,omitempty"` +} + +// File represents a file to be written on the node by the system-agent. +// Path is the absolute path on the node (e.g. /etc/kubernetes/ssl/ca.pem). +// Content is base64-encoded. If Directory is true, a directory is created, not a file. +type File struct { + Content string `json:"content,omitempty"` + Directory bool `json:"directory,omitempty"` + UID int `json:"uid,omitempty"` + GID int `json:"gid,omitempty"` + Path string `json:"path,omitempty"` + Permissions string `json:"permissions,omitempty"` // internally, the string will be converted to a uint32 to satisfy os.FileMode + Action string `json:"action,omitempty"` +} + +// CommonInstruction holds fields shared by all instruction types. +type CommonInstruction struct { + Name string `json:"name,omitempty"` + Image string `json:"image,omitempty"` + Env []string `json:"env,omitempty"` + Args []string `json:"args,omitempty"` + Command string `json:"command,omitempty"` +} + +// OneTimeInstruction is an instruction that is executed exactly once. +type OneTimeInstruction struct { + CommonInstruction + SaveOutput bool `json:"saveOutput,omitempty"` +} + +// PeriodicInstruction is an instruction that is executed on a recurring schedule. +type PeriodicInstruction struct { + CommonInstruction + PeriodSeconds int `json:"periodSeconds,omitempty"` // default 600, i.e. 10 minutes + SaveStderrOutput bool `json:"saveStderrOutput,omitempty"` +} + +// PeriodicInstructionOutput holds the result of a periodic instruction execution. +// The Stdout and Stderr fields are gzip+base64 encoded byte slices. +type PeriodicInstructionOutput struct { + Name string `json:"name"` + Stdout []byte `json:"stdout"` + Stderr []byte `json:"stderr"` + ExitCode int `json:"exitCode"` + // LastSuccessfulRunTime is a time.UnixDate formatted string of the last successful run. + LastSuccessfulRunTime string `json:"lastSuccessfulRunTime"` + // Failures is the number of consecutive times this instruction has failed. + Failures int `json:"failures"` + // LastFailedRunTime is a time.UnixDate formatted string of when the instruction started failing. + LastFailedRunTime string `json:"lastFailedRunTime"` +}