package agentapi import ( "context" "database/sql" "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 AgentFn func(context.Context) (database.WorkspaceAgent, error) WorkspaceIDFn func(context.Context, *database.WorkspaceAgent) (uuid.UUID, error) Database database.Store DerpMapFn func() *tailcfg.DERPMap } func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifestRequest) (*agentproto.Manifest, error) { workspaceAgent, err := a.AgentFn(ctx) if err != nil { return nil, err } workspaceID, err := a.WorkspaceIDFn(ctx, &workspaceAgent) if err != nil { return nil, err } var ( dbApps []database.WorkspaceApp scripts []database.WorkspaceAgentScript metadata []database.WorkspaceAgentMetadatum workspace database.Workspace owner database.User ) 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, workspaceID) if err != nil { return xerrors.Errorf("getting workspace by id: %w", err) } owner, err = a.Database.GetUserByID(ctx, workspace.OwnerID) if err != nil { return xerrors.Errorf("getting workspace owner by id: %w", err) } return err }) err = eg.Wait() if err != nil { return nil, xerrors.Errorf("fetching workspace agent data: %w", err) } appSlug := appurl.ApplicationURL{ AppSlugOrPort: "{{port}}", AgentName: workspaceAgent.Name, WorkspaceName: workspace.Name, Username: owner.Username, } 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, owner.Username, workspace) if err != nil { return nil, xerrors.Errorf("converting workspace apps: %w", err) } return &agentproto.Manifest{ AgentId: workspaceAgent.ID[:], AgentName: workspaceAgent.Name, OwnerUsername: owner.Username, WorkspaceId: workspace.ID[:], WorkspaceName: workspace.Name, GitAuthConfigs: gitAuthConfigs, EnvironmentVariables: envs, Directory: workspaceAgent.Directory, VsCodePortProxyUri: vscodeProxyURI, MotdPath: workspaceAgent.MOTDFile, DisableDirectConnections: a.DisableDirectConnections, DerpForceWebsockets: a.DerpForceWebSockets, DerpMap: tailnet.DERPMapToProto(a.DerpMapFn()), Scripts: dbAgentScriptsToProto(scripts), Apps: apps, Metadata: dbAgentMetadataToProtoDescription(metadata), }, 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.WorkspaceAgentScript) []*agentproto.WorkspaceAgentScript { ret := make([]*agentproto.WorkspaceAgentScript, len(scripts)) for i, script := range scripts { ret[i] = dbAgentScriptToProto(script) } return ret } func dbAgentScriptToProto(script database.WorkspaceAgentScript) *agentproto.WorkspaceAgentScript { return &agentproto.WorkspaceAgentScript{ 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), } } func dbAppsToProto(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) ([]*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) 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) (*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) } 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: db2sdk.AppSubdomain(dbApp, agent.Name, workspace.Name, ownerName), 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), }, nil }