Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -166,9 +172,14 @@ 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.

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
Expand Down Expand Up @@ -256,6 +267,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.
Expand Down
4 changes: 3 additions & 1 deletion cmd/mittnitectl/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
Expand Down
30 changes: 30 additions & 0 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/signal"
"strconv"
"syscall"

"github.com/mittwald/mittnite/internal/config"
Expand All @@ -19,6 +20,9 @@ import (

const (
DefaultAPIAddress = "unix:///var/run/mittnite.sock"

envJobLogTimestamps = "MITTNITE_JOB_LOG_TIMESTAMPS"
envJobLogNamePrefix = "MITTNITE_JOB_LOG_NAME_PREFIX"
)

var (
Expand All @@ -27,6 +31,8 @@ var (
apiEnabled bool
apiListenAddress string
keepRunning bool
jobLogTimestamps bool
jobLogNamePrefix bool
)

func init() {
Expand All @@ -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{
Expand All @@ -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)
}
Expand Down
48 changes: 48 additions & 0 deletions cmd/up_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 13 additions & 1 deletion examples/timestamps.d/timestamps.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,16 @@ job "echoloop_notime" {

stdout = "test_notime.log"
stderr = "test_notime_error.log"
}
}
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
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
23 changes: 23 additions & 0 deletions internal/config/ignitionconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
19 changes: 17 additions & 2 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions internal/config/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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)

// 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())
require.True(t, ign.Jobs[1].NamePrefixEnabled())
}
Loading
Loading