From 6ddb665402a845ed12f148be566c33d99e875619 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Fri, 24 Jul 2026 17:35:42 +0200 Subject: [PATCH 01/14] log mittnite's own messages with RFC3339 timestamps The logrus formatter of both binaries hardcoded the day-first layout "02-01-2006 15:04:05", which is ambiguous and carries no timezone. Part of #120. Co-Authored-By: Claude Fable 5 --- cmd/mittnitectl/main.go | 4 +++- main.go | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/mittnitectl/main.go b/cmd/mittnitectl/main.go index 750616f..021d5eb 100644 --- a/cmd/mittnitectl/main.go +++ b/cmd/mittnitectl/main.go @@ -1,12 +1,14 @@ package main import ( + "time" + log "github.com/sirupsen/logrus" ) func init() { Formatter := new(log.TextFormatter) - Formatter.TimestampFormat = "02-01-2006 15:04:05" + Formatter.TimestampFormat = time.RFC3339 Formatter.FullTimestamp = true log.SetFormatter(Formatter) } diff --git a/main.go b/main.go index d120377..3378a2b 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,16 @@ package main import ( + "os" + "time" + "github.com/mittwald/mittnite/cmd" log "github.com/sirupsen/logrus" - "os" ) func init() { Formatter := new(log.TextFormatter) - Formatter.TimestampFormat = "02-01-2006 15:04:05" + Formatter.TimestampFormat = time.RFC3339 Formatter.FullTimestamp = true log.SetFormatter(Formatter) if os.Getenv("MITTNITE_LOG_LEVEL") == "debug" { From 530c868c9dcc61c7322849e85ec9e5c89d9ce85f Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Fri, 24 Jul 2026 17:36:14 +0200 Subject: [PATCH 02/14] treat empty timestampFormat as the documented RFC3339 default enableTimestamps = true without an explicit timestampFormat fell into the unknown-format branch and warned on every job start, even though RFC3339 is the documented default for exactly this case. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob.go | 12 +++++++--- pkg/proc/basejob_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index a226bb5..664d4a7 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -270,15 +270,21 @@ func (job *baseJob) resolveTimestampLayout(l *log.Entry) string { return job.Config.CustomTimestampFormat } - layout, exists := TimeLayouts[job.Config.TimestampFormat] + format := job.Config.TimestampFormat + if format == "" { + // documented default, must not hit the unknown-format warning below + format = "RFC3339" + } + + layout, exists := TimeLayouts[format] if !exists { - l.WithField("timestamp.format", job.Config.TimestampFormat). + l.WithField("timestamp.format", format). WithField("timestamp.layout", time.RFC3339). Warn("unknown timestamp format, defaulting to RFC3339") return time.RFC3339 } - l.WithField("timestamp.format", job.Config.TimestampFormat). + l.WithField("timestamp.format", format). WithField("timestamp.layout", layout). Info("logging with timestamp layout") return layout diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index 45ca89c..85b372f 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/mittwald/mittnite/internal/config" + log "github.com/sirupsen/logrus" logtest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/require" ) @@ -122,6 +123,53 @@ func TestStartOnceDrainsPipeAfterLogTargetCloses(t *testing.T) { }, 5*time.Second, 50*time.Millisecond, "lingering child should survive writing after the log target closed") } +// An unset timestampFormat is the documented default (RFC3339) and must not +// trigger the unknown-format warning. +func TestResolveTimestampLayoutDefaultsToRFC3339WithoutWarning(t *testing.T) { + logHook := logtest.NewGlobal() + defer logHook.Reset() + + job := &baseJob{Config: &config.BaseJobConfig{Name: "default-format-job"}} + layout := job.resolveTimestampLayout(log.WithField("job.name", job.Config.Name)) + + require.Equal(t, time.RFC3339, layout) + for _, entry := range logHook.AllEntries() { + require.NotEqual(t, log.WarnLevel, entry.Level, "unexpected warning: %s", entry.Message) + } +} + +func TestResolveTimestampLayoutWarnsOnUnknownFormat(t *testing.T) { + logHook := logtest.NewGlobal() + defer logHook.Reset() + + job := &baseJob{Config: &config.BaseJobConfig{ + Name: "unknown-format-job", + TimestampFormat: "bogus", + }} + layout := job.resolveTimestampLayout(log.WithField("job.name", job.Config.Name)) + + require.Equal(t, time.RFC3339, layout) + + var warnings []string + for _, entry := range logHook.AllEntries() { + if entry.Level == log.WarnLevel { + warnings = append(warnings, entry.Message) + } + } + require.Contains(t, warnings, "unknown timestamp format, defaulting to RFC3339") +} + +func TestResolveTimestampLayoutPrefersCustomFormat(t *testing.T) { + job := &baseJob{Config: &config.BaseJobConfig{ + Name: "custom-format-job", + TimestampFormat: "Kitchen", + CustomTimestampFormat: "2006-01-02", + }} + layout := job.resolveTimestampLayout(log.WithField("job.name", job.Config.Name)) + + require.Equal(t, "2006-01-02", layout) +} + func TestStartOnceReportsExpectedStopAsIntentional(t *testing.T) { job, errChan := startTestJob(t) From eb106cbc344580f727f6eafdc1bda19c83ee8c1b Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Fri, 24 Jul 2026 17:36:42 +0200 Subject: [PATCH 03/14] initialize boot job std streams so their output is not lost NewBootJob constructed the embedded baseJob directly and never set job.stdout/job.stderr. The typed-nil *os.File values bypass os/exec's nil check (Fd() returns ^0), so the child started with closed stdout/stderr descriptors: all boot job output went nowhere, and the first file the boot command opened could silently receive fd 1. Initializing via baseJob.init wires boot jobs to mittnite's own streams and makes them honor the stdout/stderr/enableTimestamps job options, like regular jobs. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob_test.go | 12 ++++++++++++ pkg/proc/types.go | 7 ++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index 85b372f..2d79355 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -123,6 +123,18 @@ func TestStartOnceDrainsPipeAfterLogTargetCloses(t *testing.T) { }, 5*time.Second, 50*time.Millisecond, "lingering child should survive writing after the log target closed") } +// Boot jobs used to skip baseJob.init, leaving job.stdout/job.stderr as typed +// nil *os.File values; os/exec turns those into closed file descriptors in the +// child, so all boot job output was silently lost. +func TestNewBootJobInitializesStdStreams(t *testing.T) { + job, err := NewBootJob(&config.BootJobConfig{ + BaseJobConfig: config.BaseJobConfig{Name: "boot-job", Command: "true"}, + }) + require.NoError(t, err) + require.Same(t, os.Stdout, job.stdout) + require.Same(t, os.Stderr, job.stderr) +} + // An unset timestampFormat is the documented default (RFC3339) and must not // trigger the unknown-format warning. func TestResolveTimestampLayoutDefaultsToRFC3339WithoutWarning(t *testing.T) { diff --git a/pkg/proc/types.go b/pkg/proc/types.go index 4661e7b..798f8c9 100644 --- a/pkg/proc/types.go +++ b/pkg/proc/types.go @@ -222,12 +222,13 @@ func NewLazyJob(c *config.JobConfig) (*LazyJob, error) { func NewBootJob(c *config.BootJobConfig) (*BootJob, error) { bj := BootJob{ - baseJob: baseJob{ - Config: &c.BaseJobConfig, - }, Config: c, } + if err := bj.baseJob.init(&c.BaseJobConfig); err != nil { + return nil, err + } + if ts := c.Timeout; ts != "" { t, err := time.ParseDuration(ts) if err != nil { From 78dce32436d8935bfa520b68ed59297b73564637 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Mon, 27 Jul 2026 14:23:15 +0200 Subject: [PATCH 04/14] forward job output line-wise without a line-length limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logWithTimestamp used bufio.Scanner, whose 64KB token limit turns an overlong line into a permanent read error: forwarding stops, and the deferred Close of the pipe's read end kills the job with SIGPIPE on its next write. The forwarder (renamed to forwardOutput) now reads via bufio.Reader.ReadLine and splits overlong lines across writes instead — the timestamp is only written at the start of a line, the newline only at its end. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob.go | 52 ++++++++++++++++++++++++---------------- pkg/proc/basejob_test.go | 26 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 664d4a7..265872b 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -150,8 +150,8 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e pipeWriteEnds = []*os.File{stdoutWriter, stderrWriter} layout := job.resolveTimestampLayout(l) - go job.logWithTimestamp(stdoutReader, job.stdout, layout) - go job.logWithTimestamp(stderrReader, job.stderr, layout) + go job.forwardOutput(stdoutReader, job.stdout, layout) + go job.forwardOutput(stderrReader, job.stderr, layout) } else { cmd.Stdout = job.stdout cmd.Stderr = job.stderr @@ -290,31 +290,45 @@ func (job *baseJob) resolveTimestampLayout(l *log.Entry) string { return layout } -func (job *baseJob) logWithTimestamp(r io.ReadCloser, w io.Writer, layout string) { +// forwardOutput copies process output from r to w line by line, prefixing +// each line with a timestamp in the given layout. Lines longer than the read +// buffer are forwarded in chunks — the timestamp is only written at the start +// of a line and the newline only at its end — so overlong lines are split +// across writes instead of aborting the forwarding (bufio.Scanner's token +// limit would; a stopped reader lets the pipe fill up and block the child). +func (job *baseJob) forwardOutput(r io.ReadCloser, w io.Writer, timestampLayout string) { defer r.Close() l := log.WithField("job.name", job.Config.Name) - scanner := bufio.NewScanner(r) - prefix := []byte{'['} - suffix := []byte{']', ' '} - newline := []byte{'\n'} + reader := bufio.NewReaderSize(r, 64*1024) var timeBuffer []byte var lineBuffer bytes.Buffer + continuation := false - for scanner.Scan() { - // Reuse time buffer, completly avoiding allocations - timeBuffer = timeBuffer[:0] - timeBuffer = time.Now().AppendFormat(timeBuffer, layout) + for { + line, isPrefix, err := reader.ReadLine() + if err != nil { + if !errors.Is(err, io.EOF) { + l.WithError(err).Error("error reading from process") + } + return + } - // Reset line buffer lineBuffer.Reset() - lineBuffer.Write(prefix) - lineBuffer.Write(timeBuffer) - lineBuffer.Write(suffix) - lineBuffer.Write(scanner.Bytes()) - lineBuffer.Write(newline) + if !continuation && timestampLayout != "" { + // reuse the time buffer to avoid per-line allocations + timeBuffer = time.Now().AppendFormat(timeBuffer[:0], timestampLayout) + lineBuffer.WriteByte('[') + lineBuffer.Write(timeBuffer) + lineBuffer.WriteString("] ") + } + lineBuffer.Write(line) + if !isPrefix { + lineBuffer.WriteByte('\n') + } + continuation = isPrefix if _, err := w.Write(lineBuffer.Bytes()); err != nil { if errors.Is(err, os.ErrClosed) { @@ -329,10 +343,6 @@ func (job *baseJob) logWithTimestamp(r io.ReadCloser, w io.Writer, layout string continue } } - - if err := scanner.Err(); err != nil { - l.WithError(err).Error("error reading from process") - } } func (job *baseJob) readStdFile(ctx context.Context, wg *sync.WaitGroup, filePath string, outChan chan []byte, errChan chan error, follow bool, tailLen int) { diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index 2d79355..ca681d3 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -1,8 +1,10 @@ package proc import ( + "bytes" "context" "fmt" + "io" "os" "path/filepath" "strings" @@ -182,6 +184,30 @@ func TestResolveTimestampLayoutPrefersCustomFormat(t *testing.T) { require.Equal(t, "2006-01-02", layout) } +// forwardOutput must not abort on lines longer than its read buffer: the +// timestamp is written once per line, overlong lines arrive in chunks, and +// empty as well as unterminated final lines are forwarded as lines. +func TestForwardOutputHandlesOverlongLines(t *testing.T) { + logHook := logtest.NewGlobal() + defer logHook.Reset() + + payload := strings.Repeat("a", 200*1024) + input := "first\n" + payload + "\n\nlast" + + var buf bytes.Buffer + job := &baseJob{Config: &config.BaseJobConfig{Name: "long-line-job"}} + job.forwardOutput(io.NopCloser(strings.NewReader(input)), &buf, "2006") + + year := fmt.Sprintf("[%d] ", time.Now().Year()) + require.Equal(t, + year+"first\n"+year+payload+"\n"+year+"\n"+year+"last\n", + buf.String()) + + for _, entry := range logHook.AllEntries() { + require.NotEqual(t, "error reading from process", entry.Message) + } +} + func TestStartOnceReportsExpectedStopAsIntentional(t *testing.T) { job, errChan := startTestJob(t) From 4be1cd9bcee0b39e755cb40c5296f139cf4a0545 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Wed, 29 Jul 2026 14:34:17 +0200 Subject: [PATCH 05/14] add global switches to timestamp and name-prefix all job output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mittnite up learns --job-log-timestamps and --job-log-name-prefix (env: MITTNITE_JOB_LOG_TIMESTAMPS / MITTNITE_JOB_LOG_NAME_PREFIX). They apply to every job and boot job that does not configure the respective option itself; enableTimestamps and the new enableNamePrefix are bool-pointers now, so an explicit per-job false still opts out of the global default. The name prefix is written after the timestamp — RFC3339 keeps a constant width for a fixed UTC offset, so the name column stays aligned and tooling that expects a leading timestamp keeps working: [2026-07-24T10:28:52Z] [php-fpm] NOTICE: fpm is running, pid 592 Fixes #120. Co-Authored-By: Claude Fable 5 --- cmd/up.go | 30 ++++++++++ internal/config/ignitionconfig.go | 23 ++++++++ internal/config/types.go | 19 +++++- internal/config/types_test.go | 96 +++++++++++++++++++++++++++++++ pkg/proc/basejob.go | 50 ++++++++++------ pkg/proc/basejob_test.go | 73 ++++++++++++++++++++--- 6 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 internal/config/types_test.go diff --git a/cmd/up.go b/cmd/up.go index 4e50287..5ecebec 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "strconv" "syscall" "github.com/mittwald/mittnite/internal/config" @@ -19,6 +20,9 @@ import ( const ( DefaultAPIAddress = "unix:///var/run/mittnite.sock" + + envJobLogTimestamps = "MITTNITE_JOB_LOG_TIMESTAMPS" + envJobLogNamePrefix = "MITTNITE_JOB_LOG_NAME_PREFIX" ) var ( @@ -27,6 +31,8 @@ var ( apiEnabled bool apiListenAddress string keepRunning bool + jobLogTimestamps bool + jobLogNamePrefix bool ) func init() { @@ -42,6 +48,26 @@ func init() { up.PersistentFlags().BoolVarP(&apiEnabled, "api", "", false, "enables the api for remote or cli controlling") up.PersistentFlags().StringVarP(&apiListenAddress, "api-listen-address", "", DefaultAPIAddress, fmt.Sprintf("listen address for the api. Defaults to %q", DefaultAPIAddress)) up.PersistentFlags().BoolVarP(&keepRunning, "keep-running", "k", false, "keep mittnite running even if no job is running anymore") + up.PersistentFlags().BoolVar(&jobLogTimestamps, "job-log-timestamps", envBool(envJobLogTimestamps), "prefix each output line of every job with a timestamp (RFC3339 unless the job configures a format); per-job enableTimestamps wins (env: "+envJobLogTimestamps+")") + up.PersistentFlags().BoolVar(&jobLogNamePrefix, "job-log-name-prefix", envBool(envJobLogNamePrefix), "prefix each output line of every job with the job's name; per-job enableNamePrefix wins (env: "+envJobLogNamePrefix+")") +} + +// envBool interprets an environment variable as a boolean flag default; unset +// or unparsable values count as false (the latter are warned about in Run, +// since logging is not set up yet when flag defaults are evaluated). +func envBool(key string) bool { + v, err := strconv.ParseBool(os.Getenv(key)) + return err == nil && v +} + +func warnUnparsableEnvBools() { + for _, key := range []string{envJobLogTimestamps, envJobLogNamePrefix} { + if v, ok := os.LookupEnv(key); ok { + if _, err := strconv.ParseBool(v); err != nil { + log.Warnf("ignoring environment variable %s: %q is not a boolean value", key, v) + } + } + } } var up = &cobra.Command{ @@ -67,10 +93,14 @@ var up = &cobra.Command{ } }() + warnUnparsableEnvBools() + if err := ignitionConfig.GenerateFromConfigDir(configDir); err != nil { return fmt.Errorf("failed while trying to generate ignition config from dir '%s': %w", configDir, err) } + ignitionConfig.ApplyJobLogDefaults(jobLogTimestamps, jobLogNamePrefix) + if err := files.RenderFiles(ignitionConfig.Files); err != nil { return fmt.Errorf("failed while rendering files from ignition config, err: %w", err) } diff --git a/internal/config/ignitionconfig.go b/internal/config/ignitionconfig.go index 56bd5e4..e26f55b 100644 --- a/internal/config/ignitionconfig.go +++ b/internal/config/ignitionconfig.go @@ -43,3 +43,26 @@ func (ignitionConfig *Ignition) GenerateFromConfigDir(configDir string) error { return nil } + +// ApplyJobLogDefaults materializes the global job-log switches on every job +// and boot job that does not set the respective option itself; explicit +// per-job values always win. +func (ignitionConfig *Ignition) ApplyJobLogDefaults(timestamps, namePrefix bool) { + apply := func(c *BaseJobConfig) { + if c.EnableTimestamps == nil { + v := timestamps + c.EnableTimestamps = &v + } + if c.EnableNamePrefix == nil { + v := namePrefix + c.EnableNamePrefix = &v + } + } + + for i := range ignitionConfig.Jobs { + apply(&ignitionConfig.Jobs[i].BaseJobConfig) + } + for i := range ignitionConfig.BootJobs { + apply(&ignitionConfig.BootJobs[i].BaseJobConfig) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index d1e19ff..e971a75 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -95,12 +95,27 @@ type BaseJobConfig struct { Controllable bool `hcl:"controllable" json:"controllable"` WorkingDirectory string `hcl:"workingDirectory" json:"workingDirectory,omitempty"` - // log config + // log config; the bool-pointers are tri-state: unset means "follow the + // global default" (see Ignition.ApplyJobLogDefaults), an explicit value + // always wins Stdout string `hcl:"stdout" json:"stdout,omitempty"` Stderr string `hcl:"stderr" json:"stderr,omitempty"` - EnableTimestamps bool `hcl:"enableTimestamps" json:"enableTimestamps"` + EnableTimestamps *bool `hcl:"enableTimestamps" json:"enableTimestamps"` TimestampFormat string `hcl:"timestampFormat" json:"timestampFormat"` // defaults to RFC3339 CustomTimestampFormat string `hcl:"customTimestampFormat" json:"customTimestampFormat"` + EnableNamePrefix *bool `hcl:"enableNamePrefix" json:"enableNamePrefix"` +} + +// TimestampsEnabled reports whether the job's output lines should be prefixed +// with a timestamp; an unset enableTimestamps counts as disabled. +func (c *BaseJobConfig) TimestampsEnabled() bool { + return c.EnableTimestamps != nil && *c.EnableTimestamps +} + +// NamePrefixEnabled reports whether the job's output lines should be prefixed +// with the job name; an unset enableNamePrefix counts as disabled. +func (c *BaseJobConfig) NamePrefixEnabled() bool { + return c.EnableNamePrefix != nil && *c.EnableNamePrefix } type Laziness struct { diff --git a/internal/config/types_test.go b/internal/config/types_test.go new file mode 100644 index 0000000..30a4176 --- /dev/null +++ b/internal/config/types_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "testing" + + "github.com/hashicorp/hcl" + "github.com/stretchr/testify/require" +) + +// The log options are tri-state: HCL must distinguish an unset option (nil) +// from an explicit false, so that ApplyJobLogDefaults only fills the gaps. +func TestHCLKeepsUnsetLogOptionsDistinctFromFalse(t *testing.T) { + src := ` +job "unset" { + command = "true" +} + +job "opt-out" { + command = "true" + enableTimestamps = false + enableNamePrefix = false +} + +job "opt-in" { + command = "true" + enableTimestamps = true + enableNamePrefix = true +} +` + + ign := &Ignition{} + require.NoError(t, hcl.Unmarshal([]byte(src), ign)) + require.Len(t, ign.Jobs, 3) + + require.Nil(t, ign.Jobs[0].EnableTimestamps) + require.Nil(t, ign.Jobs[0].EnableNamePrefix) + + require.NotNil(t, ign.Jobs[1].EnableTimestamps) + require.False(t, *ign.Jobs[1].EnableTimestamps) + require.NotNil(t, ign.Jobs[1].EnableNamePrefix) + require.False(t, *ign.Jobs[1].EnableNamePrefix) + + require.NotNil(t, ign.Jobs[2].EnableTimestamps) + require.True(t, *ign.Jobs[2].EnableTimestamps) + require.NotNil(t, ign.Jobs[2].EnableNamePrefix) + require.True(t, *ign.Jobs[2].EnableNamePrefix) +} + +func TestApplyJobLogDefaultsFillsOnlyUnsetOptions(t *testing.T) { + optOut := false + + ign := &Ignition{ + Jobs: []JobConfig{ + {BaseJobConfig: BaseJobConfig{Name: "unset"}}, + {BaseJobConfig: BaseJobConfig{ + Name: "opt-out", + EnableTimestamps: &optOut, + EnableNamePrefix: &optOut, + }}, + }, + BootJobs: []BootJobConfig{ + {BaseJobConfig: BaseJobConfig{Name: "boot-unset"}}, + }, + } + + ign.ApplyJobLogDefaults(true, true) + + require.True(t, ign.Jobs[0].TimestampsEnabled()) + require.True(t, ign.Jobs[0].NamePrefixEnabled()) + require.False(t, ign.Jobs[1].TimestampsEnabled()) + require.False(t, ign.Jobs[1].NamePrefixEnabled()) + require.True(t, ign.BootJobs[0].TimestampsEnabled()) + require.True(t, ign.BootJobs[0].NamePrefixEnabled()) +} + +func TestApplyJobLogDefaultsOffKeepsExplicitOptIn(t *testing.T) { + optIn := true + + ign := &Ignition{ + Jobs: []JobConfig{ + {BaseJobConfig: BaseJobConfig{Name: "unset"}}, + {BaseJobConfig: BaseJobConfig{ + Name: "opt-in", + EnableTimestamps: &optIn, + EnableNamePrefix: &optIn, + }}, + }, + } + + ign.ApplyJobLogDefaults(false, false) + + require.False(t, ign.Jobs[0].TimestampsEnabled()) + require.False(t, ign.Jobs[0].NamePrefixEnabled()) + require.True(t, ign.Jobs[1].TimestampsEnabled()) + require.True(t, ign.Jobs[1].NamePrefixEnabled()) +} diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 265872b..0b1cdb6 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -122,8 +122,9 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e cmd.Env = os.Environ() cmd.Dir = job.Config.WorkingDirectory - // pipe command's stdout and stderr through timestamp function if timestamps are enabled - // otherwise just redirect stdout and err to job.stdout and job.stderr + // pipe command's stdout and stderr through the line forwarder if the + // output is decorated with timestamps and/or the job name; otherwise just + // redirect stdout and err to job.stdout and job.stderr // // the pipes are created manually instead of via cmd.StdoutPipe, because // cmd.Wait closes those as soon as the main process exits, racing the @@ -132,7 +133,7 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e // reader goroutines own the read ends and close them on EOF, which arrives // once all child-side writers are gone. var pipeWriteEnds []*os.File - if job.Config.EnableTimestamps { + if job.Config.TimestampsEnabled() || job.Config.NamePrefixEnabled() { stdoutReader, stdoutWriter, err := os.Pipe() if err != nil { return fmt.Errorf("failed to create stdout pipe for process: %s", err.Error()) @@ -149,9 +150,18 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e cmd.Stderr = stderrWriter pipeWriteEnds = []*os.File{stdoutWriter, stderrWriter} - layout := job.resolveTimestampLayout(l) - go job.forwardOutput(stdoutReader, job.stdout, layout) - go job.forwardOutput(stderrReader, job.stderr, layout) + var layout string + if job.Config.TimestampsEnabled() { + layout = job.resolveTimestampLayout(l) + } + + var namePrefix []byte + if job.Config.NamePrefixEnabled() { + namePrefix = []byte("[" + job.Config.Name + "] ") + } + + go job.forwardOutput(stdoutReader, job.stdout, layout, namePrefix) + go job.forwardOutput(stderrReader, job.stderr, layout, namePrefix) } else { cmd.Stdout = job.stdout cmd.Stderr = job.stderr @@ -291,12 +301,13 @@ func (job *baseJob) resolveTimestampLayout(l *log.Entry) string { } // forwardOutput copies process output from r to w line by line, prefixing -// each line with a timestamp in the given layout. Lines longer than the read -// buffer are forwarded in chunks — the timestamp is only written at the start -// of a line and the newline only at its end — so overlong lines are split -// across writes instead of aborting the forwarding (bufio.Scanner's token -// limit would; a stopped reader lets the pipe fill up and block the child). -func (job *baseJob) forwardOutput(r io.ReadCloser, w io.Writer, timestampLayout string) { +// each line with a timestamp in the given layout (if non-empty) followed by +// the given job-name prefix (if non-empty). Lines longer than the read buffer +// are forwarded in chunks — the prefixes are only written at the start of a +// line and the newline only at its end — so overlong lines are split across +// writes instead of aborting the forwarding (bufio.Scanner's token limit +// would; a stopped reader lets the pipe fill up and block the child). +func (job *baseJob) forwardOutput(r io.ReadCloser, w io.Writer, timestampLayout string, namePrefix []byte) { defer r.Close() l := log.WithField("job.name", job.Config.Name) @@ -317,12 +328,15 @@ func (job *baseJob) forwardOutput(r io.ReadCloser, w io.Writer, timestampLayout } lineBuffer.Reset() - if !continuation && timestampLayout != "" { - // reuse the time buffer to avoid per-line allocations - timeBuffer = time.Now().AppendFormat(timeBuffer[:0], timestampLayout) - lineBuffer.WriteByte('[') - lineBuffer.Write(timeBuffer) - lineBuffer.WriteString("] ") + if !continuation { + if timestampLayout != "" { + // reuse the time buffer to avoid per-line allocations + timeBuffer = time.Now().AppendFormat(timeBuffer[:0], timestampLayout) + lineBuffer.WriteByte('[') + lineBuffer.Write(timeBuffer) + lineBuffer.WriteString("] ") + } + lineBuffer.Write(namePrefix) } lineBuffer.Write(line) if !isPrefix { diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index ca681d3..8c7c7ab 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "syscall" "testing" @@ -18,6 +19,8 @@ import ( "github.com/stretchr/testify/require" ) +func boolPtr(b bool) *bool { return &b } + func startTestJob(t *testing.T) (*baseJob, chan error) { t.Helper() @@ -63,7 +66,7 @@ func TestStartOnceKeepsLoggingOutputOfLingeringChildren(t *testing.T) { // briefly so the trap is installed before the signal arrives Command: "sh", Args: []string{"-c", "(trap '' TERM; sleep 0.3; echo lingering) & echo main; sleep 0.2"}, - EnableTimestamps: true, + EnableTimestamps: boolPtr(true), }) require.NoError(t, err) @@ -112,7 +115,7 @@ func TestStartOnceDrainsPipeAfterLogTargetCloses(t *testing.T) { Args: []string{"-c", fmt.Sprintf( "(trap '' TERM; sleep 0.3; echo lingering; sleep 0.3; echo again; : > %q) & echo main; sleep 0.2", marker, )}, - EnableTimestamps: true, + EnableTimestamps: boolPtr(true), Stdout: filepath.Join(dir, "stdout.log"), }) require.NoError(t, err) @@ -185,8 +188,9 @@ func TestResolveTimestampLayoutPrefersCustomFormat(t *testing.T) { } // forwardOutput must not abort on lines longer than its read buffer: the -// timestamp is written once per line, overlong lines arrive in chunks, and -// empty as well as unterminated final lines are forwarded as lines. +// timestamp and name prefix are written once per line, overlong lines arrive +// in chunks, and empty as well as unterminated final lines are forwarded as +// lines. func TestForwardOutputHandlesOverlongLines(t *testing.T) { logHook := logtest.NewGlobal() defer logHook.Reset() @@ -196,11 +200,11 @@ func TestForwardOutputHandlesOverlongLines(t *testing.T) { var buf bytes.Buffer job := &baseJob{Config: &config.BaseJobConfig{Name: "long-line-job"}} - job.forwardOutput(io.NopCloser(strings.NewReader(input)), &buf, "2006") + job.forwardOutput(io.NopCloser(strings.NewReader(input)), &buf, "2006", []byte("[long-line-job] ")) - year := fmt.Sprintf("[%d] ", time.Now().Year()) + prefix := fmt.Sprintf("[%d] [long-line-job] ", time.Now().Year()) require.Equal(t, - year+"first\n"+year+payload+"\n"+year+"\n"+year+"last\n", + prefix+"first\n"+prefix+payload+"\n"+prefix+"\n"+prefix+"last\n", buf.String()) for _, entry := range logHook.AllEntries() { @@ -208,6 +212,61 @@ func TestForwardOutputHandlesOverlongLines(t *testing.T) { } } +func TestStartOncePrefixesOutputWithTimestampAndJobName(t *testing.T) { + job := &baseJob{} + err := job.init(&config.BaseJobConfig{ + Name: "prefix-job", + Command: "sh", + Args: []string{"-c", "echo hello"}, + EnableTimestamps: boolPtr(true), + EnableNamePrefix: boolPtr(true), + }) + require.NoError(t, err) + + // stand-in for the passthrough case (job.stdout = os.Stdout), which + // closeStdFiles leaves open when startOnce returns + logFile := filepath.Join(t.TempDir(), "stdout.log") + out, err := os.Create(logFile) + require.NoError(t, err) + defer out.Close() + job.stdout = out + job.stderr = out + + require.NoError(t, job.startOnce(context.Background(), nil)) + + // the forwarder goroutine may still be flushing when startOnce returns + pattern := `^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})\] \[prefix-job\] hello\n$` + require.Eventually(t, func() bool { + content, err := os.ReadFile(logFile) + return err == nil && regexp.MustCompile(pattern).Match(content) + }, 5*time.Second, 50*time.Millisecond, "output should carry an RFC3339 timestamp and the job name") +} + +func TestStartOncePrefixesOutputWithJobNameOnly(t *testing.T) { + job := &baseJob{} + err := job.init(&config.BaseJobConfig{ + Name: "name-only-job", + Command: "sh", + Args: []string{"-c", "echo hello"}, + EnableNamePrefix: boolPtr(true), + }) + require.NoError(t, err) + + logFile := filepath.Join(t.TempDir(), "stdout.log") + out, err := os.Create(logFile) + require.NoError(t, err) + defer out.Close() + job.stdout = out + job.stderr = out + + require.NoError(t, job.startOnce(context.Background(), nil)) + + require.Eventually(t, func() bool { + content, err := os.ReadFile(logFile) + return err == nil && string(content) == "[name-only-job] hello\n" + }, 5*time.Second, 50*time.Millisecond, "output should carry the job name and no timestamp") +} + func TestStartOnceReportsExpectedStopAsIntentional(t *testing.T) { job, errChan := startTestJob(t) From 1be77ed5150f21e3e405c681e9ca36bcc0bf5781 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Fri, 24 Jul 2026 17:40:51 +0200 Subject: [PATCH 06/14] document job log options and global switches Part of #120. Co-Authored-By: Claude Fable 5 --- README.md | 13 ++++++++++++- examples/timestamps.d/timestamps.hcl | 14 +++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1fa6f13..c89e5c5 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,16 @@ job "foo" { Additionally, you can enable timestamps for the output of a job using `enableTimestamps` and specify a custom format using `timestampFormat`. -Formats are named after their constant name in the Golang [`time` package](https://pkg.go.dev/time#pkg-constants) (lookup table at the bottom). +Formats are named after their constant name in the Golang [`time` package](https://pkg.go.dev/time#pkg-constants) (lookup table at the bottom). The default is `RFC3339`. You can also specify your own format by setting `customTimestampFormat` to a custom format string like "2006-01-02 15:04:05". Whatever is set in `timestampFormat` will be ignored in that case. +With `enableNamePrefix`, each output line is prefixed with the job's name. When both options are enabled, the timestamp comes first: + +``` +[2026-07-24T10:28:52Z] [foo] some output line +``` + ```hcl job "foo" { command = "/usr/local/bin/foo" @@ -166,9 +172,12 @@ job "foo" { enableTimestamps = true timestampFormat = "RFC3339" # default customTimestampFormat = "" # default + enableNamePrefix = true # defaults to false } ``` +Both options can also be enabled globally for all jobs (including boot jobs) with `mittnite up --job-log-timestamps --job-log-name-prefix`, or via the environment variables `MITTNITE_JOB_LOG_TIMESTAMPS` and `MITTNITE_JOB_LOG_NAME_PREFIX`. An explicit per-job `enableTimestamps` / `enableNamePrefix` — including an explicit `false` — always wins over the global switch. + You can configure a Job to watch files and to send a signal to the managed process if that file changes. This can be used, for example, to send a `SIGHUP` to a process to reload its configuration file when it changes. ```hcl @@ -256,6 +265,8 @@ boot "setup" { } ``` +Boot jobs write to mittnite's stdout/stderr and support the same log options as regular jobs (`stdout`, `stderr`, `enableTimestamps`, `timestampFormat`, `customTimestampFormat`, `enableNamePrefix`). + #### File Possible directives to use in a file definition. diff --git a/examples/timestamps.d/timestamps.hcl b/examples/timestamps.d/timestamps.hcl index c81b383..f4c25ed 100644 --- a/examples/timestamps.d/timestamps.hcl +++ b/examples/timestamps.d/timestamps.hcl @@ -46,4 +46,16 @@ job "echoloop_notime" { stdout = "test_notime.log" stderr = "test_notime_error.log" -} \ No newline at end of file +} +job "echoloop_nameprefix" { + command = "/bin/bash" + args = [ + "-c", + "while true ; do echo 'test'; sleep 10; done" + ] + + stdout = "test_nameprefix.log" + stderr = "test_nameprefix_error.log" + enableTimestamps = true + enableNamePrefix = true +} From 1b0599e35bc5e63efddcac3f26483b58af565bd5 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Mon, 27 Jul 2026 14:23:16 +0200 Subject: [PATCH 07/14] flush file-backed log targets before closing them on job exit closeStdFiles runs the moment startOnce returns, while the forwarder goroutines are still draining the pipes; a job's own final output lines could race into the ErrClosed discard path and be lost. startOnce now waits for the forwarders to hit EOF before the files are closed. EOF arrives as soon as the job's last write end is gone, so the wait is normally instant. Only when forked children outlive the job does the one-second cap strike: what they write within that window still reaches the file; output after the close is discarded. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob.go | 32 ++++++++++++++++++++++++++++++-- pkg/proc/basejob_test.go | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 0b1cdb6..8604a9b 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -160,8 +160,36 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e namePrefix = []byte("[" + job.Config.Name + "] ") } - go job.forwardOutput(stdoutReader, job.stdout, layout, namePrefix) - go job.forwardOutput(stderrReader, job.stderr, layout, namePrefix) + var forwardersDone sync.WaitGroup + forwardersDone.Add(2) + go func() { + defer forwardersDone.Done() + job.forwardOutput(stdoutReader, job.stdout, layout, namePrefix) + }() + go func() { + defer forwardersDone.Done() + job.forwardOutput(stderrReader, job.stderr, layout, namePrefix) + }() + + // file-backed log targets are closed by the deferred closeStdFiles as + // soon as startOnce returns; let the forwarders drain the job's + // remaining output into them first, so its final lines are not lost + // to the discard path below. The readers see EOF as soon as the last + // write end is gone, so the full second is only spent when children + // outlive the job — their output is discarded, as before. + if len(job.Config.Stdout) > 0 || len(job.Config.Stderr) > 0 { + defer func() { + done := make(chan struct{}) + go func() { + forwardersDone.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + } + }() + } } else { cmd.Stdout = job.stdout cmd.Stderr = job.stderr diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index 8c7c7ab..c04484a 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -107,13 +107,14 @@ func TestStartOnceDrainsPipeAfterLogTargetCloses(t *testing.T) { err := job.init(&config.BaseJobConfig{ Name: "draining-job", Command: "sh", - // the second write happens well after the reader saw the closed log - // target; if the read end were closed by then, the write would raise - // SIGPIPE and the marker file would never be created. The main process - // sleeps briefly so the child has installed its TERM trap before - // startOnce signals the process group. + // the child outlives the one-second forwarder flush wait, so its + // second write happens well after the log target was closed; if the + // read end were closed by then, the write would raise SIGPIPE and the + // marker file would never be created. The main process sleeps briefly + // so the child has installed its TERM trap before startOnce signals + // the process group. Args: []string{"-c", fmt.Sprintf( - "(trap '' TERM; sleep 0.3; echo lingering; sleep 0.3; echo again; : > %q) & echo main; sleep 0.2", marker, + "(trap '' TERM; sleep 0.3; echo lingering; sleep 1.2; echo again; : > %q) & echo main; sleep 0.2", marker, )}, EnableTimestamps: boolPtr(true), Stdout: filepath.Join(dir, "stdout.log"), @@ -212,6 +213,32 @@ func TestForwardOutputHandlesOverlongLines(t *testing.T) { } } +// With a file-backed log target, startOnce waits for the forwarders to drain +// the job's own final output into the file before closeStdFiles closes it; +// the last lines of a job must not race into the discard path. +func TestStartOnceFlushesFileTargetBeforeReturning(t *testing.T) { + logFile := filepath.Join(t.TempDir(), "stdout.log") + + job := &baseJob{} + err := job.init(&config.BaseJobConfig{ + Name: "flush-job", + Command: "sh", + Args: []string{"-c", "echo final-line"}, + EnableTimestamps: boolPtr(true), + EnableNamePrefix: boolPtr(true), + Stdout: logFile, + }) + require.NoError(t, err) + + require.NoError(t, job.startOnce(context.Background(), nil)) + + // deliberately no Eventually: the file must be complete when startOnce + // has returned + content, err := os.ReadFile(logFile) + require.NoError(t, err) + require.Regexp(t, `^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})\] \[flush-job\] final-line\n$`, string(content)) +} + func TestStartOncePrefixesOutputWithTimestampAndJobName(t *testing.T) { job := &baseJob{} err := job.init(&config.BaseJobConfig{ From 5f4e84648337a794f42b2e141208f4172c4d221c Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Mon, 27 Jul 2026 14:22:39 +0200 Subject: [PATCH 08/14] fix forwarder-target race when a restart follows lingering children The forwarder goroutines read job.stdout/job.stderr inside their closures since the flush wait was introduced. When that wait times out because a lingering child keeps the pipes open, the forwarders outlive startOnce, and the next start's CreateAndOpenStdFile reassigns both fields without a happens-before edge to those reads. Capture the targets before spawning, as the plain go statement did before. Found by go test -race on a restart-under-lingering-children repro, which is added as a regression test. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob.go | 11 ++++++++--- pkg/proc/basejob_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 8604a9b..4027323 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -162,13 +162,17 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e var forwardersDone sync.WaitGroup forwardersDone.Add(2) + // capture the targets outside the closures: when the flush wait below + // times out, these goroutines outlive startOnce, and a restart's + // CreateAndOpenStdFile reassigns job.stdout/job.stderr unsynchronized + stdout, stderr := job.stdout, job.stderr go func() { defer forwardersDone.Done() - job.forwardOutput(stdoutReader, job.stdout, layout, namePrefix) + job.forwardOutput(stdoutReader, stdout, layout, namePrefix) }() go func() { defer forwardersDone.Done() - job.forwardOutput(stderrReader, job.stderr, layout, namePrefix) + job.forwardOutput(stderrReader, stderr, layout, namePrefix) }() // file-backed log targets are closed by the deferred closeStdFiles as @@ -176,7 +180,8 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e // remaining output into them first, so its final lines are not lost // to the discard path below. The readers see EOF as soon as the last // write end is gone, so the full second is only spent when children - // outlive the job — their output is discarded, as before. + // outlive the job — what they write within the window still reaches + // the file; only output after the close is discarded. if len(job.Config.Stdout) > 0 || len(job.Config.Stderr) > 0 { defer func() { done := make(chan struct{}) diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index c04484a..3985a38 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -213,6 +213,31 @@ func TestForwardOutputHandlesOverlongLines(t *testing.T) { } } +// When the flush wait times out because a lingering child keeps the pipes +// open, the forwarder goroutines outlive startOnce; a restart then reassigns +// job.stdout/job.stderr via CreateAndOpenStdFile, so the forwarders must have +// captured their targets with a proper happens-before edge (only fails under +// -race). +func TestStartOnceRestartDoesNotRaceWithLingeringForwarders(t *testing.T) { + job := &baseJob{} + err := job.init(&config.BaseJobConfig{ + Name: "restart-race-job", + Command: "sh", + // the child ignores TERM and holds the inherited pipe write ends open + // well past the flush wait without writing, so the forwarders never + // see EOF before the restart; the main process sleeps briefly so the + // trap is installed before startOnce signals the process group + Args: []string{"-c", "(trap '' TERM; sleep 3) & sleep 0.2"}, + EnableTimestamps: boolPtr(true), + Stdout: filepath.Join(t.TempDir(), "stdout.log"), + }) + require.NoError(t, err) + + require.NoError(t, job.startOnce(context.Background(), nil)) + // immediate restart, like CommonJob.Run does after ProcessWillBeRestartedError + require.NoError(t, job.startOnce(context.Background(), nil)) +} + // With a file-backed log target, startOnce waits for the forwarders to drain // the job's own final output into the file before closeStdFiles closes it; // the last lines of a job must not race into the discard path. From a02d2a2fc981618d05ea10bea6a74ff2f0c6c8a5 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Mon, 27 Jul 2026 14:23:00 +0200 Subject: [PATCH 09/14] harden log-option tests and correct the forwarder comment - cover the MITTNITE_JOB_LOG_* environment switches (envBool, warnUnparsableEnvBools), which had no tests - make the overlong-line test independent of the wall-clock year - pin that ApplyJobLogDefaults materializes an explicit false, which is visible in the job status API - the forwarder comment claimed a stopped reader would block the child on a full pipe; the deferred Close of the read end actually kills it with SIGPIPE on the next write Part of #120. Co-Authored-By: Claude Fable 5 --- cmd/up_test.go | 48 +++++++++++++++++++++++++++++++++++ internal/config/types_test.go | 4 +++ pkg/proc/basejob.go | 3 ++- pkg/proc/basejob_test.go | 6 +++-- 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 cmd/up_test.go diff --git a/cmd/up_test.go b/cmd/up_test.go new file mode 100644 index 0000000..da924a6 --- /dev/null +++ b/cmd/up_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "testing" + + log "github.com/sirupsen/logrus" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/require" +) + +func TestEnvBool(t *testing.T) { + cases := map[string]bool{ + "1": true, + "true": true, + "TRUE": true, + "t": true, + "0": false, + "false": false, + "": false, + "yes": false, // not a strconv.ParseBool value, counts as false + } + + for value, expected := range cases { + t.Setenv("MITTNITE_ENVBOOL_TEST", value) + require.Equal(t, expected, envBool("MITTNITE_ENVBOOL_TEST"), "value %q", value) + } + + require.False(t, envBool("MITTNITE_ENVBOOL_TEST_UNSET")) +} + +func TestWarnUnparsableEnvBools(t *testing.T) { + logHook := logtest.NewGlobal() + defer logHook.Reset() + + t.Setenv(envJobLogTimestamps, "yes") + t.Setenv(envJobLogNamePrefix, "true") + + warnUnparsableEnvBools() + + var warnings []string + for _, entry := range logHook.AllEntries() { + if entry.Level == log.WarnLevel { + warnings = append(warnings, entry.Message) + } + } + require.Len(t, warnings, 1, "only the unparsable variable should be warned about") + require.Contains(t, warnings[0], envJobLogTimestamps) +} diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 30a4176..9ef159d 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -89,6 +89,10 @@ func TestApplyJobLogDefaultsOffKeepsExplicitOptIn(t *testing.T) { ign.ApplyJobLogDefaults(false, false) + // the accessors also return false for nil, so pin that the "off" default + // is materialized as an explicit false (visible in the job status API) + require.NotNil(t, ign.Jobs[0].EnableTimestamps) + require.NotNil(t, ign.Jobs[0].EnableNamePrefix) require.False(t, ign.Jobs[0].TimestampsEnabled()) require.False(t, ign.Jobs[0].NamePrefixEnabled()) require.True(t, ign.Jobs[1].TimestampsEnabled()) diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 4027323..908e550 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -339,7 +339,8 @@ func (job *baseJob) resolveTimestampLayout(l *log.Entry) string { // are forwarded in chunks — the prefixes are only written at the start of a // line and the newline only at its end — so overlong lines are split across // writes instead of aborting the forwarding (bufio.Scanner's token limit -// would; a stopped reader lets the pipe fill up and block the child). +// would abort it, and closing the read end would then kill the child with +// SIGPIPE on its next write). func (job *baseJob) forwardOutput(r io.ReadCloser, w io.Writer, timestampLayout string, namePrefix []byte) { defer r.Close() diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index 3985a38..9e1efd5 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -201,9 +201,11 @@ func TestForwardOutputHandlesOverlongLines(t *testing.T) { var buf bytes.Buffer job := &baseJob{Config: &config.BaseJobConfig{Name: "long-line-job"}} - job.forwardOutput(io.NopCloser(strings.NewReader(input)), &buf, "2006", []byte("[long-line-job] ")) + // the layout contains no time components, so the expected output is + // deterministic while the timestamp code path is still exercised + job.forwardOutput(io.NopCloser(strings.NewReader(input)), &buf, "T", []byte("[long-line-job] ")) - prefix := fmt.Sprintf("[%d] [long-line-job] ", time.Now().Year()) + prefix := "[T] [long-line-job] " require.Equal(t, prefix+"first\n"+prefix+payload+"\n"+prefix+"\n"+prefix+"last\n", buf.String()) From 6e01d9fc77a6a61dd65cc611667e02867dfa7c44 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Wed, 29 Jul 2026 14:33:37 +0200 Subject: [PATCH 10/14] let canFail rescue boot jobs with broken log targets Opening the configured log files in NewBootJob (via baseJob.init) made an unopenable path fail the construction: Runner.Boot returned before any boot job ran and mittnite aborted fatally, where on master the same config booted with a "job failed, but is allowed to fail" warning. The constructor-opened files also leaked, since startOnce reopens them and closeStdFiles only closes that second pair. init no longer performs file I/O; startOnce opens the files on every start, and the error flows through BootJob.Run's canFail handling again. Common and lazy jobs keep failing fast on broken paths: their constructors still open the files explicitly. closeStdFiles now also refuses to close the process-wide streams: after a failed open, job.stdout/job.stderr still point at os.Stdout/os.Stderr, and closing those silenced all further forwarded output of every job. Boot-job errors in Runner.Boot carry the job name now, like Runner.Init. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob.go | 6 +++-- pkg/proc/basejob_test.go | 53 +++++++++++++++++++++++++++++----------- pkg/proc/runner.go | 2 +- pkg/proc/types.go | 25 +++++++++++-------- 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 908e550..2861eb2 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -292,8 +292,10 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e } func (job *baseJob) closeStdFiles() { - hasStdout := len(job.Config.Stdout) > 0 - hasStderr := len(job.Config.Stderr) > 0 && job.Config.Stderr != job.Config.Stdout + // when opening a configured log file failed, job.stdout/job.stderr still + // point at the process-wide streams — never close those + hasStdout := len(job.Config.Stdout) > 0 && job.stdout != os.Stdout + hasStderr := len(job.Config.Stderr) > 0 && job.Config.Stderr != job.Config.Stdout && job.stderr != os.Stderr if hasStdout { job.stdout.Close() } diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index 9e1efd5..fa0517c 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -25,12 +25,11 @@ func startTestJob(t *testing.T) (*baseJob, chan error) { t.Helper() job := &baseJob{} - err := job.init(&config.BaseJobConfig{ + job.init(&config.BaseJobConfig{ Name: "test-job", Command: "sleep", Args: []string{"30"}, }) - require.NoError(t, err) // receiving from the process channel synchronizes with startOnce having // set job.cmd, so the job can safely be signaled afterwards @@ -59,7 +58,7 @@ func TestStartOnceKeepsLoggingOutputOfLingeringChildren(t *testing.T) { defer logHook.Reset() job := &baseJob{} - err := job.init(&config.BaseJobConfig{ + job.init(&config.BaseJobConfig{ Name: "lingering-job", // the child traps TERM because startOnce signals the job's process // group once the main process has exited; the main process sleeps @@ -68,7 +67,6 @@ func TestStartOnceKeepsLoggingOutputOfLingeringChildren(t *testing.T) { Args: []string{"-c", "(trap '' TERM; sleep 0.3; echo lingering) & echo main; sleep 0.2"}, EnableTimestamps: boolPtr(true), }) - require.NoError(t, err) // stand-in for the passthrough case (job.stdout = os.Stdout), which // closeStdFiles leaves open when startOnce returns @@ -104,7 +102,7 @@ func TestStartOnceDrainsPipeAfterLogTargetCloses(t *testing.T) { marker := filepath.Join(dir, "marker") job := &baseJob{} - err := job.init(&config.BaseJobConfig{ + job.init(&config.BaseJobConfig{ Name: "draining-job", Command: "sh", // the child outlives the one-second forwarder flush wait, so its @@ -119,7 +117,6 @@ func TestStartOnceDrainsPipeAfterLogTargetCloses(t *testing.T) { EnableTimestamps: boolPtr(true), Stdout: filepath.Join(dir, "stdout.log"), }) - require.NoError(t, err) require.NoError(t, job.startOnce(context.Background(), nil)) @@ -141,6 +138,38 @@ func TestNewBootJobInitializesStdStreams(t *testing.T) { require.Same(t, os.Stderr, job.stderr) } +// A boot job's configured log files are only opened in startOnce, so an +// unopenable path flows through Run's canFail handling instead of failing the +// construction — which would abort mittnite's entire boot. +func TestBootJobWithUnopenableLogTargetHonorsCanFail(t *testing.T) { + parent := filepath.Join(t.TempDir(), "not-a-dir") + require.NoError(t, os.WriteFile(parent, nil, 0o644)) + + newJob := func(canFail bool) *BootJob { + job, err := NewBootJob(&config.BootJobConfig{ + BaseJobConfig: config.BaseJobConfig{ + Name: "boot-bad-log-job", + Command: "true", + CanFail: canFail, + Stdout: filepath.Join(parent, "boot.log"), + }, + }) + require.NoError(t, err, "construction must not open the log target") + return job + } + + require.NoError(t, newJob(true).Run(context.Background()), + "canFail must rescue the failing open") + require.Error(t, newJob(false).Run(context.Background())) + + // the failed open left job.stdout/job.stderr pointing at the process-wide + // streams; closeStdFiles must not have closed those + _, err := os.Stdout.Stat() + require.NoError(t, err) + _, err = os.Stderr.Stat() + require.NoError(t, err) +} + // An unset timestampFormat is the documented default (RFC3339) and must not // trigger the unknown-format warning. func TestResolveTimestampLayoutDefaultsToRFC3339WithoutWarning(t *testing.T) { @@ -222,7 +251,7 @@ func TestForwardOutputHandlesOverlongLines(t *testing.T) { // -race). func TestStartOnceRestartDoesNotRaceWithLingeringForwarders(t *testing.T) { job := &baseJob{} - err := job.init(&config.BaseJobConfig{ + job.init(&config.BaseJobConfig{ Name: "restart-race-job", Command: "sh", // the child ignores TERM and holds the inherited pipe write ends open @@ -233,7 +262,6 @@ func TestStartOnceRestartDoesNotRaceWithLingeringForwarders(t *testing.T) { EnableTimestamps: boolPtr(true), Stdout: filepath.Join(t.TempDir(), "stdout.log"), }) - require.NoError(t, err) require.NoError(t, job.startOnce(context.Background(), nil)) // immediate restart, like CommonJob.Run does after ProcessWillBeRestartedError @@ -247,7 +275,7 @@ func TestStartOnceFlushesFileTargetBeforeReturning(t *testing.T) { logFile := filepath.Join(t.TempDir(), "stdout.log") job := &baseJob{} - err := job.init(&config.BaseJobConfig{ + job.init(&config.BaseJobConfig{ Name: "flush-job", Command: "sh", Args: []string{"-c", "echo final-line"}, @@ -255,7 +283,6 @@ func TestStartOnceFlushesFileTargetBeforeReturning(t *testing.T) { EnableNamePrefix: boolPtr(true), Stdout: logFile, }) - require.NoError(t, err) require.NoError(t, job.startOnce(context.Background(), nil)) @@ -268,14 +295,13 @@ func TestStartOnceFlushesFileTargetBeforeReturning(t *testing.T) { func TestStartOncePrefixesOutputWithTimestampAndJobName(t *testing.T) { job := &baseJob{} - err := job.init(&config.BaseJobConfig{ + job.init(&config.BaseJobConfig{ Name: "prefix-job", Command: "sh", Args: []string{"-c", "echo hello"}, EnableTimestamps: boolPtr(true), EnableNamePrefix: boolPtr(true), }) - require.NoError(t, err) // stand-in for the passthrough case (job.stdout = os.Stdout), which // closeStdFiles leaves open when startOnce returns @@ -298,13 +324,12 @@ func TestStartOncePrefixesOutputWithTimestampAndJobName(t *testing.T) { func TestStartOncePrefixesOutputWithJobNameOnly(t *testing.T) { job := &baseJob{} - err := job.init(&config.BaseJobConfig{ + job.init(&config.BaseJobConfig{ Name: "name-only-job", Command: "sh", Args: []string{"-c", "echo hello"}, EnableNamePrefix: boolPtr(true), }) - require.NoError(t, err) logFile := filepath.Join(t.TempDir(), "stdout.log") out, err := os.Create(logFile) diff --git a/pkg/proc/runner.go b/pkg/proc/runner.go index 501e7ff..81d242a 100644 --- a/pkg/proc/runner.go +++ b/pkg/proc/runner.go @@ -43,7 +43,7 @@ func (r *Runner) Boot() error { for j := range r.IgnitionConfig.BootJobs { job, err := NewBootJob(&r.IgnitionConfig.BootJobs[j]) if err != nil { - return err + return fmt.Errorf("error initializing boot job %s: %w", r.IgnitionConfig.BootJobs[j].Name, err) } r.bootJobs = append(r.bootJobs, job) diff --git a/pkg/proc/types.go b/pkg/proc/types.go index 798f8c9..8b1ba74 100644 --- a/pkg/proc/types.go +++ b/pkg/proc/types.go @@ -136,15 +136,15 @@ type Job interface { // init initializes the baseJob in place; baseJob must not be copied once // initialized, since it contains sync.WaitGroup and atomic.Bool fields. -func (job *baseJob) init(jobConfig *config.BaseJobConfig) error { +// Configured log files are not opened here — startOnce does that on every +// start — so constructors stay free of file I/O and a broken log path of a +// boot job can be rescued by its canFail handling instead of failing the +// construction. +func (job *baseJob) init(jobConfig *config.BaseJobConfig) { job.Config = jobConfig job.stdout = os.Stdout job.stderr = os.Stderr job.SetPhase(JobPhaseReasonAwaitingReadiness) - - // no-ops for unset stdout/stderr, so it is safe to call unconditionally; - // stderr may be configured without stdout - return job.CreateAndOpenStdFile(jobConfig) } func (job *baseJob) CreateAndOpenStdFile(jobConfig *config.BaseJobConfig) error { @@ -177,7 +177,12 @@ func NewCommonJob(c *config.JobConfig) (*CommonJob, error) { Config: c, } - if err := j.baseJob.init(&c.BaseJobConfig); err != nil { + j.baseJob.init(&c.BaseJobConfig) + + // opening the configured log files here as well surfaces broken log paths + // at config load; no-ops for unset stdout/stderr, so it is safe to call + // unconditionally (stderr may be configured without stdout) + if err := j.baseJob.CreateAndOpenStdFile(&c.BaseJobConfig); err != nil { return nil, err } @@ -191,7 +196,9 @@ func NewLazyJob(c *config.JobConfig) (*LazyJob, error) { }, } - if err := j.baseJob.init(&c.BaseJobConfig); err != nil { + j.baseJob.init(&c.BaseJobConfig) + + if err := j.baseJob.CreateAndOpenStdFile(&c.BaseJobConfig); err != nil { return nil, err } @@ -225,9 +232,7 @@ func NewBootJob(c *config.BootJobConfig) (*BootJob, error) { Config: c, } - if err := bj.baseJob.init(&c.BaseJobConfig); err != nil { - return nil, err - } + bj.baseJob.init(&c.BaseJobConfig) if ts := c.Timeout; ts != "" { t, err := time.ParseDuration(ts) From 614fc4ef720e64965acd838c08518f01b925c162 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Wed, 29 Jul 2026 14:33:54 +0200 Subject: [PATCH 11/14] always give the forwarders a bounded drain before startOnce returns The flush wait only covered jobs with file-backed log targets. For passthrough targets the forwarders race mittnite's own exit instead: a boot job that dumps diagnostics and fails takes the whole process down right after startOnce returns, truncating exactly the output that explains the failure. The bounded wait is safe for passthrough targets (they are never closed), so apply it to every decorated job. Also assert the one-second cap in the restart-race test: an unbounded wait would block startOnce on lingering children, making the job unrestartable and shutdown hang. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob.go | 37 ++++++++++++++++++------------------- pkg/proc/basejob_test.go | 5 +++++ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 2861eb2..6561a0d 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -175,26 +175,25 @@ func (job *baseJob) startOnce(ctx context.Context, process chan<- *os.Process) e job.forwardOutput(stderrReader, stderr, layout, namePrefix) }() - // file-backed log targets are closed by the deferred closeStdFiles as - // soon as startOnce returns; let the forwarders drain the job's - // remaining output into them first, so its final lines are not lost - // to the discard path below. The readers see EOF as soon as the last - // write end is gone, so the full second is only spent when children - // outlive the job — what they write within the window still reaches - // the file; only output after the close is discarded. - if len(job.Config.Stdout) > 0 || len(job.Config.Stderr) > 0 { - defer func() { - done := make(chan struct{}) - go func() { - forwardersDone.Wait() - close(done) - }() - select { - case <-done: - case <-time.After(time.Second): - } + // let the forwarders drain the job's remaining output before startOnce + // returns: the deferred closeStdFiles closes file-backed log targets, + // and after a failed boot job or on shutdown mittnite itself may exit + // right afterwards — either would cut off the job's final lines. The + // readers see EOF as soon as the last write end is gone, so the full + // second is only spent when children outlive the job — what they + // write within the window is still forwarded; into closed file + // targets, later output is discarded. + defer func() { + done := make(chan struct{}) + go func() { + forwardersDone.Wait() + close(done) }() - } + select { + case <-done: + case <-time.After(time.Second): + } + }() } else { cmd.Stdout = job.stdout cmd.Stderr = job.stderr diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index fa0517c..ea31e79 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -263,9 +263,14 @@ func TestStartOnceRestartDoesNotRaceWithLingeringForwarders(t *testing.T) { Stdout: filepath.Join(t.TempDir(), "stdout.log"), }) + start := time.Now() require.NoError(t, job.startOnce(context.Background(), nil)) // immediate restart, like CommonJob.Run does after ProcessWillBeRestartedError require.NoError(t, job.startOnce(context.Background(), nil)) + + // the flush wait is capped at one second per start; an unbounded wait + // would block on each child's 3s pipe hold and take over six seconds + require.Less(t, time.Since(start), 4500*time.Millisecond) } // With a file-backed log target, startOnce waits for the forwarders to drain From c783e59525eb0d6ccd0de620d1df65872dc41d57 Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Wed, 29 Jul 2026 14:34:05 +0200 Subject: [PATCH 12/14] note chunked-line interleaving on shared log targets Chunks of an overlong line are separate writes; on a target shared with other forwarders, their output can interleave between the chunks. Part of #120. Co-Authored-By: Claude Fable 5 --- README.md | 2 ++ pkg/proc/basejob.go | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c89e5c5..4f51ed8 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ job "foo" { Both options can also be enabled globally for all jobs (including boot jobs) with `mittnite up --job-log-timestamps --job-log-name-prefix`, or via the environment variables `MITTNITE_JOB_LOG_TIMESTAMPS` and `MITTNITE_JOB_LOG_NAME_PREFIX`. An explicit per-job `enableTimestamps` / `enableNamePrefix` — including an explicit `false` — always wins over the global switch. +With either option enabled, output is forwarded line by line. Single lines longer than 64 KiB are forwarded in multiple chunks; on a shared target, output of other jobs or streams may interleave between the chunks of such a line. + You can configure a Job to watch files and to send a signal to the managed process if that file changes. This can be used, for example, to send a `SIGHUP` to a process to reload its configuration file when it changes. ```hcl diff --git a/pkg/proc/basejob.go b/pkg/proc/basejob.go index 6561a0d..cd3b88d 100644 --- a/pkg/proc/basejob.go +++ b/pkg/proc/basejob.go @@ -341,7 +341,9 @@ func (job *baseJob) resolveTimestampLayout(l *log.Entry) string { // line and the newline only at its end — so overlong lines are split across // writes instead of aborting the forwarding (bufio.Scanner's token limit // would abort it, and closing the read end would then kill the child with -// SIGPIPE on its next write). +// SIGPIPE on its next write). The chunks are separate writes, so on a target +// shared with other forwarders, their output can interleave between the +// chunks of an overlong line. func (job *baseJob) forwardOutput(r io.ReadCloser, w io.Writer, timestampLayout string, namePrefix []byte) { defer r.Close() From 4e3069aa781a7453937fde3d69d278b4bf8b1a8a Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Fri, 31 Jul 2026 13:33:21 +0200 Subject: [PATCH 13/14] validate log targets without holding constructor-opened files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewCommonJob and NewLazyJob opened the configured log files for early validation, but startOnce opens its own handles on every run and CreateAndOpenStdFile overwrites job.stdout/job.stderr without closing the previous pair — the constructor-opened descriptors leaked for the process lifetime. Validate by opening and closing the paths instead; startOnce is the single owner of the per-run file handles. Part of #120. Co-Authored-By: Claude Fable 5 --- pkg/proc/basejob_test.go | 26 ++++++++++++++++++++++++++ pkg/proc/types.go | 26 +++++++++++++++++++++----- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pkg/proc/basejob_test.go b/pkg/proc/basejob_test.go index ea31e79..cb0e0d3 100644 --- a/pkg/proc/basejob_test.go +++ b/pkg/proc/basejob_test.go @@ -138,6 +138,32 @@ func TestNewBootJobInitializesStdStreams(t *testing.T) { require.Same(t, os.Stderr, job.stderr) } +// Construction validates configured log paths but must not keep the files +// open: startOnce opens its own handles on every run and would overwrite the +// constructor-opened pair without closing it, leaking the descriptors. +func TestNewCommonJobValidatesLogTargetsWithoutKeepingThemOpen(t *testing.T) { + job, err := NewCommonJob(&config.JobConfig{ + BaseJobConfig: config.BaseJobConfig{ + Name: "validated-job", + Command: "true", + Stdout: filepath.Join(t.TempDir(), "stdout.log"), + }, + }) + require.NoError(t, err) + require.Same(t, os.Stdout, job.stdout, "no file handle should be held after construction") + + parent := filepath.Join(t.TempDir(), "not-a-dir") + require.NoError(t, os.WriteFile(parent, nil, 0o644)) + _, err = NewCommonJob(&config.JobConfig{ + BaseJobConfig: config.BaseJobConfig{ + Name: "broken-target-job", + Command: "true", + Stdout: filepath.Join(parent, "stdout.log"), + }, + }) + require.Error(t, err, "broken log paths should still fail at config load") +} + // A boot job's configured log files are only opened in startOnce, so an // unopenable path flows through Run's canFail handling instead of failing the // construction — which would abort mittnite's entire boot. diff --git a/pkg/proc/types.go b/pkg/proc/types.go index 8b1ba74..243d691 100644 --- a/pkg/proc/types.go +++ b/pkg/proc/types.go @@ -147,6 +147,25 @@ func (job *baseJob) init(jobConfig *config.BaseJobConfig) { job.SetPhase(JobPhaseReasonAwaitingReadiness) } +// validateStdFiles opens and immediately closes the configured log files, so +// broken log paths fail at config load without holding file handles; +// startOnce is the single owner of the per-run handles. +func validateStdFiles(jobConfig *config.BaseJobConfig) error { + for _, path := range []string{jobConfig.Stdout, jobConfig.Stderr} { + if path == "" { + continue + } + + f, err := prepareStdFile(path) + if err != nil { + return err + } + f.Close() + } + + return nil +} + func (job *baseJob) CreateAndOpenStdFile(jobConfig *config.BaseJobConfig) error { if jobConfig.Stdout != "" { stdout, err := prepareStdFile(jobConfig.Stdout) @@ -179,10 +198,7 @@ func NewCommonJob(c *config.JobConfig) (*CommonJob, error) { j.baseJob.init(&c.BaseJobConfig) - // opening the configured log files here as well surfaces broken log paths - // at config load; no-ops for unset stdout/stderr, so it is safe to call - // unconditionally (stderr may be configured without stdout) - if err := j.baseJob.CreateAndOpenStdFile(&c.BaseJobConfig); err != nil { + if err := validateStdFiles(&c.BaseJobConfig); err != nil { return nil, err } @@ -198,7 +214,7 @@ func NewLazyJob(c *config.JobConfig) (*LazyJob, error) { j.baseJob.init(&c.BaseJobConfig) - if err := j.baseJob.CreateAndOpenStdFile(&c.BaseJobConfig); err != nil { + if err := validateStdFiles(&c.BaseJobConfig); err != nil { return nil, err } From aeb23f7810f6bdf20522ba46312e783e5dba957b Mon Sep 17 00:00:00 2001 From: Leon Tappe Date: Fri, 31 Jul 2026 13:37:44 +0200 Subject: [PATCH 14/14] bump golang.org/x/text to v0.39.0 Fixes CVE-2026-56852 / GO-2026-5970 (norm.Iter infinite loop on invalid UTF-8), flagged HIGH by the Trivy scan in CI. Co-Authored-By: Claude Fable 5 --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index c0519ae..efc25d3 100644 --- a/go.mod +++ b/go.mod @@ -51,8 +51,8 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 32a5ae1..7dea0ff 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -173,8 +173,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=