From 9984fe2caaa3ce47019bd5fd0489ecfe1ee95406 Mon Sep 17 00:00:00 2001 From: Andrew Reed Date: Wed, 10 Feb 2021 16:03:57 +0000 Subject: [PATCH] Get time info from timedated --- examples/preflight/host-ntp.yaml | 22 ++ examples/preflight/host-timezone.yaml | 15 ++ go.mod | 1 + go.sum | 2 + pkg/analyze/analyzer.go | 7 + pkg/analyze/host_time.go | 144 +++++++++++ pkg/analyze/host_time_test.go | 232 ++++++++++++++++++ .../v1beta2/hostanalyzer_shared.go | 7 + .../v1beta2/hostcollector_shared.go | 5 + .../v1beta2/zz_generated.deepcopy.go | 53 ++++ pkg/collect/host_collector.go | 2 + pkg/collect/host_time.go | 81 ++++++ 12 files changed, 571 insertions(+) create mode 100644 examples/preflight/host-ntp.yaml create mode 100644 examples/preflight/host-timezone.yaml create mode 100644 pkg/analyze/host_time.go create mode 100644 pkg/analyze/host_time_test.go create mode 100644 pkg/collect/host_time.go diff --git a/examples/preflight/host-ntp.yaml b/examples/preflight/host-ntp.yaml new file mode 100644 index 00000000..1492a13b --- /dev/null +++ b/examples/preflight/host-ntp.yaml @@ -0,0 +1,22 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: HostPreflight +metadata: + name: ntp +spec: + collectors: + - time: {} + analyzers: + - time: + outcomes: + - fail: + when: "ntp == unsynchronized+inactive" + message: System clock not synchronized + - warn: + when: "ntp == unsynchronized+active" + message: System clock not yet synchronized + - warn: + when: "ntp == synchronized+inactive" + message: NTP not active + - pass: + when: "ntp == synchronized+active" + message: System clock is synchronized diff --git a/examples/preflight/host-timezone.yaml b/examples/preflight/host-timezone.yaml new file mode 100644 index 00000000..962b42d4 --- /dev/null +++ b/examples/preflight/host-timezone.yaml @@ -0,0 +1,15 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: HostPreflight +metadata: + name: timezone +spec: + collectors: + - time: {} + analyzers: + - time: + outcomes: + - pass: + when: "timezone == UTC" + message: Timezone is UTC + - fail: + message: Timezone is not UTC diff --git a/go.mod b/go.mod index 6b3f869b..e166e8b7 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-redis/redis/v7 v7.2.0 github.com/go-sql-driver/mysql v1.5.0 github.com/gobwas/glob v0.2.3 + github.com/godbus/dbus v4.1.0+incompatible github.com/google/go-cmp v0.3.1 // indirect github.com/google/gofuzz v1.1.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect diff --git a/go.sum b/go.sum index 21f85b12..60549fc3 100644 --- a/go.sum +++ b/go.sum @@ -189,6 +189,8 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= +github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go index 78b70ee6..ec427e72 100644 --- a/pkg/analyze/analyzer.go +++ b/pkg/analyze/analyzer.go @@ -83,6 +83,13 @@ func HostAnalyze(hostAnalyzer *troubleshootv1beta2.HostAnalyze, getFile getColle } return []*AnalyzeResult{result}, nil } + if hostAnalyzer.Time != nil { + result, err := analyzeHostTime(hostAnalyzer.Time, getFile) + if err != nil { + return nil, err + } + return []*AnalyzeResult{result}, nil + } return nil, errors.New("invalid analyzer") } diff --git a/pkg/analyze/host_time.go b/pkg/analyze/host_time.go new file mode 100644 index 00000000..a4085776 --- /dev/null +++ b/pkg/analyze/host_time.go @@ -0,0 +1,144 @@ +package analyzer + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" +) + +const ( + SynchronizedActive = "synchronized+active" + SynchronizedInactive = "synchronized+inactive" + UnsynchronizedActive = "unsynchronized+active" + UnsynchronizedInactive = "unsynchronized+inactive" +) + +func analyzeHostTime(hostAnalyzer *troubleshootv1beta2.TimeAnalyze, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) { + contents, err := getCollectedFileContents("system/time.json") + if err != nil { + return nil, errors.Wrap(err, "failed to get collected file") + } + + timeInfo := collect.TimeInfo{} + if err := json.Unmarshal(contents, &timeInfo); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal time info") + } + + result := AnalyzeResult{} + + title := hostAnalyzer.CheckName + if title == "" { + title = "Time" + } + result.Title = title + + for _, outcome := range hostAnalyzer.Outcomes { + if outcome.Fail != nil { + if outcome.Fail.When == "" { + result.IsFail = true + result.Message = outcome.Fail.Message + result.URI = outcome.Fail.URI + + return &result, nil + } + + isMatch, err := compareHostTimeStatusToActual(outcome.Fail.When, timeInfo) + if err != nil { + return nil, errors.Wrapf(err, "failed to compare %s", outcome.Fail.When) + } + + if isMatch { + result.IsFail = true + result.Message = outcome.Fail.Message + result.URI = outcome.Fail.URI + + return &result, nil + } + } else if outcome.Warn != nil { + if outcome.Warn.When == "" { + result.IsWarn = true + result.Message = outcome.Warn.Message + result.URI = outcome.Warn.URI + + return &result, nil + } + + isMatch, err := compareHostTimeStatusToActual(outcome.Warn.When, timeInfo) + if err != nil { + return nil, errors.Wrapf(err, "failed to compare %s", outcome.Warn.When) + } + + if isMatch { + result.IsWarn = true + result.Message = outcome.Warn.Message + result.URI = outcome.Warn.URI + + return &result, nil + } + } else if outcome.Pass != nil { + if outcome.Pass.When == "" { + result.IsPass = true + result.Message = outcome.Pass.Message + result.URI = outcome.Pass.URI + + return &result, nil + } + + isMatch, err := compareHostTimeStatusToActual(outcome.Pass.When, timeInfo) + if err != nil { + return nil, errors.Wrapf(err, "failed to compare %s", outcome.Pass.When) + } + + if isMatch { + result.IsPass = true + result.Message = outcome.Pass.Message + result.URI = outcome.Pass.URI + + return &result, nil + } + } + } + + return &result, nil +} + +func compareHostTimeStatusToActual(status string, timeInfo collect.TimeInfo) (res bool, err error) { + parts := strings.Split(status, " ") + if len(parts) != 3 { + return false, fmt.Errorf("Expected exactly 3 parts, got %d", len(parts)) + } + if parts[0] == "timezone" { + if parts[1] != "=" && parts[1] != "==" && parts[1] != "===" && parts[1] != "!=" { + return false, errors.New(`Only supported operators are "==" and "!="`) + } + if parts[1] == "!=" { + return parts[2] != timeInfo.Timezone, nil + } + return parts[2] == timeInfo.Timezone, nil + } + + if parts[0] == "ntp" { + if parts[1] != "=" && parts[1] != "==" && parts[1] != "===" { + return false, errors.New(`Only supported operator is "=="`) + } + + switch parts[2] { + case SynchronizedActive: + return timeInfo.NTPSynchronized && timeInfo.NTPActive, nil + case SynchronizedInactive: + return timeInfo.NTPSynchronized && !timeInfo.NTPActive, nil + case UnsynchronizedActive: + return !timeInfo.NTPSynchronized && timeInfo.NTPActive, nil + case UnsynchronizedInactive: + return !timeInfo.NTPSynchronized && !timeInfo.NTPActive, nil + default: + return false, fmt.Errorf("Unknown status %q. Allowed values are %q, %q, %q, or %q", parts[2], SynchronizedActive, SynchronizedInactive, UnsynchronizedActive, UnsynchronizedInactive) + } + } + + return false, fmt.Errorf("Unknown keyword: %s", parts[0]) +} diff --git a/pkg/analyze/host_time_test.go b/pkg/analyze/host_time_test.go new file mode 100644 index 00000000..63bc9deb --- /dev/null +++ b/pkg/analyze/host_time_test.go @@ -0,0 +1,232 @@ +package analyzer + +import ( + "encoding/json" + "testing" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnalyzeHostTime(t *testing.T) { + tests := []struct { + name string + timeInfo *collect.TimeInfo + hostAnalyzer *troubleshootv1beta2.TimeAnalyze + result *AnalyzeResult + expectErr bool + }{ + { + name: "ntp == synchronized+active", + timeInfo: &collect.TimeInfo{ + Timezone: "UTC", + NTPSynchronized: true, + NTPActive: true, + }, + hostAnalyzer: &troubleshootv1beta2.TimeAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == unsynchronized+inactive", + Message: "System clock not synchronized", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == synchronized+active", + Message: "System clock synchronized and NTP is active", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Time", + IsPass: true, + Message: "System clock synchronized and NTP is active", + }, + }, + { + name: "ntp == unsynchronized+inactive", + timeInfo: &collect.TimeInfo{ + Timezone: "UTC", + NTPSynchronized: false, + NTPActive: false, + }, + hostAnalyzer: &troubleshootv1beta2.TimeAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == unsynchronized+inactive", + Message: "System clock not synchronized", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == synchronized+active", + Message: "System clock synchronized and NTP is active", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Time", + IsFail: true, + Message: "System clock not synchronized", + }, + }, + { + name: "ntp == unsynchronized+active", + timeInfo: &collect.TimeInfo{ + Timezone: "UTC", + NTPSynchronized: false, + NTPActive: true, + }, + hostAnalyzer: &troubleshootv1beta2.TimeAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == unsynchronized+inactive", + Message: "System clock not synchronized", + }, + }, + { + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == unsynchronized+active", + Message: "System clock not yet synchronized", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == synchronized+active", + Message: "System clock synchronized and NTP is active", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Time", + IsWarn: true, + Message: "System clock not yet synchronized", + }, + }, + { + name: "ntp == synchronized+inactive", + timeInfo: &collect.TimeInfo{ + Timezone: "UTC", + NTPSynchronized: true, + NTPActive: false, + }, + hostAnalyzer: &troubleshootv1beta2.TimeAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == unsynchronized+inactive", + Message: "System clock not synchronized", + }, + }, + { + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == unsynchronized+active", + Message: "System clock not yet synchronized", + }, + }, + { + Warn: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == synchronized+inactive", + Message: "System clock synchronized for now", + }, + }, + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "ntp == synchronized+active", + Message: "System clock synchronized and NTP is active", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Time", + IsWarn: true, + Message: "System clock synchronized for now", + }, + }, + { + name: "timezone", + timeInfo: &collect.TimeInfo{ + Timezone: "UTC", + NTPSynchronized: true, + NTPActive: true, + }, + hostAnalyzer: &troubleshootv1beta2.TimeAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "timezone == UTC", + Message: "Timezone is set to UTC", + }, + }, + { + Fail: &troubleshootv1beta2.SingleOutcome{ + Message: "timezone not set to UTC", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Time", + IsPass: true, + Message: "Timezone is set to UTC", + }, + }, + { + name: "timezone is not UTC", + timeInfo: &collect.TimeInfo{ + Timezone: "PST", + NTPSynchronized: true, + NTPActive: true, + }, + hostAnalyzer: &troubleshootv1beta2.TimeAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "timezone != UTC", + Message: "Timezone is not set to UTC", + }, + Pass: &troubleshootv1beta2.SingleOutcome{ + Message: "Timezone is set to UTC", + }, + }, + }, + }, + result: &AnalyzeResult{ + Title: "Time", + IsFail: true, + Message: "Timezone is not set to UTC", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + b, err := json.Marshal(test.timeInfo) + if err != nil { + t.Fatal(err) + } + + getCollectedFileContents := func(filename string) ([]byte, error) { + return b, nil + } + + result, err := analyzeHostTime(test.hostAnalyzer, getCollectedFileContents) + if test.expectErr { + req.Error(err) + } else { + req.NoError(err) + } + + assert.Equal(t, test.result, result) + }) + } +} diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go index 39480d97..0d05b7e9 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go @@ -34,6 +34,11 @@ type HTTPAnalyze struct { Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` } +type TimeAnalyze struct { + AnalyzeMeta `json:",inline" yaml:",inline"` + Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` +} + type HostAnalyze struct { CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"` // @@ -46,4 +51,6 @@ type HostAnalyze struct { TCPPortStatus *TCPPortStatusAnalyze `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"` HTTP *HTTPAnalyze `json:"http" yaml:"http"` + + Time *TimeAnalyze `json:"time" yaml:"time"` } diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index 417eacbf..4ec90e29 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -59,6 +59,10 @@ type HostHTTP struct { Put *Put `json:"put,omitempty" yaml:"put,omitempty"` } +type HostTime struct { + HostCollectorMeta `json:",inline" yaml:",inline"` +} + type HostCollect struct { CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` @@ -69,6 +73,7 @@ type HostCollect struct { IPV4Interfaces *IPV4Interfaces `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"` DiskUsage *DiskUsage `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"` HTTP *HostHTTP `json:"http,omitempty" yaml:"http,omitempty"` + Time *HostTime `json:"time,omitempty" yaml:"time,omitempty"` } func (c *HostCollect) GetName() string { diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index 7ba38223..c2e532b3 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -1103,6 +1103,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) { *out = new(HTTPAnalyze) (*in).DeepCopyInto(*out) } + if in.Time != nil { + in, out := &in.Time, &out.Time + *out = new(TimeAnalyze) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze. @@ -1163,6 +1168,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = new(HostHTTP) (*in).DeepCopyInto(*out) } + if in.Time != nil { + in, out := &in.Time, &out.Time + *out = new(HostTime) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect. @@ -1333,6 +1343,22 @@ func (in *HostPreflightStatus) DeepCopy() *HostPreflightStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostTime) DeepCopyInto(out *HostTime) { + *out = *in + out.HostCollectorMeta = in.HostCollectorMeta +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostTime. +func (in *HostTime) DeepCopy() *HostTime { + if in == nil { + return nil + } + out := new(HostTime) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IPV4Interfaces) DeepCopyInto(out *IPV4Interfaces) { *out = *in @@ -2348,3 +2374,30 @@ func (in *TextAnalyze) DeepCopy() *TextAnalyze { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeAnalyze) DeepCopyInto(out *TimeAnalyze) { + *out = *in + out.AnalyzeMeta = in.AnalyzeMeta + if in.Outcomes != nil { + in, out := &in.Outcomes, &out.Outcomes + *out = make([]*Outcome, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(Outcome) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeAnalyze. +func (in *TimeAnalyze) DeepCopy() *TimeAnalyze { + if in == nil { + return nil + } + out := new(TimeAnalyze) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index 5b0b0e3b..f5c9e3bf 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -30,6 +30,8 @@ func (c *HostCollector) RunCollectorSync() (result map[string][]byte, err error) result, err = HostTCPPortStatus(c) } else if c.Collect.HTTP != nil { result, err = HostHTTP(c) + } else if c.Collect.Time != nil { + result, err = HostTime(c) } else { err = errors.New("no spec found to run") return diff --git a/pkg/collect/host_time.go b/pkg/collect/host_time.go new file mode 100644 index 00000000..96546c3c --- /dev/null +++ b/pkg/collect/host_time.go @@ -0,0 +1,81 @@ +package collect + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/godbus/dbus" + "github.com/pkg/errors" +) + +type NTPStatus string + +type TimeInfo struct { + Timezone string `json:"timezone"` + NTPSynchronized bool `json:"ntp_synchronized"` + NTPActive bool `json:"ntp_active"` +} + +func HostTime(c *HostCollector) (map[string][]byte, error) { + timeInfo := TimeInfo{} + + conn, err := dbus.SystemBus() + if err != nil { + return nil, errors.Wrap(err, "failed to connect to dbus") + } + defer func() { + if err := conn.Close(); err != nil { + log.Printf("Failed to close dbus connection: %v\n", err) + } + }() + + prop := "org.freedesktop.timedate1.Timezone" + variant, err := conn.Object("org.freedesktop.timedate1", "/org/freedesktop/timedate1").GetProperty(prop) + if err != nil { + return nil, errors.Wrapf(err, "failed to read property %s", prop) + } + timeInfo.Timezone = strings.Trim(variant.String(), `"`) + // UTC is reported as Etc/UTC on Ubuntu + if strings.ToLower(timeInfo.Timezone) == "etc/utc" { + timeInfo.Timezone = "UTC" + } + + prop = "org.freedesktop.timedate1.NTPSynchronized" + variant, err = conn.Object("org.freedesktop.timedate1", "/org/freedesktop/timedate1").GetProperty(prop) + if err != nil { + return nil, errors.Wrapf(err, "failed to read property %s", prop) + } + switch variant.String() { + case "true": + timeInfo.NTPSynchronized = true + case "false": + timeInfo.NTPSynchronized = false + default: + return nil, fmt.Errorf("Unexpected value for property %s: %s", prop, variant.String()) + } + + prop = "org.freedesktop.timedate1.NTP" + variant, err = conn.Object("org.freedesktop.timedate1", "/org/freedesktop/timedate1").GetProperty(prop) + if err != nil { + return nil, errors.Wrapf(err, "failed to read property %s", prop) + } + switch variant.String() { + case "true": + timeInfo.NTPActive = true + case "false": + timeInfo.NTPActive = false + default: + return nil, fmt.Errorf("Unexpected value for property %s: %s", prop, variant.String()) + } + + b, err := json.Marshal(timeInfo) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal time info") + } + + return map[string][]byte{ + "system/time.json": b, + }, nil +}