blob: e56d240984cf5d9ca58b5f5261076ba2d4b6daf5 [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
import (
"bytes"
"context"
"crypto/sha256"
"encoding/gob"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"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/boundedlist"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/retry"
pb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
dpb "google.golang.org/protobuf/types/known/durationpb"
structpb "google.golang.org/protobuf/types/known/structpb"
)
const (
// default timeouts for gRPC calls with plugin so Guest Agent does not end up
// waiting for a response forever.
defaultApplyRPCTimeout = time.Second * 5
defaultStatusRPCTimeout = time.Second * 2
// defaultConnectTimeoutTries is the default number of tries for connecting to
// a plugin. The plugin manager will wait for one second between each try.
defaultConnectTimeoutTries = 15
)
// Plugin struct represents the plugin information.
type Plugin struct {
// Name is the current plugin name.
Name string
// Revision is the current plugin revision.
Revision string
// Address is the current address plugin is listening on.
Address string
// InstallPath is the path to the directory where plugin is
// installed/unpacked.
InstallPath string
// EntryPath is the path to the plugin/binary entry point from which its spun
// up.
EntryPath string
// Protocol is the protocol used for communication with the plugin.
Protocol string
// clientMu protects client connection with the plugin.
clientMu sync.RWMutex
// client is grpc client connection with the plugin.
client *grpc.ClientConn
// Manifest is plugin configuration defining various agent/plugin behavior.
Manifest *Manifest
// RuntimeInfo holds plugin runtime information.
RuntimeInfo *RuntimeInfo
}
// RuntimeInfo represent plugin metrics and health check information captured at
// run time. Expect info here to change during plugin execution.
type RuntimeInfo struct {
// statusMu mutex protects concurrent updates to plugin status.
statusMu sync.RWMutex
// status is the current plugin status.
status acmpb.CurrentPluginStates_StatusValue
// healthMu mutex protects plugin health check information.
healthMu sync.Mutex
// health is the current plugin health check information.
health *healthCheck
// metricsMu is a mutex that protects the metrics field.
metricsMu sync.Mutex
// metrics is a list of metrics reported by the plugin.
metrics *boundedlist.List[Metric]
// pidMu mutex protects plugin pid.
pidMu sync.RWMutex
// Pid is the process id of the plugin.
Pid int
// pendingStatusMu mutex protects pendingPluginStatus.
pendingStatusMu sync.Mutex
// pendingPluginStatus is the status of pending plugin revision. This is the
// new revision that is being installed.
pendingPluginStatus *pendingPluginStatus
}
// Manifest is the plugin specific static config agent received from ACP.
type Manifest struct {
// StartAttempts is the number of times to try launching the plugin.
StartAttempts int
// MaxMemoryUsage is the maximum allowed memory usage of the plugin, in bytes.
MaxMemoryUsage int64
// MaxCPUUsage is the maximum allowed percent CPU usage of the plugin.
MaxCPUUsage int32
// MaxMetricDatapoints is the maximum number of datapoints to report/collect.
// Metrics are collected every [MetricsInterval] but are flushed from memory
// only when reported back to the service. This count limits datapoints from
// growing indefinitely.
MaxMetricDatapoints uint
// MetricsInterval is the interval at which metrics are collected.
MetricsInterval time.Duration
// StopTimeout is the timeout set on plugin stop request before process is
// killed.
StopTimeout time.Duration
// StartTimeout is the timeout set on plugin start request.
StartTimeout time.Duration
// StartConfig is the config service has sent down for passing down to the
// plugin on each start RPC request.
StartConfig *ServiceConfig
// startConfigMu mutex protects concurrent updates to startConfig.
startConfigMu sync.Mutex
// startConfigHash is the hash of the start config. This is reported back to
// the control plane to determine if there's a new config or not and applied
// accordingly.
startConfigHash string
// LaunchArguments are extra arguments specified by plugin owners to pass down
// during process launch.
LaunchArguments []string
// PluginType identifies if the plugin is a daemon or one-shot plugin.
PluginType acmpb.PluginType
// PluginInstallationType identifies if the plugin is installed locally or dynamically.
PluginInstallationType acmpb.PluginInstallationType
// ExecutionModel contains the execution model of the plugin.
ExecutionModel *ExecutionModel
}
// ServiceConfig is agent agnostic data that is passed to the plugin on every
// start rpc request. At any given time only one of this can be set.
type ServiceConfig struct {
// Simple is simple string form of the config.
Simple string
// Structured is structured [*structpb.Struct] config message. It is marshaled
// to a byte array to persist across agent restarts and reuse on every plugin
// start request. Agent will unmarshal using [toProto] method before sending
// it to plugins on Start RPC.
Structured []byte
}
// ExecutionModel is the execution model of the plugin.
type ExecutionModel struct {
// DefaultState is the base state of the plugin before any rule evaluation.
DefaultState acmpb.ExecutionModel_State
// MatchState is the state of the plugin if the rule matches.
MatchState acmpb.ExecutionModel_State
// MatchPolicy is the policy to use when evaluating the rules.
MatchPolicy acmpb.ExecutionModel_MatchPolicy
// Rules is the list of rules to evaluate against the host.
Rules []*Rule
// BootCritical is true if the plugin must run immediately at boot after agent
// is initialized.
BootCritical bool
// OneShot is the execution model for one-shot plugins.
OneShot *OneShotExecutionModel
// Daemon is the execution model for daemon plugins.
Daemon *DaemonExecutionModel
}
// Rule is a rule for triggering a plugin.
type Rule struct {
// The namespace is the namespace of the rule.
Scope acmpb.Rules_Scope
// Attribute is the attribute to compare against.
Attribute string
// ComparisonOperator to compare the attribute against the value.
ComparisonOperator acmpb.Rules_ComparisonOperator
// Value is the value to compare against. It is marshaled to a byte array to
// persist across agent restarts and reuse on every plugin start request.
// Agent will unmarshal using [toProto] method.
Value []byte
}
// OneShotExecutionModel is the execution model for one-shot plugins.
type OneShotExecutionModel struct {
// Triggers is the list of triggers to execute the plugin.
Triggers *Triggers
// ExecutionTimeout is the max duration plugin is allowed to run.
ExecutionTimeout time.Duration
}
// Triggers is the list of triggers to execute the plugin.
type Triggers struct {
// Startup is true if the plugin runs every time when the instance boots,
// similar to startup script.
Startup bool
// Firstboot is true if the plugin runs only on the very first startup of an
// instance, triggered by an agent detecting a new instance ID.
Firstboot bool
// Interval is a simple recurring schedule in minutes. Schedule begins at
// instance startup.
Interval time.Duration
}
// DaemonExecutionModel is the execution model for daemon plugins.
type DaemonExecutionModel struct{}
// toProto unmarshals bytes and returns struct proto message representation.
func (c *ServiceConfig) toProto() (*structpb.Struct, error) {
cfg := &structpb.Struct{}
if err := proto.Unmarshal(c.Structured, cfg); err != nil {
return nil, fmt.Errorf("unable to unmarshal struct config bytes: %w", err)
}
return cfg, nil
}
func (p *Plugin) resetPendingStatus() {
p.RuntimeInfo.pendingStatusMu.Lock()
defer p.RuntimeInfo.pendingStatusMu.Unlock()
p.RuntimeInfo.pendingPluginStatus = nil
}
func (p *Plugin) setPendingStatus(revision string, status acmpb.CurrentPluginStates_StatusValue) {
p.RuntimeInfo.pendingStatusMu.Lock()
defer p.RuntimeInfo.pendingStatusMu.Unlock()
p.RuntimeInfo.pendingPluginStatus = &pendingPluginStatus{
revision: revision,
status: status,
}
}
func (p *Plugin) pendingStatus() *pendingPluginStatus {
p.RuntimeInfo.pendingStatusMu.Lock()
defer p.RuntimeInfo.pendingStatusMu.Unlock()
return p.RuntimeInfo.pendingPluginStatus
}
// IsDaemon returns true if the plugin is a daemon plugin.
func (p *Plugin) IsDaemon() bool {
return p.Manifest.PluginType == acmpb.PluginType_DAEMON
}
// IsOneShot returns true if the plugin is a one-shot plugin.
func (p *Plugin) IsOneShot() bool {
return p.Manifest.PluginType == acmpb.PluginType_ONE_SHOT
}
// IsLocal returns true if the plugin is a local plugin.
func (p *Plugin) IsLocal() bool {
return p.Manifest.PluginInstallationType == acmpb.PluginInstallationType_LOCAL_INSTALLATION
}
// IsDynamic returns true if the plugin is a dynamic plugin.
func (p *Plugin) IsDynamic() bool {
return p.Manifest.PluginInstallationType == acmpb.PluginInstallationType_DYNAMIC_INSTALLATION
}
// pendingPluginStatus struct represents the pending plugin status. This is
// set only when a plugin revision is being changed.
type pendingPluginStatus struct {
// revision is the pending plugin revision.
revision string
// status is the pending plugin status.
status acmpb.CurrentPluginStates_StatusValue
}
// healthCheck struct represents the health check information.
type healthCheck struct {
// responseCode is the response code returned by plugin during health check.
responseCode int32
// messages is the list of messages returned by plugin during health check.
// This could include potential error reasons or any info plugins might want
// to report to the service.
messages []string
// timestamp is the timestamp at which the health check was executed.
timestamp time.Time
}
// healthInfo returns the current cached plugin health check information.
func (p *Plugin) healthInfo() *healthCheck {
p.RuntimeInfo.healthMu.Lock()
defer p.RuntimeInfo.healthMu.Unlock()
return p.RuntimeInfo.health
}
// setHealthInfo sets the plugin health check information.
func (p *Plugin) setHealthInfo(h *healthCheck) {
p.RuntimeInfo.healthMu.Lock()
defer p.RuntimeInfo.healthMu.Unlock()
p.RuntimeInfo.health = h
}
// FullName returns the full name of the plugin including name and revision.
func (p *Plugin) FullName() string {
return fmt.Sprintf("%s_%s", p.Name, p.Revision)
}
// setPid sets the current plugin process id.
func (p *Plugin) setPid(pid int) {
p.RuntimeInfo.pidMu.Lock()
defer p.RuntimeInfo.pidMu.Unlock()
p.RuntimeInfo.Pid = pid
}
// pid returns the current plugin process id.
func (p *Plugin) pid() int {
p.RuntimeInfo.pidMu.RLock()
defer p.RuntimeInfo.pidMu.RUnlock()
return p.RuntimeInfo.Pid
}
// setState sets the plugin status.
func (p *Plugin) setState(s acmpb.CurrentPluginStates_StatusValue) {
p.RuntimeInfo.statusMu.Lock()
defer p.RuntimeInfo.statusMu.Unlock()
p.RuntimeInfo.status = s
}
// State returns the plugin status.
func (p *Plugin) State() acmpb.CurrentPluginStates_StatusValue {
p.RuntimeInfo.statusMu.RLock()
defer p.RuntimeInfo.statusMu.RUnlock()
return p.RuntimeInfo.status
}
// runSteps runs the steps in the order they are given.
func (p *Plugin) runSteps(ctx context.Context, steps []Step) error {
for _, step := range steps {
galog.Debugf("Running %q on plugin %q", step.Name(), p.FullName())
p.setState(step.Status())
if err := ctx.Err(); err != nil {
p.setState(step.ErrorStatus())
return fmt.Errorf("%q failed, context error: %w", step.Name(), err)
}
if err := step.Run(ctx, p); err != nil {
p.setState(step.ErrorStatus())
return fmt.Errorf("%q failed with error: %w", step.Name(), err)
}
}
return nil
}
// PluginService returns the underlying plugin service client.
func (p *Plugin) PluginService() (pb.GuestAgentPluginClient, error) {
p.clientMu.RLock()
defer p.clientMu.RUnlock()
if p.client == nil {
return nil, fmt.Errorf("plugin %q is not connected", p.FullName())
}
return pb.NewGuestAgentPluginClient(p.client), nil
}
// buildStartRequest generates Start RPC request based on what service config
// was supplied during install.
func (p *Plugin) buildStartRequest(ctx context.Context) (*pb.StartRequest, error) {
req := &pb.StartRequest{
Config: &pb.StartRequest_Config{StateDirectoryPath: p.stateDir()},
}
if p.Manifest.StartConfig == nil {
return req, nil
}
// Start config is optional and may not be present.
if len(p.Manifest.StartConfig.Simple) != 0 {
req.ServiceConfig = &pb.StartRequest_StringConfig{StringConfig: p.Manifest.StartConfig.Simple}
} else if len(p.Manifest.StartConfig.Structured) != 0 {
c, err := p.Manifest.StartConfig.toProto()
if err != nil {
return nil, fmt.Errorf("unable to generate start request for %q plugin: %w", p.FullName(), err)
}
req.ServiceConfig = &pb.StartRequest_StructConfig{StructConfig: c}
}
return req, nil
}
// Start makes plugin RPC start request.
func (p *Plugin) Start(ctx context.Context) (*pb.StartResponse, *status.Status) {
galog.Debugf("Executing start request on plugin %q", p.FullName())
svc, err := p.PluginService()
if err != nil {
return nil, status.Convert(fmt.Errorf("cannot call start on plugin %q: %w", p.FullName(), err))
}
policy := retry.Policy{MaxAttempts: p.Manifest.StartAttempts, BackoffFactor: 1, Jitter: time.Second}
req, err := p.buildStartRequest(ctx)
if err != nil {
return nil, status.Convert(err)
}
tCtx, cancel := context.WithTimeout(ctx, p.Manifest.StartTimeout)
defer cancel()
f := func() (*pb.StartResponse, error) {
return svc.Start(tCtx, req, grpc.WaitForReady(true))
}
resp, err := retry.RunWithResponse(tCtx, policy, f)
return resp, status.Convert(err)
}
// Stop makes plugin RPC stop request.
func (p *Plugin) Stop(ctx context.Context, cleanup bool) (*pb.StopResponse, *status.Status) {
galog.Debugf("Executing stop request on plugin %q", p.FullName())
svc, err := p.PluginService()
if err != nil {
return nil, status.Convert(fmt.Errorf("cannot call stop on plugin %q: %w", p.FullName(), err))
}
req := &pb.StopRequest{
Cleanup: cleanup,
Deadline: &dpb.Duration{Seconds: int64(p.Manifest.StopTimeout.Seconds())},
}
tCtx, cancel := context.WithTimeout(ctx, p.Manifest.StopTimeout)
defer cancel()
resp, err := svc.Stop(tCtx, req, grpc.WaitForReady(true))
return resp, status.Convert(err)
}
// Apply makes plugin RPC apply request. Function accepts a service config
// which is passed down to the plugin with the apply request instead of using
// the one from the plugin manifest as it allows sending adhoc configs that
// can be used for one off operations without having to update the plugin.
// For example, this can be used to trigger VM event on plugins that support it.
func (p *Plugin) Apply(ctx context.Context, serviceConfig *ServiceConfig) (*pb.ApplyResponse, *status.Status) {
galog.Debugf("Executing apply request on plugin %q", p.FullName())
req, err := p.buildApplyRequest(serviceConfig)
if err != nil {
return nil, status.Convert(err)
}
svc, err := p.PluginService()
if err != nil {
return nil, status.Convert(fmt.Errorf("cannot call apply on plugin %q: %w", p.FullName(), err))
}
tCtx, cancel := context.WithTimeout(ctx, defaultApplyRPCTimeout)
defer cancel()
resp, err := svc.Apply(tCtx, req, grpc.WaitForReady(true))
return resp, status.Convert(err)
}
// buildApplyRequest generates Apply RPC request based on what service config
// was supplied.
func (p *Plugin) buildApplyRequest(serviceConfig *ServiceConfig) (*pb.ApplyRequest, error) {
req := &pb.ApplyRequest{}
if serviceConfig == nil {
return req, nil
}
// Start config is optional and may not be present.
if len(serviceConfig.Simple) != 0 {
req.ServiceConfig = &pb.ApplyRequest_StringConfig{StringConfig: serviceConfig.Simple}
} else if len(serviceConfig.Structured) != 0 {
c, err := serviceConfig.toProto()
if err != nil {
return nil, fmt.Errorf("unable to generate apply request for %q plugin: %w", p.FullName(), err)
}
req.ServiceConfig = &pb.ApplyRequest_StructConfig{StructConfig: c}
}
return req, nil
}
// GetStatus makes the GetStatus RPC request, [req] provides the
// context on what the request is about. For e.g. if we want status for task A,
// context could be task ID. For regular health check leave it empty.
func (p *Plugin) GetStatus(ctx context.Context, req string) (*pb.Status, *status.Status) {
galog.Debugf("Executing get status request (%s) on plugin %q", req, p.FullName())
var data *string
tCtx, cancel := context.WithTimeout(ctx, defaultStatusRPCTimeout)
defer cancel()
if req != "" {
data = proto.String(req)
}
svc, err := p.PluginService()
if err != nil {
return nil, status.Convert(fmt.Errorf("cannot call get status on plugin %q: %w", p.FullName(), err))
}
r := &pb.GetStatusRequest{Data: data}
resp, err := svc.GetStatus(tCtx, r, grpc.WaitForReady(true))
return resp, status.Convert(err)
}
// connectAddress returns the address to connect to based on protocol.
// Refer https://github.com/grpc/grpc/blob/master/doc/naming.md for address
// naming convention.
func (p *Plugin) connectAddress() string {
if p.Protocol == tcpProtocol {
return p.Address
}
return fmt.Sprintf("%s:%s", udsProtocol, p.Address)
}
// Connect tries to establish grpc connection to the plugin server.
func (p *Plugin) Connect(ctx context.Context) error {
galog.Debugf("Dialing in on plugin %q", p.FullName())
p.clientMu.Lock()
defer p.clientMu.Unlock()
if p.client != nil {
// Close the previous client connection before attempting to reconnect.
p.client.Close()
p.client = nil
}
options := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
conn, err := grpc.NewClient(p.connectAddress(), options...)
if err != nil {
return fmt.Errorf("failed to dial on %q: %w", p.Address, err)
}
p.client = conn
return nil
}
// stateFile returns the path to the state file for this plugin.
func (p *Plugin) stateFile() string {
return filepath.Join(agentPluginState(), p.Name+".gob")
}
// Store writes plugin information to the file.
func (p *Plugin) Store() error {
fname := p.stateFile()
if err := os.MkdirAll(filepath.Dir(fname), 0755); err != nil {
return fmt.Errorf("unable to create directory %q: %w", filepath.Dir(fname), err)
}
b := new(bytes.Buffer)
err := gob.NewEncoder(b).Encode(p)
if err != nil {
return fmt.Errorf("unable to encode plugin: %w", err)
}
fh, err := os.OpenFile(fname, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("open file %q: %w", fname, err)
}
defer fh.Close()
if _, err := fh.Write(b.Bytes()); err != nil {
return fmt.Errorf("write plugin info to %q: %w", fname, err)
}
galog.V(2).Debugf("Sucessfully wrote plugin info to %q", fname)
return nil
}
// IsRunning checks if the plugin is running by reconnecting and executing a
// health check.
func (p *Plugin) IsRunning(ctx context.Context) bool {
galog.Debugf("Checking if plugin %q is running", p.FullName())
if err := p.Connect(ctx); err != nil {
galog.Debugf("Failed to connect to plugin %q: %v", p.FullName(), err)
return false
}
_, e := p.GetStatus(ctx, "")
if e.Err() != nil {
// Health check failed, plugin is probably not running.
galog.Debugf("Plugin health check failed, unable to get status of plugin %q: %+v", p.FullName(), e)
return false
}
return true
}
// stateDir returns the path to the scratch directory for this plugin. This
// is the directory where the plugin can store any state that it needs to
// persist across revisions.
func (p *Plugin) stateDir() string {
return filepath.Join(baseState(), agentStateDir, pluginInstallDir, p.Name)
}
// logfile returns the path to the log file for this plugin. These logs are
// written by the plugin which agent collects and pushes it out to the ACS when
// any plugin crash is detected.
// This log file not meant for general logging, but only for error logs plugins
// would want agent to collect and send to ACS. After every flush this log file
// is truncated.
func (p *Plugin) logfile() string {
return filepath.Join(p.stateDir(), "plugin.log")
}
// staticInstallPath returns the install path which remains the same across
// revisions. This will eventually become a symlink to latest running plugin
// revision.
func (p *Plugin) staticInstallPath() string {
return filepath.Join(baseState(), pluginInstallDir, p.Name)
}
// configHash returns the sha256 hash of the config applied to the plugin during
// the last start. If the config is nil it will return an empty string. If the
// hash is already computed, it will return the cached hash.
func (p *Plugin) configHash() string {
p.Manifest.startConfigMu.Lock()
defer p.Manifest.startConfigMu.Unlock()
if p.Manifest.startConfigHash != "" || p.Manifest.StartConfig == nil {
galog.V(2).Debugf("Returning cached start config hash or start config is nil for plugin %q, hash: %q", p.FullName(), p.Manifest.startConfigHash)
return p.Manifest.startConfigHash
}
var data []byte
// Either simple or structured config is expected to be present. This is
// enforced by [Manifest.Config] proto message.
if len(p.Manifest.StartConfig.Simple) != 0 {
data = []byte(p.Manifest.StartConfig.Simple)
galog.Debugf("Computing start config hash from string config for plugin %q", p.FullName())
} else if len(p.Manifest.StartConfig.Structured) != 0 {
data = p.Manifest.StartConfig.Structured
galog.Debugf("Computing start config hash from structured config for plugin %q", p.FullName())
} else {
galog.Debugf("Start config is empty for plugin %q, skipping hash computation", p.FullName())
return ""
}
hash := sha256.Sum256(data)
p.Manifest.startConfigHash = hex.EncodeToString(hash[:])
galog.Debugf("Updated start config hash for plugin %q", p.FullName())
return p.Manifest.startConfigHash
}