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/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-composer/main.go b/cmd/osbuild-composer/main.go index 3c1eba7e9..d6047f61b 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") } @@ -49,6 +58,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/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/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/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..3b8314d9e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -436,7 +436,8 @@ 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/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f +github.com/coreos/go-systemd/journal +# github.com/cyberphone/json-canonicalization v0.0.0-20231011164504-785e29786b46 ## explicit github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer # github.com/cyphar/filepath-securejoin v0.2.4