diff --git a/docs/LICENSE_OF_DEPENDENCIES.md b/docs/LICENSE_OF_DEPENDENCIES.md index 31c97d295..8dc055eb1 100644 --- a/docs/LICENSE_OF_DEPENDENCIES.md +++ b/docs/LICENSE_OF_DEPENDENCIES.md @@ -128,6 +128,7 @@ following works: - github.com/eclipse/paho.golang [Eclipse Public License - v 2.0](https://github.com/eclipse/paho.golang/blob/master/LICENSE) - github.com/eclipse/paho.mqtt.golang [Eclipse Public License - v 2.0](https://github.com/eclipse/paho.mqtt.golang/blob/master/LICENSE) - github.com/emicklei/go-restful [MIT License](https://github.com/emicklei/go-restful/blob/v3/LICENSE) +- github.com/facebook/time [Apache License 2.0](https://github.com/facebook/time/blob/main/LICENSE) - github.com/fatih/color [MIT License](https://github.com/fatih/color/blob/master/LICENSE.md) - github.com/felixge/httpsnoop [MIT License](https://github.com/felixge/httpsnoop/blob/master/LICENSE.txt) - github.com/form3tech-oss/jwt-go [MIT License](https://github.com/form3tech-oss/jwt-go/blob/master/LICENSE) diff --git a/go.mod b/go.mod index 63051e51a..9041953fc 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,7 @@ require ( github.com/compose-spec/compose-go v1.20.2 github.com/coocood/freecache v1.2.3 github.com/coreos/go-semver v0.3.1 - github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/coreos/go-systemd/v22 v22.5.0 github.com/couchbase/go-couchbase v0.1.1 github.com/datadope-io/go-zabbix/v2 v2.0.1 @@ -74,6 +74,7 @@ require ( github.com/dynatrace-oss/dynatrace-metric-utils-go v0.5.0 github.com/eclipse/paho.golang v0.11.0 github.com/eclipse/paho.mqtt.golang v1.4.3 + github.com/facebook/time v0.0.0-20240125155343-557f84f4ad3e github.com/fatih/color v1.16.0 github.com/go-ldap/ldap/v3 v3.4.6 github.com/go-logfmt/logfmt v0.6.0 diff --git a/go.sum b/go.sum index dcf09b0c8..1665d5e09 100644 --- a/go.sum +++ b/go.sum @@ -1010,8 +1010,9 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= @@ -1108,6 +1109,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= +github.com/facebook/time v0.0.0-20240125155343-557f84f4ad3e h1:PaVm1gMon1pkJdbzoyw2VUCn07cA6RDbGdTbsppjcY8= +github.com/facebook/time v0.0.0-20240125155343-557f84f4ad3e/go.mod h1:NBW7VY75BPCsOPrSsARAengTlgOICicHey4Bv70lCUI= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= github.com/facebookgo/stackerr v0.0.0-20150612192056-c2fcf88613f4 h1:fP04zlkPjAGpsduG7xN3rRkxjAqkJaIQnnkNYYw/pAk= diff --git a/plugins/inputs/chrony/README.md b/plugins/inputs/chrony/README.md index 219346d66..38644c497 100644 --- a/plugins/inputs/chrony/README.md +++ b/plugins/inputs/chrony/README.md @@ -1,63 +1,9 @@ # chrony Input Plugin -Get standard chrony metrics, requires chronyc executable. +This plugin queries metrics from a chrony NTP server. For details on the +meaning of the gathered fields please check the [chronyc manual][] -Below is the documentation of the various headers returned by `chronyc -tracking`. - -- Reference ID - This is the refid and name (or IP address) if available, of the - server to which the computer is currently synchronised. If this is 127.127.1.1 - it means the computer is not synchronised to any external source and that you - have the ‘local’ mode operating (via the local command in chronyc (see section - local), or the local directive in the ‘/etc/chrony.conf’ file (see section - local)). -- Stratum - The stratum indicates how many hops away from a computer with an - attached reference clock we are. Such a computer is a stratum-1 computer, so - the computer in the example is two hops away (i.e. a.b.c is a stratum-2 and is - synchronised from a stratum-1). -- Ref time - This is the time (UTC) at which the last measurement from the - reference source was processed. -- System time - In normal operation, chronyd never steps the system clock, - because any jump in the timescale can have adverse consequences for certain - application programs. Instead, any error in the system clock is corrected by - slightly speeding up or slowing down the system clock until the error has been - removed, and then returning to the system clock’s normal speed. A consequence - of this is that there will be a period when the system clock (as read by other - programs using the gettimeofday() system call, or by the date command in the - shell) will be different from chronyd's estimate of the current true time - (which it reports to NTP clients when it is operating in server mode). The - value reported on this line is the difference due to this effect. -- Last offset - This is the estimated local offset on the last clock update. -- RMS offset - This is a long-term average of the offset value. -- Frequency - The ‘frequency’ is the rate by which the system’s clock would be - wrong if chronyd was not correcting it. It is expressed in ppm (parts per - million). For example, a value of 1ppm would mean that when the system’s - clock thinks it has advanced 1 second, it has actually advanced by 1.000001 - seconds relative to true time. -- Residual freq - This shows the ‘residual frequency’ for the currently selected - reference source. This reflects any difference between what the measurements - from the reference source indicate the frequency should be and the frequency - currently being used. The reason this is not always zero is that a smoothing - procedure is applied to the frequency. Each time a measurement from the - reference source is obtained and a new residual frequency computed, the - estimated accuracy of this residual is compared with the estimated accuracy - (see ‘skew’ next) of the existing frequency value. A weighted average is - computed for the new frequency, with weights depending on these accuracies. If - the measurements from the reference source follow a consistent trend, the - residual will be driven to zero over time. -- Skew - This is the estimated error bound on the frequency. -- Root delay - This is the total of the network path delays to the stratum-1 - computer from which the computer is ultimately synchronised. In certain - extreme situations, this value can be negative. (This can arise in a symmetric - peer arrangement where the computers’ frequencies are not tracking each other - and the network delay is very short relative to the turn-around time at each - computer.) -- Root dispersion - This is the total dispersion accumulated through all the - computers back to the stratum-1 computer from which the computer is ultimately - synchronised. Dispersion is due to system clock resolution, statistical - measurement variations etc. -- Leap status - This is the leap status, which can be Normal, Insert second, - Delete second or Not synchronised. +[chronyc manual]: https://chrony-project.org/doc/4.4/chronyc.html ## Global configuration options @@ -73,7 +19,17 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details. ```toml @sample.conf # Get standard chrony metrics, requires chronyc executable. [[inputs.chrony]] - ## If true, chronyc tries to perform a DNS lookup for the time server. + ## Server address of chronyd with address scheme + ## If empty or not set, the plugin will mimic the behavior of chronyc and + ## check "unix:///run/chrony/chronyd.sock", "udp://127.0.0.1:323" + ## and "udp://[::1]:323". + # server = "" + + ## Timeout for establishing the connection + # timeout = "5s" + + ## Try to resolve received addresses to host-names via DNS lookups + ## Disabled by default to avoid DNS queries especially for slow DNS servers. # dns_lookup = false ``` @@ -100,5 +56,5 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details. ## Example Output ```text -chrony,leap_status=normal,reference_id=192.168.1.1,stratum=3 frequency=-35.657,system_time=0.000027073,last_offset=-0.000013616,residual_freq=-0,rms_offset=0.000027073,root_delay=0.000644,root_dispersion=0.003444,skew=0.001,update_interval=1031.2 1463750789687639161 +chrony,leap_status=not\ synchronized,reference_id=A29FC87B,stratum=3 frequency=-16.000999450683594,last_offset=0.000012651000361074694,residual_freq=0,rms_offset=0.000025576999178156257,root_delay=0.0016550000291317701,root_dispersion=0.00330700003542006,skew=0.006000000052154064,system_time=0.000020389999917824753,update_interval=507.1999816894531 1706271167571675297 ``` diff --git a/plugins/inputs/chrony/chrony.go b/plugins/inputs/chrony/chrony.go index 34d7fc303..551eda3cb 100644 --- a/plugins/inputs/chrony/chrony.go +++ b/plugins/inputs/chrony/chrony.go @@ -5,26 +5,30 @@ import ( _ "embed" "errors" "fmt" - "os/exec" + "net" + "net/url" "strconv" "strings" "time" + fbchrony "github.com/facebook/time/ntp/chrony" + "github.com/influxdata/telegraf" - "github.com/influxdata/telegraf/internal" + "github.com/influxdata/telegraf/config" "github.com/influxdata/telegraf/plugins/inputs" ) //go:embed sample.conf var sampleConfig string -var ( - execCommand = exec.Command // execCommand is used to mock commands in tests. -) - type Chrony struct { - DNSLookup bool `toml:"dns_lookup"` - path string + Server string `toml:"server"` + Timeout config.Duration `toml:"timeout"` + DNSLookup bool `toml:"dns_lookup"` + Log telegraf.Logger `toml:"-"` + + conn net.Conn + client *fbchrony.Client } func (*Chrony) SampleConfig() string { @@ -32,98 +36,134 @@ func (*Chrony) SampleConfig() string { } func (c *Chrony) Init() error { - var err error - c.path, err = exec.LookPath("chronyc") - if err != nil { - return errors.New("chronyc not found: verify that chrony is installed and that chronyc is in your PATH") + if c.Server != "" { + // Check the specified server address + u, err := url.Parse(c.Server) + if err != nil { + return fmt.Errorf("parsing server address failed: %w", err) + } + switch u.Scheme { + case "unix": + // Keep the server unmodified + case "udp": + // Check if we do have a port and add the default port if we don't + if u.Port() == "" { + u.Host += ":323" + } + // We cannot have path elements in an UDP address + if u.Path != "" { + return fmt.Errorf("path detected in UDP address %q", c.Server) + } + u = &url.URL{Scheme: "udp", Host: u.Host} + default: + return errors.New("unknown or missing address scheme") + } + c.Server = u.String() } + return nil } +func (c *Chrony) Start(_ telegraf.Accumulator) error { + if c.Server != "" { + // Create a connection + u, err := url.Parse(c.Server) + if err != nil { + return fmt.Errorf("parsing server address failed: %w", err) + } + switch u.Scheme { + case "unix": + conn, err := net.DialTimeout("unix", u.Path, time.Duration(c.Timeout)) + if err != nil { + return fmt.Errorf("dialing %q failed: %w", c.Server, err) + } + c.conn = conn + case "udp": + conn, err := net.DialTimeout("udp", u.Host, time.Duration(c.Timeout)) + if err != nil { + return fmt.Errorf("dialing %q failed: %w", c.Server, err) + } + c.conn = conn + } + } else { + // If no server is given, reproduce chronyc's behavior + if conn, err := net.DialTimeout("unix", "/run/chrony/chronyd.sock", time.Duration(c.Timeout)); err == nil { + c.Server = "unix:///run/chrony/chronyd.sock" + c.conn = conn + } else if conn, err := net.DialTimeout("udp", "127.0.0.1:323", time.Duration(c.Timeout)); err == nil { + c.Server = "udp://127.0.0.1:323" + c.conn = conn + } else { + conn, err := net.DialTimeout("udp", "[::1]:323", time.Duration(c.Timeout)) + if err != nil { + return fmt.Errorf("dialing server failed: %w", err) + } + c.Server = "udp://[::1]:323" + c.conn = conn + } + } + c.Log.Debugf("Connected to %q...", c.Server) + + // Initialize the client + c.client = &fbchrony.Client{Connection: c.conn} + + return nil +} + +func (c *Chrony) Stop() { + if c.conn != nil { + if err := c.conn.Close(); err != nil { + c.Log.Errorf("Closing connection to %q failed: %v", c.Server, err) + } + } +} + func (c *Chrony) Gather(acc telegraf.Accumulator) error { - flags := []string{} - if !c.DNSLookup { - flags = append(flags, "-n") + req := fbchrony.NewTrackingPacket() + resp, err := c.client.Communicate(req) + if err != nil { + return fmt.Errorf("querying tracking data failed: %w", err) + } + tracking, ok := resp.(*fbchrony.ReplyTracking) + if !ok { + return fmt.Errorf("got unexpected response type %T while waiting for tracking data", resp) } - flags = append(flags, "tracking") - cmd := execCommand(c.path, flags...) - out, err := internal.CombinedOutputTimeout(cmd, time.Second*5) - if err != nil { - return fmt.Errorf("failed to run command %q: %w - %s", strings.Join(cmd.Args, " "), err, string(out)) + // according to https://github.com/mlichvar/chrony/blob/e11b518a1ffa704986fb1f1835c425844ba248ef/ntp.h#L70 + var leapStatus string + switch tracking.LeapStatus { + case 0: + leapStatus = "normal" + case 1: + leapStatus = "insert second" + case 2: + leapStatus = "delete second" + case 3: + leapStatus = "not synchronized" } - fields, tags, err := processChronycOutput(string(out)) - if err != nil { - return err + + tags := map[string]string{ + "leap_status": leapStatus, + "reference_id": strings.ToUpper(strconv.FormatUint(uint64(tracking.RefID), 16)), + "stratum": strconv.FormatUint(uint64(tracking.Stratum), 10), + } + fields := map[string]interface{}{ + "frequency": tracking.FreqPPM, + "system_time": tracking.CurrentCorrection, + "last_offset": tracking.LastOffset, + "residual_freq": tracking.ResidFreqPPM, + "rms_offset": tracking.RMSOffset, + "root_delay": tracking.RootDelay, + "root_dispersion": tracking.RootDispersion, + "skew": tracking.SkewPPM, + "update_interval": tracking.LastUpdateInterval, } acc.AddFields("chrony", fields, tags) + return nil } - -// processChronycOutput takes in a string output from the chronyc command, like: -// -// Reference ID : 192.168.1.22 (ntp.example.com) -// Stratum : 3 -// Ref time (UTC) : Thu May 12 14:27:07 2016 -// System time : 0.000020390 seconds fast of NTP time -// Last offset : +0.000012651 seconds -// RMS offset : 0.000025577 seconds -// Frequency : 16.001 ppm slow -// Residual freq : -0.000 ppm -// Skew : 0.006 ppm -// Root delay : 0.001655 seconds -// Root dispersion : 0.003307 seconds -// Update interval : 507.2 seconds -// Leap status : Normal -// -// The value on the left side of the colon is used as field name, if the first field on -// the right side is a float. If it cannot be parsed as float, it is a tag name. -// -// Ref time is ignored and all names are converted to snake case. -// -// It returns (, ) -func processChronycOutput(out string) (map[string]interface{}, map[string]string, error) { - tags := map[string]string{} - fields := map[string]interface{}{} - lines := strings.Split(strings.TrimSpace(out), "\n") - for _, line := range lines { - stats := strings.Split(line, ":") - if len(stats) < 2 { - return nil, nil, fmt.Errorf("unexpected output from chronyc, expected ':' in %s", out) - } - name := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(stats[0]), " ", "_")) - // ignore reference time - if strings.Contains(name, "ref_time") { - continue - } - valueFields := strings.Fields(stats[1]) - if len(valueFields) == 0 { - return nil, nil, fmt.Errorf("unexpected output from chronyc: %s", out) - } - if strings.Contains(strings.ToLower(name), "stratum") { - tags["stratum"] = valueFields[0] - continue - } - if strings.Contains(strings.ToLower(name), "reference_id") { - tags["reference_id"] = valueFields[0] - continue - } - value, err := strconv.ParseFloat(valueFields[0], 64) - if err != nil { - tags[name] = strings.ToLower(strings.Join(valueFields, " ")) - continue - } - if strings.Contains(stats[1], "slow") { - value = -value - } - fields[name] = value - } - - return fields, tags, nil -} - func init() { inputs.Add("chrony", func() telegraf.Input { - return &Chrony{} + return &Chrony{Timeout: config.Duration(3 * time.Second)} }) } diff --git a/plugins/inputs/chrony/chrony_test.go b/plugins/inputs/chrony/chrony_test.go index 050ca9259..ece6b68b2 100644 --- a/plugins/inputs/chrony/chrony_test.go +++ b/plugins/inputs/chrony/chrony_test.go @@ -1,112 +1,329 @@ package chrony import ( + "bytes" + "encoding/binary" "fmt" - "os" - "os/exec" + "math" + "net" "testing" + "time" + fbchrony "github.com/facebook/time/ntp/chrony" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/metric" "github.com/influxdata/telegraf/testutil" ) func TestGather(t *testing.T) { - c := Chrony{ - path: "chronyc", + // Setup a mock server + server := Server{ + TrackingInfo: &fbchrony.Tracking{ + RefID: 0xA29FC87B, + IPAddr: net.ParseIP("192.168.1.22"), + Stratum: 3, + LeapStatus: 3, + RefTime: time.Now(), + CurrentCorrection: 0.000020390, + LastOffset: 0.000012651, + RMSOffset: 0.000025577, + FreqPPM: -16.001, + ResidFreqPPM: 0.0, + SkewPPM: 0.006, + RootDelay: 0.001655, + RootDispersion: 0.003307, + LastUpdateInterval: 507.2, + }, } - // overwriting exec commands with mock commands - execCommand = fakeExecCommand - defer func() { execCommand = exec.Command }() + addr, err := server.Listen(t) + require.NoError(t, err) + defer server.Shutdown() + + // Setup the plugin + plugin := &Chrony{ + Server: "udp://" + addr, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + // Start the plugin, do a gather and stop everything var acc testutil.Accumulator + require.NoError(t, plugin.Start(&acc)) + defer plugin.Stop() + require.NoError(t, plugin.Gather(&acc)) + plugin.Stop() + server.Shutdown() - err := c.Gather(&acc) - if err != nil { - t.Fatal(err) + // Do the comparison + expected := []telegraf.Metric{ + metric.New( + "chrony", + map[string]string{ + "reference_id": "A29FC87B", + "leap_status": "not synchronized", + "stratum": "3", + }, + map[string]interface{}{ + "system_time": 0.000020390, + "last_offset": 0.000012651, + "rms_offset": 0.000025577, + "frequency": -16.001, + "residual_freq": 0.0, + "skew": 0.006, + "root_delay": 0.001655, + "root_dispersion": 0.003307, + "update_interval": 507.2, + }, + time.Unix(0, 0), + ), } - tags := map[string]string{ - "reference_id": "192.168.1.22", - "leap_status": "not synchronized", - "stratum": "3", - } - fields := map[string]interface{}{ - "system_time": 0.000020390, - "last_offset": 0.000012651, - "rms_offset": 0.000025577, - "frequency": -16.001, - "residual_freq": 0.0, - "skew": 0.006, - "root_delay": 0.001655, - "root_dispersion": 0.003307, - "update_interval": 507.2, + options := []cmp.Option{ + // tests on linux with go1.20 will add a warning about code coverage, ignore that tag + testutil.IgnoreTags("warning"), + testutil.IgnoreTime(), + cmpopts.EquateApprox(0.001, 0), } - // tests on linux with go1.20 will add a warning about code coverage - // due to the code coverage dir not being set - delete(acc.Metrics[0].Tags, "warning") - - acc.AssertContainsTaggedFields(t, "chrony", fields, tags) - - // test with dns lookup - c.DNSLookup = true - err = c.Gather(&acc) - if err != nil { - t.Fatal(err) - } - acc.AssertContainsTaggedFields(t, "chrony", fields, tags) + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsEqual(t, expected, actual, options...) } -// fakeExecCommand is a helper function that mock -// the exec.Command call (and call the test binary) -func fakeExecCommand(command string, args ...string) *exec.Cmd { - cs := []string{"-test.run=TestHelperProcess", "--", command} - cs = append(cs, args...) - cmd := exec.Command(os.Args[0], cs...) - cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} - return cmd +func TestIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Start the docker container + container := testutil.Container{ + Image: "dockurr/chrony", + ExposedPorts: []string{"323/udp"}, + Files: map[string]string{ + "/etc/telegraf-chrony.conf": "testdata/chrony.conf", + "/start.sh": "testdata/start.sh", + }, + Entrypoint: []string{"/start.sh"}, + WaitingFor: wait.ForLog("Selected source"), + } + require.NoError(t, container.Start(), "failed to start container") + defer container.Terminate() + + // Setup the plugin + plugin := &Chrony{ + Server: "udp://" + container.Address + ":" + container.Ports["323"], + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + // Collect the metrics and compare + var acc testutil.Accumulator + require.NoError(t, plugin.Start(&acc)) + defer plugin.Stop() + require.NoError(t, plugin.Gather(&acc)) + + // Setup the expectations + expected := []telegraf.Metric{ + metric.New( + "chrony", + map[string]string{ + "leap_status": "normal", + "reference_id": "A29FC87B", + "stratum": "4", + }, + map[string]interface{}{ + "frequency": float64(0), + "last_offset": float64(0), + "residual_freq": float64(0), + "rms_offset": float64(0), + "root_delay": float64(0), + "root_dispersion": float64(0), + "skew": float64(0), + "system_time": float64(0), + "update_interval": float64(0), + }, + time.Unix(0, 0), + ), + } + + options := []cmp.Option{ + testutil.IgnoreTags("leap_status", "reference_id", "stratum"), + testutil.IgnoreTime(), + } + + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsStructureEqual(t, expected, actual, options...) } -// TestHelperProcess isn't a real test. It's used to mock exec.Command -// For example, if you run: -// GO_WANT_HELPER_PROCESS=1 go test -test.run=TestHelperProcess -- chrony tracking -// it returns below mockData. -func TestHelperProcess(_ *testing.T) { - if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { - return +type Server struct { + TrackingInfo *fbchrony.Tracking + + conn net.PacketConn +} + +func (s *Server) Shutdown() { + if s.conn != nil { + s.conn.Close() } +} - lookup := "Reference ID : 192.168.1.22 (ntp.example.com)\n" - noLookup := "Reference ID : 192.168.1.22 (192.168.1.22)\n" - mockData := `Stratum : 3 -Ref time (UTC) : Thu May 12 14:27:07 2016 -System time : 0.000020390 seconds fast of NTP time -Last offset : +0.000012651 seconds -RMS offset : 0.000025577 seconds -Frequency : 16.001 ppm slow -Residual freq : -0.000 ppm -Skew : 0.006 ppm -Root delay : 0.001655 seconds -Root dispersion : 0.003307 seconds -Update interval : 507.2 seconds -Leap status : Not synchronized -` - - args := os.Args - - // Previous arguments are tests stuff, that looks like : - // /tmp/go-build970079519/…/_test/integration.test -test.run=TestHelperProcess -- - cmd, args := args[3], args[4:] - - if cmd != "chronyc" { - fmt.Fprint(os.Stdout, "command not found") - //nolint:revive // error code is important for this "test" - os.Exit(1) +func (s *Server) Listen(t *testing.T) (string, error) { + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + return "", err } - if args[0] == "tracking" { - fmt.Fprint(os.Stdout, lookup+mockData) + s.conn = conn + addr := s.conn.LocalAddr().String() + + go s.serve(t) + + return addr, nil +} + +func (s *Server) serve(t *testing.T) { + defer s.conn.Close() + + for { + buf := make([]byte, 4096) + n, addr, err := s.conn.ReadFrom(buf) + if err != nil { + return + } + t.Logf("mock server: received %d bytes from %q\n", n, addr.String()) + + var header fbchrony.RequestHead + data := bytes.NewBuffer(buf) + if err := binary.Read(data, binary.BigEndian, &header); err != nil { + t.Logf("mock server: reading request header failed: %v", err) + return + } + seqno := header.Sequence + 1 + + switch header.Command { + case 33: // tracking + _, err := s.conn.WriteTo(s.encodeTrackingReply(seqno), addr) + if err != nil { + t.Logf("mock server [tracking]: writing reply failed: %v", err) + } else { + t.Log("mock server [tracking]: successfully wrote reply") + } + default: + t.Logf("mock server: unhandled command %v", header.Command) + } + } +} + +func (s *Server) encodeTrackingReply(sequence uint32) []byte { + t := s.TrackingInfo + + // Encode the header + buf := []byte{ + 0x06, // version 6 + 0x02, // packet type 2: tracking + 0x00, // res1 + 0x00, // res2 + 0x00, 0x21, // command 33: tracking request + 0x00, 0x05, // reply 5: tracking reply + 0x00, 0x00, // status 0: success + 0x00, 0x00, // pad1 + 0x00, 0x00, // pad2 + 0x00, 0x00, // pad3 + } + buf = binary.BigEndian.AppendUint32(buf, sequence) // sequence number + buf = append(buf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) // pad 4 & 5 + + // Encode data + buf = binary.BigEndian.AppendUint32(buf, t.RefID) + buf = append(buf, t.IPAddr.To16()...) + if len(t.IPAddr) == 4 { + buf = append(buf, 0x00, 0x01) // IPv4 address family } else { - fmt.Fprint(os.Stdout, noLookup+mockData) + buf = append(buf, 0x00, 0x02) // IPv6 address family + } + buf = append(buf, 0x00, 0x00) // padding + buf = binary.BigEndian.AppendUint16(buf, t.Stratum) + buf = binary.BigEndian.AppendUint16(buf, t.LeapStatus) + sec := uint64(t.RefTime.Unix()) + nsec := uint32(t.RefTime.UnixNano() % t.RefTime.Unix() * int64(time.Second)) + buf = binary.BigEndian.AppendUint32(buf, uint32(sec>>32)) // seconds high part + buf = binary.BigEndian.AppendUint32(buf, uint32(sec&0xffffffff)) // seconds low part + buf = binary.BigEndian.AppendUint32(buf, nsec) // nanoseconds + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.CurrentCorrection)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.LastOffset)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.RMSOffset)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.FreqPPM)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.ResidFreqPPM)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.SkewPPM)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.RootDelay)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.RootDispersion)) + buf = binary.BigEndian.AppendUint32(buf, encodeFloat(t.LastUpdateInterval)) + + return buf +} + +// Modified based on https://github.com/mlichvar/chrony/blob/master/util.c +const ( + floatExpBits = int32(7) + floatCoeffBits = int32(25) // 32 - floatExpBits + floatExpMin = int32(-(1 << (floatExpBits - 1))) + floatExpMax = -floatExpMin - 1 + floatCoefMin = int32(-(1 << (floatCoeffBits - 1))) + floatCoefMax = -floatCoefMin - 1 +) + +func encodeFloat(x float64) uint32 { + var neg int32 + + if math.IsNaN(x) { + /* Save NaN as zero */ + x = 0.0 + } else if x < 0.0 { + x = -x + neg = 1 } - //nolint:revive // error code is important for this "test" - os.Exit(0) + var exp, coef int32 + if x > 1.0e100 { + exp = floatExpMax + coef = floatCoefMax + neg + } else if x > 1.0e-100 { + exp = int32(math.Log2(x)) + 1 + coef = int32(x*math.Pow(2.0, float64(-exp+floatCoeffBits)) + 0.5) + + if coef <= 0 { + panic(fmt.Errorf("invalid coefficient %v for value %f", coef, x)) + } + + /* we may need to shift up to two bits down */ + for coef > floatCoefMax+neg { + coef >>= 1 + exp++ + } + + if exp > floatExpMax { + /* overflow */ + exp = floatExpMax + coef = floatCoefMax + neg + } else if exp < floatExpMin { + /* underflow */ + if exp+floatCoeffBits >= floatExpMin { + coef >>= floatExpMin - exp + exp = floatExpMin + } else { + exp = 0 + coef = 0 + } + } + } + + /* negate back */ + if neg != 0 { + coef = int32(uint32(-coef) % (1 << floatCoeffBits)) + } + + return uint32(exp<