Repository: osbuild/osbuild-composer
Pull request #2068 (2021-11-25 19:33:28)

Build a worker AMI using Schutzbot
This PR takes the latest commit from https://github.com/osbuild/image-builder-packer (private repository) and integrates it in osbuild-composer repository. The packer configuration is then adjusted in several commits. Finally, the last commit adds a new step to Schutzbot - building an AMI that contains a worker built by that same pipeline.

This allows us to retire the image-builder-packer while making its content fully public and open-source, and to iterate faster because updating the workers will no longer require the boring "*update image-builder-packer*" step.

This pull request includes:

- [ ] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] 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.

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`
-->


teg: packer can't find jq
ondrejbudai: About monit and vector: I would love to get rid of both of them at some point. I suggest just merging this firstly, and do any changes in a follow-up.

Ideally, we should split logging and metrics&alerting:

I hope that logging can be done using the official CloudWatch Agent but last time I tried, it wasn't possible to just send the whole journal. I will investigate the options though. Vector is cool but I agree that having no 3rd party software is very nice.

For metrics&alerting, I'm not sure yet, this is something that we need to discuss with AppSRE (how to get metrics from workers to Prometheus).
ondrejbudai: I had a minor issue in the playbook, it should be now fixed.

Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8d8b9e5d9..e1cc338b4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -83,6 +83,16 @@ Container:
       - RUNNER:
           - aws/fedora-33-x86_64
 
+Packer:
+  stage: test
+  extends: .terraform
+  rules:
+    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+  script:
+    - schutzbot/packer.sh
+  variables:
+    RUNNER: aws/centos-stream-8-x86_64
+
 Prepare-rhel-internal:
   stage: prepare-rhel-internal
   extends: .terraform
diff --git a/internal/kojiapi/server.go b/internal/kojiapi/server.go
index b26ccd8d8..d7fb582b4 100644
--- a/internal/kojiapi/server.go
+++ b/internal/kojiapi/server.go
@@ -400,7 +400,7 @@ func (h *apiHandlers) GetComposeIdLogs(ctx echo.Context, idstr string) error {
 		if _, _, err := h.getBuildJob(deps[i]); err != nil {
 			panic(err)
 		}
-		var buildResult worker.OSBuildJobResult
+		var buildResult worker.OSBuildKojiJobResult
 		_, _, err = h.server.workers.JobStatus(deps[i], &buildResult)
 		if err != nil {
 			// This is a programming error.
diff --git a/internal/osbuild2/cloud_init_stage.go b/internal/osbuild2/cloud_init_stage.go
index c12e4a015..5ac3be086 100644
--- a/internal/osbuild2/cloud_init_stage.go
+++ b/internal/osbuild2/cloud_init_stage.go
@@ -1,7 +1,6 @@
 package osbuild2
 
 import (
-	"encoding/json"
 	"fmt"
 )
 
@@ -13,6 +12,9 @@ type CloudInitStageOptions struct {
 func (CloudInitStageOptions) isStageOptions() {}
 
 func NewCloudInitStage(options *CloudInitStageOptions) *Stage {
+	if err := options.Config.validate(); err != nil {
+		panic(err)
+	}
 	return &Stage{
 		Type:    "org.osbuild.cloud-init",
 		Options: options,
@@ -21,18 +23,11 @@ func NewCloudInitStage(options *CloudInitStageOptions) *Stage {
 
 // Represents a cloud-init configuration file
 type CloudInitConfigFile struct {
-	SystemInfo *CloudInitConfigSystemInfo `json:"system_info,omitempty"`
-}
-
-// Unexported alias for use in CloudInitConfigFile's MarshalJSON() to prevent recursion
-type cloudInitConfigFile CloudInitConfigFile
-
-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)
+	SystemInfo     *CloudInitConfigSystemInfo `json:"system_info,omitempty"`
+	Reporting      *CloudInitConfigReporting  `json:"reporting,omitempty"`
+	Datasource     *CloudInitConfigDatasource `json:"datasource,omitempty"`
+	DatasourceList []string                   `json:"datasource_list,omitempty"`
+	Output         *CloudInitConfigOutput     `json:"output,omitempty"`
 }
 
 // Represents the 'system_info' configuration section
@@ -40,15 +35,31 @@ type CloudInitConfigSystemInfo struct {
 	DefaultUser *CloudInitConfigDefaultUser `json:"default_user,omitempty"`
 }
 
-// Unexported alias for use in CloudInitConfigSystemInfo's MarshalJSON() to prevent recursion
-type cloudInitConfigSystemInfo CloudInitConfigSystemInfo
+// Represents the 'reporting' configuration section
+type CloudInitConfigReporting struct {
+	Logging   *CloudInitConfigReportingHandlers `json:"logging,omitempty"`
+	Telemetry *CloudInitConfigReportingHandlers `json:"telemetry,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)
+type CloudInitConfigReportingHandlers struct {
+	Type string `json:"type"`
+}
+
+// Represents the 'datasource' configuration section
+type CloudInitConfigDatasource struct {
+	Azure *CloudInitConfigDatasourceAzure `json:"Azure,omitempty"`
+}
+
+type CloudInitConfigDatasourceAzure struct {
+	ApplyNetworkConfig bool `json:"apply_network_config"`
+}
+
+// Represents the 'output' configuration section
+type CloudInitConfigOutput struct {
+	Init   *string `json:"init,omitempty"`
+	Config *string `json:"config,omitempty"`
+	Final  *string `json:"final,omitempty"`
+	All    *string `json:"all,omitempty"`
 }
 
 // Configuration of the 'default' user created by cloud-init.
@@ -56,13 +67,92 @@ type CloudInitConfigDefaultUser struct {
 	Name string `json:"name,omitempty"`
 }
 
-// Unexported alias for use in CloudInitConfigDefaultUser's MarshalJSON() to prevent recursion
-type cloudInitConfigDefaultUser CloudInitConfigDefaultUser
+func (c CloudInitConfigFile) validate() error {
+	if c.SystemInfo == nil && c.Reporting == nil && c.Datasource == nil && len(c.DatasourceList) == 0 && c.Output == nil {
+		return fmt.Errorf("at least one cloud-init configuration option must be specified")
+	}
+	if c.SystemInfo != nil {
+		if err := c.SystemInfo.validate(); err != nil {
+			return err
+		}
+	}
+	if c.Reporting != nil {
+		if err := c.Reporting.validate(); err != nil {
+			return err
+		}
+	}
+	if c.Datasource != nil {
+		if err := c.Datasource.validate(); err != nil {
+			return err
+		}
+	}
+	if len(c.DatasourceList) > 0 {
+		for _, d := range c.DatasourceList {
+			if d != "Azure" {
+				return fmt.Errorf("only 'Azure' is allowed as an item in the datasource_list")
+			}
+		}
+	}
+	if c.Output != nil {
+		if err := c.Output.validate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (si CloudInitConfigSystemInfo) validate() error {
+	if si.DefaultUser == nil {
+		return fmt.Errorf("at least one configuration option must be specified for 'system_info' section")
+	} else {
+		return si.DefaultUser.validate()
+	}
+}
+
+func (r CloudInitConfigReporting) validate() error {
+	if r.Logging == nil && r.Telemetry == nil {
+		return fmt.Errorf("at least one configuration option must be specified for 'reporting' section")
+	}
+	if r.Logging != nil {
+		if err := r.Logging.validate(); err != nil {
+			return err
+		}
+	}
+	if r.Telemetry != nil {
+		if err := r.Telemetry.validate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r CloudInitConfigReportingHandlers) validate() error {
+	allowed_values := []string{"log", "print", "webhook", "hyperv"}
+	for _, v := range allowed_values {
+		if v == r.Type {
+			return nil
+		}
+	}
+	return fmt.Errorf("reporting parameters must be one of 'log', 'print', 'webhook', 'hyperv'")
+}
+
+func (d CloudInitConfigDatasource) validate() error {
+	if d.Azure == nil {
+		return fmt.Errorf("at least one configuration option must be specified for 'datasource' section")
+	}
+	return nil
+}
+
+func (o CloudInitConfigOutput) validate() error {
+	if o.Init == nil && o.Config == nil && o.Final == nil && o.All == nil {
+		return fmt.Errorf("at least one configuration option must be specified for 'output' section")
+	}
+	return nil
+}
 
-func (du CloudInitConfigDefaultUser) MarshalJSON() ([]byte, error) {
+func (du CloudInitConfigDefaultUser) validate() error {
 	if du.Name == "" {
-		return nil, fmt.Errorf("at least one configuration option must be specified for 'default_user' section")
+		return fmt.Errorf("at least one configuration option must be specified for 'default_user' section")
 	}
-	defaultUser := cloudInitConfigDefaultUser(du)
-	return json.Marshal(defaultUser)
+	return nil
 }
diff --git a/schutzbot/packer.sh b/schutzbot/packer.sh
new file mode 100755
index 000000000..054c79e38
--- /dev/null
+++ b/schutzbot/packer.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+set -euo pipefail
+
+# Colorful output.
+function greenprint {
+  echo -e "\033[1;32m[$(date -Isecond)] ${1}\033[0m"
+}
+
+greenprint "📦 Installing epel"
+sudo dnf install -y epel-release
+
+greenprint "📦 Installing hashicorp repo"
+sudo curl --location --output /etc/yum.repos.d/hashicorp.repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
+
+greenprint "📦 Installing packages needed for this script"
+sudo dnf install -y packer ansible jq
+
+greenprint "🖼️ Building the image"
+
+export PKR_VAR_aws_access_key="$PACKER_AWS_ACCESS_KEY_ID"
+export PKR_VAR_aws_secret_key="$PACKER_AWS_SECRET_ACCESS_KEY"
+export PKR_VAR_image_name="osbuild-composer-worker-$CI_COMMIT_BRANCH-$CI_COMMIT_SHA"
+export PKR_VAR_composer_commit="$CI_COMMIT_SHA"
+
+# use osbuild commit from Schutzfile
+PKR_VAR_osbuild_commit=$(jq -r '.["rhel-8.4"].dependencies.osbuild.commit' Schutzfile)
+export PKR_VAR_osbuild_commit
+
+packer build templates/packer
diff --git a/templates/packer/ansible/playbook.yml b/templates/packer/ansible/playbook.yml
new file mode 100644
index 000000000..47d649c5a
--- /dev/null
+++ b/templates/packer/ansible/playbook.yml
@@ -0,0 +1,7 @@
+# Ansible playbook for configuring our AWS images.
+---
+
+- hosts: all
+  become: yes
+  roles:
+    - common
diff --git a/templates/packer/ansible/roles/common/files/monit_scripts/pozorbot_alert b/templates/packer/ansible/roles/common/files/monit_scripts/pozorbot_alert
new file mode 100644
index 000000000..87267ef03
--- /dev/null
+++ b/templates/packer/ansible/roles/common/files/monit_scripts/pozorbot_alert
@@ -0,0 +1,18 @@
+#!/bin/bash
+# Send alerts to pozorbot.
+# Monit's environment variables are documented here:
+# https://mmonit.com/monit/documentation/monit.html#ENVIRONMENT
+
+# Get AWS instance profile name.
+INSTANCE_PROFILE=$(curl -Ls http://169.254.169.254/latest/meta-data/iam/security-credentials)
+
+# Determine if we should use staging/stable SQS endpoint.
+SQS_ENDPOINT=staging
+if [[ $INSTANCE_PROFILE =~ stable ]]; then
+  SQS_ENDPOINT=stable
+fi
+
+QUEUE_URL=https://queue.amazonaws.com/933752197999/image-builder-pozorbot-${SQS_ENDPOINT}
+MESSAGE="${MONIT_EVENT} for ${MONIT_SERVICE} on ${MONIT_HOST} at ${MONIT_DATE}"
+RESULT=$(aws sqs send-message --queue-url $QUEUE_URL --message-body "${MESSAGE}")
+echo $RESULT
diff --git a/templates/packer/ansible/roles/common/files/timber-vector.repo b/templates/packer/ansible/roles/common/files/timber-vector.repo
new file mode 100644
index 000000000..24ee7318c
--- /dev/null
+++ b/templates/packer/ansible/roles/common/files/timber-vector.repo
@@ -0,0 +1,41 @@
+[timber-vector]
+name=timber-vector
+baseurl=https://repositories.timber.io/public/vector/rpm/el/8/$basearch
+repo_gpgcheck=1
+enabled=1
+gpgkey=https://repositories.timber.io/public/vector/gpg.3543DB2D0A2BC4B8.key
+gpgcheck=1
+sslverify=1
+sslcacert=/etc/pki/tls/certs/ca-bundle.crt
+metadata_expire=300
+pkg_gpgcheck=1
+autorefresh=1
+type=rpm-md
+
+[timber-vector-noarch]
+name=timber-vector-noarch
+baseurl=https://repositories.timber.io/public/vector/rpm/el/8/noarch
+repo_gpgcheck=1
+enabled=1
+gpgkey=https://repositories.timber.io/public/vector/gpg.3543DB2D0A2BC4B8.key
+gpgcheck=1
+sslverify=1
+sslcacert=/etc/pki/tls/certs/ca-bundle.crt
+metadata_expire=300
+pkg_gpgcheck=1
+autorefresh=1
+type=rpm-md
+
+[timber-vector-source]
+name=timber-vector-source
+baseurl=https://repositories.timber.io/public/vector/rpm/el/8/SRPMS
+repo_gpgcheck=1
+enabled=1
+gpgkey=https://repositories.timber.io/public/vector/gpg.3543DB2D0A2BC4B8.key
+gpgcheck=1
+sslverify=1
+sslcacert=/etc/pki/tls/certs/ca-bundle.crt
+metadata_expire=300
+pkg_gpgcheck=1
+autorefresh=1
+type=rpm-md
diff --git a/templates/packer/ansible/roles/common/tasks/main.yml b/templates/packer/ansible/roles/common/tasks/main.yml
new file mode 100644
index 000000000..40e729a05
--- /dev/null
+++ b/templates/packer/ansible/roles/common/tasks/main.yml
@@ -0,0 +1,10 @@
+---
+
+# Install various software packages.
+- include_tasks: packages.yml
+
+# Configure monitoring.
+- include_tasks: monitoring.yml
+
+- name: Ensure SELinux contexts are updated
+  command: restorecon -Rv /etc
diff --git a/templates/packer/ansible/roles/common/tasks/monitoring.yml b/templates/packer/ansible/roles/common/tasks/monitoring.yml
new file mode 100644
index 000000000..397414cc6
--- /dev/null
+++ b/templates/packer/ansible/roles/common/tasks/monitoring.yml
@@ -0,0 +1,22 @@
+---
+
+- name: Deploy monit configuration
+  template:
+    src: monitrc.j2
+    dest: /etc/monitrc
+    mode: "0600"
+    owner: root
+    group: root
+
+- name: Create directory for monit scripts
+  file:
+    path: /opt/monit
+    state: directory
+
+- name: Deploy monit scripts
+  copy:
+    src: monit_scripts/pozorbot_alert
+    dest: /opt/monit/
+    mode: "0755"
+    owner: root
+    group: root
diff --git a/templates/packer/ansible/roles/common/tasks/packages.yml b/templates/packer/ansible/roles/common/tasks/packages.yml
new file mode 100644
index 000000000..5893e649b
--- /dev/null
+++ b/templates/packer/ansible/roles/common/tasks/packages.yml
@@ -0,0 +1,76 @@
+---
+
+- name: Add osbuild-composer repository
+  yum_repository:
+    name: "composer"
+    description: "osbuild-composer commit {{ COMPOSER_COMMIT }}"
+    baseurl: "http://osbuild-composer-repos.s3.amazonaws.com/osbuild-composer/rhel-8-cdn/x86_64/{{ COMPOSER_COMMIT }}"
+    enabled: yes
+    gpgcheck: no
+    priority: "5"
+
+- name: Add osbuild repository
+  yum_repository:
+    name: "osbuild"
+    description: "osbuild commit {{ OSBUILD_COMMIT }}"
+    baseurl: "http://osbuild-composer-repos.s3-website.us-east-2.amazonaws.com/osbuild/rhel-8-cdn/x86_64/{{ OSBUILD_COMMIT }}"
+    enabled: yes
+    gpgcheck: no
+    priority: "5"
+
+# We need EPEL for monit
+- name: Add EPEL
+  dnf:
+    name: https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm
+    state: present
+    disable_gpg_check: yes
+
+- name: Upgrade all packages
+  package:
+    name: "*"
+    state: latest
+  register: result
+  retries: 5
+  until: result is success
+
+- name: Add Vector repo
+  copy:
+    src: "{{ playbook_dir }}/roles/common/files/timber-vector.repo"
+    dest: /etc/yum.repos.d/
+
+- name: Install required packages
+  package:
+    name:
+      - jq
+      - osbuild-composer-worker
+      - unzip
+      - monit
+      - vector
+  register: result
+  retries: 5
+  until: result is success
+
+- name: Download AWS CLI installer
+  get_url:
+    url: https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip
+    dest: /tmp/awscli.zip
+  register: result
+  retries: 5
+  until: result is success
+
+- name: Unpack AWS CLI installer
+  unarchive:
+    src: /tmp/awscli.zip
+    dest: /tmp
+    remote_src: yes
+
+- name: Run AWS installer
+  command: /tmp/aws/install
+
+- name: Cleanup AWS installer
+  file:
+    path: "{{ item }}"
+    state: absent
+  loop:
+    - /tmp/awscli.zip
+    - /tmp/aws
diff --git a/templates/packer/ansible/roles/common/templates/monitrc.j2 b/templates/packer/ansible/roles/common/templates/monitrc.j2
new file mode 100644
index 000000000..926436076
--- /dev/null
+++ b/templates/packer/ansible/roles/common/templates/monitrc.j2
@@ -0,0 +1,50 @@
+{% set pozorbot_script = "/opt/monit/pozorbot_alert" -%}
+# Monit configuration for Image Builder in AWS.
+# Docs: https://mmonit.com/monit/documentation/monit.html
+
+# Check every 30 seconds and log to syslog.
+set daemon 30
+set log syslog
+
+# Allow access via ssh tunnel to see the monit console.
+set httpd port 2812 and
+    use address localhost
+    allow admin:monit
+
+# Verify that we're not eating all the memory on the instance.
+CHECK SYSTEM $HOST
+  if memory usage > 75%
+    for 5 cycles
+    then exec {{ pozorbot_script }}
+    else if succeeded then exec {{ pozorbot_script }}
+
+# Ensure the root filesystem isn't full.
+CHECK FILESYSTEM root PATH /
+  if space usage > 80%
+    for 5 times
+    within 15 cycles
+    then exec {{ pozorbot_script }}
+    else if succeeded then exec {{ pozorbot_script }}
+
+# Ensure the osbuild-composer filesystem isn't full.
+CHECK FILESYSTEM composer_persistent PATH /var/lib/osbuild-composer
+  if space usage > 80%
+    for 5 times
+    within 15 cycles
+    then exec {{ pozorbot_script }}
+    else if succeeded then exec {{ pozorbot_script }}
+
+# Check to see if we can reach cdn.redhat.com.
+# NOTE(mhayden): We will always get a 403 here because of client certs.
+CHECK HOST rhel_cdn WITH ADDRESS cdn.redhat.com
+  if failed
+    ping
+    then exec {{ pozorbot_script }}
+    else if succeeded then exec {{ pozorbot_script }}
+  if failed
+    port 443
+    protocol https
+    status = 403
+    with ssl options { CACERTIFICATEFILE: /etc/rhsm/ca/redhat-uep.pem }
+    then exec {{ pozorbot_script }}
+    else if succeeded then exec {{ pozorbot_script }}
diff --git a/templates/packer/composer.pkr.hcl b/templates/packer/composer.pkr.hcl
new file mode 100644
index 000000000..071469f67
--- /dev/null
+++ b/templates/packer/composer.pkr.hcl
@@ -0,0 +1,58 @@
+source "amazon-ebs" "image_builder" {
+
+  # AWS settings.
+  access_key = var.aws_access_key
+  secret_key = var.aws_secret_key
+  region = var.region
+
+  # Use a static RHEL Cloud Access Image.
+  source_ami          = "ami-0b0af3577fe5e3532"
+
+  # Remove previous image before making the new one.
+  force_deregister = true
+  force_delete_snapshot = true
+
+  # Ensure that the EBS snapshot used for the AMI meets our requirements.
+  launch_block_device_mappings {
+    delete_on_termination = "true"
+    device_name           = "/dev/sda1"
+    volume_size           = 25
+    volume_type           = "gp2"
+  }
+
+  # Apply tags to the instance that is building our image.
+  run_tags = {
+    AppCode = "IMGB-001"
+    Name = "packer-builder-for-${var.image_name}"
+  }
+
+  # Apply tags to the resulting AMI/EBS snapshot.
+  tags = {
+    AppCode = "IMGB-001"
+    Name = "${var.image_name}"
+    composer_commit = "${var.composer_commit}"
+    osbuild_commit = "${var.osbuild_commit}"
+  }
+
+  # Set a name for the resulting AMI.
+  ami_name = "${var.image_name}"
+
+  # Network configuration for the instance building our image.
+  associate_public_ip_address = true
+  security_group_ids = ["sg-04bbbb35"]
+  ssh_interface = "public_ip"
+  ssh_username = "ec2-user"
+  instance_type = "c5.large"
+}
+
+build {
+  sources = ["source.amazon-ebs.image_builder"]
+
+  provisioner "ansible" {
+    playbook_file = "${path.root}/ansible/playbook.yml"
+    extra_arguments = [
+      "-e", "COMPOSER_COMMIT=${var.composer_commit}",
+      "-e", "OSBUILD_COMMIT=${var.osbuild_commit}",
+    ]
+  }
+}
diff --git a/templates/packer/variables.pkr.hcl b/templates/packer/variables.pkr.hcl
new file mode 100644
index 000000000..78b3177c4
--- /dev/null
+++ b/templates/packer/variables.pkr.hcl
@@ -0,0 +1,17 @@
+# You need to specify all the following variables (except for region)
+# when running `packer build`
+
+# AWS account configuration.
+variable "aws_access_key" { type = string }
+variable "aws_secret_key" { type = string }
+variable "region" {
+  type    = string
+  default = "us-east-1"
+}
+
+# Automatically set by environment variables in GitHub Actions.
+variable "composer_commit" { type = string }
+variable "osbuild_commit" { type = string }
+
+# The name of the resulting AMI and the underlying EBS snapshot
+variable "image_name" { type = string }


Test patch:
diff --git a/internal/osbuild2/cloud_init_stage_test.go b/internal/osbuild2/cloud_init_stage_test.go
index 93d2e438f..6eada673b 100644
--- a/internal/osbuild2/cloud_init_stage_test.go
+++ b/internal/osbuild2/cloud_init_stage_test.go
@@ -2,21 +2,41 @@ package osbuild2
 
 import (
 	"encoding/json"
+	"reflect"
 	"testing"
 
+	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestNewCloudInitStage(t *testing.T) {
 	expectedStage := &Stage{
-		Type:    "org.osbuild.cloud-init",
-		Options: &CloudInitStageOptions{},
+		Type: "org.osbuild.cloud-init",
+		Options: &CloudInitStageOptions{
+			Filename: "aaa",
+			Config: CloudInitConfigFile{
+				SystemInfo: &CloudInitConfigSystemInfo{
+					DefaultUser: &CloudInitConfigDefaultUser{
+						Name: "foo",
+					},
+				},
+			},
+		},
 	}
-	actualStage := NewCloudInitStage(&CloudInitStageOptions{})
+	actualStage := NewCloudInitStage(&CloudInitStageOptions{
+		Filename: "aaa",
+		Config: CloudInitConfigFile{
+			SystemInfo: &CloudInitConfigSystemInfo{
+				DefaultUser: &CloudInitConfigDefaultUser{
+					Name: "foo",
+				},
+			},
+		},
+	})
 	assert.Equal(t, expectedStage, actualStage)
 }
 
-func TestCloudInitStage_MarshalJSON_Invalid(t *testing.T) {
+func TestCloudInitStage_NewStage_Invalid(t *testing.T) {
 	tests := []struct {
 		name    string
 		options CloudInitStageOptions
@@ -54,9 +74,142 @@ func TestCloudInitStage_MarshalJSON_Invalid(t *testing.T) {
 		},
 	}
 	for idx, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Panics(t, func() { NewCloudInitStage(&tt.options) }, "NewCloudInitStage didn't panic, but it should [idx: %d]", idx)
+		})
+	}
+}
+
+func TestCloudInitStage_MarshalJSON(t *testing.T) {
+	tests := []struct {
+		name    string
+		options CloudInitStageOptions
+		json    string
+	}{
+		{
+			name: "simple-cloud-init-config-with-system-info",
+			options: CloudInitStageOptions{
+				Config: CloudInitConfigFile{
+					SystemInfo: &CloudInitConfigSystemInfo{
+						DefaultUser: &CloudInitConfigDefaultUser{
+							Name: "foo",
+						},
+					},
+				},
+			},
+			json: `{"filename":"","config":{"system_info":{"default_user":{"name":"foo"}}}}`,
+		},
+	}
+	for _, 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)
+			assert.NoError(t, err)
+			assert.Equal(t, tt.json, string(gotBytes))
+		})
+	}
+}
+
+func TestCloudInitStage_UnmarshalJSON(t *testing.T) {
+	tests := []struct {
+		name    string
+		options CloudInitStageOptions
+		json    string
+	}{
+		{
+			name: "simple-cloud-init-config-with-system-info",
+			options: CloudInitStageOptions{
+				Config: CloudInitConfigFile{
+					SystemInfo: &CloudInitConfigSystemInfo{
+						DefaultUser: &CloudInitConfigDefaultUser{
+							Name: "foo",
+						},
+					},
+				},
+			},
+			json: `{"filename":"","config":{"system_info":{"default_user":{"name":"foo"}}}}`,
+		},
+		{
+			name: "osbuild-test-suite-1",
+			options: CloudInitStageOptions{
+				Filename: "10-azure-kfp.cfg",
+				Config: CloudInitConfigFile{
+					Reporting: &CloudInitConfigReporting{
+						Logging: &CloudInitConfigReportingHandlers{
+							Type: "log",
+						},
+						Telemetry: &CloudInitConfigReportingHandlers{
+							Type: "hyperv",
+						},
+					},
+				},
+			},
+			json: `{
+				"filename": "10-azure-kfp.cfg",
+				"config": {
+				  "reporting": {
+					"logging": {
+					  "type": "log"
+					},
+					"telemetry": {
+					  "type": "hyperv"
+					}
+				  }
+				}
+			  }`,
+		},
+		{
+			name: "osbuild-test-suite-2",
+			options: CloudInitStageOptions{
+				Filename: "91-azure_datasource.cfg",
+				Config: CloudInitConfigFile{
+					DatasourceList: []string{"Azure"},
+					Datasource: &CloudInitConfigDatasource{
+						Azure: &CloudInitConfigDatasourceAzure{
+							ApplyNetworkConfig: false,
+						},
+					},
+				},
+			},
+			json: `{
+				"filename": "91-azure_datasource.cfg",
+				"config": {
+				  "datasource_list": [
+					"Azure"
+				  ],
+				  "datasource": {
+					"Azure": {
+					  "apply_network_config": false
+					}
+				  }
+				}
+			  }`,
+		},
+		{
+			name: "osbuild-test-suite-3",
+			options: CloudInitStageOptions{
+				Filename: "06_logging_override.cfg",
+				Config: CloudInitConfigFile{
+					Output: &CloudInitConfigOutput{
+						All: common.StringToPtr(">> /var/log/cloud-init-all.log"),
+					},
+				},
+			},
+			json: `{
+				"filename": "06_logging_override.cfg",
+				"config": {
+				  "output": {
+					"all": ">> /var/log/cloud-init-all.log"
+				  }
+				}
+			  }`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var gotOptions CloudInitStageOptions
+			err := json.Unmarshal([]byte(tt.json), &gotOptions)
+			assert.NoError(t, err)
+			assert.True(t, reflect.DeepEqual(tt.options, gotOptions))
 		})
 	}
 }
diff --git a/test/README.md b/test/README.md
index 6a374a3c4..31c92d5a5 100644
--- a/test/README.md
+++ b/test/README.md
@@ -74,6 +74,8 @@ the same architecture, as the one intended for the generated test
 cases. In other words, you need to generate e.g test cases for `aarch64`
 images on an `aarch64` host.
 
+These tests are executed from the `osbuild` repository in GitHub CI.
+
 **Important Note:** `image-info` by default won't be able to read SELinux
 labels used in the image, which are unknown to the host's policy. If you are
 generating the image test case using
@@ -395,16 +397,17 @@ is is missing lots of details!
 - [@jrusz](https://github.com/jrusz) - testing across the board; backup for @atodorov;
   osbuild, osbuild-composer, weldr-client, composer-cli, image-builder, image-builder-frontend,
   bug testing & verification, anything else
+- [@jabia99](https://github.com/jabia99) - focus on image-builder and image-builder-frontend;
+  anything else; backup for @jrusz
 - [@henrywang](https://github.com/henrywang) - everything related to testing RHEL for Edge images
 - [@yih-redhat](https://github.com/yih-redhat) - everything related to testing RHEL for Edge images;
   backup for @henrywang
-- [@yuxisun1217](https://github.com/yuxisun1217) - QE for testing image-builder and image-builder-frontend
 
 
 ## Internal build testing in Gitlab CI
 
 In Gitlab CI we're using rules to define which stages and jobs are ran when. For running the test
-suite on internal rhel builds we're using schedules that run the test suite on latest nightly using
+suite on internal RHEL builds we're using schedules that run the test suite on latest nightly using
 the rpms that are shipped with it.
 
 We're using a link to latest nightly build. It's possible to manually override it with `COMPOSE_URL`