Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/plan/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/rancher/rancher/pkg/plan

go 1.25.0

toolchain go1.25.8
Empty file added pkg/plan/go.sum
Empty file.
27 changes: 27 additions & 0 deletions pkg/plan/plan.go
Original file line number Diff line number Diff line change
@@ -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))
}
164 changes: 164 additions & 0 deletions pkg/plan/plan_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
27 changes: 27 additions & 0 deletions pkg/plan/probe.go
Original file line number Diff line number Diff line change
@@ -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"`
}
59 changes: 59 additions & 0 deletions pkg/plan/types.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading