Repository: replicatedhq/troubleshoot
Pull request #315 (2021-02-10 19:55:43)

Get time info from systemd


Patch:
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/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
+}


Test patch:
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)
+		})
+	}
+}