From d5945f824dacaefcd42f1c48a50a5f84fe7967a7 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:43:45 +0100 Subject: [PATCH] Preserve Pulse severity in systemd journal --- docs/TROUBLESHOOTING.md | 16 +++ .../subsystems/deployment-installability.md | 14 +++ .../internal/subsystems/security-privacy.md | 14 +++ .../public/docs/TROUBLESHOOTING.md | 16 +++ install.sh | 4 + internal/logging/logging.go | 69 ++++++++++++- internal/logging/logging_test.go | 97 +++++++++++++++++++ scripts/installtests/root_install_sh_test.go | 19 ++++ 8 files changed, 247 insertions(+), 2 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 933ae3526..aeb53c96a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -126,6 +126,22 @@ If the Pulse server is still reachable, use its generated uninstall command so the agent can deregister cleanly. Otherwise, stopping the service is the safe first step before platform-local cleanup. +#### Filter Pulse's systemd journal by severity + +Current systemd installs preserve Pulse's structured log level as the journal +priority. For example, show warnings and more important records with: + +```bash +sudo journalctl -u pulse -p warning +``` + +The stored message remains JSON (`"level":"warn"`, for example), while +`PRIORITY` is available to `journalctl` and syslog forwarding. If a current +Pulse warning appears only with `-p info`, inspect `systemctl cat pulse`; the +installed service must contain `SyslogLevelPrefix=true` and +`PULSE_LOG_JOURNAL_LEVEL_PREFIX=true`. Re-run the current signed installer to +repair an older generated unit rather than adding a JSON-parsing wrapper. + #### VMs show "-" for disk usage - Install **QEMU Guest Agent** in the VM. - Enable "QEMU Guest Agent" in Proxmox VM Options. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f696fa23b..7ca9ce5e7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4692,3 +4692,17 @@ Assistant command-help dialog behavior, and agent server-address migration guidance. Release-integrity detail also records authenticated Unified Agent download validation and the exact-step candidate version binding. Packet proof must retain those outcomes before v6.4.2 can be dispatched for publication. +### Systemd journal output preserves Pulse severity + +The generated Pulse server unit keeps stdout and stderr attached to the journal, +enables `SyslogLevelPrefix`, and opts the process into the logging package's +level-aware stream writer. That writer maps zerolog warning, error, fatal, +panic, info, debug, and trace records to systemd priorities before the message +crosses the journal stream. systemd consumes the prefix, so `MESSAGE` remains +the original structured log record and `journalctl -p` plus downstream syslog +forwarding can use `PRIORITY` without a shell wrapper. + +The opt-in belongs only to the generated systemd service. Container and +terminal output remain unprefixed, and the logger tees the unmodified record to +the rotating file sink and authenticated live-log broadcaster. Installer and +logging tests pin the unit directives, level mapping, and sink isolation. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 4553f82c5..88a61da4a 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2704,3 +2704,17 @@ selector, or mutation argument. Summary mode does not bind lifecycle or update bridges, and its report labels the reduced authority as `typed-helper-summary`; helper loss cannot trigger sudo, root execution, or a broader direct socket fallback. + +### Journald priority framing does not alter durable or live log payloads + +The systemd-only `PULSE_LOG_JOURNAL_LEVEL_PREFIX=true` opt-in wraps the +process stream with zerolog-aware syslog priority framing so systemd can assign +native journal severity. The prefix is a transport marker, not part of the +structured event: it must be applied before the stderr branch only and systemd +must remove it from `MESSAGE`. The owner-only rotating file sink and the +authenticated in-memory live-log broadcaster continue receiving the original +unprefixed record, preserving their existing payload and access boundaries. + +`internal/logging/logging_test.go` pins both the level-to-priority mapping and +that sink isolation. Deployments that do not explicitly opt in—including +containers and interactive terminals—must retain their unprefixed output. diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index 933ae3526..aeb53c96a 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -126,6 +126,22 @@ If the Pulse server is still reachable, use its generated uninstall command so the agent can deregister cleanly. Otherwise, stopping the service is the safe first step before platform-local cleanup. +#### Filter Pulse's systemd journal by severity + +Current systemd installs preserve Pulse's structured log level as the journal +priority. For example, show warnings and more important records with: + +```bash +sudo journalctl -u pulse -p warning +``` + +The stored message remains JSON (`"level":"warn"`, for example), while +`PRIORITY` is available to `journalctl` and syslog forwarding. If a current +Pulse warning appears only with `-p info`, inspect `systemctl cat pulse`; the +installed service must contain `SyslogLevelPrefix=true` and +`PULSE_LOG_JOURNAL_LEVEL_PREFIX=true`. Re-run the current signed installer to +repair an older generated unit rather than adding a JSON-parsing wrapper. + #### VMs show "-" for disk usage - Install **QEMU Guest Agent** in the VM. - Enable "QEMU Guest Agent" in Proxmox VM Options. diff --git a/install.sh b/install.sh index 2a15f5e3c..551f5c370 100755 --- a/install.sh +++ b/install.sh @@ -4513,10 +4513,14 @@ Restart=always RestartSec=3 StandardOutput=journal StandardError=journal +SyslogLevelPrefix=true Environment="HOME=$INSTALL_DIR" Environment="PATH=$INSTALL_DIR/.local/bin:$INSTALL_DIR/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" Environment="PULSE_DATA_DIR=$CONFIG_DIR" Environment="PULSE_DEPLOYMENT_METHOD=systemd" +# Pulse prefixes each JSON line with its zerolog priority. systemd consumes +# the prefix into PRIORITY and retains the original JSON as MESSAGE. +Environment="PULSE_LOG_JOURNAL_LEVEL_PREFIX=true" EnvironmentFile=-$CONFIG_DIR/.env EOF diff --git a/internal/logging/logging.go b/internal/logging/logging.go index d68e4b052..dd82d4aef 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -25,6 +25,11 @@ type ctxKey string const ( requestIDKey ctxKey = "logging_request_id" + // journalLevelPrefixEnv is set only by the Pulse systemd service. It keeps + // container, terminal, file, and live-UI log payloads unchanged while + // allowing systemd to assign the JSON stream its real zerolog priority. + journalLevelPrefixEnv = "PULSE_LOG_JOURNAL_LEVEL_PREFIX" + bytesPerMB int64 = 1024 * 1024 defaultMaxSizeMB = 100 defaultMaxAgeDays = 30 @@ -84,6 +89,63 @@ func (w *dynamicWriter) Write(p []byte) (int, error) { return w.writer.Write(p) } +func (w *dynamicWriter) WriteLevel(level zerolog.Level, p []byte) (int, error) { + w.mu.RLock() + defer w.mu.RUnlock() + + if w.writer == nil { + return len(p), nil + } + if levelWriter, ok := w.writer.(zerolog.LevelWriter); ok { + return levelWriter.WriteLevel(level, p) + } + return w.writer.Write(p) +} + +// journalLevelWriter emits the syslog priority prefix understood by systemd +// when SyslogLevelPrefix=true. systemd strips the prefix before storing the +// message, so the retained MESSAGE remains the original JSON log record. +type journalLevelWriter struct { + writer io.Writer +} + +func (w journalLevelWriter) Write(p []byte) (int, error) { + return w.WriteLevel(zerolog.InfoLevel, p) +} + +func (w journalLevelWriter) WriteLevel(level zerolog.Level, p []byte) (int, error) { + prefix := []byte{'<', byte('0' + journalPriority(level)), '>'} + message := make([]byte, 0, len(prefix)+len(p)) + message = append(message, prefix...) + message = append(message, p...) + + written, err := w.writer.Write(message) + if err != nil { + return 0, err + } + if written != len(message) { + return 0, io.ErrShortWrite + } + return len(p), nil +} + +func journalPriority(level zerolog.Level) int { + switch level { + case zerolog.PanicLevel: + return 0 // emerg + case zerolog.FatalLevel: + return 2 // crit + case zerolog.ErrorLevel: + return 3 // err + case zerolog.WarnLevel: + return 4 // warning + case zerolog.DebugLevel, zerolog.TraceLevel: + return 7 // debug + default: + return 6 // info, nolevel, and forward-compatible unknown levels + } +} + type componentHook struct{} func (componentHook) Run(event *zerolog.Event, _ zerolog.Level, _ string) { @@ -177,15 +239,18 @@ func Init(cfg Config) zerolog.Logger { zerolog.SetGlobalLevel(parseLevel(cfg.Level)) writer := selectWriter(cfg.Format) + if strings.EqualFold(strings.TrimSpace(os.Getenv(journalLevelPrefixEnv)), "true") { + writer = journalLevelWriter{writer: writer} + } // Hook in the in-memory broadcaster for live UI streaming broadcaster := GetBroadcaster() - writer = io.MultiWriter(writer, broadcaster) + writer = zerolog.MultiLevelWriter(writer, broadcaster) if fileWriter, err := newRollingFileWriter(cfg); err != nil { fmt.Fprintf(os.Stderr, "logging: unable to configure file output: %v\n", err) } else if fileWriter != nil { - writer = io.MultiWriter(writer, fileWriter) + writer = zerolog.MultiLevelWriter(writer, fileWriter) if closer, ok := fileWriter.(io.Closer); ok { fileCloser = closer } diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index 7b8ece846..db8e7a2bd 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -127,6 +127,103 @@ func TestInitJSONFormatSetsLevelAndComponent(t *testing.T) { } } +func TestJournalLevelWriterMapsZerologLevelsToSystemdPriorities(t *testing.T) { + tests := []struct { + name string + level zerolog.Level + priority string + }{ + {name: "trace", level: zerolog.TraceLevel, priority: "<7>"}, + {name: "debug", level: zerolog.DebugLevel, priority: "<7>"}, + {name: "info", level: zerolog.InfoLevel, priority: "<6>"}, + {name: "warn", level: zerolog.WarnLevel, priority: "<4>"}, + {name: "error", level: zerolog.ErrorLevel, priority: "<3>"}, + {name: "fatal", level: zerolog.FatalLevel, priority: "<2>"}, + {name: "panic", level: zerolog.PanicLevel, priority: "<0>"}, + {name: "no level", level: zerolog.NoLevel, priority: "<6>"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + writer := journalLevelWriter{writer: &output} + payload := []byte(`{"message":"kept as json"}` + "\n") + + n, err := writer.WriteLevel(tt.level, payload) + if err != nil { + t.Fatalf("WriteLevel error: %v", err) + } + if n != len(payload) { + t.Fatalf("WriteLevel n = %d, want %d", n, len(payload)) + } + if got, want := output.String(), tt.priority+string(payload); got != want { + t.Fatalf("output = %q, want %q", got, want) + } + }) + } +} + +func TestInitJournaldPriorityOnlyPrefixesServiceStream(t *testing.T) { + t.Cleanup(resetLoggingState) + t.Setenv(journalLevelPrefixEnv, "true") + + originalStderr := os.Stderr + readPipe, writePipe, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + os.Stderr = writePipe + t.Cleanup(func() { + os.Stderr = originalStderr + _ = readPipe.Close() + _ = writePipe.Close() + }) + + logPath := filepath.Join(t.TempDir(), "pulse.log") + logger := Init(Config{ + Format: "json", + Level: "info", + FilePath: logPath, + MaxSizeMB: 1, + MaxAgeDays: 1, + }) + logger.Warn().Msg("journal severity check") + + if err := writePipe.Close(); err != nil { + t.Fatalf("close stderr writer: %v", err) + } + streamOutput, err := io.ReadAll(readPipe) + if err != nil { + t.Fatalf("read stderr output: %v", err) + } + if !bytes.HasPrefix(streamOutput, []byte(`<4>{`)) { + t.Fatalf("journal stream output = %q, want warning prefix before JSON", streamOutput) + } + + fileOutput, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read file log: %v", err) + } + if bytes.HasPrefix(fileOutput, []byte(`<4>`)) { + t.Fatalf("file log must not contain journal prefix: %q", fileOutput) + } + if !bytes.Contains(fileOutput, []byte(`"level":"warn"`)) { + t.Fatalf("file log lost structured level: %q", fileOutput) + } + + history := GetBroadcaster().GetHistory() + if len(history) == 0 { + t.Fatal("expected live log history entry") + } + latest := history[len(history)-1] + if strings.HasPrefix(latest, "<4>") { + t.Fatalf("live UI log must not contain journal prefix: %q", latest) + } + if !strings.Contains(latest, `"level":"warn"`) { + t.Fatalf("live UI log lost structured level: %q", latest) + } +} + func TestInitConsoleFormatUsesConsoleWriter(t *testing.T) { t.Cleanup(resetLoggingState) diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index fa4eedd4d..cc85af292 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -641,6 +641,25 @@ func TestCanonicalServerDeploymentMethodsAreStampedForTelemetry(t *testing.T) { } } +func TestSystemdServerLogsPreservePulseSeverityInJournal(t *testing.T) { + rootInstall, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatalf("read root install.sh: %v", err) + } + + unitContract := string(rootInstall) + for _, marker := range []string{ + "StandardOutput=journal", + "StandardError=journal", + "SyslogLevelPrefix=true", + `Environment="PULSE_LOG_JOURNAL_LEVEL_PREFIX=true"`, + } { + if !strings.Contains(unitContract, marker) { + t.Errorf("systemd server unit must include %q", marker) + } + } +} + func TestPrereleaseUpdateCopyUsesPreviewFraming(t *testing.T) { rootInstall, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) if err != nil {