mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Preserve Pulse severity in systemd journal
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user