| // 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" | |
| "fmt" | |
| "maps" | |
| "net/http" | |
| "net/http/httptest" | |
| "os" | |
| "path/filepath" | |
| "sort" | |
| "sync" | |
| "testing" | |
| "time" | |
| "github.com/google/go-cmp/cmp" | |
| "github.com/google/go-cmp/cmp/cmpopts" | |
| "google.golang.org/protobuf/proto" | |
| "google.golang.org/protobuf/testing/protocmp" | |
| dpb "google.golang.org/protobuf/types/known/durationpb" | |
| structpb "google.golang.org/protobuf/types/known/structpb" | |
| tpb "google.golang.org/protobuf/types/known/timestamppb" | |
| pb "github.com/GoogleCloudPlatform/agentcommunication_client/gapic/agentcommunicationpb" | |
| acpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/acs/client" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/boundedlist" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/resource" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/scheduler" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" | |
| ) | |
| type testConstraintClient struct { | |
| seenName string | |
| seenConstraint resource.Constraint | |
| } | |
| func (c *testConstraintClient) Apply(constraint resource.Constraint) error { | |
| c.seenConstraint = constraint | |
| return nil | |
| } | |
| func (c *testConstraintClient) RemoveConstraint(ctx context.Context, name string) error { | |
| c.seenName = name | |
| return nil | |
| } | |
| func (c *testConstraintClient) NewOOMWatcher(context.Context, resource.Constraint, time.Duration) (events.Watcher, error) { | |
| return nil, nil | |
| } | |
| func setupConstraintTestClient(t *testing.T) *testConstraintClient { | |
| oldClient := resource.Client | |
| newClient := &testConstraintClient{} | |
| resource.Client = newClient | |
| t.Cleanup(func() { resource.Client = oldClient }) | |
| return newClient | |
| } | |
| func TestStore(t *testing.T) { | |
| stateDir := t.TempDir() | |
| setBaseStateDir(t, stateDir) | |
| infoDir := filepath.Join(stateDir, agentStateDir, pluginInfoDir) | |
| if err := os.MkdirAll(infoDir, 0755); err != nil { | |
| t.Fatalf("os.MkdirAll(%s) failed to create test directories with error: %v", infoDir, err) | |
| } | |
| // Create a temporary directory for the test that must be ignored by Load(). | |
| _, err := os.MkdirTemp(infoDir, "test") | |
| if err != nil { | |
| t.Fatalf("os.MkdirTemp(%s) failed to create test directory with error: %v", stateDir, err) | |
| } | |
| wantStructCfg := &structpb.Struct{ | |
| Fields: map[string]*structpb.Value{ | |
| "name": structpb.NewStringValue("Abcd"), | |
| "age": structpb.NewNumberValue(99), | |
| "is_admin": structpb.NewBoolValue(true), | |
| "places": structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{structpb.NewStringValue("Washington"), structpb.NewStringValue("California")}}), | |
| }, | |
| } | |
| bytes, err := proto.Marshal(wantStructCfg) | |
| if err != nil { | |
| t.Fatalf("proto.Marshal(%+v) failed unexpectedly with error: %v", wantStructCfg, err) | |
| } | |
| cfg := &ServiceConfig{Simple: "simple config"} | |
| cfg2 := &ServiceConfig{Structured: bytes} | |
| p1 := &Plugin{ | |
| Name: "pluginA", | |
| Revision: "1", | |
| Address: "test-address1", | |
| Protocol: "tcp", | |
| Manifest: &Manifest{ | |
| StartAttempts: 3, | |
| StartConfig: cfg, | |
| PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{Pid: 123}, | |
| } | |
| p2 := &Plugin{ | |
| Name: "pluginB", | |
| Revision: "2", | |
| Address: "test-address2", | |
| Manifest: &Manifest{ | |
| MaxMemoryUsage: 1024 * 1024, | |
| StopTimeout: 3 * time.Second, | |
| PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{Pid: 123}, | |
| } | |
| p3 := &Plugin{ | |
| Name: "pluginC", | |
| Revision: "3", | |
| Address: "test-address3", | |
| Protocol: "tcp", | |
| Manifest: &Manifest{ | |
| StartAttempts: 3, | |
| StartConfig: cfg2, | |
| PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{Pid: 123}, | |
| } | |
| corePlugin := &Plugin{ | |
| Name: CorePluginName, | |
| Revision: "1", | |
| Address: "test-address1", | |
| Manifest: &Manifest{ | |
| StartAttempts: 5, | |
| StartTimeout: time.Second * 30, | |
| StopTimeout: time.Second * 30, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{Pid: 123}, | |
| } | |
| wantCorePlugin := &Plugin{ | |
| Name: CorePluginName, | |
| Revision: "1", | |
| Address: "test-address1", | |
| Manifest: &Manifest{ | |
| StartAttempts: 5, | |
| StartTimeout: time.Second * 30, | |
| StopTimeout: time.Second * 30, | |
| PluginType: acpb.PluginType_DAEMON, | |
| PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{Pid: 123}, | |
| } | |
| storeTests := []struct { | |
| name string | |
| plugin *Plugin | |
| }{ | |
| { | |
| name: "PluginA_store", | |
| plugin: p1, | |
| }, | |
| { | |
| name: "PluginB_store", | |
| plugin: p2, | |
| }, | |
| { | |
| name: "PluginC_store", | |
| plugin: p3, | |
| }, | |
| { | |
| name: "core_plugin_store", | |
| plugin: corePlugin, | |
| }, | |
| } | |
| for _, tc := range storeTests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| if err := tc.plugin.Store(); err != nil { | |
| t.Fatalf("plugin.Store() failed unexpectedly for [%+v] with error: %v", tc.plugin, err) | |
| } | |
| }) | |
| } | |
| got, err := load(infoDir) | |
| if err != nil { | |
| t.Fatalf("load(%s) failed unexpectedly with error: %v", infoDir, err) | |
| } | |
| loadTests := []struct { | |
| name string | |
| plugin *Plugin | |
| wantCfg *ServiceConfig | |
| }{ | |
| { | |
| name: "PluginA_load", | |
| plugin: p1, | |
| wantCfg: cfg, | |
| }, | |
| { | |
| name: "PluginB_load", | |
| plugin: p2, | |
| }, | |
| { | |
| name: "PluginC_load", | |
| plugin: p3, | |
| wantCfg: cfg2, | |
| }, | |
| { | |
| name: "core_plugin_load", | |
| plugin: wantCorePlugin, | |
| }, | |
| } | |
| for _, tc := range loadTests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| gotP := got[tc.plugin.Name] | |
| if diff := cmp.Diff(tc.plugin, gotP, cmpopts.IgnoreUnexported(Plugin{}, RuntimeInfo{}, Manifest{})); diff != "" { | |
| t.Errorf("load(%s) returned diff (-want +got):\n%s", infoDir, diff) | |
| } | |
| if tc.wantCfg == nil || len(tc.wantCfg.Structured) == 0 { | |
| return | |
| } | |
| // Tests bytes were correctly stored and can be parsed. | |
| gotCfg, err := gotP.Manifest.StartConfig.toProto() | |
| if err != nil { | |
| t.Fatalf("%s plugin.toProto() failed unexpectedly with error: %v", gotP.FullName(), err) | |
| } | |
| if diff := cmp.Diff(wantStructCfg, gotCfg, protocmp.Transform()); diff != "" { | |
| t.Errorf("load(%s) returned config diff (-want +got):\n%s", gotP.FullName(), diff) | |
| } | |
| }) | |
| } | |
| // Non-existing state dir should not cause error. | |
| nonExisting := filepath.Join(stateDir, "non-existing") | |
| got, err = load(nonExisting) | |
| if err != nil { | |
| t.Fatalf("load(%s) failed unexpectedly with error: %v", infoDir, err) | |
| } | |
| if len(got) != 0 { | |
| t.Errorf("load(%s) = %v, want empty plugin map", nonExisting, got) | |
| } | |
| } | |
| func TestConnectOrReLaunch(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| setConnectionsDir(t, "") | |
| setupConstraintTestClient(t) | |
| tests := []struct { | |
| desc string | |
| wantCmd string | |
| isRunning bool | |
| }{ | |
| { | |
| desc: "reconnect", | |
| isRunning: true, | |
| }, | |
| { | |
| desc: "launch", | |
| isRunning: false, | |
| wantCmd: "testentry/binary", | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.desc, func(t *testing.T) { | |
| var wantArgs []string | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| startTestServer(t, &testPluginServer{statusFail: !tc.isRunning, ctrs: make(map[string]int)}, udsProtocol, addr) | |
| cfg.Retrieve().Plugin.StateDir = t.TempDir() | |
| cfg.Retrieve().Plugin.SocketConnectionsDir = filepath.Dir(addr) | |
| // Setup install directory. | |
| insallDir := filepath.Join(cfg.Retrieve().Plugin.StateDir, pluginInstallDir) | |
| if err := os.MkdirAll(insallDir, 0755); err != nil { | |
| t.Fatalf("os.MkdirAll(%s) failed unexpectedly with error: %v", insallDir, err) | |
| } | |
| fakeRunner := setupFakeRunner(t) | |
| // Use invalid PID to avoid killing some process unknowingly. | |
| plugin := &Plugin{Name: "pluginA", Revision: "revisionA", Protocol: "unix", Address: addr, EntryPath: tc.wantCmd, RuntimeInfo: &RuntimeInfo{Pid: -5555}, Manifest: &Manifest{StartAttempts: 3, StartTimeout: time.Second * 3, StartConfig: &ServiceConfig{}}, InstallPath: t.TempDir()} | |
| if !tc.isRunning { | |
| wantArgs = []string{fmt.Sprintf("--protocol=%s", udsProtocol), fmt.Sprintf("--address=%s", addr), fmt.Sprintf("--errorlogfile=%s", plugin.logfile())} | |
| } | |
| if err := connectOrReLaunch(ctx, plugin); err != nil { | |
| t.Fatalf("connectOrReLaunch() failed unexpectedly with error: %v", err) | |
| } | |
| if fakeRunner.seenCommand != tc.wantCmd { | |
| t.Errorf("launchStep.Run() executed %q, want %s ", fakeRunner.seenCommand, tc.wantCmd) | |
| } | |
| if diff := cmp.Diff(wantArgs, fakeRunner.seenArguments); diff != "" { | |
| t.Errorf("launchStep.Run() executed unexpectedly with diff (-want +got):\n%s", diff) | |
| } | |
| }) | |
| } | |
| } | |
| func TestInitPluginManagerError(t *testing.T) { | |
| ctx := context.Background() | |
| if err := cfg.Load(nil); err != nil { | |
| t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) | |
| } | |
| t.Cleanup(func() { command.CurrentMonitor().UnregisterHandler(VMEventCmd) }) | |
| if err := RegisterCmdHandler(ctx); err != nil { | |
| t.Fatalf("RegisterCmdHandler(ctx) failed unexpectedly with error: %v", err) | |
| } | |
| id := "5555555555" | |
| if _, err := InitPluginManager(ctx, id); err == nil { | |
| t.Errorf("InitPluginManager(ctx) succeeded, want register command handler error") | |
| } | |
| if got := Instance().currentInstanceID(); got != id { | |
| t.Errorf("InitPluginManager(ctx) set instance ID = %s, want %s", got, id) | |
| } | |
| } | |
| func TestInitPluginManager(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| stateDir := t.TempDir() | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\n[Core]\nacs_client = false\nsocket_connections_dir = %s", stateDir, filepath.Dir(addr)) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) | |
| } | |
| startTestServer(t, &testPluginServer{statusFail: false, ctrs: make(map[string]int)}, udsProtocol, addr) | |
| pluginManager.setInstanceID("1234567890") | |
| pluginA := &Plugin{Name: "pluginA", Revision: "revisionA", Protocol: udsProtocol, Address: addr, EntryPath: "testentry/binary", RuntimeInfo: &RuntimeInfo{Pid: -5555, status: acpb.CurrentPluginStates_RUNNING}, Manifest: &Manifest{StartAttempts: 3, StartTimeout: time.Second * 3, MaxMetricDatapoints: 2, MetricsInterval: time.Second * 3}} | |
| if err := pluginA.Store(); err != nil { | |
| t.Fatalf("plugin.Store() failed unexpectedly with error: %v", err) | |
| } | |
| pluginB := &Plugin{Name: "pluginB", Revision: "revisionB", Protocol: udsProtocol, Address: "invalid-address", EntryPath: "testentry/binary", RuntimeInfo: &RuntimeInfo{Pid: -5555, status: acpb.CurrentPluginStates_CRASHED}, Manifest: &Manifest{StartAttempts: 3, StartTimeout: time.Second * 3}} | |
| if err := pluginB.Store(); err != nil { | |
| t.Fatalf("plugin.Store() failed unexpectedly with error: %v", err) | |
| } | |
| pm, err := InitPluginManager(ctx, "1234567890") | |
| if err != nil { | |
| t.Fatalf("InitPluginManager(ctx) failed unexpectedly with error: %v", err) | |
| } | |
| // InitPluginManager should have already registered the command handler. | |
| if err := command.CurrentMonitor().RegisterHandler(VMEventCmd, nil); err == nil { | |
| t.Errorf("RegisterHandler(%s, nil) succeeded, want error for duplicate registration attempt", VMEventCmd) | |
| } | |
| t.Cleanup(func() { command.CurrentMonitor().UnregisterHandler(VMEventCmd) }) | |
| tests := []struct { | |
| name string | |
| plugin *Plugin | |
| wantMonitor bool | |
| }{ | |
| { | |
| name: "valid_plugin", | |
| plugin: pluginA, | |
| wantMonitor: true, | |
| }, | |
| { | |
| name: "invalid_plugin", | |
| plugin: pluginB, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| t.Cleanup(func() { pm.stopMonitoring(tc.plugin) }) | |
| got, found := pm.plugins[tc.plugin.Name] | |
| if !found { | |
| t.Fatalf("InitPluginManager(ctx) failed to load plugin %q", tc.plugin.Name) | |
| } | |
| if got.State() != tc.plugin.State() { | |
| t.Errorf("InitPluginManager(ctx) = state %q, want %q", got.State(), tc.plugin.State()) | |
| } | |
| pm.pluginMonitorMu.Lock() | |
| defer pm.pluginMonitorMu.Unlock() | |
| if _, ok := pm.pluginMonitors[tc.plugin.FullName()]; ok != tc.wantMonitor { | |
| t.Errorf("InitPluginManager(ctx) = added plugin monitor(%s): %t, want: %t", tc.plugin.FullName(), ok, tc.wantMonitor) | |
| } | |
| }) | |
| } | |
| got := Instance() | |
| if got != pm { | |
| t.Errorf("Instance() = %p, want same as InitPluginManager %p", got, pm) | |
| } | |
| if got.protocol != udsProtocol { | |
| t.Errorf("Instance().protocol = %s, want %s", got.protocol, udsProtocol) | |
| } | |
| } | |
| func TestInitPluginManagerConfig(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| stateDir := t.TempDir() | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| tests := []struct { | |
| name string | |
| isUDSSupported bool | |
| connectionType string | |
| expectedProtocol string | |
| }{ | |
| { | |
| name: "uds_connection_type", | |
| isUDSSupported: true, | |
| connectionType: udsProtocol, | |
| expectedProtocol: udsProtocol, | |
| }, | |
| { | |
| name: "tcp_connection_type", | |
| isUDSSupported: true, | |
| connectionType: tcpProtocol, | |
| expectedProtocol: tcpProtocol, | |
| }, | |
| { | |
| name: "invalid_connection_type", | |
| isUDSSupported: true, | |
| connectionType: "invalid", | |
| expectedProtocol: udsProtocol, | |
| }, | |
| { | |
| name: "uds_not_supported", | |
| isUDSSupported: false, | |
| connectionType: udsProtocol, | |
| expectedProtocol: tcpProtocol, | |
| }, | |
| { | |
| name: "uds_not_supported_tcp_connection_type", | |
| isUDSSupported: false, | |
| connectionType: tcpProtocol, | |
| expectedProtocol: tcpProtocol, | |
| }, | |
| { | |
| name: "uds_not_supported_invalid_connection_type", | |
| isUDSSupported: false, | |
| connectionType: "invalid", | |
| expectedProtocol: tcpProtocol, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| config := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\nconnection_type = %s\n[Core]\nacs_client = false\nsocket_connections_dir = %s", stateDir, tc.connectionType, filepath.Dir(addr)) | |
| if err := cfg.Load([]byte(config)); err != nil { | |
| t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) | |
| } | |
| oldIsUDSSupported := isUDSSupported | |
| t.Cleanup(func() { isUDSSupported = oldIsUDSSupported }) | |
| isUDSSupported = func() bool { return tc.isUDSSupported } | |
| pm, err := InitPluginManager(ctx, "1234567890") | |
| if err != nil { | |
| t.Fatalf("InitPluginManager(ctx) failed unexpectedly with error: %v", err) | |
| } | |
| if pm.protocol != tc.expectedProtocol { | |
| t.Errorf("InitPluginManager(ctx) = protocol %q, want %q", pm.protocol, tc.expectedProtocol) | |
| } | |
| t.Cleanup(func() { | |
| if err := command.CurrentMonitor().UnregisterHandler(VMEventCmd); err != nil { | |
| t.Fatalf("command.CurrentMonitor().UnregisterHandler(VMEventCmd) failed unexpectedly with error: %v", err) | |
| } | |
| }) | |
| }) | |
| } | |
| } | |
| func TestConfigurePluginStates(t *testing.T) { | |
| if err := cfg.Load(nil); err != nil { | |
| t.Fatalf("Failed to load config: %v", err) | |
| } | |
| cfg.Retrieve().Core.ACSClient = false | |
| req := &acpb.ConfigurePluginStates{ | |
| ConfigurePlugins: []*acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{Name: "PluginA", RevisionId: "1"}, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{}, | |
| }, | |
| &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{Name: "PluginA", RevisionId: "1"}, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{}, | |
| }, | |
| &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_REMOVE, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{Name: "PluginB", RevisionId: "2"}, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{}, | |
| }, | |
| &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_APPLY, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{Name: "PluginD", RevisionId: "3"}, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{Config: &acpb.ConfigurePluginStates_Manifest_StringConfig{StringConfig: "foo=bar"}}, | |
| }, | |
| &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_ACTION_UNSPECIFIED, | |
| }, | |
| }, | |
| } | |
| P1 := &Plugin{Name: "PluginA", Revision: "1"} | |
| p2 := &Plugin{Name: "PluginC", Revision: "3"} | |
| m := map[string]*Plugin{P1.Name: P1, p2.Name: p2} | |
| pm := &PluginManager{plugins: m, inProgressPluginRequests: make(map[string]bool), requestCount: make(map[acpb.ConfigurePluginStates_Action]map[bool]int)} | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| pm.ConfigurePluginStates(ctx, req) | |
| // Should be a no-op. | |
| if diff := cmp.Diff(m, pm.plugins, cmpopts.IgnoreUnexported(Plugin{})); diff != "" { | |
| t.Errorf("ConfigurePluginStates(ctx, %+v) returned diff (-want +got):\n%s", req, diff) | |
| } | |
| if len(pm.inProgressPluginRequests) != 0 { | |
| t.Errorf("ConfigurePluginStates(ctx, %+v) set pending plugins = %+v, want empty map", req, pm.inProgressPluginRequests) | |
| } | |
| // Without deduplication, we would have 2 install requests. | |
| wantRequestCount := map[acpb.ConfigurePluginStates_Action]map[bool]int{ | |
| acpb.ConfigurePluginStates_INSTALL: map[bool]int{false: 1}, | |
| acpb.ConfigurePluginStates_REMOVE: map[bool]int{false: 1}, | |
| acpb.ConfigurePluginStates_APPLY: map[bool]int{false: 1}, | |
| acpb.ConfigurePluginStates_ACTION_UNSPECIFIED: map[bool]int{false: 1}, | |
| } | |
| if diff := cmp.Diff(wantRequestCount, pm.requestCount); diff != "" { | |
| t.Errorf("ConfigurePluginStates(ctx, %+v) returned diff (-want +got):\n%s", req, diff) | |
| } | |
| } | |
| type pendingPlugins struct { | |
| status map[string]*pendingPluginStatus | |
| revisions map[string]bool | |
| } | |
| func installSetup(t *testing.T, ps *testPluginServer, addr string) (*httptest.Server, string, *testRunner, *pendingPlugins) { | |
| t.Helper() | |
| archive := createTestArchive(t) | |
| hash, err := file.SHA256FileSum(archive) | |
| if err != nil { | |
| t.Fatalf("file.SHA256FileSum(%s) failed unexpectedly with error: %v", archive, err) | |
| } | |
| bytes, err := os.ReadFile(archive) | |
| if err != nil { | |
| t.Fatalf("os.ReadFile(%s) failed unexpectedly with error: %v", archive, err) | |
| } | |
| seenStates := make(map[string]*pendingPluginStatus) | |
| seenRevisions := make(map[string]bool) | |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| // During upgrade at this point agent should have set pending plugin | |
| // revision. Capture it here to test that it is set correctly. | |
| pm := Instance() | |
| for _, plugin := range pm.plugins { | |
| if s := plugin.pendingStatus(); s != nil { | |
| seenStates[plugin.Name] = s | |
| } | |
| } | |
| maps.Copy(seenRevisions, pm.inProgressPluginRequests) | |
| w.Write(bytes) | |
| })) | |
| startTestServer(t, ps, udsProtocol, addr) | |
| runner := setupFakeRunner(t) | |
| return ts, hash, runner, &pendingPlugins{status: seenStates, revisions: seenRevisions} | |
| } | |
| func TestSetMetricConfig(t *testing.T) { | |
| tests := []struct { | |
| desc string | |
| req *acpb.ConfigurePluginStates_ConfigurePlugin | |
| want *Plugin | |
| capacity uint | |
| }{ | |
| { | |
| desc: "plugin_defaults", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{}, | |
| }, | |
| want: &Plugin{ | |
| Manifest: &Manifest{ | |
| MetricsInterval: metricsCheckFrequency, | |
| MaxMetricDatapoints: maxMetricDatapoints, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{ | |
| metrics: boundedlist.New[Metric](maxMetricDatapoints), | |
| }, | |
| }, | |
| capacity: maxMetricDatapoints, | |
| }, | |
| { | |
| desc: "plugin_override_freq", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| MetricsInterval: &dpb.Duration{Seconds: 1}, | |
| }, | |
| }, | |
| want: &Plugin{ | |
| Manifest: &Manifest{ | |
| MetricsInterval: time.Second, | |
| MaxMetricDatapoints: maxMetricDatapoints, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{ | |
| metrics: boundedlist.New[Metric](maxMetricDatapoints), | |
| }, | |
| }, | |
| capacity: maxMetricDatapoints, | |
| }, | |
| { | |
| desc: "plugin_override_datapoints_count", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| MaxMetricDatapoints: 1, | |
| }, | |
| }, | |
| want: &Plugin{ | |
| Manifest: &Manifest{ | |
| MetricsInterval: metricsCheckFrequency, | |
| MaxMetricDatapoints: 1, | |
| }, | |
| RuntimeInfo: &RuntimeInfo{ | |
| metrics: boundedlist.New[Metric](1), | |
| }, | |
| }, | |
| capacity: 1, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.desc, func(t *testing.T) { | |
| got := &Plugin{Manifest: &Manifest{}, RuntimeInfo: &RuntimeInfo{}} | |
| got.setMetricConfig(tc.req) | |
| if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreUnexported(Plugin{}, RuntimeInfo{}, Manifest{})); diff != "" { | |
| t.Errorf("setMetricConfig(%+v) returned unexpected diff (-want +got):\n%s", tc.req, diff) | |
| } | |
| if got.RuntimeInfo.metrics.Capacity() != tc.capacity { | |
| t.Errorf("setMetricConfig(%+v) = capacity %d, want %d", tc.req, got.RuntimeInfo.metrics.Capacity(), tc.capacity) | |
| } | |
| }) | |
| } | |
| } | |
| func TestInstallPlugin(t *testing.T) { | |
| connections := t.TempDir() | |
| state := t.TempDir() | |
| setupConstraintTestClient(t) | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\nsocket_connections_dir = %s\n[Core]\nacs_client = false\n", state, connections) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) | |
| } | |
| addr := filepath.Join(connections, "PluginA_RevisionA.sock") | |
| ps := &testPluginServer{ctrs: make(map[string]int)} | |
| server, hash, runner, seenPendingPlugins := installSetup(t, ps, addr) | |
| runner.pid = -6666 | |
| defer server.Close() | |
| orig := pluginManager | |
| t.Cleanup(func() { pluginManager = orig }) | |
| req := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA", | |
| EntryPoint: "test-entry-point", | |
| Checksum: hash, | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| MaxMemoryUsageBytes: 1024 * 1024, | |
| StartTimeout: &dpb.Duration{Seconds: 3}, | |
| StopTimeout: &dpb.Duration{Seconds: 5}, | |
| StartAttemptCount: 3, | |
| DownloadAttemptCount: 2, | |
| DownloadTimeout: &dpb.Duration{Seconds: 5}, | |
| }, | |
| } | |
| tests := []struct { | |
| name string | |
| url string | |
| installationType acpb.PluginInstallationType | |
| wantErr bool | |
| }{ | |
| { | |
| name: "success_dynamic_plugin", | |
| url: server.URL, | |
| installationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION, | |
| wantErr: false, | |
| }, | |
| { | |
| name: "success_local_plugin", | |
| installationType: acpb.PluginInstallationType_LOCAL_INSTALLATION, | |
| wantErr: false, | |
| }, | |
| { | |
| name: "failure", | |
| installationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION, | |
| wantErr: true, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| req.Plugin.GcsSignedUrl = tc.url | |
| req.Manifest.Config = &acpb.ConfigurePluginStates_Manifest_StringConfig{StringConfig: tc.name} | |
| req.Manifest.PluginInstallationType = tc.installationType | |
| s := scheduler.Instance() | |
| t.Cleanup(s.Stop) | |
| pm := &PluginManager{ | |
| plugins: map[string]*Plugin{}, | |
| protocol: udsProtocol, | |
| pluginMonitors: make(map[string]string), | |
| pluginMetricsMonitors: make(map[string]string), | |
| scheduler: s, | |
| inProgressPluginRequests: make(map[string]bool), | |
| } | |
| pluginManager = pm | |
| err := pm.installPlugin(ctx, req) | |
| if (err != nil) != tc.wantErr { | |
| t.Fatalf("installPlugin(ctx, %+v) = error: %v, want error: %t", req, err, tc.wantErr) | |
| } | |
| // Fresh install should not have any pending plugins. | |
| if len(seenPendingPlugins.revisions) != 0 || len(seenPendingPlugins.status) != 0 { | |
| t.Errorf("installPlugin(ctx, %+v) set pending plugins = %+v, want empty revision and status map", req, seenPendingPlugins) | |
| } | |
| if tc.wantErr { | |
| if len(pm.plugins) != 0 { | |
| t.Errorf("installPlugin(ctx, %+v) = %+v, want no plugin", req, pm.plugins) | |
| } | |
| return | |
| } | |
| pluginBase := pluginInstallPath(req.Plugin.Name, req.Plugin.GetRevisionId()) | |
| entryPoint := "test-entry-point" | |
| want, err := newPlugin(req) | |
| if err != nil { | |
| t.Fatalf("newPlugin(%+v) failed unexpectedly with error: %v", req, err) | |
| } | |
| want.RuntimeInfo.Pid = runner.pid | |
| want.Address = addr | |
| want.Protocol = udsProtocol | |
| if tc.installationType == acpb.PluginInstallationType_DYNAMIC_INSTALLATION { | |
| want.InstallPath = pluginBase | |
| entryPoint = filepath.Join(pluginBase, entryPoint) | |
| } | |
| want.EntryPath = entryPoint | |
| if runner.seenCommand != entryPoint { | |
| t.Errorf("installPlugin(ctx, %+v) executed %q, want %q", req, runner.seenCommand, entryPoint) | |
| } | |
| got := pm.plugins[req.Plugin.Name] | |
| if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(Plugin{}, RuntimeInfo{}, Manifest{})); diff != "" { | |
| t.Errorf("pm.plugins[%s] returned unexpected diff (-want +got):\n%s", req.Plugin.Name, diff) | |
| } | |
| if got.State() != acpb.CurrentPluginStates_RUNNING { | |
| t.Errorf("installPlugin(ctx, %+v) = plugin state %q, want %q", req, got.State(), acpb.CurrentPluginStates_RUNNING) | |
| } | |
| if ps.ctrs[tc.name] != 1 { | |
| t.Errorf("installPlugin(ctx, %+v) did not call start RPC on plugin %q", req, req.Plugin.Name) | |
| } | |
| c := retry.Policy{MaxAttempts: 3, Jitter: time.Second * 2, BackoffFactor: 1} | |
| err = retry.Run(ctx, c, func() error { | |
| pm.pluginMonitorMu.Lock() | |
| defer pm.pluginMonitorMu.Unlock() | |
| _, ok := pm.pluginMonitors[want.FullName()] | |
| if !ok { | |
| return fmt.Errorf("installPlugin(ctx, %+v) did not create monitor for plugin %q", req, req.Plugin.Name) | |
| } | |
| return nil | |
| }) | |
| if err != nil { | |
| t.Errorf("%v", err) | |
| } | |
| }) | |
| } | |
| } | |
| func TestUpgradePlugin(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| connections := t.TempDir() | |
| state := t.TempDir() | |
| setBaseStateDir(t, state) | |
| ctc := setupConstraintTestClient(t) | |
| cfg.Retrieve().Plugin.SocketConnectionsDir = connections | |
| cfg.Retrieve().Core.ACSClient = false | |
| psClient := &mockPsClient{alive: true, exe: "test-entry-point"} | |
| setupMockPsClient(t, psClient) | |
| // Start plugin server - old revision. | |
| addr1 := filepath.Join(connections, "PluginA_RevisionA.sock") | |
| ps1 := &testPluginServer{ctrs: make(map[string]int)} | |
| startTestServer(t, ps1, udsProtocol, addr1) | |
| // Start plugin server - new revision. | |
| addr := filepath.Join(connections, "PluginA_RevisionB.sock") | |
| ps2 := &testPluginServer{ctrs: make(map[string]int)} | |
| server, hash, runner, gotPendingPlugins := installSetup(t, ps2, addr) | |
| defer server.Close() | |
| orig := pluginManager | |
| t.Cleanup(func() { pluginManager = orig }) | |
| plugin := &Plugin{Name: "PluginA", Revision: "revisionA", Protocol: udsProtocol, Address: addr1, EntryPath: "test-entry-point", RuntimeInfo: &RuntimeInfo{Pid: -5555}, Manifest: &Manifest{StartAttempts: 1, StopTimeout: time.Second * 3}} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect(ctx) failed unexpectedly with error: %v", err) | |
| } | |
| req := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionB", | |
| GcsSignedUrl: server.URL, | |
| EntryPoint: "test-entry-point2", | |
| Checksum: hash, | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| MaxMemoryUsageBytes: 1024 * 1024, | |
| MaxCpuUsagePercentage: 20, | |
| StartTimeout: &dpb.Duration{Seconds: 3}, | |
| StartAttemptCount: 3, | |
| DownloadAttemptCount: 2, | |
| DownloadTimeout: &dpb.Duration{Seconds: 5}, | |
| }, | |
| } | |
| s := scheduler.Instance() | |
| t.Cleanup(s.Stop) | |
| pm := &PluginManager{ | |
| plugins: map[string]*Plugin{plugin.Name: plugin}, | |
| protocol: udsProtocol, | |
| pluginMonitors: make(map[string]string), | |
| pluginMetricsMonitors: make(map[string]string), | |
| scheduler: s, | |
| inProgressPluginRequests: make(map[string]bool), | |
| } | |
| pluginManager = pm | |
| if err := pm.installPlugin(ctx, req); err != nil { | |
| t.Fatalf("installPlugin(ctx, %+v) failed unexpectedly with error: %v", req, err) | |
| } | |
| if !ps1.stopCalled { | |
| t.Errorf("installPlugin(ctx, %+v) did not call stop RPC on old plugin %q", req, plugin.Name) | |
| } | |
| if file.Exists(addr1, file.TypeFile) { | |
| t.Errorf("installPlugin(ctx, %+v) did not cleanup previous state, file %q still exists", req, addr1) | |
| } | |
| pluginBase := pluginInstallPath(req.Plugin.Name, req.Plugin.GetRevisionId()) | |
| entryPoint := filepath.Join(pluginBase, "test-entry-point2") | |
| if runner.seenCommand != entryPoint { | |
| t.Errorf("installPlugin(ctx, %+v) executed %q, want %q", req, runner.seenCommand, entryPoint) | |
| } | |
| expectedConstraint := resource.Constraint{ | |
| MaxMemoryUsage: req.GetManifest().GetMaxMemoryUsageBytes(), | |
| MaxCPUUsage: req.GetManifest().GetMaxCpuUsagePercentage(), | |
| } | |
| if diff := cmp.Diff(expectedConstraint, ctc.seenConstraint, cmpopts.IgnoreFields(resource.Constraint{}, "Name")); diff != "" { | |
| t.Errorf("installPlugin(ctx, %+v) applied unexpected constraints (-want +got):\n%s", req, diff) | |
| } | |
| p := pm.plugins[req.Plugin.Name] | |
| if p.Revision != "RevisionB" { | |
| t.Errorf("installPlugin(ctx, %+v) = %+v, did not update plugin map, want revision %s", req, p, req.Plugin.GetRevisionId()) | |
| } | |
| // This is intermediate state we captured during the upgrade process. | |
| wantPendingPlugins := map[string]*pendingPluginStatus{p.Name: &pendingPluginStatus{revision: req.Plugin.GetRevisionId(), status: acpb.CurrentPluginStates_INSTALLING}} | |
| if diff := cmp.Diff(wantPendingPlugins, gotPendingPlugins.status, cmp.AllowUnexported(pendingPluginStatus{})); diff != "" { | |
| t.Errorf("installPlugin(ctx, %+v) did not update pending plugin revisions (-want +got):\n%s", req, diff) | |
| } | |
| if got := plugin.pendingStatus(); got != nil { | |
| t.Errorf("installPlugin(ctx, %+v) = pending plugin status %v, want nil on old revision %s", req, got, plugin.FullName()) | |
| } | |
| } | |
| func TestRemovePlugin(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| connections := t.TempDir() | |
| state := t.TempDir() | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\n[Core]\nacs_client = false", state) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(%s) failed unexpectedly with error: %v", tmp, err) | |
| } | |
| ctc := setupConstraintTestClient(t) | |
| cfg.Retrieve().Plugin.SocketConnectionsDir = connections | |
| addr := filepath.Join(connections, "PluginA_RevisionA.sock") | |
| ps := &testPluginServer{ctrs: make(map[string]int)} | |
| startTestServer(t, ps, udsProtocol, addr) | |
| entryPoint := filepath.Join(state, "plugins", "PluginA", "test-entry-point") | |
| createTestFile(t, entryPoint) | |
| plugin := &Plugin{Name: "PluginA", Revision: "RevisionA", Protocol: udsProtocol, Address: addr, InstallPath: filepath.Dir(entryPoint), RuntimeInfo: &RuntimeInfo{Pid: -5555}, Manifest: &Manifest{StartAttempts: 1, StopTimeout: time.Second * 3, PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION}} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect() failed unexpectedly with error: %v", err) | |
| } | |
| if err := os.MkdirAll(plugin.stateDir(), 0755); err != nil { | |
| t.Fatalf("os.MkdirAll(%s) failed unexpectedly with error: %v", plugin.stateDir(), err) | |
| } | |
| req := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_REMOVE, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA", | |
| }, | |
| } | |
| s := scheduler.Instance() | |
| t.Cleanup(s.Stop) | |
| pm := &PluginManager{plugins: map[string]*Plugin{plugin.Name: plugin}, protocol: udsProtocol, pluginMonitors: make(map[string]string), scheduler: s} | |
| if err := pm.removePlugin(ctx, req); err != nil { | |
| t.Fatalf("removePlugin(ctx, %+v) failed unexpectedly with error: %v", req, err) | |
| } | |
| validatePluginRemoved(t, plugin, pm, ctc) | |
| } | |
| func TestMonitoring(t *testing.T) { | |
| s := scheduler.Instance() | |
| t.Cleanup(s.Stop) | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\n[Core]\nacs_client = false", t.TempDir()) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(%s) failed unexpectedly with error: %v", tmp, err) | |
| } | |
| addr := filepath.Join(t.TempDir(), "PluginA_RevisionA.sock") | |
| cfg.Retrieve().Plugin.SocketConnectionsDir = filepath.Dir(addr) | |
| startTestServer(t, &testPluginServer{ctrs: make(map[string]int)}, udsProtocol, "") | |
| plugin := &Plugin{Name: "PluginA", Revision: "RevisionA", Protocol: udsProtocol, RuntimeInfo: &RuntimeInfo{Pid: -5555}, Manifest: &Manifest{StartAttempts: 1, StopTimeout: time.Second * 3}} | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect(ctx) failed unexpectedly with error: %v", err) | |
| } | |
| pm := &PluginManager{plugins: map[string]*Plugin{plugin.Name: plugin}, protocol: udsProtocol, pluginMonitors: make(map[string]string), scheduler: s} | |
| pm.startMonitoring(ctx, plugin) | |
| if _, ok := pm.pluginMonitors[plugin.FullName()]; !ok { | |
| t.Errorf("startMonitoring(ctx, %+v) did not create and add monitor in map", plugin) | |
| } | |
| pm.stopMonitoring(plugin) | |
| // Stop on non-existing plugin should be a no-op. | |
| pm.stopMonitoring(&Plugin{Name: "non-existing", Revision: "avc"}) | |
| if _, ok := pm.pluginMonitors[plugin.FullName()]; ok { | |
| t.Errorf("stopMonitoring(%+v) did not stop and remove monitor from map", plugin) | |
| } | |
| } | |
| func TestListPluginStates(t *testing.T) { | |
| now := time.Now() | |
| simpleData := "simple-config" | |
| simpleHashBytes := sha256.Sum256([]byte(simpleData)) | |
| simpleHash := hex.EncodeToString(simpleHashBytes[:]) | |
| pluginA := &Plugin{Name: "PluginA", Revision: "RevisionA", RuntimeInfo: &RuntimeInfo{health: &healthCheck{responseCode: 0, messages: []string{"ok"}, timestamp: now}, metrics: boundedlist.New[Metric](2), status: acpb.CurrentPluginStates_RUNNING}, Manifest: &Manifest{StartConfig: &ServiceConfig{Simple: simpleData}}} | |
| pluginB := &Plugin{Name: "PluginB", Revision: "RevisionB", RuntimeInfo: &RuntimeInfo{health: &healthCheck{responseCode: 1, messages: []string{"missing pre-reqs"}, timestamp: now}, metrics: boundedlist.New[Metric](2), pendingPluginStatus: &pendingPluginStatus{revision: "RevisionB1"}, status: acpb.CurrentPluginStates_CRASHED}, Manifest: &Manifest{}} | |
| pluginA.setPendingStatus("RevisionA1", acpb.CurrentPluginStates_INSTALLING) | |
| pluginB.resetPendingStatus() | |
| metric := Metric{timestamp: &tpb.Timestamp{Seconds: now.Unix()}, memoryUsage: 100, cpuUsage: 500} | |
| pluginA.RuntimeInfo.metrics.Add(metric) | |
| pm := &PluginManager{plugins: map[string]*Plugin{pluginA.Name: pluginA, pluginB.Name: pluginB}} | |
| want := &acpb.CurrentPluginStates{ | |
| DaemonPluginStates: []*acpb.CurrentPluginStates_DaemonPluginState{ | |
| &acpb.CurrentPluginStates_DaemonPluginState{ | |
| Name: pluginA.Name, | |
| CurrentRevisionId: pluginA.Revision, | |
| CurrentPluginMetrics: []*acpb.CurrentPluginStates_Metric{ | |
| {Timestamp: metric.timestamp, MemoryUsage: metric.memoryUsage, CpuUsage: metric.cpuUsage}, | |
| }, | |
| PendingRevisionId: pluginA.RuntimeInfo.pendingPluginStatus.revision, | |
| CurrentPluginStatus: &acpb.CurrentPluginStates_Status{ | |
| Status: pluginA.RuntimeInfo.status, | |
| ResponseCode: pluginA.RuntimeInfo.health.responseCode, | |
| Results: pluginA.RuntimeInfo.health.messages, | |
| UpdateTime: tpb.New(now), | |
| }, | |
| ConfigHash: simpleHash, | |
| }, | |
| &acpb.CurrentPluginStates_DaemonPluginState{ | |
| Name: pluginB.Name, | |
| CurrentRevisionId: pluginB.Revision, | |
| CurrentPluginStatus: &acpb.CurrentPluginStates_Status{ | |
| Status: pluginB.RuntimeInfo.status, | |
| ResponseCode: pluginB.RuntimeInfo.health.responseCode, | |
| Results: pluginB.RuntimeInfo.health.messages, | |
| UpdateTime: tpb.New(now), | |
| }, | |
| }, | |
| }, | |
| } | |
| gotResp := pm.ListPluginStates(context.Background(), &acpb.ListPluginStates{}) | |
| got := gotResp.GetDaemonPluginStates() | |
| sort.Slice(got, func(i, j int) bool { | |
| return got[i].GetName() < got[j].GetName() | |
| }) | |
| if diff := cmp.Diff(want.GetDaemonPluginStates(), got, protocmp.Transform()); diff != "" { | |
| t.Errorf("ListPluginStates(ctx, req) returned unexpected diff (-want +got):\n%s", diff) | |
| } | |
| // Should reset after sending metrics. | |
| if pluginA.RuntimeInfo.metrics.Len() != 0 { | |
| t.Errorf("ListPluginStates(ctx, req) returned unexpected number of metrics, got %d, want 1", pluginA.RuntimeInfo.metrics.Len()) | |
| } | |
| if pluginB.RuntimeInfo.metrics.Len() != 0 { | |
| t.Errorf("ListPluginStates(ctx, req) returned unexpected number of metrics, got %d, want 1", pluginB.RuntimeInfo.metrics.Len()) | |
| } | |
| } | |
| type fakeACS struct { | |
| mu sync.Mutex | |
| seenEvent *acpb.PluginEventMessage | |
| seenType acpb.PluginEventMessage_PluginEventType | |
| throwErr bool | |
| } | |
| func (c *fakeACS) Close() {} | |
| func (c *fakeACS) SendMessage(msg *pb.MessageBody) error { | |
| c.mu.Lock() | |
| defer c.mu.Unlock() | |
| if c.throwErr { | |
| return fmt.Errorf("test error") | |
| } | |
| m := new(acpb.PluginEventMessage) | |
| if err := msg.GetBody().UnmarshalTo(m); err != nil { | |
| return fmt.Errorf("Expected acmpb.AgentInfo, failed to unmarshal message body: %v", err) | |
| } | |
| c.seenType = m.GetEventType() | |
| c.seenEvent = m | |
| return nil | |
| } | |
| func (c *fakeACS) Receive() (*pb.MessageBody, error) { | |
| return nil, nil | |
| } | |
| func TestSendEvent(t *testing.T) { | |
| if err := cfg.Load(nil); err != nil { | |
| t.Fatalf("Failed to load config: %v", err) | |
| } | |
| cfg.Retrieve().Core.ACSClient = true | |
| ctx := context.Background() | |
| plugin := &Plugin{Name: "PluginA", Revision: "RevisionA"} | |
| tests := []struct { | |
| desc string | |
| shouldFail bool | |
| }{ | |
| { | |
| desc: "success", | |
| }, | |
| { | |
| desc: "failure", | |
| shouldFail: true, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.desc, func(t *testing.T) { | |
| testconnection := &fakeACS{throwErr: tc.shouldFail} | |
| ctx = context.WithValue(ctx, client.OverrideConnection, testconnection) | |
| sendEvent(ctx, plugin, acpb.PluginEventMessage_PLUGIN_CONFIG_INSTALL, "test-event") | |
| if tc.shouldFail { | |
| return | |
| } | |
| c := retry.Policy{MaxAttempts: 3, Jitter: time.Second * 2, BackoffFactor: 1} | |
| err := retry.Run(ctx, c, func() error { | |
| wantEvent := &acpb.PluginEventMessage{ | |
| PluginName: plugin.Name, | |
| RevisionId: plugin.Revision, | |
| EventType: acpb.PluginEventMessage_PLUGIN_CONFIG_INSTALL, | |
| EventDetails: []byte("test-event"), | |
| } | |
| testconnection.mu.Lock() | |
| gotEvent := testconnection.seenEvent | |
| testconnection.mu.Unlock() | |
| if diff := cmp.Diff(wantEvent, gotEvent, protocmp.Transform(), protocmp.IgnoreFields(&acpb.PluginEventMessage{}, "event_timestamp")); diff != "" { | |
| return fmt.Errorf("sendEvent(ctx, %+v, %s, test-event) returned unexpected diff (-want +got):\n%s", plugin, acpb.PluginEventMessage_PLUGIN_CONFIG_INSTALL, diff) | |
| } | |
| return nil | |
| }) | |
| if err != nil { | |
| t.Errorf("%v", err) | |
| } | |
| }) | |
| } | |
| } | |
| func TestUpgradePluginError(t *testing.T) { | |
| if err := cfg.Load([]byte("[Core]\nacs_client = false")); err != nil { | |
| t.Fatalf("Failed to load config: %v", err) | |
| } | |
| p1 := &Plugin{Name: "PluginA", Revision: "RevisionA", RuntimeInfo: &RuntimeInfo{}} | |
| pm := &PluginManager{plugins: map[string]*Plugin{p1.Name: p1}, inProgressPluginRequests: map[string]bool{p1.FullName(): true}} | |
| // Non existing plugin. | |
| req1 := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginB", | |
| RevisionId: "RevisionB", | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{}, | |
| } | |
| // Duplicate request. | |
| req2 := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA", | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{DownloadAttemptCount: 1}, | |
| } | |
| // Fail pre-launch requirements, no download setup. | |
| req3 := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA1", | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| DownloadAttemptCount: 1, | |
| }, | |
| } | |
| tests := []struct { | |
| desc string | |
| req *acpb.ConfigurePluginStates_ConfigurePlugin | |
| }{ | |
| { | |
| desc: "non-existing-plugin", | |
| req: req1, | |
| }, | |
| { | |
| desc: "duplicate-request", | |
| req: req2, | |
| }, | |
| { | |
| desc: "prelaunch-fail", | |
| req: req3, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.desc, func(t *testing.T) { | |
| err := pm.upgradePlugin(context.Background(), tc.req) | |
| if err == nil { | |
| t.Errorf("upgradePlugin(ctx, %+v) succeeded, want error", tc.req) | |
| } | |
| }) | |
| } | |
| } | |
| func TestNewPluginManifest(t *testing.T) { | |
| cfg := &structpb.Struct{ | |
| Fields: map[string]*structpb.Value{ | |
| "foo": structpb.NewStringValue("bar"), | |
| "count": structpb.NewNumberValue(22), | |
| }, | |
| } | |
| bytes, err := proto.Marshal(cfg) | |
| if err != nil { | |
| t.Fatalf("proto.Marshal(%+v) failed unexpectedly with error: %v", cfg, err) | |
| } | |
| tests := []struct { | |
| name string | |
| req *acpb.ConfigurePluginStates_ConfigurePlugin | |
| want *Manifest | |
| }{ | |
| { | |
| name: "simple_config", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| MaxMemoryUsageBytes: 1024 * 1024, | |
| MaxCpuUsagePercentage: 20, | |
| StartTimeout: &dpb.Duration{Seconds: 3}, | |
| StopTimeout: &dpb.Duration{Seconds: 3}, | |
| StartAttemptCount: 3, | |
| Config: &acpb.ConfigurePluginStates_Manifest_StringConfig{StringConfig: "foo=bar"}, | |
| }, | |
| }, | |
| want: &Manifest{ | |
| MaxMemoryUsage: 1024 * 1024, | |
| MaxCPUUsage: 20, | |
| StartTimeout: time.Second * 3, | |
| StopTimeout: time.Second * 3, | |
| StartAttempts: 3, | |
| StartConfig: &ServiceConfig{Simple: "foo=bar"}, | |
| }, | |
| }, | |
| { | |
| name: "structured_config", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| StartTimeout: &dpb.Duration{Seconds: 3}, | |
| StopTimeout: &dpb.Duration{Seconds: 3}, | |
| Config: &acpb.ConfigurePluginStates_Manifest_StructConfig{StructConfig: cfg}, | |
| }, | |
| }, | |
| want: &Manifest{ | |
| StartTimeout: time.Second * 3, | |
| StopTimeout: time.Second * 3, | |
| StartConfig: &ServiceConfig{Structured: bytes}, | |
| }, | |
| }, | |
| { | |
| name: "no_config", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| StartTimeout: &dpb.Duration{Seconds: 3}, | |
| StopTimeout: &dpb.Duration{Seconds: 3}, | |
| }, | |
| }, | |
| want: &Manifest{ | |
| StartTimeout: time.Second * 3, | |
| StartConfig: &ServiceConfig{}, | |
| StopTimeout: time.Second * 3, | |
| }, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| got, err := newPluginManifest(tc.req) | |
| if err != nil { | |
| t.Fatalf("newPluginManifest(%v) returned an unexpected error: %v", tc.req, err) | |
| } | |
| if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreUnexported(Manifest{}), cmpopts.IgnoreFields(ServiceConfig{}, "Structured")); diff != "" { | |
| t.Errorf("newPluginManifest(%v) returned an unexpected diff (-want +got): %v", tc.req, diff) | |
| } | |
| if len(tc.want.StartConfig.Structured) == 0 { | |
| return | |
| } | |
| gotCfg, err := got.StartConfig.toProto() | |
| if err != nil { | |
| t.Fatalf("config [%+v] toProto() returned an unexpected error: %v", got.StartConfig, err) | |
| } | |
| if diff := cmp.Diff(cfg, gotCfg, protocmp.Transform()); diff != "" { | |
| t.Errorf("newPluginManifest(%v) returned an unexpected diff for structured config (-want +got): %v", tc.req, diff) | |
| } | |
| }) | |
| } | |
| } | |
| func validatePluginRemoved(t *testing.T, plugin *Plugin, pm *PluginManager, ctc *testConstraintClient) { | |
| t.Helper() | |
| tests := []struct { | |
| name string | |
| path string | |
| fType file.Type | |
| }{ | |
| {name: "state-dir", path: plugin.stateDir(), fType: file.TypeDir}, | |
| {name: "state-file", path: plugin.stateFile(), fType: file.TypeFile}, | |
| {name: "install-dir", path: plugin.InstallPath, fType: file.TypeDir}, | |
| {name: "err-log-file", path: plugin.logfile(), fType: file.TypeFile}, | |
| {name: "socket-address-file", path: plugin.Address, fType: file.TypeFile}, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| if got := file.Exists(tc.path, tc.fType); got { | |
| t.Errorf("Remove Plugin for %s did not remove file %q, got %t, want false", plugin.FullName(), tc.path, got) | |
| } | |
| }) | |
| } | |
| if got := pm.plugins[plugin.Name]; got != nil { | |
| t.Errorf("Remove Plugin for %s did not remove plugin from map, got %+v", plugin.FullName(), got) | |
| } | |
| if _, ok := pm.pluginMonitors[plugin.FullName()]; ok { | |
| t.Errorf("Remove Plugin for %s did not remove plugin monitor from map", plugin.FullName()) | |
| } | |
| if ctc.seenName != plugin.FullName() { | |
| t.Errorf("Remove Plugin did not remove constraints for plugin %s, got %s", plugin.FullName(), ctc.seenName) | |
| } | |
| } | |
| func TestRemoveAllDynamicPlugins(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| connections := t.TempDir() | |
| state := t.TempDir() | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\n[Core]\nacs_client = false", state) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(%s) failed unexpectedly with error: %v", tmp, err) | |
| } | |
| ctc := setupConstraintTestClient(t) | |
| cfg.Retrieve().Plugin.SocketConnectionsDir = connections | |
| addr := filepath.Join(connections, "PluginA_RevisionA.sock") | |
| ps := &testPluginServer{ctrs: make(map[string]int)} | |
| startTestServer(t, ps, udsProtocol, addr) | |
| entryPoint := filepath.Join(state, "plugins", "PluginA", "test-entry-point") | |
| createTestFile(t, entryPoint) | |
| corePlugin := &Plugin{Name: "CorePlugin", Revision: "RevisionA", InstallPath: t.TempDir(), Manifest: &Manifest{PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION}} | |
| plugin := &Plugin{Name: "PluginA", Revision: "RevisionA", Protocol: udsProtocol, Address: addr, InstallPath: filepath.Dir(entryPoint), RuntimeInfo: &RuntimeInfo{Pid: -5555}, Manifest: &Manifest{StartAttempts: 1, StopTimeout: time.Second * 3, PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION}} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect() failed unexpectedly with error: %v", err) | |
| } | |
| if err := os.MkdirAll(plugin.stateDir(), 0755); err != nil { | |
| t.Fatalf("os.MkdirAll(%s) failed unexpectedly with error: %v", plugin.stateDir(), err) | |
| } | |
| s := scheduler.Instance() | |
| t.Cleanup(s.Stop) | |
| pm := &PluginManager{plugins: map[string]*Plugin{plugin.Name: plugin, corePlugin.Name: corePlugin}, protocol: udsProtocol, pluginMonitors: make(map[string]string), scheduler: s} | |
| pm.pluginMonitors[plugin.FullName()] = "test-monitor" | |
| if err := pm.RemoveAllDynamicPlugins(ctx); err != nil { | |
| t.Fatalf("RemoveAllDynamicPlugins(ctx) failed unexpectedly with error: %v", err) | |
| } | |
| validatePluginRemoved(t, plugin, pm, ctc) | |
| // Core plugin should not be removed. | |
| if pm.plugins[corePlugin.Name] == nil { | |
| t.Errorf("RemoveAllDynamicPlugins(ctx) removed core plugin %s", corePlugin.Name) | |
| } | |
| } | |
| func TestInitAdHocPluginManager(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| stateDir := t.TempDir() | |
| addr := filepath.Join(t.TempDir(), "pluginA_revisionA.sock") | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\n[Core]\nacs_client = false\nsocket_connections_dir = %s", stateDir, filepath.Dir(addr)) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) | |
| } | |
| pluginManager.setInstanceID("test-instance-id") | |
| pluginA := &Plugin{Name: "pluginA", Revision: "revisionA", Protocol: udsProtocol, Address: addr, EntryPath: "testentry/binary", RuntimeInfo: &RuntimeInfo{Pid: -5555, status: acpb.CurrentPluginStates_RUNNING}, Manifest: &Manifest{StartAttempts: 3, StartTimeout: time.Second * 3, MaxMetricDatapoints: 2, MetricsInterval: time.Second * 3}} | |
| if err := pluginA.Store(); err != nil { | |
| t.Fatalf("plugin.Store() failed unexpectedly with error: %v", err) | |
| } | |
| pluginB := &Plugin{Name: "pluginB", Revision: "revisionB", Protocol: udsProtocol, Address: "invalid-address", EntryPath: "testentry/binary", RuntimeInfo: &RuntimeInfo{Pid: -5555, status: acpb.CurrentPluginStates_CRASHED}, Manifest: &Manifest{StartAttempts: 3, StartTimeout: time.Second * 3}} | |
| if err := pluginB.Store(); err != nil { | |
| t.Fatalf("plugin.Store() failed unexpectedly with error: %v", err) | |
| } | |
| // Reset the instance ID to test the ad-hoc plugin manager initialization. | |
| pluginManager.setInstanceID("") | |
| pm, err := InitAdHocPluginManager(ctx, "test-instance-id") | |
| if err != nil { | |
| t.Fatalf("InitAdHocPluginManager(ctx, test-instance-id) failed unexpectedly with error: %v", err) | |
| } | |
| if pm.instanceID != "test-instance-id" { | |
| t.Errorf("InitAdHocPluginManager(ctx, test-instance-id) set instance ID to %q, want %q", pm.instanceID, "test-instance-id") | |
| } | |
| if len(pm.plugins) != 2 { | |
| t.Errorf("InitAdHocPluginManager(ctx, test-instance-id) set %d plugins, want 2", len(pm.plugins)) | |
| } | |
| for _, plugin := range []*Plugin{pluginA, pluginB} { | |
| if pm.plugins[plugin.Name] == nil { | |
| t.Errorf("InitAdHocPluginManager(ctx, test-instance-id) did not initialize plugin %s", plugin.Name) | |
| } | |
| } | |
| } | |
| func TestAdHocStopPlugin(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| connections := t.TempDir() | |
| state := t.TempDir() | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\n[Core]\nacs_client = false", state) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(%s) failed unexpectedly with error: %v", tmp, err) | |
| } | |
| ctc := setupConstraintTestClient(t) | |
| cfg.Retrieve().Plugin.SocketConnectionsDir = connections | |
| addr := filepath.Join(connections, "PluginA_RevisionA.sock") | |
| ps := &testPluginServer{ctrs: make(map[string]int)} | |
| startTestServer(t, ps, udsProtocol, addr) | |
| entryPoint := filepath.Join(state, "plugins", "PluginA", "test-entry-point") | |
| createTestFile(t, entryPoint) | |
| plugin := &Plugin{Name: "PluginA", Revision: "RevisionA", Protocol: udsProtocol, Address: addr, InstallPath: filepath.Dir(entryPoint), RuntimeInfo: &RuntimeInfo{Pid: -5555}, Manifest: &Manifest{StopTimeout: time.Second * 3, PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION}} | |
| notRunningPlugin := &Plugin{Name: "PluginB", Revision: "RevisionB", Protocol: udsProtocol, Address: t.TempDir(), RuntimeInfo: &RuntimeInfo{Pid: -6666}, Manifest: &Manifest{StopTimeout: time.Second * 3, PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION}} | |
| pm := &PluginManager{plugins: map[string]*Plugin{plugin.Name: plugin, notRunningPlugin.Name: notRunningPlugin}, protocol: udsProtocol, pluginMonitors: make(map[string]string)} | |
| if err := pm.StopPlugin(ctx, plugin.Name); err != nil { | |
| t.Fatalf("StopPlugin(ctx, %s) failed unexpectedly with error: %v", plugin.Name, err) | |
| } | |
| validatePluginRemoved(t, plugin, pm, ctc) | |
| for _, plugin := range []string{notRunningPlugin.Name, "non-existing-plugin"} { | |
| if err := pm.StopPlugin(ctx, plugin); err != nil { | |
| t.Errorf("StopPlugin(ctx, %s) failed unexpectedly with error: %v", plugin, err) | |
| } | |
| } | |
| } | |
| func TestApplyConfig(t *testing.T) { | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| stateDir := t.TempDir() | |
| connDir := t.TempDir() | |
| infoDir := filepath.Join(stateDir, "test-instance-id", agentStateDir, pluginInfoDir) | |
| tmp := fmt.Sprintf("[PluginConfig]\nstate_dir = %s\nsocket_connections_dir = %s\n[Core]\nacs_client = false\n", stateDir, connDir) | |
| if err := cfg.Load([]byte(tmp)); err != nil { | |
| t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) | |
| } | |
| addr := filepath.Join(connDir, "PluginA_RevisionA.sock") | |
| ps := &testPluginServer{ctrs: make(map[string]int)} | |
| startTestServer(t, ps, udsProtocol, addr) | |
| setupConstraintTestClient(t) | |
| setupMockPsClient(t, &mockPsClient{alive: true, exe: "test-entry-point"}) | |
| plugin := &Plugin{Name: "PluginA", Revision: "RevisionA", Protocol: udsProtocol, Address: addr, EntryPath: "test-entry-point", RuntimeInfo: &RuntimeInfo{Pid: -5555}, Manifest: &Manifest{startConfigHash: "oldhash", StopTimeout: time.Second * 3, StartTimeout: time.Second * 3, PluginType: acpb.PluginType_DAEMON, PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION}} | |
| if err := plugin.Connect(ctx); err != nil { | |
| t.Fatalf("plugin.Connect() failed unexpectedly with error: %v", err) | |
| } | |
| computeHash := func(data string) string { | |
| hash := sha256.Sum256([]byte(data)) | |
| return hex.EncodeToString(hash[:]) | |
| } | |
| pm := &PluginManager{plugins: map[string]*Plugin{plugin.Name: plugin}, protocol: udsProtocol, instanceID: "test-instance-id"} | |
| origPluginManager := pluginManager | |
| t.Cleanup(func() { pluginManager = origPluginManager }) | |
| pluginManager = pm | |
| tests := []struct { | |
| name string | |
| plugin string | |
| config string | |
| wantErr bool | |
| wantCTR int | |
| wantHash string | |
| wantRelaunch bool | |
| nonExistentplugin bool | |
| }{ | |
| { | |
| name: "success", | |
| plugin: "PluginA", | |
| config: "success", | |
| wantCTR: 1, | |
| wantHash: computeHash("success"), | |
| }, | |
| { | |
| name: "success_no_config", | |
| plugin: "PluginA", | |
| wantCTR: 1, | |
| wantHash: "", | |
| }, | |
| { | |
| name: "apply_fails", | |
| plugin: "PluginA", | |
| config: "failure", | |
| wantErr: true, | |
| wantHash: computeHash("failure"), | |
| wantCTR: 1, | |
| }, | |
| { | |
| name: "unimplemented_error_relaunch", | |
| plugin: "PluginA", | |
| config: "unimplemented", | |
| wantErr: false, | |
| wantHash: computeHash("unimplemented"), | |
| wantCTR: 2, | |
| wantRelaunch: true, | |
| }, | |
| { | |
| name: "plugin_not_found", | |
| plugin: "PluginB", | |
| config: "nopluginfound", | |
| wantErr: true, | |
| wantCTR: 0, | |
| nonExistentplugin: true, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| tr := setupFakeRunner(t) | |
| req := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: tc.plugin, | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| Config: &acpb.ConfigurePluginStates_Manifest_StringConfig{StringConfig: tc.config}, | |
| }, | |
| } | |
| err := pm.applyConfig(ctx, req) | |
| if (err != nil) != tc.wantErr { | |
| t.Errorf("applyConfig(ctx, %s) = error: %v, want error: %t", tc.name, err, tc.wantErr) | |
| } | |
| if ps.ctrs[tc.config] != tc.wantCTR { | |
| t.Errorf("applyConfig(ctx, %s) = %d, want %d", tc.name, ps.ctrs[tc.config], tc.wantCTR) | |
| } | |
| pluginMap, err := load(infoDir) | |
| if err != nil { | |
| t.Fatalf("load(%s) failed unexpectedly with error: %v", infoDir, err) | |
| } | |
| if tc.nonExistentplugin { | |
| if p, ok := pluginMap[tc.plugin]; ok { | |
| t.Errorf("non-existing plugin %+v was added to the plugin state after applyConfig", p) | |
| } | |
| } else { | |
| if plugin.Manifest.startConfigHash != tc.wantHash { | |
| t.Errorf("applyConfig(ctx, %s) did not reset start config hash, got %q, want %q", tc.name, plugin.Manifest.startConfigHash, tc.wantHash) | |
| } | |
| if got := pluginMap[tc.plugin].Manifest.StartConfig.Simple; got != tc.config { | |
| t.Errorf("applyConfig(ctx, %s) did not update plugin state file with new config, got %q, want %q", tc.name, got, tc.config) | |
| } | |
| validatePluginRelaunched(t, tc.wantRelaunch, plugin, tr, ps) | |
| } | |
| }) | |
| } | |
| } | |
| func validatePluginRelaunched(t *testing.T, wantRelaunch bool, plugin *Plugin, tr *testRunner, ps *testPluginServer) { | |
| t.Helper() | |
| if wantRelaunch { | |
| if tr.seenCommand != plugin.EntryPath { | |
| t.Errorf("applyConfig for %s did not relaunch plugin with new config, got command %q, want %q", plugin.FullName(), tr.seenCommand, plugin.EntryPath) | |
| } | |
| if !ps.stopCalled { | |
| t.Errorf("applyConfig for %s did not stop plugin", plugin.FullName()) | |
| } | |
| startReq := ps.seenStartReq[plugin.Manifest.StartConfig.Simple] | |
| if startReq == nil { | |
| t.Errorf("applyConfig for %s did not start plugin with new config, got nil, want non-nil", plugin.FullName()) | |
| } | |
| } else { | |
| if tr.seenCommand != "" { | |
| t.Errorf("applyConfig(ctx, %s) relaunched plugin unexpectedly, got command %q, want empty", plugin.FullName(), tr.seenCommand) | |
| } | |
| startReq := ps.seenStartReq[plugin.Manifest.StartConfig.Simple] | |
| if startReq != nil { | |
| t.Errorf("applyConfig for %s attempted to start plugin with new config, got %v, want nil", plugin.FullName(), startReq) | |
| } | |
| } | |
| } | |
| func TestSetConfig(t *testing.T) { | |
| structCfg := &structpb.Struct{ | |
| Fields: map[string]*structpb.Value{ | |
| "name": structpb.NewStringValue("plugin-config"), | |
| }, | |
| } | |
| structBytes, err := proto.Marshal(structCfg) | |
| if err != nil { | |
| t.Fatalf("Failed to marshal struct config: %v", err) | |
| } | |
| testCases := []struct { | |
| name string | |
| req *acpb.ConfigurePluginStates_ConfigurePlugin | |
| want *ServiceConfig | |
| }{ | |
| { | |
| name: "nil-config", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{}, | |
| }, | |
| want: &ServiceConfig{}, | |
| }, | |
| { | |
| name: "string-config", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| Config: &acpb.ConfigurePluginStates_Manifest_StringConfig{StringConfig: "test-config"}, | |
| }, | |
| }, | |
| want: &ServiceConfig{Simple: "test-config"}, | |
| }, | |
| { | |
| name: "struct-config", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| Config: &acpb.ConfigurePluginStates_Manifest_StructConfig{StructConfig: structCfg}, | |
| }, | |
| }, | |
| want: &ServiceConfig{Structured: structBytes}, | |
| }, | |
| } | |
| for _, tc := range testCases { | |
| t.Run(tc.name, func(t *testing.T) { | |
| manifest := &Manifest{} | |
| err := setConfig(manifest, tc.req) | |
| if err != nil { | |
| t.Errorf("setConfig(%v) returned unexpected error: %v", tc.req, err) | |
| } | |
| if diff := cmp.Diff(tc.want, manifest.StartConfig); diff != "" { | |
| t.Errorf("setConfig(%v) returned diff (-want +got):\n%s", tc.req, diff) | |
| } | |
| }) | |
| } | |
| } | |
| func TestGetLocalPlugin(t *testing.T) { | |
| ctx := context.Background() | |
| pm := &PluginManager{} | |
| // Set the local plugin directory to the test state directory. | |
| localDir := t.TempDir() | |
| cfg.Load(nil) | |
| cfg.Retrieve().Plugin.LocalPluginDir = localDir | |
| if err := os.Mkdir(filepath.Join(localDir, "PluginA"), 0755); err != nil { | |
| t.Fatalf("os.Mkdir(%s) failed unexpectedly with error: %v", filepath.Join(localDir, "PluginA"), err) | |
| } | |
| // Create a local plugin manifest file. | |
| req := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA", | |
| EntryPoint: "test-entry-point", | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| MaxMemoryUsageBytes: 1024 * 1024, | |
| StartTimeout: &dpb.Duration{Seconds: 3}, | |
| StopTimeout: &dpb.Duration{Seconds: 5}, | |
| StartAttemptCount: 3, | |
| DownloadAttemptCount: 2, | |
| DownloadTimeout: &dpb.Duration{Seconds: 5}, | |
| PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION, | |
| }, | |
| } | |
| reqBytes, err := proto.Marshal(req) | |
| if err != nil { | |
| t.Fatalf("proto.Marshal(%+v) failed unexpectedly with error: %v", req, err) | |
| } | |
| if err := os.WriteFile(filepath.Join(localDir, "PluginA", manifestFile), reqBytes, 0644); err != nil { | |
| t.Fatalf("os.WriteFile(%s) failed unexpectedly with error: %v", filepath.Join(localDir, "PluginA", manifestFile), err) | |
| } | |
| got, err := pm.GetLocalPlugin(ctx, "PluginA") | |
| if err != nil { | |
| t.Fatalf("GetLocalPlugin(ctx, %s) failed unexpectedly with error: %v", "PluginA", err) | |
| } | |
| if diff := cmp.Diff(req, got, protocmp.Transform(), cmpopts.IgnoreUnexported(Plugin{}, RuntimeInfo{}, Manifest{})); diff != "" { | |
| t.Errorf("GetLocalPlugin(ctx, %s) returned unexpected diff (-want +got):\n%s", "PluginA", diff) | |
| } | |
| } | |
| func TestStartLocalPlugin(t *testing.T) { | |
| connections := t.TempDir() | |
| state := t.TempDir() | |
| setBaseStateDir(t, state) | |
| setupConstraintTestClient(t) | |
| ctx := context.WithValue(context.Background(), client.OverrideConnection, &fakeACS{}) | |
| cfg.Retrieve().Plugin.SocketConnectionsDir = connections | |
| cfg.Retrieve().Core.ACSClient = false | |
| addr := filepath.Join(connections, "PluginA_RevisionA.sock") | |
| ps := &testPluginServer{ctrs: make(map[string]int)} | |
| server, hash, runner, seenPendingPlugins := installSetup(t, ps, addr) | |
| runner.pid = -6666 | |
| defer server.Close() | |
| orig := pluginManager | |
| t.Cleanup(func() { pluginManager = orig }) | |
| tests := []struct { | |
| name string | |
| url string | |
| disabled bool | |
| onReady bool | |
| }{ | |
| { | |
| name: "success", | |
| url: server.URL, | |
| }, | |
| { | |
| name: "disabled", | |
| disabled: true, | |
| }, | |
| { | |
| name: "on-ready", | |
| onReady: true, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| // Set the local plugin directory to the test state directory. | |
| localDir := t.TempDir() | |
| cfg.Retrieve().Plugin.LocalPluginDir = localDir | |
| if err := os.Mkdir(filepath.Join(localDir, "PluginA"), 0755); err != nil { | |
| t.Fatalf("os.Mkdir(%s) failed unexpectedly with error: %v", filepath.Join(localDir, "PluginA"), err) | |
| } | |
| // Create a local plugin manifest file. | |
| req := &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Action: acpb.ConfigurePluginStates_INSTALL, | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA", | |
| EntryPoint: "test-entry-point", | |
| Checksum: hash, | |
| GcsSignedUrl: tc.url, | |
| }, | |
| Manifest: &acpb.ConfigurePluginStates_Manifest{ | |
| Config: &acpb.ConfigurePluginStates_Manifest_StringConfig{StringConfig: tc.name}, | |
| MaxMemoryUsageBytes: 1024 * 1024, | |
| StartTimeout: &dpb.Duration{Seconds: 3}, | |
| StopTimeout: &dpb.Duration{Seconds: 5}, | |
| StartAttemptCount: 3, | |
| DownloadAttemptCount: 2, | |
| DownloadTimeout: &dpb.Duration{Seconds: 5}, | |
| PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION, | |
| ExecutionModel: &acpb.ExecutionModel{ | |
| BootCritical: true, | |
| }, | |
| }, | |
| } | |
| reqBytes, err := proto.Marshal(req) | |
| if err != nil { | |
| t.Fatalf("proto.Marshal(%+v) failed unexpectedly with error: %v", req, err) | |
| } | |
| if err := os.WriteFile(filepath.Join(localDir, "PluginA", manifestFile), reqBytes, 0644); err != nil { | |
| t.Fatalf("os.WriteFile(%s) failed unexpectedly with error: %v", filepath.Join(localDir, "PluginA", manifestFile), err) | |
| } | |
| // Set up the plugin manager. | |
| s := scheduler.Instance() | |
| t.Cleanup(s.Stop) | |
| pm := &PluginManager{ | |
| plugins: map[string]*Plugin{}, | |
| protocol: udsProtocol, | |
| pluginMonitors: make(map[string]string), | |
| pluginMetricsMonitors: make(map[string]string), | |
| scheduler: s, | |
| inProgressPluginRequests: make(map[string]bool), | |
| requestCount: make(map[acpb.ConfigurePluginStates_Action]map[bool]int), | |
| } | |
| pluginManager = pm | |
| var onReadyCalled bool | |
| // Set the installations map to disable the plugin if needed, and set the | |
| // on-ready callback if needed. | |
| installations := make(map[string]LocalPluginInstallation) | |
| if tc.disabled || tc.onReady { | |
| localInstallation := LocalPluginInstallation{ | |
| Enable: !tc.disabled, | |
| } | |
| if tc.onReady { | |
| localInstallation.OnReady = func(context.Context) { | |
| onReadyCalled = true | |
| } | |
| } | |
| installations["PluginA"] = localInstallation | |
| } | |
| // Start the local plugins for the test. | |
| if err := pm.StartLocalPlugins(ctx, installations); err != nil { | |
| t.Fatalf("StartLocalPlugins(ctx, %+v) = error: %v, want no error", installations, err) | |
| } | |
| // Fresh install should not have any pending plugins. | |
| if len(seenPendingPlugins.revisions) != 0 || len(seenPendingPlugins.status) != 0 { | |
| t.Errorf("StartLocalPlugins(ctx, %+v) set pending plugins = %+v, want empty revision and status map", installations, seenPendingPlugins) | |
| } | |
| // If the plugin is disabled or an error is expected, the plugin list should be empty. | |
| if tc.disabled { | |
| if len(pm.plugins) != 0 { | |
| t.Errorf("StartLocalPlugins(ctx, %+v) = %+v, want no plugin", installations, pm.plugins) | |
| } | |
| return | |
| } | |
| if tc.onReady && !onReadyCalled { | |
| t.Errorf("StartLocalPlugins(ctx, %+v) did not call on-ready callback", installations) | |
| } | |
| entryPoint := "test-entry-point" | |
| want, err := newPlugin(req) | |
| if err != nil { | |
| t.Fatalf("newPlugin(%+v) failed unexpectedly with error: %v", req, err) | |
| } | |
| want.RuntimeInfo.Pid = runner.pid | |
| want.Address = addr | |
| want.Protocol = udsProtocol | |
| want.EntryPath = entryPoint | |
| want.Manifest.PluginInstallationType = acpb.PluginInstallationType_LOCAL_INSTALLATION | |
| if runner.seenCommand != entryPoint { | |
| t.Errorf("StartLocalPlugins(ctx, %+v) executed %q, want %q", installations, runner.seenCommand, entryPoint) | |
| } | |
| got, ok := pm.plugins[req.Plugin.Name] | |
| if !ok { | |
| t.Fatalf("StartLocalPlugins(ctx, %+v) did not create plugin %q", installations, req.Plugin.Name) | |
| } | |
| if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(Plugin{}, RuntimeInfo{}, Manifest{}), cmpopts.IgnoreFields(ServiceConfig{}, "Simple"), cmpopts.IgnoreFields(Plugin{}, "InstallPath")); diff != "" { | |
| t.Errorf("pm.plugins[%s] returned unexpected diff (-want +got):\n%s", req.Plugin.Name, diff) | |
| } | |
| if got.State() != acpb.CurrentPluginStates_RUNNING { | |
| t.Errorf("StartLocalPlugins(ctx, %+v) = plugin state %q, want %q", installations, got.State(), acpb.CurrentPluginStates_RUNNING) | |
| } | |
| if pm.requestCount[acpb.ConfigurePluginStates_INSTALL][true] != 1 { | |
| t.Errorf("StartLocalPlugins(ctx, %+v) called ConfigurePluginStates %d times, want 1 time", installations, pm.requestCount[acpb.ConfigurePluginStates_INSTALL][true]) | |
| } | |
| if ps.ctrs[tc.name] != 1 { | |
| t.Errorf("StartLocalPlugins(ctx, %+v) called start RPC %d times, want 1 time on plugin %q", installations, ps.ctrs[tc.name], req.Plugin.Name) | |
| } | |
| c := retry.Policy{MaxAttempts: 3, Jitter: time.Second * 2, BackoffFactor: 1} | |
| err = retry.Run(ctx, c, func() error { | |
| pm.pluginMonitorMu.Lock() | |
| defer pm.pluginMonitorMu.Unlock() | |
| _, ok := pm.pluginMonitors[want.FullName()] | |
| if !ok { | |
| return fmt.Errorf("StartLocalPlugins(ctx, %+v) did not create monitor for plugin %q", installations, req.Plugin.Name) | |
| } | |
| return nil | |
| }) | |
| if err != nil { | |
| t.Errorf("%v", err) | |
| } | |
| }) | |
| } | |
| } | |
| func TestVerifyPluginRunning(t *testing.T) { | |
| ctx := context.Background() | |
| tests := []struct { | |
| name string | |
| req *acpb.ConfigurePluginStates_ConfigurePlugin | |
| isRunning bool | |
| plugins []*Plugin | |
| wantErr bool | |
| }{ | |
| { | |
| name: "success", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA", | |
| EntryPoint: "test-entry-point", | |
| }, | |
| }, | |
| plugins: []*Plugin{ | |
| &Plugin{ | |
| Name: "PluginA", | |
| Revision: "RevisionA", | |
| RuntimeInfo: &RuntimeInfo{ | |
| status: acpb.CurrentPluginStates_RUNNING, | |
| }, | |
| }, | |
| }, | |
| isRunning: true, | |
| }, | |
| { | |
| name: "plugin-not-running", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginA", | |
| RevisionId: "RevisionA", | |
| EntryPoint: "test-entry-point", | |
| }, | |
| }, | |
| plugins: []*Plugin{ | |
| &Plugin{ | |
| Name: "PluginA", | |
| Revision: "RevisionA", | |
| RuntimeInfo: &RuntimeInfo{ | |
| status: acpb.CurrentPluginStates_STOPPED, | |
| }, | |
| }, | |
| }, | |
| wantErr: true, | |
| isRunning: false, | |
| }, | |
| { | |
| name: "plugin-not-found", | |
| req: &acpb.ConfigurePluginStates_ConfigurePlugin{ | |
| Plugin: &acpb.ConfigurePluginStates_Plugin{ | |
| Name: "PluginB", | |
| RevisionId: "RevisionB", | |
| EntryPoint: "test-entry-point", | |
| }, | |
| }, | |
| plugins: []*Plugin{ | |
| &Plugin{ | |
| Name: "PluginA", | |
| Revision: "RevisionA", | |
| RuntimeInfo: &RuntimeInfo{ | |
| status: acpb.CurrentPluginStates_RUNNING, | |
| }, | |
| }, | |
| }, | |
| wantErr: true, | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| pm := &PluginManager{ | |
| plugins: map[string]*Plugin{}, | |
| } | |
| for _, p := range tc.plugins { | |
| pm.plugins[p.Name] = p | |
| } | |
| err := pm.VerifyPluginRunning(ctx, tc.req) | |
| if (err != nil) != tc.wantErr { | |
| t.Errorf("VerifyPluginRunning(ctx, %+v) = error: %v, want error: %t", tc.req, err, tc.wantErr) | |
| } | |
| }) | |
| } | |
| } |