mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: include script dependencies in agent manifests Send persisted script Terraform addresses and dependency lists on every manifest request, preserving them across agent restarts, reconnects, and shutdown without rebuilding the workspace. - Add `resource_address` and `dependencies` to the agent proto, with `SUCCESS` and `COMPLETION` requirements. - Reuse shared database-to-SDK validation and add bidirectional SDK/proto conversion. Reject empty prerequisite addresses and invalid requirements rather than accepting partial lists. - Bump the agent API to v2.13 and update the agent's RPC connection. Older agents ignore the new fields; older servers return empty fields. - Test manifest delivery, subagent isolation, round trips, invalid dependencies, and unchanged encoding when the new fields are unset. Script execution is unchanged; ordering enforcement follows in PLAT-549. Production dependency lists remain empty until #30556 merges. Stacked on #30625. Refs PLAT-548.
363 lines
12 KiB
Go
363 lines
12 KiB
Go
package agentapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/xerrors"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
"tailscale.com/tailcfg"
|
|
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/externalauth"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
)
|
|
|
|
type ManifestAPI struct {
|
|
AccessURL *url.URL
|
|
AppHostname string
|
|
ExternalAuthConfigs []*externalauth.Config
|
|
DisableDirectConnections bool
|
|
DerpForceWebSockets bool
|
|
DisableUserSecretFilePath bool
|
|
WorkspaceID uuid.UUID
|
|
|
|
AgentFn func(ctx context.Context) (database.WorkspaceAgent, error)
|
|
Database database.Store
|
|
DerpMapFn func() *tailcfg.DERPMap
|
|
}
|
|
|
|
func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
|
|
var (
|
|
dbApps []database.WorkspaceApp
|
|
scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow
|
|
metadata []database.WorkspaceAgentMetadatum
|
|
workspace database.Workspace
|
|
devcontainers []database.WorkspaceAgentDevcontainer
|
|
)
|
|
|
|
workspaceAgent, err := a.AgentFn(ctx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("getting workspace agent: %w", err)
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
eg.Go(func() (err error) {
|
|
dbApps, err = a.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() (err error) {
|
|
// nolint:gocritic // This is necessary to fetch agent scripts!
|
|
scripts, err = a.Database.GetWorkspaceAgentScriptsByAgentIDs(dbauthz.AsSystemRestricted(ctx), []uuid.UUID{workspaceAgent.ID})
|
|
return err
|
|
})
|
|
eg.Go(func() (err error) {
|
|
metadata, err = a.Database.GetWorkspaceAgentMetadata(ctx, database.GetWorkspaceAgentMetadataParams{
|
|
WorkspaceAgentID: workspaceAgent.ID,
|
|
Keys: nil, // all
|
|
})
|
|
return err
|
|
})
|
|
eg.Go(func() (err error) {
|
|
workspace, err = a.Database.GetWorkspaceByID(ctx, a.WorkspaceID)
|
|
if err != nil {
|
|
return xerrors.Errorf("getting workspace by id: %w", err)
|
|
}
|
|
return err
|
|
})
|
|
eg.Go(func() (err error) {
|
|
devcontainers, err = a.Database.GetWorkspaceAgentDevcontainersByAgentID(ctx, workspaceAgent.ID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
err = eg.Wait()
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("fetching workspace agent data: %w", err)
|
|
}
|
|
|
|
// Fetch user secrets for injection into the agent manifest.
|
|
// This runs after the errgroup because it needs workspace.OwnerID.
|
|
//nolint:gocritic // System context needed to read secrets for the workspace owner.
|
|
userSecrets, err := a.Database.ListUserSecretsWithValues(dbauthz.AsSystemRestricted(ctx), workspace.OwnerID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("getting user secrets: %w", err)
|
|
}
|
|
|
|
appSlug := appurl.ApplicationURL{
|
|
AppSlugOrPort: "{{port}}",
|
|
AgentName: workspaceAgent.Name,
|
|
WorkspaceName: workspace.Name,
|
|
Username: workspace.OwnerUsername,
|
|
}
|
|
|
|
vscodeProxyURI := vscodeProxyURI(appSlug, a.AccessURL, a.AppHostname)
|
|
|
|
envs, err := db2sdk.WorkspaceAgentEnvironment(workspaceAgent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var gitAuthConfigs uint32
|
|
for _, cfg := range a.ExternalAuthConfigs {
|
|
if codersdk.EnhancedExternalAuthProvider(cfg.Type).Git() {
|
|
gitAuthConfigs++
|
|
}
|
|
}
|
|
|
|
apps, err := dbAppsToProto(dbApps, workspaceAgent, workspace.OwnerUsername, workspace, a.AppHostname)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("converting workspace apps: %w", err)
|
|
}
|
|
|
|
protoScripts, err := dbAgentScriptsToProto(scripts)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("converting workspace agent scripts: %w", err)
|
|
}
|
|
|
|
var parentID []byte
|
|
if workspaceAgent.ParentID.Valid {
|
|
parentID = workspaceAgent.ParentID.UUID[:]
|
|
}
|
|
|
|
secretFilePathPolicy := userSecretFilePathAllowed
|
|
if a.DisableUserSecretFilePath {
|
|
secretFilePathPolicy = userSecretFilePathBlocked
|
|
}
|
|
|
|
return &agentproto.Manifest{
|
|
AgentId: workspaceAgent.ID[:],
|
|
AgentName: workspaceAgent.Name,
|
|
OwnerUsername: workspace.OwnerUsername,
|
|
WorkspaceId: workspace.ID[:],
|
|
WorkspaceName: workspace.Name,
|
|
GitAuthConfigs: gitAuthConfigs,
|
|
EnvironmentVariables: envs,
|
|
Directory: workspaceAgent.Directory,
|
|
VsCodePortProxyUri: vscodeProxyURI,
|
|
MotdPath: workspaceAgent.MOTDFile,
|
|
DisableDirectConnections: a.DisableDirectConnections,
|
|
DerpForceWebsockets: a.DerpForceWebSockets,
|
|
ParentId: parentID,
|
|
|
|
DerpMap: tailnet.DERPMapToProto(a.DerpMapFn()),
|
|
Scripts: protoScripts,
|
|
Apps: apps,
|
|
Metadata: dbAgentMetadataToProtoDescription(metadata),
|
|
Devcontainers: dbAgentDevcontainersToProto(devcontainers),
|
|
Secrets: dbUserSecretsToProto(userSecrets, secretFilePathPolicy),
|
|
}, nil
|
|
}
|
|
|
|
func vscodeProxyURI(app appurl.ApplicationURL, accessURL *url.URL, appHost string) string {
|
|
// Proxying by port only works for subdomains. If subdomain support is not
|
|
// available, return an empty string.
|
|
if appHost == "" {
|
|
return ""
|
|
}
|
|
|
|
// This will handle the ports from the accessURL or appHost.
|
|
appHost = appurl.SubdomainAppHost(appHost, accessURL)
|
|
// Return the url with a scheme and any wildcards replaced with the app slug.
|
|
return accessURL.Scheme + "://" + strings.ReplaceAll(appHost, "*", app.String())
|
|
}
|
|
|
|
func dbAgentMetadataToProtoDescription(metadata []database.WorkspaceAgentMetadatum) []*agentproto.WorkspaceAgentMetadata_Description {
|
|
ret := make([]*agentproto.WorkspaceAgentMetadata_Description, len(metadata))
|
|
for i, metadatum := range metadata {
|
|
ret[i] = dbAgentMetadatumToProtoDescription(metadatum)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func dbAgentMetadatumToProtoDescription(metadatum database.WorkspaceAgentMetadatum) *agentproto.WorkspaceAgentMetadata_Description {
|
|
return &agentproto.WorkspaceAgentMetadata_Description{
|
|
DisplayName: metadatum.DisplayName,
|
|
Key: metadatum.Key,
|
|
Script: metadatum.Script,
|
|
Interval: durationpb.New(time.Duration(metadatum.Interval)),
|
|
Timeout: durationpb.New(time.Duration(metadatum.Timeout)),
|
|
}
|
|
}
|
|
|
|
func dbAgentScriptsToProto(scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow) ([]*agentproto.WorkspaceAgentScript, error) {
|
|
ret := make([]*agentproto.WorkspaceAgentScript, len(scripts))
|
|
for i, script := range scripts {
|
|
converted, err := dbAgentScriptToProto(script)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("script %s: %w", script.ID, err)
|
|
}
|
|
ret[i] = converted
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
// dbAgentScriptToProto goes through db2sdk.WorkspaceAgentScript so the
|
|
// manifest and the REST API agree on what a valid dependency list is.
|
|
func dbAgentScriptToProto(script database.GetWorkspaceAgentScriptsByAgentIDsRow) (*agentproto.WorkspaceAgentScript, error) {
|
|
sdkScript, err := db2sdk.WorkspaceAgentScript(script)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var dependencies []*agentproto.WorkspaceAgentScriptDependency
|
|
if len(sdkScript.Dependencies) > 0 {
|
|
dependencies = make([]*agentproto.WorkspaceAgentScriptDependency, len(sdkScript.Dependencies))
|
|
}
|
|
for i, dependency := range sdkScript.Dependencies {
|
|
requirement, err := workspaceAgentScriptDependencyRequirementToProto(dependency.Requirement)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("dependency %d: %w", i, err)
|
|
}
|
|
dependencies[i] = &agentproto.WorkspaceAgentScriptDependency{
|
|
PrerequisiteResourceAddress: dependency.PrerequisiteResourceAddress,
|
|
Requirement: requirement,
|
|
}
|
|
}
|
|
|
|
return &agentproto.WorkspaceAgentScript{
|
|
Id: script.ID[:],
|
|
LogSourceId: script.LogSourceID[:],
|
|
LogPath: script.LogPath,
|
|
Script: script.Script,
|
|
Cron: script.Cron,
|
|
RunOnStart: script.RunOnStart,
|
|
RunOnStop: script.RunOnStop,
|
|
StartBlocksLogin: script.StartBlocksLogin,
|
|
Timeout: durationpb.New(time.Duration(script.TimeoutSeconds) * time.Second),
|
|
ResourceAddress: sdkScript.ResourceAddress,
|
|
Dependencies: dependencies,
|
|
}, nil
|
|
}
|
|
|
|
func workspaceAgentScriptDependencyRequirementToProto(requirement codersdk.WorkspaceAgentScriptDependencyRequirement) (agentproto.WorkspaceAgentScriptDependency_Requirement, error) {
|
|
switch requirement {
|
|
case codersdk.WorkspaceAgentScriptDependencyRequirementSuccess:
|
|
return agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_SUCCESS, nil
|
|
case codersdk.WorkspaceAgentScriptDependencyRequirementCompletion:
|
|
return agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_COMPLETION, nil
|
|
default:
|
|
// db2sdk already rejected anything else. The error stays so a new
|
|
// requirement can never fall through to UNSPECIFIED unnoticed.
|
|
return agentproto.WorkspaceAgentScriptDependency_REQUIREMENT_UNSPECIFIED, xerrors.Errorf("unsupported requirement %q", requirement)
|
|
}
|
|
}
|
|
|
|
func dbAppsToProto(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace, appHostname string) ([]*agentproto.WorkspaceApp, error) {
|
|
ret := make([]*agentproto.WorkspaceApp, len(dbApps))
|
|
for i, dbApp := range dbApps {
|
|
var err error
|
|
ret[i], err = dbAppToProto(dbApp, agent, ownerName, workspace, appHostname)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse app %v (%q): %w", i, dbApp.Slug, err)
|
|
}
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
func dbAppToProto(dbApp database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace, appHostname string) (*agentproto.WorkspaceApp, error) {
|
|
sharingLevelRaw, ok := agentproto.WorkspaceApp_SharingLevel_value[strings.ToUpper(string(dbApp.SharingLevel))]
|
|
if !ok {
|
|
return nil, xerrors.Errorf("unknown app sharing level: %q", dbApp.SharingLevel)
|
|
}
|
|
|
|
healthRaw, ok := agentproto.WorkspaceApp_Health_value[strings.ToUpper(string(dbApp.Health))]
|
|
if !ok {
|
|
return nil, xerrors.Errorf("unknown app health: %q", dbApp.SharingLevel)
|
|
}
|
|
|
|
// SubdomainName should be empty if AppHostname is not configured
|
|
subdomainName := ""
|
|
if appHostname != "" {
|
|
subdomainName = db2sdk.AppSubdomain(dbApp, agent.Name, workspace.Name, ownerName)
|
|
}
|
|
|
|
return &agentproto.WorkspaceApp{
|
|
Id: dbApp.ID[:],
|
|
Url: dbApp.Url.String,
|
|
External: dbApp.External,
|
|
Slug: dbApp.Slug,
|
|
DisplayName: dbApp.DisplayName,
|
|
Command: dbApp.Command.String,
|
|
Icon: dbApp.Icon,
|
|
Subdomain: dbApp.Subdomain,
|
|
SubdomainName: subdomainName,
|
|
SharingLevel: agentproto.WorkspaceApp_SharingLevel(sharingLevelRaw),
|
|
Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
|
|
Url: dbApp.HealthcheckUrl,
|
|
Interval: durationpb.New(time.Duration(dbApp.HealthcheckInterval) * time.Second),
|
|
Threshold: dbApp.HealthcheckThreshold,
|
|
},
|
|
Health: agentproto.WorkspaceApp_Health(healthRaw),
|
|
Hidden: dbApp.Hidden,
|
|
}, nil
|
|
}
|
|
|
|
func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevcontainer) []*agentproto.WorkspaceAgentDevcontainer {
|
|
ret := make([]*agentproto.WorkspaceAgentDevcontainer, len(devcontainers))
|
|
for i, dc := range devcontainers {
|
|
var subagentID []byte
|
|
if dc.SubagentID.Valid {
|
|
subagentID = dc.SubagentID.UUID[:]
|
|
}
|
|
|
|
ret[i] = &agentproto.WorkspaceAgentDevcontainer{
|
|
Id: dc.ID[:],
|
|
Name: dc.Name,
|
|
WorkspaceFolder: dc.WorkspaceFolder,
|
|
ConfigPath: dc.ConfigPath,
|
|
SubagentId: subagentID,
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// userSecretFilePathPolicy is a named type rather than a bool parameter to
|
|
// satisfy revive's flag-parameter rule.
|
|
type userSecretFilePathPolicy int
|
|
|
|
const (
|
|
userSecretFilePathAllowed userSecretFilePathPolicy = iota
|
|
userSecretFilePathBlocked
|
|
)
|
|
|
|
func dbUserSecretsToProto(secrets []database.UserSecret, policy userSecretFilePathPolicy) []*agentproto.WorkspaceSecret {
|
|
ret := make([]*agentproto.WorkspaceSecret, 0, len(secrets))
|
|
for _, s := range secrets {
|
|
// Skip disabled secrets so they are not injected as env vars or
|
|
// written to secret files. The API guarantees every enabled
|
|
// secret has at least one of env_name or file_path set, so we
|
|
// don't need to filter both-empty rows separately here.
|
|
if !s.Enabled {
|
|
continue
|
|
}
|
|
filePath := s.FilePath
|
|
if policy == userSecretFilePathBlocked {
|
|
if s.EnvName == "" {
|
|
continue
|
|
}
|
|
filePath = ""
|
|
}
|
|
ret = append(ret, &agentproto.WorkspaceSecret{
|
|
EnvName: s.EnvName,
|
|
FilePath: filePath,
|
|
Value: []byte(s.Value),
|
|
})
|
|
}
|
|
return ret
|
|
}
|