diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 3087aee7..acb2a8c9 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - baseimage: ['debian:stretch', 'ubuntu:16.04', 'ubuntu:18.04'] + baseimage: ['debian:stretch', 'ubuntu:18.04', 'ubuntu:20.04'] steps: - run: sudo apt-get -qq update - name: Install libsystemd-dev @@ -38,7 +38,7 @@ jobs: - name: Pull base image - ${{ matrix.baseimage }} run: docker pull ${{ matrix.baseimage }} - name: Install packages for ${{ matrix.baseimage }} - run: docker run --privileged -e GOPATH=${GOPATH} --cidfile=/tmp/cidfile ${{ matrix.baseimage }} /bin/bash -c "apt-get update && apt-get install -y sudo build-essential git golang dbus libsystemd-dev libpam-systemd systemd-container" + run: docker run --privileged -e GOPATH=${GOPATH} --cidfile=/tmp/cidfile ${{ matrix.baseimage }} /bin/bash -c "export DEBIAN_FRONTEND=noninteractive; apt-get update && apt-get install -y sudo build-essential git golang dbus libsystemd-dev libpam-systemd systemd-container" - name: Persist base container run: docker commit `cat /tmp/cidfile` go-systemd/container-tests - run: rm -f /tmp/cidfile diff --git a/examples/journal/main.go b/examples/journal/main.go new file mode 100644 index 00000000..86dadc96 --- /dev/null +++ b/examples/journal/main.go @@ -0,0 +1,37 @@ +// Copyright 2022 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 main + +import ( + "fmt" + "os" + + "github.com/coreos/go-systemd/v22/journal" +) + +func main() { + ok, err := journal.StderrIsJournalStream() + if err != nil { + panic(err) + } + + if ok { + // use journal native protocol + journal.Send("this is a message logged through the native protocol", journal.PriInfo, nil) + } else { + // use stderr + fmt.Fprintln(os.Stderr, "this is a message logged through stderr") + } +} diff --git a/examples/journal/run.sh b/examples/journal/run.sh new file mode 100755 index 00000000..2909b8f0 --- /dev/null +++ b/examples/journal/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +go build + +echo "Running directly" +./journal + +echo "Running through systemd" +unit_name="run-$(systemd-id128 new)" +systemd-run -u "$unit_name" --user --wait --quiet ./journal +journalctl --user -u "$unit_name" diff --git a/journal/journal_unix.go b/journal/journal_unix.go index 439ad287..c5b23a81 100644 --- a/journal/journal_unix.go +++ b/journal/journal_unix.go @@ -69,6 +69,58 @@ func Enabled() bool { return true } +// StderrIsJournalStream returns whether the process stderr is connected +// to the Journal's stream transport. +// +// This can be used for automatic protocol upgrading described in [Journal Native Protocol]. +// +// Returns true if JOURNAL_STREAM environment variable is present, +// and stderr's device and inode numbers match it. +// +// Error is returned if unexpected error occurs: e.g. if JOURNAL_STREAM environment variable +// is present, but malformed, fstat syscall fails, etc. +// +// [Journal Native Protocol]: https://systemd.io/JOURNAL_NATIVE_PROTOCOL/#automatic-protocol-upgrading +func StderrIsJournalStream() (bool, error) { + return fdIsJournalStream(syscall.Stderr) +} + +// StdoutIsJournalStream returns whether the process stdout is connected +// to the Journal's stream transport. +// +// Returns true if JOURNAL_STREAM environment variable is present, +// and stdout's device and inode numbers match it. +// +// Error is returned if unexpected error occurs: e.g. if JOURNAL_STREAM environment variable +// is present, but malformed, fstat syscall fails, etc. +// +// Most users should probably use [StderrIsJournalStream]. +func StdoutIsJournalStream() (bool, error) { + return fdIsJournalStream(syscall.Stdout) +} + +func fdIsJournalStream(fd int) (bool, error) { + journalStream := os.Getenv("JOURNAL_STREAM") + if journalStream == "" { + return false, nil + } + + var expectedStat syscall.Stat_t + _, err := fmt.Sscanf(journalStream, "%d:%d", &expectedStat.Dev, &expectedStat.Ino) + if err != nil { + return false, fmt.Errorf("failed to parse JOURNAL_STREAM=%q: %v", journalStream, err) + } + + var stat syscall.Stat_t + err = syscall.Fstat(fd, &stat) + if err != nil { + return false, err + } + + match := stat.Dev == expectedStat.Dev && stat.Ino == expectedStat.Ino + return match, nil +} + // 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 diff --git a/journal/journal_unix_test.go b/journal/journal_unix_test.go new file mode 100644 index 00000000..3483d337 --- /dev/null +++ b/journal/journal_unix_test.go @@ -0,0 +1,188 @@ +// Copyright 2022 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. + +//go:build !windows +// +build !windows + +package journal_test + +import ( + "fmt" + "os" + "os/exec" + "syscall" + "testing" + + "github.com/coreos/go-systemd/v22/journal" +) + +func TestJournalStreamParsing(t *testing.T) { + if _, ok := os.LookupEnv("JOURNAL_STREAM"); ok { + t.Fatal("unset JOURNAL_STREAM before running this test") + } + + t.Run("Missing", func(t *testing.T) { + ok, err := journal.StderrIsJournalStream() + if err != nil { + t.Fatal(err) + } + if ok { + t.Error("stderr shouldn't be connected to journal stream") + } + }) + t.Run("Present", func(t *testing.T) { + f, stat := getUnixStreamSocket(t) + defer f.Close() + os.Setenv("JOURNAL_STREAM", fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)) + defer os.Unsetenv("JOURNAL_STREAM") + replaceStderr(int(f.Fd()), func() { + ok, err := journal.StderrIsJournalStream() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Error("stderr should've been connected to journal stream") + } + }) + }) + t.Run("NotMatching", func(t *testing.T) { + f, stat := getUnixStreamSocket(t) + defer f.Close() + os.Setenv("JOURNAL_STREAM", fmt.Sprintf("%d:%d", stat.Dev+1, stat.Ino)) + defer os.Unsetenv("JOURNAL_STREAM") + replaceStderr(int(f.Fd()), func() { + ok, err := journal.StderrIsJournalStream() + if err != nil { + t.Fatal(err) + } + if ok { + t.Error("stderr shouldn't be connected to journal stream") + } + }) + }) + t.Run("Malformed", func(t *testing.T) { + f, stat := getUnixStreamSocket(t) + defer f.Close() + os.Setenv("JOURNAL_STREAM", fmt.Sprintf("%d-%d", stat.Dev, stat.Ino)) + defer os.Unsetenv("JOURNAL_STREAM") + replaceStderr(int(f.Fd()), func() { + _, err := journal.StderrIsJournalStream() + if err == nil { + t.Fatal("JOURNAL_STREAM is malformed, but no error returned") + } + }) + }) +} + +func TestStderrIsJournalStream(t *testing.T) { + const ( + message = "TEST_MESSAGE" + ) + + userOrSystem := "--user" + if os.Getuid() == 0 { + userOrSystem = "--system" + } + + if _, ok := os.LookupEnv("JOURNAL_STREAM"); !ok { + // Re-execute this test under systemd (see the else branch), + // and observe its exit code. + args := []string{ + "systemd-run", + userOrSystem, + "--wait", + "--quiet", + "--", + os.Args[0], + "-test.run=TestStderrIsJournalStream", + "-test.count=1", // inhibit caching + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + } else { + ok, err := journal.StderrIsJournalStream() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("StderrIsJournalStream should've returned true") + } + + err = journal.Send(message, journal.PriInfo, nil) + if err != nil { + t.Fatal(err) + } + } +} + +func ExampleStderrIsJournalStream() { + // NOTE: this is just an example. Production code + // will likely use this to setup a logging library + // to write messages to either journal or stderr. + ok, err := journal.StderrIsJournalStream() + if err != nil { + panic(err) + } + + if ok { + // use journal native protocol + journal.Send("this is a message logged through the native protocol", journal.PriInfo, nil) + } else { + // use stderr + fmt.Fprintln(os.Stderr, "this is a message logged through stderr") + } +} + +func replaceStderr(fd int, cb func()) { + savedStderr, err := syscall.Dup(syscall.Stderr) + if err != nil { + panic(err) + } + defer syscall.Close(savedStderr) + err = syscall.Dup2(fd, syscall.Stderr) + if err != nil { + panic(err) + } + defer func() { + err := syscall.Dup2(savedStderr, syscall.Stderr) + if err != nil { + panic(err) + } + }() + cb() +} + +// getUnixStreamSocket returns a unix stream socket obtained with +// socketpair(2), and its fstat result. Only one end of the socket pair +// is returned, and the other end is closed immediately: we don't need +// it for our purposes. +func getUnixStreamSocket(t *testing.T) (*os.File, *syscall.Stat_t) { + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatal(os.NewSyscallError("socketpair", err)) + } + // we don't need the remote end for our tests + syscall.Close(fds[1]) + + file := os.NewFile(uintptr(fds[0]), "unix-stream") + stat, err := file.Stat() + if err != nil { + t.Fatal(err) + } + return file, stat.Sys().(*syscall.Stat_t) +} diff --git a/journal/journal_windows.go b/journal/journal_windows.go index 677aca68..322e41e7 100644 --- a/journal/journal_windows.go +++ b/journal/journal_windows.go @@ -33,3 +33,11 @@ func Enabled() bool { func Send(message string, priority Priority, vars map[string]string) error { return errors.New("could not initialize socket to journald") } + +func StderrIsJournalStream() (bool, error) { + return false, nil +} + +func StdoutIsJournalStream() (bool, error) { + return false, nil +} diff --git a/scripts/ci-runner.sh b/scripts/ci-runner.sh index 93a41057..860dcd85 100755 --- a/scripts/ci-runner.sh +++ b/scripts/ci-runner.sh @@ -23,6 +23,8 @@ function build_tests { echo " - examples/${ex}" go build -o ./test_bins/${ex}.example ./examples/activation/${ex}.go done + # just to make sure it's buildable + go build -o ./test_bins/journal ./examples/journal/ } function run_tests {