| // Copyright 2024 Google LLC | |
| // | |
| // 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 | |
| // | |
| // https://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 manager | |
| import ( | |
| "context" | |
| "crypto/sha256" | |
| "encoding/hex" | |
| "net" | |
| "path/filepath" | |
| "strings" | |
| "sync" | |
| "testing" | |
| "time" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" | |
| "github.com/google/go-cmp/cmp" | |
| "google.golang.org/grpc" | |
| "google.golang.org/grpc/codes" | |
| "google.golang.org/grpc/status" | |
| "google.golang.org/protobuf/proto" | |
| "google.golang.org/protobuf/testing/protocmp" | |
| pb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm" | |
| structpb "google.golang.org/protobuf/types/known/structpb" | |
| ) | |
| type Server interface { | |
| Serve(net.Listener) error | |
| Stop() | |
| } | |
| // startTestServer registers testServer, starts a test grpc server and returns the address. | |
| func startTestServer(t *testing.T, testServer *testPluginServer, protocol, addr string) string { | |
| t.Helper() | |
| srv := grpc.NewServer() | |
| pb.RegisterGuestAgentPluginServer(srv, testServer) | |
| if testServer.seenStartReq == nil { | |
| testServer.seenStartReq = make(map[string]*pb.StartRequest) | |
| } | |
| lis, err := net.Listen(protocol, addr) | |
| if err != nil { | |
| t.Fatalf("Test grpc server failed to listen on %s: %v", addr, err) | |
| } | |
| if protocol == "tcp" { | |
| // Get the port server started listening on. | |
| addr = lis.Addr().String() | |
| } | |
| t.Cleanup(func() { lis.Close() }) | |
| go srv.Serve(lis) | |
| t.Cleanup(srv.Stop) | |
| return addr | |
| } | |
| type testPluginServer struct { | |
| mu sync.Mutex | |
| code int32 | |
| stopCalled bool | |
| statusCalled bool | |
| applyCalled bool | |
| applyFail bool | |
| ctrs map[string]int | |
| seenStartReq map[string]*pb.StartRequest | |
| statusFail bool | |
| pb.UnimplementedGuestAgentPluginServer | |
| } | |
| func (ts *testPluginServer) Apply(ctx context.Context, msg *pb.ApplyRequest) (*pb.ApplyResponse, error) { | |
| ts.applyCalled = true | |
| data := msg.GetStringConfig() | |
| if ts.ctrs != nil { | |
| ts.ctrs[data]++ | |
| } | |
| if string(data) == "unimplemented" { | |
| return nil, status.Error(codes.Unimplemented, "unimplemented") | |
| } | |
| if string(data) == "failure" || ts.applyFail { | |
| return nil, status.Error(codes.Unknown, "test error") | |
| } | |
| return nil, nil | |
| } | |
| func (ts *testPluginServer) Start(ctx context.Context, msg *pb.StartRequest) (*pb.StartResponse, error) { | |
| key := msg.GetStringConfig() | |
| ts.mu.Lock() | |
| ts.ctrs[key]++ | |
| ts.seenStartReq[key] = msg | |
| ts.mu.Unlock() | |
| switch key { | |
| case "timeout": | |
| // Add fake delay to test request timeout. | |
| time.Sleep(time.Second) | |
| return nil, nil | |
| case "error": | |
| return nil, status.Error(1, "test error") | |
| default: | |
| return nil, nil | |
| } | |
| } | |
| func (ts *testPluginServer) Stop(ctx context.Context, msg *pb.StopRequest) (*pb.StopResponse, error) { | |
| ts.mu.Lock() | |
| ts.stopCalled = true | |
| ts.mu.Unlock() | |
| switch msg.GetDeadline().GetSeconds() { | |
| case 1: | |
| // Add fake delay to test request timeout. | |
| time.Sleep(time.Second * 2) | |
| return nil, nil | |
| case 2: | |
| return nil, status.Error(1, "test error") | |
| default: | |
| return nil, nil | |
| } | |
| } | |
| func (ts *testPluginServer) GetStatus(ctx context.Context, req *pb.GetStatusRequest) (*pb.Status, error) { | |
| ts.statusCalled = true | |
| if ts.statusFail { | |
| return nil, status.Error(1, "test error") | |
| } | |
| switch req.GetData() { | |
| case "early-initialized": | |
| return &pb.Status{Code: ts.code, Results: []string{"initialized"}}, nil | |
| case "fail": | |
| return nil, status.Error(1, "test error") | |
| default: | |
| return &pb.Status{Code: 0, Results: []string{"running ok"}}, nil | |
| } | |
| } | |
| func TestApply(t *testing.T) { | |
| ctx := context.Background() | |
| ts := &testPluginServer{ctrs: make(map[string]int)} | |
| addr := ":0" | |
| addr = startTestServer(t, ts, "tcp", addr) | |
| plugin := &Plugin{Name: "testplugin", Revision: "1", Protocol: "tcp", Address: addr, Manifest: &Manifest{StartAttempts: 3}} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect(ctx) failed unexpectedly: %v", err) | |
| } | |
| tests := []string{"success", "failure"} | |
| for _, test := range tests { | |
| t.Run(test, func(t *testing.T) { | |
| _, err := plugin.Apply(ctx, &ServiceConfig{Simple: test}) | |
| shouldFail := test == "failure" | |
| if (err != nil) != shouldFail { | |
| t.Errorf("plugin.Apply(ctx, %s) = error: %v, want error: %t", test, err, shouldFail) | |
| } | |
| }) | |
| } | |
| // No connection, skip apply. | |
| plugin.client = nil | |
| msg := `plugin "testplugin_1" is not connected` | |
| _, err := plugin.Apply(ctx, &ServiceConfig{Simple: "success"}) | |
| if err == nil || !strings.Contains(err.Message(), msg) { | |
| t.Errorf("plugin.Apply(ctx, success) = error: %v, want error containing: %v", err.Message(), msg) | |
| } | |
| } | |
| func TestStart(t *testing.T) { | |
| if err := cfg.Load(nil); err != nil { | |
| t.Fatalf("Failed to load config: %v", err) | |
| } | |
| stateDir := t.TempDir() | |
| cfg.Retrieve().Plugin.StateDir = stateDir | |
| ctx := context.Background() | |
| ts := &testPluginServer{ctrs: make(map[string]int), seenStartReq: make(map[string]*pb.StartRequest)} | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| startTestServer(t, ts, "unix", addr) | |
| plugin := &Plugin{Name: "testplugin", Revision: "1", Protocol: "unix", Address: addr, Manifest: &Manifest{StartAttempts: 3}} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect(ctx) failed unexpectedly: %v", err) | |
| } | |
| pluginState := filepath.Join(stateDir, agentStateDir, pluginInstallDir, plugin.Name) | |
| wantReq := &pb.StartRequest{ | |
| Config: &pb.StartRequest_Config{StateDirectoryPath: pluginState}, | |
| } | |
| tests := []struct { | |
| name string | |
| path string | |
| wantErr string | |
| wantCTR int | |
| startTimeout time.Duration | |
| }{ | |
| { | |
| name: "success", | |
| path: "success", | |
| wantCTR: 1, | |
| startTimeout: time.Second, | |
| }, | |
| { | |
| name: "failure_timeout", | |
| wantErr: context.DeadlineExceeded.Error(), | |
| path: "timeout", | |
| wantCTR: 1, | |
| startTimeout: time.Second / 2, | |
| }, | |
| { | |
| name: "failure_retry", | |
| wantErr: "test error", | |
| path: "error", | |
| wantCTR: 3, | |
| startTimeout: time.Second * 5, | |
| }, | |
| } | |
| for _, test := range tests { | |
| t.Run(test.name, func(t *testing.T) { | |
| plugin.Manifest.StartTimeout = test.startTimeout | |
| plugin.Manifest.StartConfig = &ServiceConfig{Simple: test.path} | |
| _, grpcStatus := plugin.Start(ctx) | |
| if test.wantErr != "" && !strings.Contains(grpcStatus.Message(), test.wantErr) { | |
| t.Errorf("plugin.Start(ctx) = error: %v, want error: %v", grpcStatus.Err(), test.wantErr) | |
| } | |
| ts.mu.Lock() | |
| defer ts.mu.Unlock() | |
| if test.wantCTR != ts.ctrs[test.path] { | |
| t.Errorf("plugin.Start(ctx) = attempts %d, want %d", ts.ctrs[test.path], test.wantCTR) | |
| } | |
| wantReq.ServiceConfig = &pb.StartRequest_StringConfig{StringConfig: test.path} | |
| if diff := cmp.Diff(wantReq, ts.seenStartReq[test.path], protocmp.Transform()); diff != "" { | |
| t.Errorf("plugin.Start(ctx) sent unexpected start request (-want +got):\n%s", diff) | |
| } | |
| }) | |
| } | |
| // No connection, skip start. | |
| plugin.client = nil | |
| plugin.Manifest.StartTimeout = time.Second * 5 | |
| plugin.Manifest.StartConfig = &ServiceConfig{Simple: "success"} | |
| msg := `plugin "testplugin_1" is not connected` | |
| _, grpcStatus := plugin.Start(ctx) | |
| if grpcStatus == nil || !strings.Contains(grpcStatus.Message(), msg) { | |
| t.Errorf("plugin.Start(ctx) = error: %v, want error containing: %v", grpcStatus.Message(), msg) | |
| } | |
| } | |
| func TestStop(t *testing.T) { | |
| ctx := context.Background() | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| startTestServer(t, &testPluginServer{}, "unix", addr) | |
| plugin := &Plugin{Name: "testplugin", Revision: "1", Protocol: "unix", Address: addr, Manifest: &Manifest{StopTimeout: time.Second / 2}} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect(ctx) failed unexpectedly: %v", err) | |
| } | |
| tests := []struct { | |
| name string | |
| reqDeadline int64 | |
| wantErr string | |
| }{ | |
| { | |
| name: "success", | |
| reqDeadline: 5, | |
| }, | |
| { | |
| name: "failure_timeout", | |
| wantErr: context.DeadlineExceeded.Error(), | |
| reqDeadline: 1, | |
| }, | |
| { | |
| name: "failure_stop_err", | |
| wantErr: "test error", | |
| reqDeadline: 2, | |
| }, | |
| } | |
| for _, test := range tests { | |
| t.Run(test.name, func(t *testing.T) { | |
| plugin.Manifest.StopTimeout = time.Duration(test.reqDeadline) * time.Second | |
| _, err := plugin.Stop(ctx, false) | |
| if err.Message() != test.wantErr { | |
| t.Errorf("plugin.Stop(ctx, false) = error: %v, want error: %v", err, test.wantErr) | |
| } | |
| }) | |
| } | |
| // No connection, skip stop. | |
| plugin.client = nil | |
| msg := `plugin "testplugin_1" is not connected` | |
| _, err := plugin.Stop(ctx, false) | |
| if err == nil || !strings.Contains(err.Message(), msg) { | |
| t.Errorf("plugin.Stop(ctx, false) = error: %v, want error containing: %v", err.Message(), msg) | |
| } | |
| } | |
| func TestGetStatus(t *testing.T) { | |
| ctx := context.Background() | |
| tests := []struct { | |
| name string | |
| shouldFail bool | |
| want *pb.Status | |
| }{ | |
| { | |
| name: "success", | |
| want: &pb.Status{Code: 0, Results: []string{"running ok"}}, | |
| }, | |
| { | |
| name: "failure", | |
| shouldFail: true, | |
| }, | |
| } | |
| for _, test := range tests { | |
| t.Run(test.name, func(t *testing.T) { | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| startTestServer(t, &testPluginServer{statusFail: test.shouldFail}, "unix", addr) | |
| plugin := Plugin{Name: "testplugin", Revision: "1", Protocol: "unix", Address: addr} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect(ctx) failed unexpectedly: %v", err) | |
| } | |
| if plugin.client == nil { | |
| t.Fatalf("plugin.Client = nil, want non-nil") | |
| } | |
| got, err := plugin.GetStatus(ctx, "") | |
| if (err != nil) != test.shouldFail { | |
| t.Errorf("plugin.GetStatus(ctx) = error: %v, want error: %t", err, test.shouldFail) | |
| } | |
| if diff := cmp.Diff(test.want, got, protocmp.Transform()); diff != "" { | |
| t.Errorf("plugin.GetStatus(ctx) returned diff (-want +got):\n%s", diff) | |
| } | |
| }) | |
| } | |
| // No connection, skip get status. | |
| plugin := Plugin{Name: "testplugin", Revision: "1"} | |
| msg := `plugin "testplugin_1" is not connected` | |
| _, err := plugin.GetStatus(ctx, "") | |
| if err == nil || !strings.Contains(err.Message(), msg) { | |
| t.Errorf("plugin.GetStatus(ctx) = error: %v, want error containing: %v", err.Message(), msg) | |
| } | |
| } | |
| func TestIsRunning(t *testing.T) { | |
| ctx := context.Background() | |
| tests := []struct { | |
| name string | |
| statusFail bool | |
| want bool | |
| }{ | |
| { | |
| name: "running", | |
| want: true, | |
| }, | |
| { | |
| name: "not_running", | |
| statusFail: true, | |
| want: false, | |
| }, | |
| } | |
| for _, test := range tests { | |
| t.Run(test.name, func(t *testing.T) { | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| startTestServer(t, &testPluginServer{statusFail: test.statusFail}, "unix", addr) | |
| plugin := Plugin{Name: "testplugin", Revision: "1", Protocol: "unix", Address: addr} | |
| if got := plugin.IsRunning(ctx); got != test.want { | |
| t.Errorf("plugin.IsRunning(ctx) = %t, want %t", got, test.want) | |
| } | |
| }) | |
| } | |
| } | |
| func TestBuildStartRequest(t *testing.T) { | |
| if err := cfg.Load(nil); err != nil { | |
| t.Fatalf("Failed to load config: %v", err) | |
| } | |
| dir := t.TempDir() | |
| cfg.Retrieve().Plugin.StateDir = dir | |
| pluginName := "plugin" | |
| stateDir := filepath.Join(dir, agentStateDir, pluginInstallDir, pluginName) | |
| wantStructCfg := &structpb.Struct{ | |
| Fields: map[string]*structpb.Value{ | |
| "foo": structpb.NewStringValue("bar"), | |
| "count": structpb.NewNumberValue(22), | |
| }, | |
| } | |
| bytes, err := proto.Marshal(wantStructCfg) | |
| if err != nil { | |
| t.Fatalf("proto.Marshal(%+v) failed unexpectedly with error: %v", wantStructCfg, err) | |
| } | |
| tests := []struct { | |
| name string | |
| plugin *Plugin | |
| want *pb.StartRequest | |
| }{ | |
| { | |
| name: "simple_cfg", | |
| plugin: &Plugin{Name: pluginName, Manifest: &Manifest{StartConfig: &ServiceConfig{Simple: "foo=bar"}}}, | |
| want: &pb.StartRequest{ | |
| Config: &pb.StartRequest_Config{StateDirectoryPath: stateDir}, | |
| ServiceConfig: &pb.StartRequest_StringConfig{StringConfig: "foo=bar"}, | |
| }, | |
| }, | |
| { | |
| name: "struct_cfg", | |
| plugin: &Plugin{Name: pluginName, Manifest: &Manifest{StartConfig: &ServiceConfig{Structured: bytes}}}, | |
| want: &pb.StartRequest{ | |
| Config: &pb.StartRequest_Config{StateDirectoryPath: stateDir}, | |
| ServiceConfig: &pb.StartRequest_StructConfig{StructConfig: wantStructCfg}, | |
| }, | |
| }, | |
| { | |
| name: "no_cfg", | |
| plugin: &Plugin{Name: pluginName, Manifest: &Manifest{StartConfig: &ServiceConfig{}}}, | |
| want: &pb.StartRequest{ | |
| Config: &pb.StartRequest_Config{StateDirectoryPath: stateDir}, | |
| }, | |
| }, | |
| { | |
| name: "nil_cfg", | |
| plugin: &Plugin{Name: pluginName, Manifest: &Manifest{}}, | |
| want: &pb.StartRequest{ | |
| Config: &pb.StartRequest_Config{StateDirectoryPath: stateDir}, | |
| }, | |
| }, | |
| } | |
| for _, test := range tests { | |
| t.Run(test.name, func(t *testing.T) { | |
| got, err := test.plugin.buildStartRequest(context.Background()) | |
| if err != nil { | |
| t.Fatalf("plugin.buildStartRequest(ctx) failed unexpectedly with error: %v", err) | |
| } | |
| if diff := cmp.Diff(test.want, got, protocmp.Transform()); diff != "" { | |
| t.Errorf("plugin.buildStartRequest(ctx) returned diff (-want +got):\n%s", diff) | |
| } | |
| }) | |
| } | |
| } | |
| func TestBuildApplyRequest(t *testing.T) { | |
| wantStructCfg := &structpb.Struct{ | |
| Fields: map[string]*structpb.Value{ | |
| "foo": structpb.NewStringValue("bar"), | |
| "count": structpb.NewNumberValue(55), | |
| }, | |
| } | |
| bytes, err := proto.Marshal(wantStructCfg) | |
| if err != nil { | |
| t.Fatalf("proto.Marshal(%+v) failed unexpectedly with error: %v", wantStructCfg, err) | |
| } | |
| tests := []struct { | |
| name string | |
| serviceConfig *ServiceConfig | |
| want *pb.ApplyRequest | |
| }{ | |
| { | |
| name: "simple_cfg", | |
| serviceConfig: &ServiceConfig{Simple: "foo=bar"}, | |
| want: &pb.ApplyRequest{ | |
| ServiceConfig: &pb.ApplyRequest_StringConfig{StringConfig: "foo=bar"}, | |
| }, | |
| }, | |
| { | |
| name: "struct_cfg", | |
| serviceConfig: &ServiceConfig{Structured: bytes}, | |
| want: &pb.ApplyRequest{ | |
| ServiceConfig: &pb.ApplyRequest_StructConfig{StructConfig: wantStructCfg}, | |
| }, | |
| }, | |
| { | |
| name: "no_cfg", | |
| serviceConfig: &ServiceConfig{}, | |
| want: &pb.ApplyRequest{}, | |
| }, | |
| { | |
| name: "nil_cfg", | |
| serviceConfig: nil, | |
| want: &pb.ApplyRequest{}, | |
| }, | |
| } | |
| for _, test := range tests { | |
| t.Run(test.name, func(t *testing.T) { | |
| p := &Plugin{Name: "plugin"} | |
| got, err := p.buildApplyRequest(test.serviceConfig) | |
| if err != nil { | |
| t.Fatalf("buildApplyRequest(%v) failed unexpectedly with error: %v", test.serviceConfig, err) | |
| } | |
| if diff := cmp.Diff(test.want, got, protocmp.Transform()); diff != "" { | |
| t.Errorf("buildApplyRequest(%v) returned diff (-want +got):\n%s", test.serviceConfig, diff) | |
| } | |
| }) | |
| } | |
| } | |
| func TestConfigHash(t *testing.T) { | |
| structCfg := &structpb.Struct{ | |
| Fields: map[string]*structpb.Value{ | |
| "foo": structpb.NewStringValue("bar"), | |
| "count": structpb.NewNumberValue(10), | |
| }, | |
| } | |
| structBytes, err := proto.Marshal(structCfg) | |
| if err != nil { | |
| t.Fatalf("proto.Marshal(%+v) failed unexpectedly with error: %v", structCfg, err) | |
| } | |
| structuredHashBytes := sha256.Sum256(structBytes) | |
| structuredHash := hex.EncodeToString(structuredHashBytes[:]) | |
| simpleData := "simple" | |
| simpleHashBytes := sha256.Sum256([]byte(simpleData)) | |
| simpleHash := hex.EncodeToString(simpleHashBytes[:]) | |
| tests := []struct { | |
| name string | |
| plugin *Plugin | |
| wantHash string | |
| wantCached bool | |
| }{ | |
| { | |
| name: "nil_start_config", | |
| plugin: &Plugin{ | |
| Name: "plugin-a", | |
| Revision: "1", | |
| Manifest: &Manifest{}, | |
| }, | |
| wantHash: "", | |
| }, | |
| { | |
| name: "empty_service_config", | |
| plugin: &Plugin{ | |
| Name: "plugin-b", | |
| Revision: "1", | |
| Manifest: &Manifest{StartConfig: &ServiceConfig{}}, | |
| }, | |
| wantHash: "", | |
| }, | |
| { | |
| name: "simple_config", | |
| plugin: &Plugin{ | |
| Name: "plugin-c", | |
| Revision: "1", | |
| Manifest: &Manifest{StartConfig: &ServiceConfig{Simple: simpleData}}, | |
| }, | |
| wantHash: simpleHash, | |
| }, | |
| { | |
| name: "structured_config", | |
| plugin: &Plugin{ | |
| Name: "plugin-d", | |
| Revision: "1", | |
| Manifest: &Manifest{StartConfig: &ServiceConfig{Structured: structBytes}}, | |
| }, | |
| wantHash: structuredHash, | |
| }, | |
| { | |
| name: "cached_hash", | |
| plugin: &Plugin{ | |
| Name: "plugin-e", | |
| Revision: "1", | |
| Manifest: &Manifest{startConfigHash: "cachedhash"}, | |
| }, | |
| wantHash: "cachedhash", | |
| wantCached: true, | |
| }, | |
| } | |
| for _, test := range tests { | |
| t.Run(test.name, func(t *testing.T) { | |
| hash := test.plugin.configHash() | |
| if hash != test.wantHash { | |
| t.Errorf("configHash() = %q, want %q", hash, test.wantHash) | |
| } | |
| if !test.wantCached && test.plugin.Manifest.startConfigHash != test.wantHash { | |
| t.Errorf("startConfigHash cache = %q, want %q", test.plugin.Manifest.startConfigHash, test.wantHash) | |
| } | |
| }) | |
| } | |
| } |