Files
coder/codersdk/agentsdk/convert.go
T
Spike Curtis 393b3874ac feat: add UpdateAppStatus to the workspace agent API (#22219)
<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->

part of https://github.com/coder/coder/issues/21335  
  
This moves updating app status (used by Tasks) into the workspace agent
API over dRPC. This will allow us to update the status without having to
re-authenticate each time, like we would with an HTTP PATCH request.
  
Further PRs in this stack will pipe these requests thru from the CLI MCP
server to the agentsock and finally to this dRPC call to coderd.
2026-02-24 13:26:55 +04:00

480 lines
16 KiB
Go

package agentsdk
import (
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
)
func ManifestFromProto(manifest *proto.Manifest) (Manifest, error) {
parentID := uuid.Nil
if pid := manifest.GetParentId(); pid != nil {
var err error
parentID, err = uuid.FromBytes(pid)
if err != nil {
return Manifest{}, xerrors.Errorf("error converting workspace agent parent ID: %w", err)
}
}
apps, err := AppsFromProto(manifest.Apps)
if err != nil {
return Manifest{}, xerrors.Errorf("error converting workspace agent apps: %w", err)
}
scripts, err := AgentScriptsFromProto(manifest.Scripts)
if err != nil {
return Manifest{}, xerrors.Errorf("error converting workspace agent scripts: %w", err)
}
agentID, err := uuid.FromBytes(manifest.AgentId)
if err != nil {
return Manifest{}, xerrors.Errorf("error converting workspace agent ID: %w", err)
}
workspaceID, err := uuid.FromBytes(manifest.WorkspaceId)
if err != nil {
return Manifest{}, xerrors.Errorf("error converting workspace ID: %w", err)
}
devcontainers, err := DevcontainersFromProto(manifest.Devcontainers)
if err != nil {
return Manifest{}, xerrors.Errorf("error converting workspace agent devcontainers: %w", err)
}
return Manifest{
ParentID: parentID,
AgentID: agentID,
AgentName: manifest.AgentName,
OwnerName: manifest.OwnerUsername,
WorkspaceID: workspaceID,
WorkspaceName: manifest.WorkspaceName,
Apps: apps,
Scripts: scripts,
DERPMap: tailnet.DERPMapFromProto(manifest.DerpMap),
DERPForceWebSockets: manifest.DerpForceWebsockets,
GitAuthConfigs: int(manifest.GitAuthConfigs),
EnvironmentVariables: manifest.EnvironmentVariables,
Directory: manifest.Directory,
VSCodePortProxyURI: manifest.VsCodePortProxyUri,
MOTDFile: manifest.MotdPath,
DisableDirectConnections: manifest.DisableDirectConnections,
Metadata: MetadataDescriptionsFromProto(manifest.Metadata),
Devcontainers: devcontainers,
}, nil
}
func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) {
apps, err := ProtoFromApps(manifest.Apps)
if err != nil {
return nil, xerrors.Errorf("convert workspace apps: %w", err)
}
return &proto.Manifest{
ParentId: manifest.ParentID[:],
AgentId: manifest.AgentID[:],
AgentName: manifest.AgentName,
OwnerUsername: manifest.OwnerName,
WorkspaceId: manifest.WorkspaceID[:],
WorkspaceName: manifest.WorkspaceName,
// #nosec G115 - Safe conversion for GitAuthConfigs which is expected to be small and positive
GitAuthConfigs: uint32(manifest.GitAuthConfigs),
EnvironmentVariables: manifest.EnvironmentVariables,
Directory: manifest.Directory,
VsCodePortProxyUri: manifest.VSCodePortProxyURI,
MotdPath: manifest.MOTDFile,
DisableDirectConnections: manifest.DisableDirectConnections,
DerpForceWebsockets: manifest.DERPForceWebSockets,
DerpMap: tailnet.DERPMapToProto(manifest.DERPMap),
Scripts: ProtoFromScripts(manifest.Scripts),
Apps: apps,
Metadata: ProtoFromMetadataDescriptions(manifest.Metadata),
Devcontainers: ProtoFromDevcontainers(manifest.Devcontainers),
}, nil
}
func MetadataDescriptionsFromProto(descriptions []*proto.WorkspaceAgentMetadata_Description) []codersdk.WorkspaceAgentMetadataDescription {
ret := make([]codersdk.WorkspaceAgentMetadataDescription, len(descriptions))
for i, description := range descriptions {
ret[i] = MetadataDescriptionFromProto(description)
}
return ret
}
func ProtoFromMetadataDescriptions(descriptions []codersdk.WorkspaceAgentMetadataDescription) []*proto.WorkspaceAgentMetadata_Description {
ret := make([]*proto.WorkspaceAgentMetadata_Description, len(descriptions))
for i, d := range descriptions {
ret[i] = ProtoFromMetadataDescription(d)
}
return ret
}
func MetadataDescriptionFromProto(description *proto.WorkspaceAgentMetadata_Description) codersdk.WorkspaceAgentMetadataDescription {
return codersdk.WorkspaceAgentMetadataDescription{
DisplayName: description.DisplayName,
Key: description.Key,
Script: description.Script,
Interval: int64(description.Interval.AsDuration()),
Timeout: int64(description.Timeout.AsDuration()),
}
}
func ProtoFromMetadataDescription(d codersdk.WorkspaceAgentMetadataDescription) *proto.WorkspaceAgentMetadata_Description {
return &proto.WorkspaceAgentMetadata_Description{
DisplayName: d.DisplayName,
Key: d.Key,
Script: d.Script,
Interval: durationpb.New(time.Duration(d.Interval)),
Timeout: durationpb.New(time.Duration(d.Timeout)),
}
}
func ProtoFromMetadataResult(r codersdk.WorkspaceAgentMetadataResult) *proto.WorkspaceAgentMetadata_Result {
return &proto.WorkspaceAgentMetadata_Result{
CollectedAt: timestamppb.New(r.CollectedAt),
Age: r.Age,
Value: r.Value,
Error: r.Error,
}
}
func MetadataResultFromProto(r *proto.WorkspaceAgentMetadata_Result) codersdk.WorkspaceAgentMetadataResult {
return codersdk.WorkspaceAgentMetadataResult{
CollectedAt: r.GetCollectedAt().AsTime(),
Age: r.GetAge(),
Value: r.GetValue(),
Error: r.GetError(),
}
}
func MetadataFromProto(m *proto.Metadata) Metadata {
return Metadata{
Key: m.GetKey(),
WorkspaceAgentMetadataResult: MetadataResultFromProto(m.GetResult()),
}
}
func AgentScriptsFromProto(protoScripts []*proto.WorkspaceAgentScript) ([]codersdk.WorkspaceAgentScript, error) {
ret := make([]codersdk.WorkspaceAgentScript, len(protoScripts))
for i, protoScript := range protoScripts {
app, err := AgentScriptFromProto(protoScript)
if err != nil {
return nil, xerrors.Errorf("parse script %v: %w", i, err)
}
ret[i] = app
}
return ret, nil
}
func ProtoFromScripts(scripts []codersdk.WorkspaceAgentScript) []*proto.WorkspaceAgentScript {
ret := make([]*proto.WorkspaceAgentScript, len(scripts))
for i, script := range scripts {
ret[i] = ProtoFromScript(script)
}
return ret
}
func AgentScriptFromProto(protoScript *proto.WorkspaceAgentScript) (codersdk.WorkspaceAgentScript, error) {
id, err := uuid.FromBytes(protoScript.Id)
if err != nil {
return codersdk.WorkspaceAgentScript{}, xerrors.Errorf("parse id: %w", err)
}
logSourceID, err := uuid.FromBytes(protoScript.LogSourceId)
if err != nil {
return codersdk.WorkspaceAgentScript{}, xerrors.Errorf("parse log source id: %w", err)
}
return codersdk.WorkspaceAgentScript{
ID: id,
LogSourceID: logSourceID,
LogPath: protoScript.LogPath,
Script: protoScript.Script,
Cron: protoScript.Cron,
RunOnStart: protoScript.RunOnStart,
RunOnStop: protoScript.RunOnStop,
StartBlocksLogin: protoScript.StartBlocksLogin,
Timeout: protoScript.Timeout.AsDuration(),
DisplayName: protoScript.DisplayName,
}, nil
}
func ProtoFromScript(s codersdk.WorkspaceAgentScript) *proto.WorkspaceAgentScript {
return &proto.WorkspaceAgentScript{
Id: s.ID[:],
LogSourceId: s.LogSourceID[:],
LogPath: s.LogPath,
Script: s.Script,
Cron: s.Cron,
RunOnStart: s.RunOnStart,
RunOnStop: s.RunOnStop,
StartBlocksLogin: s.StartBlocksLogin,
Timeout: durationpb.New(s.Timeout),
DisplayName: s.DisplayName,
}
}
func AppsFromProto(protoApps []*proto.WorkspaceApp) ([]codersdk.WorkspaceApp, error) {
ret := make([]codersdk.WorkspaceApp, len(protoApps))
for i, protoApp := range protoApps {
app, err := AppFromProto(protoApp)
if err != nil {
return nil, xerrors.Errorf("parse app %v (%q): %w", i, protoApp.Slug, err)
}
ret[i] = app
}
return ret, nil
}
func ProtoFromApps(apps []codersdk.WorkspaceApp) ([]*proto.WorkspaceApp, error) {
ret := make([]*proto.WorkspaceApp, len(apps))
var err error
for i, a := range apps {
ret[i], err = ProtoFromApp(a)
if err != nil {
return nil, err
}
}
return ret, nil
}
func AppFromProto(protoApp *proto.WorkspaceApp) (codersdk.WorkspaceApp, error) {
id, err := uuid.FromBytes(protoApp.Id)
if err != nil {
return codersdk.WorkspaceApp{}, xerrors.Errorf("parse id: %w", err)
}
sharingLevel := codersdk.WorkspaceAppSharingLevel(strings.ToLower(protoApp.SharingLevel.String()))
if _, ok := codersdk.MapWorkspaceAppSharingLevels[sharingLevel]; !ok {
return codersdk.WorkspaceApp{}, xerrors.Errorf("unknown app sharing level: %v (%q)", protoApp.SharingLevel, protoApp.SharingLevel.String())
}
health := codersdk.WorkspaceAppHealth(strings.ToLower(protoApp.Health.String()))
if _, ok := codersdk.MapWorkspaceAppHealths[health]; !ok {
return codersdk.WorkspaceApp{}, xerrors.Errorf("unknown app health: %v (%q)", protoApp.Health, protoApp.Health.String())
}
return codersdk.WorkspaceApp{
ID: id,
URL: protoApp.Url,
External: protoApp.External,
Slug: protoApp.Slug,
DisplayName: protoApp.DisplayName,
Command: protoApp.Command,
Icon: protoApp.Icon,
Subdomain: protoApp.Subdomain,
SubdomainName: protoApp.SubdomainName,
SharingLevel: sharingLevel,
Healthcheck: codersdk.Healthcheck{
URL: protoApp.Healthcheck.Url,
Interval: int32(protoApp.Healthcheck.Interval.AsDuration().Seconds()),
Threshold: protoApp.Healthcheck.Threshold,
},
Health: health,
Hidden: protoApp.Hidden,
}, nil
}
func ProtoFromApp(a codersdk.WorkspaceApp) (*proto.WorkspaceApp, error) {
sharingLevel, ok := proto.WorkspaceApp_SharingLevel_value[strings.ToUpper(string(a.SharingLevel))]
if !ok {
return nil, xerrors.Errorf("unknown sharing level %s", a.SharingLevel)
}
health, ok := proto.WorkspaceApp_Health_value[strings.ToUpper(string(a.Health))]
if !ok {
return nil, xerrors.Errorf("unknown health %s", a.Health)
}
return &proto.WorkspaceApp{
Id: a.ID[:],
Url: a.URL,
External: a.External,
Slug: a.Slug,
DisplayName: a.DisplayName,
Command: a.Command,
Icon: a.Icon,
Subdomain: a.Subdomain,
SubdomainName: a.SubdomainName,
SharingLevel: proto.WorkspaceApp_SharingLevel(sharingLevel),
Healthcheck: &proto.WorkspaceApp_Healthcheck{
Url: a.Healthcheck.URL,
Interval: durationpb.New(time.Duration(a.Healthcheck.Interval) * time.Second),
Threshold: a.Healthcheck.Threshold,
},
Health: proto.WorkspaceApp_Health(health),
Hidden: a.Hidden,
}, nil
}
func ServiceBannerFromProto(sbp *proto.ServiceBanner) codersdk.BannerConfig {
return codersdk.BannerConfig{
Enabled: sbp.GetEnabled(),
Message: sbp.GetMessage(),
BackgroundColor: sbp.GetBackgroundColor(),
}
}
func ProtoFromServiceBanner(sb codersdk.BannerConfig) *proto.ServiceBanner {
return &proto.ServiceBanner{
Enabled: sb.Enabled,
Message: sb.Message,
BackgroundColor: sb.BackgroundColor,
}
}
func BannerConfigFromProto(sbp *proto.BannerConfig) codersdk.BannerConfig {
return codersdk.BannerConfig{
Enabled: sbp.GetEnabled(),
Message: sbp.GetMessage(),
BackgroundColor: sbp.GetBackgroundColor(),
}
}
func ProtoFromBannerConfig(sb codersdk.BannerConfig) *proto.BannerConfig {
return &proto.BannerConfig{
Enabled: sb.Enabled,
Message: sb.Message,
BackgroundColor: sb.BackgroundColor,
}
}
func ProtoFromSubsystems(ss []codersdk.AgentSubsystem) ([]proto.Startup_Subsystem, error) {
ret := make([]proto.Startup_Subsystem, len(ss))
for i, s := range ss {
pi, ok := proto.Startup_Subsystem_value[strings.ToUpper(string(s))]
if !ok {
return nil, xerrors.Errorf("unknown subsystem: %s", s)
}
ret[i] = proto.Startup_Subsystem(pi)
}
return ret, nil
}
func ProtoFromAppHealthsRequest(req PostAppHealthsRequest) (*proto.BatchUpdateAppHealthRequest, error) {
pReq := &proto.BatchUpdateAppHealthRequest{}
for id, h := range req.Healths {
hp, ok := proto.AppHealth_value[strings.ToUpper(string(h))]
if !ok {
return nil, xerrors.Errorf("unknown app health: %s", h)
}
// Copy the ID, otherwise all updates will have the same ID (the last
// one in the list).
var idCopy uuid.UUID
copy(idCopy[:], id[:])
pReq.Updates = append(pReq.Updates, &proto.BatchUpdateAppHealthRequest_HealthUpdate{
Id: idCopy[:],
Health: proto.AppHealth(hp),
})
}
return pReq, nil
}
func ProtoFromLog(log Log) (*proto.Log, error) {
lvl, ok := proto.Log_Level_value[strings.ToUpper(string(log.Level))]
if !ok {
return nil, xerrors.Errorf("unknown log level: %s", log.Level)
}
return &proto.Log{
CreatedAt: timestamppb.New(log.CreatedAt),
Output: strings.ToValidUTF8(log.Output, "❌"),
Level: proto.Log_Level(lvl),
}, nil
}
func ProtoFromLifecycle(req PostLifecycleRequest) (*proto.Lifecycle, error) {
s, ok := proto.Lifecycle_State_value[strings.ToUpper(string(req.State))]
if !ok {
return nil, xerrors.Errorf("unknown lifecycle state: %s", req.State)
}
return &proto.Lifecycle{
State: proto.Lifecycle_State(s),
ChangedAt: timestamppb.New(req.ChangedAt),
}, nil
}
func LifecycleStateFromProto(s proto.Lifecycle_State) (codersdk.WorkspaceAgentLifecycle, error) {
caps, ok := proto.Lifecycle_State_name[int32(s)]
if !ok {
return "", xerrors.Errorf("unknown lifecycle state: %d", s)
}
return codersdk.WorkspaceAgentLifecycle(strings.ToLower(caps)), nil
}
func ProtoFromLifecycleState(s codersdk.WorkspaceAgentLifecycle) (proto.Lifecycle_State, error) {
caps, ok := proto.Lifecycle_State_value[strings.ToUpper(string(s))]
if !ok {
return 0, xerrors.Errorf("unknown lifecycle state: %s", s)
}
return proto.Lifecycle_State(caps), nil
}
func DevcontainersFromProto(pdcs []*proto.WorkspaceAgentDevcontainer) ([]codersdk.WorkspaceAgentDevcontainer, error) {
ret := make([]codersdk.WorkspaceAgentDevcontainer, len(pdcs))
for i, pdc := range pdcs {
dc, err := DevcontainerFromProto(pdc)
if err != nil {
return nil, xerrors.Errorf("parse devcontainer %v: %w", i, err)
}
ret[i] = dc
}
return ret, nil
}
func DevcontainerFromProto(pdc *proto.WorkspaceAgentDevcontainer) (codersdk.WorkspaceAgentDevcontainer, error) {
id, err := uuid.FromBytes(pdc.Id)
if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse id: %w", err)
}
var subagentID uuid.NullUUID
if pdc.SubagentId != nil {
subagentID.Valid = true
subagentID.UUID, err = uuid.FromBytes(pdc.SubagentId)
if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse subagent id: %w", err)
}
}
return codersdk.WorkspaceAgentDevcontainer{
ID: id,
Name: pdc.Name,
WorkspaceFolder: pdc.WorkspaceFolder,
ConfigPath: pdc.ConfigPath,
SubagentID: subagentID,
}, nil
}
func ProtoFromDevcontainers(dcs []codersdk.WorkspaceAgentDevcontainer) []*proto.WorkspaceAgentDevcontainer {
ret := make([]*proto.WorkspaceAgentDevcontainer, len(dcs))
for i, dc := range dcs {
ret[i] = ProtoFromDevcontainer(dc)
}
return ret
}
func ProtoFromDevcontainer(dc codersdk.WorkspaceAgentDevcontainer) *proto.WorkspaceAgentDevcontainer {
var subagentID []byte
if dc.SubagentID.Valid {
subagentID = dc.SubagentID.UUID[:]
}
return &proto.WorkspaceAgentDevcontainer{
Id: dc.ID[:],
Name: dc.Name,
WorkspaceFolder: dc.WorkspaceFolder,
ConfigPath: dc.ConfigPath,
SubagentId: subagentID,
}
}
func ProtoFromPatchAppStatus(pas PatchAppStatus) (*proto.UpdateAppStatusRequest, error) {
state, ok := proto.UpdateAppStatusRequest_AppStatusState_value[strings.ToUpper(string(pas.State))]
if !ok {
return nil, xerrors.Errorf("Invalid state: %s", pas.State)
}
return &proto.UpdateAppStatusRequest{
Slug: pas.AppSlug,
State: proto.UpdateAppStatusRequest_AppStatusState(state),
Message: pas.Message,
Uri: pas.URI,
}, nil
}