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 +}