Repository: osbuild/osbuild-composer
Pull request #1525 (2021-07-02 17:09:07)

Add support for newly added / extended osbuild stages required for RHEL AMIs
This pull request includes:

- Add two new helper functions `IntToPtr()` and `BoolToPtr()` to the `internal/common` package, which can be used to conveniently set basic type literals in a struct literal, in which pointer to the basic type is expected.
- Add support for the `org.osbuild.modprobe` osbuild stage (osbuild/osbuild#670), which allows one to configure modprobe by creating configuration files.
- Add support for the `org.osbuild.dracut.conf` osbuild stage (osbuild/osbuild#688), which allows one to configure dracut by creating configuration files.
- Add support for the `org.osbuild.systemd-logind` osbuild stage (osbuild/osbuild#668), which allows one to configure systemd-logind by creating configuration drop-ins.
- Add support for the newly added `network-scripts` option in the `org.osbuild.sysconfig` osbuild stage (osbuild/osbuild#663). The stage allows one to create `ifcfg` configuration files under `/etc/sysconfig/network-scripts`.
- Add support for the `org.osbuild.cloud-init` osbuild stage (osbuild/osbuild#689), which allows one to configure cloud-init by creating configuration files under `/etc/cloud/cloud.cfg.d/`.
- Add support for the newly added `unit-dropins` option in the `org.osbuild.systemd` osbuild stage (osbuild/osbuild#664). The stage allows one to create `.service` unit files drop-in configuration files under `/usr/lib/systemd/system/`.
- Add support for the newly added `servers` option in the `org.osbuild.chrony` osbuild stage (osbuild/osbuild#692). The option allows one to specify timeservers to be used by chrony, including a subset of lower-level configuration options per each server.
- Add support for the newly added `x11-keymap` option in the `org.osbuild.keymap` osbuild stage (osbuild/osbuild#693). The option allows one to set layouts for the X11 keyboard.
- Add support for the `org.osbuild.authselect` osbuild stage (osbuild/osbuild#696), which allows one to set system identity profile and authentication sources using `authselect`.


- [x] adequate testing for the new functionality or fixed issue
  - unit tests 
- [ ] adequate documentation informing people about the change such as
  - unfortunately I didn't manage to write documentation, will do it after vacation.
  - [ ] create a file in [news/unreleased](https://github.com/osbuild/osbuild-composer/tree/main/docs/news/unreleased) directory if this change should be mentioned in the release news
  - [ ] submit a PR for the [guides](https://github.com/osbuild/guides) repository if this PR changed any behavior described there: https://www.osbuild.org/guides/

<!--
Thanks for proposing a change to osbuild-composer!

Please don't remove the above check list. These are things that each pull
request must have before it is merged. It helps maintainers to not forget
anything.

If the reason for ticking any of the boxes is ambiguous, please add a short
note explaining why.

For user-visible changes, "adequate documentation" is an entry describing the
change for users in docs/news. Please refer to docs/news/README.md for details.

In addition, if this pull request fixes a downstream issue, please refer to
test/README.md and add these additional items:

- [ ] 1st commit of any `rhbz#` related PR contains bug reproducer; CI reports FAIL or
- [ ] PR contains automated tests for new functionality and
- [ ] QE has approved reproducer/new tests and
- [ ] Subsequent commits provide bug fixes without modifying the reproducer; CI reports PASS and
- [ ] QE approves this PR; RHBZ status is set to `MODIFIED + Verified=Tested`
-->


thozza: I didn't update the osbuild version used in CI, because the new functionality is not really used in any manifest yet. Once it is, the `main` from osbuild repo should be used, or at least the commit from the latest referenced osbuild PR. 
achilleas-k: Rebased this PR since Tomas' is away to get CI fixed so we can merge this and get AMIs in RHEL 8.5.
achilleas-k: A lot of failed jobs.  Looks like #1519.  Restarting each one.

Patch:
diff --git a/internal/common/pointers.go b/internal/common/pointers.go
new file mode 100644
index 000000000..bf8a0d7bd
--- /dev/null
+++ b/internal/common/pointers.go
@@ -0,0 +1,9 @@
+package common
+
+func IntToPtr(x int) *int {
+	return &x
+}
+
+func BoolToPtr(x bool) *bool {
+	return &x
+}
diff --git a/internal/osbuild2/authselect_stage.go b/internal/osbuild2/authselect_stage.go
new file mode 100644
index 000000000..72ed5597b
--- /dev/null
+++ b/internal/osbuild2/authselect_stage.go
@@ -0,0 +1,15 @@
+package osbuild2
+
+type AuthselectStageOptions struct {
+	Profile  string   `json:"profile_id"`
+	Features []string `json:"features,omitempty"`
+}
+
+func (AuthselectStageOptions) isStageOptions() {}
+
+func NewAuthselectStage(options *AuthselectStageOptions) *Stage {
+	return &Stage{
+		Type:    "org.osbuild.authselect",
+		Options: options,
+	}
+}
diff --git a/internal/osbuild2/chrony_stage.go b/internal/osbuild2/chrony_stage.go
index 4233d02e5..1e3e4835c 100644
--- a/internal/osbuild2/chrony_stage.go
+++ b/internal/osbuild2/chrony_stage.go
@@ -1,11 +1,41 @@
 package osbuild2
 
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// Exactly one of 'Timeservers' or 'Servers' must be specified
 type ChronyStageOptions struct {
-	Timeservers []string `json:"timeservers"`
+	Timeservers []string             `json:"timeservers,omitempty"`
+	Servers     []ChronyConfigServer `json:"servers,omitempty"`
 }
 
 func (ChronyStageOptions) isStageOptions() {}
 
+// Use '*ToPtr()' functions from the internal/common package to set the pointer values in literals
+type ChronyConfigServer struct {
+	Hostname string `json:"hostname"`
+	Minpoll  *int   `json:"minpoll,omitempty"`
+	Maxpoll  *int   `json:"maxpoll,omitempty"`
+	Iburst   *bool  `json:"iburst,omitempty"`
+	Prefer   *bool  `json:"prefer,omitempty"`
+}
+
+// Unexported struct for use in ChronyStageOptions's MarshalJSON() to prevent recursion
+type chronyStageOptions struct {
+	Timeservers []string             `json:"timeservers,omitempty"`
+	Servers     []ChronyConfigServer `json:"servers,omitempty"`
+}
+
+func (o ChronyStageOptions) MarshalJSON() ([]byte, error) {
+	if (len(o.Timeservers) != 0 && len(o.Servers) != 0) || (len(o.Timeservers) == 0 && len(o.Servers) == 0) {
+		return nil, fmt.Errorf("exactly one of 'Timeservers' or 'Servers' must be specified")
+	}
+	stageOptions := chronyStageOptions(o)
+	return json.Marshal(stageOptions)
+}
+
 func NewChronyStage(options *ChronyStageOptions) *Stage {
 	return &Stage{
 		Type:    "org.osbuild.chrony",
diff --git a/internal/osbuild2/cloud_init_stage.go b/internal/osbuild2/cloud_init_stage.go
new file mode 100644
index 000000000..4e1858b69
--- /dev/null
+++ b/internal/osbuild2/cloud_init_stage.go
@@ -0,0 +1,73 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+type CloudInitStageOptions struct {
+	ConfigFiles map[string]CloudInitConfigFile `json:"configuration_files,omitempty"`
+}
+
+func (CloudInitStageOptions) isStageOptions() {}
+
+func NewCloudInitStage(options *CloudInitStageOptions) *Stage {
+	return &Stage{
+		Type:    "org.osbuild.cloud-init",
+		Options: options,
+	}
+}
+
+// Represents a cloud-init configuration file
+type CloudInitConfigFile struct {
+	SystemInfo *CloudInitConfigSystemInfo `json:"system_info,omitempty"`
+}
+
+// Unexported struct for use in CloudInitConfigFile's MarshalJSON() to prevent recursion
+type cloudInitConfigFile struct {
+	SystemInfo *CloudInitConfigSystemInfo `json:"system_info,omitempty"`
+}
+
+func (c CloudInitConfigFile) MarshalJSON() ([]byte, error) {
+	if c.SystemInfo == nil {
+		return nil, fmt.Errorf("at least one cloud-init configuration option must be specified")
+	}
+	configFile := cloudInitConfigFile(c)
+	return json.Marshal(configFile)
+}
+
+// Represents the 'system_info' configuration section
+type CloudInitConfigSystemInfo struct {
+	DefaultUser *CloudInitConfigDefaultUser `json:"default_user,omitempty"`
+}
+
+// Unexported struct for use in CloudInitConfigSystemInfo's MarshalJSON() to prevent recursion
+type cloudInitConfigSystemInfo struct {
+	DefaultUser *CloudInitConfigDefaultUser `json:"default_user,omitempty"`
+}
+
+func (si CloudInitConfigSystemInfo) MarshalJSON() ([]byte, error) {
+	if si.DefaultUser == nil {
+		return nil, fmt.Errorf("at least one configuration option must be specified for 'system_info' section")
+	}
+	systemInfo := cloudInitConfigSystemInfo(si)
+	return json.Marshal(systemInfo)
+}
+
+// Configuration of the 'default' user created by cloud-init.
+type CloudInitConfigDefaultUser struct {
+	Name string `json:"name,omitempty"`
+}
+
+// Unexported struct for use in CloudInitConfigDefaultUser's MarshalJSON() to prevent recursion
+type cloudInitConfigDefaultUser struct {
+	Name string `json:"name,omitempty"`
+}
+
+func (du CloudInitConfigDefaultUser) MarshalJSON() ([]byte, error) {
+	if du.Name == "" {
+		return nil, fmt.Errorf("at least one configuration option must be specified for 'default_user' section")
+	}
+	defaultUser := cloudInitConfigDefaultUser(du)
+	return json.Marshal(defaultUser)
+}
diff --git a/internal/osbuild2/dracut_conf_stage.go b/internal/osbuild2/dracut_conf_stage.go
new file mode 100644
index 000000000..077c116d1
--- /dev/null
+++ b/internal/osbuild2/dracut_conf_stage.go
@@ -0,0 +1,109 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+type DracutConfStageOptions struct {
+	ConfigFiles map[string]DracutConfigFile `json:"configuration_files,omitempty"`
+}
+
+func (DracutConfStageOptions) isStageOptions() {}
+
+// Dracut.conf stage creates dracut configuration files under /usr/lib/dracut/dracut.conf.d/
+func NewDracutConfStageOptions(options *DracutConfStageOptions) *Stage {
+	return &Stage{
+		Type:    "org.osbuild.dracut.conf",
+		Options: options,
+	}
+}
+
+type DracutConfigFile struct {
+	// Compression method for the initramfs
+	Compress string `json:"compress,omitempty"`
+
+	// Exact list of dracut modules to use
+	Modules []string `json:"dracutmodules,omitempty"`
+
+	// Additional dracut modules to include
+	AddModules []string `json:"add_dracutmodules,omitempty"`
+
+	// Dracut modules to not include
+	OmitModules []string `json:"omit_dracutmodules,omitempty"`
+
+	// Kernel modules to exclusively include
+	Drivers []string `json:"drivers,omitempty"`
+
+	// Add a specific kernel module
+	AddDrivers []string `json:"add_drivers,omitempty"`
+
+	// Add driver and ensure that they are tried to be loaded
+	ForceDrivers []string `json:"force_drivers,omitempty"`
+
+	// Kernel filesystem modules to exclusively include
+	Filesystems []string `json:"filesystems,omitempty"`
+
+	// Install the specified files
+	Install []string `json:"install_items,omitempty"`
+
+	// Combine early microcode with the initramfs
+	EarlyMicrocode *bool `json:"early_microcode,omitempty"`
+
+	// Create reproducible images
+	Reproducible *bool `json:"reproducible,omitempty"`
+}
+
+// Unexported struct for use in DracutConfigFile MarshalJSON() to prevent recursion
+type dracutConfigFile struct {
+	// Compression method for the initramfs
+	Compress string `json:"compress,omitempty"`
+
+	// Exact list of dracut modules to use
+	Modules []string `json:"dracutmodules,omitempty"`
+
+	// Additional dracut modules to include
+	AddModules []string `json:"add_dracutmodules,omitempty"`
+
+	// Dracut modules to not include
+	OmitModules []string `json:"omit_dracutmodules,omitempty"`
+
+	// Kernel modules to exclusively include
+	Drivers []string `json:"drivers,omitempty"`
+
+	// Add a specific kernel module
+	AddDrivers []string `json:"add_drivers,omitempty"`
+
+	// Add driver and ensure that they are tried to be loaded
+	ForceDrivers []string `json:"force_drivers,omitempty"`
+
+	// Kernel filesystem modules to exclusively include
+	Filesystems []string `json:"filesystems,omitempty"`
+
+	// Install the specified files
+	Install []string `json:"install_items,omitempty"`
+
+	// Combine early microcode with the initramfs
+	EarlyMicrocode *bool `json:"early_microcode,omitempty"`
+
+	// Create reproducible images
+	Reproducible *bool `json:"reproducible,omitempty"`
+}
+
+func (c DracutConfigFile) MarshalJSON() ([]byte, error) {
+	if c.Compress == "" &&
+		len(c.Modules) == 0 &&
+		len(c.AddModules) == 0 &&
+		len(c.OmitModules) == 0 &&
+		len(c.Drivers) == 0 &&
+		len(c.AddDrivers) == 0 &&
+		len(c.ForceDrivers) == 0 &&
+		len(c.Filesystems) == 0 &&
+		len(c.Install) == 0 &&
+		c.EarlyMicrocode == nil &&
+		c.Reproducible == nil {
+		return nil, fmt.Errorf("at least one dracut configuration option must be specified")
+	}
+	configFile := dracutConfigFile(c)
+	return json.Marshal(configFile)
+}
diff --git a/internal/osbuild2/keymap_stage.go b/internal/osbuild2/keymap_stage.go
index 571d19ed9..c480ff05a 100644
--- a/internal/osbuild2/keymap_stage.go
+++ b/internal/osbuild2/keymap_stage.go
@@ -1,7 +1,13 @@
 package osbuild2
 
+import (
+	"encoding/json"
+	"fmt"
+)
+
 type KeymapStageOptions struct {
-	Keymap string `json:"keymap"`
+	Keymap    string            `json:"keymap"`
+	X11Keymap *X11KeymapOptions `json:"x11-keymap,omitempty"`
 }
 
 func (KeymapStageOptions) isStageOptions() {}
@@ -12,3 +18,20 @@ func NewKeymapStage(options *KeymapStageOptions) *Stage {
 		Options: options,
 	}
 }
+
+type X11KeymapOptions struct {
+	Layouts []string `json:"layouts"`
+}
+
+// Unexported struct for use in X11KeymapOptions's MarshalJSON() to prevent recursion
+type x11KeymapOptions struct {
+	Layouts []string `json:"layouts"`
+}
+
+func (o X11KeymapOptions) MarshalJSON() ([]byte, error) {
+	if len(o.Layouts) == 0 {
+		return nil, fmt.Errorf("at least one layout must be provided for X11 keymap")
+	}
+	keymapOptions := x11KeymapOptions(o)
+	return json.Marshal(keymapOptions)
+}
diff --git a/internal/osbuild2/modprobe_stage.go b/internal/osbuild2/modprobe_stage.go
new file mode 100644
index 000000000..04d349b28
--- /dev/null
+++ b/internal/osbuild2/modprobe_stage.go
@@ -0,0 +1,91 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+type ModprobeStageOptions struct {
+	ConfigFiles map[string]ModprobeConfigCmdList `json:"configuration_files,omitempty"`
+}
+
+func (ModprobeStageOptions) isStageOptions() {}
+
+func NewModprobeStage(options *ModprobeStageOptions) *Stage {
+	return &Stage{
+		Type:    "org.osbuild.modprobe",
+		Options: options,
+	}
+}
+
+type ModprobeConfigCmd interface {
+	isModprobeConfigCmd()
+}
+
+// ModprobeConfigCmdList represents a modprobe configuration file, which contains
+// a list of commands.
+type ModprobeConfigCmdList []ModprobeConfigCmd
+
+func (configFile *ModprobeConfigCmdList) UnmarshalJSON(data []byte) error {
+	var rawConfigFile []interface{}
+
+	if err := json.Unmarshal(data, &rawConfigFile); err != nil {
+		return err
+	}
+
+	for _, rawConfigCmd := range rawConfigFile {
+		var modprobeCmd ModprobeConfigCmd
+
+		// The command object structure depends on the value of "command"
+		// item, therefore make no assumptions on the structure.
+		configCmdMap, ok := rawConfigCmd.(map[string]interface{})
+		if !ok {
+			return fmt.Errorf("unexpected modprobe configuration file format")
+		}
+		command, ok := configCmdMap["command"].(string)
+		if !ok {
+			return fmt.Errorf("'command' item should be string, not %T", configCmdMap["command"])
+		}
+
+		switch command {
+		case "blacklist":
+			modulename, ok := configCmdMap["modulename"].(string)
+			if !ok {
+				return fmt.Errorf("'modulename' item should be string, not %T", configCmdMap["modulename"])
+			}
+			modprobeCmd = NewModprobeConfigCmdBlacklist(modulename)
+		default:
+			return fmt.Errorf("unexpected modprobe command: %s", command)
+		}
+
+		*configFile = append(*configFile, modprobeCmd)
+	}
+
+	return nil
+}
+
+func (o ModprobeConfigCmdList) MarshalJSON() ([]byte, error) {
+	if len(o) == 0 {
+		return nil, fmt.Errorf("at least one modprobe command must be specified for a configuration file")
+	}
+	var configList []ModprobeConfigCmd = o
+	return json.Marshal(configList)
+}
+
+// ModprobeConfigCmdBlacklist represents the 'blacklist' command in the
+// modprobe configuration.
+type ModprobeConfigCmdBlacklist struct {
+	Command    string `json:"command"`
+	Modulename string `json:"modulename"`
+}
+
+func (ModprobeConfigCmdBlacklist) isModprobeConfigCmd() {}
+
+// NewModprobeConfigCmdBlacklist creates a new instance of ModprobeConfigCmdBlacklist
+// for the provided modulename.
+func NewModprobeConfigCmdBlacklist(modulename string) *ModprobeConfigCmdBlacklist {
+	return &ModprobeConfigCmdBlacklist{
+		Command:    "blacklist",
+		Modulename: modulename,
+	}
+}
diff --git a/internal/osbuild2/rhsm_stage.go b/internal/osbuild2/rhsm_stage.go
index 3d072c802..160e6529f 100644
--- a/internal/osbuild2/rhsm_stage.go
+++ b/internal/osbuild2/rhsm_stage.go
@@ -7,10 +7,19 @@ package osbuild2
 // state of DNF plugins used by the Subscription Manager.
 type RHSMStageOptions struct {
 	DnfPlugins *RHSMStageOptionsDnfPlugins `json:"dnf-plugins,omitempty"`
+	SubMan     *RHSMStageOptionsSubMan     `json:"subscription-manager,omitempty"`
 }
 
 func (RHSMStageOptions) isStageOptions() {}
 
+// NewRHSMStage creates a new RHSM stage
+func NewRHSMStage(options *RHSMStageOptions) *Stage {
+	return &Stage{
+		Type:    "org.osbuild.rhsm",
+		Options: options,
+	}
+}
+
 // RHSMStageOptionsDnfPlugins describes configuration of all RHSM DNF plugins
 type RHSMStageOptionsDnfPlugins struct {
 	ProductID           *RHSMStageOptionsDnfPlugin `json:"product-id,omitempty"`
@@ -25,10 +34,20 @@ type RHSMStageOptionsDnfPlugin struct {
 	Enabled bool `json:"enabled"`
 }
 
-// NewRHSMStage creates a new RHSM stage
-func NewRHSMStage(options *RHSMStageOptions) *Stage {
-	return &Stage{
-		Type:    "org.osbuild.rhsm",
-		Options: options,
-	}
+// Subscription-manager configuration (/etc/rhsm/rhsm.conf)
+type RHSMStageOptionsSubMan struct {
+	Rhsm      *SubManConfigRHSMSection      `json:"rhsm,omitempty"`
+	Rhsmcertd *SubManConfigRHSMCERTDSection `json:"rhsmcertd,omitempty"`
+}
+
+// RHSM configuration section of /etc/rhsm/rhsm.conf
+type SubManConfigRHSMSection struct {
+	// Whether subscription-manager should manage DNF repos file
+	ManageRepos *bool `json:"manage_repos,omitempty"`
+}
+
+// RHSMCERTD configuration section of /etc/rhsm/rhsm.conf
+type SubManConfigRHSMCERTDSection struct {
+	// Automatic system registration
+	AutoRegistration *bool `json:"auto_registration,omitempty"`
 }
diff --git a/internal/osbuild2/stage.go b/internal/osbuild2/stage.go
index 37dce4e1d..454baffb1 100644
--- a/internal/osbuild2/stage.go
+++ b/internal/osbuild2/stage.go
@@ -67,6 +67,8 @@ func (stage *Stage) UnmarshalJSON(data []byte) error {
 	var options StageOptions
 	var inputs Inputs
 	switch rawStage.Type {
+	case "org.osbuild.authselect":
+		options = new(AuthselectStageOptions)
 	case "org.osbuild.fix-bls":
 		options = new(FixBLSStageOptions)
 	case "org.osbuild.fstab":
@@ -85,16 +87,26 @@ func (stage *Stage) UnmarshalJSON(data []byte) error {
 		options = new(GroupsStageOptions)
 	case "org.osbuild.timezone":
 		options = new(TimezoneStageOptions)
+	case "org.osbuild.cloud-init":
+		options = new(CloudInitStageOptions)
 	case "org.osbuild.chrony":
 		options = new(ChronyStageOptions)
+	case "org.osbuild.dracut":
+		options = new(DracutStageOptions)
+	case "org.osbuild.dracut.conf":
+		options = new(DracutConfStageOptions)
 	case "org.osbuild.keymap":
 		options = new(KeymapStageOptions)
+	case "org.osbuild.modprobe":
+		options = new(ModprobeStageOptions)
 	case "org.osbuild.firewall":
 		options = new(FirewallStageOptions)
 	case "org.osbuild.rhsm":
 		options = new(RHSMStageOptions)
 	case "org.osbuild.systemd":
 		options = new(SystemdStageOptions)
+	case "org.osbuild.systemd-logind":
+		options = new(SystemdLogindStageOptions)
 	case "org.osbuild.script":
 		options = new(ScriptStageOptions)
 	case "org.osbuild.sysconfig":
diff --git a/internal/osbuild2/sysconfig_stage.go b/internal/osbuild2/sysconfig_stage.go
index b133aa066..32258ff69 100644
--- a/internal/osbuild2/sysconfig_stage.go
+++ b/internal/osbuild2/sysconfig_stage.go
@@ -1,8 +1,18 @@
 package osbuild2
 
 type SysconfigStageOptions struct {
-	Kernel  SysconfigKernelOptions  `json:"kernel,omitempty"`
-	Network SysconfigNetworkOptions `json:"network,omitempty"`
+	Kernel         SysconfigKernelOptions  `json:"kernel,omitempty"`
+	Network        SysconfigNetworkOptions `json:"network,omitempty"`
+	NetworkScripts *NetworkScriptsOptions  `json:"network-scripts,omitempty"`
+}
+
+func (SysconfigStageOptions) isStageOptions() {}
+
+func NewSysconfigStage(options *SysconfigStageOptions) *Stage {
+	return &Stage{
+		Type:    "org.osbuild.sysconfig",
+		Options: options,
+	}
 }
 
 type SysconfigNetworkOptions struct {
@@ -15,11 +25,55 @@ type SysconfigKernelOptions struct {
 	DefaultKernel string `json:"default_kernel,omitempty"`
 }
 
-func (SysconfigStageOptions) isStageOptions() {}
+type NetworkScriptsOptions struct {
+	// Keys are interface names, values are objects containing interface configuration
+	IfcfgFiles map[string]IfcfgFile `json:"ifcfg,omitempty"`
+}
 
-func NewSysconfigStage(options *SysconfigStageOptions) *Stage {
-	return &Stage{
-		Type:    "org.osbuild.sysconfig",
-		Options: options,
-	}
+type IfcfgBootprotoValue string
+
+// Valid values for the 'Bootproto' item of 'IfcfgFile' struct
+const (
+	IfcfgBootprotoNone   IfcfgBootprotoValue = "none"
+	IfcfgBootprotoBootp  IfcfgBootprotoValue = "bootp"
+	IfcfgBootprotoDHCP   IfcfgBootprotoValue = "dhcp"
+	IfcfgBootprotoStatic IfcfgBootprotoValue = "static"
+	IfcfgBootprotoIbft   IfcfgBootprotoValue = "ibft"
+	IfcfgBootprotoAutoIP IfcfgBootprotoValue = "autoip"
+	IfcfgBootprotoShared IfcfgBootprotoValue = "shared"
+)
+
+type IfcfgTypeValue string
+
+// Valid values for the 'Type' item of 'IfcfgFile' struct
+const (
+	IfcfgTypeEthernet   IfcfgTypeValue = "Ethernet"
+	IfcfgTypeWireless   IfcfgTypeValue = "Wireless"
+	IfcfgTypeInfiniBand IfcfgTypeValue = "InfiniBand"
+	IfcfgTypeBridge     IfcfgTypeValue = "Bridge"
+	IfcfgTypeBond       IfcfgTypeValue = "Bond"
+	IfcfgTypeVLAN       IfcfgTypeValue = "Vlan"
+)
+
+type IfcfgFile struct {
+	// Method used for IPv4 protocol configuration
+	Bootproto IfcfgBootprotoValue `json:"bootproto,omitempty"`
+
+	// Interface name of the device
+	Device string `json:"device,omitempty"`
+
+	// Whether to initialize this device for IPv6 addressing
+	IPv6Init *bool `json:"ipv6init,omitempty"`
+
+	// Whether the connection should be autoconnected
+	OnBoot *bool `json:"onboot,omitempty"`
+
+	// Whether to modify /etc/resolv.conf
+	PeerDNS *bool `json:"peerdns,omitempty"`
+
+	// Base type of the connection
+	Type IfcfgTypeValue `json:"type,omitempty"`
+
+	// Whether non-root users are allowed to control the device
+	UserCtl *bool `json:"userctl,omitempty"`
 }
diff --git a/internal/osbuild2/systemd_logind_stage.go b/internal/osbuild2/systemd_logind_stage.go
new file mode 100644
index 000000000..cc0c88522
--- /dev/null
+++ b/internal/osbuild2/systemd_logind_stage.go
@@ -0,0 +1,46 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+type SystemdLogindStageOptions struct {
+	ConfigDropins map[string]SystemdLogindConfigDropin `json:"configuration_dropins,omitempty"`
+}
+
+func (SystemdLogindStageOptions) isStageOptions() {}
+
+func NewSystemdLogindStage(options *SystemdLogindStageOptions) *Stage {
+	return &Stage{
+		Type:    "org.osbuild.systemd-logind",
+		Options: options,
+	}
+}
+
+// Drop-in configuration for systemd-logind
+type SystemdLogindConfigDropin struct {
+	Login SystemdLogindConfigLoginSection `json:"Login"`
+}
+
+// 'Login' configuration section - at least one option must be specified
+type SystemdLogindConfigLoginSection struct {
+	// Configures how many virtual terminals (VTs) to allocate by default
+	// The option is optional, but zero is a valid value
+	NAutoVT *int `json:"NAutoVT,omitempty"`
+}
+
+// Unexported struct for use in SystemdLogindConfigLoginSection's MarshalJSON() to prevent recursion
+type systemdLogindConfigLoginSection struct {
+	// Configures how many virtual terminals (VTs) to allocate by default
+	// The option is optional, but zero is a valid value
+	NAutoVT *int `json:"NAutoVT,omitempty"`
+}
+
+func (s SystemdLogindConfigLoginSection) MarshalJSON() ([]byte, error) {
+	if s.NAutoVT == nil {
+		return nil, fmt.Errorf("at least one 'Login' section option must be specified")
+	}
+	loginSection := systemdLogindConfigLoginSection(s)
+	return json.Marshal(loginSection)
+}
diff --git a/internal/osbuild2/systemd_stage.go b/internal/osbuild2/systemd_stage.go
index ca0b80a64..35f7100b9 100644
--- a/internal/osbuild2/systemd_stage.go
+++ b/internal/osbuild2/systemd_stage.go
@@ -4,6 +4,9 @@ type SystemdStageOptions struct {
 	EnabledServices  []string `json:"enabled_services,omitempty"`
 	DisabledServices []string `json:"disabled_services,omitempty"`
 	DefaultTarget    string   `json:"default_target,omitempty"`
+
+	// For now we support only .service drop-ins, but this may change in the future
+	UnitDropins map[string]SystemdServiceUnitDropins `json:"unit_dropins,omitempty"`
 }
 
 func (SystemdStageOptions) isStageOptions() {}
@@ -14,3 +17,17 @@ func NewSystemdStage(options *SystemdStageOptions) *Stage {
 		Options: options,
 	}
 }
+
+// Drop-in configurations for a '.service' unit
+type SystemdServiceUnitDropins map[string]SystemdServiceUnitDropin
+
+// Drop-in configuration for a '.service' unit
+type SystemdServiceUnitDropin struct {
+	Service *SystemdUnitServiceSection `json:"Service,omitempty"`
+}
+
+// 'Service' configuration section of a unit file
+type SystemdUnitServiceSection struct {
+	// Sets environment variables for executed process
+	Environment string `json:"Environment,omitempty"`
+}


Test patch:
diff --git a/internal/common/pointers_test.go b/internal/common/pointers_test.go
new file mode 100644
index 000000000..01c1d0cd8
--- /dev/null
+++ b/internal/common/pointers_test.go
@@ -0,0 +1,19 @@
+package common
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIntToPtr(t *testing.T) {
+	var value int = 42
+	got := IntToPtr(value)
+	assert.Equal(t, value, *got)
+}
+
+func TestBoolToPtr(t *testing.T) {
+	var value bool = true
+	got := BoolToPtr(value)
+	assert.Equal(t, value, *got)
+}
diff --git a/internal/osbuild2/authselect_stage_test.go b/internal/osbuild2/authselect_stage_test.go
new file mode 100644
index 000000000..7e954fc85
--- /dev/null
+++ b/internal/osbuild2/authselect_stage_test.go
@@ -0,0 +1,16 @@
+package osbuild2
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewAuthselectStage(t *testing.T) {
+	expectedStage := &Stage{
+		Type:    "org.osbuild.authselect",
+		Options: &AuthselectStageOptions{},
+	}
+	actualStage := NewAuthselectStage(&AuthselectStageOptions{})
+	assert.Equal(t, expectedStage, actualStage)
+}
diff --git a/internal/osbuild2/chrony_stage_test.go b/internal/osbuild2/chrony_stage_test.go
index afb8ef8ce..2d8e390c0 100644
--- a/internal/osbuild2/chrony_stage_test.go
+++ b/internal/osbuild2/chrony_stage_test.go
@@ -1,6 +1,7 @@
 package osbuild2
 
 import (
+	"encoding/json"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -14,3 +15,28 @@ func TestNewChronyStage(t *testing.T) {
 	actualStage := NewChronyStage(&ChronyStageOptions{})
 	assert.Equal(t, expectedStage, actualStage)
 }
+
+func TestChronyStage_MarshalJSON_Invalid(t *testing.T) {
+	tests := []struct {
+		name    string
+		options ChronyStageOptions
+	}{
+		{
+			name:    "not-timeservers-nor-servers",
+			options: ChronyStageOptions{},
+		},
+		{
+			name: "timeservers-and-servers",
+			options: ChronyStageOptions{
+				Timeservers: []string{"ntp.example.com"},
+				Servers:     []ChronyConfigServer{{Hostname: "ntp2.example.com"}},
+			},
+		},
+	}
+	for idx, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := json.Marshal(tt.options)
+			assert.NotNilf(t, err, "json.Marshal() didn't return an error [idx: %d]", idx)
+		})
+	}
+}
diff --git a/internal/osbuild2/cloud_init_stage_test.go b/internal/osbuild2/cloud_init_stage_test.go
new file mode 100644
index 000000000..3db7a8a34
--- /dev/null
+++ b/internal/osbuild2/cloud_init_stage_test.go
@@ -0,0 +1,61 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewCloudInitStage(t *testing.T) {
+	expectedStage := &Stage{
+		Type:    "org.osbuild.cloud-init",
+		Options: &CloudInitStageOptions{},
+	}
+	actualStage := NewCloudInitStage(&CloudInitStageOptions{})
+	assert.Equal(t, expectedStage, actualStage)
+}
+
+func TestCloudInitStage_MarshalJSON_Invalid(t *testing.T) {
+	tests := []struct {
+		name    string
+		options CloudInitStageOptions
+	}{
+		{
+			name: "no-config-file-section",
+			options: CloudInitStageOptions{
+				ConfigFiles: map[string]CloudInitConfigFile{
+					"00-default_user.cfg": {},
+				},
+			},
+		},
+		{
+			name: "no-system-info-section-option",
+			options: CloudInitStageOptions{
+				ConfigFiles: map[string]CloudInitConfigFile{
+					"00-default_user.cfg": {
+						SystemInfo: &CloudInitConfigSystemInfo{},
+					},
+				},
+			},
+		},
+		{
+			name: "no-default-user-section-option",
+			options: CloudInitStageOptions{
+				ConfigFiles: map[string]CloudInitConfigFile{
+					"00-default_user.cfg": {
+						SystemInfo: &CloudInitConfigSystemInfo{
+							DefaultUser: &CloudInitConfigDefaultUser{},
+						},
+					},
+				},
+			},
+		},
+	}
+	for idx, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotBytes, err := json.Marshal(tt.options)
+			assert.NotNilf(t, err, "json.Marshal() didn't return an error, but: %s [idx: %d]", string(gotBytes), idx)
+		})
+	}
+}
diff --git a/internal/osbuild2/dracut_conf_stage_test.go b/internal/osbuild2/dracut_conf_stage_test.go
new file mode 100644
index 000000000..faedea3ce
--- /dev/null
+++ b/internal/osbuild2/dracut_conf_stage_test.go
@@ -0,0 +1,39 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewDracutConfStage(t *testing.T) {
+	expectedStage := &Stage{
+		Type:    "org.osbuild.dracut.conf",
+		Options: &DracutConfStageOptions{},
+	}
+	actualStage := NewDracutConfStageOptions(&DracutConfStageOptions{})
+	assert.Equal(t, expectedStage, actualStage)
+}
+
+func TestDracutConfStage_MarshalJSON_Invalid(t *testing.T) {
+	tests := []struct {
+		name    string
+		options DracutConfStageOptions
+	}{
+		{
+			name: "no-options-in-config",
+			options: DracutConfStageOptions{
+				ConfigFiles: map[string]DracutConfigFile{
+					"testing.conf": {},
+				},
+			},
+		},
+	}
+	for idx, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotBytes, err := json.Marshal(tt.options)
+			assert.NotNilf(t, err, "json.Marshal() didn't return an error, but %s [idx: %d]", string(gotBytes), idx)
+		})
+	}
+}
diff --git a/internal/osbuild2/keymap_stage_test.go b/internal/osbuild2/keymap_stage_test.go
index abb9d7b6c..7ae3ca530 100644
--- a/internal/osbuild2/keymap_stage_test.go
+++ b/internal/osbuild2/keymap_stage_test.go
@@ -1,6 +1,7 @@
 package osbuild2
 
 import (
+	"encoding/json"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -14,3 +15,23 @@ func TestNewKeymapStage(t *testing.T) {
 	actualStage := NewKeymapStage(&KeymapStageOptions{})
 	assert.Equal(t, expectedStage, actualStage)
 }
+
+func TestKeymapStage_MarshalJSON_Invalid(t *testing.T) {
+	tests := []struct {
+		name    string
+		options KeymapStageOptions
+	}{
+		{
+			name: "x11-keymap-empty-layout-list",
+			options: KeymapStageOptions{
+				X11Keymap: &X11KeymapOptions{},
+			},
+		},
+	}
+	for idx, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotBytes, err := json.Marshal(tt.options)
+			assert.NotNilf(t, err, "json.Marshal() didn't return an error, but: %s [idx: %d]", string(gotBytes), idx)
+		})
+	}
+}
diff --git a/internal/osbuild2/modprobe_stage_test.go b/internal/osbuild2/modprobe_stage_test.go
new file mode 100644
index 000000000..fd5ee43ad
--- /dev/null
+++ b/internal/osbuild2/modprobe_stage_test.go
@@ -0,0 +1,39 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewModprobeStage(t *testing.T) {
+	expectedStage := &Stage{
+		Type:    "org.osbuild.modprobe",
+		Options: &ModprobeStageOptions{},
+	}
+	actualStage := NewModprobeStage(&ModprobeStageOptions{})
+	assert.Equal(t, expectedStage, actualStage)
+}
+
+func TestModprobeStage_MarshalJSON_Invalid(t *testing.T) {
+	tests := []struct {
+		name    string
+		options ModprobeStageOptions
+	}{
+		{
+			name: "no-commands",
+			options: ModprobeStageOptions{
+				ConfigFiles: map[string]ModprobeConfigCmdList{
+					"disallow-modules.conf": {},
+				},
+			},
+		},
+	}
+	for idx, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotBytes, err := json.Marshal(tt.options)
+			assert.NotNilf(t, err, "json.Marshal() didn't return an error, but: %s [idx: %d]", string(gotBytes), idx)
+		})
+	}
+}
diff --git a/internal/osbuild2/stage_test.go b/internal/osbuild2/stage_test.go
index 59b792c99..9af9b1d7f 100644
--- a/internal/osbuild2/stage_test.go
+++ b/internal/osbuild2/stage_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/google/uuid"
+	"github.com/osbuild/osbuild-composer/internal/common"
 )
 
 func TestStage_UnmarshalJSON(t *testing.T) {
@@ -53,13 +54,143 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 			wantErr: true,
 		},
 		{
-			name: "chrony",
+			name: "authselect",
 			fields: fields{
-				Type:    "org.osbuild.chrony",
-				Options: &ChronyStageOptions{},
+				Type: "org.osbuild.authselect",
+				Options: &AuthselectStageOptions{
+					Profile: "sssd",
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.authselect","options":{"profile_id":"sssd"}}`),
+			},
+		},
+		{
+			name: "authselect-features",
+			fields: fields{
+				Type: "org.osbuild.authselect",
+				Options: &AuthselectStageOptions{
+					Profile:  "nis",
+					Features: []string{"with-ecryptfs", "with-mkhomedir"},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.authselect","options":{"profile_id":"nis","features":["with-ecryptfs","with-mkhomedir"]}}`),
+			},
+		},
+		{
+			name: "cloud-init",
+			fields: fields{
+				Type:    "org.osbuild.cloud-init",
+				Options: &CloudInitStageOptions{},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.cloud-init","options":{}}`),
+			},
+		},
+		{
+			name: "cloud-init-data",
+			fields: fields{
+				Type: "org.osbuild.cloud-init",
+				Options: &CloudInitStageOptions{
+					ConfigFiles: map[string]CloudInitConfigFile{
+						"00-default_user.cfg": {
+							SystemInfo: &CloudInitConfigSystemInfo{
+								DefaultUser: &CloudInitConfigDefaultUser{
+									Name: "ec2-user",
+								},
+							},
+						},
+					},
+				},
 			},
 			args: args{
-				data: []byte(`{"type":"org.osbuild.chrony","options":{"timeservers":null}}`),
+				data: []byte(`{"type":"org.osbuild.cloud-init","options":{"configuration_files":{"00-default_user.cfg":{"system_info":{"default_user":{"name":"ec2-user"}}}}}}`),
+			},
+		},
+		{
+			name: "chrony-timeservers",
+			fields: fields{
+				Type: "org.osbuild.chrony",
+				Options: &ChronyStageOptions{
+					Timeservers: []string{
+						"ntp1.example.com",
+						"ntp2.example.com",
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.chrony","options":{"timeservers":["ntp1.example.com","ntp2.example.com"]}}`),
+			},
+		},
+		{
+			name: "chrony-servers",
+			fields: fields{
+				Type: "org.osbuild.chrony",
+				Options: &ChronyStageOptions{
+					Servers: []ChronyConfigServer{
+						{
+							Hostname: "127.0.0.1",
+							Minpoll:  common.IntToPtr(0),
+							Maxpoll:  common.IntToPtr(4),
+							Iburst:   common.BoolToPtr(true),
+							Prefer:   common.BoolToPtr(false),
+						},
+						{
+							Hostname: "ntp.example.com",
+						},
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.chrony","options":{"servers":[{"hostname":"127.0.0.1","minpoll":0,"maxpoll":4,"iburst":true,"prefer":false},{"hostname":"ntp.example.com"}]}}`),
+			},
+		},
+		{
+			name: "dracut",
+			fields: fields{
+				Type:    "org.osbuild.dracut",
+				Options: &DracutStageOptions{},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.dracut","options":{"kernel":null}}`),
+			},
+		},
+		{
+			name: "dracut.conf",
+			fields: fields{
+				Type:    "org.osbuild.dracut.conf",
+				Options: &DracutConfStageOptions{},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.dracut.conf","options":{}}`),
+			},
+		},
+		{
+			name: "dracut.conf-data",
+			fields: fields{
+				Type: "org.osbuild.dracut.conf",
+				Options: &DracutConfStageOptions{
+					ConfigFiles: map[string]DracutConfigFile{
+						"sgdisk.conf": {
+							Install: []string{"sgdisk"},
+						},
+						"testing.conf": {
+							Compress:       "xz",
+							AddModules:     []string{"floppy"},
+							OmitModules:    []string{"nouveau"},
+							AddDrivers:     []string{"driver1"},
+							ForceDrivers:   []string{"driver2"},
+							Filesystems:    []string{"ext4"},
+							Install:        []string{"file1"},
+							EarlyMicrocode: common.BoolToPtr(false),
+							Reproducible:   common.BoolToPtr(false),
+						},
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.dracut.conf","options":{"configuration_files":{"sgdisk.conf":{"install_items":["sgdisk"]},"testing.conf":{"compress":"xz","add_dracutmodules":["floppy"],"omit_dracutmodules":["nouveau"],"add_drivers":["driver1"],"force_drivers":["driver2"],"filesystems":["ext4"],"install_items":["file1"],"early_microcode":false,"reproducible":false}}}}`),
 			},
 		},
 		{
@@ -162,6 +293,60 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 				data: []byte(`{"type":"org.osbuild.keymap","options":{"keymap":""}}`),
 			},
 		},
+		{
+			name: "keymap-x11-keymap",
+			fields: fields{
+				Type: "org.osbuild.keymap",
+				Options: &KeymapStageOptions{
+					Keymap: "us",
+					X11Keymap: &X11KeymapOptions{
+						Layouts: []string{"cz"},
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.keymap","options":{"keymap":"us","x11-keymap":{"layouts":["cz"]}}}`),
+			},
+		},
+		{
+			name: "modprobe",
+			fields: fields{
+				Type:    "org.osbuild.modprobe",
+				Options: &ModprobeStageOptions{},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.modprobe","options":{}}`),
+			},
+		},
+		{
+			name: "modprobe-data",
+			fields: fields{
+				Type: "org.osbuild.modprobe",
+				Options: &ModprobeStageOptions{
+					ConfigFiles: map[string]ModprobeConfigCmdList{
+						"disallow-modules.conf": {
+							&ModprobeConfigCmdBlacklist{
+								Command:    "blacklist",
+								Modulename: "nouveau",
+							},
+							&ModprobeConfigCmdBlacklist{
+								Command:    "blacklist",
+								Modulename: "floppy",
+							},
+						},
+						"disallow-additional-modules.conf": {
+							&ModprobeConfigCmdBlacklist{
+								Command:    "blacklist",
+								Modulename: "my-module",
+							},
+						},
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.modprobe","options":{"configuration_files":{"disallow-additional-modules.conf":[{"command":"blacklist","modulename":"my-module"}],"disallow-modules.conf":[{"command":"blacklist","modulename":"nouveau"},{"command":"blacklist","modulename":"floppy"}]}}}`),
+			},
+		},
 		{
 			name: "locale",
 			fields: fields{
@@ -195,10 +380,18 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 							Enabled: false,
 						},
 					},
+					SubMan: &RHSMStageOptionsSubMan{
+						Rhsm: &SubManConfigRHSMSection{
+							ManageRepos: common.BoolToPtr(false),
+						},
+						Rhsmcertd: &SubManConfigRHSMCERTDSection{
+							AutoRegistration: common.BoolToPtr(true),
+						},
+					},
 				},
 			},
 			args: args{
-				data: []byte(`{"type":"org.osbuild.rhsm","options":{"dnf-plugins":{"product-id":{"enabled":false},"subscription-manager":{"enabled":false}}}}`),
+				data: []byte(`{"type":"org.osbuild.rhsm","options":{"dnf-plugins":{"product-id":{"enabled":false},"subscription-manager":{"enabled":false}},"subscription-manager":{"rhsm":{"manage_repos":false},"rhsmcertd":{"auto_registration":true}}}}`),
 			},
 		},
 		{
@@ -221,6 +414,57 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 				data: []byte(`{"type":"org.osbuild.selinux","options":{"file_contexts":""}}`),
 			},
 		},
+		{
+			name: "sysconfig",
+			fields: fields{
+				Type:    "org.osbuild.sysconfig",
+				Options: &SysconfigStageOptions{},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.sysconfig","options":{"kernel":{},"network":{}}}`),
+			},
+		},
+		{
+			name: "sysconfig-data",
+			fields: fields{
+				Type: "org.osbuild.sysconfig",
+				Options: &SysconfigStageOptions{
+					Kernel: SysconfigKernelOptions{
+						UpdateDefault: true,
+						DefaultKernel: "kernel",
+					},
+					Network: SysconfigNetworkOptions{
+						Networking: true,
+						NoZeroConf: true,
+					},
+					NetworkScripts: &NetworkScriptsOptions{
+						IfcfgFiles: map[string]IfcfgFile{
+							"eth0": {
+								Device:    "eth0",
+								Bootproto: IfcfgBootprotoDHCP,
+								OnBoot:    common.BoolToPtr(true),
+								Type:      IfcfgTypeEthernet,
+								UserCtl:   common.BoolToPtr(true),
+								PeerDNS:   common.BoolToPtr(true),
+								IPv6Init:  common.BoolToPtr(false),
+							},
+							"eth1": {
+								Device:    "eth1",
+								Bootproto: IfcfgBootprotoDHCP,
+								OnBoot:    common.BoolToPtr(true),
+								Type:      IfcfgTypeEthernet,
+								UserCtl:   common.BoolToPtr(false),
+								PeerDNS:   common.BoolToPtr(true),
+								IPv6Init:  common.BoolToPtr(true),
+							},
+						},
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.sysconfig","options":{"kernel":{"update_default":true,"default_kernel":"kernel"},"network":{"networking":true,"no_zero_conf":true},"network-scripts":{"ifcfg":{"eth0":{"bootproto":"dhcp","device":"eth0","ipv6init":false,"onboot":true,"peerdns":true,"type":"Ethernet","userctl":true},"eth1":{"bootproto":"dhcp","device":"eth1","ipv6init":true,"onboot":true,"peerdns":true,"type":"Ethernet","userctl":false}}}}}`),
+			},
+		},
 		{
 			name: "systemd",
 			fields: fields{
@@ -243,6 +487,54 @@ func TestStage_UnmarshalJSON(t *testing.T) {
 				data: []byte(`{"type":"org.osbuild.systemd","options":{"enabled_services":["foo.service"]}}`),
 			},
 		},
+		{
+			name: "systemd-unit-dropins",
+			fields: fields{
+				Type: "org.osbuild.systemd",
+				Options: &SystemdStageOptions{
+					UnitDropins: map[string]SystemdServiceUnitDropins{
+						"nm-cloud-setup.service": {
+							"10-rh-enable-for-ec2.conf": {
+								Service: &SystemdUnitServiceSection{
+									Environment: "NM_CLOUD_SETUP_EC2=yes",
+								},
+							},
+						},
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.systemd","options":{"unit_dropins":{"nm-cloud-setup.service":{"10-rh-enable-for-ec2.conf":{"Service":{"Environment":"NM_CLOUD_SETUP_EC2=yes"}}}}}}`),
+			},
+		},
+		{
+			name: "systemd-logind",
+			fields: fields{
+				Type:    "org.osbuild.systemd-logind",
+				Options: &SystemdLogindStageOptions{},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.systemd-logind","options":{}}`),
+			},
+		},
+		{
+			name: "systemd-logind-data",
+			fields: fields{
+				Type: "org.osbuild.systemd-logind",
+				Options: &SystemdLogindStageOptions{
+					ConfigDropins: map[string]SystemdLogindConfigDropin{
+						"10-ec2-getty-fix.conf": {
+							Login: SystemdLogindConfigLoginSection{
+								NAutoVT: common.IntToPtr(0),
+							},
+						},
+					},
+				},
+			},
+			args: args{
+				data: []byte(`{"type":"org.osbuild.systemd-logind","options":{"configuration_dropins":{"10-ec2-getty-fix.conf":{"Login":{"NAutoVT":0}}}}}`),
+			},
+		},
 		{
 			name: "timezone",
 			fields: fields{
diff --git a/internal/osbuild2/systemd_logind_stage_test.go b/internal/osbuild2/systemd_logind_stage_test.go
new file mode 100644
index 000000000..a3be6fb38
--- /dev/null
+++ b/internal/osbuild2/systemd_logind_stage_test.go
@@ -0,0 +1,41 @@
+package osbuild2
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewSystemdLogindStage(t *testing.T) {
+	expectedStage := &Stage{
+		Type:    "org.osbuild.systemd-logind",
+		Options: &SystemdLogindStageOptions{},
+	}
+	actualStage := NewSystemdLogindStage(&SystemdLogindStageOptions{})
+	assert.Equal(t, expectedStage, actualStage)
+}
+
+func TestSystemdLogindStage_MarshalJSON_Invalid(t *testing.T) {
+	tests := []struct {
+		name    string
+		options SystemdLogindStageOptions
+	}{
+		{
+			name: "no-section-options",
+			options: SystemdLogindStageOptions{
+				ConfigDropins: map[string]SystemdLogindConfigDropin{
+					"10-ec2-getty-fix.conf": {
+						Login: SystemdLogindConfigLoginSection{},
+					},
+				},
+			},
+		},
+	}
+	for idx, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotBytes, err := json.Marshal(tt.options)
+			assert.NotNilf(t, err, "json.Marshal() didn't return an error, but: %s [idx: %d]", string(gotBytes), idx)
+		})
+	}
+}