From f20c1e0636ef4eaeccc41a6716063a5849009514 Mon Sep 17 00:00:00 2001 From: Lukas Zapletal Date: Fri, 18 Jul 2025 10:06:58 +0200 Subject: [PATCH] customization: add firstboot Adds a new customization called firstboot. There are three types of firstboot entries: - custom - satellite registration - aap registration Required field is "type" which decides the type, optionally name can be used when generating filenames and failure of one firstboot script will stop chain of execution, unless a flag is set. The contents for the custom firstboot can be any executable script, such as shell script, Python, or any other script that can be executed by the system. If the firstboot string does not contain shebang, it is executed depending on the target OS configuration, typically as a shell script. Satellite requires the full registration command, as generated by Satellite UI or CLI. AAP requires hook URL and a key in order to generate the registration command. Both entries optionally take one or more CACerts with CA for the registration command. If no CA cert is provided for the AAP registration, then insecure HTTP connection will be used. --- pkg/blueprint/customizations.go | 1 + pkg/blueprint/firstboot_customizations.go | 181 +++++++++++++ .../firstboot_customizations_test.go | 254 ++++++++++++++++++ pkg/blueprint/toml_json_bridge.go | 34 +++ 4 files changed, 470 insertions(+) create mode 100644 pkg/blueprint/firstboot_customizations.go create mode 100644 pkg/blueprint/firstboot_customizations_test.go diff --git a/pkg/blueprint/customizations.go b/pkg/blueprint/customizations.go index 7d56026..ac1e578 100644 --- a/pkg/blueprint/customizations.go +++ b/pkg/blueprint/customizations.go @@ -36,6 +36,7 @@ type Customizations struct { RHSM *RHSMCustomization `json:"rhsm,omitempty" toml:"rhsm,omitempty"` CACerts *CACustomization `json:"cacerts,omitempty" toml:"cacerts,omitempty"` ContainersStorage *ContainerStorageCustomization `json:"containers-storage,omitempty" toml:"containers-storage,omitempty"` + Firstboot *FirstbootCustomization `json:"firstboot,omitempty" toml:"firstboot,omitempty"` } type IgnitionCustomization struct { diff --git a/pkg/blueprint/firstboot_customizations.go b/pkg/blueprint/firstboot_customizations.go new file mode 100644 index 0000000..b99df82 --- /dev/null +++ b/pkg/blueprint/firstboot_customizations.go @@ -0,0 +1,181 @@ +package blueprint + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" +) + +type FirstbootCustomization struct { + Scripts []FirstbootScriptCustomization `json:"scripts,omitempty" toml:"scripts,omitempty"` +} + +type FirstbootScriptCustomization struct { + union json.RawMessage +} + +// FirstbootCommonCustomization contains common fields for all firstboot customizations. +type FirstbootCommonCustomization struct { + // Type of the firstboot customization. Supported values are: + // "custom", "satellite", and "aap". + Type string `json:"type,omitempty" toml:"type,omitempty"` + + // Optional firstboot name. Must be unique within the blueprint and only + // alphanumeric characters with dashes and underscores are allowed. + Name string `json:"name,omitempty" toml:"name,omitempty"` + + // Ignore errors when executing the firstboot script and continue with + // execution of the following firstboot scripts, if any. By default, + // firstboot scripts are executed in order and if one of them fails, the + // execution stops immediately. + IgnoreFailure bool `json:"ignore_failure,omitempty" toml:"ignore_failure,omitempty"` +} + +// CustomFirstbootCustomization contains fields specific to custom firstboot +// customizations. +type CustomFirstbootCustomization struct { + FirstbootCommonCustomization + + // Strings without shebang will be interpreted as shell scripts, otherwise + // the script will be executed using the shebang interpreter. Required if + // type is set to "custom". + Contents string `json:"contents" toml:"contents"` +} + +// SatelliteFirstbootCustomization contains fields specific to satellite firstboot +// customizations. +type SatelliteFirstbootCustomization struct { + FirstbootCommonCustomization + + // Optional CA certificate to enroll into the system before executing the + // firstboot script. + CACerts []string `json:"cacerts,omitempty" toml:"cacerts,omitempty"` + + // Registration command as generated by the Satellite server. Required, if + // type is set to "satellite". + Command string `json:"command,omitempty" toml:"command,omitempty"` +} + +// AAPFirstbootCustomization contains fields specific to AAP firstboot +// customizations. +type AAPFirstbootCustomization struct { + FirstbootCommonCustomization + + // Optional CA certificate to enroll into the system before executing the + // firstboot script. + CACerts []string `json:"cacerts,omitempty" toml:"cacerts,omitempty"` + + // Job template URL as generated by the AAP server. Required if type is set + // to "aap". Example URLs are + // https://aap.example.com/api/controller/v2/job_templates/9/callback/ or + // https://aap.example.com/api/v2/job_templates/9/callback/ depending on the + // AAP version. + JobTemplateURL string `json:"job_template_url,omitempty" toml:"job_template_url,omitempty"` + + // The host config key. Required if type is set to "aap". + HostConfigKey string `json:"host_config_key,omitempty" toml:"host_config_key,omitempty"` +} + +func (t FirstbootScriptCustomization) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("FirstbootScriptCustomization marshalling error: %w", err) + } + return b, nil +} + +func (t *FirstbootScriptCustomization) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return fmt.Errorf("FirstbootScriptCustomization unmarshalling error: %w", err) + } + return nil +} + +func (t FirstbootScriptCustomization) MarshalTOML() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + return jsonToToml(b) +} + +func (t *FirstbootScriptCustomization) UnmarshalTOML(data any) error { + return unmarshalTOMLviaJSON(t, data) +} + +var ErrMissingCustomContents = errors.New("missing contents field for custom firstboot customization") +var ErrUnknownFirstbootCustomization = errors.New("unknown firstboot customization: missing or invalid type field") +var ErrMissingAAPFields = errors.New("missing job_template_url or host_config_key field for aap firstboot customization") +var ErrMissingSatelliteCommand = errors.New("missing command field for satellite firstboot customization") + +func unmarshalFirstbootScript[S any](data json.RawMessage) (*S, error) { + var script S + jd := json.NewDecoder(bytes.NewReader(data)) + jd.DisallowUnknownFields() + err := jd.Decode(&script) + if err != nil { + return nil, err + } + return &script, nil +} + +// SelectUnion returns the specific firstboot customization types. The detection +// is based on the "type" field in the JSON payload. Returns nil for irrelevant types, +// or if the type is unknown or if there is an error during unmarshaling. +func (sp FirstbootScriptCustomization) SelectUnion() (*CustomFirstbootCustomization, *SatelliteFirstbootCustomization, *AAPFirstbootCustomization, error) { + var fcc FirstbootCommonCustomization + err := json.Unmarshal(sp.union, &fcc) + if err != nil { + return nil, nil, nil, err + } + + switch strings.ToLower(fcc.Type) { + case "custom": + cc, err := unmarshalFirstbootScript[CustomFirstbootCustomization](sp.union) + if err != nil { + return nil, nil, nil, err + } + if cc.Contents == "" { + return nil, nil, nil, ErrMissingCustomContents + } + return cc, nil, nil, nil + case "satellite": + sc, err := unmarshalFirstbootScript[SatelliteFirstbootCustomization](sp.union) + if err != nil { + return nil, nil, nil, err + } + if sc.Command == "" { + return nil, nil, nil, ErrMissingSatelliteCommand + } + return nil, sc, nil, err + case "aap": + ac, err := unmarshalFirstbootScript[AAPFirstbootCustomization](sp.union) + if err != nil { + return nil, nil, nil, err + } + if ac.JobTemplateURL == "" || ac.HostConfigKey == "" { + return nil, nil, nil, ErrMissingAAPFields + } + return nil, nil, ac, err + default: + return nil, nil, nil, ErrUnknownFirstbootCustomization + } +} + +func FirstbootScriptCustomizationFromCustom(node CustomFirstbootCustomization) FirstbootScriptCustomization { + u, _ := json.Marshal(node) + return FirstbootScriptCustomization{union: u} +} + +func FirstbootScriptCustomizationFromSatellite(node SatelliteFirstbootCustomization) FirstbootScriptCustomization { + u, _ := json.Marshal(node) + return FirstbootScriptCustomization{union: u} +} + +func FirstbootScriptCustomizationFromAAP(node AAPFirstbootCustomization) FirstbootScriptCustomization { + u, _ := json.Marshal(node) + return FirstbootScriptCustomization{union: u} +} diff --git a/pkg/blueprint/firstboot_customizations_test.go b/pkg/blueprint/firstboot_customizations_test.go new file mode 100644 index 0000000..82bf536 --- /dev/null +++ b/pkg/blueprint/firstboot_customizations_test.go @@ -0,0 +1,254 @@ +package blueprint + +import ( + "encoding/json" + "testing" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/assert" +) + +func TestJSON(t *testing.T) { + tests := []struct { + name string + json string + field FirstbootScriptCustomization + }{ + { + name: "custom", + json: `{"type":"custom","name":"test","contents":"echo hello"}`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"custom","name":"test","contents":"echo hello"}`), + }, + }, + { + name: "satellite", + json: `{"type":"satellite","name":"test","command":"echo hello"}`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"satellite","name":"test","command":"echo hello"}`), + }, + }, + { + name: "aap", + json: `{"type":"aap","name":"test","job_template_url":"https://aap.example.com/api/v2/job_templates/9/callback/"}`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"aap","name":"test","job_template_url":"https://aap.example.com/api/v2/job_templates/9/callback/"}`), + }, + }, + { + name: "unknown", + json: `{"type":"unknown","name":"test"}`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"unknown","name":"test"}`), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var actual FirstbootScriptCustomization + + err := json.Unmarshal([]byte(tt.json), &actual) + assert.NoError(t, err) + assert.Equal(t, tt.field, actual) + + b, err := json.Marshal(tt.field) + assert.NoError(t, err) + assert.Equal(t, tt.json, string(b)) + }) + } +} + +func TestTOML(t *testing.T) { + tests := []struct { + name string + toml string + field FirstbootScriptCustomization + }{ + { + name: "custom", + toml: `type = "custom" +name = "test" +contents = "echo hello"`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"custom","name":"test","contents":"echo hello"}`), + }, + }, + { + name: "satellite", + toml: `type = "satellite" +name = "test" +command = "echo hello"`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"satellite","name":"test","command":"echo hello"}`), + }, + }, + { + name: "aap", + toml: `type = "aap" +name = "test" +job_template_url = "https://aap.example.com/api/v2/job_templates/9/callback/"`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"aap","name":"test","job_template_url":"https://aap.example.com/api/v2/job_templates/9/callback/"}`), + }, + }, + { + name: "unknown", + toml: `type = "unknown" +name = "test"`, + field: FirstbootScriptCustomization{ + union: json.RawMessage(`{"type":"unknown","name":"test"}`), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var actual FirstbootScriptCustomization + + err := toml.Unmarshal([]byte(tt.toml), &actual) + assert.NoError(t, err) + assert.JSONEq(t, string(tt.field.union), string(actual.union)) + + b, err := toml.Marshal(tt.field) + assert.NoError(t, err) + + ok, err := tomlEq([]byte(tt.toml), b) + if err != nil { + assert.Fail(t, "TOML equality check failed", "expected: %s, actual: %s, error: %v", tt.toml, string(b), err) + } + + if !ok { + assert.Fail(t, "TOML mismatch", "expected: %s, actual: %s", tt.toml, string(b)) + } + }) + } +} + +func TestSelectUnion(t *testing.T) { + tests := []struct { + name string + json string + toml string + expectedCustom *CustomFirstbootCustomization + expectedSatellite *SatelliteFirstbootCustomization + expectedAAP *AAPFirstbootCustomization + err string // errors are common for json and toml + }{ + { + name: "err-bad-type", + json: `{"type":"xxx"}`, + toml: `type = "xxx"`, + err: "unknown firstboot customization: missing or invalid type field", + }, + { + name: "err-missing-type", + json: `{}`, + toml: ``, + err: "unknown firstboot customization: missing or invalid type field", + }, + { + name: "err-custom-with-aap", + json: `{"type":"custom","job_template_url":"https://aap.example.com/api/v2/job_templates/9/callback/"}`, + toml: `type = "custom" +job_template_url = "https://aap.example.com/api/v2/job_templates/9/callback/"`, + err: "json: unknown field \"job_template_url\"", + }, + { + name: "custom", + json: `{"type":"custom","name":"test","contents":"echo hello"}`, + toml: `type = "custom" +name = "test" +contents = "echo hello"`, + expectedCustom: &CustomFirstbootCustomization{ + FirstbootCommonCustomization: FirstbootCommonCustomization{ + Type: "custom", + Name: "test", + }, + Contents: "echo hello", + }, + }, + { + name: "satellite", + json: `{"type":"satellite","name":"test","command":"echo hello"}`, + toml: `type = "satellite" +name = "test" +command = "echo hello"`, + expectedSatellite: &SatelliteFirstbootCustomization{ + FirstbootCommonCustomization: FirstbootCommonCustomization{ + Type: "satellite", + Name: "test", + }, + Command: "echo hello", + }, + }, + { + name: "missing-satellite-command", + json: `{"type":"satellite"}`, + toml: `type = "satellite"`, + err: "missing command field for satellite firstboot customization", + }, + { + name: "aap", + json: `{"type":"aap","host_config_key":"test","job_template_url":"https://aap.example.com/api/v2/job_templates/9/callback/"}`, + toml: `type = "aap" +host_config_key = "test" +job_template_url = "https://aap.example.com/api/v2/job_templates/9/callback/"`, + expectedAAP: &AAPFirstbootCustomization{ + FirstbootCommonCustomization: FirstbootCommonCustomization{ + Type: "aap", + }, + JobTemplateURL: "https://aap.example.com/api/v2/job_templates/9/callback/", + HostConfigKey: "test", + }, + }, + { + name: "missing-aap-host-config-key", + json: `{"type":"aap"}`, + toml: `type = "aap"`, + err: "missing job_template_url or host_config_key field for aap firstboot customization", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var actual FirstbootScriptCustomization + + err := json.Unmarshal([]byte(tt.json), &actual) + if err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + cust, sat, aap, err := actual.SelectUnion() + if tt.err != "" { + if assert.Error(t, err) { + assert.Equal(t, tt.err, err.Error()) + } + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.expectedCustom, cust) + assert.Equal(t, tt.expectedSatellite, sat) + assert.Equal(t, tt.expectedAAP, aap) + + err = toml.Unmarshal([]byte(tt.toml), &actual) + if err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + cust, sat, aap, err = actual.SelectUnion() + if tt.err != "" { + if assert.Error(t, err) { + assert.Equal(t, tt.err, err.Error()) + } + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.expectedCustom, cust) + assert.Equal(t, tt.expectedSatellite, sat) + assert.Equal(t, tt.expectedAAP, aap) + }) + } +} diff --git a/pkg/blueprint/toml_json_bridge.go b/pkg/blueprint/toml_json_bridge.go index 6e75cfe..8b061d2 100644 --- a/pkg/blueprint/toml_json_bridge.go +++ b/pkg/blueprint/toml_json_bridge.go @@ -1,8 +1,12 @@ package blueprint import ( + "bytes" "encoding/json" "fmt" + "reflect" + + "github.com/BurntSushi/toml" ) // XXX: move to interal/common ? @@ -22,3 +26,33 @@ func unmarshalTOMLviaJSON(u json.Unmarshaler, data any) error { } return nil } + +// jsonToToml converts a JSON byte slice to a TOML byte slice. +func jsonToToml(data []byte) ([]byte, error) { + var result any + + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("error unmarshaling JSON: %w", err) + } + + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(result); err != nil { + return nil, fmt.Errorf("error marshaling to TOML: %w", err) + } + + return buf.Bytes(), nil +} + +// tomlEq compares two TOML byte slices for equality +func tomlEq(expected []byte, actual []byte) (bool, error) { + var expectedMap, actualMap map[string]any + + if err := toml.Unmarshal(expected, &expectedMap); err != nil { + return false, fmt.Errorf("error unmarshaling expected TOML: %w", err) + } + if err := toml.Unmarshal(actual, &actualMap); err != nil { + return false, fmt.Errorf("error unmarshaling actual TOML: %w", err) + } + + return reflect.DeepEqual(expectedMap, actualMap), nil +}