diff --git a/README.md b/README.md index b36d552..40878ff 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ puppet_last_run_duration_seconds 28.023470087 # HELP puppet_last_run_success 1 if the last Puppet run was successful. # TYPE puppet_last_run_success gauge puppet_last_run_success 1 +# HELP puppet_disabled_since_seconds Unix timestamp since when puppet has been disabled. +# TYPE puppet_disabled_since_seconds gauge +puppet_disabled_since_seconds 1.6863428664914448e+09 ``` ### Example Alert Rules diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..c38e1fe --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,21 @@ +// Copyright 2023 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logging + +// Logger is a vendor-agnostic interface for event tracking +type Logger interface { + Errorw(msg string, keysAndValues ...interface{}) + Panicw(msg string, keysAndValues ...interface{}) +} diff --git a/internal/logging/noop.go b/internal/logging/noop.go new file mode 100644 index 0000000..b7307c0 --- /dev/null +++ b/internal/logging/noop.go @@ -0,0 +1,24 @@ +// Copyright 2023 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logging + +type noOpLogger struct{} + +// NewNoOpLogger returns a Logger implementation with no side effects +func NewNoOpLogger() Logger { return noOpLogger{} } + +func (noOpLogger) Errorw(_msg string, _keysAndValues ...interface{}) {} + +func (noOpLogger) Panicw(_msg string, _keysAndValues ...interface{}) {} diff --git a/internal/logging/noop_test.go b/internal/logging/noop_test.go new file mode 100644 index 0000000..cd6c10d --- /dev/null +++ b/internal/logging/noop_test.go @@ -0,0 +1,30 @@ +// Copyright 2023 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logging + +import ( + "testing" +) + +func TestNoOpLogger(t *testing.T) { + got := NewNoOpLogger() + + got.Errorw("error", "key", "value") + got.Panicw("panic", "key", "value") + got.Errorw("error", "key") + got.Panicw("panic", "key") + got.Errorw("error") + got.Panicw("panic") +} diff --git a/internal/puppet/agent_disabled_lockfile.go b/internal/puppet/agent_disabled_lockfile.go new file mode 100644 index 0000000..55f7662 --- /dev/null +++ b/internal/puppet/agent_disabled_lockfile.go @@ -0,0 +1,61 @@ +// Copyright 2023 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package puppet + +import ( + "encoding/json" + "io" + "os" + "time" +) + +// AgentDisabledLockfile holds metadata when the +// agent has been administratively disabled. +type AgentDisabledLockfile struct { + DisabledMessage string `json:"disabled_message"` + DisabledSince time.Time `json:-` +} + +// ParseAgentDisabledLockfile reads the file and its filesystem metadata +// at the given path. The returned error can be the result of a filesystem +// operation (including `os.ErrNotExist`) or parsing procedure. +func ParseAgentDisabledLockfile(path string) (*AgentDisabledLockfile, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + result := &AgentDisabledLockfile{ + DisabledSince: info.ModTime(), + } + + err = json.Unmarshal(data, result) + if err != nil { + return nil, err + } + + return result, nil +} diff --git a/internal/puppet/agent_disabled_lockfile_test.go b/internal/puppet/agent_disabled_lockfile_test.go new file mode 100644 index 0000000..dda530a --- /dev/null +++ b/internal/puppet/agent_disabled_lockfile_test.go @@ -0,0 +1,51 @@ +// Copyright 2023 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package puppet + +import ( + "testing" +) + +func TestParseAgentDisabledLockfile(t *testing.T) { + testCases := map[string]struct { + wantDisabledMessage string + wantError bool + }{ + "404": {wantError: true}, + "empty": {wantError: true}, + "malformed": {wantError: true}, + "empty_hash": {}, + "disabled_message": {wantDisabledMessage: "testing unmarshalling"}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, err := ParseAgentDisabledLockfile("testdata/agent_disabled_lockfile/" + name + ".json") + + if tc.wantError { + if err == nil { + t.Errorf("expected fixture %q to produce error but got none", name) + } + } else { + if err != nil { + t.Errorf("expected fixture %q to parse properly but got error\n%s", name, err) + } + if got == nil { + t.Errorf("expected fixture %q to parse properly but got nil as result", name) + } + } + }) + } +} diff --git a/internal/puppet/defaults.go b/internal/puppet/defaults.go new file mode 100644 index 0000000..508d9e6 --- /dev/null +++ b/internal/puppet/defaults.go @@ -0,0 +1,21 @@ +// Copyright 2023 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package puppet + +const ( + DefaultConfigFile = "/etc/puppetlabs/puppet/puppet.conf" + DefaultAgentDisabledLockfile = "/opt/puppetlabs/puppet/cache/state/agent_disabled.lock" + DefaultLastRunReportFile = "/opt/puppetlabs/puppet/cache/state/last_run_report.yaml" +) diff --git a/internal/puppet/testdata/agent_disabled_lockfile/disabled_message.json b/internal/puppet/testdata/agent_disabled_lockfile/disabled_message.json new file mode 100644 index 0000000..3ecff12 --- /dev/null +++ b/internal/puppet/testdata/agent_disabled_lockfile/disabled_message.json @@ -0,0 +1 @@ +{"disabled_message":"testing unmarshalling"} diff --git a/internal/puppet/testdata/agent_disabled_lockfile/empty.json b/internal/puppet/testdata/agent_disabled_lockfile/empty.json new file mode 100644 index 0000000..e69de29 diff --git a/internal/puppet/testdata/agent_disabled_lockfile/empty_hash.json b/internal/puppet/testdata/agent_disabled_lockfile/empty_hash.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/internal/puppet/testdata/agent_disabled_lockfile/empty_hash.json @@ -0,0 +1 @@ +{} diff --git a/internal/puppet/testdata/agent_disabled_lockfile/malformed.json b/internal/puppet/testdata/agent_disabled_lockfile/malformed.json new file mode 100644 index 0000000..7a70a48 --- /dev/null +++ b/internal/puppet/testdata/agent_disabled_lockfile/malformed.json @@ -0,0 +1 @@ +{"disabled_message": "missing bracket" diff --git a/internal/utils/time.go b/internal/utils/time.go new file mode 100644 index 0000000..2c81d31 --- /dev/null +++ b/internal/utils/time.go @@ -0,0 +1,23 @@ +// Copyright 2023 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "time" +) + +func UnixSeconds(t time.Time) float64 { + return float64(t.Unix()) + (float64(t.Nanosecond()) / 1e+9) +} diff --git a/puppet-agent-exporter.go b/puppet-agent-exporter.go index 7701f50..2b6eab2 100644 --- a/puppet-agent-exporter.go +++ b/puppet-agent-exporter.go @@ -25,10 +25,12 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/retailnext/puppet-agent-exporter/puppetconfig" - "github.com/retailnext/puppet-agent-exporter/puppetreport" "go.uber.org/zap" "golang.org/x/term" + + "github.com/retailnext/puppet-agent-exporter/puppetconfig" + "github.com/retailnext/puppet-agent-exporter/puppetdisabled" + "github.com/retailnext/puppet-agent-exporter/puppetreport" ) func setupLogger() func() { @@ -87,6 +89,9 @@ func run(ctx context.Context, listenAddress, telemetryPath string) (ok bool) { prometheus.DefaultRegisterer.MustRegister(puppetreport.Collector{ Logger: lgr, }) + prometheus.DefaultRegisterer.MustRegister(puppetdisabled.Collector{ + Logger: lgr, + }) mux := http.NewServeMux() mux.Handle(telemetryPath, promhttp.Handler()) diff --git a/puppetconfig/collector.go b/puppetconfig/collector.go index 1303890..ee4f274 100644 --- a/puppetconfig/collector.go +++ b/puppetconfig/collector.go @@ -17,6 +17,9 @@ package puppetconfig import ( "github.com/prometheus/client_golang/prometheus" "gopkg.in/ini.v1" + + "github.com/retailnext/puppet-agent-exporter/internal/logging" + "github.com/retailnext/puppet-agent-exporter/internal/puppet" ) var configDesc = prometheus.NewDesc( @@ -27,7 +30,7 @@ var configDesc = prometheus.NewDesc( ) type Collector struct { - Logger Logger + Logger logging.Logger ConfigPath string } @@ -50,10 +53,5 @@ func (c Collector) configPath() string { if c.ConfigPath != "" { return c.ConfigPath } - return "/etc/puppetlabs/puppet/puppet.conf" -} - -type Logger interface { - Errorw(msg string, keysAndValues ...interface{}) - Panicw(msg string, keysAndValues ...interface{}) + return puppet.DefaultConfigFile } diff --git a/puppetdisabled/collector.go b/puppetdisabled/collector.go new file mode 100644 index 0000000..55717ca --- /dev/null +++ b/puppetdisabled/collector.go @@ -0,0 +1,62 @@ +// Copyright 2021 RetailNext, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package puppetdisabled + +import ( + "errors" + "os" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/retailnext/puppet-agent-exporter/internal/logging" + "github.com/retailnext/puppet-agent-exporter/internal/puppet" + "github.com/retailnext/puppet-agent-exporter/internal/utils" +) + +var disabledSinceDesc = prometheus.NewDesc( + "puppet_disabled_since_seconds", + "Time since when puppet has been disabled.", + nil, + nil, +) + +type Collector struct { + Logger logging.Logger + LockfilePath string +} + +func (c Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- disabledSinceDesc +} + +func (c Collector) Collect(ch chan<- prometheus.Metric) { + dto, err := puppet.ParseAgentDisabledLockfile(c.lockfilePath()) + if errors.Is(err, os.ErrNotExist) { + return // nothing to report + } else if err != nil { + c.Logger.Errorw("puppet_open_lockfile_failed", "err", err) + return + } + + disabledSince := utils.UnixSeconds(dto.DisabledSince) + ch <- prometheus.MustNewConstMetric(disabledSinceDesc, prometheus.GaugeValue, disabledSince) +} + +func (c Collector) lockfilePath() string { + if c.LockfilePath != "" { + return c.LockfilePath + } + return puppet.DefaultAgentDisabledLockfile +} diff --git a/puppetreport/collector.go b/puppetreport/collector.go index ffe8001..89b6f9e 100644 --- a/puppetreport/collector.go +++ b/puppetreport/collector.go @@ -14,7 +14,12 @@ package puppetreport -import "github.com/prometheus/client_golang/prometheus" +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/retailnext/puppet-agent-exporter/internal/logging" + "github.com/retailnext/puppet-agent-exporter/internal/puppet" +) var ( catalogVersionDesc = prometheus.NewDesc( @@ -47,7 +52,7 @@ var ( ) type Collector struct { - Logger Logger + Logger logging.Logger ReportPath string } @@ -72,11 +77,7 @@ func (c Collector) reportPath() string { if c.ReportPath != "" { return c.ReportPath } - return "/opt/puppetlabs/puppet/cache/state/last_run_report.yaml" -} - -type Logger interface { - Errorw(msg string, keysAndValues ...interface{}) + return puppet.DefaultLastRunReportFile } type interpretedReport struct { diff --git a/puppetreport/report.go b/puppetreport/report.go index 1355bb5..af1158c 100644 --- a/puppetreport/report.go +++ b/puppetreport/report.go @@ -21,6 +21,8 @@ import ( "go.uber.org/multierr" "gopkg.in/yaml.v3" + + "github.com/retailnext/puppet-agent-exporter/internal/utils" ) type runReport struct { @@ -35,7 +37,7 @@ type runReport struct { func (r runReport) interpret() interpretedReport { result := interpretedReport{ - RunAt: asUnixSeconds(r.Time), + RunAt: utils.UnixSeconds(r.Time), RunDuration: r.totalDuration(), CatalogVersion: r.ConfigurationVersion, } @@ -45,10 +47,6 @@ func (r runReport) interpret() interpretedReport { return result } -func asUnixSeconds(t time.Time) float64 { - return float64(t.Unix()) + (float64(t.Nanosecond()) / 1e+9) -} - func (r runReport) totalDuration() float64 { timeMetrics, ok := r.Metrics["time"] if !ok {