Preserve Pulse severity in systemd journal

This commit is contained in:
pulse-triage[bot]
2026-08-31 05:43:45 +01:00
parent f47ac1f020
commit d5945f824d
8 changed files with 247 additions and 2 deletions
+16
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+4
View File
@@ -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
+67 -2
View File
@@ -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
}
+97
View File
@@ -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)
@@ -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 {