blob: 47dff4862856a1d5d46db30124feeb386557830a [file] [edit]
// 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 contains the plugin manager engine that handles the workflow.
package manager
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/GoogleCloudPlatform/galog"
acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/retry"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file"
)
const (
// pluginInstallDir is the directory under the agent base state directory
// where all plugins are installed.
pluginInstallDir = "plugins"
// pluginStateDir is the directory under the agent base state directory where
// all plugins store their state and is persisted across revisions.
pluginStateDir = "plugin_state"
)
// baseState returns the base path where the agent and all plugins state is
// stored.
func baseState() string {
return filepath.Join(filepath.Clean(cfg.Retrieve().Plugin.StateDir), pluginManager.currentInstanceID())
}
// pluginInstallPath returns the path where the plugin is installed. This is
// the path where the plugin is unpacked and the entry point is executed from.
func pluginInstallPath(name, revision string) string {
return filepath.Join(baseState(), pluginInstallDir, fmt.Sprintf("%s_%s", name, revision))
}
// Step represents an interface for each step run as part of a plugin
// configuration.
type Step interface {
// The name of the step.
Name() string
// Status returns the plugin state for current step.
Status() acmpb.CurrentPluginStates_StatusValue
// ErrorStatus returns the plugin state if current step fails.
ErrorStatus() acmpb.CurrentPluginStates_StatusValue
// Performs the step.
Run(context.Context, *Plugin) error
}
// downloadStep represents the download step of plugin install.
type downloadStep struct {
// url is the GCS signed URL of the plugin package.
url string
// targetPath is the path to the target file where the package is downloaded.
targetPath string
// checksum is the expected sha256sum of the downloaded package.
checksum string
// attempts is the number of times to try downloading the package in case of
// failure.
attempts int
// timeout is the timeout for the download.
timeout time.Duration
}
// Name returns the name of the step.
func (d *downloadStep) Name() string { return "DownloadPluginStep" }
// Status returns the plugin state for current step.
func (d *downloadStep) Status() acmpb.CurrentPluginStates_StatusValue {
return acmpb.CurrentPluginStates_INSTALLING
}
// ErrorStatus returns the plugin state if download step fails.
func (d *downloadStep) ErrorStatus() acmpb.CurrentPluginStates_StatusValue {
return acmpb.CurrentPluginStates_INSTALL_FAILED
}
// Run downloads a package from GCS and validates the checksum.
func (d *downloadStep) Run(ctx context.Context, _ *Plugin) error {
// Try downloading package d.attempts times with 2 second interval.
policy := retry.Policy{MaxAttempts: d.attempts, BackoffFactor: 1, Jitter: time.Second * 2}
return retry.Run(ctx, policy, func() error {
client := http.Client{
Timeout: d.timeout,
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url, nil)
if err != nil {
return fmt.Errorf("failed to create request for %q: %w", d.url, err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download from %q: %w", d.url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download from %q, bad status: %q", d.url, resp.Status)
}
dir := filepath.Dir(d.targetPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %q: %w", dir, err)
}
f, err := os.Create(d.targetPath)
if err != nil {
return fmt.Errorf("unable to create file %q: %w", d.targetPath, err)
}
defer f.Close()
if _, err = io.Copy(f, resp.Body); err != nil {
return fmt.Errorf("unable to copy response body to file %q: %w", f.Name(), err)
}
calculated, err := file.SHA256FileSum(f.Name())
if err != nil {
return fmt.Errorf("failed to calculate sha256sum of file %q: %w", f.Name(), err)
}
if calculated != d.checksum {
return fmt.Errorf("file %q has different sha256sum: %q != %q", f.Name(), calculated, d.checksum)
}
galog.Debugf("Successfully downloaded %q to %q", d.url, d.targetPath)
return nil
})
}
// unpackStep represents the unpack step of plugin install.
type unpackStep struct {
// archivePath is the path to the archive.
archivePath string
// targetDir is the path to the target directory where archive is unpacked.
targetDir string
}
// Name returns the name of the step.
func (u *unpackStep) Name() string { return "UnpackPluginArchiveStep" }
// Status returns the plugin state for current step.
func (u *unpackStep) Status() acmpb.CurrentPluginStates_StatusValue {
return acmpb.CurrentPluginStates_INSTALLING
}
// ErrorStatus returns the plugin state if unpack step fails.
func (u *unpackStep) ErrorStatus() acmpb.CurrentPluginStates_StatusValue {
return acmpb.CurrentPluginStates_INSTALL_FAILED
}
// Run unpacks to the target directory and deletes the archive file.
func (u *unpackStep) Run(ctx context.Context, p *Plugin) error {
// Make sure we unpack in to a clean directory. No previous unpack state
// is retained on install.
if err := os.RemoveAll(u.targetDir); err != nil {
return fmt.Errorf("failed to cleanup %q: %w", u.targetDir, err)
}
if err := file.UnpackTargzFile(u.archivePath, u.targetDir); err != nil {
return fmt.Errorf("failed to unpack %q to directory %q: %w", u.archivePath, u.targetDir, err)
}
// Cleanup archive file on successful decompression.
if err := os.RemoveAll(u.archivePath); err != nil {
return fmt.Errorf("failed to remove %q: %w", u.archivePath, err)
}
p.InstallPath = u.targetDir
galog.Debugf("Successfully unpacked %q to %q", u.archivePath, u.targetDir)
return nil
}
// generateInstallWorkflow generates the workflow for a plugin installation.
func (m *PluginManager) generateInstallWorkflow(ctx context.Context, req *acmpb.ConfigurePluginStates_ConfigurePlugin) []Step {
var steps []Step
if req.GetAction() != acmpb.ConfigurePluginStates_INSTALL {
return steps
}
steps = append(steps, m.preLaunchWorkflow(ctx, req)...)
steps = append(steps, m.newLaunchStep(req))
return steps
}
func (m *PluginManager) newLaunchStep(req *acmpb.ConfigurePluginStates_ConfigurePlugin) Step {
state := pluginInstallPath(req.GetPlugin().GetName(), req.GetPlugin().GetRevisionId())
l := &launchStep{
entryPath: filepath.Join(state, req.GetPlugin().GetEntryPoint()),
maxMemoryUsage: req.GetManifest().GetMaxMemoryUsageBytes(),
maxCPUUsage: req.GetManifest().GetMaxCpuUsagePercentage(),
startAttempts: int(req.GetManifest().GetStartAttemptCount()),
protocol: m.protocol,
extraArgs: req.GetPlugin().GetArguments(),
}
if req.GetManifest().GetPluginInstallationType() == acmpb.PluginInstallationType_LOCAL_INSTALLATION {
// Since plugin is already present on disk entry point is not prepended with
// state directory (install path as its done for dynamic plugins).
l.entryPath = req.GetPlugin().GetEntryPoint()
}
return l
}
// relaunchWorkflow generates the workflow for a re-launching a plugin.
func relaunchWorkflow(ctx context.Context, p *Plugin) []Step {
// Run stop to make sure plugin process is not alive.
// Relaunch means we're not removing plugin, always set cleanup to false.
s := &stopStep{cleanup: false}
l := &launchStep{
entryPath: p.EntryPath,
maxMemoryUsage: p.Manifest.MaxMemoryUsage,
maxCPUUsage: p.Manifest.MaxCPUUsage,
startAttempts: p.Manifest.StartAttempts,
protocol: p.Protocol,
extraArgs: p.Manifest.LaunchArguments,
}
return []Step{s, l}
}
// preLaunchWorkflow generates the workflow to run before attempting any action
// on a plugin revision.
func (m *PluginManager) preLaunchWorkflow(ctx context.Context, req *acmpb.ConfigurePluginStates_ConfigurePlugin) []Step {
// Plugins that are already on disk (generally installed by package manager)
// don't need download/unpack step, simply launch them.
if req.GetManifest().GetPluginInstallationType() == acmpb.PluginInstallationType_LOCAL_INSTALLATION {
return nil
}
statePath := baseState()
state := pluginInstallPath(req.GetPlugin().GetName(), req.GetPlugin().GetRevisionId())
archivePath := filepath.Join(statePath, req.GetPlugin().GetName()+".tar.gz")
d := &downloadStep{
url: req.GetPlugin().GetGcsSignedUrl(),
targetPath: archivePath,
timeout: time.Duration(req.GetManifest().GetDownloadTimeout().GetSeconds()) * time.Second,
attempts: int(req.GetManifest().GetDownloadAttemptCount()),
checksum: req.GetPlugin().GetChecksum(),
}
u := &unpackStep{
archivePath: archivePath,
targetDir: state,
}
return []Step{d, u}
}