| // 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) |
| } |
| }) |
| } |
| } |