| // Copyright 2025 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" | |
| "errors" | |
| "fmt" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "strings" | |
| "time" | |
| "github.com/GoogleCloudPlatform/galog" | |
| acpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" | |
| "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" | |
| ) | |
| // retryFailedRemovals removes all old plugin states. | |
| // | |
| // This function is called by the cleanup watcher to remove old plugin states. | |
| // This will primarily retry removing plugins that weren't properly removed before. | |
| func (m *PluginManager) retryFailedRemovals(ctx context.Context) (bool, error) { | |
| galog.Debugf("Cleaning up old/failed plugin states") | |
| // Check for plugins that aren't cached by the guest agent that should be removed. | |
| pluginInstallLoc := filepath.Join(baseState(), pluginInstallDir) | |
| pluginDirs, err := os.ReadDir(pluginInstallLoc) | |
| if err != nil { | |
| if errors.Is(err, os.ErrNotExist) { | |
| galog.V(1).Debugf("Plugin install directory %q does not exist, nothing to clean up", pluginInstallLoc) | |
| return true, nil | |
| } | |
| return true, fmt.Errorf("failed to read plugin install directory: %w", err) | |
| } | |
| // Keep track of the links to the plugin directories. We need to verify that | |
| // the link points to a plugin directory that is no longer cached by the guest | |
| // agent before removing it. | |
| var links []string | |
| failedRemovals := make(map[string]*Plugin) | |
| m.pendingPluginRevisionsMu.RLock() | |
| for _, p := range pluginDirs { | |
| if !p.IsDir() { | |
| links = append(links, p.Name()) | |
| continue | |
| } | |
| // Plugin directories have the form <name>_<revision>. The revision is | |
| // typically a hash, so it should be safe to split by the last underscore. | |
| nameIndex := strings.LastIndex(p.Name(), "_") | |
| pluginName := p.Name()[:nameIndex] | |
| pluginRevision := p.Name()[nameIndex+1:] | |
| galog.V(1).Debugf("Checking plugin %q, revision %q", pluginName, pluginRevision) | |
| // We want to avoid cleaning up plugins that currently have a request in progress. | |
| // This is to prevent a race condition where the plugin is removed in the | |
| // middle of an installation request. | |
| if m.inProgressPluginRequests[pluginName] { | |
| continue | |
| } | |
| if _, err := m.Fetch(p.Name()); err != nil { | |
| installPath := filepath.Join(pluginInstallLoc, p.Name()) | |
| failedRemovals[p.Name()] = &Plugin{Name: pluginName, Revision: pluginRevision, InstallPath: installPath, Manifest: &Manifest{PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION}} | |
| galog.Debugf("Found leftover plugin %q, marking for removal", p.Name()) | |
| } | |
| } | |
| m.pendingPluginRevisionsMu.RUnlock() | |
| // Remove the links to the plugin directories that are no longer cached by the guest agent. | |
| for _, link := range links { | |
| dest, err := os.Readlink(filepath.Join(pluginInstallLoc, link)) | |
| if err != nil { | |
| continue | |
| } | |
| // Check if the link points to a plugin directory that is no longer cached | |
| // by the guest agent. | |
| galog.V(1).Debugf("Checking link %q", link) | |
| if _, ok := failedRemovals[filepath.Base(dest)]; ok { | |
| linkPath := filepath.Join(pluginInstallLoc, link) | |
| galog.V(1).Debugf("Removing link %q", linkPath) | |
| if err := os.Remove(linkPath); err != nil { | |
| galog.Debugf("Unable to remove link %q: %v", linkPath, err) | |
| } | |
| } | |
| } | |
| var errs []error | |
| noop := len(failedRemovals) == 0 | |
| for _, p := range failedRemovals { | |
| galog.Debugf("Cleaning up plugin %q", p.FullName()) | |
| if err := cleanup(ctx, p); err != nil { | |
| errs = append(errs, err) | |
| } | |
| } | |
| galog.Debugf("Finished cleaning up old/failed plugin states") | |
| return noop, errors.Join(errs...) | |
| } | |
| func (m *PluginManager) cleanupOldState(ctx context.Context, path string) error { | |
| re := regexp.MustCompile("^[0-9]+$") | |
| if !file.Exists(path, file.TypeDir) { | |
| // This is not an error, it just means there's nothing to clean up, which | |
| // can happen if the agent is started for the first time or plugins were | |
| // never installed. | |
| galog.Debugf("Plugin state directory %q does not exist, skipping cleanup", path) | |
| return nil | |
| } | |
| dirs, err := os.ReadDir(path) | |
| if err != nil { | |
| return fmt.Errorf("failed to read directory %q: %w", path, err) | |
| } | |
| currentID := m.currentInstanceID() | |
| for _, dir := range dirs { | |
| absPath := filepath.Join(path, dir.Name()) | |
| // Skip the current instance directory and any non-numeric directories, | |
| // these are most likely not agent created. | |
| if !dir.IsDir() || dir.Name() == currentID || !re.MatchString(dir.Name()) { | |
| galog.V(2).Debugf("Skipping %q from plugin manager old state cleanup", absPath) | |
| continue | |
| } | |
| galog.Debugf("Removing previous plugin state %q", absPath) | |
| if err := os.RemoveAll(absPath); err != nil { | |
| return fmt.Errorf("failed to remove file %q: %w", absPath, err) | |
| } | |
| } | |
| return nil | |
| } | |
| // CleanupJob is a job that cleans up old plugin states. | |
| type CleanupJob struct { | |
| pm *PluginManager | |
| } | |
| // newCleanupJob creates a new cleanup job. | |
| func newCleanupJob(pm *PluginManager) *CleanupJob { | |
| return &CleanupJob{pm: pm} | |
| } | |
| // ID returns the ID of the cleanup job. | |
| func (s *CleanupJob) ID() string { | |
| return "cleanup" | |
| } | |
| // MetricName returns the metric name of the cleanup job. | |
| func (s *CleanupJob) MetricName() acpb.GuestAgentModuleMetric_Metric { | |
| return acpb.GuestAgentModuleMetric_PLUGIN_CLEANUP | |
| } | |
| // Interval returns the interval of the cleanup job. This is set to 24 hours | |
| // to run once a day. | |
| func (s *CleanupJob) Interval() (time.Duration, bool) { | |
| return 24 * time.Hour, true | |
| } | |
| // ShouldEnable returns true if the cleanup job should be enabled. | |
| // | |
| // This should always be enabled to ensure that old plugin states are cleaned | |
| // up. | |
| func (s *CleanupJob) ShouldEnable(context.Context) bool { | |
| return true | |
| } | |
| // Run runs the cleanup job. | |
| func (s *CleanupJob) Run(ctx context.Context) (bool, error) { | |
| galog.Debugf("Running cleanup job") | |
| noop, err := s.pm.retryFailedRemovals(ctx) | |
| galog.Debugf("Finished running cleanup job") | |
| return noop, err | |
| } |