Repository: osbuild/osbuild-composer
Pull request #4047 (2024-03-28 14:46:34)

main: journald support with external correlation
This patch adds native journald support through logrus which enables to work with structured logs. It uses a dependency that is already in the project (`coreos/systemd-go`) which is a pure Go implementation. New log type is introduced named "journal" and it is now the default with fallback to "text" if journald is not present on the system.

Structured logging enabled me to work with logrus fields that can help with correlating log records on standalone or hosted deployments. Already existing "operation_id" field (random string) is now fully propagated via echo context logger and new logrus hook extracts it from Go standard context.

A new field called "external_id" is extracted from HTTP header `X-External-Id` when present. The idea is to set this to X-Edge-Request-Id on hosted image builder so logs can be easily correlated across both services.

Additionally, each cloud API request is now logged using the built-in echo logging middleware which creates entries like `Processed request GET /path`. Together with journald this can be tested end-to-end:

```
# curl -H "X-External-Id: XXXX" --unix-socket /run/cloudapi/api.socket -XGET http://localhost/api/image-builder-composer/v2/openapi
```

This will create the following entries:

```
# journalctl -e -o json-pretty --output-fields=OPERATION_ID,MESSAGE,EXTERNAL_ID
{
        "OPERATION_ID" : "2eJwe1mdLa9s2rB3PVZbVAttQi1",
        "EXTERNAL_ID" : "XXXX",
        "MESSAGE" : "Returning OpenAPI document"
}
{
        "MESSAGE" : "Processed request GET /api/image-builder-composer/v2/openapi",
        "EXTERNAL_ID" : "XXXX",
        "OPERATION_ID" : "2eJwe1mdLa9s2rB3PVZbVAttQi1",
}
```

Users can easily dig logs per request or operation:

```
# journalctl -u osbuild-composer OPERATION_ID=2eJwe1mdLa9s2rB3PVZbVAttQi1 -o cat
Returning OpenAPI document
Processed request GET /api/image-builder-composer/v2/openapi
```

TODO

- [ ] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] submit a PR for the READMEs [listed here](https://github.com/osbuild/osbuild.github.io/blob/main/readme-list)
  - [ ] submit a PR for the [osbuild.org website](https://github.com/osbuild/osbuild.github.io/) repository if this PR changed any behavior not covered by the automatically updated READMEs



lzap: Moved all comments into the codebase, broken into three commits:

* Documentation and gitignore update
* Adding journald native support
* Propagating operation and external id through context to logs (this one is hard to break up into two as I had to do updates to existing operation id in order to efficiently implement external id)
achilleas-k: @lzap We don't do merge commits in these repos.  Can you drop the merge commit and rebase instead?
lzap: Damn that was a misclick, amended a rebase.
achilleas-k: > and then an AWS test which I don’t understand why fails

`iot-bootable-container` failed with
```
dial tcp: lookup cdn03.quay.io: no such host
```
which looks like a plain old network hiccup.  Annoying.
lzap: @ezr-ondrej please do not rebase, @achilleas-k plans to only restart failed tests. is that right? thanks.
achilleas-k: > @ezr-ondrej please do not rebase, @achilleas-k plans to only restart failed tests. is that right? thanks.

It's a tricky decision.  It's good to rebase when there's a lot of changes in main and there's a chance the rebase might break things, but OTOH the CI runtimes are long and annoying.

A merge queue would be great but that doesn't work with flaky testing.
ezr-ondrej: Yeah, sorry! I've got new buttons in Gitlab now, I'll push those instead xD
lzap: Only schulzbot is red, seems only 8.4 is red, likely not caused by this PR.
achilleas-k: There's a known issue with that test but I thought we fixed it already.
lzap: Rebased.
ezr-ondrej: The main pipeline is green!! Woooot!
lzap: Well bunch of tests failed, someone help me to understand if these are relevant. These logs are hard to read.
achilleas-k: Since all the edge tests are failing but the main gitlab CI pipeline succeeded, it might something with the edge tests themselves or something in the PR changed something the tests rely on.

I'm seeing at least one instance of the issue described in #4094
github-actions[bot]: This PR is stale because it has been open 30 days with no activity. Remove "Stale" label or comment or this will be closed in 7 days.
github-actions[bot]: This PR is stale because it has been open 30 days with no activity. Remove "Stale" label or comment or this will be closed in 7 days.
lzap: Just `rpm-build:centos-stream-10-ppc64le` failed

Patch:
diff --git a/.gitignore b/.gitignore
index cb05d4f42..a899a7e1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@ __pycache__
 /containers/config/
 /bin
 /rpmbuild
+/osbuild-composer
+/osbuild-worker
 
 /tools/appsre-ansible/inventory
 
diff --git a/HACKING.md b/HACKING.md
index 9c33e4034..34d72dd7c 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -6,6 +6,8 @@
 onto a system. We recommend doing this by building rpms, with:
 
 ```
+dnf install fedora-packager
+dnf builddep osbuild-composer
 make rpm
 ```
 
@@ -94,4 +96,22 @@ To rebuild the containers after a change, add the `--build` flag to the `docker-
 
 ```
 docker-compose up --build
-```
\ No newline at end of file
+```
+
+## Shortening the loop
+
+For some components, it is possible to install distribution packages first and then only to replace binaries which may or may not work for smaller changes.
+
+```
+systemctl stop osbuild-composer.service osbuild-composer.socket osbuild-local-worker.socket
+make build && sudo install -m755 bin/osbuild-composer bin/osbuild-worker /usr/libexec/osbuild-composer/
+systemctl start osbuild-composer.socket osbuild-local-worker.socket
+```
+
+## Accessing Cloud API
+
+You can use curl to access the Cloud API:
+
+```
+curl --unix-socket /run/cloudapi/api.socket -XGET http://localhost/api/image-builder-composer/v2/openapi
+```
diff --git a/cmd/osbuild-composer/config.go b/cmd/osbuild-composer/config.go
index 96fe16360..176eed508 100644
--- a/cmd/osbuild-composer/config.go
+++ b/cmd/osbuild-composer/config.go
@@ -129,7 +129,7 @@ func GetDefaultConfig() *ComposerConfigFile {
 			"rhel-10": "rhel-10.0",
 		},
 		LogLevel:  "info",
-		LogFormat: "text",
+		LogFormat: "journal",
 		DNFJson:   "/usr/libexec/osbuild-depsolve-dnf",
 	}
 }
diff --git a/cmd/osbuild-composer/main.go b/cmd/osbuild-composer/main.go
index 3c1eba7e9..3bd6dd600 100644
--- a/cmd/osbuild-composer/main.go
+++ b/cmd/osbuild-composer/main.go
@@ -3,11 +3,15 @@ package main
 import (
 	"context"
 	"flag"
+	"io"
+	"log"
 	"os"
 
 	"github.com/coreos/go-systemd/activation"
+	"github.com/coreos/go-systemd/journal"
 	"github.com/getsentry/sentry-go"
 	sentrylogrus "github.com/getsentry/sentry-go/logrus"
+	"github.com/osbuild/osbuild-composer/internal/common"
 	slogger "github.com/osbuild/osbuild-composer/pkg/splunk_logger"
 	"github.com/sirupsen/logrus"
 )
@@ -28,6 +32,11 @@ func main() {
 	flag.BoolVar(&verbose, "verbose", false, "Print access log")
 	flag.Parse()
 
+	// Redirect Go standard logger into logrus before it's used by other packages
+	log.SetOutput(common.Logger())
+	// Ensure the Go standard logger does not have any prefix or timestamp
+	log.SetFlags(0)
+
 	if !verbose {
 		logrus.Print("verbose flag is provided for backward compatibility only, current behavior is always printing the access log")
 	}
@@ -41,6 +50,8 @@ func main() {
 	logLevel, err := logrus.ParseLevel(config.LogLevel)
 
 	logrus.SetReportCaller(true)
+	// Add context hook to log operation_id and external_id
+	logrus.AddHook(&common.ContextHook{})
 
 	if err == nil {
 		logrus.SetLevel(logLevel)
@@ -49,6 +60,16 @@ func main() {
 	}
 
 	switch config.LogFormat {
+	case "journal":
+		// If we are running under systemd, use the journal. Otherwise,
+		// fallback to text formatter.
+		if journal.Enabled() {
+			logrus.SetFormatter(&logrus.JSONFormatter{})
+			logrus.AddHook(&common.JournalHook{})
+			logrus.SetOutput(io.Discard)
+		} else {
+			logrus.SetFormatter(&logrus.TextFormatter{})
+		}
 	case "text":
 		logrus.SetFormatter(&logrus.TextFormatter{})
 	case "json":
diff --git a/cmd/osbuild-worker-executor/handler_build.go b/cmd/osbuild-worker-executor/handler_build.go
index 13edd9ab7..eed9b740d 100644
--- a/cmd/osbuild-worker-executor/handler_build.go
+++ b/cmd/osbuild-worker-executor/handler_build.go
@@ -13,7 +13,6 @@ import (
 	"strings"
 
 	"golang.org/x/exp/slices"
-	"golang.org/x/sys/unix"
 
 	"github.com/sirupsen/logrus"
 )
@@ -109,7 +108,6 @@ func runOsbuild(logger *logrus.Logger, buildDir string, control *controlJSON, ou
 type controlJSON struct {
 	Environments []string `json:"environments"`
 	Exports      []string `json:"exports"`
-	JobID        string   `json:"job-id"`
 }
 
 func mustRead(atar *tar.Reader, name string) error {
@@ -237,15 +235,6 @@ func handleIncludedSources(atar *tar.Reader, buildDir string) error {
 	}
 }
 
-var unixSethostname = unix.Sethostname
-
-func setHostname(name string) error {
-	if name == "" {
-		return nil
-	}
-	return unixSethostname([]byte(name))
-}
-
 // test for real via:
 // curl -o - --data-binary "@./test.tar" -H "Content-Type: application/x-tar"  -X POST http://localhost:8001/api/v1/build
 func handleBuild(logger *logrus.Logger, config *Config) http.Handler {
@@ -273,11 +262,6 @@ func handleBuild(logger *logrus.Logger, config *Config) http.Handler {
 				http.Error(w, "cannot decode control.json", http.StatusBadRequest)
 				return
 			}
-			if err := setHostname(control.JobID); err != nil {
-				logger.Error(err)
-				http.Error(w, "cannot set hostname", http.StatusBadRequest)
-				return
-			}
 
 			buildDir, err := createBuildDir(config)
 			if err != nil {
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index 98945de0b..eca86fb5b 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -515,7 +515,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			return err
 		}
 		defer os.RemoveAll(tmpDir)
-		executor = osbuildexecutor.NewAWSEC2Executor(impl.OSBuildExecutor.IAMProfile, impl.OSBuildExecutor.KeyName, impl.OSBuildExecutor.CloudWatchGroup, tmpDir)
+		executor = osbuildexecutor.NewAWSEC2Executor(impl.OSBuildExecutor.IAMProfile, impl.OSBuildExecutor.KeyName, impl.OSBuildExecutor.CloudWatchGroup, job.Id().String(), tmpDir)
 	default:
 		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, "No osbuild executor defined", nil)
 		return err
@@ -533,7 +533,6 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 		ExportPaths: exportPaths,
 		ExtraEnv:    extraEnv,
 		Result:      true,
-		JobID:       job.Id().String(),
 	}
 	osbuildJobResult.OSBuildOutput, err = executor.RunOSBuild(jobArgs.Manifest, opts, os.Stderr)
 	// First handle the case when "running" osbuild failed
diff --git a/internal/cloud/awscloud/secure-instance.go b/internal/cloud/awscloud/secure-instance.go
index 68c895659..7f2db55d2 100644
--- a/internal/cloud/awscloud/secure-instance.go
+++ b/internal/cloud/awscloud/secure-instance.go
@@ -19,14 +19,15 @@ type SecureInstance struct {
 }
 
 // SecureInstanceUserData returns the cloud-init user data for a secure instance.
-func SecureInstanceUserData(CloudWatchGroup string) string {
+func SecureInstanceUserData(cloudWatchGroup, hostname string) string {
 	additionalFiles := ""
 
-	if CloudWatchGroup != "" {
+	if cloudWatchGroup != "" {
 		additionalFiles += fmt.Sprintf(`  - path: /tmp/cloud_init_vars
     content: |
       OSBUILD_EXECUTOR_CLOUDWATCH_GROUP='%s'
-`, CloudWatchGroup)
+      OSBUILD_EXECUTOR_HOSTNAME='%s'
+`, cloudWatchGroup, hostname)
 	}
 
 	return fmt.Sprintf(`#cloud-config
@@ -38,7 +39,7 @@ write_files:
 
 // Runs an instance with a security group that only allows traffic to
 // the host. Will replace resources if they already exists.
-func (a *AWS) RunSecureInstance(iamProfile, keyName, CloudWatchGroup string) (*SecureInstance, error) {
+func (a *AWS) RunSecureInstance(iamProfile, keyName, cloudWatchGroup, hostname string) (*SecureInstance, error) {
 	identity, err := a.ec2metadata.GetInstanceIdentityDocument()
 	if err != nil {
 		logrus.Errorf("Error getting the identity document, %s", err)
@@ -79,7 +80,7 @@ func (a *AWS) RunSecureInstance(iamProfile, keyName, CloudWatchGroup string) (*S
 		return nil, err
 	}
 
-	ltID, err := a.createOrReplaceLT(identity.InstanceID, imageID, sgID, instanceType, iamProfile, keyName, CloudWatchGroup)
+	ltID, err := a.createOrReplaceLT(identity.InstanceID, imageID, sgID, instanceType, iamProfile, keyName, cloudWatchGroup, hostname)
 	if ltID != "" {
 		secureInstance.LTID = ltID
 	}
@@ -280,7 +281,7 @@ func isLaunchTemplateNotFoundError(err error) bool {
 
 }
 
-func (a *AWS) createOrReplaceLT(hostInstanceID, imageID, sgID, instanceType, iamProfile, keyName, CloudWatchGroup string) (string, error) {
+func (a *AWS) createOrReplaceLT(hostInstanceID, imageID, sgID, instanceType, iamProfile, keyName, cloudWatchGroup, hostname string) (string, error) {
 	ltName := fmt.Sprintf("launch-template-for-%s-runner-instance", hostInstanceID)
 	descrLTOutput, err := a.ec2.DescribeLaunchTemplates(&ec2.DescribeLaunchTemplatesInput{
 		LaunchTemplateNames: []*string{
@@ -333,7 +334,7 @@ func (a *AWS) createOrReplaceLT(hostInstanceID, imageID, sgID, instanceType, iam
 			SecurityGroupIds: []*string{
 				aws.String(sgID),
 			},
-			UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(SecureInstanceUserData(CloudWatchGroup)))),
+			UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(SecureInstanceUserData(cloudWatchGroup, hostname)))),
 		},
 		TagSpecifications: []*ec2.TagSpecification{
 			&ec2.TagSpecification{
diff --git a/internal/cloudapi/v2/server.go b/internal/cloudapi/v2/server.go
index 9a51d24ca..ac56719da 100644
--- a/internal/cloudapi/v2/server.go
+++ b/internal/cloudapi/v2/server.go
@@ -87,10 +87,37 @@ func (s *Server) Handler(path string) http.Handler {
 	e := echo.New()
 	e.Binder = binder{}
 	e.HTTPErrorHandler = s.HTTPErrorHandler
-	e.Pre(common.OperationIDMiddleware)
-	e.Use(middleware.Recover())
 	e.Logger = common.Logger()
 
+	// OperationIDMiddleware - generates OperationID random string and puts it into the contexts
+	// ExternalIDMiddleware - extracts ID from HTTP header and puts it into the contexts
+	// LoggerMiddleware - creates context-aware logger for each request
+	e.Pre(common.OperationIDMiddleware, common.ExternalIDMiddleware, common.LoggerMiddleware)
+	e.Use(middleware.Recover())
+
+	e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
+		LogURI:     true,
+		LogStatus:  true,
+		LogLatency: true,
+		LogMethod:  true,
+		LogValuesFunc: func(c echo.Context, values middleware.RequestLoggerValues) error {
+			fields := logrus.Fields{
+				"uri":          values.URI,
+				"method":       values.Method,
+				"status":       values.Status,
+				"latency_ms":   values.Latency.Milliseconds(),
+				"operation_id": c.Get(common.OperationIDKey),
+				"external_id":  c.Get(common.ExternalIDKey),
+			}
+			if values.Error != nil {
+				fields["error"] = values.Error
+			}
+			logrus.WithFields(fields).Infof("Processed request %s %s", values.Method, values.URI)
+
+			return nil
+		},
+	}))
+
 	if sentry.CurrentHub().Client() == nil {
 		logrus.Warn("Sentry/Glitchtip not initialized, echo middleware was not enabled")
 	} else {
diff --git a/internal/common/context_hook.go b/internal/common/context_hook.go
new file mode 100644
index 000000000..294b2867e
--- /dev/null
+++ b/internal/common/context_hook.go
@@ -0,0 +1,33 @@
+package common
+
+import (
+	"github.com/sirupsen/logrus"
+)
+
+type ContextHook struct{}
+
+func (h *ContextHook) Levels() []logrus.Level {
+	return []logrus.Level{
+		logrus.DebugLevel,
+		logrus.InfoLevel,
+		logrus.WarnLevel,
+		logrus.ErrorLevel,
+		logrus.FatalLevel,
+		logrus.PanicLevel,
+	}
+}
+
+func (h *ContextHook) Fire(e *logrus.Entry) error {
+	if e.Context == nil {
+		return nil
+	}
+
+	if val := e.Context.Value(operationIDKeyCtx); val != nil {
+		e.Data["operation_id"] = val
+	}
+	if val := e.Context.Value(externalIDKeyCtx); val != nil {
+		e.Data["external_id"] = val
+	}
+
+	return nil
+}
diff --git a/internal/common/echo_logrus.go b/internal/common/echo_logrus.go
index 6871f8713..198ce1731 100644
--- a/internal/common/echo_logrus.go
+++ b/internal/common/echo_logrus.go
@@ -1,39 +1,42 @@
 package common
 
 import (
+	"context"
 	"encoding/json"
 	"io"
 
-	"github.com/labstack/gommon/log"
+	lslog "github.com/labstack/gommon/log"
 	"github.com/sirupsen/logrus"
 )
 
 // EchoLogrusLogger extend logrus.Logger
 type EchoLogrusLogger struct {
 	*logrus.Logger
+	Ctx context.Context
 }
 
 var commonLogger = &EchoLogrusLogger{
 	Logger: logrus.StandardLogger(),
+	Ctx:    context.Background(),
 }
 
 func Logger() *EchoLogrusLogger {
 	return commonLogger
 }
 
-func toEchoLevel(level logrus.Level) log.Lvl {
+func toEchoLevel(level logrus.Level) lslog.Lvl {
 	switch level {
 	case logrus.DebugLevel:
-		return log.DEBUG
+		return lslog.DEBUG
 	case logrus.InfoLevel:
-		return log.INFO
+		return lslog.INFO
 	case logrus.WarnLevel:
-		return log.WARN
+		return lslog.WARN
 	case logrus.ErrorLevel:
-		return log.ERROR
+		return lslog.ERROR
 	}
 
-	return log.OFF
+	return lslog.OFF
 }
 
 func (l *EchoLogrusLogger) Output() io.Writer {
@@ -44,11 +47,11 @@ func (l *EchoLogrusLogger) SetOutput(w io.Writer) {
 	// disable operations that would change behavior of global logrus logger.
 }
 
-func (l *EchoLogrusLogger) Level() log.Lvl {
+func (l *EchoLogrusLogger) Level() lslog.Lvl {
 	return toEchoLevel(l.Logger.Level)
 }
 
-func (l *EchoLogrusLogger) SetLevel(v log.Lvl) {
+func (l *EchoLogrusLogger) SetLevel(v lslog.Lvl) {
 	// disable operations that would change behavior of global logrus logger.
 }
 
@@ -63,113 +66,117 @@ func (l *EchoLogrusLogger) SetPrefix(p string) {
 }
 
 func (l *EchoLogrusLogger) Print(i ...interface{}) {
-	l.Logger.Print(i...)
+	l.Logger.WithContext(l.Ctx).Print(i...)
 }
 
 func (l *EchoLogrusLogger) Printf(format string, args ...interface{}) {
-	l.Logger.Printf(format, args...)
+	l.Logger.WithContext(l.Ctx).Printf(format, args...)
 }
 
-func (l *EchoLogrusLogger) Printj(j log.JSON) {
+func (l *EchoLogrusLogger) Printj(j lslog.JSON) {
 	b, err := json.Marshal(j)
 	if err != nil {
 		panic(err)
 	}
-	l.Logger.Println(string(b))
+	l.Logger.WithContext(l.Ctx).Println(string(b))
 }
 
 func (l *EchoLogrusLogger) Debug(i ...interface{}) {
-	l.Logger.Debug(i...)
+	l.Logger.WithContext(l.Ctx).Debug(i...)
 }
 
 func (l *EchoLogrusLogger) Debugf(format string, args ...interface{}) {
-	l.Logger.Debugf(format, args...)
+	l.Logger.WithContext(l.Ctx).Debugf(format, args...)
 }
 
-func (l *EchoLogrusLogger) Debugj(j log.JSON) {
+func (l *EchoLogrusLogger) Debugj(j lslog.JSON) {
 	b, err := json.Marshal(j)
 	if err != nil {
 		panic(err)
 	}
-	l.Logger.Debugln(string(b))
+	l.Logger.WithContext(l.Ctx).Debugln(string(b))
 }
 
 func (l *EchoLogrusLogger) Info(i ...interface{}) {
-	l.Logger.Info(i...)
+	l.Logger.WithContext(l.Ctx).Info(i...)
 }
 
 func (l *EchoLogrusLogger) Infof(format string, args ...interface{}) {
-	l.Logger.Infof(format, args...)
+	l.Logger.WithContext(l.Ctx).Infof(format, args...)
 }
 
-func (l *EchoLogrusLogger) Infoj(j log.JSON) {
+func (l *EchoLogrusLogger) Infoj(j lslog.JSON) {
 	b, err := json.Marshal(j)
 	if err != nil {
 		panic(err)
 	}
-	l.Logger.Infoln(string(b))
+	l.Logger.WithContext(l.Ctx).Infoln(string(b))
 }
 
 func (l *EchoLogrusLogger) Warn(i ...interface{}) {
-	l.Logger.Warn(i...)
+	l.Logger.WithContext(l.Ctx).Warn(i...)
 }
 
 func (l *EchoLogrusLogger) Warnf(format string, args ...interface{}) {
-	l.Logger.Warnf(format, args...)
+	l.Logger.WithContext(l.Ctx).Warnf(format, args...)
 }
 
-func (l *EchoLogrusLogger) Warnj(j log.JSON) {
+func (l *EchoLogrusLogger) Warnj(j lslog.JSON) {
 	b, err := json.Marshal(j)
 	if err != nil {
 		panic(err)
 	}
-	l.Logger.Warnln(string(b))
+	l.Logger.WithContext(l.Ctx).Warnln(string(b))
 }
 
 func (l *EchoLogrusLogger) Error(i ...interface{}) {
-	l.Logger.Error(i...)
+	l.Logger.WithContext(l.Ctx).Error(i...)
 }
 
 func (l *EchoLogrusLogger) Errorf(format string, args ...interface{}) {
-	l.Logger.Errorf(format, args...)
+	l.Logger.WithContext(l.Ctx).Errorf(format, args...)
 }
 
-func (l *EchoLogrusLogger) Errorj(j log.JSON) {
+func (l *EchoLogrusLogger) Errorj(j lslog.JSON) {
 	b, err := json.Marshal(j)
 	if err != nil {
 		panic(err)
 	}
-	l.Logger.Errorln(string(b))
+	l.Logger.WithContext(l.Ctx).Errorln(string(b))
 }
 
 func (l *EchoLogrusLogger) Fatal(i ...interface{}) {
-	l.Logger.Fatal(i...)
+	l.Logger.WithContext(l.Ctx).Fatal(i...)
 }
 
 func (l *EchoLogrusLogger) Fatalf(format string, args ...interface{}) {
-	l.Logger.Fatalf(format, args...)
+	l.Logger.WithContext(l.Ctx).Fatalf(format, args...)
 }
 
-func (l *EchoLogrusLogger) Fatalj(j log.JSON) {
+func (l *EchoLogrusLogger) Fatalj(j lslog.JSON) {
 	b, err := json.Marshal(j)
 	if err != nil {
 		panic(err)
 	}
-	l.Logger.Fatalln(string(b))
+	l.Logger.WithContext(l.Ctx).Fatalln(string(b))
 }
 
 func (l *EchoLogrusLogger) Panic(i ...interface{}) {
-	l.Logger.Panic(i...)
+	l.Logger.WithContext(l.Ctx).Panic(i...)
 }
 
 func (l *EchoLogrusLogger) Panicf(format string, args ...interface{}) {
-	l.Logger.Panicf(format, args...)
+	l.Logger.WithContext(l.Ctx).Panicf(format, args...)
 }
 
-func (l *EchoLogrusLogger) Panicj(j log.JSON) {
+func (l *EchoLogrusLogger) Panicj(j lslog.JSON) {
 	b, err := json.Marshal(j)
 	if err != nil {
 		panic(err)
 	}
-	l.Logger.Panicln(string(b))
+	l.Logger.WithContext(l.Ctx).Panicln(string(b))
+}
+
+func (l *EchoLogrusLogger) Write(p []byte) (n int, err error) {
+	return l.Logger.WithContext(l.Ctx).Writer().Write(p)
 }
diff --git a/internal/common/external_id.go b/internal/common/external_id.go
new file mode 100644
index 000000000..04038129c
--- /dev/null
+++ b/internal/common/external_id.go
@@ -0,0 +1,30 @@
+package common
+
+import (
+	"context"
+	"strings"
+
+	"github.com/labstack/echo/v4"
+)
+
+const ExternalIDKey string = "externalID"
+const externalIDKeyCtx ctxKey = ctxKey(ExternalIDKey)
+
+// Extracts HTTP header X-External-Id and sets it as a request context value
+func ExternalIDMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		eid := c.Request().Header.Get("X-External-Id")
+		if eid == "" {
+			return next(c)
+		}
+
+		eid = strings.TrimSuffix(eid, "\n")
+		c.Set(ExternalIDKey, eid)
+
+		ctx := c.Request().Context()
+		ctx = context.WithValue(ctx, externalIDKeyCtx, eid)
+		c.SetRequest(c.Request().WithContext(ctx))
+
+		return next(c)
+	}
+}
diff --git a/internal/common/journal_hook.go b/internal/common/journal_hook.go
new file mode 100644
index 000000000..d676a8005
--- /dev/null
+++ b/internal/common/journal_hook.go
@@ -0,0 +1,70 @@
+// Inspired by github.com/wercker/journalhook (MIT license)
+package common
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/coreos/go-systemd/journal"
+	logrus "github.com/sirupsen/logrus"
+)
+
+type JournalHook struct{}
+
+var (
+	severityMap = map[logrus.Level]journal.Priority{
+		logrus.DebugLevel: journal.PriDebug,
+		logrus.InfoLevel:  journal.PriInfo,
+		logrus.WarnLevel:  journal.PriWarning,
+		logrus.ErrorLevel: journal.PriErr,
+		logrus.FatalLevel: journal.PriCrit,
+		logrus.PanicLevel: journal.PriEmerg,
+	}
+)
+
+func stringifyOp(r rune) rune {
+	switch {
+	case r >= 'A' && r <= 'Z':
+		return r
+	case r >= '0' && r <= '9':
+		return r
+	case r == '_':
+		return r
+	case r >= 'a' && r <= 'z':
+		return r - 32
+	default:
+		return rune('_')
+	}
+}
+
+func stringifyKey(key string) string {
+	key = strings.Map(stringifyOp, key)
+	key = strings.TrimPrefix(key, "_")
+	return key
+}
+
+// Journal wants strings but logrus takes anything.
+func stringifyEntries(data map[string]interface{}) map[string]string {
+	entries := make(map[string]string)
+	for k, v := range data {
+
+		key := stringifyKey(k)
+		entries[key] = fmt.Sprint(v)
+	}
+	return entries
+}
+
+func (hook *JournalHook) Fire(entry *logrus.Entry) error {
+	return journal.Send(entry.Message, severityMap[entry.Level], stringifyEntries(entry.Data))
+}
+
+func (hook *JournalHook) Levels() []logrus.Level {
+	return []logrus.Level{
+		logrus.PanicLevel,
+		logrus.FatalLevel,
+		logrus.ErrorLevel,
+		logrus.WarnLevel,
+		logrus.InfoLevel,
+		logrus.DebugLevel,
+	}
+}
diff --git a/internal/common/logger_middleware.go b/internal/common/logger_middleware.go
new file mode 100644
index 000000000..0a26c6e8a
--- /dev/null
+++ b/internal/common/logger_middleware.go
@@ -0,0 +1,18 @@
+package common
+
+import (
+	"github.com/labstack/echo/v4"
+	"github.com/sirupsen/logrus"
+)
+
+// Store context in request logger to propagate correlation ids
+func LoggerMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		c.SetLogger(&EchoLogrusLogger{
+			Logger: logrus.StandardLogger(),
+			Ctx:    c.Request().Context(),
+		})
+
+		return next(c)
+	}
+}
diff --git a/internal/common/operation_id.go b/internal/common/operation_id.go
index 1c522e78e..2c27ade16 100644
--- a/internal/common/operation_id.go
+++ b/internal/common/operation_id.go
@@ -1,18 +1,29 @@
 package common
 
 import (
+	"context"
+
 	"github.com/labstack/echo/v4"
 	"github.com/segmentio/ksuid"
 )
 
+type ctxKey string
+
 const OperationIDKey string = "operationID"
+const operationIDKeyCtx ctxKey = ctxKey(OperationIDKey)
 
 // Adds a time-sortable globally unique identifier to an echo.Context if not already set
 func OperationIDMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
 	return func(c echo.Context) error {
 		if c.Get(OperationIDKey) == nil {
-			c.Set(OperationIDKey, GenerateOperationID())
+			oid := GenerateOperationID()
+			c.Set(OperationIDKey, oid)
+
+			ctx := c.Request().Context()
+			ctx = context.WithValue(ctx, operationIDKeyCtx, oid)
+			c.SetRequest(c.Request().WithContext(ctx))
 		}
+
 		return next(c)
 	}
 }
diff --git a/internal/osbuildexecutor/osbuild-executor.go b/internal/osbuildexecutor/osbuild-executor.go
index 9748d1e42..ed3c35eae 100644
--- a/internal/osbuildexecutor/osbuild-executor.go
+++ b/internal/osbuildexecutor/osbuild-executor.go
@@ -14,9 +14,6 @@ type OsbuildOpts struct {
 	Checkpoints []string
 	ExtraEnv    []string
 	Result      bool
-
-	// not strict a osbuild opt
-	JobID string
 }
 
 type Executor interface {
diff --git a/internal/osbuildexecutor/runner-impl-aws-ec2.go b/internal/osbuildexecutor/runner-impl-aws-ec2.go
index d49beb7a9..012947c63 100644
--- a/internal/osbuildexecutor/runner-impl-aws-ec2.go
+++ b/internal/osbuildexecutor/runner-impl-aws-ec2.go
@@ -25,6 +25,7 @@ type awsEC2Executor struct {
 	iamProfile      string
 	keyName         string
 	cloudWatchGroup string
+	hostname        string
 	tmpDir          string
 }
 
@@ -73,17 +74,15 @@ func waitForSI(ctx context.Context, host string) bool {
 	}
 }
 
-func writeInputArchive(cacheDir, store, jobID string, exports []string, manifestData []byte) (string, error) {
+func writeInputArchive(cacheDir, store string, exports []string, manifestData []byte) (string, error) {
 	archive := filepath.Join(cacheDir, "input.tar")
 	control := filepath.Join(cacheDir, "control.json")
 	manifest := filepath.Join(cacheDir, "manifest.json")
 
 	controlData := struct {
 		Exports []string `json:"exports"`
-		JobID   string   `json:"job-id"`
 	}{
 		Exports: exports,
-		JobID:   jobID,
 	}
 	controlDataBytes, err := json.Marshal(controlData)
 	if err != nil {
@@ -270,7 +269,7 @@ func (ec2e *awsEC2Executor) RunOSBuild(manifest []byte, opts *OsbuildOpts, error
 		return nil, fmt.Errorf("Failed to get default aws client in %s region: %w", region, err)
 	}
 
-	si, err := aws.RunSecureInstance(ec2e.iamProfile, ec2e.keyName, ec2e.cloudWatchGroup)
+	si, err := aws.RunSecureInstance(ec2e.iamProfile, ec2e.keyName, ec2e.cloudWatchGroup, ec2e.hostname)
 	if err != nil {
 		return nil, fmt.Errorf("Unable to start secure instance: %w", err)
 	}
@@ -289,7 +288,7 @@ func (ec2e *awsEC2Executor) RunOSBuild(manifest []byte, opts *OsbuildOpts, error
 		return nil, fmt.Errorf("Timeout waiting for executor to come online")
 	}
 
-	inputArchive, err := writeInputArchive(ec2e.tmpDir, opts.StoreDir, opts.JobID, opts.Exports, manifest)
+	inputArchive, err := writeInputArchive(ec2e.tmpDir, opts.StoreDir, opts.Exports, manifest)
 	if err != nil {
 		logrus.Errorf("Unable to write input archive: %v", err)
 		return nil, err
@@ -319,11 +318,12 @@ func (ec2e *awsEC2Executor) RunOSBuild(manifest []byte, opts *OsbuildOpts, error
 	return osbuildResult, nil
 }
 
-func NewAWSEC2Executor(iamProfile, keyName, cloudWatchGroup, tmpDir string) Executor {
+func NewAWSEC2Executor(iamProfile, keyName, cloudWatchGroup, hostname, tmpDir string) Executor {
 	return &awsEC2Executor{
 		iamProfile,
 		keyName,
 		cloudWatchGroup,
+		hostname,
 		tmpDir,
 	}
 }
diff --git a/templates/packer/ansible/roles/common/files/worker-executor.service b/templates/packer/ansible/roles/common/files/worker-executor.service
index 7b1233b0d..c908a0042 100644
--- a/templates/packer/ansible/roles/common/files/worker-executor.service
+++ b/templates/packer/ansible/roles/common/files/worker-executor.service
@@ -6,6 +6,7 @@ After=cloud-final.service
 
 [Service]
 Type=oneshot
+ExecStart=/usr/local/libexec/worker-initialization-scripts/set_executor_hostname.sh
 ExecStart=/usr/local/libexec/worker-initialization-scripts/worker_executor.sh
 
 [Install]
diff --git a/templates/packer/ansible/roles/common/files/worker-initialization-scripts/set_executor_hostname.sh b/templates/packer/ansible/roles/common/files/worker-initialization-scripts/set_executor_hostname.sh
new file mode 100644
index 000000000..513a423d8
--- /dev/null
+++ b/templates/packer/ansible/roles/common/files/worker-initialization-scripts/set_executor_hostname.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+set -euo pipefail
+source /tmp/cloud_init_vars
+
+if [[ -z "$OSBUILD_EXECUTOR_HOSTNAME" ]]; then
+    echo "OSBUILD_EXECUTOR_HOSTNAME not set, skipping."
+    exit 0
+fi
+
+echo "Setting system hostname to $OSBUILD_EXECUTOR_HOSTNAME."
+hostnamectl set-hostname "$OSBUILD_EXECUTOR_HOSTNAME"
diff --git a/vendor/github.com/coreos/go-systemd/journal/journal.go b/vendor/github.com/coreos/go-systemd/journal/journal.go
new file mode 100644
index 000000000..a0f4837a0
--- /dev/null
+++ b/vendor/github.com/coreos/go-systemd/journal/journal.go
@@ -0,0 +1,225 @@
+// Copyright 2015 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package journal provides write bindings to the local systemd journal.
+// It is implemented in pure Go and connects to the journal directly over its
+// unix socket.
+//
+// To read from the journal, see the "sdjournal" package, which wraps the
+// sd-journal a C API.
+//
+// http://www.freedesktop.org/software/systemd/man/systemd-journald.service.html
+package journal
+
+import (
+	"bytes"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"syscall"
+	"unsafe"
+)
+
+// Priority of a journal message
+type Priority int
+
+const (
+	PriEmerg Priority = iota
+	PriAlert
+	PriCrit
+	PriErr
+	PriWarning
+	PriNotice
+	PriInfo
+	PriDebug
+)
+
+var (
+	// This can be overridden at build-time:
+	// https://github.com/golang/go/wiki/GcToolchainTricks#including-build-information-in-the-executable
+	journalSocket = "/run/systemd/journal/socket"
+
+	// unixConnPtr atomically holds the local unconnected Unix-domain socket.
+	// Concrete safe pointer type: *net.UnixConn
+	unixConnPtr unsafe.Pointer
+	// onceConn ensures that unixConnPtr is initialized exactly once.
+	onceConn sync.Once
+)
+
+func init() {
+	onceConn.Do(initConn)
+}
+
+// Enabled checks whether the local systemd journal is available for logging.
+func Enabled() bool {
+	onceConn.Do(initConn)
+
+	if (*net.UnixConn)(atomic.LoadPointer(&unixConnPtr)) == nil {
+		return false
+	}
+
+	if _, err := net.Dial("unixgram", journalSocket); err != nil {
+		return false
+	}
+
+	return true
+}
+
+// Send a message to the local systemd journal. vars is a map of journald
+// fields to values.  Fields must be composed of uppercase letters, numbers,
+// and underscores, but must not start with an underscore. Within these
+// restrictions, any arbitrary field name may be used.  Some names have special
+// significance: see the journalctl documentation
+// (http://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html)
+// for more details.  vars may be nil.
+func Send(message string, priority Priority, vars map[string]string) error {
+	conn := (*net.UnixConn)(atomic.LoadPointer(&unixConnPtr))
+	if conn == nil {
+		return errors.New("could not initialize socket to journald")
+	}
+
+	socketAddr := &net.UnixAddr{
+		Name: journalSocket,
+		Net:  "unixgram",
+	}
+
+	data := new(bytes.Buffer)
+	appendVariable(data, "PRIORITY", strconv.Itoa(int(priority)))
+	appendVariable(data, "MESSAGE", message)
+	for k, v := range vars {
+		appendVariable(data, k, v)
+	}
+
+	_, _, err := conn.WriteMsgUnix(data.Bytes(), nil, socketAddr)
+	if err == nil {
+		return nil
+	}
+	if !isSocketSpaceError(err) {
+		return err
+	}
+
+	// Large log entry, send it via tempfile and ancillary-fd.
+	file, err := tempFd()
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+	_, err = io.Copy(file, data)
+	if err != nil {
+		return err
+	}
+	rights := syscall.UnixRights(int(file.Fd()))
+	_, _, err = conn.WriteMsgUnix([]byte{}, rights, socketAddr)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Print prints a message to the local systemd journal using Send().
+func Print(priority Priority, format string, a ...interface{}) error {
+	return Send(fmt.Sprintf(format, a...), priority, nil)
+}
+
+func appendVariable(w io.Writer, name, value string) {
+	if err := validVarName(name); err != nil {
+		fmt.Fprintf(os.Stderr, "variable name %s contains invalid character, ignoring\n", name)
+	}
+	if strings.ContainsRune(value, '\n') {
+		/* When the value contains a newline, we write:
+		 * - the variable name, followed by a newline
+		 * - the size (in 64bit little endian format)
+		 * - the data, followed by a newline
+		 */
+		fmt.Fprintln(w, name)
+		binary.Write(w, binary.LittleEndian, uint64(len(value)))
+		fmt.Fprintln(w, value)
+	} else {
+		/* just write the variable and value all on one line */
+		fmt.Fprintf(w, "%s=%s\n", name, value)
+	}
+}
+
+// validVarName validates a variable name to make sure journald will accept it.
+// The variable name must be in uppercase and consist only of characters,
+// numbers and underscores, and may not begin with an underscore:
+// https://www.freedesktop.org/software/systemd/man/sd_journal_print.html
+func validVarName(name string) error {
+	if name == "" {
+		return errors.New("Empty variable name")
+	} else if name[0] == '_' {
+		return errors.New("Variable name begins with an underscore")
+	}
+
+	for _, c := range name {
+		if !(('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '_') {
+			return errors.New("Variable name contains invalid characters")
+		}
+	}
+	return nil
+}
+
+// isSocketSpaceError checks whether the error is signaling
+// an "overlarge message" condition.
+func isSocketSpaceError(err error) bool {
+	opErr, ok := err.(*net.OpError)
+	if !ok || opErr == nil {
+		return false
+	}
+
+	sysErr, ok := opErr.Err.(*os.SyscallError)
+	if !ok || sysErr == nil {
+		return false
+	}
+
+	return sysErr.Err == syscall.EMSGSIZE || sysErr.Err == syscall.ENOBUFS
+}
+
+// tempFd creates a temporary, unlinked file under `/dev/shm`.
+func tempFd() (*os.File, error) {
+	file, err := ioutil.TempFile("/dev/shm/", "journal.XXXXX")
+	if err != nil {
+		return nil, err
+	}
+	err = syscall.Unlink(file.Name())
+	if err != nil {
+		return nil, err
+	}
+	return file, nil
+}
+
+// initConn initializes the global `unixConnPtr` socket.
+// It is meant to be called exactly once, at program startup.
+func initConn() {
+	autobind, err := net.ResolveUnixAddr("unixgram", "")
+	if err != nil {
+		return
+	}
+
+	sock, err := net.ListenUnixgram("unixgram", autobind)
+	if err != nil {
+		return
+	}
+
+	atomic.StorePointer(&unixConnPtr, unsafe.Pointer(sock))
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index a6ae880d9..c77b92eb9 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -436,6 +436,7 @@ github.com/coreos/go-semver/semver
 # github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
 ## explicit
 github.com/coreos/go-systemd/activation
+github.com/coreos/go-systemd/journal
 # github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f
 ## explicit
 github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer


Test patch:
diff --git a/cmd/osbuild-composer/config_test.go b/cmd/osbuild-composer/config_test.go
index 52c1d3d18..ba7377527 100644
--- a/cmd/osbuild-composer/config_test.go
+++ b/cmd/osbuild-composer/config_test.go
@@ -77,7 +77,7 @@ func TestDefaultConfig(t *testing.T) {
 	}
 	require.Equal(t, expectedDistroAliases, defaultConfig.DistroAliases)
 
-	require.Equal(t, "text", defaultConfig.LogFormat)
+	require.Equal(t, "journal", defaultConfig.LogFormat)
 }
 
 func TestConfig(t *testing.T) {
diff --git a/cmd/osbuild-worker-executor/export_test.go b/cmd/osbuild-worker-executor/export_test.go
index b47976633..9698623c8 100644
--- a/cmd/osbuild-worker-executor/export_test.go
+++ b/cmd/osbuild-worker-executor/export_test.go
@@ -12,14 +12,6 @@ var (
 	HandleIncludedSources = handleIncludedSources
 )
 
-func MockUnixSethostname(new func([]byte) error) (restore func()) {
-	saved := unixSethostname
-	unixSethostname = new
-	return func() {
-		unixSethostname = saved
-	}
-}
-
 func MockOsbuildBinary(t *testing.T, new string) (restore func()) {
 	t.Helper()
 
diff --git a/cmd/osbuild-worker-executor/handler_build_test.go b/cmd/osbuild-worker-executor/handler_build_test.go
index d70f1c19f..57decab58 100644
--- a/cmd/osbuild-worker-executor/handler_build_test.go
+++ b/cmd/osbuild-worker-executor/handler_build_test.go
@@ -324,30 +324,3 @@ func TestBuildErrorHandlingTar(t *testing.T) {
 	assert.Contains(t, string(body), "cannot tar output directory:")
 	assert.Contains(t, loggerHook.LastEntry().Message, "cannot tar output directory:")
 }
-
-func TestBuildSethostname(t *testing.T) {
-	baseURL, baseBuildDir, _ := runTestServer(t)
-	endpoint := baseURL + "api/v1/build"
-
-	restore := main.MockOsbuildBinary(t, fmt.Sprintf(`#!/bin/sh -e
-# simulate output
-mkdir -p %[1]s/build/output/image
-echo "fake-build-result" > %[1]s/build/output/image/disk.img
-`, baseBuildDir))
-	t.Cleanup(restore)
-
-	var hostnameCalls []string
-	restore = main.MockUnixSethostname(func(hn []byte) error {
-		hostnameCalls = append(hostnameCalls, string(hn))
-		return nil
-	})
-	t.Cleanup(restore)
-
-	buf := makeTestPost(t, `{"exports": ["tree"], "job-id": "1234-56"}`, `{"fake": "manifest"}`)
-	rsp, err := http.Post(endpoint, "application/x-tar", buf)
-	assert.NoError(t, err)
-	defer func() { _, _ = io.ReadAll(rsp.Body) }()
-	defer rsp.Body.Close()
-
-	assert.Equal(t, []string{"1234-56"}, hostnameCalls)
-}
diff --git a/cmd/osbuild-worker-executor/main_test.go b/cmd/osbuild-worker-executor/main_test.go
index 09097ed65..e50d05add 100644
--- a/cmd/osbuild-worker-executor/main_test.go
+++ b/cmd/osbuild-worker-executor/main_test.go
@@ -72,11 +72,6 @@ func runTestServer(t *testing.T) (baseURL, buildBaseDir string, loggerHook *logr
 	buildBaseDir = t.TempDir()
 	baseURL = fmt.Sprintf("http://%s:%d/", host, port)
 
-	restore := main.MockUnixSethostname(func([]byte) error {
-		return nil
-	})
-	t.Cleanup(restore)
-
 	ctx, cancel := context.WithCancel(context.Background())
 	t.Cleanup(cancel)
 
diff --git a/internal/cloud/awscloud/secure-instance_test.go b/internal/cloud/awscloud/secure-instance_test.go
index 0a3c9548b..c67734486 100644
--- a/internal/cloud/awscloud/secure-instance_test.go
+++ b/internal/cloud/awscloud/secure-instance_test.go
@@ -8,6 +8,7 @@ import (
 func TestSecureInstanceUserData(t *testing.T) {
 	type testCase struct {
 		CloudWatchGroup  string
+		Hostname         string
 		ExpectedUserData string
 	}
 
@@ -21,6 +22,7 @@ write_files:
 		},
 		{
 			CloudWatchGroup: "test-group",
+			Hostname:        "test-hostname",
 			ExpectedUserData: `#cloud-config
 write_files:
   - path: /tmp/worker-run-executor-service
@@ -28,13 +30,14 @@ write_files:
   - path: /tmp/cloud_init_vars
     content: |
       OSBUILD_EXECUTOR_CLOUDWATCH_GROUP='test-group'
+      OSBUILD_EXECUTOR_HOSTNAME='test-hostname'
 `,
 		},
 	}
 
 	for idx, tc := range testCases {
 		t.Run(fmt.Sprintf("Test case %d", idx), func(t *testing.T) {
-			userData := SecureInstanceUserData(tc.CloudWatchGroup)
+			userData := SecureInstanceUserData(tc.CloudWatchGroup, tc.Hostname)
 			if userData != tc.ExpectedUserData {
 				t.Errorf("Expected: %s, got: %s", tc.ExpectedUserData, userData)
 			}
diff --git a/internal/common/journal_hook_test.go b/internal/common/journal_hook_test.go
new file mode 100644
index 000000000..31a3e05aa
--- /dev/null
+++ b/internal/common/journal_hook_test.go
@@ -0,0 +1,27 @@
+package common
+
+import "testing"
+
+func TestStringifyEntries(t *testing.T) {
+	input := map[string]interface{}{
+		"foo":     "bar",
+		"baz":     123,
+		"foo-foo": "x",
+		"-bar":    "1",
+	}
+
+	output := stringifyEntries(input)
+	if output["FOO"] != "bar" {
+		t.Fatalf("%v", output)
+		t.Fatalf("expected value 'bar'. Got %q", output["FOO"])
+	}
+	if output["BAZ"] != "123" {
+		t.Fatalf("expected value '123'. Got %q", output["BAZ"])
+	}
+	if output["FOO_FOO"] != "x" {
+		t.Fatalf("expected value 'x'. Got %q", output["FOO_FOO"])
+	}
+	if output["BAR"] != "1" {
+		t.Fatalf("expected value 'x'. Got %q", output["BAR"])
+	}
+}
diff --git a/internal/osbuildexecutor/runner-impl-aws-ec2_test.go b/internal/osbuildexecutor/runner-impl-aws-ec2_test.go
index 4261f2160..ed36d6b89 100644
--- a/internal/osbuildexecutor/runner-impl-aws-ec2_test.go
+++ b/internal/osbuildexecutor/runner-impl-aws-ec2_test.go
@@ -46,7 +46,7 @@ func TestWriteInputArchive(t *testing.T) {
 	require.NoError(t, os.WriteFile(filepath.Join(storeDir, "contents"), []byte("storedata"), 0600))
 	require.NoError(t, os.WriteFile(filepath.Join(storeSubDir, "contents"), []byte("storedata"), 0600))
 
-	archive, err := osbuildexecutor.WriteInputArchive(cacheDir, storeDir, "some-job-id", []string{"image"}, []byte("{\"version\": 2}"))
+	archive, err := osbuildexecutor.WriteInputArchive(cacheDir, storeDir, []string{"image"}, []byte("{\"version\": 2}"))
 	require.NoError(t, err)
 
 	cmd := exec.Command("tar",
@@ -64,10 +64,6 @@ func TestWriteInputArchive(t *testing.T) {
 		"store/contents",
 		"",
 	}, strings.Split(string(out), "\n"))
-
-	output, err := exec.Command("tar", "xOf", archive, "control.json").CombinedOutput()
-	require.NoError(t, err)
-	require.Equal(t, `{"exports":["image"],"job-id":"some-job-id"}`, string(output))
 }
 
 func TestHandleBuild(t *testing.T) {