diff --git a/pkg/blueprint/customizations.go b/pkg/blueprint/customizations.go index 7a576c4e1a..8a751c5326 100644 --- a/pkg/blueprint/customizations.go +++ b/pkg/blueprint/customizations.go @@ -34,6 +34,7 @@ type Customizations struct { RPM *RPMCustomization `json:"rpm,omitempty" toml:"rpm,omitempty"` RHSM *RHSMCustomization `json:"rhsm,omitempty" toml:"rhsm,omitempty"` CACerts *CACustomization `json:"cacerts,omitempty" toml:"cacerts,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 0000000000..03d7e6a6c0 --- /dev/null +++ b/pkg/blueprint/firstboot_customizations.go @@ -0,0 +1,50 @@ +package blueprint + +type FirstbootCustomization 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"` + + // Optional CA certificate to enroll into the system before executing the + // firstboot script. + CACerts []string `json:"cacerts,omitempty" toml:"cacerts,omitempty"` + + CustomFirstbootCustomization + SatelliteFirstbootCustomization + AAPFirstbootCustomization +} + +type CustomFirstbootCustomization struct { + // 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,omitempty" toml:"contents,omitempty"` +} + +type SatelliteFirstbootCustomization struct { + // Registration command as generated by the Satellite server. Required, if + // type is set to "satellite". + Command string `json:"command,omitempty" toml:"command,omitempty"` +} + +type AAPFirstbootCustomization struct { + // 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"` +} diff --git a/pkg/customizations/firstboot/firstboot.go b/pkg/customizations/firstboot/firstboot.go new file mode 100644 index 0000000000..0bcc9c08cc --- /dev/null +++ b/pkg/customizations/firstboot/firstboot.go @@ -0,0 +1,91 @@ +package firstboot + +import ( + "errors" + "net/url" + + "github.com/osbuild/images/pkg/blueprint" +) + +type FirstbootOptions struct { + Custom []CustomFirstbootOptions + Satellite *SatelliteFirstbootOptions + AAP *AAPFirstbootOptions +} + +type CustomFirstbootOptions struct { + Contents string + Name string + IgnoreFailure bool +} + +type SatelliteFirstbootOptions struct { + Command string + CACerts []string +} + +type AAPFirstbootOptions struct { + JobTemplateURL string + HostConfigKey string + CACerts []string +} + +func FirstbootOptionsFromBP(bpFirstboot blueprint.FirstbootCustomization) (*FirstbootOptions, error) { + var custom []CustomFirstbootOptions + for _, c := range bpFirstboot.Custom { + if c.Contents == "" { + return nil, errors.New("custom firstboot script contents cannot be empty") + } + + custom = append(custom, CustomFirstbootOptions{ + Contents: c.Contents, + Name: c.Name, + IgnoreFailure: c.IgnoreFailure, + }) + } + + var satellite *SatelliteFirstbootOptions + if bpFirstboot.Satellite != nil { + if bpFirstboot.Satellite.Command == "" { + return nil, errors.New("satellite firstboot command cannot be empty") + } + + var certs []string + for _, cert := range bpFirstboot.Satellite.CACerts { + certs = append(certs, cert) + } + + satellite = &SatelliteFirstbootOptions{ + Command: bpFirstboot.Satellite.Command, + CACerts: certs, + } + } + + var aap *AAPFirstbootOptions + if bpFirstboot.AAP != nil { + if bpFirstboot.AAP.JobTemplateURL == "" { + return nil, errors.New("AAP firstboot job template URL cannot be empty") + } + + if _, err := url.ParseRequestURI(bpFirstboot.AAP.JobTemplateURL); err != nil { + return nil, errors.New("AAP firstboot job template URL is not a valid URI") + } + + var certs []string + for _, cert := range bpFirstboot.AAP.CACerts { + certs = append(certs, cert) + } + + aap = &AAPFirstbootOptions{ + JobTemplateURL: bpFirstboot.AAP.JobTemplateURL, + HostConfigKey: bpFirstboot.AAP.HostConfigKey, + CACerts: certs, + } + } + + return &FirstbootOptions{ + Custom: custom, + Satellite: satellite, + AAP: aap, + }, nil +} diff --git a/pkg/customizations/firstboot/firstboot_test.go b/pkg/customizations/firstboot/firstboot_test.go new file mode 100644 index 0000000000..fc3c4cf1c9 --- /dev/null +++ b/pkg/customizations/firstboot/firstboot_test.go @@ -0,0 +1,99 @@ +package firstboot_test + +import ( + "testing" + + "github.com/osbuild/images/pkg/blueprint" + "github.com/osbuild/images/pkg/customizations/firstboot" + "github.com/stretchr/testify/assert" +) + +func TestFirstbootOptionsFromValidBP(t *testing.T) { + validBP := blueprint.FirstbootCustomization{ + Custom: []blueprint.CustomFirstbootCustomization{ + {Contents: "echo hello", Name: "greet"}, + }, + Satellite: &blueprint.SatelliteFirstbootCustomization{ + Command: "satellite-command", + CACerts: []string{"cert1", "cert2"}, + }, + AAP: &blueprint.AAPFirstbootCustomization{ + JobTemplateURL: "https://example.com/job-template", + HostConfigKey: "host-config-key", + CACerts: []string{"cert3"}, + }, + } + + options, err := firstboot.FirstbootOptionsFromBP(validBP) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assert.Len(t, options.Custom, 1) + assert.Equal(t, "echo hello", options.Custom[0].Contents) + + assert.NotNil(t, options.Satellite) + assert.Equal(t, "satellite-command", options.Satellite.Command) + assert.ElementsMatch(t, []string{"cert1", "cert2"}, options.Satellite.CACerts) + + assert.NotNil(t, options.AAP) + assert.Equal(t, "https://example.com/job-template", options.AAP.JobTemplateURL) + assert.Equal(t, "host-config-key", options.AAP.HostConfigKey) + assert.ElementsMatch(t, []string{"cert3"}, options.AAP.CACerts) +} + +func TestFirstbootOptionsFromEmptyBP(t *testing.T) { + emptyBP := blueprint.FirstbootCustomization{} + options, err := firstboot.FirstbootOptionsFromBP(emptyBP) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assert.Empty(t, options.Custom) + assert.Nil(t, options.Satellite) + assert.Nil(t, options.AAP) +} + +func TestFirstbootOptionsFromBPWithEmptyCustomScript(t *testing.T) { + bpWithEmptyCustom := blueprint.FirstbootCustomization{ + Custom: []blueprint.CustomFirstbootCustomization{ + {Contents: "", Name: "empty-script"}, + }, + } + + _, err := firstboot.FirstbootOptionsFromBP(bpWithEmptyCustom) + assert.Error(t, err, "expected error for empty custom script contents") +} + +func TestFirstbootOptionsFromBPWithEmptySatelliteCommand(t *testing.T) { + bpWithEmptySatellite := blueprint.FirstbootCustomization{ + Satellite: &blueprint.SatelliteFirstbootCustomization{ + Command: "", + }, + } + + _, err := firstboot.FirstbootOptionsFromBP(bpWithEmptySatellite) + assert.Error(t, err, "expected error for empty satellite command") +} + +func TestFirstbootOptionsFromBPWithEmptyAAPJobTemplateURL(t *testing.T) { + bpWithEmptyAAP := blueprint.FirstbootCustomization{ + AAP: &blueprint.AAPFirstbootCustomization{ + JobTemplateURL: "", + }, + } + + _, err := firstboot.FirstbootOptionsFromBP(bpWithEmptyAAP) + assert.Error(t, err, "expected error for empty AAP job template URL") +} + +func TestFirstbootOptionsFromBPWithInvalidAAPJobTemplateURL(t *testing.T) { + bpWithInvalidAAP := blueprint.FirstbootCustomization{ + AAP: &blueprint.AAPFirstbootCustomization{ + JobTemplateURL: "not-a-valid-url", + }, + } + + _, err := firstboot.FirstbootOptionsFromBP(bpWithInvalidAAP) + assert.Error(t, err, "expected error for invalid AAP job template URL") +} diff --git a/pkg/distro/generic/images.go b/pkg/distro/generic/images.go index 4ff575a458..db5de96be0 100644 --- a/pkg/distro/generic/images.go +++ b/pkg/distro/generic/images.go @@ -11,6 +11,7 @@ import ( "github.com/osbuild/images/pkg/customizations/anaconda" "github.com/osbuild/images/pkg/customizations/bootc" "github.com/osbuild/images/pkg/customizations/fdo" + "github.com/osbuild/images/pkg/customizations/firstboot" "github.com/osbuild/images/pkg/customizations/fsnode" "github.com/osbuild/images/pkg/customizations/ignition" "github.com/osbuild/images/pkg/customizations/kickstart" @@ -302,6 +303,13 @@ func osCustomizations(t *imageType, osPackageSet rpmmd.PackageSet, options distr osc.CACerts = ca.PEMCerts } + if c != nil && c.Firstboot != nil { + osc.Firstboot, err = firstboot.FirstbootOptionsFromBP(*c.Firstboot) + if err != nil { + return manifest.OSCustomizations{}, fmt.Errorf("firstboot customization: %w", err) + } + } + if imageConfig.InstallWeakDeps != nil { osc.InstallWeakDeps = *imageConfig.InstallWeakDeps } diff --git a/pkg/manifest/firstboot.go b/pkg/manifest/firstboot.go new file mode 100644 index 0000000000..fc9b4da5fd --- /dev/null +++ b/pkg/manifest/firstboot.go @@ -0,0 +1,160 @@ +package manifest + +import ( + "fmt" + "html/template" + "io/fs" + "strings" + + "github.com/osbuild/images/internal/common" + "github.com/osbuild/images/pkg/customizations/firstboot" + "github.com/osbuild/images/pkg/customizations/fsnode" +) + +// checkName prevents path traversal +func checkName(str string) error { + if str == "" { + return fmt.Errorf("name cannot be empty") + } + + for _, r := range str { + if !(('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') || r == '-' || r == '_') { + return fmt.Errorf("name can only contain alphanumeric characters, dashes, and underscores") + } + } + + return nil +} + +var tmplFirstbootUnit = `[Unit] +ConditionPathExists=!/var/local/.osbuild-custom-first-boot-done +Wants=network-online.target +After=network-online.target +After=osbuild-first-boot.service + +[Service] +Type=oneshot +{{ range .Executables }} +ExecStart={{ . -}} +{{ end }} +ExecStartPost=/usr/bin/touch /var/local/.osbuild-custom-first-boot-done +RemainAfterExit=yes + +[Install] +WantedBy=basic.target +` + +var tmplFirstbootAAP = `#!/usr/bin/bash +curl -s -i --data "host_config_key={{ .HostConfigKey }}" {{ .URL -}} +` + +func renderFirstboot(tmplStr string, data any) (string, error) { + tmpl, err := template.New("firstboot-unit").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("error parsing firstboot unit template: %w", err) + } + + var result strings.Builder + err = tmpl.Execute(&result, data) + if err != nil { + return "", fmt.Errorf("error rendering firstboot unit: %w", err) + } + + return result.String(), nil +} + +func firstbootFileNodes(fbo *firstboot.FirstbootOptions) ([]string, []*fsnode.File, error) { + if fbo == nil { + return nil, nil, nil + } + + var certs []string + var files []*fsnode.File + var executables []string + + if fbo.Satellite != nil { + // add CA certificates to the list + for _, cert := range fbo.Satellite.CACerts { + certs = append(certs, cert) + } + + // create the Satellite firstboot script + f, err := fsnode.NewFile("/usr/local/bin/osbuild-first-satellite", common.ToPtr(fs.FileMode(0770)), "root", "root", []byte(fbo.Satellite.Command)) + if err != nil { + return nil, nil, fmt.Errorf("error creating firstboot file node: %w", err) + } + + files = append(files, f) + executables = append(executables, "-"+f.Path()) + } + + if fbo.AAP != nil { + // add CA certificates to the list + for _, cert := range fbo.AAP.CACerts { + certs = append(certs, cert) + } + + // create the AAP firstboot script + data := struct { + URL string + HostConfigKey string + }{ + URL: fbo.AAP.JobTemplateURL, + HostConfigKey: fbo.AAP.HostConfigKey, + } + aapContent, err := renderFirstboot(tmplFirstbootAAP, data) + if err != nil { + return nil, nil, fmt.Errorf("error rendering firstboot aap template: %w", err) + } + + f, err := fsnode.NewFile("/usr/local/bin/osbuild-first-aap", common.ToPtr(fs.FileMode(0770)), "root", "root", []byte(aapContent)) + if err != nil { + return nil, nil, fmt.Errorf("error creating firstboot file node: %w", err) + } + + files = append(files, f) + executables = append(executables, "-"+f.Path()) + } + + for i, custom := range fbo.Custom { + // keep the naming convention consistent with the existing "osbuild-first-boot" + name := fmt.Sprintf("osbuild-first-%s", custom.Name) + if checkName(custom.Name) != nil { + name = fmt.Sprintf("osbuild-first-custom-%d", i+1) + } + + // create the executable + exec := fmt.Sprintf("/usr/local/bin/%s", name) + + f, err := fsnode.NewFile(exec, common.ToPtr(fs.FileMode(0770)), "root", "root", []byte(custom.Contents)) + if err != nil { + return nil, nil, fmt.Errorf("error creating firstboot file node: %w", err) + } + files = append(files, f) + + // prepare data for the systemd unit + if custom.IgnoreFailure { + exec = "-" + exec + } + executables = append(executables, exec) + + } + + // create the systemd unit + data := struct { + Executables []string + }{ + Executables: executables, + } + unitContent, err := renderFirstboot(tmplFirstbootUnit, data) + if err != nil { + return nil, nil, fmt.Errorf("error rendering firstboot unit: %w", err) + } + unitFile, err := fsnode.NewFile("/etc/systemd/system/osbuild-first-boot.service", common.ToPtr(fs.FileMode(0644)), "root", "root", []byte(unitContent)) + if err != nil { + return nil, nil, fmt.Errorf("error creating firstboot systemd unit file node: %w", err) + } + files = append(files, unitFile) + + return certs, files, nil +} diff --git a/pkg/manifest/firstboot_test.go b/pkg/manifest/firstboot_test.go new file mode 100644 index 0000000000..97e19caed3 --- /dev/null +++ b/pkg/manifest/firstboot_test.go @@ -0,0 +1,103 @@ +package manifest + +import ( + "strings" + "testing" + + "github.com/osbuild/images/pkg/customizations/firstboot" + "github.com/osbuild/images/pkg/customizations/fsnode" + "github.com/stretchr/testify/assert" +) + +func concatFiles(files []*fsnode.File) string { + var result strings.Builder + result.WriteString("\n") + + for i, f := range files { + result.WriteString("### " + f.Path() + " ###\n") + result.Write(f.Data()) + if i < len(files)-1 { + result.WriteString("\n\n") + } + } + + return result.String() +} + +func TestFirstbootFileNodes(t *testing.T) { + fbo := &firstboot.FirstbootOptions{ + Satellite: &firstboot.SatelliteFirstbootOptions{ + Command: "curl https://sat.example.com/register", + CACerts: []string{"cert1"}, + }, + AAP: &firstboot.AAPFirstbootOptions{ + JobTemplateURL: "https://aap.example.com/api/v2/job_templates/9/callback/", + HostConfigKey: "host-config-key", + CACerts: []string{"cert2"}, + }, + Custom: []firstboot.CustomFirstbootOptions{ + { + Contents: "#!/usr/bin/bash\necho 'Unnamed'", + }, + { + Contents: "#!/usr/bin/bash\necho 'Do not ignore errors'", + Name: "no-ignore-errors", + IgnoreFailure: false, + }, + { + Contents: "#!/usr/bin/bash\necho 'Ignore errors'", + Name: "ignore-errors", + IgnoreFailure: true, + }, + }, + } + + want := ` +### /usr/local/sbin/osbuild-first-satellite ### +curl https://sat.example.com/register + +### /usr/local/sbin/osbuild-first-aap ### +#!/usr/bin/bash +curl -s -i --data "host_config_key=host-config-key" https://aap.example.com/api/v2/job_templates/9/callback/ + +### /usr/local/sbin/osbuild-first-custom-1 ### +#!/usr/bin/bash +echo 'Unnamed' + +### /usr/local/sbin/osbuild-first-no-ignore-errors ### +#!/usr/bin/bash +echo 'Do not ignore errors' + +### /usr/local/sbin/osbuild-first-ignore-errors ### +#!/usr/bin/bash +echo 'Ignore errors' + +### /etc/systemd/system/osbuild-first-boot.service ### +[Unit] +ConditionPathExists=!/var/local/.osbuild-custom-first-boot-done +Wants=network-online.target +After=network-online.target +After=osbuild-first-boot.service + +[Service] +Type=oneshot + +ExecStart=-/usr/local/sbin/osbuild-first-satellite +ExecStart=-/usr/local/sbin/osbuild-first-aap +ExecStart=/usr/local/sbin/osbuild-first-custom-1 +ExecStart=/usr/local/sbin/osbuild-first-no-ignore-errors +ExecStart=-/usr/local/sbin/osbuild-first-ignore-errors +ExecStartPost=/usr/bin/touch /var/local/.osbuild-custom-first-boot-done +RemainAfterExit=yes + +[Install] +WantedBy=basic.target +` + certs, files, err := firstbootFileNodes(fbo) + assert.NoError(t, err) + + assert.Equal(t, []string{"cert1", "cert2"}, certs) + + got := concatFiles(files) + assert.Equal(t, want, got) +} diff --git a/pkg/manifest/os.go b/pkg/manifest/os.go index dacfdbea12..e0f5c2808f 100644 --- a/pkg/manifest/os.go +++ b/pkg/manifest/os.go @@ -14,6 +14,7 @@ import ( "github.com/osbuild/images/pkg/arch" "github.com/osbuild/images/pkg/container" "github.com/osbuild/images/pkg/customizations/bootc" + "github.com/osbuild/images/pkg/customizations/firstboot" "github.com/osbuild/images/pkg/customizations/fsnode" "github.com/osbuild/images/pkg/customizations/oscap" "github.com/osbuild/images/pkg/customizations/shell" @@ -157,6 +158,8 @@ type OSCustomizations struct { CACerts []string + Firstboot *firstboot.FirstbootOptions + FIPS bool // NoBLS configures the image bootloader with traditional menu entries @@ -915,6 +918,18 @@ func (p *OS) serialize() osbuild.Pipeline { })) } + fbCerts, fbFiles, err := firstbootFileNodes(p.OSCustomizations.Firstboot) + if err != nil { + panic(err.Error()) + } + if len(fbFiles) > 0 { + p.addStagesForAllFilesAndInlineData(&pipeline, fbFiles) + } + + if len(fbCerts) > 0 { + p.OSCustomizations.CACerts = append(p.OSCustomizations.CACerts, fbCerts...) + } + if len(p.OSCustomizations.CACerts) > 0 { for _, cc := range p.OSCustomizations.CACerts { files, err := osbuild.NewCAFileNodes(cc) diff --git a/test/configs/all-customizations.json b/test/configs/all-customizations.json index bfce468ebc..42ea7f7f86 100644 --- a/test/configs/all-customizations.json +++ b/test/configs/all-customizations.json @@ -183,6 +183,26 @@ "disabled": ["telnet"] }, "ports": ["1337:udp", "42-48:tcp"] + }, + "firstboot": { + "satellite": { + "command": "curl https://satellite.example.com/register", + "cacerts": ["-----BEGIN CERTIFICATE-----\nMIIDszCCApugAwIBAgIUJ4lK+JfdJCNgcEVxZDinJfKKbQswDQYJKoZIhvcNAQEL\nBQAwaDELMAkGA1UEBhMCVVMxFzAVBgNVBAgMDk5vcnRoIENhcm9saW5hMRAwDgYD\nVQQHDAdSYWxlaWdoMRAwDgYDVQQKDAdSZWQgSGF0MRwwGgYDVQQDDBNUZXN0IENB\nIGZvciBvc2J1aWxkMCAXDTI0MDkwMzEzMjkyMFoYDzIyOTgwNjE4MTMyOTIwWjBo\nMQswCQYDVQQGEwJVUzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExEDAOBgNVBAcM\nB1JhbGVpZ2gxEDAOBgNVBAoMB1JlZCBIYXQxHDAaBgNVBAMME1Rlc3QgQ0EgZm9y\nIG9zYnVpbGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeA7OcWTrV\ngstoBsUaeJKm8nelg7Lc0WNXH6yOTLsr4td4yHs0YOvFGwgSf+ffV3RAG1mgqnMG\nMgkD2+z+7QhHbHHs3y0d0zfhA2bg0KVvfCWk7fNRPHY0UOePpXk245Bfw3D0VTpl\nF7nePk1I7ZY09snPWUeb2rjKXzYjKjzM0h27+ykV8I8+FbdyPk/pR8whyDqtHLUa\nXfFy2TFloDSYMkHKVd38BnL0bj91x5F+KsZkN4HzfbYwxLbCQfOSgy7q6TWce9kq\nLo6tya9vuvpWFm1dye7L+BodAQAq/dI/JMeCfyTb0eFb+tyzfr5aVIoqqDN+p9ft\ncw4OefpHbhtNAgMBAAGjUzBRMB0GA1UdDgQWBBRV2A9YmusekPzu5Yf08cV0oPL1\nwjAfBgNVHSMEGDAWgBRV2A9YmusekPzu5Yf08cV0oPL1wjAPBgNVHRMBAf8EBTAD\nAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCgQZ2Xfj+NxaKBZgn2KNxS0MTbhzHRz6Rn\nqJs+h8OUz2Crmaf6N+RHlmDRZXUrDjSHpxVT2LxFy7ofRrLYIezFDUYfb920VkkV\nSVcxh1YDFROJalfMoE6wdyR/LnK4MJZS9fUpeCJJc/A0J+9FK9CwcyUrHgJ8XbJh\nMKYyQ+cf6O7wzutuBpMyRqSKS+hVM7BQTmSFvv1eAJlo6klGAmmKiYmAEvcQadH1\ndjrujsA3Cn5vX2L+0yuiLB5/zoxqx5cEy97TuKUYB8OqMMujAXNzF4L3HJDUNba2\nAhEkFozMXwYX73TGbGZ0mawPS5D3v3tYTEmJFf6SnVCmUW1fs57g\n-----END CERTIFICATE-----\n"] + }, + "aap": { + "job_template_url": "https://aap.example.com/api/v2/job_templates/12345/callback/", + "host_config_key": "1234567890abcdef", + "cacerts": ["-----BEGIN CERTIFICATE-----\nMIIDszCCApugAwIBAgIUJ4lK+JfdJCNgcEVxZDinJfKKbQswDQYJKoZIhvcNAQEL\nBQAwaDELMAkGA1UEBhMCVVMxFzAVBgNVBAgMDk5vcnRoIENhcm9saW5hMRAwDgYD\nVQQHDAdSYWxlaWdoMRAwDgYDVQQKDAdSZWQgSGF0MRwwGgYDVQQDDBNUZXN0IENB\nIGZvciBvc2J1aWxkMCAXDTI0MDkwMzEzMjkyMFoYDzIyOTgwNjE4MTMyOTIwWjBo\nMQswCQYDVQQGEwJVUzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExEDAOBgNVBAcM\nB1JhbGVpZ2gxEDAOBgNVBAoMB1JlZCBIYXQxHDAaBgNVBAMME1Rlc3QgQ0EgZm9y\nIG9zYnVpbGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeA7OcWTrV\ngstoBsUaeJKm8nelg7Lc0WNXH6yOTLsr4td4yHs0YOvFGwgSf+ffV3RAG1mgqnMG\nMgkD2+z+7QhHbHHs3y0d0zfhA2bg0KVvfCWk7fNRPHY0UOePpXk245Bfw3D0VTpl\nF7nePk1I7ZY09snPWUeb2rjKXzYjKjzM0h27+ykV8I8+FbdyPk/pR8whyDqtHLUa\nXfFy2TFloDSYMkHKVd38BnL0bj91x5F+KsZkN4HzfbYwxLbCQfOSgy7q6TWce9kq\nLo6tya9vuvpWFm1dye7L+BodAQAq/dI/JMeCfyTb0eFb+tyzfr5aVIoqqDN+p9ft\ncw4OefpHbhtNAgMBAAGjUzBRMB0GA1UdDgQWBBRV2A9YmusekPzu5Yf08cV0oPL1\nwjAfBgNVHSMEGDAWgBRV2A9YmusekPzu5Yf08cV0oPL1wjAPBgNVHRMBAf8EBTAD\nAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCgQZ2Xfj+NxaKBZgn2KNxS0MTbhzHRz6Rn\nqJs+h8OUz2Crmaf6N+RHlmDRZXUrDjSHpxVT2LxFy7ofRrLYIezFDUYfb920VkkV\nSVcxh1YDFROJalfMoE6wdyR/LnK4MJZS9fUpeCJJc/A0J+9FK9CwcyUrHgJ8XbJh\nMKYyQ+cf6O7wzutuBpMyRqSKS+hVM7BQTmSFvv1eAJlo6klGAmmKiYmAEvcQadH1\ndjrujsA3Cn5vX2L+0yuiLB5/zoxqx5cEy97TuKUYB8OqMMujAXNzF4L3HJDUNba2\nAhEkFozMXwYX73TGbGZ0mawPS5D3v3tYTEmJFf6SnVCmUW1fs57g\n-----END CERTIFICATE-----\n"] + }, + "custom": [ + { + "name": "optional-name", + "contents": "#!/bin/bash\necho \"Hello from firstboot script\"\n" + }, + { + "contents": "echo 'Hello from firstboot command'" + } + ] } } } diff --git a/test/configs/firstboot-scripts.json b/test/configs/firstboot-scripts.json new file mode 100644 index 0000000000..c2d19e1ee1 --- /dev/null +++ b/test/configs/firstboot-scripts.json @@ -0,0 +1,40 @@ +{ + "name": "firstboot-scripts", + "blueprint": { + "customizations": { + "hostname": "firstboot", + "user": [ + { + "name": "test", + "description": "Password is test", + "password": "test" + } + ], + "firstboot": { + "satellite": { + "command": "curl -s https://osbuild.org -o /script_satellite_done", + "cacerts": ["-----BEGIN CERTIFICATE-----\nMIIDszCCApugAwIBAgIUJ4lK+JfdJCNgcEVxZDinJfKKbQswDQYJKoZIhvcNAQEL\nBQAwaDELMAkGA1UEBhMCVVMxFzAVBgNVBAgMDk5vcnRoIENhcm9saW5hMRAwDgYD\nVQQHDAdSYWxlaWdoMRAwDgYDVQQKDAdSZWQgSGF0MRwwGgYDVQQDDBNUZXN0IENB\nIGZvciBvc2J1aWxkMCAXDTI0MDkwMzEzMjkyMFoYDzIyOTgwNjE4MTMyOTIwWjBo\nMQswCQYDVQQGEwJVUzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExEDAOBgNVBAcM\nB1JhbGVpZ2gxEDAOBgNVBAoMB1JlZCBIYXQxHDAaBgNVBAMME1Rlc3QgQ0EgZm9y\nIG9zYnVpbGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeA7OcWTrV\ngstoBsUaeJKm8nelg7Lc0WNXH6yOTLsr4td4yHs0YOvFGwgSf+ffV3RAG1mgqnMG\nMgkD2+z+7QhHbHHs3y0d0zfhA2bg0KVvfCWk7fNRPHY0UOePpXk245Bfw3D0VTpl\nF7nePk1I7ZY09snPWUeb2rjKXzYjKjzM0h27+ykV8I8+FbdyPk/pR8whyDqtHLUa\nXfFy2TFloDSYMkHKVd38BnL0bj91x5F+KsZkN4HzfbYwxLbCQfOSgy7q6TWce9kq\nLo6tya9vuvpWFm1dye7L+BodAQAq/dI/JMeCfyTb0eFb+tyzfr5aVIoqqDN+p9ft\ncw4OefpHbhtNAgMBAAGjUzBRMB0GA1UdDgQWBBRV2A9YmusekPzu5Yf08cV0oPL1\nwjAfBgNVHSMEGDAWgBRV2A9YmusekPzu5Yf08cV0oPL1wjAPBgNVHRMBAf8EBTAD\nAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCgQZ2Xfj+NxaKBZgn2KNxS0MTbhzHRz6Rn\nqJs+h8OUz2Crmaf6N+RHlmDRZXUrDjSHpxVT2LxFy7ofRrLYIezFDUYfb920VkkV\nSVcxh1YDFROJalfMoE6wdyR/LnK4MJZS9fUpeCJJc/A0J+9FK9CwcyUrHgJ8XbJh\nMKYyQ+cf6O7wzutuBpMyRqSKS+hVM7BQTmSFvv1eAJlo6klGAmmKiYmAEvcQadH1\ndjrujsA3Cn5vX2L+0yuiLB5/zoxqx5cEy97TuKUYB8OqMMujAXNzF4L3HJDUNba2\nAhEkFozMXwYX73TGbGZ0mawPS5D3v3tYTEmJFf6SnVCmUW1fs57g\n-----END CERTIFICATE-----\n"] + }, + "aap": { + "job_template_url": "https://osbuild.org", + "host_config_key": "unused", + "cacerts": ["-----BEGIN CERTIFICATE-----\nMIIDszCCApugAwIBAgIUJ4lK+JfdJCNgcEVxZDinJfKKbQswDQYJKoZIhvcNAQEL\nBQAwaDELMAkGA1UEBhMCVVMxFzAVBgNVBAgMDk5vcnRoIENhcm9saW5hMRAwDgYD\nVQQHDAdSYWxlaWdoMRAwDgYDVQQKDAdSZWQgSGF0MRwwGgYDVQQDDBNUZXN0IENB\nIGZvciBvc2J1aWxkMCAXDTI0MDkwMzEzMjkyMFoYDzIyOTgwNjE4MTMyOTIwWjBo\nMQswCQYDVQQGEwJVUzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExEDAOBgNVBAcM\nB1JhbGVpZ2gxEDAOBgNVBAoMB1JlZCBIYXQxHDAaBgNVBAMME1Rlc3QgQ0EgZm9y\nIG9zYnVpbGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeA7OcWTrV\ngstoBsUaeJKm8nelg7Lc0WNXH6yOTLsr4td4yHs0YOvFGwgSf+ffV3RAG1mgqnMG\nMgkD2+z+7QhHbHHs3y0d0zfhA2bg0KVvfCWk7fNRPHY0UOePpXk245Bfw3D0VTpl\nF7nePk1I7ZY09snPWUeb2rjKXzYjKjzM0h27+ykV8I8+FbdyPk/pR8whyDqtHLUa\nXfFy2TFloDSYMkHKVd38BnL0bj91x5F+KsZkN4HzfbYwxLbCQfOSgy7q6TWce9kq\nLo6tya9vuvpWFm1dye7L+BodAQAq/dI/JMeCfyTb0eFb+tyzfr5aVIoqqDN+p9ft\ncw4OefpHbhtNAgMBAAGjUzBRMB0GA1UdDgQWBBRV2A9YmusekPzu5Yf08cV0oPL1\nwjAfBgNVHSMEGDAWgBRV2A9YmusekPzu5Yf08cV0oPL1wjAPBgNVHRMBAf8EBTAD\nAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCgQZ2Xfj+NxaKBZgn2KNxS0MTbhzHRz6Rn\nqJs+h8OUz2Crmaf6N+RHlmDRZXUrDjSHpxVT2LxFy7ofRrLYIezFDUYfb920VkkV\nSVcxh1YDFROJalfMoE6wdyR/LnK4MJZS9fUpeCJJc/A0J+9FK9CwcyUrHgJ8XbJh\nMKYyQ+cf6O7wzutuBpMyRqSKS+hVM7BQTmSFvv1eAJlo6klGAmmKiYmAEvcQadH1\ndjrujsA3Cn5vX2L+0yuiLB5/zoxqx5cEy97TuKUYB8OqMMujAXNzF4L3HJDUNba2\nAhEkFozMXwYX73TGbGZ0mawPS5D3v3tYTEmJFf6SnVCmUW1fs57g\n-----END CERTIFICATE-----\n"] + }, + "custom": [ + { + "name": "script1", + "contents": "touch /script1_done\n" + }, + { + "name": "script2", + "contents": "touch /script2_done; exit 1\n", + "ignore_failure": true + }, + { + "contents": "touch /script3_done\n" + } + ] + } + } + } +}