blob: 8c250ec279e1a5899c242b03936058337a7180d3 [file] [edit]
// Copyright 2019 Google Inc. All Rights Reserved.
//
// 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 agentconfig
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
"github.com/GoogleCloudPlatform/osconfig/util/utiltest"
)
// setupMockMetadataServer starts an httptest.Server and points metadata requests at it.
func setupMockMetadataServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
ts := httptest.NewServer(handler)
t.Setenv(metadataHostEnv, strings.TrimPrefix(ts.URL, "http://"))
t.Cleanup(ts.Close)
return ts
}
func TestWatchConfig(t *testing.T) {
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `{"project":{"numericProjectID":12345,"projectId":"projectId","attributes":{"osconfig-endpoint":"bad!!1","enable-os-inventory":"false"}},"instance":{"id":12345,"name":"name","zone":"zone","attributes":{"osconfig-endpoint":"SvcEndpoint","enable-os-inventory":"1","enable-os-config-debug":"true","osconfig-enabled-prerelease-features":"ospackage,ospatch", "osconfig-poll-interval":"3", "enable-scalibr-linux":"true", "trace-get-inventory":"true", "enable-guest-attributes":"true"}}}`)
})
if err := WatchConfig(context.Background()); err != nil {
t.Fatalf("Error running WatchConfig: %v", err)
}
tests := []struct {
name string
op func() any
want any
}{
{
name: "metadata endpoint is SvcEndpoint, returns configured service endpoint",
op: asAny(SvcEndpoint),
want: "SvcEndpoint",
},
{
name: "metadata zone and name are populated, returns instance resource path",
op: asAny(Instance),
want: "zone/instances/name",
},
{
name: "metadata instance id is 12345, returns instance id string",
op: asAny(ID),
want: "12345",
},
{
name: "metadata project id is projectId, returns project id",
op: asAny(ProjectID),
want: "projectId",
},
{
name: "metadata zone is zone, returns zone",
op: asAny(Zone),
want: "zone",
},
{
name: "metadata instance name is name, returns instance name",
op: asAny(Name),
want: "name",
},
{
name: "project disables inventory and instance enables it, returns inventory enabled",
op: asAny(OSInventoryEnabled),
want: true,
},
{
name: "instance enables prerelease tasks, returns task notifications enabled",
op: asAny(TaskNotificationEnabled),
want: true,
},
{
name: "instance enables prerelease ospatch, returns guest policies enabled",
op: asAny(GuestPoliciesEnabled),
want: true,
},
{
name: "project disables debug and instance enables it, returns debug enabled",
op: asAny(Debug),
want: true,
},
{
name: "instance enables scalibr linux, returns scalibr linux enabled",
op: asAny(ScalibrLinuxEnabled),
want: true,
},
{
name: "instance enables trace get inventory, returns inventory tracing enabled",
op: asAny(TraceGetInventory),
want: true,
},
{
name: "instance enables guest attributes, returns guest attributes enabled",
op: asAny(GuestAttributesEnabled),
want: true,
},
{
name: "svc poll interval is 3 minutes, returns proper time",
op: asAny(SvcPollInterval),
want: 3 * time.Minute,
},
{
name: "numeric project id is 12345, successfuly returned",
op: asAny(NumericProjectID),
want: int64(12345),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
utiltest.AssertEquals(t, tt.op(), tt.want)
})
}
}
func TestSetConfigEnabled(t *testing.T) {
var request int
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
switch request {
case 0:
w.Header().Set("Etag", "etag-0")
fmt.Fprintln(w, `{"project":{"attributes":{"enable-osconfig":"false"}},"instance":{"attributes":{"enable-osconfig":"false"}}}`)
case 1:
w.Header().Set("Etag", "etag-1")
fmt.Fprintln(w, `{"project":{"attributes":{"enable-osconfig":"false"}},"instance":{"attributes":{"enable-osconfig":"true"}}}`)
case 2:
w.Header().Set("Etag", "etag-2")
fmt.Fprintln(w, `{"project":{"attributes":{"enable-osconfig":"false"}},"instance":{"attributes":{"enable-osconfig":"false"}}}`)
case 3:
w.Header().Set("Etag", "etag-3")
fmt.Fprintln(w, `{"project":{"attributes":{"enable-osconfig":"true","osconfig-disabled-features":"osinventory"}}}`)
}
})
type assertion struct {
name string
op func() bool
want bool
}
tests := []struct {
name string
request int
assertions []assertion
}{
{
name: "project and instance disable osconfig, returns features disabled",
request: 0,
assertions: []assertion{
{name: "inventory is requested, returns disabled", op: OSInventoryEnabled, want: false},
{name: "task notifications are requested, returns disabled", op: TaskNotificationEnabled, want: false},
{name: "guest policies are requested, returns disabled", op: GuestPoliciesEnabled, want: false},
},
},
{
name: "project disables osconfig and instance enables osconfig, returns features enabled",
request: 1,
assertions: []assertion{
{name: "inventory is requested, returns enabled", op: OSInventoryEnabled, want: true},
{name: "task notifications are requested, returns enabled", op: TaskNotificationEnabled, want: true},
{name: "guest policies are requested, returns enabled", op: GuestPoliciesEnabled, want: true},
},
},
{
name: "project and instance disable osconfig again, returns features disabled",
request: 2,
assertions: []assertion{
{name: "inventory is requested, returns disabled", op: OSInventoryEnabled, want: false},
{name: "task notifications are requested, returns disabled", op: TaskNotificationEnabled, want: false},
{name: "guest policies are requested, returns disabled", op: GuestPoliciesEnabled, want: false},
},
},
{
name: "osconfig enabled and disabled features contains osinventory, returns inventory disabled only",
request: 3,
assertions: []assertion{
{name: "inventory is requested, returns disabled", op: OSInventoryEnabled, want: false},
{name: "task notifications are requested, returns enabled", op: TaskNotificationEnabled, want: true},
{name: "guest policies are requested, returns enabled", op: GuestPoliciesEnabled, want: true},
},
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("request %d: %s", tt.request, tt.name), func(t *testing.T) {
request = tt.request
if err := WatchConfig(context.Background()); err != nil {
t.Fatalf("Error running SetConfig: %v", err)
}
for _, assertion := range tt.assertions {
t.Run(assertion.name, func(t *testing.T) {
utiltest.AssertEquals(t, assertion.op(), assertion.want)
})
}
})
}
}
func TestSetConfigDefaultValues(t *testing.T) {
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Etag", "sample-etag")
// we always get zone value in instance metadata.
fmt.Fprintln(w, `{"instance": {"zone": "fake-zone"}}`)
})
if err := WatchConfig(context.Background()); err != nil {
t.Fatalf("Error running SetConfig: %v", err)
}
tests := []struct {
name string
op func() any
want any
}{
{
name: "apt repo file path is requested, returns default apt repo file path",
op: asAny(AptRepoFilePath),
want: aptRepoFilePath,
},
{
name: "yum repo file path is requested, returns default yum repo file path",
op: asAny(YumRepoFilePath),
want: yumRepoFilePath,
},
{
name: "zypper repo file path is requested, returns default zypper repo file path",
op: asAny(ZypperRepoFilePath),
want: zypperRepoFilePath,
},
{
name: "googet repo file path is requested, returns default googet repo file path",
op: asAny(GooGetRepoFilePath),
want: googetRepoFilePath,
},
{
name: "zypper repo dir is requested, returns default zypper repo dir",
op: asAny(ZypperRepoDir),
want: zypperRepoDir,
},
{
name: "zypper repo format is requested, returns default zypper repo format",
op: asAny(ZypperRepoFormat),
want: filepath.Join(zypperRepoDir, "osconfig_managed_%s.repo"),
},
{
name: "yum repo dir is requested, returns default yum repo dir",
op: asAny(YumRepoDir),
want: yumRepoDir,
},
{
name: "yum repo format is requested, returns default yum repo format",
op: asAny(YumRepoFormat),
want: filepath.Join(yumRepoDir, "osconfig_managed_%s.repo"),
},
{
name: "apt repo dir is requested, returns default apt repo dir",
op: asAny(AptRepoDir),
want: aptRepoDir,
},
{
name: "apt repo format is requested, returns default apt repo format",
op: asAny(AptRepoFormat),
want: filepath.Join(aptRepoDir, "osconfig_managed_%s.list"),
},
{
name: "googet repo dir is requested, returns default googet repo dir",
op: asAny(GooGetRepoDir),
want: googetRepoDir,
},
{
name: "googet repo format is requested, returns default googet repo format",
op: asAny(GooGetRepoFormat),
want: filepath.Join(googetRepoDir, "osconfig_managed_%s.repo"),
},
{
name: "universe domain is requested, returns default universe domain",
op: asAny(UniverseDomain),
want: universeDomainDefault,
},
{
name: "inventory enabled is requested, returns default boolean",
op: asAny(OSInventoryEnabled),
want: osInventoryEnabledDefault,
},
{
name: "task notification enabled is requested, returns default boolean",
op: asAny(TaskNotificationEnabled),
want: taskNotificationEnabledDefault,
},
{
name: "guest policies enabled is requested, returns default boolean",
op: asAny(GuestPoliciesEnabled),
want: guestPoliciesEnabledDefault,
},
{
name: "debug enabled is requested, returns default boolean",
op: asAny(Debug),
want: debugEnabledDefault,
},
{
name: "svc poll interval is requested, returns default duration",
op: asAny(SvcPollInterval),
want: time.Duration(osConfigPollIntervalDefault) * time.Minute,
},
{
name: "svc endpoint is requested, returns default zonal endpoint",
op: asAny(SvcEndpoint),
want: "fake-zone-osconfig.googleapis.com.:443",
},
{
name: "extended inventory enabled is requested, returns default boolean",
op: asAny(ExtendedInventoryEnabled),
want: extendedInventoryEnabledDefault,
},
{
name: "extended inventory extractors allowed is requested, returns default slice",
op: asAny(ExtendedInventoryExtractorsAllowed),
want: []string(nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
utiltest.AssertEquals(t, tt.op(), tt.want)
})
}
}
func TestSetExtendedInventory(t *testing.T) {
tests := []struct {
name string
md metadataJSON
wantEnabled bool
wantExtractors []string
}{
{
name: "project and instance values are empty, returns defaults",
md: metadataJSON{},
wantEnabled: false,
wantExtractors: nil,
},
{
name: "project sets extended inventory values, returns project config",
md: metadataJSON{
Project: projectJSON{
Attributes: attributesJSON{
ExtendedInventoryEnabled: "true",
ExtendedInventoryExtractorsAllowed: "pkg, os",
},
},
},
wantEnabled: true,
wantExtractors: []string{"pkg", "os"},
},
{
name: "instance overrides project extended inventory values, returns instance config",
md: metadataJSON{
Project: projectJSON{
Attributes: attributesJSON{
ExtendedInventoryEnabled: "true",
ExtendedInventoryExtractorsAllowed: "pkg, os",
},
},
Instance: instanceJSON{
Attributes: attributesJSON{
ExtendedInventoryEnabled: "false",
ExtendedInventoryExtractorsAllowed: "pkg",
},
},
},
wantEnabled: false,
wantExtractors: []string{"pkg"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := createConfigFromMetadata(tt.md)
agentConfigMx.Lock()
agentConfig = c
agentConfigMx.Unlock()
utiltest.AssertEquals(t, ExtendedInventoryEnabled(), tt.wantEnabled)
utiltest.AssertEquals(t, ExtendedInventoryExtractorsAllowed(), tt.wantExtractors)
})
}
}
// TestWatchConfigUnchangedConfigTimeout ignores unchanged metadata until timeout.
func TestWatchConfigUnchangedConfigTimeout(t *testing.T) {
utiltest.OverrideVariable(t, &watchConfigRetryInterval, 1*time.Millisecond)
utiltest.OverrideVariable(t, &osConfigWatchConfigTimeout, 10*time.Millisecond)
utiltest.OverrideVariable(t, &agentConfig, createConfigFromMetadata(metadataJSON{}))
before := getAgentConfig()
var count int
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
count++
w.Header().Set("Etag", fmt.Sprintf("etag-%d", count))
w.Header().Set("Metadata-Flavor", "Google")
// Return exactly the same config on every request so asSha256() matches
fmt.Fprint(w, `{}`)
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := WatchConfig(ctx)
utiltest.AssertErrorMatch(t, err, nil)
utiltest.AssertErrorMatch(t, ctx.Err(), nil)
if got := getAgentConfig(); !reflect.DeepEqual(got, before) {
t.Errorf("Agent config changed after unchanged metadata: got %+v, want %+v", got, before)
}
if count <= 1 {
t.Errorf("WatchConfig made %d metadata requests, want more than 1", count)
}
}
// TestWatchConfigWebErrorLimit returns a wrapped network error after retry exhaustion.
func TestWatchConfigWebErrorLimit(t *testing.T) {
lEtag.set("0")
utiltest.OverrideVariable(t, &watchConfigRetryInterval, 1*time.Millisecond)
utiltest.OverrideVariable(t, &osConfigWatchConfigTimeout, 1*time.Second)
t.Setenv(metadataHostEnv, "mock-host")
mockNetErr := &net.OpError{
Op: "dial",
Net: "tcp",
Err: errors.New("connection refused"),
}
utiltest.OverrideVariable(t, &defaultClient, &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return nil, mockNetErr
})})
err := WatchConfig(context.Background())
wantBaseErr := &url.Error{
Op: "Get",
URL: "http://mock-host/computeMetadata/v1/?recursive=true&alt=json&wait_for_change=true&last_etag=0&timeout_sec=60",
Err: mockNetErr,
}
wantErr := fmt.Errorf("network error when requesting metadata, make sure your instance has an active network and can reach the metadata server: %w", wantBaseErr)
utiltest.AssertErrorMatch(t, err, wantErr)
}
// TestWatchConfigUnmarshalErrorLimit returns the unmarshal error after retry exhaustion.
func TestWatchConfigUnmarshalErrorLimit(t *testing.T) {
utiltest.OverrideVariable(t, &watchConfigRetryInterval, 1*time.Millisecond)
utiltest.OverrideVariable(t, &osConfigWatchConfigTimeout, 1*time.Second)
badJSON := []byte(`{"bad json"`)
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Etag", fmt.Sprintf("unmarshal-error-etag-%d", time.Now().UnixNano()))
w.Header().Set("Metadata-Flavor", "Google")
w.Write(badJSON)
})
err := WatchConfig(context.Background())
utiltest.AssertErrorMatch(t, err, metadataUnmarshalErr(badJSON))
}
// TestWatchConfigContextCancel returns nil when the context is canceled.
func TestWatchConfigContextCancel(t *testing.T) {
utiltest.OverrideVariable(t, &watchConfigRetryInterval, 1*time.Minute)
utiltest.OverrideVariable(t, &osConfigWatchConfigTimeout, 1*time.Minute)
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Etag", fmt.Sprintf("cancel-etag-%d", time.Now().UnixNano()))
w.Header().Set("Metadata-Flavor", "Google")
fmt.Fprint(w, `{"bad json"`) // Trigger unmarshal error loop which checks context
})
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel the context immediately prior to passing it in
utiltest.AssertErrorMatch(t, WatchConfig(ctx), nil)
}
// TestSetConfigError returns an unmarshal error when metadata is empty.
func TestSetConfigError(t *testing.T) {
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {})
utiltest.OverrideVariable(t, &osConfigWatchConfigTimeout, 1*time.Millisecond)
err := WatchConfig(context.Background())
utiltest.AssertErrorMatch(t, err, metadataUnmarshalErr([]byte{}))
}
func TestVersion(t *testing.T) {
utiltest.AssertEquals(t, Version(), "")
var v = "1"
SetVersion(v)
utiltest.AssertEquals(t, Version(), v)
}
// TestLoggingFlags reflects the current logging flag values.
func TestLoggingFlags(t *testing.T) {
utiltest.OverrideVariable(t, stdout, true)
utiltest.OverrideVariable(t, disableLocalLogging, true)
utiltest.AssertEquals(t, Stdout(), true)
utiltest.AssertEquals(t, DisableLocalLogging(), true)
utiltest.OverrideVariable(t, stdout, false)
utiltest.OverrideVariable(t, disableLocalLogging, false)
utiltest.AssertEquals(t, Stdout(), false)
utiltest.AssertEquals(t, DisableLocalLogging(), false)
}
// TestIDToken validates token caching and token parsing errors.
func TestIDToken(t *testing.T) {
validTokenExp := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)
validToken := tokenWithExp(validTokenExp)
expiringToken := tokenWithExp(time.Now().Add(5 * time.Minute))
malformedToken := "not.a.valid.token"
malformedTokenErr := errors.New("jws: invalid token received")
tests := []struct {
name string
handler http.HandlerFunc
setup func()
wantToken string
wantErr error
wantRequests int
}{
{
name: "cached token is valid, returns cached token without metadata request",
handler: metadataIdentityHandler(validToken),
setup: func() {
identity = idToken{raw: validToken, exp: &validTokenExp}
},
wantToken: validToken,
wantErr: nil,
wantRequests: 0,
},
{
name: "cached token expires within ten minutes, fetches a fresh valid token",
handler: metadataIdentityHandler(validToken),
setup: func() {
exp := time.Now().Add(5 * time.Minute)
identity = idToken{raw: expiringToken, exp: &exp}
},
wantToken: validToken,
wantErr: nil,
wantRequests: 1,
},
{
name: "metadata server returns http 500, returns an error after retries",
handler: func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
},
wantErr: fmt.Errorf("error getting token from metadata: %w", errors.New("compute: Received 500 `internal error\n`")),
// The compute/metadata client library automatically retries on 500 errors (1 initial + 5 retries).
wantRequests: 6,
},
{
name: "metadata server returns malformed token, returns an error",
handler: metadataIdentityHandler(malformedToken),
wantErr: malformedTokenErr,
wantRequests: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var requests int
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
requests++
tt.handler(w, r)
})
identity = idToken{}
if tt.setup != nil {
tt.setup()
}
token, err := IDToken()
utiltest.AssertErrorMatch(t, err, tt.wantErr)
utiltest.AssertEquals(t, token, tt.wantToken)
utiltest.AssertEquals(t, requests, tt.wantRequests)
})
}
}
// TestFormatMetadataError wraps DNS and network metadata errors.
func TestFormatMetadataError(t *testing.T) {
errStandard := fmt.Errorf("standard error")
errDNS := &url.Error{Err: &net.DNSError{Err: "no such host"}}
errNet := &url.Error{Err: &net.OpError{Op: "dial", Net: "tcp"}}
tests := []struct {
name string
inputErr error
wantErr error
}{
{
name: "input is a standard error, returns the original error",
inputErr: errStandard,
wantErr: errStandard,
},
{
name: "input is a dns error, returns a wrapped dns error",
inputErr: errDNS,
wantErr: fmt.Errorf("DNS error when requesting metadata, check DNS settings and ensure metadata.google.internal is setup in your hosts file: %w", errDNS),
},
{
name: "input is a network error, returns a wrapped network error",
inputErr: errNet,
wantErr: fmt.Errorf("network error when requesting metadata, make sure your instance has an active network and can reach the metadata server: %w", errNet),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
utiltest.AssertErrorMatch(t, formatMetadataError(tt.inputErr), tt.wantErr)
})
}
}
// TestGetMetadata returns metadata bodies and etags for known responses.
func TestGetMetadata(t *testing.T) {
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/computeMetadata/v1/test-success" {
w.Header().Set("Etag", "test-etag")
fmt.Fprint(w, "success")
return
}
if r.URL.Path == "/computeMetadata/v1/test-404" {
http.NotFound(w, r)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
})
tests := []struct {
name string
suffix string
wantBody string
wantEtag string
}{
{
name: "metadata suffix maps to a 200 response, returns body and etag",
suffix: "test-success",
wantBody: "success",
wantEtag: "test-etag",
},
{
name: "metadata suffix maps to a 404 response, returns empty body and etag",
suffix: "test-404",
},
{
name: "metadata suffix maps to a 500 response, returns empty body and etag",
suffix: "test-500",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, etag, err := getMetadata(tt.suffix)
utiltest.AssertErrorMatch(t, err, nil)
utiltest.AssertEquals(t, string(body), tt.wantBody)
utiltest.AssertEquals(t, etag, tt.wantEtag)
})
}
}
// TestGetMetadataFallback uses the metadata IP when the host env var is empty.
func TestGetMetadataFallback(t *testing.T) {
t.Setenv(metadataHostEnv, "")
var requestedURL string
utiltest.OverrideVariable(t, &defaultClient, &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
requestedURL = req.URL.String()
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("mock response"))}, nil
})})
_, _, err := getMetadata("test-suffix")
utiltest.AssertErrorMatch(t, err, nil)
want := "http://" + metadataIP + "/computeMetadata/v1/test-suffix"
utiltest.AssertEquals(t, requestedURL, want)
}
// TestGetMetadataErrors returns request construction and transport errors.
func TestGetMetadataErrors(t *testing.T) {
invalidSuffix := "suffix\x7f"
invalidURLErr := func() error {
_, err := http.NewRequest("GET", "http://"+metadataIP+"/computeMetadata/v1/"+invalidSuffix, nil)
return err
}()
transportErr := errors.New("mock dial error")
tests := []struct {
name string
suffix string
mockTransport http.RoundTripper
wantErr error
}{
{
name: "metadata suffix contains invalid control character, returns request creation error",
suffix: invalidSuffix,
wantErr: invalidURLErr,
},
{
name: "metadata client transport returns an error, propagates transport error",
suffix: "test-suffix",
mockTransport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return nil, transportErr }),
wantErr: &url.Error{Op: "Get", URL: "http://" + metadataIP + "/computeMetadata/v1/test-suffix", Err: transportErr},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mockTransport != nil {
utiltest.OverrideVariable(t, &defaultClient, &http.Client{Transport: tt.mockTransport})
}
_, _, err := getMetadata(tt.suffix)
utiltest.AssertErrorMatch(t, err, tt.wantErr)
})
}
}
// TestConfigSha256 changes when config content changes.
func TestConfigSha256(t *testing.T) {
c1 := &config{projectID: "test-project", osInventoryEnabled: true}
c2 := &config{projectID: "test-project", osInventoryEnabled: true}
c3 := &config{projectID: "test-project", osInventoryEnabled: false}
utiltest.AssertEquals(t, c1.asSha256(), c2.asSha256())
if c1.asSha256() == c3.asSha256() {
t.Errorf("Expected different configs to have different SHA256")
}
}
// TestSystemPaths returns OS-specific system paths.
func TestSystemPaths(t *testing.T) {
utiltest.OverrideVariable(t, &goos, runtime.GOOS)
tests := []struct {
name string
op func() string
want map[string]string
}{
{
name: "task state file is requested",
op: TaskStateFile,
want: map[string]string{"windows": filepath.Join(GetCacheDirWindows(), "osconfig_task.state"), "linux": taskStateFileLinux},
},
{
name: "old task state file is requested",
op: OldTaskStateFile,
want: map[string]string{"windows": oldTaskStateFileWindows, "linux": oldTaskStateFileLinux},
},
{
name: "restart file is requested",
op: RestartFile,
want: map[string]string{"windows": filepath.Join(GetCacheDirWindows(), "osconfig_agent_restart_required"), "linux": restartFileLinux},
},
{
name: "old restart file is requested",
op: OldRestartFile,
want: map[string]string{"windows": oldRestartFileLinux, "linux": oldRestartFileLinux},
},
{
name: "cache directory is requested",
op: CacheDir,
want: map[string]string{"windows": GetCacheDirWindows(), "linux": cacheDirLinux},
},
{
name: "serial log port is requested",
op: SerialLogPort,
want: map[string]string{"windows": "COM1", "linux": ""},
},
}
for _, tt := range tests {
for _, testOS := range []string{"windows", "linux"} {
t.Run(fmt.Sprintf("%s, returns %s path", tt.name, testOS), func(t *testing.T) {
utiltest.OverrideVariable(t, &goos, testOS)
utiltest.AssertEquals(t, tt.op(), tt.want[testOS])
})
}
}
}
// TestMiscGetters returns static getter values.
func TestMiscGetters(t *testing.T) {
SetVersion("1.2.3")
tests := []struct {
name string
got any
want any
}{
{
name: "agent capabilities are requested, returns supported capability list",
got: Capabilities(),
want: []string{"PATCH_GA", "GUEST_POLICY_BETA", "CONFIG_V1"},
},
{
name: "user agent is requested after version is set, returns versioned user agent",
got: UserAgent(),
want: "google-osconfig-agent/1.2.3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
utiltest.AssertEquals(t, tt.got, tt.want)
})
}
}
// TestCreateConfigFromMetadata applies metadata precedence to config values.
func TestCreateConfigFromMetadata(t *testing.T) {
// Reset the global agent config to avoid test cross-contamination
agentConfigMx.Lock()
agentConfig = &config{}
agentConfigMx.Unlock()
pollInt15 := json.Number("15")
pollInt20 := json.Number("20")
id98765 := json.Number("98765")
tests := []struct {
name string
md metadataJSON
setDebugFlag bool
want *config
}{
{
name: "metadata is empty, returns config defaults",
md: metadataJSON{},
want: &config{
osInventoryEnabled: osInventoryEnabledDefault,
guestPoliciesEnabled: guestPoliciesEnabledDefault,
taskNotificationEnabled: taskNotificationEnabledDefault,
debugEnabled: debugEnabledDefault,
extendedInventoryEnabled: extendedInventoryEnabledDefault,
svcEndpoint: strings.ReplaceAll(prodEndpoint, "{zone}", ""),
osConfigPollInterval: osConfigPollIntervalDefault,
googetRepoFilePath: googetRepoFilePath,
zypperRepoFilePath: zypperRepoFilePath,
yumRepoFilePath: yumRepoFilePath,
aptRepoFilePath: aptRepoFilePath,
universeDomain: universeDomainDefault,
},
},
{
name: "project metadata sets debug and poll interval, returns project derived config",
md: metadataJSON{
Project: projectJSON{
ProjectID: "proj-1",
Attributes: attributesJSON{
LogLevel: "debug",
PollInterval: &pollInt15,
OSConfigEnabled: "true",
},
},
},
want: &config{
projectID: "proj-1",
osInventoryEnabled: true,
guestPoliciesEnabled: true,
taskNotificationEnabled: true,
debugEnabled: true,
extendedInventoryEnabled: extendedInventoryEnabledDefault,
svcEndpoint: strings.ReplaceAll(prodEndpoint, "{zone}", ""),
osConfigPollInterval: 15,
googetRepoFilePath: googetRepoFilePath,
zypperRepoFilePath: zypperRepoFilePath,
yumRepoFilePath: yumRepoFilePath,
aptRepoFilePath: aptRepoFilePath,
universeDomain: universeDomainDefault,
},
},
{
name: "instance metadata conflicts with project metadata, returns instance overrides",
md: metadataJSON{
Project: projectJSON{
ProjectID: "proj-1",
Attributes: attributesJSON{
LogLevel: "info",
PollInterval: &pollInt15,
OSConfigEnabled: "true",
},
},
Instance: instanceJSON{
Attributes: attributesJSON{
LogLevel: "debug",
PollInterval: &pollInt20,
OSConfigEnabled: "false",
},
},
},
want: &config{
projectID: "proj-1",
osInventoryEnabled: false,
guestPoliciesEnabled: false,
taskNotificationEnabled: false,
debugEnabled: true,
extendedInventoryEnabled: extendedInventoryEnabledDefault,
svcEndpoint: strings.ReplaceAll(prodEndpoint, "{zone}", ""),
osConfigPollInterval: 20,
googetRepoFilePath: googetRepoFilePath,
zypperRepoFilePath: zypperRepoFilePath,
yumRepoFilePath: yumRepoFilePath,
aptRepoFilePath: aptRepoFilePath,
universeDomain: universeDomainDefault,
},
},
{
name: "legacy poll interval and disabled features are set, returns config with legacy values applied",
md: metadataJSON{
Project: projectJSON{
Attributes: attributesJSON{
PollIntervalOld: &pollInt15,
},
},
Instance: instanceJSON{
ID: &id98765,
Attributes: attributesJSON{
OSConfigEnabled: "true",
DisabledFeatures: "osinventory, guestpolicies",
},
},
},
want: &config{
instanceID: "98765",
osInventoryEnabled: false,
guestPoliciesEnabled: false,
taskNotificationEnabled: true,
debugEnabled: debugEnabledDefault,
extendedInventoryEnabled: extendedInventoryEnabledDefault,
svcEndpoint: strings.ReplaceAll(prodEndpoint, "{zone}", ""),
osConfigPollInterval: 15,
googetRepoFilePath: googetRepoFilePath,
zypperRepoFilePath: zypperRepoFilePath,
yumRepoFilePath: yumRepoFilePath,
aptRepoFilePath: aptRepoFilePath,
universeDomain: universeDomainDefault,
},
},
{
name: "debug flag is enabled with non-debug metadata, returns config with debug enabled",
md: metadataJSON{
Project: projectJSON{
Attributes: attributesJSON{
LogLevel: "info",
},
},
},
setDebugFlag: true,
want: &config{
osInventoryEnabled: osInventoryEnabledDefault,
guestPoliciesEnabled: guestPoliciesEnabledDefault,
taskNotificationEnabled: taskNotificationEnabledDefault,
debugEnabled: true,
extendedInventoryEnabled: extendedInventoryEnabledDefault,
svcEndpoint: strings.ReplaceAll(prodEndpoint, "{zone}", ""),
osConfigPollInterval: osConfigPollIntervalDefault,
googetRepoFilePath: googetRepoFilePath,
zypperRepoFilePath: zypperRepoFilePath,
yumRepoFilePath: yumRepoFilePath,
aptRepoFilePath: aptRepoFilePath,
universeDomain: universeDomainDefault,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
utiltest.OverrideVariable(t, debug, tt.setDebugFlag)
got := createConfigFromMetadata(tt.md)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("createConfigFromMetadata() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestSvcEndpoint(t *testing.T) {
var request int
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
switch request {
case 0:
w.Header().Set("Etag", "etag-0")
// we always get zone value in instance metadata.
fmt.Fprintln(w, `{"instance": {"id": 12345,"name": "name","zone": "fakezone","attributes": {"osconfig-endpoint": "{zone}-dev.osconfig.googleapis.com"}}}`)
case 1:
w.Header().Set("Etag", "etag-1")
fmt.Fprintln(w, `{"universe": {"universeDomain": "domain.com"}, "instance": {"id": 12345,"name": "name","zone": "fakezone","attributes": {"osconfig-endpoint": "{zone}-dev.osconfig.googleapis.com"}}}`)
}
})
for i, expectedSvcEndpoint := range []string{"fakezone-dev.osconfig.googleapis.com", "fakezone-dev.osconfig.domain.com"} {
request = i
if err := WatchConfig(context.Background()); err != nil {
t.Fatalf("Error running SetConfig: %v", err)
}
utiltest.AssertEquals(t, SvcEndpoint(), expectedSvcEndpoint)
}
}
func TestDisableCloudLogging(t *testing.T) {
var request int
setupMockMetadataServer(t, func(w http.ResponseWriter, r *http.Request) {
switch request {
case 0:
w.Header().Set("Etag", "etag-0")
fmt.Fprintln(w, `{"universe":{"universeDomain": "domain.com"}}`)
case 1:
w.Header().Set("Etag", "etag-1")
fmt.Fprintln(w, `{"instance": {"zone": "fake-zone"}}`)
}
})
for i, expectedDisableCloudLoggingValue := range []bool{true, false} {
request = i
if err := WatchConfig(context.Background()); err != nil {
t.Fatalf("Error running SetConfig: %v", err)
}
utiltest.AssertEquals(t, DisableCloudLogging(), expectedDisableCloudLoggingValue)
}
}
// TestSetScalibrEnablement applies metadata precedence for scalibr enablement.
func TestSetScalibrEnablement(t *testing.T) {
tests := []struct {
name string
md metadataJSON
want bool
}{
{
name: "project and instance values are empty, returns scalibr disabled",
want: false,
},
{
name: "project enables scalibr and instance is empty, returns scalibr enabled",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{ScalibrLinuxEnabled: "true"}},
},
want: true,
},
{
name: "project disables scalibr and instance is empty, returns scalibr disabled",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{ScalibrLinuxEnabled: "false"}},
},
want: false,
},
{
name: "instance enables scalibr and project is empty, returns scalibr enabled",
md: metadataJSON{
Instance: instanceJSON{Attributes: attributesJSON{ScalibrLinuxEnabled: "true"}},
},
want: true,
},
{
name: "instance enables scalibr and project disables it, returns instance override",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{ScalibrLinuxEnabled: "false"}},
Instance: instanceJSON{Attributes: attributesJSON{ScalibrLinuxEnabled: "true"}},
},
want: true,
},
{
name: "instance disables scalibr and project enables it, returns instance override",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{ScalibrLinuxEnabled: "true"}},
Instance: instanceJSON{Attributes: attributesJSON{ScalibrLinuxEnabled: "false"}},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &config{}
setScalibrEnablement(tt.md, c)
utiltest.AssertEquals(t, c.scalibrLinuxEnabled, tt.want)
})
}
}
// TestSetTraceGetInventory applies metadata precedence for inventory tracing.
func TestSetTraceGetInventory(t *testing.T) {
tests := []struct {
name string
md metadataJSON
want bool
}{
{
name: "project and instance values are empty, returns trace get inventory disabled",
want: false,
},
{
name: "project enables trace get inventory and instance is empty, returns tracing enabled",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{TraceGetInventory: "true"}},
},
want: true,
},
{
name: "project disables trace get inventory and instance is empty, returns tracing disabled",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{TraceGetInventory: "false"}},
},
want: false,
},
{
name: "instance enables trace get inventory and project is empty, returns tracing enabled",
md: metadataJSON{
Instance: instanceJSON{Attributes: attributesJSON{TraceGetInventory: "true"}},
},
want: true,
},
{
name: "instance enables trace get inventory and project disables it, returns instance override",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{TraceGetInventory: "false"}},
Instance: instanceJSON{Attributes: attributesJSON{TraceGetInventory: "true"}},
},
want: true,
},
{
name: "instance disables trace get inventory and project enables it, returns instance override",
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{TraceGetInventory: "true"}},
Instance: instanceJSON{Attributes: attributesJSON{TraceGetInventory: "false"}},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &config{}
setTraceGetInventory(tt.md, c)
utiltest.AssertEquals(t, c.traceGetInventory, tt.want)
})
}
}
// TestSetSVCEndpoint applies endpoint precedence and placeholder replacement.
func TestSetSVCEndpoint(t *testing.T) {
utiltest.OverrideVariable(t, endpoint, *endpoint)
tests := []struct {
name string
flag string
md metadataJSON
cfg config
want string
}{
{
name: "flag and metadata endpoints are empty, returns default zonal endpoint",
flag: prodEndpoint,
cfg: config{
instanceZone: "projects/123/zones/us-west1-a",
svcEndpoint: prodEndpoint,
universeDomain: "googleapis.com",
},
want: "us-west1-a-osconfig.googleapis.com.:443",
},
{
name: "endpoint flag is set, returns flag endpoint",
flag: "custom-endpoint",
md: metadataJSON{
Instance: instanceJSON{Attributes: attributesJSON{OSConfigEndpoint: "inst-new"}},
},
cfg: config{
svcEndpoint: prodEndpoint,
universeDomain: "googleapis.com",
},
want: "custom-endpoint",
},
{
name: "instance new endpoint is set, returns zonal instance endpoint",
flag: prodEndpoint,
md: metadataJSON{
Instance: instanceJSON{Attributes: attributesJSON{OSConfigEndpoint: "inst-new-{zone}"}},
},
cfg: config{
instanceZone: "projects/123/zones/us-west1-a",
svcEndpoint: prodEndpoint,
universeDomain: "googleapis.com",
},
want: "inst-new-us-west1-a",
},
{
name: "instance legacy endpoint is set, returns zonal legacy instance endpoint",
flag: prodEndpoint,
md: metadataJSON{
Instance: instanceJSON{Attributes: attributesJSON{OSConfigEndpointOld: "inst-old-{zone}"}},
},
cfg: config{
instanceZone: "projects/123/zones/us-west1-a",
svcEndpoint: prodEndpoint,
universeDomain: "googleapis.com",
},
want: "inst-old-us-west1-a",
},
{
name: "project new endpoint is set, returns zonal project endpoint",
flag: prodEndpoint,
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{OSConfigEndpoint: "proj-new-{zone}"}},
},
cfg: config{
instanceZone: "projects/123/zones/us-west1-a",
svcEndpoint: prodEndpoint,
universeDomain: "googleapis.com",
},
want: "proj-new-us-west1-a",
},
{
name: "project legacy endpoint is set, returns zonal legacy project endpoint",
flag: prodEndpoint,
md: metadataJSON{
Project: projectJSON{Attributes: attributesJSON{OSConfigEndpointOld: "proj-old-{zone}"}},
},
cfg: config{
instanceZone: "projects/123/zones/us-west1-a",
svcEndpoint: prodEndpoint,
universeDomain: "googleapis.com",
},
want: "proj-old-us-west1-a",
},
{
name: "endpoint uses default domain and universe domain is custom, returns rewritten universe endpoint",
flag: prodEndpoint,
md: metadataJSON{
Instance: instanceJSON{Attributes: attributesJSON{OSConfigEndpoint: "test-osconfig.googleapis.com"}},
},
cfg: config{
svcEndpoint: prodEndpoint,
universeDomain: "my-universe.com",
},
want: "test-osconfig.my-universe.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
utiltest.OverrideVariable(t, endpoint, tt.flag)
c := tt.cfg
setSVCEndpoint(tt.md, &c)
utiltest.AssertEquals(t, c.svcEndpoint, tt.want)
})
}
}
// TestGetCacheDirWindows prefers the user cache dir and falls back to TempDir.
func TestGetCacheDirWindows(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
want func(t *testing.T) string
}{
{
name: "user cache directory is available, returns cache path under user cache directory",
setup: func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LocalAppData", "")
t.Setenv("XDG_CACHE_HOME", "")
},
want: func(t *testing.T) string {
cacheDir, err := os.UserCacheDir()
utiltest.AssertErrorMatch(t, err, nil)
return filepath.Join(cacheDir, windowsCacheDir)
},
},
{
name: "windows user cache directory is unavailable, returns tempdir fallback path",
setup: func(t *testing.T) {
envs := []string{"HOME", "LocalAppData", "XDG_CACHE_HOME"}
for _, env := range envs {
t.Setenv(env, "")
}
},
want: func(t *testing.T) string {
return filepath.Join("/tmp", windowsCacheDir)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup(t)
t.Setenv("TMPDIR", "/tmp")
utiltest.AssertEquals(t, GetCacheDirWindows(), tt.want(t))
})
}
}
// TestFlagsAndEnvVars parses environment-backed flags.
func TestFlagsAndEnvVars(t *testing.T) {
tests := []struct {
name string
freeOSMemoryVal string
disableInventoryWrite string
wantFreeOS bool
wantDisableInv bool
}{
{
name: "environment enables both flags, returns both flags enabled",
freeOSMemoryVal: "true",
disableInventoryWrite: "1",
wantFreeOS: true,
wantDisableInv: true,
},
{
name: "environment disables both flags, returns both flags disabled",
freeOSMemoryVal: "false",
disableInventoryWrite: "0",
wantFreeOS: false,
wantDisableInv: false,
},
{
name: "environment leaves both flags empty, returns both flags disabled",
freeOSMemoryVal: "",
disableInventoryWrite: "",
wantFreeOS: false,
wantDisableInv: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
utiltest.OverrideVariable(t, &freeOSMemory, tt.freeOSMemoryVal)
utiltest.OverrideVariable(t, &disableInventoryWrite, tt.disableInventoryWrite)
utiltest.AssertEquals(t, FreeOSMemory(), tt.wantFreeOS)
utiltest.AssertEquals(t, DisableInventoryWrite(), tt.wantDisableInv)
})
}
}
// TestParseBool parses supported boolean string forms.
func TestParseBool(t *testing.T) {
tests := []struct {
input string
want bool
}{
{input: "true", want: true},
{input: "1", want: true},
{input: "false", want: false},
{input: "0", want: false},
{input: "invalid", want: false},
}
for _, tt := range tests {
utiltest.AssertEquals(t, parseBool(tt.input), tt.want)
}
}
// TestParseFeatures applies comma-separated feature flags.
func TestParseFeatures(t *testing.T) {
tests := []struct {
name string
initial config
features string
enabled bool
want config
}{
{
name: "feature list enables tasks ospackage and osinventory, returns enabled feature state",
initial: config{},
features: "tasks, ospackage, osinventory, unknown",
enabled: true,
want: config{
taskNotificationEnabled: true,
guestPoliciesEnabled: true,
osInventoryEnabled: true,
},
},
{
name: "feature list disables ospatch and guestpolicies, returns disabled task and guest policy state",
initial: config{
taskNotificationEnabled: true,
guestPoliciesEnabled: true,
osInventoryEnabled: true,
},
features: "ospatch, guestpolicies",
enabled: false,
want: config{
taskNotificationEnabled: false,
guestPoliciesEnabled: false,
osInventoryEnabled: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := tt.initial
c.parseFeatures(tt.features, tt.enabled)
if !reflect.DeepEqual(c, tt.want) {
t.Errorf("parseFeatures() state = %+v, want %+v", c, tt.want)
}
})
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func metadataUnmarshalErr(data []byte) error {
var dummy metadataJSON
return json.Unmarshal(data, &dummy)
}
func tokenWithExp(exp time.Time) string {
payload := fmt.Sprintf(`{"exp": %d}`, exp.Unix())
payloadB64 := base64.RawURLEncoding.EncodeToString([]byte(payload))
return "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + payloadB64 + ".ZHVtbXk"
}
func metadataIdentityHandler(token string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/computeMetadata/v1/instance/service-accounts/default/identity") {
w.Header().Set("Metadata-Flavor", "Google")
fmt.Fprint(w, token)
return
}
http.NotFound(w, r)
}
}
func asAny[T any](f func() T) func() any {
return func() any {
return f()
}
}