| // 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 ( |
| "archive/tar" |
| "compress/gzip" |
| "context" |
| "errors" |
| "fmt" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| "runtime" |
| "strings" |
| "sync" |
| "testing" |
| "time" |
| |
| acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" |
| "github.com/google/go-cmp/cmp" |
| dpb "google.golang.org/protobuf/types/known/durationpb" |
| ) |
| |
| func TestDownloadStep(t *testing.T) { |
| content := "test" |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| fmt.Fprint(w, content) |
| })) |
| defer ts.Close() |
| |
| downloadPath := filepath.Join(t.TempDir(), "download") |
| |
| step := &downloadStep{ |
| url: ts.URL + "/download", |
| targetPath: downloadPath, |
| // This is SHA256 of "test" string. |
| checksum: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", |
| attempts: 1, |
| } |
| |
| wantStatus := acmpb.CurrentPluginStates_INSTALLING |
| if got := step.Status(); got != wantStatus { |
| t.Errorf("downloadStep.Status() = %q, want %q", got, wantStatus) |
| } |
| |
| wantErrorStatus := acmpb.CurrentPluginStates_INSTALL_FAILED |
| if got := step.ErrorStatus(); got != wantErrorStatus { |
| t.Errorf("downloadStep.ErrorStatus() = %s, want %s", got, wantErrorStatus) |
| } |
| |
| wantName := "DownloadPluginStep" |
| if got := step.Name(); got != wantName { |
| t.Errorf("downloadStep.Name() = %q, want %q", got, wantName) |
| } |
| |
| if err := step.Run(context.Background(), &Plugin{}); err != nil { |
| t.Fatalf("step.Run(ctx) failed unexpectedly with error: %v", err) |
| } |
| |
| got, err := os.ReadFile(downloadPath) |
| if err != nil { |
| t.Fatalf("os.ReadFile(%q) failed unexpectedly with error: %v", downloadPath, err) |
| } |
| if string(got) != content { |
| t.Errorf("os.ReadFile(%q) = %q, want %q", downloadPath, string(got), content) |
| } |
| } |
| |
| func TestDownloadStepError(t *testing.T) { |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if strings.Contains(r.URL.Path, "error") { |
| http.Error(w, "test url error", http.StatusInternalServerError) |
| } else { |
| fmt.Fprint(w, "test") |
| } |
| })) |
| defer ts.Close() |
| |
| validSum := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" |
| downloadPath := filepath.Join(t.TempDir(), "download") |
| |
| tests := []struct { |
| desc string |
| url string |
| path string |
| checksum string |
| }{ |
| { |
| desc: "invalid_http_code", |
| url: ts.URL + "/error", |
| path: downloadPath, |
| checksum: validSum, |
| }, |
| { |
| desc: "invalid_checksum", |
| url: ts.URL + "/download", |
| path: downloadPath, |
| checksum: "incorrect-cksum", |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.desc, func(t *testing.T) { |
| step := &downloadStep{ |
| targetPath: tc.path, |
| attempts: 1, |
| url: tc.url, |
| checksum: tc.checksum, |
| } |
| |
| if err := step.Run(context.Background(), &Plugin{}); err == nil { |
| t.Errorf("step.Run(ctx) succeeded for malformed input, want error") |
| } |
| }) |
| } |
| } |
| |
| type testStep struct { |
| executedAt time.Time |
| throwError error |
| status acmpb.CurrentPluginStates_StatusValue |
| errorStatus acmpb.CurrentPluginStates_StatusValue |
| } |
| |
| func (t *testStep) Run(context.Context, *Plugin) error { |
| t.executedAt = time.Now() |
| if t.throwError != nil { |
| return t.throwError |
| } |
| return nil |
| } |
| |
| func (t *testStep) Status() acmpb.CurrentPluginStates_StatusValue { |
| return t.status |
| } |
| |
| func (t *testStep) ErrorStatus() acmpb.CurrentPluginStates_StatusValue { |
| return t.errorStatus |
| } |
| |
| func (t *testStep) Name() string { |
| return "testStep" |
| } |
| |
| func setBaseStateDir(t *testing.T, addr string) { |
| t.Helper() |
| if err := cfg.Load([]byte{}); err != nil { |
| t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) |
| } |
| cfg.Retrieve().Plugin.StateDir = addr |
| } |
| |
| func TestBaseState(t *testing.T) { |
| if err := cfg.Load([]byte{}); err != nil { |
| t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) |
| } |
| |
| want := map[string]string{ |
| "windows": `C:\ProgramData\Google\Compute Engine\google-guest-agent`, |
| "linux": "/var/lib/google-guest-agent", |
| }[runtime.GOOS] |
| |
| got := baseState() |
| if got != want { |
| t.Errorf("baseState() = %q, want %q", got, want) |
| } |
| } |
| |
| func TestPluginState(t *testing.T) { |
| if err := cfg.Load(nil); err != nil { |
| t.Fatalf("Failed to load config: %v", err) |
| } |
| |
| tests := []struct { |
| desc string |
| base string |
| name string |
| revision string |
| want string |
| }{ |
| { |
| desc: "plugin_state_pluginname", |
| base: "/some/path", |
| name: "pluginname", |
| revision: "revision", |
| want: filepath.Join("/some/path", "plugins/pluginname_revision"), |
| }, |
| { |
| desc: "plugin_state_pluginname2", |
| base: "/some/path2", |
| name: "pluginname2", |
| revision: "revision2", |
| want: filepath.Join("/some/path2", "plugins/pluginname2_revision2"), |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.desc, func(t *testing.T) { |
| cfg.Retrieve().Plugin.StateDir = tc.base |
| if got := pluginInstallPath(tc.name, tc.revision); got != tc.want { |
| t.Errorf("pluginState(%s, %s) = %q, want %q", tc.base, tc.name, got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestRunSteps(t *testing.T) { |
| plugin := &Plugin{RuntimeInfo: &RuntimeInfo{statusMu: sync.RWMutex{}}} |
| |
| step1 := &testStep{status: acmpb.CurrentPluginStates_INSTALLING} |
| wantState := acmpb.CurrentPluginStates_RUNNING |
| step2 := &testStep{status: wantState} |
| |
| if err := plugin.runSteps(context.Background(), []Step{step1, step2}); err != nil { |
| t.Fatalf("plugin.runSteps(ctx, %+v) failed unexpectedly with error: %v", step1, err) |
| } |
| |
| if step1.executedAt.After(step2.executedAt) { |
| t.Errorf("plugin.runSteps(ctx, steps) executed in wrong order, step1.executedAt = %v, want earlier than step2.executedAt = %v", step1.executedAt, step2.executedAt) |
| } |
| |
| if got := plugin.State(); got != wantState { |
| t.Errorf("plugin.Status() = %q, want %q", got, wantState) |
| } |
| } |
| |
| func TestRunStepsError(t *testing.T) { |
| ctx := context.Background() |
| plugin := &Plugin{ |
| Name: "testPlugin", |
| Revision: "testPluginRevision", |
| RuntimeInfo: &RuntimeInfo{statusMu: sync.RWMutex{}}, |
| } |
| |
| tests := []struct { |
| desc string |
| steps []Step |
| wantError error |
| cancelContext bool |
| wantState acmpb.CurrentPluginStates_StatusValue |
| }{ |
| { |
| desc: "fail_at_first_step", |
| steps: []Step{&testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_INSTALL_FAILED, throwError: context.DeadlineExceeded}}, |
| wantError: context.DeadlineExceeded, |
| wantState: acmpb.CurrentPluginStates_INSTALL_FAILED, |
| }, |
| { |
| desc: "fail_after_first_step", |
| steps: []Step{&testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_INSTALL_FAILED}, &testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_CRASHED, throwError: fmt.Errorf("test error")}}, |
| wantError: fmt.Errorf("test error"), |
| wantState: acmpb.CurrentPluginStates_CRASHED, |
| }, |
| { |
| desc: "fail_immediately", |
| steps: []Step{&testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_INSTALL_FAILED}}, |
| wantError: context.Canceled, |
| cancelContext: true, |
| wantState: acmpb.CurrentPluginStates_INSTALL_FAILED, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.desc, func(t *testing.T) { |
| ctx, cancel := context.WithTimeout(ctx, time.Minute) |
| if tc.cancelContext { |
| cancel() |
| } else { |
| defer cancel() |
| } |
| |
| err := plugin.runSteps(ctx, tc.steps) |
| gotErr := errors.Unwrap(err) |
| if gotErr.Error() != tc.wantError.Error() { |
| t.Errorf("plugin.runSteps(ctx, %+v) = %v, want %v", tc.steps, err, tc.wantError) |
| } |
| if got := plugin.State(); got != tc.wantState { |
| t.Errorf("plugin.Status() = %q, want %q", got, tc.wantState) |
| } |
| }) |
| } |
| } |
| |
| func TestGenerateInstallWorkflow(t *testing.T) { |
| pm := &PluginManager{protocol: udsProtocol} |
| req := &acmpb.ConfigurePluginStates_ConfigurePlugin{ |
| Action: acmpb.ConfigurePluginStates_INSTALL, |
| Plugin: &acmpb.ConfigurePluginStates_Plugin{ |
| Name: "testPlugin", |
| RevisionId: "abc", |
| GcsSignedUrl: "https://test.com", |
| Checksum: "testPluginChecksum", |
| EntryPoint: "testPluginEntryPoint", |
| }, |
| Manifest: &acmpb.ConfigurePluginStates_Manifest{ |
| DownloadTimeout: &dpb.Duration{Seconds: 60}, |
| DownloadAttemptCount: 1, |
| MaxMemoryUsageBytes: 100, |
| StartAttemptCount: 3, |
| }, |
| } |
| |
| baseDir := t.TempDir() |
| setBaseStateDir(t, baseDir) |
| p := pluginInstallPath(req.GetPlugin().GetName(), req.GetPlugin().GetRevisionId()) |
| archivePath := filepath.Join(baseDir, req.GetPlugin().GetName()+".tar.gz") |
| ctx := context.Background() |
| |
| tests := []struct { |
| name string |
| installationType acmpb.PluginInstallationType |
| wantSteps []Step |
| }{ |
| { |
| name: "dynamic-plugin", |
| installationType: acmpb.PluginInstallationType_DYNAMIC_INSTALLATION, |
| wantSteps: []Step{ |
| &downloadStep{url: "https://test.com", targetPath: archivePath, checksum: "testPluginChecksum", attempts: 1, timeout: 60 * time.Second}, |
| &unpackStep{archivePath: archivePath, targetDir: p}, |
| &launchStep{entryPath: filepath.Join(p, "testPluginEntryPoint"), maxMemoryUsage: 100, startAttempts: 3, protocol: pm.protocol}, |
| }, |
| }, |
| { |
| name: "local-plugin", |
| installationType: acmpb.PluginInstallationType_LOCAL_INSTALLATION, |
| wantSteps: []Step{ |
| &launchStep{entryPath: "testPluginEntryPoint", maxMemoryUsage: 100, startAttempts: 3, protocol: pm.protocol}, |
| }, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| req.Manifest.PluginInstallationType = tc.installationType |
| |
| gotSteps := pm.generateInstallWorkflow(ctx, req) |
| |
| allow := []any{downloadStep{}, unpackStep{}, launchStep{}} |
| if diff := cmp.Diff(tc.wantSteps, gotSteps, cmp.AllowUnexported(allow...)); diff != "" { |
| t.Errorf("generateInstallWorkflow(ctx, %+v) returned diff (-want +got):\n%s", req, diff) |
| } |
| }) |
| } |
| |
| req.Action = acmpb.ConfigurePluginStates_REMOVE |
| |
| if gotSteps := pm.generateInstallWorkflow(ctx, req); gotSteps != nil { |
| t.Errorf("generateInstallWorkflow(ctx, %+v) = %+v, want empty", req, gotSteps) |
| } |
| } |
| |
| func createTestArchive(t *testing.T) string { |
| t.Helper() |
| |
| tempDir := t.TempDir() |
| tempFile := filepath.Join(tempDir, "test.tar.gz") |
| f, err := os.Create(tempFile) |
| if err != nil { |
| t.Fatalf("error creating test file: %v", err) |
| } |
| defer func() { |
| if err := f.Close(); err != nil { |
| t.Fatalf("error closing test archive file: %v", err) |
| } |
| }() |
| |
| gw := gzip.NewWriter(f) |
| defer func() { |
| if err := gw.Close(); err != nil { |
| t.Fatalf("error closing test archive gzip writer: %v", err) |
| } |
| }() |
| |
| tw := tar.NewWriter(gw) |
| defer func() { |
| if err := tw.Close(); err != nil { |
| t.Fatalf("error closing test archive tar writer: %v", err) |
| } |
| }() |
| |
| name := filepath.Join("dir1", "file1") |
| body := "test content" |
| |
| if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0777, Size: int64(len(body))}); err != nil { |
| t.Fatalf("error creating test archive header: %v", err) |
| } |
| |
| if _, err := tw.Write([]byte(body)); err != nil { |
| t.Fatalf("error writing test archive content: %v", err) |
| } |
| |
| return tempFile |
| } |
| |
| func TestUnpackStepRun(t *testing.T) { |
| out := filepath.Join(t.TempDir(), "unpack-to") |
| archive := createTestArchive(t) |
| |
| tests := []struct { |
| desc string |
| step *unpackStep |
| wantErr bool |
| }{ |
| { |
| desc: "success", |
| step: &unpackStep{archivePath: archive, targetDir: out}, |
| }, |
| { |
| desc: "failure", |
| step: &unpackStep{archivePath: "/non/existent/archive", targetDir: t.TempDir()}, |
| wantErr: true, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.desc, func(t *testing.T) { |
| step := tc.step |
| wantStatus := acmpb.CurrentPluginStates_INSTALLING |
| if got := step.Status(); got != wantStatus { |
| t.Errorf("downloadStep.Status() = %q, want %q", got, wantStatus) |
| } |
| |
| wantErrorStatus := acmpb.CurrentPluginStates_INSTALL_FAILED |
| if got := step.ErrorStatus(); got != wantErrorStatus { |
| t.Errorf("downloadStep.ErrorStatus() = %s, want %s", got, wantErrorStatus) |
| } |
| |
| wantName := "UnpackPluginArchiveStep" |
| if got := step.Name(); got != wantName { |
| t.Errorf("downloadStep.Name() = %q, want %q", got, wantName) |
| } |
| |
| if err := step.Run(context.Background(), &Plugin{}); (err != nil) != tc.wantErr { |
| t.Errorf("step.Run(ctx): got error %v, want error %v", err, tc.wantErr) |
| } |
| |
| if _, err := os.Stat(step.archivePath); !os.IsNotExist(err) { |
| t.Errorf("os.Stat(%s) = %v, want %v", step.archivePath, err, os.ErrNotExist) |
| } |
| |
| if _, err := os.Stat(step.targetDir); err != nil { |
| t.Errorf("os.Stat(%s) failed unexpectedly with error: %v", step.targetDir, err) |
| } |
| }) |
| } |
| } |
| |
| func TestRelaunchWorkflow(t *testing.T) { |
| ctx := context.Background() |
| p := &Plugin{ |
| Name: "testPlugin", |
| Revision: "testPluginRevision", |
| EntryPath: "testPluginEntryPoint", |
| Manifest: &Manifest{MaxMemoryUsage: 100, StartAttempts: 3}, |
| Protocol: udsProtocol, |
| } |
| want := []Step{ |
| &stopStep{cleanup: false}, |
| &launchStep{entryPath: p.EntryPath, maxMemoryUsage: p.Manifest.MaxMemoryUsage, startAttempts: p.Manifest.StartAttempts, protocol: p.Protocol}, |
| } |
| |
| got := relaunchWorkflow(ctx, p) |
| |
| allow := []any{stopStep{}, launchStep{}} |
| if diff := cmp.Diff(want, got, cmp.AllowUnexported(allow...)); diff != "" { |
| t.Errorf("relaunchWorkflow(ctx, %+v) returned diff (-want +got):\n%s", p, diff) |
| } |
| } |